feat(applet): canonical CBOR uint/bstr/tstr encoders (M2B.4)

This commit is contained in:
michael
2026-06-17 15:51:30 -07:00
parent 6373bf1109
commit 047503e8c5
2 changed files with 235 additions and 0 deletions

View File

@@ -233,6 +233,97 @@ final class StructuralCbor {
return (short) (span + childSpan); return (short) (span + childSpan);
} }
/**
* Writes a canonical CBOR unsigned integer (major type 0) into
* {@code out[outOff..)}.
*
* <p>Picks the shortest argument size that fits per RFC 8949 §4.2.1:
* <ul>
* <li>0..23 → immediate (1 byte total)</li>
* <li>24..255 → 1-byte argument (2 bytes total)</li>
* <li>256..65535 → 2-byte argument (3 bytes total)</li>
* <li>65536..(2^32-1) → 4-byte argument (5 bytes total)</li>
* </ul>
* Argument type is {@code int} because the 4-byte form covers the full
* unsigned 32-bit range; {@code >>>} is used so a value with the sign bit
* set still serializes correctly. Negative values are rejected because
* Aliro mdoc CBOR never uses negative integers (those would be major
* type 1, which we don't emit).
*
* @return number of bytes written
* @throws ISOException SW_DATA_INVALID if {@code value} is negative.
*/
static short encodeUint(int value, byte[] out, short outOff) {
return encodeTypeAndArg((short) 0, value, out, outOff);
}
/**
* Writes a canonical CBOR byte-string header (major type 2) for a payload
* of {@code len} bytes into {@code out[outOff..)}.
*
* <p>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 * Reads the 4-byte big-endian argument {@code decodeHeader} wrote into
* the scratch and returns it as a short. Argument values larger than * the scratch and returns it as a short. Argument values larger than

View File

@@ -360,6 +360,150 @@ class StructuralCborTest {
assertEquals(4, span); 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) { private static byte[] hex(String s) {
s = s.replaceAll("\\s+", ""); s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2]; byte[] out = new byte[s.length() / 2];