diff --git a/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java b/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java index 8cdc44f..14c0c4a 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java @@ -233,6 +233,97 @@ final class StructuralCbor { return (short) (span + childSpan); } + /** + * Writes a canonical CBOR unsigned integer (major type 0) into + * {@code out[outOff..)}. + * + *
Picks the shortest argument size that fits per RFC 8949 §4.2.1: + *
Same argument-size rules as {@link #encodeUint}. {@code short} arg is + * sufficient: Aliro mdoc payloads are short-bounded. Negative lengths are + * rejected as malformed input. + * + * @return number of bytes written (the header only — caller appends payload) + * @throws ISOException SW_DATA_INVALID if {@code len} is negative. + */ + static short encodeBstrHeader(short len, byte[] out, short outOff) { + return encodeTypeAndArg((short) 2, len, out, outOff); + } + + /** + * Writes a canonical CBOR text-string header (major type 3) for a payload + * of {@code len} UTF-8 bytes into {@code out[outOff..)}. Same shape as + * {@link #encodeBstrHeader}. + * + * @return number of bytes written (the header only — caller appends payload) + * @throws ISOException SW_DATA_INVALID if {@code len} is negative. + */ + static short encodeTstrHeader(short len, byte[] out, short outOff) { + return encodeTypeAndArg((short) 3, len, out, outOff); + } + + /** + * Shared backend for {@link #encodeUint} / {@link #encodeBstrHeader} / + * {@link #encodeTstrHeader}. Picks the shortest argument size that fits + * {@code value}, writes the header byte (major-type bits 7..5, additional + * info bits 4..0), then the big-endian argument bytes. + */ + private static short encodeTypeAndArg(short major, int value, byte[] out, short outOff) { + if (value < 0) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } + byte mt = (byte) (major << 5); + if (value <= 23) { + // Immediate argument — additional info bits hold the value. + out[outOff] = (byte) (mt | value); + return 1; + } + if (value <= 0xFF) { + // 1-byte argument (additional info 24). + out[outOff] = (byte) (mt | 24); + out[(short) (outOff + 1)] = (byte) value; + return 2; + } + if (value <= 0xFFFF) { + // 2-byte big-endian argument (additional info 25). + out[outOff] = (byte) (mt | 25); + out[(short) (outOff + 1)] = (byte) (value >>> 8); + out[(short) (outOff + 2)] = (byte) value; + return 3; + } + // 4-byte big-endian argument (additional info 26). >>> keeps it unsigned + // so values >= 0x80000000 (impossible here because `value < 0` is + // already rejected) would still serialize correctly via the 4-byte form. + out[outOff] = (byte) (mt | 26); + out[(short) (outOff + 1)] = (byte) (value >>> 24); + out[(short) (outOff + 2)] = (byte) (value >>> 16); + out[(short) (outOff + 3)] = (byte) (value >>> 8); + out[(short) (outOff + 4)] = (byte) value; + return 5; + } + /** * Reads the 4-byte big-endian argument {@code decodeHeader} wrote into * the scratch and returns it as a short. Argument values larger than diff --git a/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java b/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java index 02295d7..f0c6d53 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java @@ -360,6 +360,150 @@ class StructuralCborTest { assertEquals(4, span); } + // --------------------------------------------------------------------- + // Canonical encoder tests (M2B.4) — uint / bstr-header / tstr-header + // writers used by M2D.2 (DeviceResponseBuilder). + // + // Canonical CBOR (RFC 8949 §4.2.1): shortest argument size that fits the + // value. Boundary at each size step (0..23 immediate, 24..0xff one-byte, + // 0x100..0xffff two-byte, 0x10000..0xffffffff four-byte). + // --------------------------------------------------------------------- + + @Test + void encodeUint_zeroImmediate() { + // 0 fits in the immediate range — single header byte 0x00. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(0, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "00", n); + } + + @Test + void encodeUint_twentyThreeImmediate() { + // 23 is the largest immediate value — header byte 0x17. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(23, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "17", n); + } + + @Test + void encodeUint_twentyFourOneByteBoundary() { + // 24 is the lower boundary of the 1-byte argument form — 0x18 0x18. + // Canonical rule forbids encoding 24 as 0x19 0x00 0x18 etc. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(24, out, (short) 0); + assertEquals(2, n); + assertArrayPrefix(out, "1818", n); + } + + @Test + void encodeUint_twoByteBoundary() { + // 0x100 = 256 is the lower boundary of the 2-byte argument form. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(0x100, out, (short) 0); + assertEquals(3, n); + assertArrayPrefix(out, "190100", n); + } + + @Test + void encodeUint_fourByteBoundary() { + // 0x10000 = 65536 is the lower boundary of the 4-byte argument form. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeUint(0x10000, out, (short) 0); + assertEquals(5, n); + assertArrayPrefix(out, "1a00010000", n); + } + + @Test + void encodeUint_negativeRejected() { + // Aliro mdoc CBOR never uses negative integers (those are major type 1). + byte[] out = new byte[8]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.encodeUint(-1, out, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + @Test + void encodeBstrHeader_zero() { + // bstr len 0 — header byte 0x40 (major type 2 | additional info 0). + byte[] out = new byte[8]; + short n = StructuralCbor.encodeBstrHeader((short) 0, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "40", n); + } + + @Test + void encodeBstrHeader_twentyThreeImmediate() { + // Largest immediate-length bstr header — 0x57. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeBstrHeader((short) 23, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "57", n); + } + + @Test + void encodeBstrHeader_twentyFourOneByteBoundary() { + // 24 forces into 1-byte argument form — 0x58 0x18. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeBstrHeader((short) 24, out, (short) 0); + assertEquals(2, n); + assertArrayPrefix(out, "5818", n); + } + + @Test + void encodeBstrHeader_twoByteBoundary() { + // 0x100 = 256 forces into 2-byte argument form — 0x59 0x01 0x00. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeBstrHeader((short) 0x100, out, (short) 0); + assertEquals(3, n); + assertArrayPrefix(out, "590100", n); + } + + @Test + void encodeBstrHeader_negativeLenRejected() { + byte[] out = new byte[8]; + ISOException ex = assertThrows(ISOException.class, + () -> StructuralCbor.encodeBstrHeader((short) -1, out, (short) 0)); + assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF); + } + + @Test + void encodeTstrHeader_zero() { + // tstr len 0 — header byte 0x60 (major type 3 | additional info 0). + byte[] out = new byte[8]; + short n = StructuralCbor.encodeTstrHeader((short) 0, out, (short) 0); + assertEquals(1, n); + assertArrayPrefix(out, "60", n); + } + + @Test + void encodeTstrHeader_twentyFourOneByteBoundary() { + // 24 forces into 1-byte argument form — 0x78 0x18. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeTstrHeader((short) 24, out, (short) 0); + assertEquals(2, n); + assertArrayPrefix(out, "7818", n); + } + + @Test + void encodeTstrHeader_twoByteBoundary() { + // 0x100 forces into 2-byte argument form — 0x79 0x01 0x00. + byte[] out = new byte[8]; + short n = StructuralCbor.encodeTstrHeader((short) 0x100, out, (short) 0); + assertEquals(3, n); + assertArrayPrefix(out, "790100", n); + } + + /** Helper: assert {@code out[0..n)} equals the hex-decoded expected bytes. */ + private static void assertArrayPrefix(byte[] out, String expectedHex, short n) { + byte[] expected = hex(expectedHex); + assertEquals(expected.length, n, "expected " + expectedHex + " (" + expected.length + " bytes)"); + for (int i = 0; i < expected.length; i++) { + assertEquals(expected[i], out[i], "byte " + i + " mismatch"); + } + } + private static byte[] hex(String s) { s = s.replaceAll("\\s+", ""); byte[] out = new byte[s.length() / 2];