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,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