From a7bc7e4478753c992b1498ee5073e50c4d55d0dd Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 17 Jun 2026 16:19:12 -0700 Subject: [PATCH] feat(applet): DeviceRequestParser structural validation (M2D.1) --- .../aliro/DeviceRequestParser.java | 317 ++++++++++++++++++ .../aliro/DeviceRequestParserTest.java | 91 +++++ 2 files changed, 408 insertions(+) create mode 100644 applet/src/main/java/com/dangerousthings/aliro/DeviceRequestParser.java create mode 100644 applet/src/test/java/com/dangerousthings/aliro/DeviceRequestParserTest.java diff --git a/applet/src/main/java/com/dangerousthings/aliro/DeviceRequestParser.java b/applet/src/main/java/com/dangerousthings/aliro/DeviceRequestParser.java new file mode 100644 index 0000000..1102357 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/DeviceRequestParser.java @@ -0,0 +1,317 @@ +package com.dangerousthings.aliro; + +import javacard.framework.ISO7816; +import javacard.framework.ISOException; +import javacard.framework.Util; + +/** + * Structural validator for the inbound mdoc DeviceRequest carried in M2's + * Step-Up ENVELOPE chain (ISO 18013-5 §8.3.2.1): + * + *
+ * DeviceRequest = {
+ *     "version": tstr,        ; must be "1.0"
+ *     "docRequests": [+ DocRequest]
+ * }
+ *
+ * DocRequest = {
+ *     "itemsRequest": bstr,   ; encoded ItemsRequest — opaque to us
+ *     ? "readerAuth": COSE_Sign1
+ * }
+ * 
+ * + *

Aliro's M2 reader profile asks for a fixed Access Document; we don't + * interpret docType / nameSpaces / readerAuth. The job here is to lock the + * wire shape so malformed input fails fast with the right SW instead of being + * silently swallowed. + * + *

Validation policy: + *

+ * + *

Allocation-free: the caller passes the 4-byte scratch used by + * {@link StructuralCbor#decodeHeader}'s argument output. M2D.3 will wire + * {@link StepUpApplet} to call this before discarding the request body and + * returning the single Access Document we hold. + */ +final class DeviceRequestParser { + + // ASCII bytes for CBOR tstr keys / values. Using literal arrays keeps the + // parser allocation-free and avoids any UTF-8 encoder dependency on-card. + private static final byte[] KEY_VERSION = + { 'v', 'e', 'r', 's', 'i', 'o', 'n' }; + private static final byte[] KEY_DOC_REQUESTS = + { 'd', 'o', 'c', 'R', 'e', 'q', 'u', 'e', 's', 't', 's' }; + private static final byte[] KEY_ITEMS_REQUEST = + { 'i', 't', 'e', 'm', 's', 'R', 'e', 'q', 'u', 'e', 's', 't' }; + private static final byte[] VERSION_1_0 = { '1', '.', '0' }; + + private static final short MAJOR_BSTR = 2; + private static final short MAJOR_TSTR = 3; + private static final short MAJOR_ARRAY = 4; + private static final short MAJOR_MAP = 5; + + private DeviceRequestParser() { + // Utility class — no instances. + } + + /** + * Structurally validates an mdoc DeviceRequest at + * {@code buf[off..off+len)}. Returns normally iff the request is + * shape-valid for M2's "return the only Access Document we have" + * behavior — no value is returned because no information from the + * request body affects the response. + * + * @param buf buffer holding the encoded DeviceRequest + * @param off offset of the first CBOR byte + * @param len length of the request in bytes + * @param scratch4 4-byte scratch for {@link StructuralCbor#decodeHeader} + * @param scratch4Off offset within {@code scratch4} + * @throws ISOException SW_CONDITIONS_NOT_SATISFIED (0x6985) on unsupported + * version; SW_DATA_INVALID (0x6984) on malformed CBOR + * or wrong shape (incl. missing required keys). + */ + static void validate( + byte[] buf, short off, short len, + byte[] scratch4, short scratch4Off) { + + // Top-level header: must be a map. + short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + if (major != MAJOR_MAP) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short entries = readArgAsShort(scratch4, scratch4Off); + + short cursor = (short) (off + consumed); + short remaining = (short) (len - consumed); + + boolean sawVersion = false; + boolean sawDocRequests = false; + + for (short i = 0; i < entries; i++) { + // --- Key --- + short keyHeader = StructuralCbor.decodeHeader( + buf, cursor, remaining, scratch4, scratch4Off); + short keyMajor = (short) ((keyHeader >> 8) & 0x07); + short keyConsumed = (short) (keyHeader & 0xFF); + short keyLen = readArgAsShort(scratch4, scratch4Off); + + short keyBodyOff = (short) (cursor + keyConsumed); + short keyTotal = (short) (keyConsumed + keyLen); + if (keyMajor != MAJOR_TSTR || keyTotal > remaining) { + // Non-string keys are out of spec for DeviceRequest; treat as + // malformed. (DeviceRequest is a string-keyed map.) + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + cursor = (short) (cursor + keyTotal); + remaining = (short) (remaining - keyTotal); + + // --- Value: dispatch on the key string --- + if (matches(buf, keyBodyOff, keyLen, KEY_VERSION)) { + short valConsumed = validateVersionValue( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + valConsumed); + remaining = (short) (remaining - valConsumed); + sawVersion = true; + } else if (matches(buf, keyBodyOff, keyLen, KEY_DOC_REQUESTS)) { + short valConsumed = validateDocRequestsValue( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + valConsumed); + remaining = (short) (remaining - valConsumed); + sawDocRequests = true; + } else { + // Unknown top-level key — skip its value for + // forward-compatibility with spec additions. + short skip = StructuralCbor.elementSpan( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + skip); + remaining = (short) (remaining - skip); + } + } + + if (!sawVersion || !sawDocRequests) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + } + + /** + * Validates the value associated with the {@code "version"} key: must be + * a tstr equal to {@code "1.0"}. Returns the number of bytes consumed by + * the value (header + payload). Mismatched value → SW_CONDITIONS_NOT_SATISFIED. + */ + private static short validateVersionValue( + byte[] buf, short off, short len, + byte[] scratch4, short scratch4Off) { + + short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + short payload = readArgAsShort(scratch4, scratch4Off); + + if (major != MAJOR_TSTR) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short total = (short) (consumed + payload); + if (total > len) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + if (!matches(buf, (short) (off + consumed), payload, VERSION_1_0)) { + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } + return total; + } + + /** + * Validates the value associated with the {@code "docRequests"} key: must + * be a non-empty array where every entry is a map containing an + * {@code "itemsRequest"} bstr. Returns total bytes consumed. + */ + private static short validateDocRequestsValue( + byte[] buf, short off, short len, + byte[] scratch4, short scratch4Off) { + + short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + short count = readArgAsShort(scratch4, scratch4Off); + + if (major != MAJOR_ARRAY || count < 1) { + // Empty docRequests array is illegal per the [+ DocRequest] + // one-or-more CDDL marker. + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + + short cursor = (short) (off + consumed); + short remaining = (short) (len - consumed); + short total = consumed; + + for (short i = 0; i < count; i++) { + short entrySpan = validateDocRequestEntry( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + entrySpan); + remaining = (short) (remaining - entrySpan); + total = (short) (total + entrySpan); + } + return total; + } + + /** + * Validates a single DocRequest map. Walks its keys; on + * {@code "itemsRequest"} asserts the value is a bstr (contents opaque), + * other keys (e.g. {@code "readerAuth"}) skip via {@code elementSpan}. + * Returns the total bytes the entry occupies. + */ + private static short validateDocRequestEntry( + byte[] buf, short off, short len, + byte[] scratch4, short scratch4Off) { + + short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + short entries = readArgAsShort(scratch4, scratch4Off); + + if (major != MAJOR_MAP) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + + short cursor = (short) (off + consumed); + short remaining = (short) (len - consumed); + short total = consumed; + boolean sawItemsRequest = false; + + for (short i = 0; i < entries; i++) { + // Key (must be tstr per DeviceRequest CDDL). + short keyHeader = StructuralCbor.decodeHeader( + buf, cursor, remaining, scratch4, scratch4Off); + short keyMajor = (short) ((keyHeader >> 8) & 0x07); + short keyConsumed = (short) (keyHeader & 0xFF); + short keyLen = readArgAsShort(scratch4, scratch4Off); + + short keyBodyOff = (short) (cursor + keyConsumed); + short keyTotal = (short) (keyConsumed + keyLen); + if (keyMajor != MAJOR_TSTR || keyTotal > remaining) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + cursor = (short) (cursor + keyTotal); + remaining = (short) (remaining - keyTotal); + total = (short) (total + keyTotal); + + if (matches(buf, keyBodyOff, keyLen, KEY_ITEMS_REQUEST)) { + // Value must be a bstr — contents are an opaque encoded + // ItemsRequest we deliberately do not decode. + short vHeader = StructuralCbor.decodeHeader( + buf, cursor, remaining, scratch4, scratch4Off); + short vMajor = (short) ((vHeader >> 8) & 0x07); + short vConsumed = (short) (vHeader & 0xFF); + short vLen = readArgAsShort(scratch4, scratch4Off); + short vTotal = (short) (vConsumed + vLen); + if (vMajor != MAJOR_BSTR || vTotal > remaining) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + cursor = (short) (cursor + vTotal); + remaining = (short) (remaining - vTotal); + total = (short) (total + vTotal); + sawItemsRequest = true; + } else { + // Skip unrecognized key's value (e.g. readerAuth COSE_Sign1). + short skip = StructuralCbor.elementSpan( + buf, cursor, remaining, scratch4, scratch4Off); + cursor = (short) (cursor + skip); + remaining = (short) (remaining - skip); + total = (short) (total + skip); + } + } + + if (!sawItemsRequest) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + return total; + } + + /** + * Constant-shape ASCII string compare: returns true iff + * {@code buf[off..off+len) == expected[0..expected.length)}. Used to match + * CBOR tstr keys / values against the literal byte arrays at the top of + * this file. + */ + private static boolean matches(byte[] buf, short off, short len, byte[] expected) { + if (len != (short) expected.length) { + return false; + } + return Util.arrayCompare(buf, off, expected, (short) 0, len) == 0; + } + + /** + * Reads the 4-byte big-endian argument {@link StructuralCbor#decodeHeader} + * wrote into {@code scratch4} and returns it as a short. Values > 0x7FFF + * are rejected as malformed input — Aliro mdoc payloads fit in a + * short-bounded buffer, mirroring StructuralCbor's own bound. + */ + private static short readArgAsShort(byte[] scratch, short off) { + short hi = (short) (((scratch[off] & 0xFF) << 8) + | (scratch[(short) (off + 1)] & 0xFF)); + if (hi != 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short v = (short) (((scratch[(short) (off + 2)] & 0xFF) << 8) + | (scratch[(short) (off + 3)] & 0xFF)); + if (v < 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + return v; + } +} diff --git a/applet/src/test/java/com/dangerousthings/aliro/DeviceRequestParserTest.java b/applet/src/test/java/com/dangerousthings/aliro/DeviceRequestParserTest.java new file mode 100644 index 0000000..ba59574 --- /dev/null +++ b/applet/src/test/java/com/dangerousthings/aliro/DeviceRequestParserTest.java @@ -0,0 +1,91 @@ +package com.dangerousthings.aliro; + +import javacard.framework.ISO7816; +import javacard.framework.ISOException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link DeviceRequestParser} — structural validation of the inbound + * mdoc DeviceRequest (ISO 18013-5 §8.3.2.1). + * + *

M2D's ENVELOPE handler receives a DeviceRequest, must confirm its shape + * before discarding the contents (we always return our single Access Document), + * and reject malformed input with the canonical SW codes: + *

+ * + *

Test vectors were generated via cbor2 (canonical encoding); see the + * generator script in the M2D.1 plan. The Python equivalents: + *

+ *   VALID         = cbor2.dumps({"version":"1.0","docRequests":[{"itemsRequest":cbor2.dumps({"docType":"org.iso.18013.5.1.mDL","nameSpaces":{}})}]}, canonical=True)
+ *   BAD_VERSION   = cbor2.dumps({"version":"2.0","docRequests":[{"itemsRequest":b"\x40"}]}, canonical=True)
+ *   TRUNCATED     = VALID[:8]
+ * 
+ */ +class DeviceRequestParserTest { + + /** + * Canonical CBOR for a minimal-but-valid DeviceRequest: + * {@code {"version":"1.0","docRequests":[{"itemsRequest":}]}} + * with itemsRequest containing an inner CBOR + * {@code {"docType":"org.iso.18013.5.1.mDL","nameSpaces":{}}}. + */ + private static final byte[] VALID = hex( + "a26776657273696f6e63312e306b646f63526571756573747381" + + "a16c6974656d7352657175657374582ba267646f6354797065" + + "756f72672e69736f2e31383031332e352e312e6d444c" + + "6a6e616d65537061636573a0"); + + /** Same shape as VALID but version tstr value is "2.0". */ + private static final byte[] BAD_VERSION = hex( + "a26776657273696f6e63322e306b646f63526571756573747381" + + "a16c6974656d73526571756573744140"); + + /** First 8 bytes of VALID — truncated mid-key, malformed CBOR. */ + private static final byte[] TRUNCATED = hex("a26776657273696f"); + + @Test + void parseDeviceRequest_validShape_returnsOk() { + byte[] scratch = new byte[4]; + // Should not throw. + DeviceRequestParser.validate( + VALID, (short) 0, (short) VALID.length, + scratch, (short) 0); + } + + @Test + void parseDeviceRequest_unknownVersion_throwsConditionsNotSatisfied() { + byte[] scratch = new byte[4]; + ISOException ex = assertThrows(ISOException.class, () -> + DeviceRequestParser.validate( + BAD_VERSION, (short) 0, (short) BAD_VERSION.length, + scratch, (short) 0)); + assertEquals(ISO7816.SW_CONDITIONS_NOT_SATISFIED, ex.getReason(), + "unknown version must throw SW_CONDITIONS_NOT_SATISFIED (0x6985)"); + } + + @Test + void parseDeviceRequest_malformedCbor_throwsDataInvalid() { + byte[] scratch = new byte[4]; + ISOException ex = assertThrows(ISOException.class, () -> + DeviceRequestParser.validate( + TRUNCATED, (short) 0, (short) TRUNCATED.length, + scratch, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason(), + "truncated CBOR must throw SW_DATA_INVALID (0x6984)"); + } + + 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; + } +}