From ed316102bf8a1d1385c337c7f0340ed840956ea1 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 17 Jun 2026 16:54:45 -0700 Subject: [PATCH] fix(applet): signaling_bitmap reflects actual AD capability (M2F.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hardcoded 0x0005 in Table 8-11 with a computed value: - Bit 2 (step-up AID SELECT required) is always set — our architecture always uses split AID (5501 expedited + 5502 step-up) so the channel is always advertised, even when no AD is present. - Bit 0 (AD retrievable) is set only when hasAccessDocument() AND isAccessDocumentVerified() — we don't advertise a capability we can't deliver. The "finalized but not verified" branch is defensive (post-M2A.3 finalize requires verify) but matches the contract. Tests in AliroAppletAuth1Test: - auth1_bitmap_adNotProvisioned_returns0x0004 (replaces the old "all-zero" expectation — bit 2 is now always on) - auth1_bitmap_adProvisionedButNotVerified_returns0x0004 (new; uses markAccessDocumentFinalizedForTesting without markAccessDocumentVerified) - auth1_bitmap_adProvisionedAndVerified_returns0x0005 (the previous M2A.3 case, now correctly gated on the verified flag) Tests run: 147, Failures: 0, Errors: 3 (pre-existing GCM only). --- .../dangerousthings/aliro/AliroApplet.java | 49 +++++++++-------- .../aliro/AliroAppletAuth1Test.java | 52 ++++++++++++++----- 2 files changed, 66 insertions(+), 35 deletions(-) diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java index df16eaa..1041a95 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java @@ -795,7 +795,8 @@ public class AliroApplet extends Applet { * credential_PubK), per §8.3.3.4.2 + ref [12]) when command_parameters * bit 0 = 0, or {@code 0x5A credential_PubK} (full uncompressed 65B) * when bit 0 = 1. Then {@code 0x9E UD_signature} and a 2-byte - * all-zero {@code 0x5E signaling_bitmap}. + * {@code 0x5E signaling_bitmap} reflecting step-up capability and + * AD retrievability (see inline comment at the bitmap emit). */ private short buildTable811Plaintext( byte auth1CmdParams, CredentialStore store, @@ -824,31 +825,35 @@ public class AliroApplet extends Applet { p += rawSigLen; // 0x5E 0x02 [signaling_bitmap] — 16-bit big-endian. Bit 0: Access - // Document retrievable. Bit 2: retrieval requires step-up AID SELECT - // (applicable on NFC). Other bits unused in v1 (no mailbox/notify). + // Document retrievable from this credential. Bit 2: retrieval + // requires step-up AID SELECT (applicable on NFC). Other bits unused + // in v1 (no mailbox/notify). // - // We emit 0x0005 (bits 0 + 2) when an Access Document is provisioned. - // Honest reading of the spec would say we should leave these off - // until Step-up Phase is actually implemented (CBOR + mdoc + ENVELOPE - // + GET RESPONSE + AES-GCM over StepUpSK, §8.4), but empirically the - // closed-source ACWG_processAUTH1ResponsePayload() in X-CUBE-ALIRO's - // Aliro.a errors out when bits 0 + 2 are clear and AD is provisioned - // -- it expects "AD present" to be advertised. The bits are - // informational about capabilities anyway, not enforceable - // commitments, so 0x0005 satisfies the vendor library. AliroApplet - // now returns 6D00 for any post-AUTH1 INS like 0xC9 -- StepUpApplet - // at ACCE5502 handles ENVELOPE and EXCHANGE properly per spec §10.2 - // + §8.4. Revisit when we test against more readers and can lean on - // the spec literally. - short bitmap = 0; - if (store.hasAccessDocument()) { - bitmap |= 0x0001; // bit 0 - bitmap |= 0x0004; // bit 2 — NFC requires step-up AID to fetch AD + // Bit 2 is always set: our architecture always uses split AID + // (5501 expedited + 5502 step-up), so any AD retrieval will go + // through SELECT ACCE5502. We advertise the step-up channel even + // when nothing's there yet — readers that don't speak step-up just + // won't try. + // + // Bit 0 reflects whether we can actually serve an AD: it requires + // both hasAccessDocument() (finalized) AND isAccessDocumentVerified() + // (IssuerAuth check passed). Post-M2A.3 finalize requires verify so + // the "finalized but not verified" branch is defensive — see + // CredentialStore.markAccessDocumentVerified() callers. + // + // Historical note: M1 hardcoded 0x0005 because the closed-source + // ACWG_processAUTH1ResponsePayload() in X-CUBE-ALIRO's Aliro.a errors + // out when bits 0 + 2 are clear and AD is provisioned. With bit 2 + // now always set we still keep that library happy, and bit 0 only + // makes a promise we can actually keep. + byte bitmapLo = 0x04; // bit 2 — split-AID step-up architecture + if (store.hasAccessDocument() && store.isAccessDocumentVerified()) { + bitmapLo |= 0x01; // bit 0 — AD retrievable } out[p++] = (byte) 0x5E; out[p++] = (byte) 0x02; - out[p++] = (byte) ((bitmap >> 8) & 0xFF); - out[p++] = (byte) (bitmap & 0xFF); + out[p++] = (byte) 0x00; // high byte unused in v1 + out[p++] = bitmapLo; return (short) (p - outOff); } diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java index 837e53c..23f05b2 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java +++ b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java @@ -162,8 +162,11 @@ class AliroAppletAuth1Test { } @Test - void auth1SignalingBitmapIsAllZeroWhenNoAccessDocProvisioned() throws Exception { - // Default setUp provisions keys but no Access Document. + void auth1_bitmap_adNotProvisioned_returns0x0004() throws Exception { + // Default setUp provisions keys but no Access Document. Bit 2 + // (step-up AID SELECT required) is still set — the split-AID step-up + // architecture is always advertised, even when there's nothing + // behind it. sendStandardAuth0(); ResponseAPDU r = sendValidAuth1(); assertEquals(0x9000, r.getSW()); @@ -172,24 +175,47 @@ class AliroAppletAuth1Test { reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData()); byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E); org.junit.jupiter.api.Assertions.assertArrayEquals( - new byte[] { 0x00, 0x00 }, bitmap, - "no Access Document → signaling_bitmap is 0x0000"); + new byte[] { 0x00, 0x04 }, bitmap, + "no Access Document → bit 2 only (0x0004); bit 0 reflects actual retrievability"); } @Test - void auth1SignalingBitmapHasAccessDocBitsWhenAdProvisioned() throws Exception { - // Provision a non-empty Access Document via CredentialStore (bypasses - // the PersonalizationApplet INS layer — that flow is tested - // separately). Content is opaque here; we only care that the - // signaling_bitmap reflects presence. + void auth1_bitmap_adProvisionedButNotVerified_returns0x0004() throws Exception { + // Provision opaque AD bytes and mark them finalized, but NOT verified. + // Post-M2A.3 the finalize flow requires IssuerAuth verify so this is a + // defensive case — bit 0 (AD retrievable) must NOT be advertised since + // we wouldn't actually serve unverified AD on the step-up path. byte[] ad = new byte[120]; 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)); - // 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); + // Deliberately do NOT mark verified. + + sendStandardAuth0(); + ResponseAPDU r = sendValidAuth1(); + assertEquals(0x9000, r.getSW()); + + byte[] pt = ReaderSide.decryptAuth1Response( + reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData()); + byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E); + org.junit.jupiter.api.Assertions.assertArrayEquals( + new byte[] { 0x00, 0x04 }, bitmap, + "AD finalized but not verified → bit 2 only (0x0004); bit 0 requires verified flag"); + } + + @Test + void auth1_bitmap_adProvisionedAndVerified_returns0x0005() throws Exception { + // Provision a non-empty Access Document via CredentialStore (bypasses + // the PersonalizationApplet INS layer — that flow is tested + // separately). Mark both finalized AND verified to mirror the + // post-M2A.3 happy path. + byte[] ad = new byte[120]; + 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)); + CredentialStore.get().markAccessDocumentFinalizedForTesting((short) ad.length); + CredentialStore.get().markAccessDocumentVerified(); sendStandardAuth0(); ResponseAPDU r = sendValidAuth1(); @@ -202,7 +228,7 @@ class AliroAppletAuth1Test { // = 2^0 + 2^2 = 0x0005 (big-endian) org.junit.jupiter.api.Assertions.assertArrayEquals( new byte[] { 0x00, 0x05 }, bitmap, - "Access Document provisioned on NFC → bitmap bits 0 and 2 set (0x0005)"); + "AD finalized AND verified → bits 0 + 2 set (0x0005)"); } @Test