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:
@@ -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.
|
||||
*
|
||||
* <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()}.
|
||||
* 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.
|
||||
*
|
||||
* <p>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
|
||||
* <p>Pre-conditions enforced by the caller (PersonalizationApplet):
|
||||
* <ul>
|
||||
* <li>The issuer pubkey must be set ({@link #hasCredentialIssuerPubKey}) —
|
||||
* callers that omit this get SW_CONDITIONS_NOT_SATISFIED.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 - <<'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. */
|
||||
|
||||
Reference in New Issue
Block a user