diff --git a/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java b/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java index 669e231..8cdc44f 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java @@ -23,8 +23,8 @@ import javacard.framework.ISOException; * matching the codebase's "fail closed on malformed input" convention * (see {@link PersonalizationApplet} and {@link CoseVerifier}). * - *

M2B.1 implements {@link #decodeHeader} only — element span walking, - * IssuerAuth location, and canonical encoders land in M2B.2/M2B.3/M2B.4. + *

M2B.1 implements {@link #decodeHeader}; M2B.2 adds {@link #elementSpan}. + * IssuerAuth location and canonical encoders land in M2B.3/M2B.4. */ final class StructuralCbor { @@ -126,4 +126,133 @@ final class StructuralCbor { return (short) ((major << 8) | consumed); } + + /** + * Returns the total byte length of the CBOR data item that begins at + * {@code buf[bufOff]}, recursing into arrays / maps / tags so the result + * spans the complete (possibly nested) element. + * + *

By major type (per RFC 8949 §3): + *

+ * + *

Implementation is recursive. Worst-case Aliro mdoc nesting depth is + * ~5 (DeviceResponse map → documents array → entry map → issuerSigned map + * → issuerAuth array), so the JC stack budget is comfortable. Length / + * count fields use the 4B big-endian argument written into + * {@code argScratch[argScratchOff..argScratchOff+4)} by {@code decodeHeader}. + * The scratch is overwritten at every recursive descent — that's fine + * because the parent has already consumed its argument before recursing. + * + * @param buf buffer holding the CBOR stream + * @param bufOff offset of the first byte of the element + * @param bufLen number of valid bytes remaining at {@code bufOff} + * @param argScratch 4-byte scratch for {@code decodeHeader}'s argument + * @param argScratchOff offset within {@code argScratch} (4 bytes needed) + * @return total bytes the element (including children) occupies + * @throws ISOException SW_DATA_INVALID on malformed input — either + * {@link #decodeHeader} rejected a header, the + * declared bstr / tstr payload runs past {@code bufLen}, + * or a child element runs past the parent's bounds. + */ + static short elementSpan( + byte[] buf, short bufOff, short bufLen, + byte[] argScratch, short argScratchOff) { + + short header = decodeHeader(buf, bufOff, bufLen, argScratch, argScratchOff); + short major = (short) ((header >> 8) & 0x07); + short consumed = (short) (header & 0xFF); + + // 4-byte big-endian read of the argument decodeHeader just wrote. + // Aliro mdoc payloads fit in a short-sized buffer, so we cap argument + // values at Short.MAX_VALUE when used as a length / count; any larger + // value is malformed input and rejected. + short argument = readArgAsShort(argScratch, argScratchOff); + + // Span starts with the header bytes; children (if any) extend it. + short span = consumed; + + if (major == 0 || major == 1) { + // uint / nint — header is the whole element. + return span; + } + if (major == 2 || major == 3) { + // bstr / tstr — header + payload bytes. + short total = (short) (span + argument); + // Reject if the declared payload extends past bufLen, otherwise a + // truncated bstr would silently report a span larger than the + // available buffer and confuse later callers. + if (argument < 0 || total < 0 || total > bufLen) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + return total; + } + if (major == 4) { + // Array of `argument` children. + for (short i = 0; i < argument; i++) { + short childOff = (short) (bufOff + span); + short childLen = (short) (bufLen - span); + if (childLen <= 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short childSpan = elementSpan( + buf, childOff, childLen, argScratch, argScratchOff); + span = (short) (span + childSpan); + } + return span; + } + if (major == 5) { + // Map of `argument` key/value pairs — 2 * argument items. + short items = (short) (argument << 1); + for (short i = 0; i < items; i++) { + short childOff = (short) (bufOff + span); + short childLen = (short) (bufLen - span); + if (childLen <= 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short childSpan = elementSpan( + buf, childOff, childLen, argScratch, argScratchOff); + span = (short) (span + childSpan); + } + return span; + } + // major == 6 (tag) — header + single tagged element. + short childOff = (short) (bufOff + span); + short childLen = (short) (bufLen - span); + if (childLen <= 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short childSpan = elementSpan( + buf, childOff, childLen, argScratch, argScratchOff); + return (short) (span + childSpan); + } + + /** + * Reads the 4-byte big-endian argument {@code decodeHeader} wrote into + * the scratch and returns it as a short. Argument values larger than + * {@code Short.MAX_VALUE} (32767) are out of buffer range for Aliro mdoc + * payloads and rejected as malformed input. + */ + private static short readArgAsShort(byte[] scratch, short off) { + short hi = (short) (((scratch[off] & 0xFF) << 8) + | (scratch[(short) (off + 1)] & 0xFF)); + if (hi != 0) { + // 32-bit argument with any of the upper 16 bits set won't fit + // a short used as length / count. + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + short v = (short) (((scratch[(short) (off + 2)] & 0xFF) << 8) + | (scratch[(short) (off + 3)] & 0xFF)); + if (v < 0) { + // Top bit set = value > Short.MAX_VALUE. + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + return v; + } } diff --git a/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java b/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java index bc8bbbc..02295d7 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java @@ -251,6 +251,115 @@ class StructuralCborTest { assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); } + // --------------------------------------------------------------------- + // elementSpan tests (M2B.2) — span of a complete CBOR element including + // nested children, used by M2B.3 / M2D.1 to walk IssuerSigned / docRequests. + // --------------------------------------------------------------------- + + /** Shared 4B scratch for elementSpan's decodeHeader calls. */ + private final byte[] scratch = new byte[4]; + + @Test + void elementSpan_uintImmediate() { + // 0x17 = uint 23, immediate argument. One header byte, no payload. + byte[] buf = hex("17"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(1, span); + } + + @Test + void elementSpan_uintOneByteArgument() { + // 0x18 0x64 = uint 100. Header byte + 1B argument, no payload. + byte[] buf = hex("1864"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(2, span); + } + + @Test + void elementSpan_bstrEmpty() { + // 0x40 = bstr len 0. One header byte, zero payload. + byte[] buf = hex("40"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(1, span); + } + + @Test + void elementSpan_bstrLen3() { + // 0x43 0x01 0x02 0x03 = bstr h'010203'. Header + 3 payload bytes. + byte[] buf = hex("43010203"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(4, span); + } + + @Test + void elementSpan_tstrHello() { + // 0x65 + "hello" = tstr "hello". Header + 5 payload bytes. + byte[] buf = hex("6568656c6c6f"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(6, span); + } + + @Test + void elementSpan_arrayOfThreeUints() { + // 0x83 0x01 0x02 0x03 = [1, 2, 3]. Header + 3 single-byte children. + byte[] buf = hex("83010203"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(4, span); + } + + @Test + void elementSpan_nestedArray() { + // 0x82 0x82 0x01 0x02 0x03 = [[1, 2], 3]. Exercises recursion. + byte[] buf = hex("8282010203"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(5, span); + } + + @Test + void elementSpan_mapTwoUintPairs() { + // 0xa2 0x01 0x02 0x03 0x04 = {1: 2, 3: 4}. Header + 4 single-byte items. + byte[] buf = hex("a201020304"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(5, span); + } + + @Test + void elementSpan_mapWithBstrUintPairs() { + // 0xa2 0x43 010203 0x1864 0x41 ff 0x17 + // = { h'010203': 100, h'ff': 23 } + // Header(1) + bstr(4) + uint(2) + bstr(2) + uint(1) = 10. + byte[] buf = hex("a2 43 010203 1864 41 ff 17"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(10, span); + } + + @Test + void elementSpan_tagUint() { + // 0xc1 0x01 = tag(1, 1). Tag header + tagged element span. + byte[] buf = hex("c101"); + short span = StructuralCbor.elementSpan( + buf, (short) 0, (short) buf.length, scratch, (short) 0); + assertEquals(2, span); + } + + @Test + void elementSpan_honoursBufOff() { + // Pad with 4 junk bytes, then a 4-byte array [1,2,3]; span starts at offset 4. + byte[] buf = hex("deadbeef" + "83010203"); + short span = StructuralCbor.elementSpan( + buf, (short) 4, (short) (buf.length - 4), scratch, (short) 0); + assertEquals(4, span); + } + private static byte[] hex(String s) { s = s.replaceAll("\\s+", ""); byte[] out = new byte[s.length() / 2];