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

@@ -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;

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_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);
}
}
}