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": [ 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):
+ * 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;
+ }
+}
+ *
+ */
+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.
+ *
+ *