feat(applet): StructuralCbor.decodeHeader (M2B.1)
This commit is contained in:
@@ -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:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Major types 0..6 (uint, nint, bstr, tstr, array, map, tag) — major
|
||||||
|
* type 7 (floats / null / true / false) is rejected.</li>
|
||||||
|
* <li>Argument sizes immediate (0..23), 1-byte, 2-byte, 4-byte — the
|
||||||
|
* 8-byte form is rejected because Aliro mdoc payloads fit comfortably
|
||||||
|
* in a short-bounded buffer and supporting it would force long
|
||||||
|
* arithmetic across the rest of the codec.</li>
|
||||||
|
* <li>Definite lengths only — indefinite-length (additional info 31) is
|
||||||
|
* rejected; canonical Aliro mdoc encoding never uses it.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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}).
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>Result encoding (single short, lazy):
|
||||||
|
* <ul>
|
||||||
|
* <li>high byte = major type (0..6)</li>
|
||||||
|
* <li>low byte = bytes consumed (1, 2, 3, or 5)</li>
|
||||||
|
* </ul>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Header byte layout (RFC 8949 §3):
|
||||||
|
* <pre>
|
||||||
|
* 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)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Result encoding (matches {@link StructuralCbor#decodeHeader} javadoc):
|
||||||
|
* <ul>
|
||||||
|
* <li>return value (short): high byte = major type, low byte = bytes consumed
|
||||||
|
* <li>argument: written big-endian into {@code argOut[argOff..argOff+4)}
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user