feat(applet): DeviceResponseBuilder (M2D.2)

Pure builder for the mdoc DeviceResponse (ISO 18013-5 / Aliro Table 8-22)
carrying the cached Access Document at
documents[0].issuerSigned.issuerAuth. Single fixed shape — one mDL
document, empty nameSpaces, no deviceSigned. AD bytes embedded verbatim
(not re-encoded), so the issuer's signed bytes pass through unchanged.

Map keys canonical-CBOR sorted (length-then-lex). Headers for the fixed
counts inlined (map(0/2/3), array(1)); tstr headers/values go through
StructuralCbor for the canonical length encoding.

For a 272 B AD the output is 372 B (100 B wrapper). Reference vector
generated from /home/work/aliro-trust/access_document.bin and asserted
byte-for-byte.

M2D.3 will wire this into StepUpApplet.processEnvelope behind GCM
encryption; M2D.4 adds GET RESPONSE chaining.
This commit is contained in:
michael
2026-06-17 16:24:03 -07:00
parent a7bc7e4478
commit 9e31da0cdc
2 changed files with 311 additions and 0 deletions

View File

@@ -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}.
*
* <p>The fixed shape (Aliro Step-Up only ever returns a single mDL document
* with empty nameSpaces and no deviceSigned):
* <pre>
* DeviceResponse = {
* "status": 0,
* "version": "1.0",
* "documents": [
* {
* "docType": "org.iso.18013.5.1.mDL",
* "issuerSigned": {
* "issuerAuth": &lt;Access Document bytes verbatim&gt;,
* "nameSpaces": {}
* }
* }
* ]
* }
* </pre>
*
* <p>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' &lt; 'n'
* so issuerAuth precedes nameSpaces.
*
* <p>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).
*
* <p>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.
*
* <p>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": [ <document> ]
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": <AD bytes verbatim>
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);
}
}

View File

@@ -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}.
*
* <p>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.
*
* <p>Canonical key ordering at each map level (RFC 8949 §4.2.1: sort by
* encoded-key-length then lexicographically):
* <ul>
* <li>DeviceResponse: {@code "status"} (7B), {@code "version"} (8B),
* {@code "documents"} (11B)</li>
* <li>document: {@code "docType"} (8B), {@code "issuerSigned"} (13B)</li>
* <li>issuerSigned: {@code "issuerAuth"} (11B), {@code "nameSpaces"} (11B,
* 'i' &lt; 'n' so issuerAuth first)</li>
* </ul>
*/
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.
*
* <p>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;
}
}