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);
}
}