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: + * + * + * + *

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): + *

+ * 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): + *

+ */ +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; + } +}