feat(applet): DeviceRequestParser structural validation (M2D.1)

This commit is contained in:
michael
2026-06-17 16:19:12 -07:00
parent d5945be60e
commit a7bc7e4478
2 changed files with 408 additions and 0 deletions

View File

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