feat(applet): DeviceRequestParser structural validation (M2D.1)
This commit is contained in:
@@ -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):
|
||||
*
|
||||
* <pre>
|
||||
* DeviceRequest = {
|
||||
* "version": tstr, ; must be "1.0"
|
||||
* "docRequests": [+ DocRequest]
|
||||
* }
|
||||
*
|
||||
* DocRequest = {
|
||||
* "itemsRequest": bstr, ; encoded ItemsRequest — opaque to us
|
||||
* ? "readerAuth": COSE_Sign1
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Validation policy:
|
||||
* <ul>
|
||||
* <li>Top-level must be a CBOR map (major type 5).</li>
|
||||
* <li>Recognized keys: {@code "version"} (tstr) and {@code "docRequests"}
|
||||
* (array). Both required. Any other top-level key is skipped via
|
||||
* {@link StructuralCbor#elementSpan} for forward compatibility.</li>
|
||||
* <li>{@code "version"} value must equal {@code "1.0"} —
|
||||
* {@code SW_CONDITIONS_NOT_SATISFIED (0x6985)} otherwise.</li>
|
||||
* <li>{@code "docRequests"} must be a non-empty array; each entry must be
|
||||
* a map containing an {@code "itemsRequest"} bstr. Other entry keys
|
||||
* (e.g. {@code "readerAuth"}) skip via {@code elementSpan}.</li>
|
||||
* <li>Anything else — wrong major type, missing required key, truncated
|
||||
* input, unknown CBOR header form — propagates as
|
||||
* {@code SW_DATA_INVALID (0x6984)} via {@link StructuralCbor}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>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:
|
||||
* <ul>
|
||||
* <li>{@code SW_DATA_INVALID (0x6984)} — malformed CBOR or wrong shape</li>
|
||||
* <li>{@code SW_CONDITIONS_NOT_SATISFIED (0x6985)} — unsupported version</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Test vectors were generated via cbor2 (canonical encoding); see the
|
||||
* generator script in the M2D.1 plan. The Python equivalents:
|
||||
* <pre>
|
||||
* 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]
|
||||
* </pre>
|
||||
*/
|
||||
class DeviceRequestParserTest {
|
||||
|
||||
/**
|
||||
* Canonical CBOR for a minimal-but-valid DeviceRequest:
|
||||
* {@code {"version":"1.0","docRequests":[{"itemsRequest":<bstr>}]}}
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user