diff --git a/applet/INSTALL.md b/applet/INSTALL.md index 02dc831..fab5812 100644 --- a/applet/INSTALL.md +++ b/applet/INSTALL.md @@ -96,22 +96,27 @@ or Access Document — every Aliro flow returns `SW_CONDITIONS_NOT_SATISFIED`. Provision via the PersonalizationApplet (CLA `0x80`): -| INS | P1\|P2 | Data | Description | -| ------ | --------------- | --------------------------------------- | ----------------------------------- | -| `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey | -| `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey | -| `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey | -| `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write | -| `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document | -| `0x2C` | `0000` | (none) | COMMIT — locks all writes | +| INS | P1\|P2 | Data | Description | +| ------ | --------------- | --------------------------------------- | -------------------------------------------------------------- | +| `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey | +| `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey | +| `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey | +| `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write | +| `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document — runs IssuerAuth COSE_Sign1 verify | +| `0x25` | `0000` | 64B credential_issuer_PubK (x\|\|y) | Credential Issuer pubkey — trust anchor for IssuerAuth verify | +| `0x2C` | `0000` | (none) | COMMIT — locks all writes | -Required order: SELECT provisioning AID → write all keys + AD chunks → -finalize → COMMIT. After COMMIT, every write returns -`SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1). +Required order: SELECT provisioning AID → write all keys (including +credential_issuer_PubK) + AD chunks → finalize → COMMIT. The issuer pubkey +MUST be set before FINALIZE — without it FINALIZE returns +`SW_CONDITIONS_NOT_SATISFIED`. A signature mismatch at FINALIZE returns +`SW_DATA_INVALID` and leaves the AD un-finalized. After COMMIT, every +write returns `SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1). Source bytes come from `aliro-trustgen init --out-dir ./out`: - `out/access_credential.pem` → derive priv/pub bytes - `out/reader.pem` → derive pub bytes +- `out/issuer.pem` → derive Credential Issuer pubkey - `out/access_document.bin` → chunk into ≤255B writes **One-shot personalization:** the harness ships an `aliro-personalize` @@ -188,6 +193,33 @@ step the real firmware will eventually run — so a green bench-test is strong evidence the applet is correct independently of any future reader implementation. +### Step-Up M2 verification (`--step-up`) + +``` +aliro-bench-test --trust-dir ~/aliro-trust --step-up +``` + +Adds the Step-Up phase on top of the EXPEDITED verdict: SELECT-STEPUP +(ACCE5502) derives session keys from the cached `StepUpSK`, EXCHANGE ++ ENVELOPE + chained GET RESPONSE drive a real mdoc DeviceRequest to +the card and pull the encrypted DeviceResponse back. The harness +decrypts under `StepUpSKDevice` and asserts the embedded Access +Document round-trips byte-for-byte against the personalized blob. + +Successful output appends: + +``` +STEP-UP M2: OK — M2 step-up verified (EXCHANGE + ENVELOPE Access Document round-trip) +``` + +Verified against J3R452 UID `04555A4A0B2190` on 2026-06-12 — full verdict +log at `docs/verdicts/2026-06-12-m2-pcsc-verdict.log`. + +A `STEP-UP M2: FAIL` line means one of: SELECT-STEPUP didn't arm +(no preceding AUTH1), GCM tag mismatch (key/counter divergence), or +the recovered AD bytes don't match. The Expedited block above still +needs to be `OK` for any of this to run. + ## Uninstall / re-install `gp --delete ` won't succeed while applet *instances* still diff --git a/applet/pom.xml b/applet/pom.xml index a693997..426d4fe 100644 --- a/applet/pom.xml +++ b/applet/pom.xml @@ -119,6 +119,7 @@ aid="${cap.elf.aid}" package="com.dangerousthings.aliro" version="0.1" + ints="true" classes="${project.build.outputDirectory}"> diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java index 0d686af..a8028f4 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java @@ -46,7 +46,7 @@ public class AliroApplet extends Applet { // AUTH session scratch). The diagnostic keypair is allocated separately // from the protocol's ephemeral keypair, so calling diag mid-transaction // cannot clobber an in-flight AUTH0/AUTH1. - private static final boolean DIAGNOSTICS_ENABLED = true; + private static final boolean DIAGNOSTICS_ENABLED = false; private static final byte INS_DIAG_HMAC = (byte) 0xD0; private static final byte INS_DIAG_ECDH = (byte) 0xD1; @@ -193,45 +193,10 @@ public class AliroApplet extends Applet { 'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*' }; - // secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2. - // J3R180 doesn't ship a default P-256 parameter set on its EC keys, so - // calls into genKeyPair / setS / setW / Signature.init throw - // CryptoException.ILLEGAL_VALUE until we seed the curve explicitly. - private static final byte[] SECP256R1_P = { - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF - }; - private static final byte[] SECP256R1_A = { - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFC - }; - private static final byte[] SECP256R1_B = { - 0x5A,(byte)0xC6,0x35,(byte)0xD8,(byte)0xAA,0x3A,(byte)0x93,(byte)0xE7, - (byte)0xB3,(byte)0xEB,(byte)0xBD,0x55,0x76,(byte)0x98,(byte)0x86,(byte)0xBC, - 0x65,0x1D,0x06,(byte)0xB0,(byte)0xCC,0x53,(byte)0xB0,(byte)0xF6, - 0x3B,(byte)0xCE,0x3C,0x3E,0x27,(byte)0xD2,0x60,0x4B - }; - private static final byte[] SECP256R1_G = { - 0x04, - 0x6B,0x17,(byte)0xD1,(byte)0xF2,(byte)0xE1,0x2C,0x42,0x47, - (byte)0xF8,(byte)0xBC,(byte)0xE6,(byte)0xE5,0x63,(byte)0xA4,0x40,(byte)0xF2, - 0x77,0x03,0x7D,(byte)0x81,0x2D,(byte)0xEB,0x33,(byte)0xA0, - (byte)0xF4,(byte)0xA1,0x39,0x45,(byte)0xD8,(byte)0x98,(byte)0xC2,(byte)0x96, - 0x4F,(byte)0xE3,0x42,(byte)0xE2,(byte)0xFE,0x1A,0x7F,(byte)0x9B, - (byte)0x8E,(byte)0xE7,(byte)0xEB,0x4A,0x7C,0x0F,(byte)0x9E,0x16, - 0x2B,(byte)0xCE,0x33,0x57,0x6B,0x31,0x5E,(byte)0xCE, - (byte)0xCB,(byte)0xB6,0x40,0x68,0x37,(byte)0xBF,0x51,(byte)0xF5 - }; - private static final byte[] SECP256R1_R = { - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x00, - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, - (byte)0xBC,(byte)0xE6,(byte)0xFA,(byte)0xAD,(byte)0xA7,0x17,(byte)0x9E,(byte)0x84, - (byte)0xF3,(byte)0xB9,(byte)0xCA,(byte)0xC2,(byte)0xFC,0x63,0x25,0x51 - }; + // secp256r1 / NIST P-256 curve parameters live in {@link Secp256r1Params} + // — shared with {@link CoseVerifier}. J3R180 doesn't ship a default P-256 + // parameter set on its EC keys, so seedSecp256r1() must run before any + // genKeyPair / setS / setW / Signature.init. /** NFC interface byte, spec §8.3.1.13. */ private static final byte INTERFACE_BYTE_NFC = (byte) 0x5E; /** Table 8-13 usage constant for UD signature (spec §8.3.3.4.3). */ @@ -317,22 +282,12 @@ public class AliroApplet extends Applet { /** Loads the secp256r1 / NIST P-256 curve parameters into both halves of * {@code kp}. Must run before any {@code genKeyPair}, {@code setS}, * {@code setW}, or {@code Signature.init} on cards (like J3R180) that - * don't preset domain parameters on freshly-allocated EC keys. */ + * don't preset domain parameters on freshly-allocated EC keys. + * + *

