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:
@@ -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": <Access Document bytes verbatim>,
|
||||
* "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' < '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);
|
||||
}
|
||||
}
|
||||
@@ -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' < '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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user