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