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/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): + *

+ */ +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; + } +}