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