feat(applet): canonical CBOR uint/bstr/tstr encoders (M2B.4)
This commit is contained in:
@@ -233,6 +233,97 @@ final class StructuralCbor {
|
||||
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
|
||||
* the scratch and returns it as a short. Argument values larger than
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user