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

@@ -96,22 +96,27 @@ or Access Document — every Aliro flow returns `SW_CONDITIONS_NOT_SATISFIED`.
Provision via the PersonalizationApplet (CLA `0x80`): Provision via the PersonalizationApplet (CLA `0x80`):
| INS | P1\|P2 | Data | Description | | INS | P1\|P2 | Data | Description |
| ------ | --------------- | --------------------------------------- | ----------------------------------- | | ------ | --------------- | --------------------------------------- | -------------------------------------------------------------- |
| `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey | | `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey |
| `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey | | `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey |
| `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey | | `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey |
| `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write | | `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write |
| `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document | | `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document — runs IssuerAuth COSE_Sign1 verify |
| `0x2C` | `0000` | (none) | COMMIT — locks all writes | | `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 → Required order: SELECT provisioning AID → write all keys (including
finalize → COMMIT. After COMMIT, every write returns credential_issuer_PubK) + AD chunks → finalize → COMMIT. The issuer pubkey
`SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1). 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`: Source bytes come from `aliro-trustgen init --out-dir ./out`:
- `out/access_credential.pem` → derive priv/pub bytes - `out/access_credential.pem` → derive priv/pub bytes
- `out/reader.pem` → derive pub bytes - `out/reader.pem` → derive pub bytes
- `out/issuer.pem` → derive Credential Issuer pubkey
- `out/access_document.bin` → chunk into ≤255B writes - `out/access_document.bin` → chunk into ≤255B writes
**One-shot personalization:** the harness ships an `aliro-personalize` **One-shot personalization:** the harness ships an `aliro-personalize`

View File

@@ -28,6 +28,7 @@ final class CredentialStore {
static final short CRED_PRIV_KEY_LEN = 32; static final short CRED_PRIV_KEY_LEN = 32;
static final short CRED_PUBK_LEN = 64; static final short CRED_PUBK_LEN = 64;
static final short READER_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 /** Max Access Document blob size (serialized COSE_Sign1 bytes). 1 KB
* accommodates a typical Aliro Access Document with room to spare. */ * 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 — /** Stable field order for AMD-H Element serialization. Append-only —
* never reorder or remove without bumping the package version and * never reorder or remove without bumping the package version and
* writing an explicit migration step in the new ELF's onRestore. */ * writing an explicit migration step in the new ELF's onRestore.
static final byte FIELD_VERSION = 2; *
* <p>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()}. /** Publish-point read by AliroApplet/StepUpApplet via {@link #get()}.
* PersonalizationApplet owns the actual instance; this is just an alias * PersonalizationApplet owns the actual instance; this is just an alias
@@ -54,6 +59,9 @@ final class CredentialStore {
private final byte[] readerPubKey; private final byte[] readerPubKey;
private boolean readerPubKeySet; private boolean readerPubKeySet;
private final byte[] credentialIssuerPubKey;
private boolean credentialIssuerPubKeySet;
private final byte[] accessDocument; private final byte[] accessDocument;
private short accessDocumentLen; private short accessDocumentLen;
private boolean accessDocumentFinalized; private boolean accessDocumentFinalized;
@@ -65,6 +73,7 @@ final class CredentialStore {
credentialPrivKey = new byte[CRED_PRIV_KEY_LEN]; credentialPrivKey = new byte[CRED_PRIV_KEY_LEN];
credentialPubKey = new byte[CRED_PUBK_LEN]; credentialPubKey = new byte[CRED_PUBK_LEN];
readerPubKey = new byte[READER_PUBK_LEN]; readerPubKey = new byte[READER_PUBK_LEN];
credentialIssuerPubKey = new byte[CRED_ISSUER_PUBK_LEN];
accessDocument = new byte[ACCESS_DOC_MAX_LEN]; accessDocument = new byte[ACCESS_DOC_MAX_LEN];
} }
@@ -160,6 +169,30 @@ final class CredentialStore {
return (short) 32; 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. * Stores an Access Document chunk at the given destination offset.
* Returns true iff the write stayed within {@link #ACCESS_DOC_MAX_LEN}; * 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 * Finalizes the Access Document: runs the supplied {@link CoseVerifier}
* of valid content starting at offset 0. Returns false (and does not * against the staged AD bytes using the stored Credential Issuer public
* mutate state) if {@code totalLen} is outside {@code [0, ACCESS_DOC_MAX_LEN]}. * 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.
* *
* <p>TODO (Step-Up impl, opt 4a): wrap this in * <p>Pre-conditions enforced by the caller (PersonalizationApplet):
* {@code JCSystem.beginTransaction()} along with a one-shot IssuerAuth * <ul>
* COSE_Sign1 verify against the stored Credential Issuer public key, and * <li>The issuer pubkey must be set ({@link #hasCredentialIssuerPubKey}) —
* set a persistent {@code accessDocumentVerified} flag. Caching the * callers that omit this get SW_CONDITIONS_NOT_SATISFIED.</li>
* verify result saves ~100 ms per Step-Up transaction at the cost of one * </ul>
* extra persistent byte + the assumption that the Credential Issuer *
* trust anchor is fixed for the card's lifetime (opt 4b — true for DT's * <p>This is the M2A.3 expansion of opt 4a from the Step-Up plan: caching
* implantable use case but document the limitation). The verify-flag * the verify result saves ~100 ms per Step-Up transaction. The
* write and the {@code accessDocumentFinalized} flip MUST land in the * verify-flag write and the {@code accessDocumentFinalized} flip land in
* same atomic transaction so partial state can't ship an unverified * the same atomic transaction so partial state can't ship an unverified
* document marked verified. * 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) { if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) {
return false; return false;
} }
accessDocumentLen = totalLen; // Load the issuer pubkey into the caller-supplied scratch as
accessDocumentFinalized = true; // 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; return true;
} }
@@ -231,12 +296,14 @@ final class CredentialStore {
sink.write(credentialPrivKeySet); sink.write(credentialPrivKeySet);
sink.write(credentialPubKeySet); sink.write(credentialPubKeySet);
sink.write(readerPubKeySet); sink.write(readerPubKeySet);
sink.write(credentialIssuerPubKeySet);
sink.write(accessDocumentFinalized); sink.write(accessDocumentFinalized);
sink.write(accessDocumentVerified); sink.write(accessDocumentVerified);
sink.write(accessDocumentLen); sink.write(accessDocumentLen);
sink.write(credentialPrivKey); sink.write(credentialPrivKey);
sink.write(credentialPubKey); sink.write(credentialPubKey);
sink.write(readerPubKey); sink.write(readerPubKey);
sink.write(credentialIssuerPubKey);
sink.write(accessDocument); sink.write(accessDocument);
} }
@@ -255,6 +322,7 @@ final class CredentialStore {
s.credentialPrivKeySet = src.readBoolean(); s.credentialPrivKeySet = src.readBoolean();
s.credentialPubKeySet = src.readBoolean(); s.credentialPubKeySet = src.readBoolean();
s.readerPubKeySet = src.readBoolean(); s.readerPubKeySet = src.readBoolean();
s.credentialIssuerPubKeySet = src.readBoolean();
s.accessDocumentFinalized = src.readBoolean(); s.accessDocumentFinalized = src.readBoolean();
s.accessDocumentVerified = src.readBoolean(); s.accessDocumentVerified = src.readBoolean();
s.accessDocumentLen = src.readShort(); s.accessDocumentLen = src.readShort();
@@ -266,10 +334,23 @@ final class CredentialStore {
a = src.readByteArray(); a = src.readByteArray();
javacard.framework.Util.arrayCopy(a, (short) 0, s.readerPubKey, (short) 0, READER_PUBK_LEN); javacard.framework.Util.arrayCopy(a, (short) 0, s.readerPubKey, (short) 0, READER_PUBK_LEN);
a = src.readByteArray(); 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); javacard.framework.Util.arrayCopy(a, (short) 0, s.accessDocument, (short) 0, ACCESS_DOC_MAX_LEN);
return s; 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. */ /** Test-only: returns a fresh byte[] copy of the credential private key. */
byte[] copyCredentialPrivKey() { byte[] copyCredentialPrivKey() {
byte[] out = new byte[CRED_PRIV_KEY_LEN]; 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(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(credentialPubKey, (short) 0, CRED_PUBK_LEN, (byte) 0);
javacard.framework.Util.arrayFillNonAtomic(readerPubKey, (short) 0, READER_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); javacard.framework.Util.arrayFillNonAtomic(accessDocument, (short) 0, ACCESS_DOC_MAX_LEN, (byte) 0);
credentialPrivKeySet = false; credentialPrivKeySet = false;
credentialPubKeySet = false; credentialPubKeySet = false;
readerPubKeySet = false; readerPubKeySet = false;
credentialIssuerPubKeySet = false;
accessDocumentLen = 0; accessDocumentLen = 0;
accessDocumentFinalized = false; accessDocumentFinalized = false;
accessDocumentVerified = false; accessDocumentVerified = false;

View File

@@ -24,6 +24,7 @@ public class PersonalizationApplet extends Applet {
private static final byte INS_SET_READER_PUBK = (byte) 0x22; 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_WRITE_ACCESS_DOC = (byte) 0x23;
private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24; 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; private static final byte INS_COMMIT = (byte) 0x2C;
/** The owning reference to the shared CredentialStore. AliroApplet and /** The owning reference to the shared CredentialStore. AliroApplet and
@@ -34,8 +35,23 @@ public class PersonalizationApplet extends Applet {
* state) don't lose enrollment data. */ * state) don't lose enrollment data. */
private final CredentialStore store; 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() { public PersonalizationApplet() {
this.store = CredentialStore.bootstrap(); this.store = CredentialStore.bootstrap();
this.issuerVerifier = new CoseVerifier();
this.issuerPubScratch = new byte[65];
} }
public static void install(byte[] bArray, short bOffset, byte bLength) { public static void install(byte[] bArray, short bOffset, byte bLength) {
@@ -76,6 +92,10 @@ public class PersonalizationApplet extends Applet {
requireUnlocked(store); requireUnlocked(store);
setReaderPubKey(apdu, store); setReaderPubKey(apdu, store);
return; return;
case INS_SET_CREDENTIAL_ISSUER_PUBK:
requireUnlocked(store);
setCredentialIssuerPubKey(apdu, store);
return;
case INS_WRITE_ACCESS_DOC: case INS_WRITE_ACCESS_DOC:
requireUnlocked(store); requireUnlocked(store);
writeAccessDocChunk(apdu, store); writeAccessDocChunk(apdu, store);
@@ -126,6 +146,15 @@ public class PersonalizationApplet extends Applet {
store.setReaderPubKey(buf, apdu.getOffsetCdata()); 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 * Writes an Access Document chunk. P1|P2 is the destination offset
* within the stored Access Document (big-endian unsigned 16-bit); the * 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 * Finalizes the Access Document: triggers a one-shot IssuerAuth verify
* (big-endian unsigned 16-bit). Lc must be 0. * 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.
*
* <p>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) { private void finalizeAccessDoc(APDU apdu, CredentialStore store) {
short lc = apdu.setIncomingAndReceive(); short lc = apdu.setIncomingAndReceive();
if (lc != 0) { if (lc != 0) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA); ISOException.throwIt(ISO7816.SW_WRONG_DATA);
} }
if (!store.hasCredentialIssuerPubKey()) {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
byte[] buf = apdu.getBuffer(); byte[] buf = apdu.getBuffer();
short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8) short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
| (buf[ISO7816.OFFSET_P2] & 0xFF)); | (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); ISOException.throwIt(ISO7816.SW_WRONG_DATA);
} }
if (!store.finalizeAccessDocument(totalLen, issuerVerifier, issuerPubScratch)) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
} }
} }

View File

@@ -186,8 +186,10 @@ class AliroAppletAuth1Test {
for (int i = 0; i < ad.length; i++) ad[i] = (byte) (i ^ 0x5A); for (int i = 0; i < ad.length; i++) ad[i] = (byte) (i ^ 0x5A);
org.junit.jupiter.api.Assertions.assertTrue( org.junit.jupiter.api.Assertions.assertTrue(
CredentialStore.get().writeAccessDocumentChunk(ad, (short) 0, (short) 0, (short) ad.length)); CredentialStore.get().writeAccessDocumentChunk(ad, (short) 0, (short) 0, (short) ad.length));
org.junit.jupiter.api.Assertions.assertTrue( // Bypass IssuerAuth verify — opaque random bytes here, not a real
CredentialStore.get().finalizeAccessDocument((short) ad.length)); // COSE_Sign1. The signaling bitmap path only cares about the
// finalized flag, not the verified flag.
CredentialStore.get().markAccessDocumentFinalizedForTesting((short) ad.length);
sendStandardAuth0(); sendStandardAuth0();
ResponseAPDU r = sendValidAuth1(); 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_32_BYTES = makeRange(32, 0xA0);
private static final byte[] KNOWN_64_BYTES_B = makeRange(64, 0x80); 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) { private static byte[] makeRange(int len, int start) {
byte[] out = new byte[len]; byte[] out = new byte[len];
@@ -51,6 +52,8 @@ class CredentialStoreSerializationTest {
src.setCredentialPrivKey(KNOWN_32_BYTES, (short) 0); src.setCredentialPrivKey(KNOWN_32_BYTES, (short) 0);
// Intentionally do NOT setCredentialPubKey: leave credentialPubKeySet=false. // Intentionally do NOT setCredentialPubKey: leave credentialPubKeySet=false.
src.setReaderPubKey(KNOWN_64_BYTES_B, (short) 0); 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 // Intentionally do NOT finalizeAccessDocument: leave accessDocumentFinalized=false
// and accessDocumentLen=0. With len=0, copyAccessDocument() returns an empty array. // and accessDocumentLen=0. With len=0, copyAccessDocument() returns an empty array.
src.commit(); src.commit();
@@ -64,6 +67,15 @@ class CredentialStoreSerializationTest {
assertArrayEquals(KNOWN_32_BYTES, restored.copyCredentialPrivKey()); assertArrayEquals(KNOWN_32_BYTES, restored.copyCredentialPrivKey());
assertArrayEquals(KNOWN_64_BYTES_B, restored.copyReaderPubKey()); 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 — // The unset credential pubkey buffer should round-trip as all-zero —
// resetForTesting cleared it and we never wrote to it. // resetForTesting cleared it and we never wrote to it.
assertArrayEquals(new byte[CredentialStore.CRED_PUBK_LEN], 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_SET_READER_PUBK = (byte) 0x22;
private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23; 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_FINALIZE_ACCESS_DOC = (byte) 0x24;
private static final byte INS_SET_CRED_ISSUER_PUBK = (byte) 0x25;
private static final byte INS_COMMIT = (byte) 0x2C; 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; private CardSimulator sim;
@BeforeEach @BeforeEach
@@ -103,12 +143,14 @@ class PersonalizationAppletTest {
@Test @Test
void accessDocumentChunkedWriteAndFinalizeRoundTrip() { void accessDocumentChunkedWriteAndFinalizeRoundTrip() {
byte[] ad = new byte[420]; // Uses the canonical AD_GOOD vector because finalize now triggers
for (int i = 0; i < ad.length; i++) ad[i] = (byte) ((i * 13) ^ 0xA5); // 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[] 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, 0, chunk1).getSW());
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW()); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW());
assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, ad.length, null).getSW()); assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, ad.length, null).getSW());
@@ -131,6 +173,9 @@ class PersonalizationAppletTest {
@Test @Test
void accessDocumentFinalizeBeyondMaxSizeFails() { 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]; byte[] chunk = new byte[10];
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW()); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1; int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1;
@@ -185,6 +230,84 @@ class PersonalizationAppletTest {
"Static publish-point must alias the instance-owned store"); "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 /** Reaches through jcardsim's protected runtime/applet APIs to fetch the
* installed Applet instance for a given AID. Reflection-only — this is * installed Applet instance for a given AID. Reflection-only — this is
* test infrastructure, not production code. */ * test infrastructure, not production code. */

View File

@@ -11,6 +11,7 @@ INS_SET_CRED_PUBK = 0x21
INS_SET_READER_PUBK = 0x22 INS_SET_READER_PUBK = 0x22
INS_WRITE_ACCESS_DOC = 0x23 INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24 INS_FINALIZE_ACCESS_DOC = 0x24
INS_SET_CRED_ISSUER_PUBK = 0x25
INS_COMMIT = 0x2C INS_COMMIT = 0x2C
# Provisioning AID — must match AliroAids.PROVISIONING in the applet. # Provisioning AID — must match AliroAids.PROVISIONING in the applet.
@@ -21,6 +22,7 @@ PROVISIONING_AID = bytes.fromhex("A000000909ACCE559901")
CRED_PRIV_LEN = 32 CRED_PRIV_LEN = 32
CRED_PUBK_LEN = 64 CRED_PUBK_LEN = 64
READER_PUBK_LEN = 64 READER_PUBK_LEN = 64
CRED_ISSUER_PUBK_LEN = 64
def select_provisioning_apdu() -> bytes: 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) 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: def commit_apdu() -> bytes:
return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00]) return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])

View File

@@ -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"Loaded artifacts: credential_PrivK={len(artifacts.credential_priv)}B, "
f"credential_PubK={len(artifacts.credential_pubk_xy)}B, " f"credential_PubK={len(artifacts.credential_pubk_xy)}B, "
f"reader_PubK={len(artifacts.reader_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" f"Access Document={len(artifacts.access_document)}B"
) )

View File

@@ -13,6 +13,7 @@ from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.apdus import ( from aliro_harness.personalizer.apdus import (
commit_apdu, commit_apdu,
select_provisioning_apdu, select_provisioning_apdu,
set_credential_issuer_pubk_apdu,
set_credential_priv_apdu, set_credential_priv_apdu,
set_credential_pubk_apdu, set_credential_pubk_apdu,
set_reader_pubk_apdu, set_reader_pubk_apdu,
@@ -45,21 +46,25 @@ class TrustArtifacts:
credential_priv: bytes # 32B credential_priv: bytes # 32B
credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix) credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix)
reader_pubk_xy: bytes # 64B reader_pubk_xy: bytes # 64B
credential_issuer_pub: bytes # 64B issuer pubkey x||y for IssuerAuth verify
access_document: bytes # COSE_Sign1 serialized access_document: bytes # COSE_Sign1 serialized
@classmethod @classmethod
def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts": def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts":
cred_pem = (trust_dir / "access_credential.pem").read_bytes() cred_pem = (trust_dir / "access_credential.pem").read_bytes()
reader_pem = (trust_dir / "reader.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() ad = (trust_dir / "access_document.bin").read_bytes()
cred_key = serialization.load_pem_private_key(cred_pem, password=None) cred_key = serialization.load_pem_private_key(cred_pem, password=None)
reader_key = serialization.load_pem_private_key(reader_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( return cls(
credential_priv=extract_priv_scalar(cred_key), credential_priv=extract_priv_scalar(cred_key),
credential_pubk_xy=extract_pub_xy(cred_key.public_key()), credential_pubk_xy=extract_pub_xy(cred_key.public_key()),
reader_pubk_xy=extract_pub_xy(reader_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, 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_PrivK", set_credential_priv_apdu(artifacts.credential_priv))
_send(transmit, "SET credential_PubK", set_credential_pubk_apdu(artifacts.credential_pubk_xy)) _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)) _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)): for i, apdu in enumerate(access_document_apdus(artifacts.access_document)):
_send(transmit, f"Access Document APDU {i}", apdu) _send(transmit, f"Access Document APDU {i}", apdu)

View File

@@ -5,12 +5,14 @@ import pytest
from aliro_harness.personalizer.apdus import ( from aliro_harness.personalizer.apdus import (
CLA_PROPRIETARY, CLA_PROPRIETARY,
INS_COMMIT, INS_COMMIT,
INS_SET_CRED_ISSUER_PUBK,
INS_SET_CRED_PRIV, INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK, INS_SET_CRED_PUBK,
INS_SET_READER_PUBK, INS_SET_READER_PUBK,
PROVISIONING_AID, PROVISIONING_AID,
commit_apdu, commit_apdu,
select_provisioning_apdu, select_provisioning_apdu,
set_credential_issuer_pubk_apdu,
set_credential_priv_apdu, set_credential_priv_apdu,
set_credential_pubk_apdu, set_credential_pubk_apdu,
set_reader_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 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(): def test_commit_apdu_has_no_data_field():
apdu = commit_apdu() apdu = commit_apdu()
assert apdu == bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00]) assert apdu == bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])

View File

@@ -8,6 +8,7 @@ from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.personalizer.apdus import ( from aliro_harness.personalizer.apdus import (
INS_COMMIT, INS_COMMIT,
INS_FINALIZE_ACCESS_DOC, INS_FINALIZE_ACCESS_DOC,
INS_SET_CRED_ISSUER_PUBK,
INS_SET_CRED_PRIV, INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK, INS_SET_CRED_PUBK,
INS_SET_READER_PUBK, INS_SET_READER_PUBK,
@@ -46,6 +47,7 @@ def artifacts() -> TrustArtifacts:
credential_priv=extract_priv_scalar(cred), credential_priv=extract_priv_scalar(cred),
credential_pubk_xy=extract_pub_xy(cred.public_key()), credential_pubk_xy=extract_pub_xy(cred.public_key()),
reader_pubk_xy=extract_pub_xy(reader.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( access_document=build_access_document(
issuer_private_key=issuer, issuer_private_key=issuer,
access_credential_public_key=cred.public_key(), access_credential_public_key=cred.public_key(),
@@ -58,17 +60,35 @@ def test_full_sequence_in_expected_order(artifacts):
personalize_card(transport, artifacts) personalize_card(transport, artifacts)
insns = [apdu[1] for apdu in transport.sent] 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[0] == 0xA4
assert insns[1] == INS_SET_CRED_PRIV assert insns[1] == INS_SET_CRED_PRIV
assert insns[2] == INS_SET_CRED_PUBK assert insns[2] == INS_SET_CRED_PUBK
assert insns[3] == INS_SET_READER_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. # 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[-2] == INS_FINALIZE_ACCESS_DOC
assert insns[-1] == INS_COMMIT 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): def test_select_apdu_carries_provisioning_aid(artifacts):
transport = FakeTransport() transport = FakeTransport()
personalize_card(transport, artifacts) personalize_card(transport, artifacts)