diff --git a/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java b/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java
new file mode 100644
index 0000000..669e231
--- /dev/null
+++ b/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java
@@ -0,0 +1,129 @@
+package com.dangerousthings.aliro;
+
+import javacard.framework.ISO7816;
+import javacard.framework.ISOException;
+
+/**
+ * Structural CBOR utilities for the Aliro Access Document / DeviceResponse
+ * walk. CBOR is defined by RFC 8949; we implement only the canonical subset
+ * actually used by Aliro / ISO 18013-5 mdoc messages:
+ *
+ *
+ * - Major types 0..6 (uint, nint, bstr, tstr, array, map, tag) — major
+ * type 7 (floats / null / true / false) is rejected.
+ * - Argument sizes immediate (0..23), 1-byte, 2-byte, 4-byte — the
+ * 8-byte form is rejected because Aliro mdoc payloads fit comfortably
+ * in a short-bounded buffer and supporting it would force long
+ * arithmetic across the rest of the codec.
+ * - Definite lengths only — indefinite-length (additional info 31) is
+ * rejected; canonical Aliro mdoc encoding never uses it.
+ *
+ *
+ * All rejection paths throw {@code ISOException(SW_DATA_INVALID = 0x6984)},
+ * 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.
+ */
+final class StructuralCbor {
+
+ private StructuralCbor() {
+ // Utility class — no instances.
+ }
+
+ /**
+ * Decodes a single CBOR header at {@code buf[bufOff..bufOff+bufLen)} per
+ * RFC 8949 §3.
+ *
+ *
The header byte's top 3 bits are the major type and the bottom 5
+ * bits are the additional info; values 0..23 are the immediate argument,
+ * 24/25/26 mean a 1/2/4-byte big-endian uint argument follows. All
+ * other additional-info values are rejected.
+ *
+ *
Result encoding (single short, lazy):
+ *
+ * - high byte = major type (0..6)
+ * - low byte = bytes consumed (1, 2, 3, or 5)
+ *
+ * The argument value is written big-endian into
+ * {@code argOut[argOff..argOff+4)}. For arguments smaller than 4 bytes
+ * the result is zero-extended at the high end (so the caller can always
+ * read 4 bytes BE and get the correct integer).
+ *
+ * @param buf buffer holding the CBOR stream
+ * @param bufOff offset of the header byte
+ * @param bufLen number of valid bytes remaining at {@code bufOff}
+ * @param argOut destination for the 4-byte big-endian argument
+ * @param argOff offset within {@code argOut} to write the argument
+ * @return packed (major type << 8) | bytesConsumed
+ * @throws ISOException SW_DATA_INVALID on any of: empty buffer, truncated
+ * argument, major type 7, indefinite length, reserved
+ * additional info (28..30), or 8-byte argument (27).
+ */
+ static short decodeHeader(
+ byte[] buf, short bufOff, short bufLen,
+ byte[] argOut, short argOff) {
+
+ if (bufLen < 1) {
+ ISOException.throwIt(ISO7816.SW_DATA_INVALID);
+ }
+
+ byte b0 = buf[bufOff];
+ short major = (short) ((b0 >> 5) & 0x07);
+ short addInfo = (short) (b0 & 0x1F);
+
+ // Major type 7 (floats / null / true / false / break) — out of
+ // scope for Aliro mdoc which uses only the data-bearing major
+ // types 0..6.
+ if (major == 7) {
+ ISOException.throwIt(ISO7816.SW_DATA_INVALID);
+ }
+
+ // Zero-extend the 4-byte big-endian argument slot up front. For
+ // immediate / 1B / 2B arguments the upper bytes must read as 0.
+ argOut[argOff] = (byte) 0;
+ argOut[(short) (argOff + 1)] = (byte) 0;
+ argOut[(short) (argOff + 2)] = (byte) 0;
+ argOut[(short) (argOff + 3)] = (byte) 0;
+
+ short consumed;
+ if (addInfo <= 23) {
+ // Immediate argument: the additional-info bits ARE the value.
+ argOut[(short) (argOff + 3)] = (byte) addInfo;
+ consumed = 1;
+ } else if (addInfo == 24) {
+ // 1-byte uint argument follows.
+ if (bufLen < 2) {
+ ISOException.throwIt(ISO7816.SW_DATA_INVALID);
+ }
+ argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 1)];
+ consumed = 2;
+ } else if (addInfo == 25) {
+ // 2-byte big-endian uint argument follows.
+ if (bufLen < 3) {
+ ISOException.throwIt(ISO7816.SW_DATA_INVALID);
+ }
+ argOut[(short) (argOff + 2)] = buf[(short) (bufOff + 1)];
+ argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 2)];
+ consumed = 3;
+ } else if (addInfo == 26) {
+ // 4-byte big-endian uint argument follows.
+ if (bufLen < 5) {
+ ISOException.throwIt(ISO7816.SW_DATA_INVALID);
+ }
+ argOut[argOff] = buf[(short) (bufOff + 1)];
+ argOut[(short) (argOff + 1)] = buf[(short) (bufOff + 2)];
+ argOut[(short) (argOff + 2)] = buf[(short) (bufOff + 3)];
+ argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 4)];
+ consumed = 5;
+ } else {
+ // additional info 27 (8-byte arg), 28..30 (reserved), 31
+ // (indefinite length) — all rejected.
+ ISOException.throwIt(ISO7816.SW_DATA_INVALID);
+ return 0; // unreachable; satisfies javac.
+ }
+
+ return (short) ((major << 8) | consumed);
+ }
+}
diff --git a/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java b/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java
new file mode 100644
index 0000000..bc8bbbc
--- /dev/null
+++ b/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java
@@ -0,0 +1,262 @@
+package com.dangerousthings.aliro;
+
+import javacard.framework.ISOException;
+import javacard.framework.ISO7816;
+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 StructuralCbor#decodeHeader} — RFC 8949 §3 header
+ * decoding (major type + argument) used by the M2 DeviceResponse / IssuerAuth
+ * walker.
+ *
+ * Header byte layout (RFC 8949 §3):
+ *
+ * bits 7..5: major type (0..7)
+ * bits 4..0: additional info
+ * 0..23: immediate argument value
+ * 24: 1-byte uint argument follows
+ * 25: 2-byte uint argument follows (big-endian)
+ * 26: 4-byte uint argument follows (big-endian)
+ * 27: 8-byte uint argument follows (REJECTED — exceeds short range)
+ * 28..30: reserved (REJECTED)
+ * 31: indefinite length (REJECTED — Aliro mdoc is canonical)
+ *
+ *
+ * Result encoding (matches {@link StructuralCbor#decodeHeader} javadoc):
+ *
+ * - return value (short): high byte = major type, low byte = bytes consumed
+ *
- argument: written big-endian into {@code argOut[argOff..argOff+4)}
+ *
+ */
+class StructuralCborTest {
+
+ /** Helper: decode at offset 0 and return major type. */
+ private static short majorOf(short result) {
+ return (short) ((result >> 8) & 0x07);
+ }
+
+ /** Helper: decode at offset 0 and return bytes consumed. */
+ private static short consumedOf(short result) {
+ return (short) (result & 0xFF);
+ }
+
+ /** Helper: read the 4-byte big-endian argument the codec wrote. */
+ private static long argOf(byte[] argOut) {
+ return ((long) (argOut[0] & 0xFF) << 24)
+ | ((long) (argOut[1] & 0xFF) << 16)
+ | ((long) (argOut[2] & 0xFF) << 8)
+ | ((long) (argOut[3] & 0xFF));
+ }
+
+ @Test
+ void decodeHeader_uintImmediateZero() {
+ byte[] buf = hex("00");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(0, majorOf(r), "major type 0 (uint)");
+ assertEquals(1, consumedOf(r), "1 byte consumed");
+ assertEquals(0L, argOf(arg), "argument value 0");
+ }
+
+ @Test
+ void decodeHeader_uintImmediateMax() {
+ // Additional info 23 is the largest immediate-argument value.
+ byte[] buf = hex("17");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(0, majorOf(r));
+ assertEquals(1, consumedOf(r));
+ assertEquals(23L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_uintOneByteArgument() {
+ // 0x18 = uint with 1-byte argument follow; argument = 0xff (255).
+ byte[] buf = hex("18ff");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(0, majorOf(r));
+ assertEquals(2, consumedOf(r));
+ assertEquals(255L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_uintTwoByteArgument() {
+ // 0x19 = uint with 2-byte argument; 0x0100 = 256.
+ byte[] buf = hex("190100");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(0, majorOf(r));
+ assertEquals(3, consumedOf(r));
+ assertEquals(256L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_uintFourByteArgument() {
+ // 0x1a = uint with 4-byte argument; 0x00010000 = 65536.
+ byte[] buf = hex("1a00010000");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(0, majorOf(r));
+ assertEquals(5, consumedOf(r));
+ assertEquals(65536L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_bstrOneByteLen() {
+ // 0x58 = bstr major type (2) + additional info 24 (1-byte len follows).
+ // Length 200 won't overflow short.
+ byte[] buf = hex("58c8");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(2, majorOf(r), "major type 2 (bstr)");
+ assertEquals(2, consumedOf(r));
+ assertEquals(200L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_tstrImmediate() {
+ // 0x65 = tstr major type (3) + immediate length 5.
+ byte[] buf = hex("65");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(3, majorOf(r), "major type 3 (tstr)");
+ assertEquals(1, consumedOf(r));
+ assertEquals(5L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_arrayImmediate() {
+ // 0x83 = array major type (4) + immediate count 3.
+ byte[] buf = hex("83");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(4, majorOf(r), "major type 4 (array)");
+ assertEquals(1, consumedOf(r));
+ assertEquals(3L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_mapImmediate() {
+ // 0xa2 = map major type (5) + immediate count 2.
+ byte[] buf = hex("a2");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(5, majorOf(r), "major type 5 (map)");
+ assertEquals(1, consumedOf(r));
+ assertEquals(2L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_tagImmediate() {
+ // 0xc0 = tag major type (6) + immediate tag 0.
+ byte[] buf = hex("c0");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
+ assertEquals(6, majorOf(r), "major type 6 (tag)");
+ assertEquals(1, consumedOf(r));
+ assertEquals(0L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_atNonzeroOffset() {
+ // Verify decodeHeader honours bufOff: pad with junk, decode at offset 3.
+ byte[] buf = hex("deadbeef" + "190100");
+ byte[] arg = new byte[4];
+ short r = StructuralCbor.decodeHeader(buf, (short) 4, (short) (buf.length - 4), arg, (short) 0);
+ assertEquals(0, majorOf(r));
+ assertEquals(3, consumedOf(r));
+ assertEquals(256L, argOf(arg));
+ }
+
+ @Test
+ void decodeHeader_argOutAtNonzeroOffset() {
+ // Verify argOff: write argument into arg[2..6) and leave arg[0..2) zero.
+ byte[] buf = hex("190100");
+ byte[] arg = new byte[8];
+ short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 2);
+ assertEquals(0, majorOf(r));
+ assertEquals(3, consumedOf(r));
+ assertEquals(0, arg[0]);
+ assertEquals(0, arg[1]);
+ // Argument bytes land at arg[2..6).
+ long argVal = ((long) (arg[2] & 0xFF) << 24)
+ | ((long) (arg[3] & 0xFF) << 16)
+ | ((long) (arg[4] & 0xFF) << 8)
+ | ((long) (arg[5] & 0xFF));
+ assertEquals(256L, argVal);
+ }
+
+ @Test
+ void decodeHeader_indefiniteLengthRejected() {
+ // 0x1f = uint major type + additional info 31 (indefinite length).
+ // We refuse indefinite-length in Aliro mdoc — canonical CBOR only.
+ byte[] buf = hex("1f");
+ byte[] arg = new byte[4];
+ ISOException ex = assertThrows(ISOException.class,
+ () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
+ assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF,
+ "indefinite-length must throw SW_DATA_INVALID");
+ }
+
+ @Test
+ void decodeHeader_eightByteArgumentRejected() {
+ // 0x1b = uint with 8-byte argument follow. Out of short-buffer scope.
+ byte[] buf = hex("1b00000000000000ff");
+ byte[] arg = new byte[4];
+ ISOException ex = assertThrows(ISOException.class,
+ () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
+ assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
+ }
+
+ @Test
+ void decodeHeader_majorTypeSevenRejected() {
+ // 0xf5 = major type 7 (CBOR true). We don't handle floats/null/bool.
+ byte[] buf = hex("f5");
+ byte[] arg = new byte[4];
+ ISOException ex = assertThrows(ISOException.class,
+ () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
+ assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
+ }
+
+ @Test
+ void decodeHeader_reservedAdditionalInfoRejected() {
+ // 0x1c = additional info 28 (reserved by RFC 8949).
+ byte[] buf = hex("1c");
+ byte[] arg = new byte[4];
+ ISOException ex = assertThrows(ISOException.class,
+ () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
+ assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
+ }
+
+ @Test
+ void decodeHeader_truncatedArgumentRejected() {
+ // 0x19 says "2-byte argument follows" but only 1 byte is present.
+ byte[] buf = hex("1901");
+ byte[] arg = new byte[4];
+ ISOException ex = assertThrows(ISOException.class,
+ () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
+ assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
+ }
+
+ @Test
+ void decodeHeader_emptyBufferRejected() {
+ byte[] buf = new byte[0];
+ byte[] arg = new byte[4];
+ ISOException ex = assertThrows(ISOException.class,
+ () -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
+ assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
+ }
+
+ 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;
+ }
+}