Thin wrapper over {@link Secp256r1Params#seed(KeyPair)} kept for + * call-site readability; the byte arrays live in the shared class. */ private static void seedSecp256r1(KeyPair kp) { - javacard.security.ECPublicKey pub = (javacard.security.ECPublicKey) kp.getPublic(); - javacard.security.ECPrivateKey priv = (javacard.security.ECPrivateKey) kp.getPrivate(); - pub.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); - pub.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); - pub.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); - pub.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); - pub.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); - pub.setK((short) 1); - priv.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); - priv.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); - priv.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); - priv.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); - priv.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); - priv.setK((short) 1); + Secp256r1Params.seed(kp); } // --- Diagnostic test vectors ------------------------------------------- @@ -436,7 +391,7 @@ public class AliroApplet extends Applet { AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto(); for (short i = 0; i < n; i++) { cryptoEcdh.computeEcdhSharedX(priv, - SECP256R1_G, (short) 0, + Secp256r1Params.SECP256R1_G, (short) 0, buf, DIAG_OUT_OFF); } return; @@ -840,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, @@ -869,31 +825,34 @@ 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). finalizeAccessDocument() commits both + // flags atomically, so the "finalized but not verified" branch is + // defensive. + // + // Note: 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/main/java/com/dangerousthings/aliro/AliroGcm.java b/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java index a2be448..7eeb3c8 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java @@ -144,22 +144,7 @@ final class AliroGcm { byte[] pt, short ptOff, short ptLen, byte[] out, short outOff) { - aesKey.setKey(key, keyOff); - aesEcb.init(aesKey, Cipher.MODE_ENCRYPT); - - // H = AES_K(0^128). Zero scratch[OFF_H..OFF_H+16) and encrypt in place. - Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0); - aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H); - // Build the 4-bit GHASH M-table from the fresh H. One-time cost per - // encrypt (~700 JC bytecodes), amortized over the ~10 gfMul4Bit calls. - buildMTable(); - - // J0 = IV || 0x00000001 (12B IV + 4B counter). 96-bit IV canonical case. - Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN); - scratch[(short) (OFF_J0 + 12)] = 0x00; - scratch[(short) (OFF_J0 + 13)] = 0x00; - scratch[(short) (OFF_J0 + 14)] = 0x00; - scratch[(short) (OFF_J0 + 15)] = 0x01; + setKeyAndIv(key, keyOff, iv, ivOff); // cb = INC32(J0) — first counter block for the plaintext stream. Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN); @@ -305,23 +290,7 @@ final class AliroGcm { short ctLen = (short) (inLen - TAG_LEN); short tagOff = (short) (inOff + ctLen); - aesKey.setKey(key, keyOff); - aesEcb.init(aesKey, Cipher.MODE_ENCRYPT); - - // H = AES_K(0^128). Same as encrypt() -- GCM is one-direction at the - // primitive level: encrypt and decrypt both run GCTR + GHASH and - // differ only in whether GHASH consumes provided ciphertext or - // freshly-emitted ciphertext, plus the tag compare/emit step. - Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0); - aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H); - buildMTable(); - - // J0 = IV || 0x00000001 (96-bit IV canonical case). - Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN); - scratch[(short) (OFF_J0 + 12)] = 0x00; - scratch[(short) (OFF_J0 + 13)] = 0x00; - scratch[(short) (OFF_J0 + 14)] = 0x00; - scratch[(short) (OFF_J0 + 15)] = 0x01; + setKeyAndIv(key, keyOff, iv, ivOff); // GHASH over the PROVIDED ciphertext first (so tag verify doesn't // depend on a successful decrypt). AAD is empty. @@ -412,6 +381,35 @@ final class AliroGcm { return ctLen; } + /** + * Per-(key, IV) GCM setup, shared by {@link #encrypt} and {@link #decrypt}: + * loads the AES-256 key, derives H = AES_K(0^128), rebuilds the 4-bit + * GHASH M-table, and lays down J0 = IV || 0x00000001 at OFF_J0. M2D.3's + * stream-encrypt path calls {@link #encrypt} twice per Step-Up session + * with a different (key, IV) each time; consolidating the rekey path + * keeps that contract pinned to one method. + */ + private void setKeyAndIv( + byte[] key, short keyOff, + byte[] iv, short ivOff) { + aesKey.setKey(key, keyOff); + aesEcb.init(aesKey, Cipher.MODE_ENCRYPT); + + // H = AES_K(0^128). Zero scratch[OFF_H..OFF_H+16) and encrypt in place. + Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0); + aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H); + // Build the 4-bit GHASH M-table from the fresh H. One-time cost per + // encrypt (~700 JC bytecodes), amortized over the ~10 gfMul4Bit calls. + buildMTable(); + + // J0 = IV || 0x00000001 (12B IV + 4B counter). 96-bit IV canonical case. + Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN); + scratch[(short) (OFF_J0 + 12)] = 0x00; + scratch[(short) (OFF_J0 + 13)] = 0x00; + scratch[(short) (OFF_J0 + 14)] = 0x00; + scratch[(short) (OFF_J0 + 15)] = 0x01; + } + /** * INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the * 16-byte block, big-endian, modulo 2^32. diff --git a/applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java b/applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java new file mode 100644 index 0000000..0079806 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java @@ -0,0 +1,274 @@ +package com.dangerousthings.aliro; + +import javacard.framework.JCSystem; +import javacard.framework.Util; +import javacard.security.ECPublicKey; +import javacard.security.KeyBuilder; +import javacard.security.Signature; + +/** + * Verifies an Aliro IssuerAuth COSE_Sign1 (RFC 9052) over ECDSA-P256 + + * SHA-256. Single use case: at personalization we verify the Access + * Document signature ONCE, cache the verdict (M2A.1's + * {@code accessDocumentVerified} flag), and trust thereafter. + * + *

COSE_Sign1 wire format (RFC 9052 §3): + *

+ *   [ protected_bstr, unprotected_map, payload_bstr, signature_bstr ]
+ * 
+ * Sig_structure that was signed (RFC 9052 §4.4): + *
+ *   [ "Signature1", body_protected, external_aad, payload ]
+ * 
+ * Signature is raw {@code r || s} (64 bytes for P-256), not DER — + * the JC {@code Signature.ALG_ECDSA_SHA_256} primitive expects DER, so + * we transcode raw → DER before {@link Signature#verify}. + * + *

The CBOR walk uses {@link StructuralCbor#decodeHeader} + + * {@link StructuralCbor#elementSpan} for the 4-element COSE_Sign1 array: + * decode the outer array header, decode each bstr header at elements 0/2/3 + * (protected / payload / signature) to capture value offsets, and span-skip + * the unprotected map at element 1. Any malformed input throws + * {@code ISOException(SW_DATA_INVALID)} which the outer try/catch collapses + * to "not verified". + * + *

The verifier owns a reusable {@link ECPublicKey} slot, seeded with + * P-256 curve params at construction. Each call calls {@code setW} with + * the caller-supplied uncompressed (0x04 || X || Y) issuer key — no + * per-call allocation. + */ +final class CoseVerifier { + + /** Maximum supported COSE_Sign1 payload length. The Aliro Access + * Document's inner COSE payload is ~188 B today; we cap at 256 to + * keep the Sig_structure working buffer inside the J3R452 transient + * pool (~3,120 B) once the other applets' allocations are summed in. */ + static final short MAX_PAYLOAD = (short) 256; + + /** Maximum supported protected bstr length. Aliro IssuerAuth uses a + * single {alg: ES256} map (3 bytes), but we cap at 32 for slack. */ + static final short MAX_PROTECTED = (short) 32; + + /** Length of a raw P-256 ECDSA signature: r(32) || s(32). */ + private static final short RAW_SIG_LEN = (short) 64; + + /** Length of an uncompressed SEC1 P-256 public key: 0x04 || X(32) || Y(32). */ + private static final short UNCOMP_PUB_LEN = (short) 65; + + /** + * Sig_structure working buffer layout: + *

+     *   0x84              array(4)
+     *   0x6A              tstr(10)        — "Signature1"
+     *   53 69 67 6E 61    "Signature1" (10 bytes)
+     *   74 75 72 65 31
+     *   [protected_bstr CBOR header + bytes]
+     *   0x40              bstr(0)         — external_aad (empty)
+     *   [payload_bstr CBOR header + bytes]
+     * 
+ * "Signature1" prefix is fixed; cache it. + */ + private static final byte[] SIG_STRUCT_PREFIX = { + (byte) 0x84, // array(4) + (byte) 0x6A, // tstr(10) + (byte) 0x53, (byte) 0x69, (byte) 0x67, (byte) 0x6E, // "Sign" + (byte) 0x61, (byte) 0x74, (byte) 0x75, (byte) 0x72, // "atur" + (byte) 0x65, (byte) 0x31 // "e1" + }; + + private final ECPublicKey issuerPubKey; + private final Signature ecdsaVerifier; + /** Transient scratch for building the Sig_structure and DER signature. + * Sized for MAX_PAYLOAD + MAX_PROTECTED + headers + DER overhead. */ + private final byte[] scratch; + + CoseVerifier() { + issuerPubKey = (ECPublicKey) KeyBuilder.buildKey( + KeyBuilder.TYPE_EC_FP_PUBLIC, + KeyBuilder.LENGTH_EC_FP_256, + false); + Secp256r1Params.seedPublic(issuerPubKey); + ecdsaVerifier = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false); + // Sig_structure size = prefix(12) + prot_hdr(<=3) + prot_bytes(<=32) + // + ext_aad(1) + pl_hdr(<=3) + pl_bytes(<=256) + // DER sig max = 2 + 2 + 33 + 2 + 33 = 72. Total ~381. Round to 384. + // Was 768 (M2A.2) -- blew the J3R452 transient pool budget once all + // applets' allocations summed in (~3,120 B cap). M2G.2 verdict run + // tripped 0x6FC4 from AliroCrypto.expandScratch. + scratch = JCSystem.makeTransientByteArray( + (short) 384, JCSystem.CLEAR_ON_DESELECT); + } + + /** + * Verifies a COSE_Sign1 IssuerAuth blob against the supplied issuer + * public key. + * + * @param coseSign1 buffer holding the CBOR-encoded COSE_Sign1 + * @param coseOff offset of the outer array tag (0x84) + * @param coseLen total length of the COSE_Sign1 blob + * @param issuerPubUncomp buffer holding the 65-byte uncompressed SEC1 + * pubkey (0x04 || X || Y) + * @param pubOff offset of the 0x04 tag + * @return {@code true} iff the signature verifies; {@code false} on any + * malformed input, mismatched key, or invalid signature. + */ + boolean verifyCoseSign1( + byte[] coseSign1, short coseOff, short coseLen, + byte[] issuerPubUncomp, short pubOff) { + try { + return verifyInternal(coseSign1, coseOff, coseLen, issuerPubUncomp, pubOff); + } catch (Throwable t) { + // Any malformed CBOR, length overrun, crypto exception, etc. + // collapses to "not verified". The personalization caller will + // refuse to install the access document. + return false; + } + } + + private boolean verifyInternal( + byte[] coseSign1, short coseOff, short coseLen, + byte[] issuerPubUncomp, short pubOff) { + short p = coseOff; + short remaining = coseLen; + // 4-byte scratch shared across decodeHeader / elementSpan calls. + // Reuse the end of `scratch` so we don't allocate. + final short argOff = (short) (scratch.length - 4); + + // Outer array(4): expect major type 4, argument == 4. + short hdr = StructuralCbor.decodeHeader( + coseSign1, p, remaining, scratch, argOff); + if ((short) ((hdr >> 8) & 0x07) != 4) return false; + if (readArg(scratch, argOff) != 4) return false; + short consumed = (short) (hdr & 0xFF); + p += consumed; + remaining -= consumed; + + // Element 0: protected_bstr — decode header, capture value off/len. + hdr = StructuralCbor.decodeHeader( + coseSign1, p, remaining, scratch, argOff); + if ((short) ((hdr >> 8) & 0x07) != 2) return false; + short protLen = readArg(scratch, argOff); + if (protLen > MAX_PROTECTED) return false; + consumed = (short) (hdr & 0xFF); + short protValOff = (short) (p + consumed); + if ((short) (consumed + protLen) > remaining) return false; + p += (short) (consumed + protLen); + remaining -= (short) (consumed + protLen); + + // Element 1: unprotected_map — span-skip, contents don't enter Sig_structure. + short mapSpan = StructuralCbor.elementSpan( + coseSign1, p, remaining, scratch, argOff); + p += mapSpan; + remaining -= mapSpan; + + // Element 2: payload_bstr — decode header, capture value off/len. + hdr = StructuralCbor.decodeHeader( + coseSign1, p, remaining, scratch, argOff); + if ((short) ((hdr >> 8) & 0x07) != 2) return false; + short payloadLen = readArg(scratch, argOff); + if (payloadLen > MAX_PAYLOAD) return false; + consumed = (short) (hdr & 0xFF); + short payloadValOff = (short) (p + consumed); + if ((short) (consumed + payloadLen) > remaining) return false; + p += (short) (consumed + payloadLen); + remaining -= (short) (consumed + payloadLen); + + // Element 3: signature_bstr — raw 64-byte P-256 ECDSA (r||s). + hdr = StructuralCbor.decodeHeader( + coseSign1, p, remaining, scratch, argOff); + if ((short) ((hdr >> 8) & 0x07) != 2) return false; + short sigLen = readArg(scratch, argOff); + if (sigLen != RAW_SIG_LEN) return false; + consumed = (short) (hdr & 0xFF); + short sigValOff = (short) (p + consumed); + if ((short) (consumed + sigLen) > remaining) return false; + + // Build Sig_structure into scratch. + short s = (short) 0; + Util.arrayCopyNonAtomic(SIG_STRUCT_PREFIX, (short) 0, + scratch, s, (short) SIG_STRUCT_PREFIX.length); + s += (short) SIG_STRUCT_PREFIX.length; + // protected_bstr (re-emit header so it matches input verbatim). + s = writeBstr(coseSign1, protValOff, protLen, scratch, s); + // external_aad = empty bstr (h''): single byte 0x40. + scratch[s++] = (byte) 0x40; + // payload bstr. + s = writeBstr(coseSign1, payloadValOff, payloadLen, scratch, s); + + // Place the DER-encoded signature after the Sig_structure. + short derLen = rawSigToDer(coseSign1, sigValOff, scratch, s); + short derOff = s; + + // Load issuer pubkey. + issuerPubKey.setW(issuerPubUncomp, pubOff, UNCOMP_PUB_LEN); + ecdsaVerifier.init(issuerPubKey, Signature.MODE_VERIFY); + return ecdsaVerifier.verify( + scratch, (short) 0, s, + scratch, derOff, derLen); + } + + /** Reads the 4-byte big-endian argument that + * {@link StructuralCbor#decodeHeader} wrote into the scratch as a short. + * StructuralCbor itself caps argument values at {@code Short.MAX_VALUE} + * before they can reach us, so the high half is guaranteed zero. */ + private static short readArg(byte[] buf, short off) { + return (short) (((buf[(short) (off + 2)] & 0xFF) << 8) + | (buf[(short) (off + 3)] & 0xFF)); + } + + /** Copies {@code len} bytes from {@code src[srcOff..]} into {@code dst} + * prefixed by a freshly-emitted CBOR bstr header. Returns the post-write + * offset into {@code dst}. */ + private static short writeBstr(byte[] src, short srcOff, short len, + byte[] dst, short dstOff) { + if (len <= 23) { + dst[dstOff++] = (byte) (0x40 | len); + } else if (len <= 0xFF) { + dst[dstOff++] = (byte) 0x58; + dst[dstOff++] = (byte) len; + } else { + dst[dstOff++] = (byte) 0x59; + dst[dstOff++] = (byte) ((len >> 8) & 0xFF); + dst[dstOff++] = (byte) (len & 0xFF); + } + Util.arrayCopyNonAtomic(src, srcOff, dst, dstOff, len); + return (short) (dstOff + len); + } + + /** + * Converts a 64-byte raw ECDSA (r||s) signature into ASN.1 DER: + * {@code SEQUENCE { INTEGER r, INTEGER s }}. Both r and s are written + * without leading-zero stripping, and a 0x00 is prepended if the high + * bit of the first byte is set (to keep the INTEGER positive). + * + *

Duplicates {@code AliroApplet.rawSigToDer} verbatim — both + * callers are private and Java Card 1.7 has no facility for a shared + * package-level helper without a separate utility class. + */ + private static short rawSigToDer(byte[] raw, short rawOff, + byte[] out, short outOff) { + boolean rPad = (raw[rawOff] & 0x80) != 0; + boolean sPad = (raw[(short) (rawOff + 32)] & 0x80) != 0; + short rLen = rPad ? (short) 33 : (short) 32; + short sLen = sPad ? (short) 33 : (short) 32; + short contentLen = (short) (2 + rLen + 2 + sLen); + + short p = outOff; + out[p++] = (byte) 0x30; + out[p++] = (byte) contentLen; + + out[p++] = (byte) 0x02; + out[p++] = (byte) rLen; + if (rPad) out[p++] = (byte) 0x00; + Util.arrayCopyNonAtomic(raw, rawOff, out, p, (short) 32); + p += 32; + + out[p++] = (byte) 0x02; + out[p++] = (byte) sLen; + if (sPad) out[p++] = (byte) 0x00; + Util.arrayCopyNonAtomic(raw, (short) (rawOff + 32), out, p, (short) 32); + p += 32; + + return (short) (p - outOff); + } +} diff --git a/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java b/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java index 2e015f4..7eec6db 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java +++ b/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java @@ -28,6 +28,7 @@ final class CredentialStore { static final short CRED_PRIV_KEY_LEN = 32; static final short CRED_PUBK_LEN = 64; static final short READER_PUBK_LEN = 64; + static final short CRED_ISSUER_PUBK_LEN = 64; /** Max Access Document blob size (serialized COSE_Sign1 bytes). 1 KB * accommodates a typical Aliro Access Document with room to spare. */ @@ -35,8 +36,12 @@ final class CredentialStore { /** Stable field order for AMD-H Element serialization. Append-only — * never reorder or remove without bumping the package version and - * writing an explicit migration step in the new ELF's onRestore. */ - static final byte FIELD_VERSION = 1; + * writing an explicit migration step in the new ELF's onRestore. + * + *

v3 (M2A.3): adds {@code credentialIssuerPubKey} (64 B x||y) + + * {@code credentialIssuerPubKeySet} flag for IssuerAuth verify at + * finalize. */ + static final byte FIELD_VERSION = 3; /** Publish-point read by AliroApplet/StepUpApplet via {@link #get()}. * PersonalizationApplet owns the actual instance; this is just an alias @@ -54,9 +59,13 @@ 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; + private boolean accessDocumentVerified; private boolean committed; @@ -64,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]; } @@ -159,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}; @@ -173,28 +207,60 @@ final class CredentialStore { } /** - * Marks the Access Document as provisioned with {@code totalLen} bytes - * of valid content starting at offset 0. Returns false (and does not - * mutate state) if {@code totalLen} is outside {@code [0, ACCESS_DOC_MAX_LEN]}. + * Finalizes the Access Document: runs the supplied {@link CoseVerifier} + * against the staged AD bytes using the stored Credential Issuer public + * key. On success, atomically sets {@code accessDocumentLen}, + * {@code accessDocumentFinalized}, and {@code accessDocumentVerified} + * inside a {@link javacard.framework.JCSystem#beginTransaction} so + * partial state can never ship. * - *

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

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

+ * + *

This is the M2A.3 expansion of opt 4a from the Step-Up plan: caching + * the verify result saves ~100 ms per Step-Up transaction. The + * verify-flag write and the {@code accessDocumentFinalized} flip land in + * the same atomic transaction so partial state can't ship an unverified * document marked verified. + * + * @param totalLen length of staged AD bytes at {@code accessDocument[0..)} + * @param verifier IssuerAuth verifier (held as a field on + * PersonalizationApplet; never per-call constructed) + * @param scratch65 caller-supplied 65 B scratch into which this method + * writes the uncompressed issuer pubkey before handing + * it to {@code verifier.verifyCoseSign1} + * @return true iff verify succeeded AND state was atomically committed; + * false on bad length OR verify failure (state untouched). */ - boolean finalizeAccessDocument(short totalLen) { + boolean finalizeAccessDocument(short totalLen, CoseVerifier verifier, byte[] scratch65) { if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) { return false; } - accessDocumentLen = totalLen; - accessDocumentFinalized = true; + // Load the issuer pubkey into the caller-supplied scratch as + // 0x04 || x || y (CoseVerifier needs SEC1 uncompressed). + copyCredentialIssuerPubKeyUncomp(scratch65, (short) 0); + + boolean verified = verifier.verifyCoseSign1( + accessDocument, (short) 0, totalLen, scratch65, (short) 0); + if (!verified) { + return false; + } + + try { + javacard.framework.JCSystem.beginTransaction(); + accessDocumentLen = totalLen; + accessDocumentFinalized = true; + accessDocumentVerified = true; + javacard.framework.JCSystem.commitTransaction(); + } catch (Throwable t) { + if (javacard.framework.JCSystem.getTransactionDepth() != 0) { + javacard.framework.JCSystem.abortTransaction(); + } + return false; + } return true; } @@ -202,6 +268,21 @@ final class CredentialStore { return accessDocumentFinalized; } + /** + * Test-only: forces the verified flag without running the IssuerAuth + * COSE_Sign1 verify. Used by tests that exercise downstream behavior + * (signaling bitmap, serialization round-trip) with opaque AD bytes that + * aren't signed by a real issuer key. Production code reaches the verified + * state via {@link #finalizeAccessDocument(short, CoseVerifier, byte[])}. + */ + void markAccessDocumentVerifiedForTesting() { + accessDocumentVerified = true; + } + + boolean isAccessDocumentVerified() { + return accessDocumentVerified; + } + short getAccessDocumentLen() { return accessDocumentLen; } @@ -222,11 +303,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); } @@ -245,7 +329,9 @@ 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(); byte[] a; a = src.readByteArray(); @@ -255,10 +341,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]; @@ -301,12 +400,15 @@ 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; committed = false; } } diff --git a/applet/src/main/java/com/dangerousthings/aliro/DeviceRequestParser.java b/applet/src/main/java/com/dangerousthings/aliro/DeviceRequestParser.java new file mode 100644 index 0000000..1102357 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/DeviceRequestParser.java @@ -0,0 +1,317 @@ +package com.dangerousthings.aliro; + +import javacard.framework.ISO7816; +import javacard.framework.ISOException; +import javacard.framework.Util; + +/** + * Structural validator for the inbound mdoc DeviceRequest carried in M2's + * Step-Up ENVELOPE chain (ISO 18013-5 §8.3.2.1): + * + *

+ * DeviceRequest = {
+ *     "version": tstr,        ; must be "1.0"
+ *     "docRequests": [+ DocRequest]
+ * }
+ *
+ * DocRequest = {
+ *     "itemsRequest": bstr,   ; encoded ItemsRequest — opaque to us
+ *     ? "readerAuth": COSE_Sign1
+ * }
+ * 
+ * + *

Aliro's M2 reader profile asks for a fixed Access Document; we don't + * interpret docType / nameSpaces / readerAuth. The job here is to lock the + * wire shape so malformed input fails fast with the right SW instead of being + * silently swallowed. + * + *

Validation policy: + *

+ * + *

Allocation-free: the caller passes the 4-byte scratch used by + * {@link StructuralCbor#decodeHeader}'s argument output. M2D.3 will wire + * {@link StepUpApplet} to call this before discarding the request body and + * returning the single Access Document we hold. + */ +final class DeviceRequestParser { + + // ASCII bytes for CBOR tstr keys / values. Using literal arrays keeps the + // parser allocation-free and avoids any UTF-8 encoder dependency on-card. + private static final byte[] KEY_VERSION = + { 'v', 'e', 'r', 's', 'i', 'o', 'n' }; + private static final byte[] KEY_DOC_REQUESTS = + { 'd', 'o', 'c', 'R', 'e', 'q', 'u', 'e', 's', 't', 's' }; + private static final byte[] KEY_ITEMS_REQUEST = + { 'i', 't', 'e', 'm', 's', 'R', 'e', 'q', 'u', 'e', 's', 't' }; + private static final byte[] VERSION_1_0 = { '1', '.', '0' }; + + private static final short MAJOR_BSTR = 2; + private static final short MAJOR_TSTR = 3; + private static final short MAJOR_ARRAY = 4; + private static final short MAJOR_MAP = 5; + + private DeviceRequestParser() { + // Utility class — no instances. + } + + /** + * Structurally validates an mdoc DeviceRequest at + * {@code buf[off..off+len)}. Returns normally iff the request is + * shape-valid for M2's "return the only Access Document we have" + * behavior — no value is returned because no information from the + * request body affects the response. + * + * @param buf buffer holding the encoded DeviceRequest + * @param off offset of the first CBOR byte + * @param len length of the request in bytes + * @param scratch4 4-byte scratch for {@link StructuralCbor#decodeHeader} + * @param scratch4Off offset within {@code scratch4} + * @throws ISOException SW_CONDITIONS_NOT_SATISFIED (0x6985) on unsupported + * version; SW_DATA_INVALID (0x6984) on malformed CBOR + * or wrong shape (incl. missing required keys). + */ + static void validate( + byte[] buf, short off, short len, + byte[] scratch4, short scratch4Off) { + + // Top-level header: must be a map. + short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + if (major != MAJOR_MAP) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short entries = readArgAsShort(scratch4, scratch4Off); + + short cursor = (short) (off + consumed); + short remaining = (short) (len - consumed); + + boolean sawVersion = false; + boolean sawDocRequests = false; + + for (short i = 0; i < entries; i++) { + // --- Key --- + short keyHeader = StructuralCbor.decodeHeader( + buf, cursor, remaining, scratch4, scratch4Off); + short keyMajor = (short) ((keyHeader >> 8) & 0x07); + short keyConsumed = (short) (keyHeader & 0xFF); + short keyLen = readArgAsShort(scratch4, scratch4Off); + + short keyBodyOff = (short) (cursor + keyConsumed); + short keyTotal = (short) (keyConsumed + keyLen); + if (keyMajor != MAJOR_TSTR || keyTotal > remaining) { + // Non-string keys are out of spec for DeviceRequest; treat as + // malformed. (DeviceRequest is a string-keyed map.) + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + cursor = (short) (cursor + keyTotal); + remaining = (short) (remaining - keyTotal); + + // --- Value: dispatch on the key string --- + if (matches(buf, keyBodyOff, keyLen, KEY_VERSION)) { + short valConsumed = validateVersionValue( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + valConsumed); + remaining = (short) (remaining - valConsumed); + sawVersion = true; + } else if (matches(buf, keyBodyOff, keyLen, KEY_DOC_REQUESTS)) { + short valConsumed = validateDocRequestsValue( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + valConsumed); + remaining = (short) (remaining - valConsumed); + sawDocRequests = true; + } else { + // Unknown top-level key — skip its value for + // forward-compatibility with spec additions. + short skip = StructuralCbor.elementSpan( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + skip); + remaining = (short) (remaining - skip); + } + } + + if (!sawVersion || !sawDocRequests) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + } + + /** + * Validates the value associated with the {@code "version"} key: must be + * a tstr equal to {@code "1.0"}. Returns the number of bytes consumed by + * the value (header + payload). Mismatched value → SW_CONDITIONS_NOT_SATISFIED. + */ + private static short validateVersionValue( + byte[] buf, short off, short len, + byte[] scratch4, short scratch4Off) { + + short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + short payload = readArgAsShort(scratch4, scratch4Off); + + if (major != MAJOR_TSTR) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short total = (short) (consumed + payload); + if (total > len) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + if (!matches(buf, (short) (off + consumed), payload, VERSION_1_0)) { + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } + return total; + } + + /** + * Validates the value associated with the {@code "docRequests"} key: must + * be a non-empty array where every entry is a map containing an + * {@code "itemsRequest"} bstr. Returns total bytes consumed. + */ + private static short validateDocRequestsValue( + byte[] buf, short off, short len, + byte[] scratch4, short scratch4Off) { + + short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + short count = readArgAsShort(scratch4, scratch4Off); + + if (major != MAJOR_ARRAY || count < 1) { + // Empty docRequests array is illegal per the [+ DocRequest] + // one-or-more CDDL marker. + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + + short cursor = (short) (off + consumed); + short remaining = (short) (len - consumed); + short total = consumed; + + for (short i = 0; i < count; i++) { + short entrySpan = validateDocRequestEntry( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + entrySpan); + remaining = (short) (remaining - entrySpan); + total = (short) (total + entrySpan); + } + return total; + } + + /** + * Validates a single DocRequest map. Walks its keys; on + * {@code "itemsRequest"} asserts the value is a bstr (contents opaque), + * other keys (e.g. {@code "readerAuth"}) skip via {@code elementSpan}. + * Returns the total bytes the entry occupies. + */ + private static short validateDocRequestEntry( + byte[] buf, short off, short len, + byte[] scratch4, short scratch4Off) { + + short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + short entries = readArgAsShort(scratch4, scratch4Off); + + if (major != MAJOR_MAP) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + + short cursor = (short) (off + consumed); + short remaining = (short) (len - consumed); + short total = consumed; + boolean sawItemsRequest = false; + + for (short i = 0; i < entries; i++) { + // Key (must be tstr per DeviceRequest CDDL). + short keyHeader = StructuralCbor.decodeHeader( + buf, cursor, remaining, scratch4, scratch4Off); + short keyMajor = (short) ((keyHeader >> 8) & 0x07); + short keyConsumed = (short) (keyHeader & 0xFF); + short keyLen = readArgAsShort(scratch4, scratch4Off); + + short keyBodyOff = (short) (cursor + keyConsumed); + short keyTotal = (short) (keyConsumed + keyLen); + if (keyMajor != MAJOR_TSTR || keyTotal > remaining) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + cursor = (short) (cursor + keyTotal); + remaining = (short) (remaining - keyTotal); + total = (short) (total + keyTotal); + + if (matches(buf, keyBodyOff, keyLen, KEY_ITEMS_REQUEST)) { + // Value must be a bstr — contents are an opaque encoded + // ItemsRequest we deliberately do not decode. + short vHeader = StructuralCbor.decodeHeader( + buf, cursor, remaining, scratch4, scratch4Off); + short vMajor = (short) ((vHeader >> 8) & 0x07); + short vConsumed = (short) (vHeader & 0xFF); + short vLen = readArgAsShort(scratch4, scratch4Off); + short vTotal = (short) (vConsumed + vLen); + if (vMajor != MAJOR_BSTR || vTotal > remaining) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + cursor = (short) (cursor + vTotal); + remaining = (short) (remaining - vTotal); + total = (short) (total + vTotal); + sawItemsRequest = true; + } else { + // Skip unrecognized key's value (e.g. readerAuth COSE_Sign1). + short skip = StructuralCbor.elementSpan( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + skip); + remaining = (short) (remaining - skip); + total = (short) (total + skip); + } + } + + if (!sawItemsRequest) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + return total; + } + + /** + * Constant-shape ASCII string compare: returns true iff + * {@code buf[off..off+len) == expected[0..expected.length)}. Used to match + * CBOR tstr keys / values against the literal byte arrays at the top of + * this file. + */ + private static boolean matches(byte[] buf, short off, short len, byte[] expected) { + if (len != (short) expected.length) { + return false; + } + return Util.arrayCompare(buf, off, expected, (short) 0, len) == 0; + } + + /** + * Reads the 4-byte big-endian argument {@link StructuralCbor#decodeHeader} + * wrote into {@code scratch4} and returns it as a short. Values > 0x7FFF + * are rejected as malformed input — Aliro mdoc payloads fit in a + * short-bounded buffer, mirroring StructuralCbor's own bound. + */ + private static short readArgAsShort(byte[] scratch, short off) { + short hi = (short) (((scratch[off] & 0xFF) << 8) + | (scratch[(short) (off + 1)] & 0xFF)); + if (hi != 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short v = (short) (((scratch[(short) (off + 2)] & 0xFF) << 8) + | (scratch[(short) (off + 3)] & 0xFF)); + if (v < 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + return v; + } +} diff --git a/applet/src/main/java/com/dangerousthings/aliro/DeviceResponseBuilder.java b/applet/src/main/java/com/dangerousthings/aliro/DeviceResponseBuilder.java new file mode 100644 index 0000000..aa5f3a1 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/DeviceResponseBuilder.java @@ -0,0 +1,164 @@ +package com.dangerousthings.aliro; + +import javacard.framework.Util; + +/** + * Builds the mdoc DeviceResponse (ISO 18013-5 §8.3.2.1.2.2 / Aliro Table 8-22) + * carrying the cached Access Document at + * {@code documents[0].issuerSigned.issuerAuth}. + * + *

The fixed shape (Aliro Step-Up only ever returns a single mDL document + * with empty nameSpaces and no deviceSigned): + *

+ *   DeviceResponse = {
+ *       "status":    0,
+ *       "version":   "1.0",
+ *       "documents": [
+ *           {
+ *               "docType":      "org.iso.18013.5.1.mDL",
+ *               "issuerSigned": {
+ *                   "issuerAuth": <Access Document bytes verbatim>,
+ *                   "nameSpaces": {}
+ *               }
+ *           }
+ *       ]
+ *   }
+ * 
+ * + *

Map keys are canonical-CBOR sorted (RFC 8949 §4.2.1: encoded-key-length + * then lexicographic). Both 10-char issuerSigned keys tie on length; 'i' < 'n' + * so issuerAuth precedes nameSpaces. + * + *

The Access Document is embedded VERBATIM via + * {@link Util#arrayCopyNonAtomic} — it was verified as a 4-element COSE_Sign1 + * at personalization (see {@code CoseVerifier}), so the applet trusts its + * encoding and must not re-encode it (re-encoding could alter the very bytes + * the issuer signed over). + * + *

All map / array headers are single-byte for our fixed counts + * ({@code map(0)=0xA0, map(2)=0xA2, map(3)=0xA3, array(1)=0x81}) and inlined + * — extending {@link StructuralCbor} with encodeMapHeader / encodeArrayHeader + * would add a static helper used only here. Tstr key/value bodies go through + * {@link StructuralCbor#encodeTstrHeader} so the header / payload split stays + * canonical-CBOR-correct (1-byte header for these tiny strings, but the + * encoder picks the right size on its own). + * + * @see CoseVerifier for the personalization-time AD shape check this builder + * relies on. + */ +final class DeviceResponseBuilder { + + // Canonical CBOR map / array headers used by the fixed Aliro DeviceResponse + // shape. All entry counts are < 24, so each fits in the immediate + // 1-byte form (major type bits 7..5, additional info bits 4..0). + private static final byte CBOR_MAP_3 = (byte) 0xA3; // map, 3 entries + private static final byte CBOR_MAP_2 = (byte) 0xA2; // map, 2 entries + private static final byte CBOR_MAP_0 = (byte) 0xA0; // map, 0 entries (empty) + private static final byte CBOR_ARRAY_1 = (byte) 0x81; // array, 1 element + + // Top-level DeviceResponse keys, sorted canonical (length-then-lex). + private static final byte[] KEY_STATUS = { 's', 't', 'a', 't', 'u', 's' }; + private static final byte[] KEY_VERSION = { 'v', 'e', 'r', 's', 'i', 'o', 'n' }; + private static final byte[] KEY_DOCUMENTS = { 'd', 'o', 'c', 'u', 'm', 'e', 'n', 't', 's' }; + + // Document entry keys. + private static final byte[] KEY_DOC_TYPE = { 'd', 'o', 'c', 'T', 'y', 'p', 'e' }; + private static final byte[] KEY_ISSUER_SIGNED = { + 'i', 's', 's', 'u', 'e', 'r', 'S', 'i', 'g', 'n', 'e', 'd' }; + + // issuerSigned entry keys — issuerAuth before nameSpaces ('i' < 'n'). + private static final byte[] KEY_ISSUER_AUTH = { + 'i', 's', 's', 'u', 'e', 'r', 'A', 'u', 't', 'h' }; + private static final byte[] KEY_NAME_SPACES = { + 'n', 'a', 'm', 'e', 'S', 'p', 'a', 'c', 'e', 's' }; + + // Constants for the only docType Aliro Step-Up ever emits. + private static final byte[] VAL_VERSION = { '1', '.', '0' }; + private static final byte[] VAL_MDL_DOC_TYPE = { + 'o', 'r', 'g', '.', 'i', 's', 'o', '.', '1', '8', '0', '1', '3', + '.', '5', '.', '1', '.', 'm', 'D', 'L' }; + + private DeviceResponseBuilder() { + // Utility class — no instances. + } + + /** + * Builds a canonical-CBOR DeviceResponse carrying {@code ad} at + * {@code documents[0].issuerSigned.issuerAuth}. Writes into {@code out} + * starting at {@code outOff} and returns the total bytes written. + * + *

For a 272-byte AD the output is 372 bytes (100 B wrapper); the + * caller should provision {@code out} with at least {@code adLen + 128} + * bytes for headroom against future shape tweaks. This builder is + * allocation-free and side-effect-free; the only state it touches is + * the slice {@code out[outOff..outOff+return)}. + * + * @param ad Access Document buffer (treated as a single CBOR blob, + * embedded verbatim — caller has already validated shape) + * @param adOff AD start offset + * @param adLen AD length + * @param out output buffer + * @param outOff output start offset + * @return total bytes written + */ + static short build( + byte[] ad, short adOff, short adLen, + byte[] out, short outOff) { + + short p = outOff; + + // DeviceResponse map header — 3 entries: status, version, documents. + out[p++] = CBOR_MAP_3; + + // "status": 0 + p += StructuralCbor.encodeTstrHeader((short) KEY_STATUS.length, out, p); + p = arrayCopy(KEY_STATUS, out, p); + p += StructuralCbor.encodeUint(0, out, p); + + // "version": "1.0" + p += StructuralCbor.encodeTstrHeader((short) KEY_VERSION.length, out, p); + p = arrayCopy(KEY_VERSION, out, p); + p += StructuralCbor.encodeTstrHeader((short) VAL_VERSION.length, out, p); + p = arrayCopy(VAL_VERSION, out, p); + + // "documents": [ ] + p += StructuralCbor.encodeTstrHeader((short) KEY_DOCUMENTS.length, out, p); + p = arrayCopy(KEY_DOCUMENTS, out, p); + out[p++] = CBOR_ARRAY_1; + + // document map header — 2 entries: docType, issuerSigned. + out[p++] = CBOR_MAP_2; + + // "docType": "org.iso.18013.5.1.mDL" + p += StructuralCbor.encodeTstrHeader((short) KEY_DOC_TYPE.length, out, p); + p = arrayCopy(KEY_DOC_TYPE, out, p); + p += StructuralCbor.encodeTstrHeader((short) VAL_MDL_DOC_TYPE.length, out, p); + p = arrayCopy(VAL_MDL_DOC_TYPE, out, p); + + // "issuerSigned": { ... } + p += StructuralCbor.encodeTstrHeader((short) KEY_ISSUER_SIGNED.length, out, p); + p = arrayCopy(KEY_ISSUER_SIGNED, out, p); + + // issuerSigned map header — 2 entries: issuerAuth, nameSpaces. + out[p++] = CBOR_MAP_2; + + // "issuerAuth": + p += StructuralCbor.encodeTstrHeader((short) KEY_ISSUER_AUTH.length, out, p); + p = arrayCopy(KEY_ISSUER_AUTH, out, p); + Util.arrayCopyNonAtomic(ad, adOff, out, p, adLen); + p = (short) (p + adLen); + + // "nameSpaces": {} + p += StructuralCbor.encodeTstrHeader((short) KEY_NAME_SPACES.length, out, p); + p = arrayCopy(KEY_NAME_SPACES, out, p); + out[p++] = CBOR_MAP_0; + + return (short) (p - outOff); + } + + /** Copy a small constant byte[] into {@code out[off..)} and return the new write cursor. */ + private static short arrayCopy(byte[] src, byte[] out, short off) { + Util.arrayCopyNonAtomic(src, (short) 0, out, off, (short) src.length); + return (short) (off + src.length); + } +} diff --git a/applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java b/applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java index fa1ba03..b97556a 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java @@ -24,6 +24,7 @@ public class PersonalizationApplet extends Applet { private static final byte INS_SET_READER_PUBK = (byte) 0x22; private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23; private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24; + private static final byte INS_SET_CREDENTIAL_ISSUER_PUBK = (byte) 0x25; private static final byte INS_COMMIT = (byte) 0x2C; /** The owning reference to the shared CredentialStore. AliroApplet and @@ -34,8 +35,23 @@ public class PersonalizationApplet extends Applet { * state) don't lose enrollment data. */ private final CredentialStore store; + /** One-shot IssuerAuth COSE_Sign1 verifier — constructed at install so + * the 768 B CLEAR_ON_DESELECT transient scratch is allocated once. + * Reconstructing it on every finalize would eventually exhaust the + * transient pool on real hardware. */ + private final CoseVerifier issuerVerifier; + + /** Persistent 65 B scratch the issuer pubkey is rebuilt into before + * every {@link CoseVerifier#verifyCoseSign1} call. Held on the applet + * instance so it survives AMD-H upgrades — we'd rather pay the 65 B + * of EEPROM than risk transient-pool exhaustion or per-finalize + * allocation. */ + private final byte[] issuerPubScratch; + public PersonalizationApplet() { this.store = CredentialStore.bootstrap(); + this.issuerVerifier = new CoseVerifier(); + this.issuerPubScratch = new byte[65]; } public static void install(byte[] bArray, short bOffset, byte bLength) { @@ -76,6 +92,10 @@ public class PersonalizationApplet extends Applet { requireUnlocked(store); setReaderPubKey(apdu, store); return; + case INS_SET_CREDENTIAL_ISSUER_PUBK: + requireUnlocked(store); + setCredentialIssuerPubKey(apdu, store); + return; case INS_WRITE_ACCESS_DOC: requireUnlocked(store); writeAccessDocChunk(apdu, store); @@ -126,6 +146,15 @@ public class PersonalizationApplet extends Applet { store.setReaderPubKey(buf, apdu.getOffsetCdata()); } + private void setCredentialIssuerPubKey(APDU apdu, CredentialStore store) { + short lc = apdu.setIncomingAndReceive(); + if (lc != CredentialStore.CRED_ISSUER_PUBK_LEN) { + ISOException.throwIt(ISO7816.SW_WRONG_DATA); + } + byte[] buf = apdu.getBuffer(); + store.setCredentialIssuerPubKey(buf, apdu.getOffsetCdata()); + } + /** * Writes an Access Document chunk. P1|P2 is the destination offset * within the stored Access Document (big-endian unsigned 16-bit); the @@ -144,19 +173,36 @@ public class PersonalizationApplet extends Applet { } /** - * Marks the Access Document as provisioned. P1|P2 is the total length - * (big-endian unsigned 16-bit). Lc must be 0. + * Finalizes the Access Document: triggers a one-shot IssuerAuth verify + * against the stored Credential Issuer pubkey and atomically flips + * {@code accessDocumentFinalized} + {@code accessDocumentVerified} on + * success. P1|P2 is the total length (big-endian unsigned 16-bit). + * Lc must be 0. + * + *

Pre-conditions: the issuer pubkey must have been set first + * (INS_SET_CREDENTIAL_ISSUER_PUBK = 0x25); without it we have no trust + * anchor and reject with {@code SW_CONDITIONS_NOT_SATISFIED}. A verify + * failure (signature didn't match) returns {@code SW_DATA_INVALID}. */ private void finalizeAccessDoc(APDU apdu, CredentialStore store) { short lc = apdu.setIncomingAndReceive(); if (lc != 0) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } + if (!store.hasCredentialIssuerPubKey()) { + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } byte[] buf = apdu.getBuffer(); short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8) | (buf[ISO7816.OFFSET_P2] & 0xFF)); - if (!store.finalizeAccessDocument(totalLen)) { + // Bound-check first so the existing oversize test stays at + // SW_WRONG_DATA. Once length is valid, only IssuerAuth verify can + // fail — and that's the tamper case the brief maps to SW_DATA_INVALID. + if (totalLen < 0 || totalLen > CredentialStore.ACCESS_DOC_MAX_LEN) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } + if (!store.finalizeAccessDocument(totalLen, issuerVerifier, issuerPubScratch)) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } } } diff --git a/applet/src/main/java/com/dangerousthings/aliro/Secp256r1Params.java b/applet/src/main/java/com/dangerousthings/aliro/Secp256r1Params.java new file mode 100644 index 0000000..28abca0 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/Secp256r1Params.java @@ -0,0 +1,84 @@ +package com.dangerousthings.aliro; + +/** + * secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2. + * + *

Shared across {@link AliroApplet} and {@link CoseVerifier} (and any + * future Aliro applet that needs to seed an EC key). J3R180 ships ECC keys + * with no default domain parameters; without explicitly seeding the curve, + * calls into {@code genKeyPair} / {@code setS} / {@code setW} / + * {@code Signature.init} throw {@code CryptoException.ILLEGAL_VALUE}. + * + *

Package-private — only intra-package callers should depend on these. + */ +final class Secp256r1Params { + + private Secp256r1Params() { } + + static final byte[] SECP256R1_P = { + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF + }; + static final byte[] SECP256R1_A = { + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFC + }; + static final byte[] SECP256R1_B = { + 0x5A,(byte)0xC6,0x35,(byte)0xD8,(byte)0xAA,0x3A,(byte)0x93,(byte)0xE7, + (byte)0xB3,(byte)0xEB,(byte)0xBD,0x55,0x76,(byte)0x98,(byte)0x86,(byte)0xBC, + 0x65,0x1D,0x06,(byte)0xB0,(byte)0xCC,0x53,(byte)0xB0,(byte)0xF6, + 0x3B,(byte)0xCE,0x3C,0x3E,0x27,(byte)0xD2,0x60,0x4B + }; + static final byte[] SECP256R1_G = { + 0x04, + 0x6B,0x17,(byte)0xD1,(byte)0xF2,(byte)0xE1,0x2C,0x42,0x47, + (byte)0xF8,(byte)0xBC,(byte)0xE6,(byte)0xE5,0x63,(byte)0xA4,0x40,(byte)0xF2, + 0x77,0x03,0x7D,(byte)0x81,0x2D,(byte)0xEB,0x33,(byte)0xA0, + (byte)0xF4,(byte)0xA1,0x39,0x45,(byte)0xD8,(byte)0x98,(byte)0xC2,(byte)0x96, + 0x4F,(byte)0xE3,0x42,(byte)0xE2,(byte)0xFE,0x1A,0x7F,(byte)0x9B, + (byte)0x8E,(byte)0xE7,(byte)0xEB,0x4A,0x7C,0x0F,(byte)0x9E,0x16, + 0x2B,(byte)0xCE,0x33,0x57,0x6B,0x31,0x5E,(byte)0xCE, + (byte)0xCB,(byte)0xB6,0x40,0x68,0x37,(byte)0xBF,0x51,(byte)0xF5 + }; + static final byte[] SECP256R1_R = { + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x00, + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, + (byte)0xBC,(byte)0xE6,(byte)0xFA,(byte)0xAD,(byte)0xA7,0x17,(byte)0x9E,(byte)0x84, + (byte)0xF3,(byte)0xB9,(byte)0xCA,(byte)0xC2,(byte)0xFC,0x63,0x25,0x51 + }; + + /** + * Loads the secp256r1 / NIST P-256 curve parameters into both halves of + * {@code kp}. Must run before any {@code genKeyPair}, {@code setS}, + * {@code setW}, or {@code Signature.init} on cards (like J3R180) that + * don't preset domain parameters on freshly-allocated EC keys. + */ + static void seed(javacard.security.KeyPair kp) { + javacard.security.ECPublicKey pub = (javacard.security.ECPublicKey) kp.getPublic(); + javacard.security.ECPrivateKey priv = (javacard.security.ECPrivateKey) kp.getPrivate(); + seedPublic(pub); + priv.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); + priv.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); + priv.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); + priv.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); + priv.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); + priv.setK((short) 1); + } + + /** + * Seeds curve params on a standalone public key (for verify-only flows + * like {@link CoseVerifier} that never need a matching private key). + */ + static void seedPublic(javacard.security.ECPublicKey pub) { + pub.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); + pub.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); + pub.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); + pub.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); + pub.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); + pub.setK((short) 1); + } +} diff --git a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java index 69dc6e7..8ab20b3 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java @@ -44,19 +44,17 @@ import javacard.framework.Util; * the entire encrypted DeviceResponse then chunk it; pipe plaintext * through GCM as we emit ENVELOPE response chunks. Reduces transient * footprint AND minimizes plaintext residence in RAM. - *

  • Opt 4 — IssuerAuth verify cached at personalization. See - * {@link CredentialStore#finalizeAccessDocument(short)} TODO — the - * COSE_Sign1 verify happens once at write time, the persistent + *
  • Opt 4 — IssuerAuth verify cached at personalization. + * {@link CredentialStore#finalizeAccessDocument} runs the COSE_Sign1 + * verify once at write time; the persistent * {@code accessDocumentVerified} flag is checked here at read time. * Saves ~100 ms per transaction. Trusts that the Credential Issuer * trust anchor is fixed for the card's lifetime — true for DT's * implantable target but document the limitation.
  • * * - *

    The {@code AliroApplet.INS_EXCHANGE} stub was retired once StepUpApplet - * landed handling for 0xC9 directly — spec-conformant readers (X-CUBE-ALIRO - * included, once the upstream crypto interop is right) route EXCHANGE here - * after the step-up AID SELECT per §10.2. + *

    StepUpApplet handles {@code INS_EXCHANGE} (0xC9) directly: spec-conformant + * readers route EXCHANGE here after the step-up AID SELECT per §10.2. */ public class StepUpApplet extends Applet { @@ -74,23 +72,28 @@ public class StepUpApplet extends Applet { * X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the * ENVELOPE body once the Step-Up AID is the active applet. */ private static final byte INS_ENVELOPE = (byte) 0xC3; - - /** Length of each derived Step-Up session key (spec §8.4.3). */ - private static final short STEP_UP_SK_LEN = 32; + /** GET RESPONSE per ISO 7816-4 §7.6.1 — the reader pulls the rest of a + * chained response after the applet returns SW=61xx. */ + private static final byte INS_GET_RESPONSE = (byte) 0xC0; /** 12-byte AES-256-GCM IV layout (§8.3.1.8/9): 8B prefix + 4B counter. */ - private static final short GCM_IV_LEN = 12; private static final short GCM_TAG_LEN = 16; - private static final short COUNTER_LEN = 4; - /** {@code StepUpSKDevice} — UD→reader leg of the Step-Up AES-256-GCM - * session, derived from {@code StepUpSK} via HKDF (§8.4.3) when SELECT - * finds an armed {@link SessionContext}. Transient, cleared on deselect. */ - private final byte[] stepUpSKDevice; + /** Per-chunk APDU outgoing window for the response chaining path. The + * T=0/T=1 short-APDU response buffer caps cleanly at this size — leaves + * the trailing 4 B headroom of the standard 261 B jcardsim/JC buffer + * for SW + framing. */ + private static final short CHUNK_LEN = 252; - /** {@code StepUpSKReader} — reader→UD leg of the Step-Up GCM session. - * Same derivation context as {@link #stepUpSKDevice}. */ - private final byte[] stepUpSKReader; + /** Response chaining buffer for DeviceResponse outputs > {@link #CHUNK_LEN}. + * Sized for 372 B canonical DeviceResponse + 16 B GCM tag = 388 B with + * headroom for future shape tweaks. */ + private static final short RESPONSE_BUFFER_LEN = 512; + + /** Holds StepUpSKDevice / StepUpSKReader and the two BE32 message + * counters, plus the IV-stamping math (spec §8.3.1.6/8/9 + §8.4.3). + * All four arrays inside are CLEAR_ON_DESELECT. */ + private final StepUpSession session; /** 32-byte scratch used only during {@link #select()} to stage the * StepUpSK copied out of {@link SessionContext} before HKDF derives the @@ -98,32 +101,14 @@ public class StepUpApplet extends Applet { * outlives the call. */ private final byte[] stepUpSKScratch; - /** Session-bound {@code StepUp_reader_counter} per §8.4.3 + mdoc [6] - * §9.1.1.5: 32-bit big-endian counter starting at {@code 0x00000001} the - * first time the reader→UD direction is used in this Step-Up session, - * incremented after each successful decrypt. CLEAR_ON_DESELECT so each - * Step-Up phase entry starts fresh -- the matching SELECT re-initialises - * the counter alongside the SK derivation. Shared across ENVELOPE and - * EXCHANGE: both commands are reader→device so both consume from the - * same counter. */ - private final byte[] stepUpReaderCounter; - - /** Session-bound {@code StepUp_device_counter} per §8.4.3 + mdoc [6] - * §9.1.1.5: 32-bit big-endian counter for the device→reader direction, - * starting at {@code 0x00000001} on Step-Up session entry, incremented - * after each successful encrypt. In M1 only ENVELOPE returns ciphertext - * (EXCHANGE returns empty), so this advances once per ENVELOPE. */ - private final byte[] stepUpDeviceCounter; - - /** 12-byte scratch for the GCM IV: 8 zero bytes + 4-byte reader counter - * per §8.3.1.8. Rebuilt per EXCHANGE; CLEAR_ON_DESELECT. */ + /** 12-byte scratch for the GCM IV: filled by {@link StepUpSession#readerIv} + * or {@link StepUpSession#deviceIv} before each en/decrypt. CLEAR_ON_DESELECT. */ private final byte[] ivScratch; - /** Persistent EXCHANGE plaintext sink for the decrypt-and-discard path. - * Sized to the largest reasonable Reader Status sub-event we'd see - * during M1 (X-CUBE-ALIRO observed values are well under 64 B); we'll - * resize when the real Reader Status payload size lands. CLEAR_ON_DESELECT - * so post-deselect there's no plaintext residue. */ + /** Transient plaintext sink for ENVELOPE DeviceRequest and EXCHANGE + * Reader Status request decrypts. Sized for the largest reasonable + * reader-supplied payload; CLEAR_ON_DESELECT so post-deselect there's + * no plaintext residue. */ private final byte[] scratchPlaintext; private static final short SCRATCH_PLAINTEXT_LEN = 256; @@ -135,6 +120,28 @@ public class StepUpApplet extends Applet { private static final short FLAG_KEYS_READY = 0; private static final short FLAGS_LEN = 1; + /** Built-then-encrypted DeviceResponse staging area for the response + * chaining path. ENVELOPE writes the encrypted DeviceResponse into here, + * then sends one {@link #CHUNK_LEN}-sized chunk per APDU (the first + * inside the ENVELOPE reply, the rest pulled via GET RESPONSE). Reused + * during the build step as a scratch for the canonical-CBOR DeviceResponse + * plaintext too — encrypt-in-place isn't an option because the GCM tag + * appends after the ciphertext. CLEAR_ON_DESELECT. */ + private final byte[] responseBuffer; + + /** {@code [offset, remaining]} for the chaining drain — both reset at the + * start of every ENVELOPE. {@code remaining == 0} signals + * "no more bytes to ship": GET RESPONSE arriving in that state returns + * SW_CONDITIONS_NOT_SATISFIED. CLEAR_ON_DESELECT. */ + private final short[] responseState; + private static final short STATE_OFF = 0; + private static final short STATE_REMAINING = 1; + + /** 4-byte scratch the {@link DeviceRequestParser} writes its CBOR header + * argument into. Reused across calls — caller-supplied is the + * allocation-free convention StructuralCbor uses. CLEAR_ON_DESELECT. */ + private final byte[] parserScratch4; + public static void install(byte[] bArray, short bOffset, byte bLength) { StepUpApplet applet = new StepUpApplet(); if (bArray == null || bLength == 0) { @@ -149,25 +156,24 @@ public class StepUpApplet extends Applet { // EXPEDITED or pulls the field) zeroes the session keys, matching the // "fresh keys per Step-Up phase" invariant we'll need when ENVELOPE / // EXCHANGE handlers run AES-GCM. - stepUpSKDevice = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT); - stepUpSKReader = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT); - stepUpSKScratch = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT); - stepUpReaderCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT); - stepUpDeviceCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT); - ivScratch = JCSystem.makeTransientByteArray(GCM_IV_LEN, JCSystem.CLEAR_ON_DESELECT); + session = new StepUpSession(); + stepUpSKScratch = JCSystem.makeTransientByteArray(StepUpSession.SK_LEN, JCSystem.CLEAR_ON_DESELECT); + ivScratch = JCSystem.makeTransientByteArray(StepUpSession.IV_LEN, JCSystem.CLEAR_ON_DESELECT); scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT); sessionFlags = JCSystem.makeTransientByteArray(FLAGS_LEN, JCSystem.CLEAR_ON_DESELECT); + responseBuffer = JCSystem.makeTransientByteArray(RESPONSE_BUFFER_LEN, JCSystem.CLEAR_ON_DESELECT); + responseState = JCSystem.makeTransientShortArray((short) 2, JCSystem.CLEAR_ON_DESELECT); + parserScratch4 = JCSystem.makeTransientByteArray((short) 4, JCSystem.CLEAR_ON_DESELECT); } /** * SELECT entry point. If {@link SessionContext} is armed (i.e. AUTH1 on * {@link AliroApplet} just succeeded and parked {@code StepUpSK}), copy * it out and derive {@code StepUpSKDevice}/{@code StepUpSKReader} via - * the §8.4.3 HKDF so the ENVELOPE / EXCHANGE handlers in M1B / M1C can - * AES-256-GCM with them. Always returns true — an un-armed SELECT (e.g. - * a reader that touches the Step-Up AID before AUTH1) still gets a - * successful FCI; downstream handlers will reject commands that need a - * live session. + * the §8.4.3 HKDF so the ENVELOPE / EXCHANGE handlers can AES-256-GCM + * with them. Always returns true — an un-armed SELECT (e.g. a reader + * that touches the Step-Up AID before AUTH1) still gets a successful + * FCI; downstream handlers will reject commands that need a live session. */ @Override public boolean select() { @@ -175,20 +181,18 @@ public class StepUpApplet extends Applet { SessionContext.copyStepUpSK(stepUpSKScratch, (short) 0); CryptoSingletons.getAliroCrypto().deriveStepUpSessionKeys( stepUpSKScratch, (short) 0, - stepUpSKDevice, (short) 0, - stepUpSKReader, (short) 0); + session.skDevice, (short) 0, + session.skReader, (short) 0); // Wipe the staged StepUpSK — the derived keys are sufficient // from here on and we don't want the IKM lingering in transient. - Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, STEP_UP_SK_LEN, (byte) 0); + Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, StepUpSession.SK_LEN, (byte) 0); // Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound counters - // initialized to 0x00000001 on session entry, one per direction. + // initialised to 0x00000001 on session entry, one per direction. // CLEAR_ON_DESELECT already zeroes them on each fresh select; - // rewrite explicitly so a Step-Up SELECT mid-session (without a - // deselect in between) also starts both counters at 1. - Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0); - stepUpReaderCounter[3] = (byte) 0x01; - Util.arrayFillNonAtomic(stepUpDeviceCounter, (short) 0, COUNTER_LEN, (byte) 0); - stepUpDeviceCounter[3] = (byte) 0x01; + // session.reset() rewrites explicitly so a Step-Up SELECT + // mid-session (without a deselect in between) also starts both + // counters at 1. + session.reset(); sessionFlags[FLAG_KEYS_READY] = (byte) 1; } else { sessionFlags[FLAG_KEYS_READY] = (byte) 0; @@ -214,6 +218,10 @@ public class StepUpApplet extends Applet { processEnvelope(apdu); return; } + if (cla == CLA_ISO && ins == INS_GET_RESPONSE) { + processGetResponse(apdu); + return; + } if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) { processExchange(apdu); return; @@ -221,21 +229,34 @@ public class StepUpApplet extends Applet { if (cla != CLA_ISO && cla != CLA_PROPRIETARY) { ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED); } - // GET RESPONSE handler plugs in here in follow-up milestones. ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); } /** - * EXCHANGE (CLA=0x80, INS=0xC9) handler — Milestone 1 decrypt-and-discard. + * EXCHANGE (CLA=0x80, INS=0xC9) handler: request validation + encrypted + * Reader Status response. * - *

    Spec §8.3.3.5 / Table 8-14: the reader sends - * {@code encrypted_payload || authentication_tag} encrypted with - * {@code StepUpSKReader} per §8.3.1.8, IV layout - * {@code 0x0000000000000000 || stepup_reader_counter (4B BE)} and empty - * AAD. M1 only needs to verify the tag (proves matching session keys) - * then ACK with 9000 + empty payload so the X-CUBE-ALIRO firmware marks - * "DOOR OPERATION SUCCEEDED" and moves on. The real Reader Status - * response sub-event (encrypted with StepUpSKDevice) lands in M2. + *

    Spec §8.3.3.5 / Tables 8-19 + 8-20. The reader sends + * {@code encrypted_payload || authentication_tag} under + * {@code StepUpSKReader} (IV {@code 0x00*8 || stepup_reader_counter}, + * §8.3.1.8), empty AAD. The decrypted plaintext is the Reader Status + * sub-event REQUEST: + *

    +     * sub_event_id : 1B   ; 0x01 = ReaderStatusRequest (M2 only supports this)
    +     * payload_len  : 1B
    +     * payload      : Lb   ; empty for 0x01
    +     * 
    + * + *

    The applet validates the structure, then emits a Reader Status + * sub-event RESPONSE (Table 8-20) plaintext: + *

    +     * sub_event_id : 1B   ; echoes 0x01
    +     * status       : 1B   ; 0x00 = OK
    +     * payload_len  : 1B   ; 0 for M2
    +     * 
    + * GCM-encrypted under {@code StepUpSKDevice} + device IV + * ({@code 0x00*7 || 0x01 || stepup_device_counter}, §8.3.1.6). Ciphertext+ + * tag is 3 + 16 = 19 B — fits in one APDU, no chaining needed. */ private void processExchange(APDU apdu) { if (sessionFlags[FLAG_KEYS_READY] == 0) { @@ -253,9 +274,6 @@ public class StepUpApplet extends Applet { if (lc < GCM_TAG_LEN) { ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); } - // M1 sink is fixed-size; reject payloads that wouldn't fit. The real - // EXCHANGE payload during M1 ack flow is tiny (X-CUBE-ALIRO sends a - // few bytes of CBOR), so this bound is comfortable. short ptLen = (short) (lc - GCM_TAG_LEN); if (ptLen > SCRATCH_PLAINTEXT_LEN) { ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); @@ -263,16 +281,14 @@ public class StepUpApplet extends Applet { // Build the IV: 8 zero bytes (reader→device prefix per §8.3.1.8) + // stepup_reader_counter, big-endian, in the trailing 4 bytes. - Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 8, (byte) 0); - Util.arrayCopyNonAtomic(stepUpReaderCounter, (short) 0, - ivScratch, (short) 8, COUNTER_LEN); + session.readerIv(ivScratch, (short) 0); - // Decrypt-and-discard. AliroGcm.decrypt throws CryptoException on - // tag mismatch; remap to a security SW so an attacker can't tell - // tag-mismatch from any other failure mode. + // Decrypt. AliroGcm.decrypt throws CryptoException on tag mismatch; + // remap to a security SW so an attacker can't tell tag-mismatch from + // any other failure mode. try { CryptoSingletons.getAliroGcm().decrypt( - stepUpSKReader, (short) 0, + session.skReader, (short) 0, ivScratch, (short) 0, buf, dataOff, lc, scratchPlaintext, (short) 0); @@ -282,40 +298,84 @@ public class StepUpApplet extends Applet { ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED); } - // Wipe the discarded plaintext immediately -- M1 has no use for it, - // and CLEAR_ON_DESELECT alone would leave it sitting around until the - // reader walks away. + // Spec §8.3.1.8: reader_counter <- reader_counter + 1 after use. + // Advance now so an early exit from request validation still leaves + // the counter at the post-decrypt value (the reader counter advances + // on every successful decrypt regardless of whether the request + // semantically validates). + session.advanceReaderCounter(); + + // Parse the request shape: [sub_event_id, payload_len, payload]. + // Need at least sub_event_id + payload_len = 2 bytes. + if (ptLen < 2) { + Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0); + ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); + } + byte subEventId = scratchPlaintext[0]; + short payloadLen = (short) (scratchPlaintext[1] & 0xFF); + if (payloadLen != (short) (ptLen - 2)) { + Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0); + ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); + } + // Only sub_event_id 0x01 (ReaderStatusRequest) is supported in M2. + // M2 ignores the payload contents for 0x01 (just length-validated above). + if (subEventId != (byte) 0x01) { + Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0); + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + + // Wipe the request plaintext — content not needed past validation. Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0); - // Spec §8.3.1.8: reader_counter <- reader_counter + 1 after use. - incrementCounter(stepUpReaderCounter, (short) 0); + // Build the Reader Status response plaintext directly into the APDU + // buffer at offset 0, then encrypt in place. AliroGcm.encrypt supports + // out == pt at the same offset (CTR mode + appended tag). + buf[0] = (byte) 0x01; // sub_event_id (echoes request) + buf[1] = (byte) 0x00; // status = OK + buf[2] = (byte) 0x00; // payload_len = 0 + short respPtLen = 3; - // Ack with SW=9000 and empty payload. If field testing on real - // X-CUBE-ALIRO firmware shows the reader rejects an empty payload, - // M1E iteration escalates this to "9000 + encrypted-empty-CBOR-map" - // per the implementation plan. - apdu.setOutgoingAndSend((short) 0, (short) 0); + // Device-side IV: 0x00*7 || 0x01 || stepup_device_counter (§8.3.1.6). + session.deviceIv(ivScratch, (short) 0); + + short ctLen = CryptoSingletons.getAliroGcm().encrypt( + session.skDevice, (short) 0, + ivScratch, (short) 0, + buf, (short) 0, respPtLen, + buf, (short) 0); + // Spec §8.3.1.6: device_counter <- device_counter + 1 after use. + session.advanceDeviceCounter(); + + apdu.setOutgoingAndSend((short) 0, ctLen); } /** - * ENVELOPE (CLA=0x00, INS=0xC3) handler — Milestone 1 decrypt-and-discard - * the mdoc DeviceRequest, then return a spec-shape encrypted empty CBOR - * map (canonical RFC 8949: single byte 0xA0). + * ENVELOPE (CLA=0x00, INS=0xC3) handler: emits the canonical-CBOR + * DeviceResponse via ISO 7816 response chaining. * - *

    Spec §8.3.1.9: inbound payload (reader→device) is decrypted with - * {@code StepUpSKReader}, IV {@code 0x0000000000000000 || stepup_reader_counter} - * (8-byte zero prefix + 4-byte BE counter), empty AAD. - * - *

    Spec §8.3.1.6: outbound payload (device→reader) is encrypted with - * {@code StepUpSKDevice}, IV {@code 0x0000000000000001 || stepup_device_counter} - * (8-byte prefix ending in 0x01 + 4-byte BE counter), empty AAD. - * - *

    The DeviceRequest body is discarded in M1: we don't build a real - * mdoc DeviceResponse yet -- that's M2's job. The 17-byte ciphertext - * (1 ct + 16 tag) is enough for X-CUBE-ALIRO to see a spec-shape - * encrypted response and move on. Counter sequencing: both - * stepup_reader_counter (shared with EXCHANGE) and stepup_device_counter - * advance independently after each successful use. + *

    Pipeline: + *

      + *
    1. Decrypt the inbound payload under {@code StepUpSKReader} with + * reader-side IV {@code 0x0000000000000000 || stepup_reader_counter} + * (§8.3.1.9). Advance {@code stepup_reader_counter}.
    2. + *
    3. Structurally validate the recovered DeviceRequest via + * {@link DeviceRequestParser#validate} — propagates SW_DATA_INVALID + * (malformed CBOR / wrong shape) or SW_CONDITIONS_NOT_SATISFIED + * (unsupported version) unchanged.
    4. + *
    5. Build the canonical-CBOR DeviceResponse via + * {@link DeviceResponseBuilder#build} carrying the cached Access + * Document at {@code documents[0].issuerSigned.issuerAuth}.
    6. + *
    7. In-place GCM-encrypt the DeviceResponse plaintext under + * {@code StepUpSKDevice} with device-side IV + * {@code 0x00*7 || 0x01 || stepup_device_counter} (§8.3.1.6). + * AliroGcm supports {@code in == out} at the same offset (CTR mode + + * trailing tag), so the cleartext is overwritten on the encryption + * pass — no second buffer needed.
    8. + *
    9. Advance {@code stepup_device_counter} and ship the first chunk. + * For our standard 372 B AD wrapper the response is 388 B; we send + * the first {@link #CHUNK_LEN} bytes inline with SW=61xx and the + * reader pulls the rest via GET RESPONSE.
    10. + *
    */ private void processEnvelope(APDU apdu) { if (sessionFlags[FLAG_KEYS_READY] == 0) { @@ -325,6 +385,11 @@ public class StepUpApplet extends Applet { ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); } + // Invalidate any in-flight chaining state from a previous ENVELOPE. + // A new ENVELOPE always restarts the response stream. + responseState[STATE_OFF] = 0; + responseState[STATE_REMAINING] = 0; + short lc = apdu.setIncomingAndReceive(); byte[] buf = apdu.getBuffer(); short dataOff = apdu.getOffsetCdata(); @@ -340,13 +405,11 @@ public class StepUpApplet extends Applet { } // Build the reader-side IV: 8 zero bytes (§8.3.1.9) + reader counter. - Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 8, (byte) 0); - Util.arrayCopyNonAtomic(stepUpReaderCounter, (short) 0, - ivScratch, (short) 8, COUNTER_LEN); + session.readerIv(ivScratch, (short) 0); try { CryptoSingletons.getAliroGcm().decrypt( - stepUpSKReader, (short) 0, + session.skReader, (short) 0, ivScratch, (short) 0, buf, dataOff, lc, scratchPlaintext, (short) 0); @@ -356,51 +419,100 @@ public class StepUpApplet extends Applet { ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED); } // Spec §8.3.1.9: reader_counter <- reader_counter + 1 after use. - incrementCounter(stepUpReaderCounter, (short) 0); + session.advanceReaderCounter(); - // Wipe the decrypted DeviceRequest -- M1 has no use for it. M2 will - // replace this with real mdoc parsing and a real DeviceResponse. + // Structural validation. Throws ISOException on malformed CBOR / + // unsupported version — let it propagate; SW mapping is the parser's + // responsibility. + DeviceRequestParser.validate( + scratchPlaintext, (short) 0, ptLen, + parserScratch4, (short) 0); + + // Wipe the decrypted DeviceRequest — its content doesn't influence the + // response (M2 reader profile asks for the single cached AD), and we + // don't want plaintext loitering past CLEAR_ON_DESELECT. Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0); - // Build the canonical-CBOR empty map plaintext (single byte 0xA0, - // RFC 8949 major type 5 (map) with length 0). One byte total. - scratchPlaintext[0] = (byte) 0xA0; + // Build the canonical-CBOR DeviceResponse into responseBuffer. The + // cached AD is staged directly into the builder via copyAccessDocument; + // CredentialStore.hasAccessDocument() is asserted first so a card that + // somehow reached Step-Up without an AD doesn't synthesize an empty + // issuerAuth that would crash downstream readers. + CredentialStore store = CredentialStore.get(); + if (store == null || !store.hasAccessDocument()) { + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } + short adLen = store.getAccessDocumentLen(); + // Stage the AD into responseBuffer past the wrapper's max footprint. + // The wrapper is ~100 B for our fixed shape; staging AD at offset 128 + // gives the builder a clean target at offset 0 and the AD source at a + // distinct, non-overlapping location. + short adStage = 128; + store.copyAccessDocument(responseBuffer, adStage, (short) 0, adLen); + short respLen = DeviceResponseBuilder.build( + responseBuffer, adStage, adLen, + responseBuffer, (short) 0); - // Build the device-side IV: 0x00*7 || 0x01 || device_counter - // (§8.3.1.6 -- 8-byte prefix ending in 0x01, then 4B BE counter). - Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 7, (byte) 0); - ivScratch[7] = (byte) 0x01; - Util.arrayCopyNonAtomic(stepUpDeviceCounter, (short) 0, - ivScratch, (short) 8, COUNTER_LEN); + // Build the device-side IV: 0x00*7 || 0x01 || device_counter (§8.3.1.6). + session.deviceIv(ivScratch, (short) 0); - // Encrypt into the APDU buffer at offset 0. Safe to overwrite the - // inbound command bytes here because we've finished reading them. - // Output length = 1 (ct) + 16 (tag) = 17 bytes; well under the 252-byte - // APDU buffer ceiling. + // In-place encrypt: AliroGcm supports out == in at the same offset. + // Ciphertext overwrites the plaintext; the 16 B tag appends after. short ctLen = CryptoSingletons.getAliroGcm().encrypt( - stepUpSKDevice, (short) 0, + session.skDevice, (short) 0, ivScratch, (short) 0, - scratchPlaintext, (short) 0, (short) 1, - buf, (short) 0); + responseBuffer, (short) 0, respLen, + responseBuffer, (short) 0); // Spec §8.3.1.6: device_counter <- device_counter + 1 after use. - incrementCounter(stepUpDeviceCounter, (short) 0); + session.advanceDeviceCounter(); - // Wipe the single plaintext byte (CLEAR_ON_DESELECT alone would - // leave 0xA0 sitting in transient until reader walks away). - scratchPlaintext[0] = 0; - - apdu.setOutgoingAndSend((short) 0, ctLen); + // Ship the first chunk. Total ciphertext is 388 B for the standard + // 272 B AD case; we send CHUNK_LEN bytes and signal more via SW=61xx + // (handled below in sendChunk). + responseState[STATE_OFF] = 0; + responseState[STATE_REMAINING] = ctLen; + sendChunk(apdu); } - /** 32-bit big-endian counter increment with carry across all 4 bytes. - * Wraps mod 2^32; spec §8.3.3.5.4 says the counter SHALL never reach - * 0xFFFF before increment (note: spec uses 0xFFFF where 0xFFFFFFFF is - * clearly meant -- 4-byte BE counter), so wrap is unreachable in - * practice during normal protocol flow. */ - private static void incrementCounter(byte[] buf, short off) { - for (short i = (short) (off + 3); i >= off; i--) { - buf[i]++; - if (buf[i] != 0) return; + /** + * GET RESPONSE (CLA=0x00, INS=0xC0) handler — drains the next chunk of an + * outstanding chained DeviceResponse staged by {@link #processEnvelope}. + * Returns SW_CONDITIONS_NOT_SATISFIED if no chain is in flight (per + * ISO 7816-4: GET RESPONSE outside a chained transfer is illegal). + */ + private void processGetResponse(APDU apdu) { + if (responseState[STATE_REMAINING] <= 0) { + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } + sendChunk(apdu); + } + + /** + * Copies up to {@link #CHUNK_LEN} bytes out of {@link #responseBuffer} into + * the APDU buffer, advances the chaining offset, and either ends naturally + * with SW=9000 (last chunk) or throws {@code 0x6100 | xx} where {@code xx} + * is the {@code min(0xFF, remaining)} signal of how many bytes are still + * available via the next GET RESPONSE. + * + *

    The {@code 0x6100} branch follows ISO 7816-4 §5.1.4: SW1=0x61 means + * "process completed normally, SW2 more bytes available". SW2=0x00 in + * that protocol means "256 or more remain"; we cap at 0xFF before the OR. + */ + private void sendChunk(APDU apdu) { + short off = responseState[STATE_OFF]; + short remaining = responseState[STATE_REMAINING]; + short chunk = remaining > CHUNK_LEN ? CHUNK_LEN : remaining; + + Util.arrayCopyNonAtomic(responseBuffer, off, apdu.getBuffer(), (short) 0, chunk); + responseState[STATE_OFF] = (short) (off + chunk); + responseState[STATE_REMAINING] = (short) (remaining - chunk); + + apdu.setOutgoingAndSend((short) 0, chunk); + + short stillRemaining = responseState[STATE_REMAINING]; + if (stillRemaining > 0) { + short xx = stillRemaining > (short) 0xFF ? (short) 0xFF : stillRemaining; + ISOException.throwIt((short) (0x6100 | (xx & 0xFF))); } } @@ -435,10 +547,10 @@ public class StepUpApplet extends Applet { // NOT for use outside the applet's own test module. void copyStepUpSKDeviceForTesting(byte[] out, short outOff) { - Util.arrayCopyNonAtomic(stepUpSKDevice, (short) 0, out, outOff, STEP_UP_SK_LEN); + Util.arrayCopyNonAtomic(session.skDevice, (short) 0, out, outOff, StepUpSession.SK_LEN); } void copyStepUpSKReaderForTesting(byte[] out, short outOff) { - Util.arrayCopyNonAtomic(stepUpSKReader, (short) 0, out, outOff, STEP_UP_SK_LEN); + Util.arrayCopyNonAtomic(session.skReader, (short) 0, out, outOff, StepUpSession.SK_LEN); } } diff --git a/applet/src/main/java/com/dangerousthings/aliro/StepUpSession.java b/applet/src/main/java/com/dangerousthings/aliro/StepUpSession.java new file mode 100644 index 0000000..0e3b06b --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/StepUpSession.java @@ -0,0 +1,123 @@ +package com.dangerousthings.aliro; + +import javacard.framework.JCSystem; +import javacard.framework.Util; + +/** + * Holder for all Step-Up AES-256-GCM session state (spec §8.4.3 + mdoc [6] + * §9.1.1.5): the two derived session keys ({@code StepUpSKDevice}, + * {@code StepUpSKReader}) and the two big-endian 32-bit message counters + * ({@code stepup_reader_counter}, {@code stepup_device_counter}). + * + *

    Extracted out of {@link StepUpApplet} so the IV-construction + + * counter-advance math live in one place and can be unit-tested without + * spinning up the whole applet. {@link StepUpApplet} owns exactly one + * instance; there is no singleton. + * + *

    Lifecycle: + *

    + */ +final class StepUpSession { + + /** Length of each derived Step-Up session key (spec §8.4.3). */ + static final short SK_LEN = 32; + + /** 12-byte AES-256-GCM IV total length. */ + static final short IV_LEN = 12; + + /** Trailing 4-byte big-endian counter portion of the IV. */ + static final short COUNTER_LEN = 4; + + /** {@code StepUpSKDevice} — device->reader leg of the GCM session, + * derived from {@code StepUpSK} via HKDF (§8.4.3) by + * {@link StepUpApplet#select()}. Transient, CLEAR_ON_DESELECT. */ + final byte[] skDevice; + + /** {@code StepUpSKReader} — reader->device leg of the GCM session. + * Same derivation context as {@link #skDevice}. */ + final byte[] skReader; + + /** {@code stepup_reader_counter} per §8.4.3 + mdoc [6] §9.1.1.5: + * 32-bit big-endian counter, initialised to {@code 0x00000001} on + * session entry, incremented after each successful reader-side decrypt. + * Shared across ENVELOPE and EXCHANGE (both reader->device). */ + final byte[] readerCounter; + + /** {@code stepup_device_counter} per §8.4.3 + mdoc [6] §9.1.1.5: + * 32-bit big-endian counter for the device->reader direction, init + * to {@code 0x00000001}, incremented after each successful encrypt. */ + final byte[] deviceCounter; + + StepUpSession() { + skDevice = JCSystem.makeTransientByteArray(SK_LEN, JCSystem.CLEAR_ON_DESELECT); + skReader = JCSystem.makeTransientByteArray(SK_LEN, JCSystem.CLEAR_ON_DESELECT); + readerCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT); + deviceCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT); + } + + /** + * Re-initialise both counters to {@code 0x00000001}. Called from + * {@link StepUpApplet#select()} whenever the SELECT lands armed. + * + *

    Keys are NOT zeroed: CLEAR_ON_DESELECT handles that on deselect, + * and the surrounding SELECT immediately re-derives them from a fresh + * StepUpSK anyway. + */ + void reset() { + Util.arrayFillNonAtomic(readerCounter, (short) 0, COUNTER_LEN, (byte) 0); + readerCounter[3] = (byte) 0x01; + Util.arrayFillNonAtomic(deviceCounter, (short) 0, COUNTER_LEN, (byte) 0); + deviceCounter[3] = (byte) 0x01; + } + + /** + * Write the 12-byte reader-side IV at {@code out[outOff..outOff+12]}: + * {@code 0x00 * 8 || stepup_reader_counter (4B BE)} per spec §8.3.1.8/9. + */ + void readerIv(byte[] out, short outOff) { + Util.arrayFillNonAtomic(out, outOff, (short) 8, (byte) 0); + Util.arrayCopyNonAtomic(readerCounter, (short) 0, + out, (short) (outOff + 8), COUNTER_LEN); + } + + /** + * Write the 12-byte device-side IV at {@code out[outOff..outOff+12]}: + * {@code 0x00 * 7 || 0x01 || stepup_device_counter (4B BE)} per spec §8.3.1.6. + */ + void deviceIv(byte[] out, short outOff) { + Util.arrayFillNonAtomic(out, outOff, (short) 7, (byte) 0); + out[(short) (outOff + 7)] = (byte) 0x01; + Util.arrayCopyNonAtomic(deviceCounter, (short) 0, + out, (short) (outOff + 8), COUNTER_LEN); + } + + /** stepup_reader_counter += 1 (32-bit BE, wraps mod 2^32). */ + void advanceReaderCounter() { + incrementCounter(readerCounter, (short) 0); + } + + /** stepup_device_counter += 1 (32-bit BE, wraps mod 2^32). */ + void advanceDeviceCounter() { + incrementCounter(deviceCounter, (short) 0); + } + + /** 32-bit big-endian counter increment with carry across all 4 bytes. + * Wraps mod 2^32; spec §8.3.3.5.4 says the counter SHALL never reach + * the limit, so wrap is unreachable in normal protocol flow. */ + private static void incrementCounter(byte[] buf, short off) { + for (short i = (short) (off + 3); i >= off; i--) { + buf[i]++; + if (buf[i] != 0) return; + } + } +} diff --git a/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java b/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java new file mode 100644 index 0000000..14c0c4a --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java @@ -0,0 +1,349 @@ +package com.dangerousthings.aliro; + +import javacard.framework.ISO7816; +import javacard.framework.ISOException; + +/** + * Structural CBOR utilities for the Aliro Access Document / DeviceResponse + * walk. CBOR is defined by RFC 8949; we implement only the canonical subset + * actually used by Aliro / ISO 18013-5 mdoc messages: + * + *

    + * + *

    All rejection paths throw {@code ISOException(SW_DATA_INVALID = 0x6984)}, + * matching the codebase's "fail closed on malformed input" convention + * (see {@link PersonalizationApplet} and {@link CoseVerifier}). + * + *

    M2B.1 implements {@link #decodeHeader}; M2B.2 adds {@link #elementSpan}. + * IssuerAuth location and canonical encoders land in M2B.3/M2B.4. + */ +final class StructuralCbor { + + private StructuralCbor() { + // Utility class — no instances. + } + + /** + * Decodes a single CBOR header at {@code buf[bufOff..bufOff+bufLen)} per + * RFC 8949 §3. + * + *

    The header byte's top 3 bits are the major type and the bottom 5 + * bits are the additional info; values 0..23 are the immediate argument, + * 24/25/26 mean a 1/2/4-byte big-endian uint argument follows. All + * other additional-info values are rejected. + * + *

    Result encoding (single short, lazy): + *

    + * The argument value is written big-endian into + * {@code argOut[argOff..argOff+4)}. For arguments smaller than 4 bytes + * the result is zero-extended at the high end (so the caller can always + * read 4 bytes BE and get the correct integer). + * + * @param buf buffer holding the CBOR stream + * @param bufOff offset of the header byte + * @param bufLen number of valid bytes remaining at {@code bufOff} + * @param argOut destination for the 4-byte big-endian argument + * @param argOff offset within {@code argOut} to write the argument + * @return packed (major type << 8) | bytesConsumed + * @throws ISOException SW_DATA_INVALID on any of: empty buffer, truncated + * argument, major type 7, indefinite length, reserved + * additional info (28..30), or 8-byte argument (27). + */ + static short decodeHeader( + byte[] buf, short bufOff, short bufLen, + byte[] argOut, short argOff) { + + if (bufLen < 1) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + + byte b0 = buf[bufOff]; + short major = (short) ((b0 >> 5) & 0x07); + short addInfo = (short) (b0 & 0x1F); + + // Major type 7 (floats / null / true / false / break) — out of + // scope for Aliro mdoc which uses only the data-bearing major + // types 0..6. + if (major == 7) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + + // Zero-extend the 4-byte big-endian argument slot up front. For + // immediate / 1B / 2B arguments the upper bytes must read as 0. + argOut[argOff] = (byte) 0; + argOut[(short) (argOff + 1)] = (byte) 0; + argOut[(short) (argOff + 2)] = (byte) 0; + argOut[(short) (argOff + 3)] = (byte) 0; + + short consumed; + if (addInfo <= 23) { + // Immediate argument: the additional-info bits ARE the value. + argOut[(short) (argOff + 3)] = (byte) addInfo; + consumed = 1; + } else if (addInfo == 24) { + // 1-byte uint argument follows. + if (bufLen < 2) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 1)]; + consumed = 2; + } else if (addInfo == 25) { + // 2-byte big-endian uint argument follows. + if (bufLen < 3) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + argOut[(short) (argOff + 2)] = buf[(short) (bufOff + 1)]; + argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 2)]; + consumed = 3; + } else if (addInfo == 26) { + // 4-byte big-endian uint argument follows. + if (bufLen < 5) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + argOut[argOff] = buf[(short) (bufOff + 1)]; + argOut[(short) (argOff + 1)] = buf[(short) (bufOff + 2)]; + argOut[(short) (argOff + 2)] = buf[(short) (bufOff + 3)]; + argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 4)]; + consumed = 5; + } else { + // additional info 27 (8-byte arg), 28..30 (reserved), 31 + // (indefinite length) — all rejected. + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + return 0; // unreachable; satisfies javac. + } + + return (short) ((major << 8) | consumed); + } + + /** + * Returns the total byte length of the CBOR data item that begins at + * {@code buf[bufOff]}, recursing into arrays / maps / tags so the result + * spans the complete (possibly nested) element. + * + *

    By major type (per RFC 8949 §3): + *

    + * + *

    Implementation is recursive. Worst-case Aliro mdoc nesting depth is + * ~5 (DeviceResponse map → documents array → entry map → issuerSigned map + * → issuerAuth array), so the JC stack budget is comfortable. Length / + * count fields use the 4B big-endian argument written into + * {@code argScratch[argScratchOff..argScratchOff+4)} by {@code decodeHeader}. + * The scratch is overwritten at every recursive descent — that's fine + * because the parent has already consumed its argument before recursing. + * + * @param buf buffer holding the CBOR stream + * @param bufOff offset of the first byte of the element + * @param bufLen number of valid bytes remaining at {@code bufOff} + * @param argScratch 4-byte scratch for {@code decodeHeader}'s argument + * @param argScratchOff offset within {@code argScratch} (4 bytes needed) + * @return total bytes the element (including children) occupies + * @throws ISOException SW_DATA_INVALID on malformed input — either + * {@link #decodeHeader} rejected a header, the + * declared bstr / tstr payload runs past {@code bufLen}, + * or a child element runs past the parent's bounds. + */ + static short elementSpan( + byte[] buf, short bufOff, short bufLen, + byte[] argScratch, short argScratchOff) { + + short header = decodeHeader(buf, bufOff, bufLen, argScratch, argScratchOff); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + + // 4-byte big-endian read of the argument decodeHeader just wrote. + // Aliro mdoc payloads fit in a short-sized buffer, so we cap argument + // values at Short.MAX_VALUE when used as a length / count; any larger + // value is malformed input and rejected. + short argument = readArgAsShort(argScratch, argScratchOff); + + // Span starts with the header bytes; children (if any) extend it. + short span = consumed; + + if (major == 0 || major == 1) { + // uint / nint — header is the whole element. + return span; + } + if (major == 2 || major == 3) { + // bstr / tstr — header + payload bytes. + short total = (short) (span + argument); + // Reject if the declared payload extends past bufLen, otherwise a + // truncated bstr would silently report a span larger than the + // available buffer and confuse later callers. + if (argument < 0 || total < 0 || total > bufLen) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + return total; + } + if (major == 4) { + // Array of `argument` children. + for (short i = 0; i < argument; i++) { + short childOff = (short) (bufOff + span); + short childLen = (short) (bufLen - span); + if (childLen <= 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short childSpan = elementSpan( + buf, childOff, childLen, argScratch, argScratchOff); + span = (short) (span + childSpan); + } + return span; + } + if (major == 5) { + // Map of `argument` key/value pairs — 2 * argument items. + short items = (short) (argument << 1); + for (short i = 0; i < items; i++) { + short childOff = (short) (bufOff + span); + short childLen = (short) (bufLen - span); + if (childLen <= 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short childSpan = elementSpan( + buf, childOff, childLen, argScratch, argScratchOff); + span = (short) (span + childSpan); + } + return span; + } + // major == 6 (tag) — header + single tagged element. + short childOff = (short) (bufOff + span); + short childLen = (short) (bufLen - span); + if (childLen <= 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short childSpan = elementSpan( + buf, childOff, childLen, argScratch, argScratchOff); + return (short) (span + childSpan); + } + + /** + * Writes a canonical CBOR unsigned integer (major type 0) into + * {@code out[outOff..)}. + * + *

    Picks the shortest argument size that fits per RFC 8949 §4.2.1: + *

    + * Argument type is {@code int} because the 4-byte form covers the full + * unsigned 32-bit range; {@code >>>} is used so a value with the sign bit + * set still serializes correctly. Negative values are rejected because + * Aliro mdoc CBOR never uses negative integers (those would be major + * type 1, which we don't emit). + * + * @return number of bytes written + * @throws ISOException SW_DATA_INVALID if {@code value} is negative. + */ + static short encodeUint(int value, byte[] out, short outOff) { + return encodeTypeAndArg((short) 0, value, out, outOff); + } + + /** + * Writes a canonical CBOR byte-string header (major type 2) for a payload + * of {@code len} bytes into {@code out[outOff..)}. + * + *

    Same argument-size rules as {@link #encodeUint}. {@code short} arg is + * sufficient: Aliro mdoc payloads are short-bounded. Negative lengths are + * rejected as malformed input. + * + * @return number of bytes written (the header only — caller appends payload) + * @throws ISOException SW_DATA_INVALID if {@code len} is negative. + */ + static short encodeBstrHeader(short len, byte[] out, short outOff) { + return encodeTypeAndArg((short) 2, len, out, outOff); + } + + /** + * Writes a canonical CBOR text-string header (major type 3) for a payload + * of {@code len} UTF-8 bytes into {@code out[outOff..)}. Same shape as + * {@link #encodeBstrHeader}. + * + * @return number of bytes written (the header only — caller appends payload) + * @throws ISOException SW_DATA_INVALID if {@code len} is negative. + */ + static short encodeTstrHeader(short len, byte[] out, short outOff) { + return encodeTypeAndArg((short) 3, len, out, outOff); + } + + /** + * Shared backend for {@link #encodeUint} / {@link #encodeBstrHeader} / + * {@link #encodeTstrHeader}. Picks the shortest argument size that fits + * {@code value}, writes the header byte (major-type bits 7..5, additional + * info bits 4..0), then the big-endian argument bytes. + */ + private static short encodeTypeAndArg(short major, int value, byte[] out, short outOff) { + if (value < 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + byte mt = (byte) (major << 5); + if (value <= 23) { + // Immediate argument — additional info bits hold the value. + out[outOff] = (byte) (mt | value); + return 1; + } + if (value <= 0xFF) { + // 1-byte argument (additional info 24). + out[outOff] = (byte) (mt | 24); + out[(short) (outOff + 1)] = (byte) value; + return 2; + } + if (value <= 0xFFFF) { + // 2-byte big-endian argument (additional info 25). + out[outOff] = (byte) (mt | 25); + out[(short) (outOff + 1)] = (byte) (value >>> 8); + out[(short) (outOff + 2)] = (byte) value; + return 3; + } + // 4-byte big-endian argument (additional info 26). >>> keeps it unsigned + // so values >= 0x80000000 (impossible here because `value < 0` is + // already rejected) would still serialize correctly via the 4-byte form. + out[outOff] = (byte) (mt | 26); + out[(short) (outOff + 1)] = (byte) (value >>> 24); + out[(short) (outOff + 2)] = (byte) (value >>> 16); + out[(short) (outOff + 3)] = (byte) (value >>> 8); + out[(short) (outOff + 4)] = (byte) value; + return 5; + } + + /** + * Reads the 4-byte big-endian argument {@code decodeHeader} wrote into + * the scratch and returns it as a short. Argument values larger than + * {@code Short.MAX_VALUE} (32767) are out of buffer range for Aliro mdoc + * payloads and rejected as malformed input. + */ + private static short readArgAsShort(byte[] scratch, short off) { + short hi = (short) (((scratch[off] & 0xFF) << 8) + | (scratch[(short) (off + 1)] & 0xFF)); + if (hi != 0) { + // 32-bit argument with any of the upper 16 bits set won't fit + // a short used as length / count. + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short v = (short) (((scratch[(short) (off + 2)] & 0xFF) << 8) + | (scratch[(short) (off + 3)] & 0xFF)); + if (v < 0) { + // Top bit set = value > Short.MAX_VALUE. + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + return v; + } +} diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java index 56d7a0a..460127f 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,22 +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)); + 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().finalizeAccessDocument((short) ad.length)); + CredentialStore.get().writeAccessDocumentChunk(ad, (short) 0, (short) 0, (short) ad.length)); + CredentialStore.get().markAccessDocumentFinalizedForTesting((short) ad.length); + CredentialStore.get().markAccessDocumentVerifiedForTesting(); sendStandardAuth0(); ResponseAPDU r = sendValidAuth1(); @@ -200,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 diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java index 3ee363d..40ff8a4 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java @@ -95,12 +95,12 @@ class AliroAppletTest { @Test void auth1ExchangeInsRejectedNowThatStepUpAppletOwnsIt() { - // Once M1B.1 / M1C.1 land StepUpApplet (ACCE5502) as the proper owner - // of INS_EXCHANGE (0xC9), AliroApplet must NOT also answer 0xC9 — a - // spec-conformant reader routes EXCHANGE to ACCE5502 after the §10.2 - // step-up AID SELECT. AliroApplet receiving 0xC9 is a reader bug or - // a stale flow, and must be rejected with SW_INS_NOT_SUPPORTED so it - // can never accidentally interact with expedited session state. + // StepUpApplet (ACCE5502) owns INS_EXCHANGE (0xC9); AliroApplet must + // NOT also answer 0xC9 — a spec-conformant reader routes EXCHANGE to + // ACCE5502 after the §10.2 step-up AID SELECT. AliroApplet receiving + // 0xC9 is a reader bug or a stale flow, and must be rejected with + // SW_INS_NOT_SUPPORTED so it can never accidentally interact with + // expedited session state. selectExpedited(); CommandAPDU exchange = new CommandAPDU(0x80, 0xC9, 0x00, 0x00); ResponseAPDU resp = sim.transmitCommand(exchange); diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java b/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java index 4a77ad2..4e97449 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java @@ -191,6 +191,65 @@ class AliroGcmTest { "different keys must produce different tags"); } + /** + * Rekey-across-encrypts: M2D.3's stream-encrypt path calls + * {@link AliroGcm#encrypt} twice in a single session (once for the + * EXCHANGE Reader Status ack, once for the ENVELOPE DeviceResponse) with + * a different (key, IV) pair each time. This test pins that no leftover + * state from the first call (cached H, M-table, AES key slot) corrupts + * the second. M2C.2 extracts the rekey path into {@code setKeyAndIv}; + * this test is the regression spec for that refactor. + */ + @Test + void setKeyAndIvAllowsRekeyAcrossMultipleEncrypts() { + AliroGcm gcm = new AliroGcm(); + + // Encrypt block A under key1 + iv1 + byte[] keyA = makeKey((byte) 0xA0); + byte[] ivA = makeIv((byte) 0xAA); + byte[] ptA = bytes("hello A"); + byte[] outA = new byte[ptA.length + 16]; + gcm.encrypt(keyA, (short) 0, ivA, (short) 0, + ptA, (short) 0, (short) ptA.length, outA, (short) 0); + + // Rekey + encrypt block B under key2 + iv2 + byte[] keyB = makeKey((byte) 0xB0); + byte[] ivB = makeIv((byte) 0xBB); + byte[] ptB = bytes("hello B"); + byte[] outB = new byte[ptB.length + 16]; + gcm.encrypt(keyB, (short) 0, ivB, (short) 0, + ptB, (short) 0, (short) ptB.length, outB, (short) 0); + + // Both must round-trip cleanly through decrypt under their own key+iv + byte[] ptAroundTrip = new byte[ptA.length]; + gcm.decrypt(keyA, (short) 0, ivA, (short) 0, + outA, (short) 0, (short) outA.length, ptAroundTrip, (short) 0); + assertArrayEquals(ptA, ptAroundTrip, + "block A must decrypt under (keyA, ivA) after gcm has been re-keyed to B"); + + byte[] ptBroundTrip = new byte[ptB.length]; + gcm.decrypt(keyB, (short) 0, ivB, (short) 0, + outB, (short) 0, (short) outB.length, ptBroundTrip, (short) 0); + assertArrayEquals(ptB, ptBroundTrip, + "block B must decrypt under (keyB, ivB) — no leftover state from A"); + } + + private static byte[] makeKey(byte seed) { + byte[] k = new byte[32]; + for (int i = 0; i < 32; i++) k[i] = (byte) (seed + i); + return k; + } + + private static byte[] makeIv(byte seed) { + byte[] iv = new byte[12]; + for (int i = 0; i < 12; i++) iv[i] = (byte) (seed + i); + return iv; + } + + private static byte[] bytes(String s) { + return s.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + @Test void differentIvsProduceDifferentCiphertexts() { byte[] key = new byte[32]; diff --git a/applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java b/applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java new file mode 100644 index 0000000..892ed36 --- /dev/null +++ b/applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java @@ -0,0 +1,86 @@ +package com.dangerousthings.aliro; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link CoseVerifier} — RFC 9052 COSE_Sign1 verification over + * ECDSA P-256 + SHA-256, used to authenticate the Aliro Access Document + * IssuerAuth signature. + * + *

    Test vector generated once by harness/src/aliro_harness/issuer/cose.py + * using a deterministic private key seed ("M2A.2-cose-verifier-test-key!!!_" + * reduced mod n) and the literal payload "M2A.2 known-good payload". + * Self-verified via verify_cose_sign1 before being copied here. + * + *

    ECDSA k is randomised inside the python {@code cryptography} library; + * regenerating the vector would produce a different signature. The hex + * constants below are the canonical M2A.2 known-answer pair. + */ +class CoseVerifierTest { + + /** secp256r1 public key, uncompressed SEC1 (0x04 || X(32) || Y(32)). */ + private static final byte[] ISSUER_PUB_UNCOMP = hex( + "04d28f7bbdb8bc7c4d1bbcb7b76e6bdffaa4994f978f316df8d5341bf164e36b82" + + "76f90f72b5680bfd21901de452ea133f35ef44fd34e5b14ddb9a7e14ca700a0a"); + + /** + * COSE_Sign1 wire bytes (CBOR-encoded 4-element array) for the test + * vector above. Layout: + *

    +     *   [  0] 0x84            outer array(4)
    +     *   [  1] 0x43            protected bstr header (len 3)
    +     *   [  2..4] a1 01 26     {1: -7} → ES256 alg id
    +     *   [  5] 0xa1            unprotected map(1)
    +     *   [  6] 0x04            key 4 (kid)
    +     *   [  7] 0x48            bstr(8)
    +     *   [  8..15]             kid 01 02 03 04 05 06 07 08
    +     *   [ 16] 0x58            payload bstr 1-byte len
    +     *   [ 17] 0x18            len = 24
    +     *   [ 18..41]             "M2A.2 known-good payload" (24 bytes)
    +     *   [ 42] 0x58            sig bstr 1-byte len
    +     *   [ 43] 0x40            len = 64
    +     *   [ 44..107]            raw r||s ECDSA-P256 signature (64 bytes)
    +     * 
    + */ + private static final byte[] COSE_SIGN1_GOOD = hex( + "8443a10126a1044801020304050607085818" + + "4d32412e32206b6e6f776e2d676f6f64207061796c6f6164" + + "5840" + + "02e1c7996e16b3c47c020d7894f38f2c3850abc10a1a53dd56051c80412ba203" + + "447598813bab380e0d286dc55985c24d123a7bc98f1f31419679d4f2dde0e916"); + + @Test + void verifyCoseSign1_validSignature_returnsTrue() { + CoseVerifier verifier = new CoseVerifier(); + boolean ok = verifier.verifyCoseSign1( + COSE_SIGN1_GOOD, (short) 0, (short) COSE_SIGN1_GOOD.length, + ISSUER_PUB_UNCOMP, (short) 0); + assertTrue(ok, "known-good COSE_Sign1 must verify against issuer pubkey"); + } + + @Test + void verifyCoseSign1_tamperedPayload_returnsFalse() { + // Flip one bit in the payload region (offset 25 = mid-payload). + byte[] tampered = new byte[COSE_SIGN1_GOOD.length]; + System.arraycopy(COSE_SIGN1_GOOD, 0, tampered, 0, COSE_SIGN1_GOOD.length); + tampered[25] ^= (byte) 0x01; + + CoseVerifier verifier = new CoseVerifier(); + boolean ok = verifier.verifyCoseSign1( + tampered, (short) 0, (short) tampered.length, + ISSUER_PUB_UNCOMP, (short) 0); + assertFalse(ok, "tampered payload must NOT verify"); + } + + 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; + } +} diff --git a/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java b/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java index cf14835..ea3ae01 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java @@ -30,6 +30,7 @@ class CredentialStoreSerializationTest { private static final byte[] KNOWN_32_BYTES = makeRange(32, 0xA0); private static final byte[] KNOWN_64_BYTES_B = makeRange(64, 0x80); + private static final byte[] KNOWN_64_BYTES_ISSUER = makeRange(64, 0x40); private static byte[] makeRange(int len, int start) { byte[] out = new byte[len]; @@ -51,6 +52,8 @@ class CredentialStoreSerializationTest { src.setCredentialPrivKey(KNOWN_32_BYTES, (short) 0); // Intentionally do NOT setCredentialPubKey: leave credentialPubKeySet=false. src.setReaderPubKey(KNOWN_64_BYTES_B, (short) 0); + // Exercise the new credentialIssuerPubKey slot (M2A.3, FIELD_VERSION=3). + src.setCredentialIssuerPubKey(KNOWN_64_BYTES_ISSUER, (short) 0); // Intentionally do NOT finalizeAccessDocument: leave accessDocumentFinalized=false // and accessDocumentLen=0. With len=0, copyAccessDocument() returns an empty array. src.commit(); @@ -64,6 +67,15 @@ class CredentialStoreSerializationTest { assertArrayEquals(KNOWN_32_BYTES, restored.copyCredentialPrivKey()); assertArrayEquals(KNOWN_64_BYTES_B, restored.copyReaderPubKey()); + // Issuer pubkey round-trips as 0x04 || x || y (uncompressed SEC1). + byte[] expectedIssuerUncomp = new byte[65]; + expectedIssuerUncomp[0] = 0x04; + System.arraycopy(KNOWN_64_BYTES_ISSUER, 0, expectedIssuerUncomp, 1, 64); + byte[] gotIssuerUncomp = new byte[65]; + restored.copyCredentialIssuerPubKeyUncomp(gotIssuerUncomp, (short) 0); + assertArrayEquals(expectedIssuerUncomp, gotIssuerUncomp); + assertTrue(restored.hasCredentialIssuerPubKey()); + // The unset credential pubkey buffer should round-trip as all-zero — // resetForTesting cleared it and we never wrote to it. assertArrayEquals(new byte[CredentialStore.CRED_PUBK_LEN], @@ -81,6 +93,20 @@ class CredentialStoreSerializationTest { assertFalse(restored.hasAccessDocument()); } + @Test + void accessDocumentVerifiedRoundTripsThroughSerialize() { + CredentialStore s1 = CredentialStore.bootstrap(); + s1.resetForTesting(); + s1.markAccessDocumentVerifiedForTesting(); + + RecordingSink buf = new RecordingSink(); + s1.writeTo(buf); + CredentialStore s2 = CredentialStore.readFrom(buf.toSource()); + + assertTrue(s2.isAccessDocumentVerified(), + "verified flag must survive serialize/deserialize round-trip"); + } + /** * Forges a payload with a bogus FIELD_VERSION byte and asserts that * {@link CredentialStore#readFrom} rejects it with @@ -91,7 +117,7 @@ class CredentialStoreSerializationTest { @Test void readFromRejectsWrongFieldVersion() { RecordingSink buf = new RecordingSink(); - buf.write((byte) 0x7F); // != FIELD_VERSION (== 1) + buf.write((byte) 0x7F); // != FIELD_VERSION ISOException ex = assertThrows(ISOException.class, () -> CredentialStore.readFrom(buf.toSource())); diff --git a/applet/src/test/java/com/dangerousthings/aliro/DeviceRequestParserTest.java b/applet/src/test/java/com/dangerousthings/aliro/DeviceRequestParserTest.java new file mode 100644 index 0000000..ba59574 --- /dev/null +++ b/applet/src/test/java/com/dangerousthings/aliro/DeviceRequestParserTest.java @@ -0,0 +1,91 @@ +package com.dangerousthings.aliro; + +import javacard.framework.ISO7816; +import javacard.framework.ISOException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link DeviceRequestParser} — structural validation of the inbound + * mdoc DeviceRequest (ISO 18013-5 §8.3.2.1). + * + *

    M2D's ENVELOPE handler receives a DeviceRequest, must confirm its shape + * before discarding the contents (we always return our single Access Document), + * and reject malformed input with the canonical SW codes: + *

    + * + *

    Test vectors were generated via cbor2 (canonical encoding); see the + * generator script in the M2D.1 plan. The Python equivalents: + *

    + *   VALID         = cbor2.dumps({"version":"1.0","docRequests":[{"itemsRequest":cbor2.dumps({"docType":"org.iso.18013.5.1.mDL","nameSpaces":{}})}]}, canonical=True)
    + *   BAD_VERSION   = cbor2.dumps({"version":"2.0","docRequests":[{"itemsRequest":b"\x40"}]}, canonical=True)
    + *   TRUNCATED     = VALID[:8]
    + * 
    + */ +class DeviceRequestParserTest { + + /** + * Canonical CBOR for a minimal-but-valid DeviceRequest: + * {@code {"version":"1.0","docRequests":[{"itemsRequest":}]}} + * with itemsRequest containing an inner CBOR + * {@code {"docType":"org.iso.18013.5.1.mDL","nameSpaces":{}}}. + */ + private static final byte[] VALID = hex( + "a26776657273696f6e63312e306b646f63526571756573747381" + + "a16c6974656d7352657175657374582ba267646f6354797065" + + "756f72672e69736f2e31383031332e352e312e6d444c" + + "6a6e616d65537061636573a0"); + + /** Same shape as VALID but version tstr value is "2.0". */ + private static final byte[] BAD_VERSION = hex( + "a26776657273696f6e63322e306b646f63526571756573747381" + + "a16c6974656d73526571756573744140"); + + /** First 8 bytes of VALID — truncated mid-key, malformed CBOR. */ + private static final byte[] TRUNCATED = hex("a26776657273696f"); + + @Test + void parseDeviceRequest_validShape_returnsOk() { + byte[] scratch = new byte[4]; + // Should not throw. + DeviceRequestParser.validate( + VALID, (short) 0, (short) VALID.length, + scratch, (short) 0); + } + + @Test + void parseDeviceRequest_unknownVersion_throwsConditionsNotSatisfied() { + byte[] scratch = new byte[4]; + ISOException ex = assertThrows(ISOException.class, () -> + DeviceRequestParser.validate( + BAD_VERSION, (short) 0, (short) BAD_VERSION.length, + scratch, (short) 0)); + assertEquals(ISO7816.SW_CONDITIONS_NOT_SATISFIED, ex.getReason(), + "unknown version must throw SW_CONDITIONS_NOT_SATISFIED (0x6985)"); + } + + @Test + void parseDeviceRequest_malformedCbor_throwsDataInvalid() { + byte[] scratch = new byte[4]; + ISOException ex = assertThrows(ISOException.class, () -> + DeviceRequestParser.validate( + TRUNCATED, (short) 0, (short) TRUNCATED.length, + scratch, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason(), + "truncated CBOR must throw SW_DATA_INVALID (0x6984)"); + } + + 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; + } +} diff --git a/applet/src/test/java/com/dangerousthings/aliro/DeviceResponseBuilderTest.java b/applet/src/test/java/com/dangerousthings/aliro/DeviceResponseBuilderTest.java new file mode 100644 index 0000000..4a2cfe1 --- /dev/null +++ b/applet/src/test/java/com/dangerousthings/aliro/DeviceResponseBuilderTest.java @@ -0,0 +1,147 @@ +package com.dangerousthings.aliro; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for {@link DeviceResponseBuilder} — emits the mdoc DeviceResponse + * (ISO 18013-5 §8.3.2.1.2.2 / Aliro Table 8-22) carrying a cached Access + * Document at {@code documents[0].issuerSigned.issuerAuth}. + * + *

    The reference {@link #DEVICE_RESPONSE_HEX} was generated against the + * real Access Document at {@code /home/work/aliro-trust/access_document.bin} + * via a manual canonical-CBOR builder (NOT {@code cbor2.dumps(canonical=True)} + * on the whole structure — that would re-encode the inner COSE_Sign1 and the + * applet doesn't re-encode it; the AD bytes are embedded verbatim). See the + * M2D.2 plan for the generator script. + * + *

    Canonical key ordering at each map level (RFC 8949 §4.2.1: sort by + * encoded-key-length then lexicographically): + *

      + *
    • DeviceResponse: {@code "status"} (7B), {@code "version"} (8B), + * {@code "documents"} (11B)
    • + *
    • document: {@code "docType"} (8B), {@code "issuerSigned"} (13B)
    • + *
    • issuerSigned: {@code "issuerAuth"} (11B), {@code "nameSpaces"} (11B, + * 'i' < 'n' so issuerAuth first)
    • + *
    + */ +class DeviceResponseBuilderTest { + + /** + * Real Access Document from {@code /home/work/aliro-trust/access_document.bin} + * — a 272-byte COSE_Sign1 (4-element array, tag-less per Aliro Table 8-22's + * literal "COSE_Sign1" embedding). + */ + private static final byte[] ACCESS_DOC_HEX = hex( + "8443a10126a104488dae9624eed9280c58bca7613163312e30613267534" + + "8412d3235366133a06134a16131a401022001215820aa3115ead5d1fec" + + "ca289aef3598790a6dba23edbe9b14e6818ac683e31a5af0222582083" + + "9dcc32bcd32924a942c3b9999f6cbf46960396e69606fe4295e83a0c7" + + "787a2613567616c69726f2d616136a36131c074323032362d30342d3" + + "1395432303a32393a31365a6132c074323032362d30342d3139543230" + + "3a32393a31365a6133c074323032372d30342d31395432303a32393a3" + + "1365a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac436" + + "44e92c8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d" + + "38df6f1c5b1caf685c365b22a3f2a"); + + /** + * Expected DeviceResponse bytes for {@link #ACCESS_DOC_HEX}. 372 bytes + * total (272 AD + 100 B wrapper). + */ + private static final byte[] DEVICE_RESPONSE_HEX = hex( + "a366737461747573006776657273696f6e63312e3069646f63756d656e" + + "747381a267646f6354797065756f72672e69736f2e31383031332e352e" + + "312e6d444c6c6973737565725369676e6564a26a697373756572417574" + + "688443a10126a104488dae9624eed9280c58bca7613163312e30613267" + + "5348412d3235366133a06134a16131a401022001215820aa3115ead5d1" + + "fecca289aef3598790a6dba23edbe9b14e6818ac683e31a5af02225820" + + "839dcc32bcd32924a942c3b9999f6cbf46960396e69606fe4295e83a0c" + + "7787a2613567616c69726f2d616136a36131c074323032362d30342d31" + + "395432303a32393a31365a6132c074323032362d30342d31395432303a" + + "32393a31365a6133c074323032372d30342d31395432303a32393a3136" + + "5a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac43644e9" + + "2c8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d38df6" + + "f1c5b1caf685c365b22a3f2a6a6e616d65537061636573a0"); + + @Test + void buildDeviceResponse_outputBytes_matchPythonReference() { + // Buffer sized generously — actual is 372 B for a 272 B AD. + byte[] out = new byte[ACCESS_DOC_HEX.length + 128]; + short written = DeviceResponseBuilder.build( + ACCESS_DOC_HEX, (short) 0, (short) ACCESS_DOC_HEX.length, + out, (short) 0); + + assertEquals(DEVICE_RESPONSE_HEX.length, written, + "DeviceResponse byte count must match Python canonical-CBOR reference"); + assertArrayEquals( + DEVICE_RESPONSE_HEX, + Arrays.copyOfRange(out, 0, written), + "DeviceResponse bytes must match Python canonical-CBOR reference byte-for-byte"); + } + + /** + * Decodes the builder output and confirms the spec shape: top-level + * 3-entry map, single document with docType + issuerSigned, issuerSigned + * has empty nameSpaces and the AD embedded verbatim at issuerAuth. + * + *

    Uses {@link StructuralCbor#elementSpan} to walk — that's the same + * tooling M2D.3 will use on the receiving side, so a passing structural + * walk here also confirms the output is parseable by our own parser. + */ + @Test + void buildDeviceResponse_shape_matchesAliroTable8_22() { + byte[] out = new byte[ACCESS_DOC_HEX.length + 128]; + short written = DeviceResponseBuilder.build( + ACCESS_DOC_HEX, (short) 0, (short) ACCESS_DOC_HEX.length, + out, (short) 0); + + byte[] scratch = new byte[4]; + + // Whole-response span must equal `written` — no trailing garbage. + short span = StructuralCbor.elementSpan( + out, (short) 0, written, scratch, (short) 0); + assertEquals(written, span, + "DeviceResponse span must consume exactly the written bytes"); + + // Top-level: map(3) — major type 5, additional info 3. + assertEquals((byte) 0xA3, out[0], + "top-level must be a 3-entry CBOR map (0xA3)"); + + // Locate the AD inside the output and confirm it's byte-identical. + // Brute-force scan for the AD's first byte (0x84 — COSE_Sign1 array + // header) and verify the run matches. Only one such run should exist + // because the wrapper itself doesn't contain that byte sequence. + int adOffset = indexOf(out, 0, written, ACCESS_DOC_HEX); + assertEquals(true, adOffset >= 0, + "Access Document bytes must appear verbatim somewhere in the output"); + assertArrayEquals( + ACCESS_DOC_HEX, + Arrays.copyOfRange(out, adOffset, adOffset + ACCESS_DOC_HEX.length), + "Embedded Access Document must be byte-identical to the input"); + } + + /** Naive byte-array indexOf — sufficient for the small test buffers. */ + private static int indexOf(byte[] haystack, int from, int to, byte[] needle) { + outer: + for (int i = from; i <= to - needle.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (haystack[i + j] != needle[j]) continue outer; + } + return i; + } + return -1; + } + + 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; + } +} diff --git a/applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java index 3204af7..679c0f6 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java @@ -25,8 +25,48 @@ class PersonalizationAppletTest { private static final byte INS_SET_READER_PUBK = (byte) 0x22; private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23; private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24; + private static final byte INS_SET_CRED_ISSUER_PUBK = (byte) 0x25; private static final byte INS_COMMIT = (byte) 0x2C; + /** + * Canonical Aliro Access Document COSE_Sign1 bytes (272 B) — generated + * by harness/aliro_harness.issuer.cose against the trustgen-issued + * issuer.pem. Layout is the bare 4-element COSE_Sign1 array (no wrapper); + * CoseVerifier.verifyCoseSign1 verifies these bytes verbatim against the + * uncompressed issuer pubkey below. + * + *

    Reproducibility: dump via + *

    +     *   python - <<'EOF'
    +     *   from pathlib import Path
    +     *   from cryptography.hazmat.primitives import serialization
    +     *   ad = Path("/home/work/aliro-trust/access_document.bin").read_bytes()
    +     *   iss = serialization.load_pem_private_key(
    +     *       Path("/home/work/aliro-trust/issuer.pem").read_bytes(), password=None)
    +     *   pub = iss.public_key().public_bytes(
    +     *       serialization.Encoding.X962,
    +     *       serialization.PublicFormat.UncompressedPoint)
    +     *   print(ad.hex()); print(pub[1:].hex())
    +     *   EOF
    +     * 
    + */ + private static final byte[] AD_GOOD = hex( + "8443a10126a104488dae9624eed9280c58bca7613163312e306132675348412d3235" + + "366133a06134a16131a401022001215820aa3115ead5d1fecca289aef3598790a6" + + "dba23edbe9b14e6818ac683e31a5af02225820839dcc32bcd32924a942c3b9999f" + + "6cbf46960396e69606fe4295e83a0c7787a2613567616c69726f2d616136a36131" + + "c074323032362d30342d31395432303a32393a31365a6132c074323032362d3034" + + "2d31395432303a32393a31365a6133c074323032372d30342d31395432303a3239" + + "3a31365a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac43644e92c" + + "8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d38df6f1c5b1caf6" + + "85c365b22a3f2a"); + + /** Issuer pubkey x||y (64 B) corresponding to ISSUER_PEM at trustgen time. + * This is what the applet INS_SET_CRED_ISSUER_PUBK accepts (no 0x04 prefix). */ + private static final byte[] ISSUER_PUBK_XY = hex( + "ebee35bacdfc585295da337b29b6f5e8b86d4056e22c793e4bf033e19e9ba31d" + + "6b8679bb41a64c4f7f8a37972b6315b4fe91248527c1487caf3a6fe31f75bbc4"); + private CardSimulator sim; @BeforeEach @@ -103,12 +143,14 @@ class PersonalizationAppletTest { @Test void accessDocumentChunkedWriteAndFinalizeRoundTrip() { - byte[] ad = new byte[420]; - for (int i = 0; i < ad.length; i++) ad[i] = (byte) ((i * 13) ^ 0xA5); + // Uses the canonical AD_GOOD vector because finalize now triggers + // IssuerAuth verify — random bytes wouldn't verify. The shape under + // test here is still the multi-chunk write path + final length. + assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW()); - // Two chunks: [0..200), [200..420) + byte[] ad = AD_GOOD; byte[] chunk1 = java.util.Arrays.copyOfRange(ad, 0, 200); - byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, 420); + byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, ad.length); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW()); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW()); assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, ad.length, null).getSW()); @@ -131,6 +173,9 @@ class PersonalizationAppletTest { @Test void accessDocumentFinalizeBeyondMaxSizeFails() { + // Set issuer pubkey so the precondition check passes — we want to + // test the length bound specifically, not the missing-pubkey path. + assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW()); byte[] chunk = new byte[10]; assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW()); int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1; @@ -185,6 +230,84 @@ class PersonalizationAppletTest { "Static publish-point must alias the instance-owned store"); } + @Test + void finalizeAccessDocument_validIssuerAuth_setsVerifiedFlag() { + // Push the issuer pubkey, write the AD, then finalize with the exact + // total length. The applet should run CoseVerifier internally, see + // the signature verify, and atomically flip both the finalized and + // verified flags. + assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW()); + + // Single chunk fits in a short-form APDU (255B max) — except AD is + // 272B. Split into two chunks like the orchestrator does. + byte[] chunk1 = java.util.Arrays.copyOfRange(AD_GOOD, 0, 200); + byte[] chunk2 = java.util.Arrays.copyOfRange(AD_GOOD, 200, AD_GOOD.length); + assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW()); + assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW()); + + assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, AD_GOOD.length, null).getSW(), + "finalize with valid IssuerAuth must succeed"); + + CredentialStore s = CredentialStore.get(); + org.junit.jupiter.api.Assertions.assertTrue(s.hasAccessDocument(), + "finalized flag must be set after successful verify"); + org.junit.jupiter.api.Assertions.assertTrue(s.isAccessDocumentVerified(), + "verified flag must be set after successful verify"); + } + + @Test + void finalizeAccessDocument_tamperedIssuerAuth_returnsErrorAndLeavesUnverified() { + assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW()); + + // Flip one byte in the payload region (around offset 30 — well + // inside the issuer-signed payload, away from outer CBOR headers). + byte[] tampered = new byte[AD_GOOD.length]; + System.arraycopy(AD_GOOD, 0, tampered, 0, AD_GOOD.length); + tampered[30] ^= (byte) 0x01; + + byte[] chunk1 = java.util.Arrays.copyOfRange(tampered, 0, 200); + byte[] chunk2 = java.util.Arrays.copyOfRange(tampered, 200, tampered.length); + assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW()); + assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW()); + + // Spec says verify failure → SW_DATA_INVALID (0x6984). + assertEquals(0x6984, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, tampered.length, null).getSW(), + "finalize with tampered IssuerAuth must return SW_DATA_INVALID"); + + CredentialStore s = CredentialStore.get(); + org.junit.jupiter.api.Assertions.assertFalse(s.isAccessDocumentVerified(), + "verified flag must stay false on tamper"); + org.junit.jupiter.api.Assertions.assertFalse(s.hasAccessDocument(), + "finalized flag must stay false on tamper — atomic store guards both"); + } + + @Test + void finalizeAccessDocument_missingIssuerPubkey_returnsConditionsNotSatisfied() { + // Skip the SET_CRED_ISSUER_PUBK step entirely. Even with a valid AD + // loaded, finalize cannot run verify without the trust anchor and + // must refuse with SW_CONDITIONS_NOT_SATISFIED. + byte[] chunk1 = java.util.Arrays.copyOfRange(AD_GOOD, 0, 200); + byte[] chunk2 = java.util.Arrays.copyOfRange(AD_GOOD, 200, AD_GOOD.length); + assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW()); + assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW()); + + assertEquals(0x6985, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, AD_GOOD.length, null).getSW(), + "finalize without an issuer pubkey must return SW_CONDITIONS_NOT_SATISFIED"); + + CredentialStore s = CredentialStore.get(); + org.junit.jupiter.api.Assertions.assertFalse(s.hasAccessDocument()); + org.junit.jupiter.api.Assertions.assertFalse(s.isAccessDocumentVerified()); + } + + private static byte[] hex(String s) { + s = s.replaceAll("\\s+", ""); + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16); + } + return out; + } + /** Reaches through jcardsim's protected runtime/applet APIs to fetch the * installed Applet instance for a given AID. Reflection-only — this is * test infrastructure, not production code. */ diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java index 502586c..be75a3d 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java @@ -145,21 +145,24 @@ class StepUpAppletTest { } /** - * After SELECT-Step-Up has armed {@code StepUpSKReader}, the X-CUBE-ALIRO - * firmware sends a "Reader Status sub-event" via the EXCHANGE command - * (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-14. The payload is - * AES-256-GCM encrypted with {@code StepUpSKReader}; IV layout from - * §8.3.1.8 is {@code 0x0000000000000000 || stepup_reader_counter (4B BE)}, - * with the counter session-bound and initialized to 1 per §8.4.3 (mdoc - * [6] §9.1.1.5 derivation). + * After SELECT-Step-Up has armed {@code StepUpSKReader}/{@code StepUpSKDevice}, + * the X-CUBE-ALIRO firmware sends a "Reader Status sub-event request" via + * the EXCHANGE command (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-19. * - *

    For Milestone 1 the applet only needs to decrypt-and-discard: tag - * verification proves the session keys match, then we return SW=9000 with - * empty payload. The real Step-Up "Reader Status response sub-event" - * (encrypted with StepUpSKDevice) lands in M2. + *

    M2 wire shape (plaintext under GCM): + *

    +     * sub_event_id : 1B   ; 0x01 = ReaderStatusRequest
    +     * payload_len  : 1B
    +     * payload      : Lb   ; empty for ReaderStatusRequest
    +     * 
    + * + *

    The applet validates the structure, then emits a Reader Status response + * sub-event (Table 8-20) plaintext {@code [sub_event_id=0x01, status=0x00, + * payload_len=0x00]}, GCM-encrypted under {@code StepUpSKDevice} + + * device IV (counter=1). Ciphertext+tag = 3 + 16 = 19 B; fits in one APDU. */ @Test - void exchangeAfterStepUpSelectDecryptsAndAcksWithEmptyPayload() throws Exception { + void exchangeReturnsEncryptedReaderStatusResponse() throws Exception { sim = new CardSimulator(); AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length); sim.installApplet(expeditedAid, AliroApplet.class); @@ -170,8 +173,6 @@ class StepUpAppletTest { ReaderSide reader = new ReaderSide(); reader.provision(sim, credentialKeyPair); - // SELECT expedited + run AUTH0 + AUTH1 -- mirrors the existing - // selectAfterArmedAuth1DerivesStepUpSessionKeys test. assertEquals(0x9000, sim.transmitCommand( new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(), "SELECT expedited must succeed"); @@ -188,58 +189,58 @@ class StepUpAppletTest { reader.buildAuth1Data(credentialEphemPubKey), 256)); assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed"); - // Compute the StepUpSKReader the card now holds. byte[] stepUpSK = java.util.Arrays.copyOfRange( reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96); byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader"); + byte[] stepUpSKDevice = hkdfStepUp(stepUpSK, "SKDevice"); - // SELECT the Step-Up AID -- arms StepUpApplet's StepUpSKReader and - // initializes its stepup_reader_counter session-bound to 0x00000001. + // SELECT the Step-Up AID -- arms session keys + initialises both + // counters to 0x00000001. assertEquals(0x9000, sim.transmitCommand( new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(), "SELECT step-up must succeed"); - // Reader-side encrypt of a 4-byte fake Reader Status payload under - // IV = 00 00 00 00 00 00 00 00 00 00 00 01 (8B zero prefix + counter=1). - byte[] iv = new byte[12]; - iv[11] = 0x01; - byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 }; - Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding"); - gcm.init(Cipher.ENCRYPT_MODE, + // Reader-side encrypt of the spec Reader Status request plaintext: + // sub_event_id=0x01, payload_len=0x00 (no payload bytes follow). + byte[] readerIv = new byte[12]; + readerIv[11] = 0x01; + byte[] requestPt = new byte[] { 0x01, 0x00 }; + Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding"); + gcmEnc.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(stepUpSKReader, "AES"), - new GCMParameterSpec(128, iv)); - byte[] ctAndTag = gcm.doFinal(plaintext); // 4 + 16 = 20 bytes - assertEquals(20, ctAndTag.length); + new GCMParameterSpec(128, readerIv)); + byte[] ctAndTag = gcmEnc.doFinal(requestPt); // 2 + 16 = 18 bytes ResponseAPDU exchangeResp = sim.transmitCommand( new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256)); assertEquals(0x9000, exchangeResp.getSW(), - "EXCHANGE with valid GCM tag must return SW=9000"); - assertEquals(0, exchangeResp.getData().length, - "M1 EXCHANGE handler returns empty payload (decrypt-and-discard)"); + "EXCHANGE with valid Reader Status request must return SW=9000"); + byte[] respCt = exchangeResp.getData(); + assertEquals(19, respCt.length, + "Reader Status response plaintext is 3 B + 16 B GCM tag = 19 B"); + + // Decrypt under StepUpSKDevice + device IV (counter=1 — this is the + // first device-side message in the Step-Up session). + byte[] deviceIv = new byte[12]; + deviceIv[7] = 0x01; + deviceIv[11] = 0x01; + Cipher gcmDec = Cipher.getInstance("AES/GCM/NoPadding"); + gcmDec.init(Cipher.DECRYPT_MODE, + new SecretKeySpec(stepUpSKDevice, "AES"), + new GCMParameterSpec(128, deviceIv)); + byte[] respPt = gcmDec.doFinal(respCt); + assertArrayEquals(new byte[] { 0x01, 0x00, 0x00 }, respPt, + "Reader Status response plaintext = [sub_event_id=0x01, status=0x00, payload_len=0x00]"); } /** - * After SELECT-Step-Up the X-CUBE-ALIRO firmware also sends an ENVELOPE - * command (CLA=0x00 INS=0xC3) carrying the encrypted mdoc DeviceRequest - * (spec §8.4 + ISO 7816 ENVELOPE). Inbound encryption per §8.3.1.9 - * (reader-side IV {@code 0x0000000000000000 || stepup_reader_counter}, - * empty AAD); response encryption per §8.3.1.6 (device-side IV - * {@code 0x0000000000000001 || stepup_device_counter}, empty AAD). - * - *

    For Milestone 1 we decrypt-and-discard the DeviceRequest, then - * return an encrypted single-byte CBOR empty map ({@code 0xA0}) so the - * X-CUBE-ALIRO firmware sees a spec-shape encrypted response. Total - * response bytes: 1 ct + 16 tag = 17. - * - *

    This test also pins counter sequencing: ENVELOPE consumes - * stepup_reader_counter=1 (then increments) and emits with - * stepup_device_counter=1 (then increments). The reader-counter is - * shared with EXCHANGE, but EXCHANGE isn't sent in this test so we - * only see counter=1 on each side. + * EXCHANGE with an unknown {@code sub_event_id} must return + * {@code SW_DATA_INVALID (0x6984)} per M2 spec §8.3.3.5 — M2 only supports + * sub_event_id 0x01 (ReaderStatusRequest); 0x02 (TransactionEnd) and + * higher are reserved / not implemented. */ @Test - void envelopeAfterStepUpSelectDecryptsAndAcksWithEncryptedEmptyCborMap() throws Exception { + void exchangeWithUnknownSubEventIdReturnsDataInvalid() throws Exception { sim = new CardSimulator(); AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length); sim.installApplet(expeditedAid, AliroApplet.class); @@ -250,6 +251,81 @@ class StepUpAppletTest { ReaderSide reader = new ReaderSide(); reader.provision(sim, credentialKeyPair); + assertEquals(0x9000, sim.transmitCommand( + new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(), + "SELECT expedited must succeed"); + + reader.startTransaction(); + ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU( + Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, + reader.buildAuth0Data(), 256)); + assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed"); + byte[] credentialEphemPubKey = TlvUtil.findTopLevel(auth0Resp.getData(), 0x86); + + ResponseAPDU auth1Resp = sim.transmitCommand(new CommandAPDU( + Auth0Command.CLA & 0xFF, 0x81, 0x00, 0x00, + reader.buildAuth1Data(credentialEphemPubKey), 256)); + assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed"); + + byte[] stepUpSK = java.util.Arrays.copyOfRange( + reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96); + byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader"); + + assertEquals(0x9000, sim.transmitCommand( + new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(), + "SELECT step-up must succeed"); + + // Unknown sub_event_id (0x02 = TransactionEnd, reserved for M3+). + byte[] readerIv = new byte[12]; + readerIv[11] = 0x01; + byte[] requestPt = new byte[] { 0x02, 0x00 }; + Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding"); + gcmEnc.init(Cipher.ENCRYPT_MODE, + new SecretKeySpec(stepUpSKReader, "AES"), + new GCMParameterSpec(128, readerIv)); + byte[] ctAndTag = gcmEnc.doFinal(requestPt); + + ResponseAPDU exchangeResp = sim.transmitCommand( + new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256)); + assertEquals(0x6984, exchangeResp.getSW(), + "unknown sub_event_id must return SW_DATA_INVALID"); + } + + /** + * M2D.3 + M2D.4 end-to-end: after SELECT-Step-Up the reader sends an + * ENVELOPE (CLA=0x00 INS=0xC3) carrying an encrypted mdoc DeviceRequest. + * The applet decrypts under StepUpSKReader (§8.3.1.9 IV), runs the + * {@link DeviceRequestParser} structural validation, builds the real + * {@link DeviceResponseBuilder} output around the cached Access Document, + * and encrypts under StepUpSKDevice (§8.3.1.6 IV). + * + *

    The encrypted response is 372 B ciphertext + 16 B GCM tag = 388 B, + * which exceeds the ~252 B APDU outgoing window. The applet therefore + * uses ISO 7816 response chaining: the first ENVELOPE response carries + * the head chunk + SW=61xx ("xx more bytes available"), and the reader + * pulls remaining chunks via GET RESPONSE (INS=0xC0) until SW=9000. + */ + @Test + void envelopeAfterStepUpSelectReturnsRealDeviceResponseViaChaining() throws Exception { + sim = new CardSimulator(); + AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length); + sim.installApplet(expeditedAid, AliroApplet.class); + AID stepUpAid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length); + sim.installApplet(stepUpAid, StepUpApplet.class); + + KeyPair credentialKeyPair = Auth0Command.generateEphemeralKeyPair(); + ReaderSide reader = new ReaderSide(); + reader.provision(sim, credentialKeyPair); + + // Stage the cached Access Document via the test-only hook (mirrors + // AliroAppletAuth1Test's pattern — bypassing the personalization + // pipeline's IssuerAuth verify since the AD here is shaped for the + // DeviceResponse round-trip, not for real signature verification). + CredentialStore.get().writeAccessDocumentChunk( + ACCESS_DOC_HEX, (short) 0, (short) 0, (short) ACCESS_DOC_HEX.length); + CredentialStore.get().markAccessDocumentFinalizedForTesting( + (short) ACCESS_DOC_HEX.length); + // SELECT expedited + run AUTH0 + AUTH1. assertEquals(0x9000, sim.transmitCommand( new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(), @@ -279,31 +355,49 @@ class StepUpAppletTest { new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(), "SELECT step-up must succeed"); - // Reader-side encrypt of a 4-byte plaintext under the reader IV - // (00 00 00 00 00 00 00 00 || 00 00 00 01). The applet decrypts and - // discards; the actual plaintext is irrelevant for M1. + // Reader-side encrypt of a valid DeviceRequest CBOR under the reader IV + // (00*8 || 00 00 00 01). The applet decrypts, validates the structure + // via DeviceRequestParser, then emits the cached DeviceResponse. byte[] readerIv = new byte[12]; readerIv[11] = 0x01; - byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 }; Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding"); gcmEnc.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(stepUpSKReader, "AES"), new GCMParameterSpec(128, readerIv)); - byte[] envelopeBody = gcmEnc.doFinal(plaintext); // 4 + 16 = 20 bytes - assertEquals(20, envelopeBody.length); + byte[] envelopeBody = gcmEnc.doFinal(VALID_DEVICE_REQUEST); - // Send ENVELOPE: CLA=0x00 INS=0xC3 (ISO-class command per Table 8-14). + // Send ENVELOPE: CLA=0x00 INS=0xC3. Le=0 maxes the inbound buffer; + // the response head chunk will come back with SW=61xx since 388 B + // exceeds one APDU. ResponseAPDU envelopeResp = sim.transmitCommand( new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256)); - assertEquals(0x9000, envelopeResp.getSW(), - "ENVELOPE with valid GCM tag must return SW=9000"); + int sw = envelopeResp.getSW(); + org.junit.jupiter.api.Assertions.assertEquals( + 0x6100, sw & 0xFF00, + "ENVELOPE response > APDU window must yield SW=61xx; got 0x" + Integer.toHexString(sw)); - byte[] respBody = envelopeResp.getData(); - assertEquals(17, respBody.length, - "M1 ENVELOPE response = 1 ciphertext byte + 16 GCM tag"); + // Drain chunks via GET RESPONSE (INS=0xC0) until SW=9000. + java.io.ByteArrayOutputStream agg = new java.io.ByteArrayOutputStream(); + agg.write(envelopeResp.getData()); + int round = 0; + while ((sw & 0xFF00) == 0x6100) { + ResponseAPDU getResp = sim.transmitCommand( + new CommandAPDU(0x00, 0xC0, 0x00, 0x00, 256)); + agg.write(getResp.getData()); + sw = getResp.getSW(); + round++; + // Safety net against an infinite loop in case the chaining state + // never converges -- 388 B / 252 B per chunk = 2 rounds max. + org.junit.jupiter.api.Assertions.assertTrue(round < 5, + "GET RESPONSE chain should terminate in well under 5 rounds"); + } + assertEquals(0x9000, sw, "final GET RESPONSE must end with SW=9000"); - // Device-side decrypt under IV = 00 00 00 00 00 00 00 01 || 00 00 00 01 - // (device-prefix per §8.3.1.6 + device_counter=1). + byte[] respBody = agg.toByteArray(); + assertEquals(388, respBody.length, + "DeviceResponse ciphertext = 372 B body + 16 B GCM tag = 388 B"); + + // Device-side decrypt under IV = 00*7 || 0x01 || 00 00 00 01. byte[] deviceIv = new byte[12]; deviceIv[7] = 0x01; deviceIv[11] = 0x01; @@ -312,8 +406,121 @@ class StepUpAppletTest { new SecretKeySpec(stepUpSKDevice, "AES"), new GCMParameterSpec(128, deviceIv)); byte[] decoded = gcmDec.doFinal(respBody); - assertArrayEquals(new byte[] { (byte) 0xA0 }, decoded, - "M1 ENVELOPE response plaintext = canonical CBOR empty map (0xA0)"); + assertArrayEquals(DEVICE_RESPONSE_HEX, decoded, + "decrypted plaintext must equal the M2D.2 canonical DeviceResponse bytes"); + } + + /** + * If the decrypted DeviceRequest fails {@link DeviceRequestParser}'s + * structural validation (truncated CBOR here), the ENVELOPE handler must + * propagate the parser's SW unchanged. SW_DATA_INVALID (0x6984) for a + * malformed CBOR header per the parser javadoc. + */ + @Test + void envelopeWithMalformedDeviceRequestReturnsDataInvalid() throws Exception { + sim = new CardSimulator(); + AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length); + sim.installApplet(expeditedAid, AliroApplet.class); + AID stepUpAid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length); + sim.installApplet(stepUpAid, StepUpApplet.class); + + KeyPair credentialKeyPair = Auth0Command.generateEphemeralKeyPair(); + ReaderSide reader = new ReaderSide(); + reader.provision(sim, credentialKeyPair); + + // No AD needed -- the parser fails before the response builder runs. + + assertEquals(0x9000, sim.transmitCommand( + new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW()); + reader.startTransaction(); + ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU( + Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, + reader.buildAuth0Data(), 256)); + assertEquals(0x9000, auth0Resp.getSW()); + byte[] credentialEphemPubKey = TlvUtil.findTopLevel(auth0Resp.getData(), 0x86); + ResponseAPDU auth1Resp = sim.transmitCommand(new CommandAPDU( + Auth0Command.CLA & 0xFF, 0x81, 0x00, 0x00, + reader.buildAuth1Data(credentialEphemPubKey), 256)); + assertEquals(0x9000, auth1Resp.getSW()); + + byte[] stepUpSK = java.util.Arrays.copyOfRange( + reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96); + byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader"); + + assertEquals(0x9000, sim.transmitCommand( + new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW()); + + // Encrypt the truncated CBOR -- the GCM tag is intact, so decrypt + // succeeds. The parser then rejects on the malformed CBOR header. + byte[] readerIv = new byte[12]; + readerIv[11] = 0x01; + Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding"); + gcmEnc.init(Cipher.ENCRYPT_MODE, + new SecretKeySpec(stepUpSKReader, "AES"), + new GCMParameterSpec(128, readerIv)); + byte[] envelopeBody = gcmEnc.doFinal(TRUNCATED_DEVICE_REQUEST); + + ResponseAPDU envelopeResp = sim.transmitCommand( + new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256)); + assertEquals(0x6984, envelopeResp.getSW(), + "malformed inner DeviceRequest must propagate SW_DATA_INVALID from the parser"); + } + + // ---- M2D.3 / M2D.4 fixtures ---- + + /** Canonical CBOR for a minimal valid DeviceRequest; mirrors the VALID + * constant in {@link DeviceRequestParserTest}. */ + private static final byte[] VALID_DEVICE_REQUEST = hex( + "a26776657273696f6e63312e306b646f63526571756573747381" + + "a16c6974656d7352657175657374582ba267646f6354797065" + + "756f72672e69736f2e31383031332e352e312e6d444c" + + "6a6e616d65537061636573a0"); + + /** Truncated mid-key DeviceRequest; mirrors the TRUNCATED constant in + * {@link DeviceRequestParserTest}. */ + private static final byte[] TRUNCATED_DEVICE_REQUEST = hex("a26776657273696f"); + + /** Same Access Document fixture as {@link DeviceResponseBuilderTest} — + * 272 B COSE_Sign1 generated against the canonical AD. The applet + * embeds these bytes verbatim under + * {@code documents[0].issuerSigned.issuerAuth}. */ + private static final byte[] ACCESS_DOC_HEX = hex( + "8443a10126a104488dae9624eed9280c58bca7613163312e30613267534" + + "8412d3235366133a06134a16131a401022001215820aa3115ead5d1fec" + + "ca289aef3598790a6dba23edbe9b14e6818ac683e31a5af0222582083" + + "9dcc32bcd32924a942c3b9999f6cbf46960396e69606fe4295e83a0c7" + + "787a2613567616c69726f2d616136a36131c074323032362d30342d3" + + "1395432303a32393a31365a6132c074323032362d30342d3139543230" + + "3a32393a31365a6133c074323032372d30342d31395432303a32393a3" + + "1365a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac436" + + "44e92c8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d" + + "38df6f1c5b1caf685c365b22a3f2a"); + + /** Expected DeviceResponse plaintext for {@link #ACCESS_DOC_HEX} — + * 372 B canonical-CBOR built around the 272 B AD. Same vector as + * {@link DeviceResponseBuilderTest#DEVICE_RESPONSE_HEX}. */ + private static final byte[] DEVICE_RESPONSE_HEX = hex( + "a366737461747573006776657273696f6e63312e3069646f63756d656e" + + "747381a267646f6354797065756f72672e69736f2e31383031332e352e" + + "312e6d444c6c6973737565725369676e6564a26a697373756572417574" + + "688443a10126a104488dae9624eed9280c58bca7613163312e30613267" + + "5348412d3235366133a06134a16131a401022001215820aa3115ead5d1" + + "fecca289aef3598790a6dba23edbe9b14e6818ac683e31a5af02225820" + + "839dcc32bcd32924a942c3b9999f6cbf46960396e69606fe4295e83a0c" + + "7787a2613567616c69726f2d616136a36131c074323032362d30342d31" + + "395432303a32393a31365a6132c074323032362d30342d31395432303a" + + "32393a31365a6133c074323032372d30342d31395432303a32393a3136" + + "5a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac43644e9" + + "2c8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d38df6" + + "f1c5b1caf685c365b22a3f2a6a6e616d65537061636573a0"); + + 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; } /** HKDF-SHA-256 with empty salt and single-block Expand (L=32). Mirrors diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpSessionTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpSessionTest.java new file mode 100644 index 0000000..b102cae --- /dev/null +++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpSessionTest.java @@ -0,0 +1,103 @@ +package com.dangerousthings.aliro; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +/** + * Tests for {@link StepUpSession} — the session-state holder extracted out + * of {@link StepUpApplet} for the Step-Up AES-256-GCM session (spec §8.4.3 + * + §8.3.1.6/8/9). Covers IV layout, counter advance, and 32-bit wrap. + * + *

    Note: jcardsim is initialised lazily by {@link StepUpSession}'s + * {@code makeTransientByteArray} calls. The {@link CardSimulator} static + * initialiser does that wiring; instantiating one here is sufficient. + */ +class StepUpSessionTest { + + private static StepUpSession freshSession() { + // CardSimulator's static init installs the jcardsim runtime that + // StepUpSession needs for makeTransientByteArray. Construct one and + // discard — only the static-init side-effect matters. + new com.licel.jcardsim.smartcardio.CardSimulator(); + StepUpSession s = new StepUpSession(); + s.reset(); + return s; + } + + /** + * Spec §8.3.1.8/9 reader-side IV layout: 8-byte zero prefix + + * 4-byte big-endian stepup_reader_counter. After {@link StepUpSession#reset()} + * the counter is {@code 0x00000001} per §8.4.3 + mdoc [6] §9.1.1.5. + */ + @Test + void readerIv_counter1_returnsAllZerosThen0001() { + StepUpSession s = freshSession(); + byte[] iv = new byte[12]; + s.readerIv(iv, (short) 0); + assertArrayEquals( + new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, + iv, + "fresh-session reader IV = 00*8 || 00 00 00 01"); + } + + /** + * Advance reader counter 255 times from 1 -> 256 (= 0x00000100). Pins + * carry propagation from the low byte into byte 2 of the counter — the + * IV trailing 4 bytes must read {@code 00 00 01 00}. + */ + @Test + void readerIv_counter256_handlesCarryIntoByte2() { + StepUpSession s = freshSession(); + // 1 -> 2 -> ... -> 256: 255 advances. + for (int i = 0; i < 255; i++) { + s.advanceReaderCounter(); + } + byte[] iv = new byte[12]; + s.readerIv(iv, (short) 0); + assertArrayEquals( + new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 }, + iv, + "after 255 advances reader IV trails 00 00 01 00 (BE 256)"); + } + + /** + * Spec §8.3.1.6 device-side IV layout: 7-byte zero prefix + 0x01 + + * 4-byte big-endian stepup_device_counter. After reset the device + * counter is {@code 0x00000001} per §8.4.3. + */ + @Test + void deviceIv_counter1_returnsZeros_then01_then0001() { + StepUpSession s = freshSession(); + byte[] iv = new byte[12]; + s.deviceIv(iv, (short) 0); + assertArrayEquals( + new byte[] { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1 }, + iv, + "fresh-session device IV = 00*7 || 01 || 00 00 00 01"); + } + + /** + * Spec §8.3.3.5.4 says the counter SHALL never reach the limit, but the + * applet has no explicit overflow guard (YAGNI: the protocol's wire-speed + * ceiling makes this unreachable in practice). Lock the 32-bit BE wrap + * behaviour here so a future "let's add a check" doesn't silently change + * the math layer. + */ + @Test + void incrementCounterWrapsAt0xFFFFFFFF() { + StepUpSession s = freshSession(); + // Force the reader counter to 0xFFFFFFFF, then advance. + s.readerCounter[0] = (byte) 0xFF; + s.readerCounter[1] = (byte) 0xFF; + s.readerCounter[2] = (byte) 0xFF; + s.readerCounter[3] = (byte) 0xFF; + s.advanceReaderCounter(); + byte[] iv = new byte[12]; + s.readerIv(iv, (short) 0); + assertArrayEquals( + new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + iv, + "0xFFFFFFFF + 1 wraps to 0x00000000 (mod 2^32)"); + } +} diff --git a/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java b/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java new file mode 100644 index 0000000..f0c6d53 --- /dev/null +++ b/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java @@ -0,0 +1,515 @@ +package com.dangerousthings.aliro; + +import javacard.framework.ISOException; +import javacard.framework.ISO7816; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link StructuralCbor#decodeHeader} — RFC 8949 §3 header + * decoding (major type + argument) used by the M2 DeviceResponse / IssuerAuth + * walker. + * + *

    Header byte layout (RFC 8949 §3): + *

    + *   bits 7..5: major type (0..7)
    + *   bits 4..0: additional info
    + *     0..23: immediate argument value
    + *     24:    1-byte uint argument follows
    + *     25:    2-byte uint argument follows (big-endian)
    + *     26:    4-byte uint argument follows (big-endian)
    + *     27:    8-byte uint argument follows (REJECTED — exceeds short range)
    + *     28..30: reserved (REJECTED)
    + *     31:    indefinite length (REJECTED — Aliro mdoc is canonical)
    + * 
    + * + *

    Result encoding (matches {@link StructuralCbor#decodeHeader} javadoc): + *

      + *
    • return value (short): high byte = major type, low byte = bytes consumed + *
    • argument: written big-endian into {@code argOut[argOff..argOff+4)} + *
    + */ +class StructuralCborTest { + + /** Helper: decode at offset 0 and return major type. */ + private static short majorOf(short result) { + return (short) ((result >> 8) & 0x07); + } + + /** Helper: decode at offset 0 and return bytes consumed. */ + private static short consumedOf(short result) { + return (short) (result & 0xFF); + } + + /** Helper: read the 4-byte big-endian argument the codec wrote. */ + private static long argOf(byte[] argOut) { + return ((long) (argOut[0] & 0xFF) << 24) + | ((long) (argOut[1] & 0xFF) << 16) + | ((long) (argOut[2] & 0xFF) << 8) + | ((long) (argOut[3] & 0xFF)); + } + + @Test + void decodeHeader_uintImmediateZero() { + byte[] buf = hex("00"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(0, majorOf(r), "major type 0 (uint)"); + assertEquals(1, consumedOf(r), "1 byte consumed"); + assertEquals(0L, argOf(arg), "argument value 0"); + } + + @Test + void decodeHeader_uintImmediateMax() { + // Additional info 23 is the largest immediate-argument value. + byte[] buf = hex("17"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(0, majorOf(r)); + assertEquals(1, consumedOf(r)); + assertEquals(23L, argOf(arg)); + } + + @Test + void decodeHeader_uintOneByteArgument() { + // 0x18 = uint with 1-byte argument follow; argument = 0xff (255). + byte[] buf = hex("18ff"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(0, majorOf(r)); + assertEquals(2, consumedOf(r)); + assertEquals(255L, argOf(arg)); + } + + @Test + void decodeHeader_uintTwoByteArgument() { + // 0x19 = uint with 2-byte argument; 0x0100 = 256. + byte[] buf = hex("190100"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(0, majorOf(r)); + assertEquals(3, consumedOf(r)); + assertEquals(256L, argOf(arg)); + } + + @Test + void decodeHeader_uintFourByteArgument() { + // 0x1a = uint with 4-byte argument; 0x00010000 = 65536. + byte[] buf = hex("1a00010000"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(0, majorOf(r)); + assertEquals(5, consumedOf(r)); + assertEquals(65536L, argOf(arg)); + } + + @Test + void decodeHeader_bstrOneByteLen() { + // 0x58 = bstr major type (2) + additional info 24 (1-byte len follows). + // Length 200 won't overflow short. + byte[] buf = hex("58c8"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(2, majorOf(r), "major type 2 (bstr)"); + assertEquals(2, consumedOf(r)); + assertEquals(200L, argOf(arg)); + } + + @Test + void decodeHeader_tstrImmediate() { + // 0x65 = tstr major type (3) + immediate length 5. + byte[] buf = hex("65"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(3, majorOf(r), "major type 3 (tstr)"); + assertEquals(1, consumedOf(r)); + assertEquals(5L, argOf(arg)); + } + + @Test + void decodeHeader_arrayImmediate() { + // 0x83 = array major type (4) + immediate count 3. + byte[] buf = hex("83"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(4, majorOf(r), "major type 4 (array)"); + assertEquals(1, consumedOf(r)); + assertEquals(3L, argOf(arg)); + } + + @Test + void decodeHeader_mapImmediate() { + // 0xa2 = map major type (5) + immediate count 2. + byte[] buf = hex("a2"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(5, majorOf(r), "major type 5 (map)"); + assertEquals(1, consumedOf(r)); + assertEquals(2L, argOf(arg)); + } + + @Test + void decodeHeader_tagImmediate() { + // 0xc0 = tag major type (6) + immediate tag 0. + byte[] buf = hex("c0"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0); + assertEquals(6, majorOf(r), "major type 6 (tag)"); + assertEquals(1, consumedOf(r)); + assertEquals(0L, argOf(arg)); + } + + @Test + void decodeHeader_atNonzeroOffset() { + // Verify decodeHeader honours bufOff: pad with junk, decode at offset 3. + byte[] buf = hex("deadbeef" + "190100"); + byte[] arg = new byte[4]; + short r = StructuralCbor.decodeHeader(buf, (short) 4, (short) (buf.length - 4), arg, (short) 0); + assertEquals(0, majorOf(r)); + assertEquals(3, consumedOf(r)); + assertEquals(256L, argOf(arg)); + } + + @Test + void decodeHeader_argOutAtNonzeroOffset() { + // Verify argOff: write argument into arg[2..6) and leave arg[0..2) zero. + byte[] buf = hex("190100"); + byte[] arg = new byte[8]; + short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 2); + assertEquals(0, majorOf(r)); + assertEquals(3, consumedOf(r)); + assertEquals(0, arg[0]); + assertEquals(0, arg[1]); + // Argument bytes land at arg[2..6). + long argVal = ((long) (arg[2] & 0xFF) << 24) + | ((long) (arg[3] & 0xFF) << 16) + | ((long) (arg[4] & 0xFF) << 8) + | ((long) (arg[5] & 0xFF)); + assertEquals(256L, argVal); + } + + @Test + void decodeHeader_indefiniteLengthRejected() { + // 0x1f = uint major type + additional info 31 (indefinite length). + // We refuse indefinite-length in Aliro mdoc — canonical CBOR only. + byte[] buf = hex("1f"); + byte[] arg = new byte[4]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF, + "indefinite-length must throw SW_DATA_INVALID"); + } + + @Test + void decodeHeader_eightByteArgumentRejected() { + // 0x1b = uint with 8-byte argument follow. Out of short-buffer scope. + byte[] buf = hex("1b00000000000000ff"); + byte[] arg = new byte[4]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + @Test + void decodeHeader_majorTypeSevenRejected() { + // 0xf5 = major type 7 (CBOR true). We don't handle floats/null/bool. + byte[] buf = hex("f5"); + byte[] arg = new byte[4]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + @Test + void decodeHeader_reservedAdditionalInfoRejected() { + // 0x1c = additional info 28 (reserved by RFC 8949). + byte[] buf = hex("1c"); + byte[] arg = new byte[4]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + @Test + void decodeHeader_truncatedArgumentRejected() { + // 0x19 says "2-byte argument follows" but only 1 byte is present. + byte[] buf = hex("1901"); + byte[] arg = new byte[4]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + @Test + void decodeHeader_emptyBufferRejected() { + byte[] buf = new byte[0]; + byte[] arg = new byte[4]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + // --------------------------------------------------------------------- + // elementSpan tests (M2B.2) — span of a complete CBOR element including + // nested children, used by M2B.3 / M2D.1 to walk IssuerSigned / docRequests. + // --------------------------------------------------------------------- + + /** Shared 4B scratch for elementSpan's decodeHeader calls. */ + private final byte[] scratch = new byte[4]; + + @Test + void elementSpan_uintImmediate() { + // 0x17 = uint 23, immediate argument. One header byte, no payload. + byte[] buf = hex("17"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(1, span); + } + + @Test + void elementSpan_uintOneByteArgument() { + // 0x18 0x64 = uint 100. Header byte + 1B argument, no payload. + byte[] buf = hex("1864"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(2, span); + } + + @Test + void elementSpan_bstrEmpty() { + // 0x40 = bstr len 0. One header byte, zero payload. + byte[] buf = hex("40"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(1, span); + } + + @Test + void elementSpan_bstrLen3() { + // 0x43 0x01 0x02 0x03 = bstr h'010203'. Header + 3 payload bytes. + byte[] buf = hex("43010203"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(4, span); + } + + @Test + void elementSpan_tstrHello() { + // 0x65 + "hello" = tstr "hello". Header + 5 payload bytes. + byte[] buf = hex("6568656c6c6f"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(6, span); + } + + @Test + void elementSpan_arrayOfThreeUints() { + // 0x83 0x01 0x02 0x03 = [1, 2, 3]. Header + 3 single-byte children. + byte[] buf = hex("83010203"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(4, span); + } + + @Test + void elementSpan_nestedArray() { + // 0x82 0x82 0x01 0x02 0x03 = [[1, 2], 3]. Exercises recursion. + byte[] buf = hex("8282010203"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(5, span); + } + + @Test + void elementSpan_mapTwoUintPairs() { + // 0xa2 0x01 0x02 0x03 0x04 = {1: 2, 3: 4}. Header + 4 single-byte items. + byte[] buf = hex("a201020304"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(5, span); + } + + @Test + void elementSpan_mapWithBstrUintPairs() { + // 0xa2 0x43 010203 0x1864 0x41 ff 0x17 + // = { h'010203': 100, h'ff': 23 } + // Header(1) + bstr(4) + uint(2) + bstr(2) + uint(1) = 10. + byte[] buf = hex("a2 43 010203 1864 41 ff 17"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(10, span); + } + + @Test + void elementSpan_tagUint() { + // 0xc1 0x01 = tag(1, 1). Tag header + tagged element span. + byte[] buf = hex("c101"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(2, span); + } + + @Test + void elementSpan_honoursBufOff() { + // Pad with 4 junk bytes, then a 4-byte array [1,2,3]; span starts at offset 4. + byte[] buf = hex("deadbeef" + "83010203"); + short span = StructuralCbor.elementSpan( + buf, (short) 4, (short) (buf.length - 4), scratch, (short) 0); + assertEquals(4, span); + } + + // --------------------------------------------------------------------- + // Canonical encoder tests (M2B.4) — uint / bstr-header / tstr-header + // writers used by M2D.2 (DeviceResponseBuilder). + // + // Canonical CBOR (RFC 8949 §4.2.1): shortest argument size that fits the + // value. Boundary at each size step (0..23 immediate, 24..0xff one-byte, + // 0x100..0xffff two-byte, 0x10000..0xffffffff four-byte). + // --------------------------------------------------------------------- + + @Test + void encodeUint_zeroImmediate() { + // 0 fits in the immediate range — single header byte 0x00. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(0, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "00", n); + } + + @Test + void encodeUint_twentyThreeImmediate() { + // 23 is the largest immediate value — header byte 0x17. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(23, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "17", n); + } + + @Test + void encodeUint_twentyFourOneByteBoundary() { + // 24 is the lower boundary of the 1-byte argument form — 0x18 0x18. + // Canonical rule forbids encoding 24 as 0x19 0x00 0x18 etc. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(24, out, (short) 0); + assertEquals(2, n); + assertArrayPrefix(out, "1818", n); + } + + @Test + void encodeUint_twoByteBoundary() { + // 0x100 = 256 is the lower boundary of the 2-byte argument form. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(0x100, out, (short) 0); + assertEquals(3, n); + assertArrayPrefix(out, "190100", n); + } + + @Test + void encodeUint_fourByteBoundary() { + // 0x10000 = 65536 is the lower boundary of the 4-byte argument form. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(0x10000, out, (short) 0); + assertEquals(5, n); + assertArrayPrefix(out, "1a00010000", n); + } + + @Test + void encodeUint_negativeRejected() { + // Aliro mdoc CBOR never uses negative integers (those are major type 1). + byte[] out = new byte[8]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.encodeUint(-1, out, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + @Test + void encodeBstrHeader_zero() { + // bstr len 0 — header byte 0x40 (major type 2 | additional info 0). + byte[] out = new byte[8]; + short n = StructuralCbor.encodeBstrHeader((short) 0, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "40", n); + } + + @Test + void encodeBstrHeader_twentyThreeImmediate() { + // Largest immediate-length bstr header — 0x57. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeBstrHeader((short) 23, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "57", n); + } + + @Test + void encodeBstrHeader_twentyFourOneByteBoundary() { + // 24 forces into 1-byte argument form — 0x58 0x18. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeBstrHeader((short) 24, out, (short) 0); + assertEquals(2, n); + assertArrayPrefix(out, "5818", n); + } + + @Test + void encodeBstrHeader_twoByteBoundary() { + // 0x100 = 256 forces into 2-byte argument form — 0x59 0x01 0x00. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeBstrHeader((short) 0x100, out, (short) 0); + assertEquals(3, n); + assertArrayPrefix(out, "590100", n); + } + + @Test + void encodeBstrHeader_negativeLenRejected() { + byte[] out = new byte[8]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.encodeBstrHeader((short) -1, out, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + @Test + void encodeTstrHeader_zero() { + // tstr len 0 — header byte 0x60 (major type 3 | additional info 0). + byte[] out = new byte[8]; + short n = StructuralCbor.encodeTstrHeader((short) 0, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "60", n); + } + + @Test + void encodeTstrHeader_twentyFourOneByteBoundary() { + // 24 forces into 1-byte argument form — 0x78 0x18. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeTstrHeader((short) 24, out, (short) 0); + assertEquals(2, n); + assertArrayPrefix(out, "7818", n); + } + + @Test + void encodeTstrHeader_twoByteBoundary() { + // 0x100 forces into 2-byte argument form — 0x79 0x01 0x00. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeTstrHeader((short) 0x100, out, (short) 0); + assertEquals(3, n); + assertArrayPrefix(out, "790100", n); + } + + /** Helper: assert {@code out[0..n)} equals the hex-decoded expected bytes. */ + private static void assertArrayPrefix(byte[] out, String expectedHex, short n) { + byte[] expected = hex(expectedHex); + assertEquals(expected.length, n, "expected " + expectedHex + " (" + expected.length + " bytes)"); + for (int i = 0; i < expected.length; i++) { + assertEquals(expected[i], out[i], "byte " + i + " mismatch"); + } + } + + 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; + } +} diff --git a/docs/vendor-bugs/2026-06-12-st-xcube-aliro-processAUTH1ResponsePayload.md b/docs/vendor-bugs/2026-06-12-st-xcube-aliro-processAUTH1ResponsePayload.md new file mode 100644 index 0000000..df18c50 --- /dev/null +++ b/docs/vendor-bugs/2026-06-12-st-xcube-aliro-processAUTH1ResponsePayload.md @@ -0,0 +1,55 @@ +# ST X-CUBE-ALIRO: `ACWG_processAUTH1ResponsePayload` errors against spec-compliant card + +**Status:** open +**Reporter:** Dangerous Things (ops@dangerousthings.com) +**Date filed:** 2026-06-12 +**Severity:** blocking — prevents Aliro Expedited phase from completing against any third-party Java Card applet + +## Summary + +`ACWG_processAUTH1ResponsePayload` (shipped precompiled in `Aliro.a` / `ACWG_Security.a`) returns `ACWG_Error_Crypto_EncryptDecrypt` on every iteration when processing AUTH1 responses from our open-source Java Card Aliro applet, despite the applet emitting bytes that an independent CSA Aliro v1.0 reference reader decrypts cleanly. Because the failing routine is closed-source, we cannot localize where ST's interpretation diverges from ours. We are asking for source access, a concrete statement of which §8.3.1 interpretation ST uses, or confirmation that ours is correct plus a vendor patch. + +## Reproducer + +- **Reader hardware:** NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1 (ST25R200), STM32U535/U545 target. +- **Reader firmware:** X-CUBE-ALIRO V1.0.0 (released 25-February-2026) + ST25 RFAL middleware V2.8.0 (T=CL). +- **Card:** NXP J3R452 (also reproduces on J3R180) running our open-source Aliro applet, implementing CSA Aliro v1.0 §8.3.1.4 (Kdh via X9.63 KDF / BSI TR-03111 §4.3.3 single-step KDF with SHA-256), §8.3.1.5–.13 (HKDF salt expansion), §8.3.1.6 (AES-256-GCM AUTH1 response encryption: 12-byte IV, 16-byte tag), §8.3.1.13 (`salt_volatile` layout). +- **Expected:** Expedited Standard AUTH1 completes; reader extracts `credential_PubK` and UD signature. +- **Observed:** every AUTH1 response into `ACWG_processAUTH1ResponsePayload` returns `ACWG_Error_Crypto_EncryptDecrypt`. UART trace shows the retval at the call boundary; no internal context exposed. + +## Evidence + +- **UART trace:** `ACWG_processAUTH1ResponsePayload -> ACWG_Error_Crypto_EncryptDecrypt`, repeatable across power cycles and across both J3R180 and J3R452. +- **Card output:** AUTH1 response APDU captured; structurally a single AES-256-GCM blob per §8.3.1.6. +- **Independent decrypt:** the same captured bytes fed into our PC/SC reader (`aliro-bench-test`, full Aliro spec implementation in Python) decrypt cleanly. Plaintext TLV: + - `signaling_bitmap = 0x0005` + - `0x5A credential_PubK` = 65 B (uncompressed P-256, `04 || X || Y`) + - `0x9E UD signature` = 64 B (raw ECDSA `r || s` over P-256) +- PC/SC path completes Expedited Standard end-to-end against the same card, same applet, same personalization. The card therefore emits well-formed §8.3.1 output by at least one reasonable reading of the spec. + +## What we ruled out + +Three crypto fixes ("the trifecta") landed on the applet 2026-06-11; none changed Nucleo behavior: + +1. **`salt_volatile` structure** — verified byte-for-byte against §8.3.1.13. Same bytes accepted by PC/SC. +2. **`Kdh` derivation** — corrected from HKDF to X9.63 KDF per §8.3.1.4. The corrected `Kdh` is what now decrypts cleanly under PC/SC. +3. **AUTH1 `cmd_params` byte** — confirmed the parameter byte the card hashes into its transcript matches what the Nucleo transmits on-air (RFAL frame capture). + +All three were necessary for PC/SC; none were sufficient for the Nucleo library. + +## What we can't rule out + +The vendor library's interpretation of §8.3.1.4 / §8.3.1.6 / §8.3.1.13 or the surrounding transcript construction may differ from ours in a way the spec text permits but does not require — e.g. AAD composition, IV layout, or transcript hash boundaries. Without source or symbols for `Aliro.a` / `ACWG_Security.a` we cannot identify which step raises `ACWG_Error_Crypto_EncryptDecrypt`. Reverse-engineering the archive is months of work for a vendor support question. + +## Request + +Any of the following resolves the block: + +1. **Source access** to `Aliro.a` / `ACWG_Security.a` — at minimum `ACWG_processAUTH1ResponsePayload` and its crypto helpers — under whatever NDA ST requires. +2. **Concrete spec interpretation** the vendor library uses for §8.3.1.4 / §8.3.1.6 / §8.3.1.13 in byte terms (key schedule, AAD, IV, tag). +3. **Confirmation** that our interpretation (matching the PC/SC reference) is correct, plus a vendor patch. +4. **Partner-program acceptance** if a beta firmware fix is already in flight. + +We can provide the personalized card, the captured AUTH1 request/response APDU pair, and the PC/SC reader's decrypted plaintext for ST to validate against an independent reference. + +**ST contact for this report:** filing via the X-CUBE-ALIRO support form at https://www.st.com/content/st_com/en/products/embedded-software/mcu-mpu-embedded-software/stm32-embedded-software/stm32cube-expansion-packages/x-cube-aliro.html. If routing-to-engineering needs a named FAE/partner contact, please advise — happy to re-send through whichever channel ST prefers. diff --git a/docs/verdicts/2026-06-12-m2-pcsc-verdict.log b/docs/verdicts/2026-06-12-m2-pcsc-verdict.log new file mode 100644 index 0000000..6bbdd36 --- /dev/null +++ b/docs/verdicts/2026-06-12-m2-pcsc-verdict.log @@ -0,0 +1,13 @@ +Using reader: NXP PR533 (3.70) 00 00 +Connected. ATR: 3b8a80014a444e4752665334353258 +Loaded trust artifacts from /home/work/aliro-trust +RESULT: OK — applet round-trip on real hardware. + 0x5A credential_PubK: 65B + 0x9E UD signature: 64B + 0x5E signaling_bitmap: 0x0005 + APDU latencies (ms): + select 23.1 + auth0 667.1 + auth1 3258.3 + total 3948.4 +STEP-UP M2: OK — M2 step-up verified (EXCHANGE + ENVELOPE Access Document round-trip) diff --git a/harness/src/aliro_harness/personalizer/apdus.py b/harness/src/aliro_harness/personalizer/apdus.py index 0fdf75b..5e65f14 100644 --- a/harness/src/aliro_harness/personalizer/apdus.py +++ b/harness/src/aliro_harness/personalizer/apdus.py @@ -11,6 +11,7 @@ INS_SET_CRED_PUBK = 0x21 INS_SET_READER_PUBK = 0x22 INS_WRITE_ACCESS_DOC = 0x23 INS_FINALIZE_ACCESS_DOC = 0x24 +INS_SET_CRED_ISSUER_PUBK = 0x25 INS_COMMIT = 0x2C # Provisioning AID — must match AliroAids.PROVISIONING in the applet. @@ -21,6 +22,7 @@ PROVISIONING_AID = bytes.fromhex("A000000909ACCE559901") CRED_PRIV_LEN = 32 CRED_PUBK_LEN = 64 READER_PUBK_LEN = 64 +CRED_ISSUER_PUBK_LEN = 64 def select_provisioning_apdu() -> bytes: @@ -46,6 +48,16 @@ def set_reader_pubk_apdu(pubk_xy: bytes) -> bytes: return _short_apdu(INS_SET_READER_PUBK, 0, 0, pubk_xy) +def set_credential_issuer_pubk_apdu(pubk_xy: bytes) -> bytes: + """Push the Credential Issuer public key (P-256 x||y, no 0x04 prefix). + The applet stages it for IssuerAuth COSE_Sign1 verify at FINALIZE_AD.""" + if len(pubk_xy) != CRED_ISSUER_PUBK_LEN: + raise ValueError( + f"credential_issuer_PubK must be {CRED_ISSUER_PUBK_LEN}B (x||y), got {len(pubk_xy)}" + ) + return _short_apdu(INS_SET_CRED_ISSUER_PUBK, 0, 0, pubk_xy) + + def commit_apdu() -> bytes: return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00]) diff --git a/harness/src/aliro_harness/personalizer/cli.py b/harness/src/aliro_harness/personalizer/cli.py index 948d40f..5658e1e 100644 --- a/harness/src/aliro_harness/personalizer/cli.py +++ b/harness/src/aliro_harness/personalizer/cli.py @@ -80,6 +80,7 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None: f"Loaded artifacts: credential_PrivK={len(artifacts.credential_priv)}B, " f"credential_PubK={len(artifacts.credential_pubk_xy)}B, " f"reader_PubK={len(artifacts.reader_pubk_xy)}B, " + f"credential_issuer_PubK={len(artifacts.credential_issuer_pub)}B, " f"Access Document={len(artifacts.access_document)}B" ) diff --git a/harness/src/aliro_harness/personalizer/orchestrator.py b/harness/src/aliro_harness/personalizer/orchestrator.py index edba186..3821908 100644 --- a/harness/src/aliro_harness/personalizer/orchestrator.py +++ b/harness/src/aliro_harness/personalizer/orchestrator.py @@ -13,6 +13,7 @@ from aliro_harness.personalizer.access_document import access_document_apdus from aliro_harness.personalizer.apdus import ( commit_apdu, select_provisioning_apdu, + set_credential_issuer_pubk_apdu, set_credential_priv_apdu, set_credential_pubk_apdu, set_reader_pubk_apdu, @@ -45,21 +46,25 @@ class TrustArtifacts: credential_priv: bytes # 32B credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix) reader_pubk_xy: bytes # 64B + credential_issuer_pub: bytes # 64B issuer pubkey x||y for IssuerAuth verify access_document: bytes # COSE_Sign1 serialized @classmethod def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts": cred_pem = (trust_dir / "access_credential.pem").read_bytes() reader_pem = (trust_dir / "reader.pem").read_bytes() + issuer_pem = (trust_dir / "issuer.pem").read_bytes() ad = (trust_dir / "access_document.bin").read_bytes() cred_key = serialization.load_pem_private_key(cred_pem, password=None) reader_key = serialization.load_pem_private_key(reader_pem, password=None) + issuer_key = serialization.load_pem_private_key(issuer_pem, password=None) return cls( credential_priv=extract_priv_scalar(cred_key), credential_pubk_xy=extract_pub_xy(cred_key.public_key()), reader_pubk_xy=extract_pub_xy(reader_key.public_key()), + credential_issuer_pub=extract_pub_xy(issuer_key.public_key()), access_document=ad, ) @@ -73,6 +78,14 @@ def personalize_card(transmit: Transmit, artifacts: TrustArtifacts) -> None: _send(transmit, "SET credential_PrivK", set_credential_priv_apdu(artifacts.credential_priv)) _send(transmit, "SET credential_PubK", set_credential_pubk_apdu(artifacts.credential_pubk_xy)) _send(transmit, "SET reader_PubK", set_reader_pubk_apdu(artifacts.reader_pubk_xy)) + # Issuer pubkey must land BEFORE the AD chunks so that by the time + # FINALIZE_AD runs and triggers IssuerAuth COSE_Sign1 verify, the trust + # anchor is already staged. + _send( + transmit, + "SET credential_issuer_PubK", + set_credential_issuer_pubk_apdu(artifacts.credential_issuer_pub), + ) for i, apdu in enumerate(access_document_apdus(artifacts.access_document)): _send(transmit, f"Access Document APDU {i}", apdu) diff --git a/harness/src/aliro_harness/reader/cli.py b/harness/src/aliro_harness/reader/cli.py index 837f404..53ea42d 100644 --- a/harness/src/aliro_harness/reader/cli.py +++ b/harness/src/aliro_harness/reader/cli.py @@ -12,7 +12,7 @@ from pathlib import Path import click -from aliro_harness.reader.step_up import verify_step_up_m1 +from aliro_harness.reader.step_up import verify_step_up_m2 from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction @@ -42,9 +42,10 @@ from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction @click.option( "--step-up", is_flag=True, - help="After AUTH1, also exercise Step-Up Milestone 1: SELECT ACCE5502, " - "INS=0xC9 EXCHANGE, INS=0xC3 ENVELOPE. Verifies the StepUpApplet decrypts " - "with StepUpSKReader and encrypts the empty-CBOR-map ack with StepUpSKDevice.", + help="After AUTH1, also exercise Step-Up Milestone 2: SELECT ACCE5502, " + "INS=0xC9 EXCHANGE, INS=0xC3 ENVELOPE (DeviceRequest) + GET RESPONSE " + "chaining. Decrypts the DeviceResponse under StepUpSKDevice and asserts " + "documents[0].issuerSigned.issuerAuth == access_document.bin from --trust-dir.", ) def main( trust_dir: Path | None, reader_index: int, list_readers: bool, step_up: bool @@ -87,6 +88,12 @@ def main( bundle = TrustBundle.from_trust_dir(trust_dir) click.echo(f"Loaded trust artifacts from {trust_dir}") + # Lazy: only load the AD if --step-up is set — otherwise this would + # gratuitously require access_document.bin for the AUTH1-only path. + expected_ad: bytes | None = None + if step_up: + expected_ad = (trust_dir / "access_document.bin").read_bytes() + def transmit(apdu: bytes) -> tuple[bytes, int]: data, sw1, sw2 = connection.transmit(list(apdu)) return bytes(data), (sw1 << 8) | sw2 @@ -94,7 +101,10 @@ def main( result = run_aliro_transaction(transmit=transmit, bundle=bundle) step_up_verdict: tuple[bool, str] | None = None if step_up and result.ok and result.step_up_sk is not None: - step_up_verdict = verify_step_up_m1(transmit, result.step_up_sk) + assert expected_ad is not None # set above whenever step_up is true + step_up_verdict = verify_step_up_m2( + transmit, result.step_up_sk, expected_ad + ) finally: connection.disconnect() @@ -131,9 +141,9 @@ def main( if step_up_verdict is not None: ok, msg = step_up_verdict if ok: - click.echo(f"STEP-UP M1: OK \u2014 {msg}") + click.echo(f"STEP-UP M2: OK \u2014 {msg}") else: - click.echo(f"STEP-UP M1: FAIL \u2014 {msg}", err=True) + click.echo(f"STEP-UP M2: FAIL \u2014 {msg}", err=True) raise click.exceptions.Exit(1) diff --git a/harness/src/aliro_harness/reader/step_up.py b/harness/src/aliro_harness/reader/step_up.py index a7caaec..67ed61f 100644 --- a/harness/src/aliro_harness/reader/step_up.py +++ b/harness/src/aliro_harness/reader/step_up.py @@ -1,17 +1,31 @@ -"""Step-Up Milestone 1 PC/SC verification. +"""Step-Up Milestone 2 PC/SC verification. -Verifies the M1 applet path post-AUTH1: +Drives the M2 applet path post-AUTH1: - SELECT ACCE5502 (StepUpApplet) - - INS=0xC9 EXCHANGE encrypted with StepUpSKReader -> expect SW=9000, empty body - - INS=0xC3 ENVELOPE encrypted with StepUpSKReader -> expect SW=9000, - 17B body that decrypts under StepUpSKDevice to single byte 0xA0. + - INS=0xC9 EXCHANGE encrypted with StepUpSKReader carrying a Reader Status + sub-event REQUEST plaintext (spec §8.3.3.5 / Table 8-19): + sub_event_id = 0x01 (ReaderStatusRequest), payload_len = 0 + -> applet returns 19 B = 3 B response plaintext + 16 B GCM tag, decrypts + under StepUpSKDevice + deviceIv(1) to: + sub_event_id = 0x01, status = 0x00, payload_len = 0 (Table 8-20). + - INS=0xC3 ENVELOPE encrypted with StepUpSKReader carrying a canonical CBOR + mdoc DeviceRequest -> applet returns the first chunk of an encrypted + DeviceResponse with SW=61xx. + - INS=0xC0 GET RESPONSE repeated until SW=9000 — concatenated body decrypts + under StepUpSKDevice + deviceIv(2) (EXCHANGE consumed deviceIv(1)). + - Plaintext is a canonical CBOR DeviceResponse; we walk + ``documents[0].issuerSigned.issuerAuth`` and assert it round-trips the + Access Document the reader was provisioned with. IV layout per applet (StepUpApplet.processExchange / processEnvelope): reader -> device : 0x00*8 || counter(4B BE) device -> reader : 0x00*7 || 0x01 || counter(4B BE) -Both counters init to 1; each advances by 1 after use. +Both counters init to 1; each advances by 1 after use. EXCHANGE now consumes +deviceCounter=1 (encrypted Reader Status response), so the ENVELOPE response +ciphertext decrypts under deviceCounter=2. """ +import cbor2 from cryptography.hazmat.primitives.ciphers.aead import AESGCM from aliro_harness.reader.crypto import derive_step_up_session_keys @@ -19,12 +33,34 @@ from aliro_harness.reader.transaction import Transmit STEP_UP_AID = bytes.fromhex("A000000909ACCE5502") SW_OK = 0x9000 +SW1_MORE_DATA = 0x61 +INS_GET_RESPONSE = 0xC0 CLA_ISO = 0x00 CLA_PROPRIETARY = 0x80 INS_EXCHANGE = 0xC9 INS_ENVELOPE = 0xC3 +# Canonical-CBOR DeviceRequest the M2D.1 applet parser accepts: +# {"version":"1.0", +# "docRequests":[{"itemsRequest": }]} +# Built once at import so each call ships the same byte sequence the +# applet's DeviceRequestParserTest pins as VALID. +_DEVICE_REQUEST = cbor2.dumps( + { + "version": "1.0", + "docRequests": [ + { + "itemsRequest": cbor2.dumps( + {"docType": "org.iso.18013.5.1.mDL", "nameSpaces": {}}, + canonical=True, + ) + } + ], + }, + canonical=True, +) + def _iv_reader(counter: int) -> bytes: return b"\x00" * 8 + counter.to_bytes(4, "big") @@ -34,7 +70,36 @@ def _iv_device(counter: int) -> bytes: return b"\x00" * 7 + b"\x01" + counter.to_bytes(4, "big") -def verify_step_up_m1(transmit: Transmit, step_up_sk: bytes) -> tuple[bool, str]: +def _drain_chaining(transmit: Transmit, first_body: bytes, first_sw: int) -> tuple[bytes, int]: + """Follow ISO 7816-4 SW=61xx response chaining until a terminal SW. Returns + the concatenated body and the final SW. SW2 advertises bytes remaining + (0x00 means "256 or more"; the applet caps at 0xFF). On a non-61xx SW + we return whatever's been accumulated so far so the caller can produce a + clear error message.""" + body = bytearray(first_body) + sw = first_sw + while (sw >> 8) == SW1_MORE_DATA: + le = sw & 0xFF # 0x00 -> request 256, else the advertised count + apdu = bytes([CLA_ISO, INS_GET_RESPONSE, 0x00, 0x00, le]) + chunk, sw = transmit(apdu) + body.extend(chunk) + return bytes(body), sw + + +def verify_step_up_m2( + transmit: Transmit, step_up_sk: bytes, expected_ad: bytes +) -> tuple[bool, str]: + """Drives the M2 step-up round-trip and asserts the applet hands back our + Access Document inside the DeviceResponse. + + Args: + transmit: PC/SC transport (bytes APDU -> (response, SW)). + step_up_sk: 32 B StepUpSK from AUTH1 (decrypted derived_keys_volatile). + expected_ad: Access Document bytes the reader was provisioned with; + compared against the issuerAuth field of the decrypted DeviceResponse. + + Returns ``(ok, message)``. On failure ``message`` names the failed step. + """ sk_device, sk_reader = derive_step_up_session_keys(step_up_sk) reader_counter = 1 device_counter = 1 @@ -45,30 +110,74 @@ def verify_step_up_m1(transmit: Transmit, step_up_sk: bytes) -> tuple[bool, str] if sw != SW_OK: return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}" - # M1B.1 -- EXCHANGE: any plaintext, expect 9000+empty. - pt = b"\x00" # one byte plaintext to exercise the decrypt path - ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None) + # M2E.1 + M2E.2 -- EXCHANGE: ship a Reader Status sub-event REQUEST + # ([sub_event_id=0x01, payload_len=0x00]), expect a 19 B encrypted Reader + # Status sub-event RESPONSE back ([sub_event_id=0x01, status=0x00, + # payload_len=0x00] under SKDevice + deviceIv(1)). + request_pt = b"\x01\x00" + ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), request_pt, None) apdu = bytes([CLA_PROPRIETARY, INS_EXCHANGE, 0x00, 0x00, len(ct)]) + ct + b"\x00" data, sw = transmit(apdu) if sw != SW_OK: - return False, f"M1B.1 EXCHANGE failed: SW=0x{sw:04X}" - if len(data) != 0: - return False, f"M1B.1 EXCHANGE expected empty body, got {len(data)}B: {data.hex()}" - reader_counter += 1 - - # M1C.1 -- ENVELOPE: any plaintext, expect 17B response decrypting to 0xA0. - ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None) - apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00" - data, sw = transmit(apdu) - if sw != SW_OK: - return False, f"M1C.1 ENVELOPE failed: SW=0x{sw:04X}" - if len(data) != 17: - return False, f"M1C.1 ENVELOPE expected 17B response, got {len(data)}B" + return False, f"M2 EXCHANGE failed: SW=0x{sw:04X}" + if len(data) != 19: + return False, f"M2 EXCHANGE expected 19B response, got {len(data)}B: {data.hex()}" try: - plaintext = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None) + exchange_pt = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None) except Exception as e: - return False, f"M1C.1 response decrypt failed (tag/key mismatch): {e}" - if plaintext != b"\xA0": - return False, f"M1C.1 response plaintext expected 0xA0, got {plaintext.hex()}" + return False, f"M2 EXCHANGE response decrypt failed (tag/key mismatch): {e}" + if exchange_pt != b"\x01\x00\x00": + return False, ( + "M2 EXCHANGE response plaintext mismatch: expected " + f"[0x01, 0x00, 0x00], got {exchange_pt.hex()}" + ) + reader_counter += 1 + device_counter += 1 - return True, "M1 step-up verified (EXCHANGE+ENVELOPE round-trip)" + # M2 -- ENVELOPE: ship a valid CBOR DeviceRequest, drain GET RESPONSE + # chaining, decrypt under deviceCounter=2, and assert the round-tripped AD. + ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), _DEVICE_REQUEST, None) + apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00" + first_body, first_sw = transmit(apdu) + full_body, sw = _drain_chaining(transmit, first_body, first_sw) + if sw != SW_OK: + return False, f"M2 ENVELOPE/GET RESPONSE failed: SW=0x{sw:04X} after {len(full_body)}B" + + try: + plaintext = AESGCM(sk_device).decrypt(_iv_device(device_counter), full_body, None) + except Exception as e: + return False, f"M2 ENVELOPE response decrypt failed (tag/key mismatch): {e}" + + try: + resp = cbor2.loads(plaintext) + except Exception as e: + return False, f"M2 ENVELOPE plaintext is not valid CBOR: {e}" + + try: + documents = resp["documents"] + issuer_signed = documents[0]["issuerSigned"] + issuer_auth = issuer_signed["issuerAuth"] + except (KeyError, TypeError, IndexError) as e: + return False, f"M2 DeviceResponse missing documents[0].issuerSigned.issuerAuth: {e}" + + # The applet splices the AD verbatim under issuerAuth, but cbor2 decoded it + # into a 4-element list. Re-encode canonically for byte-equality with the + # provisioned AD (trustgen produces canonical CBOR ADs). Fall back to + # element-wise comparison on the decoded list to avoid false negatives + # from harmless canonical-encoding drift (e.g. tag wrappers). + ad_round_trip = cbor2.dumps(issuer_auth, canonical=True) + if ad_round_trip != expected_ad: + try: + expected_decoded = cbor2.loads(expected_ad) + except Exception: + return False, ( + "M2 issuerAuth mismatch: round-tripped bytes differ from " + f"expected AD and expected AD isn't valid CBOR ({len(expected_ad)}B)" + ) + if issuer_auth != expected_decoded: + return False, ( + "M2 issuerAuth mismatch vs. provisioned Access Document " + f"(got {len(ad_round_trip)}B, expected {len(expected_ad)}B)" + ) + + return True, "M2 step-up verified (EXCHANGE + ENVELOPE Access Document round-trip)" diff --git a/harness/tests/test_personalizer_apdus.py b/harness/tests/test_personalizer_apdus.py index b512418..68a7274 100644 --- a/harness/tests/test_personalizer_apdus.py +++ b/harness/tests/test_personalizer_apdus.py @@ -5,12 +5,14 @@ import pytest from aliro_harness.personalizer.apdus import ( CLA_PROPRIETARY, INS_COMMIT, + INS_SET_CRED_ISSUER_PUBK, INS_SET_CRED_PRIV, INS_SET_CRED_PUBK, INS_SET_READER_PUBK, PROVISIONING_AID, commit_apdu, select_provisioning_apdu, + set_credential_issuer_pubk_apdu, set_credential_priv_apdu, set_credential_pubk_apdu, set_reader_pubk_apdu, @@ -55,6 +57,23 @@ def test_set_reader_pubk_apdu(): assert apdu == bytes([CLA_PROPRIETARY, INS_SET_READER_PUBK, 0x00, 0x00, 0x40]) + pub +def test_set_credential_issuer_pubk_apdu(): + pub = bytes(range(64)) + apdu = set_credential_issuer_pubk_apdu(pub) + # CLA INS P1 P2 Lc data — INS 0x25, same 64B x||y shape as cred_pubk + assert apdu == bytes([CLA_PROPRIETARY, INS_SET_CRED_ISSUER_PUBK, 0x00, 0x00, 0x40]) + pub + + +def test_set_credential_issuer_pubk_rejects_wrong_length(): + with pytest.raises(ValueError, match="64B"): + set_credential_issuer_pubk_apdu(bytes(63)) + + +def test_ins_set_cred_issuer_pubk_constant_matches_applet(): + """Lock-down: applet PersonalizationApplet.INS_SET_CREDENTIAL_ISSUER_PUBK = 0x25.""" + assert INS_SET_CRED_ISSUER_PUBK == 0x25 + + def test_commit_apdu_has_no_data_field(): apdu = commit_apdu() assert apdu == bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00]) diff --git a/harness/tests/test_personalizer_orchestrator.py b/harness/tests/test_personalizer_orchestrator.py index 988b3ae..753ca22 100644 --- a/harness/tests/test_personalizer_orchestrator.py +++ b/harness/tests/test_personalizer_orchestrator.py @@ -8,6 +8,7 @@ from aliro_harness.issuer.access_document import build_access_document from aliro_harness.personalizer.apdus import ( INS_COMMIT, INS_FINALIZE_ACCESS_DOC, + INS_SET_CRED_ISSUER_PUBK, INS_SET_CRED_PRIV, INS_SET_CRED_PUBK, INS_SET_READER_PUBK, @@ -46,6 +47,7 @@ def artifacts() -> TrustArtifacts: credential_priv=extract_priv_scalar(cred), credential_pubk_xy=extract_pub_xy(cred.public_key()), reader_pubk_xy=extract_pub_xy(reader.public_key()), + credential_issuer_pub=extract_pub_xy(issuer.public_key()), access_document=build_access_document( issuer_private_key=issuer, access_credential_public_key=cred.public_key(), @@ -58,17 +60,35 @@ def test_full_sequence_in_expected_order(artifacts): personalize_card(transport, artifacts) insns = [apdu[1] for apdu in transport.sent] - # SELECT (0xA4) → SET_PRIV → SET_PUBK → SET_READER_PUBK → WRITE_AD+ → FINALIZE_AD → COMMIT + # SELECT (0xA4) → SET_PRIV → SET_PUBK → SET_READER_PUBK → + # SET_CRED_ISSUER_PUBK → WRITE_AD+ → FINALIZE_AD → COMMIT. + # SET_CRED_ISSUER_PUBK must land BEFORE WRITE_AD chunks so that by + # the time FINALIZE runs, the issuer trust anchor is staged. assert insns[0] == 0xA4 assert insns[1] == INS_SET_CRED_PRIV assert insns[2] == INS_SET_CRED_PUBK assert insns[3] == INS_SET_READER_PUBK + assert insns[4] == INS_SET_CRED_ISSUER_PUBK # AD writes (variable count) then a single finalize, then commit. - assert all(i == INS_WRITE_ACCESS_DOC for i in insns[4:-2]) + assert all(i == INS_WRITE_ACCESS_DOC for i in insns[5:-2]) assert insns[-2] == INS_FINALIZE_ACCESS_DOC assert insns[-1] == INS_COMMIT +def test_credential_issuer_pubk_apdu_carries_exact_bytes(artifacts): + transport = FakeTransport() + personalize_card(transport, artifacts) + # Find the INS_SET_CRED_ISSUER_PUBK APDU. + issuer_apdu = next(a for a in transport.sent if a[1] == INS_SET_CRED_ISSUER_PUBK) + # CLA INS P1 P2 Lc data + assert issuer_apdu[0] == 0x80 + assert issuer_apdu[2] == 0x00 + assert issuer_apdu[3] == 0x00 + assert issuer_apdu[4] == 0x40 # Lc = 64 + assert issuer_apdu[5:] == artifacts.credential_issuer_pub + assert len(artifacts.credential_issuer_pub) == 64 + + def test_select_apdu_carries_provisioning_aid(artifacts): transport = FakeTransport() personalize_card(transport, artifacts) diff --git a/harness/tests/test_reader_step_up.py b/harness/tests/test_reader_step_up.py index 108feb0..c232619 100644 --- a/harness/tests/test_reader_step_up.py +++ b/harness/tests/test_reader_step_up.py @@ -1,9 +1,27 @@ -"""Smoke test for step-up key derivation parity with the applet.""" +"""Smoke tests for step-up reader verification. + +Covers: + - StepUpSK -> SKDevice/SKReader HKDF parity with stdlib RFC 5869. + - M2G.1: `verify_step_up_m2` drives the full M2 ENVELOPE round-trip: + ENVELOPE(CBOR DeviceRequest) -> 252B chunk + SW=61xx + GET RESPONSE -> remainder + SW=9000 + decrypt(StepUpSKDevice, deviceIv(1)) -> canonical DeviceResponse + extract documents[0].issuerSigned.issuerAuth, assert == expected_ad. + +The M2G.1 test uses a mock transmit driven by a canned ciphertext that the +test itself synthesizes via AESGCM — so it pins the reader's framing / +chaining / decrypt / CBOR-walk logic without depending on the applet build. +M2G.2 covers the real-hardware verdict. +""" import hashlib import hmac +import cbor2 +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from aliro_harness.reader.crypto import derive_step_up_session_keys +from aliro_harness.reader.step_up import verify_step_up_m2 def _hkdf_manual(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes: @@ -37,3 +55,207 @@ def test_derive_step_up_session_keys_matches_rfc5869(): assert sk_device == _hkdf_manual(step_up_sk, b"", b"SKDevice", 32) assert sk_reader == _hkdf_manual(step_up_sk, b"", b"SKReader", 32) + + +# ------------------------------------------------------------------------- # +# M2G.1: full ENVELOPE round-trip with GET RESPONSE chaining + AD assert. # +# ------------------------------------------------------------------------- # + + +# Same canonical-CBOR shape the applet's DeviceResponseBuilder produces and +# the M2G.1 verify function walks. Used to build a synthetic +# (decrypts-cleanly) ciphertext for the mock transmit. +def _build_fixed_device_response(ad_bytes: bytes) -> bytes: + """Mirror of applet DeviceResponseBuilder.build for the M2 fixed shape. + `issuerAuth` is the raw AD bytes spliced verbatim — we decode them so + cbor2 re-encodes canonically (matches the applet which assumes the AD + was canonical at personalization).""" + return cbor2.dumps( + { + "status": 0, + "version": "1.0", + "documents": [ + { + "docType": "org.iso.18013.5.1.mDL", + "issuerSigned": { + "issuerAuth": cbor2.loads(ad_bytes), + "nameSpaces": {}, + }, + } + ], + }, + canonical=True, + ) + + +def _fake_ad() -> bytes: + """A valid 4-element COSE_Sign1 stand-in, sized so the wrapped DeviceResponse + exceeds 252 B and forces GET RESPONSE chaining (the real applet's 388 B + case). The M2G.1 verifier doesn't crypto-verify the AD, only round-trips + its bytes — so a 4-element CBOR array of the right size is enough.""" + return cbor2.dumps( + [ + b"\xa0", # protected (empty map, bstr-wrapped) + {}, # unprotected (empty map) + b"\x11" * 200, # payload — pad to push the response over CHUNK_LEN + b"\x00" * 64, # signature (raw r||s, fixed P-256 width) + ], + canonical=True, + ) + + +def _iv_reader(counter: int) -> bytes: + return b"\x00" * 8 + counter.to_bytes(4, "big") + + +def _iv_device(counter: int) -> bytes: + return b"\x00" * 7 + b"\x01" + counter.to_bytes(4, "big") + + +class _MockTransmit: + """Records APDUs and replies from a scripted SELECT/EXCHANGE/ENVELOPE/ + GET RESPONSE sequence. + + EXCHANGE replies with the spec Reader Status sub-event RESPONSE + ([0x01, 0x00, 0x00]) encrypted under SKDevice + deviceIv(1). The applet + consumes deviceCounter=1 on this encrypt, so the subsequent ENVELOPE + response uses deviceCounter=2. + + ENVELOPE replies with the first 252 B of a pre-encrypted DeviceResponse + and SW=61xx; GET RESPONSE drains the rest.""" + + CHUNK_LEN = 252 + EXCHANGE_RESPONSE_PT = b"\x01\x00\x00" + + def __init__( + self, + sk_device: bytes, + sk_reader: bytes, + device_response_plaintext: bytes, + ): + self.sk_device = sk_device + self.sk_reader = sk_reader + # Pre-encrypt the EXCHANGE response under SKDevice + deviceIv(1). The + # applet's EXCHANGE handler consumes deviceCounter=1 first. + self.exchange_ct = AESGCM(sk_device).encrypt( + _iv_device(1), self.EXCHANGE_RESPONSE_PT, None + ) + # Pre-encrypt the DeviceResponse under SKDevice + deviceIv(2) — after + # EXCHANGE advanced the device counter from 1 -> 2. + self.ct = AESGCM(sk_device).encrypt( + _iv_device(2), device_response_plaintext, None + ) + self.ct_off = 0 + self.apdus: list[bytes] = [] + self.reader_counter = 1 + self.device_counter = 1 + # The Reader Status sub-event REQUEST the verifier is expected to send. + self.expected_exchange_pt = b"\x01\x00" + # The DeviceRequest the verifier is expected to send. + self.expected_request_pt = cbor2.dumps( + { + "version": "1.0", + "docRequests": [ + { + "itemsRequest": cbor2.dumps( + { + "docType": "org.iso.18013.5.1.mDL", + "nameSpaces": {}, + }, + canonical=True, + ) + } + ], + }, + canonical=True, + ) + + def __call__(self, apdu: bytes) -> tuple[bytes, int]: + self.apdus.append(apdu) + cla, ins = apdu[0], apdu[1] + + # SELECT 5502 (CLA=0x00 INS=0xA4 P1=0x04 P2=0x00 Lc=09 ...) + if cla == 0x00 and ins == 0xA4: + return b"", 0x9000 + + # EXCHANGE — decrypt [0x01, 0x00], reply with [0x01, 0x00, 0x00] + # encrypted under SKDevice + deviceIv(device_counter=1). + if cla == 0x80 and ins == 0xC9: + lc = apdu[4] + body = apdu[5 : 5 + lc] + pt = AESGCM(self.sk_reader).decrypt( + _iv_reader(self.reader_counter), body, None + ) + assert pt == self.expected_exchange_pt, ( + f"EXCHANGE pt mismatch:\n got={pt.hex()}\n want={self.expected_exchange_pt.hex()}" + ) + self.reader_counter += 1 + self.device_counter += 1 + return self.exchange_ct, 0x9000 + + # ENVELOPE — decrypt DeviceRequest under reader-counter=2, ship first + # chunk of the response encrypted under deviceCounter=2. + if cla == 0x00 and ins == 0xC3: + lc = apdu[4] + body = apdu[5 : 5 + lc] + pt = AESGCM(self.sk_reader).decrypt( + _iv_reader(self.reader_counter), body, None + ) + assert pt == self.expected_request_pt, ( + f"ENVELOPE pt mismatch:\n got={pt.hex()}\n want={self.expected_request_pt.hex()}" + ) + self.reader_counter += 1 + chunk = self.ct[: self.CHUNK_LEN] + self.ct_off = self.CHUNK_LEN + remaining = len(self.ct) - self.CHUNK_LEN + assert remaining > 0, "test fixture must produce a chained response" + sw2 = 0xFF if remaining >= 0x100 else remaining + return chunk, 0x6100 | sw2 + + # GET RESPONSE — drain whatever's left. + if cla == 0x00 and ins == 0xC0: + chunk = self.ct[self.ct_off :] + self.ct_off = len(self.ct) + return chunk, 0x9000 + + raise AssertionError(f"unexpected APDU: {apdu.hex()}") + + +def _setup_fixture() -> tuple[bytes, bytes, _MockTransmit]: + step_up_sk = bytes(range(0xC0, 0xE0)) + sk_device, sk_reader = derive_step_up_session_keys(step_up_sk) + ad = _fake_ad() + device_response = _build_fixed_device_response(ad) + mock = _MockTransmit(sk_device, sk_reader, device_response) + return step_up_sk, ad, mock + + +def test_verify_step_up_m2_round_trips_access_document(): + step_up_sk, expected_ad, mock = _setup_fixture() + + ok, msg = verify_step_up_m2(mock, step_up_sk, expected_ad) + + assert ok, f"verify failed: {msg}" + + # Quick shape audit on the captured APDUs. + sent = mock.apdus + # SELECT, EXCHANGE, ENVELOPE, GET RESPONSE -> 4 APDUs. + assert len(sent) == 4, [a.hex() for a in sent] + assert sent[0][0:2] == bytes([0x00, 0xA4]) # SELECT + assert sent[1][0:2] == bytes([0x80, 0xC9]) # EXCHANGE + assert sent[2][0:2] == bytes([0x00, 0xC3]) # ENVELOPE + assert sent[3][0:2] == bytes([0x00, 0xC0]) # GET RESPONSE + # GET RESPONSE Le must equal the SW2 the applet handed us (136 remaining). + expected_le = len(mock.ct) - _MockTransmit.CHUNK_LEN + assert sent[3][4] == expected_le + + +def test_verify_step_up_m2_detects_ad_mismatch(): + step_up_sk, _, mock = _setup_fixture() + # Pass a different AD than the one wrapped into the canned DeviceResponse. + bogus_ad = cbor2.dumps([b"\xa0", {}, b"different", b"\x00" * 64], canonical=True) + + ok, msg = verify_step_up_m2(mock, step_up_sk, bogus_ad) + + assert not ok + assert "issuerAuth" in msg or "Access Document" in msg or "AD" in msg, msg