feat(applet): real DeviceResponse via ENVELOPE + GET RESPONSE chaining (M2D.3, M2D.4)

The ENVELOPE handler now consumes the decrypted DeviceRequest through
DeviceRequestParser.validate (M2D.1), builds a canonical-CBOR
DeviceResponse around the cached Access Document via
DeviceResponseBuilder.build (M2D.2), and AES-256-GCM-encrypts the
result under StepUpSKDevice. For our standard 272 B AD the encrypted
output is 388 B (372 B body + 16 B GCM tag), so we ship the first
252 B inline with SW=61xx and let the reader pull the remaining 136 B
via GET RESPONSE (INS=0xC0). The chaining offset/remaining state is
held in a CLEAR_ON_DESELECT short pair; a fresh ENVELOPE resets both
slots, and GET RESPONSE outside an in-flight chain returns
SW_CONDITIONS_NOT_SATISFIED.

Tests:
- envelopeAfterStepUpSelectReturnsRealDeviceResponseViaChaining drives
  AUTH0/AUTH1, sends a valid CBOR DeviceRequest under the StepUpSKReader
  GCM, drains GET RESPONSE until SW=9000, and asserts the decrypted
  plaintext matches the M2D.2 canonical-CBOR DeviceResponse byte-for-byte.
- envelopeWithMalformedDeviceRequestReturnsDataInvalid pins the
  SW_DATA_INVALID propagation path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
michael
2026-06-17 16:34:45 -07:00
parent 9e31da0cdc
commit 9879668472
2 changed files with 322 additions and 68 deletions

View File

@@ -220,26 +220,21 @@ class StepUpAppletTest {
}
/**
* After SELECT-Step-Up the X-CUBE-ALIRO firmware also sends an ENVELOPE
* command (CLA=0x00 INS=0xC3) carrying the encrypted mdoc DeviceRequest
* (spec §8.4 + ISO 7816 ENVELOPE). Inbound encryption per §8.3.1.9
* (reader-side IV {@code 0x0000000000000000 || stepup_reader_counter},
* empty AAD); response encryption per §8.3.1.6 (device-side IV
* {@code 0x0000000000000001 || stepup_device_counter}, empty AAD).
* M2D.3 + M2D.4 end-to-end: after SELECT-Step-Up the reader sends an
* ENVELOPE (CLA=0x00 INS=0xC3) carrying an encrypted mdoc DeviceRequest.
* The applet decrypts under StepUpSKReader (§8.3.1.9 IV), runs the
* {@link DeviceRequestParser} structural validation, builds the real
* {@link DeviceResponseBuilder} output around the cached Access Document,
* and encrypts under StepUpSKDevice (§8.3.1.6 IV).
*
* <p>For Milestone 1 we decrypt-and-discard the DeviceRequest, then
* return an encrypted single-byte CBOR empty map ({@code 0xA0}) so the
* X-CUBE-ALIRO firmware sees a spec-shape encrypted response. Total
* response bytes: 1 ct + 16 tag = 17.
*
* <p>This test also pins counter sequencing: ENVELOPE consumes
* stepup_reader_counter=1 (then increments) and emits with
* stepup_device_counter=1 (then increments). The reader-counter is
* shared with EXCHANGE, but EXCHANGE isn't sent in this test so we
* only see counter=1 on each side.
* <p>The encrypted response is 372 B ciphertext + 16 B GCM tag = 388 B,
* which exceeds the ~252 B APDU outgoing window. The applet therefore
* uses ISO 7816 response chaining: the first ENVELOPE response carries
* the head chunk + SW=61xx ("xx more bytes available"), and the reader
* pulls remaining chunks via GET RESPONSE (INS=0xC0) until SW=9000.
*/
@Test
void envelopeAfterStepUpSelectDecryptsAndAcksWithEncryptedEmptyCborMap() throws Exception {
void envelopeAfterStepUpSelectReturnsRealDeviceResponseViaChaining() throws Exception {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
@@ -250,6 +245,15 @@ class StepUpAppletTest {
ReaderSide reader = new ReaderSide();
reader.provision(sim, credentialKeyPair);
// Stage the cached Access Document via the test-only hook (mirrors
// AliroAppletAuth1Test's pattern — bypassing the personalization
// pipeline's IssuerAuth verify since the AD here is shaped for the
// DeviceResponse round-trip, not for real signature verification).
CredentialStore.get().writeAccessDocumentChunk(
ACCESS_DOC_HEX, (short) 0, (short) 0, (short) ACCESS_DOC_HEX.length);
CredentialStore.get().markAccessDocumentFinalizedForTesting(
(short) ACCESS_DOC_HEX.length);
// SELECT expedited + run AUTH0 + AUTH1.
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
@@ -279,31 +283,49 @@ class StepUpAppletTest {
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
"SELECT step-up must succeed");
// Reader-side encrypt of a 4-byte plaintext under the reader IV
// (00 00 00 00 00 00 00 00 || 00 00 00 01). The applet decrypts and
// discards; the actual plaintext is irrelevant for M1.
// Reader-side encrypt of a valid DeviceRequest CBOR under the reader IV
// (00*8 || 00 00 00 01). The applet decrypts, validates the structure
// via DeviceRequestParser, then emits the cached DeviceResponse.
byte[] readerIv = new byte[12];
readerIv[11] = 0x01;
byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcmEnc.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(stepUpSKReader, "AES"),
new GCMParameterSpec(128, readerIv));
byte[] envelopeBody = gcmEnc.doFinal(plaintext); // 4 + 16 = 20 bytes
assertEquals(20, envelopeBody.length);
byte[] envelopeBody = gcmEnc.doFinal(VALID_DEVICE_REQUEST);
// Send ENVELOPE: CLA=0x00 INS=0xC3 (ISO-class command per Table 8-14).
// Send ENVELOPE: CLA=0x00 INS=0xC3. Le=0 maxes the inbound buffer;
// the response head chunk will come back with SW=61xx since 388 B
// exceeds one APDU.
ResponseAPDU envelopeResp = sim.transmitCommand(
new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256));
assertEquals(0x9000, envelopeResp.getSW(),
"ENVELOPE with valid GCM tag must return SW=9000");
int sw = envelopeResp.getSW();
org.junit.jupiter.api.Assertions.assertEquals(
0x6100, sw & 0xFF00,
"ENVELOPE response > APDU window must yield SW=61xx; got 0x" + Integer.toHexString(sw));
byte[] respBody = envelopeResp.getData();
assertEquals(17, respBody.length,
"M1 ENVELOPE response = 1 ciphertext byte + 16 GCM tag");
// Drain chunks via GET RESPONSE (INS=0xC0) until SW=9000.
java.io.ByteArrayOutputStream agg = new java.io.ByteArrayOutputStream();
agg.write(envelopeResp.getData());
int round = 0;
while ((sw & 0xFF00) == 0x6100) {
ResponseAPDU getResp = sim.transmitCommand(
new CommandAPDU(0x00, 0xC0, 0x00, 0x00, 256));
agg.write(getResp.getData());
sw = getResp.getSW();
round++;
// Safety net against an infinite loop in case the chaining state
// never converges -- 388 B / 252 B per chunk = 2 rounds max.
org.junit.jupiter.api.Assertions.assertTrue(round < 5,
"GET RESPONSE chain should terminate in well under 5 rounds");
}
assertEquals(0x9000, sw, "final GET RESPONSE must end with SW=9000");
// Device-side decrypt under IV = 00 00 00 00 00 00 00 01 || 00 00 00 01
// (device-prefix per §8.3.1.6 + device_counter=1).
byte[] respBody = agg.toByteArray();
assertEquals(388, respBody.length,
"DeviceResponse ciphertext = 372 B body + 16 B GCM tag = 388 B");
// Device-side decrypt under IV = 00*7 || 0x01 || 00 00 00 01.
byte[] deviceIv = new byte[12];
deviceIv[7] = 0x01;
deviceIv[11] = 0x01;
@@ -312,8 +334,121 @@ class StepUpAppletTest {
new SecretKeySpec(stepUpSKDevice, "AES"),
new GCMParameterSpec(128, deviceIv));
byte[] decoded = gcmDec.doFinal(respBody);
assertArrayEquals(new byte[] { (byte) 0xA0 }, decoded,
"M1 ENVELOPE response plaintext = canonical CBOR empty map (0xA0)");
assertArrayEquals(DEVICE_RESPONSE_HEX, decoded,
"decrypted plaintext must equal the M2D.2 canonical DeviceResponse bytes");
}
/**
* If the decrypted DeviceRequest fails {@link DeviceRequestParser}'s
* structural validation (truncated CBOR here), the ENVELOPE handler must
* propagate the parser's SW unchanged. SW_DATA_INVALID (0x6984) for a
* malformed CBOR header per the parser javadoc.
*/
@Test
void envelopeWithMalformedDeviceRequestReturnsDataInvalid() throws Exception {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
AID stepUpAid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
sim.installApplet(stepUpAid, StepUpApplet.class);
KeyPair credentialKeyPair = Auth0Command.generateEphemeralKeyPair();
ReaderSide reader = new ReaderSide();
reader.provision(sim, credentialKeyPair);
// No AD needed -- the parser fails before the response builder runs.
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW());
reader.startTransaction();
ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
reader.buildAuth0Data(), 256));
assertEquals(0x9000, auth0Resp.getSW());
byte[] credentialEphemPubKey = TlvUtil.findTopLevel(auth0Resp.getData(), 0x86);
ResponseAPDU auth1Resp = sim.transmitCommand(new CommandAPDU(
Auth0Command.CLA & 0xFF, 0x81, 0x00, 0x00,
reader.buildAuth1Data(credentialEphemPubKey), 256));
assertEquals(0x9000, auth1Resp.getSW());
byte[] stepUpSK = java.util.Arrays.copyOfRange(
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW());
// Encrypt the truncated CBOR -- the GCM tag is intact, so decrypt
// succeeds. The parser then rejects on the malformed CBOR header.
byte[] readerIv = new byte[12];
readerIv[11] = 0x01;
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcmEnc.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(stepUpSKReader, "AES"),
new GCMParameterSpec(128, readerIv));
byte[] envelopeBody = gcmEnc.doFinal(TRUNCATED_DEVICE_REQUEST);
ResponseAPDU envelopeResp = sim.transmitCommand(
new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256));
assertEquals(0x6984, envelopeResp.getSW(),
"malformed inner DeviceRequest must propagate SW_DATA_INVALID from the parser");
}
// ---- M2D.3 / M2D.4 fixtures ----
/** Canonical CBOR for a minimal valid DeviceRequest; mirrors the VALID
* constant in {@link DeviceRequestParserTest}. */
private static final byte[] VALID_DEVICE_REQUEST = hex(
"a26776657273696f6e63312e306b646f63526571756573747381"
+ "a16c6974656d7352657175657374582ba267646f6354797065"
+ "756f72672e69736f2e31383031332e352e312e6d444c"
+ "6a6e616d65537061636573a0");
/** Truncated mid-key DeviceRequest; mirrors the TRUNCATED constant in
* {@link DeviceRequestParserTest}. */
private static final byte[] TRUNCATED_DEVICE_REQUEST = hex("a26776657273696f");
/** Same Access Document fixture as {@link DeviceResponseBuilderTest} —
* 272 B COSE_Sign1 generated against the canonical AD. The applet
* embeds these bytes verbatim under
* {@code documents[0].issuerSigned.issuerAuth}. */
private static final byte[] ACCESS_DOC_HEX = hex(
"8443a10126a104488dae9624eed9280c58bca7613163312e30613267534"
+ "8412d3235366133a06134a16131a401022001215820aa3115ead5d1fec"
+ "ca289aef3598790a6dba23edbe9b14e6818ac683e31a5af0222582083"
+ "9dcc32bcd32924a942c3b9999f6cbf46960396e69606fe4295e83a0c7"
+ "787a2613567616c69726f2d616136a36131c074323032362d30342d3"
+ "1395432303a32393a31365a6132c074323032362d30342d3139543230"
+ "3a32393a31365a6133c074323032372d30342d31395432303a32393a3"
+ "1365a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac436"
+ "44e92c8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d"
+ "38df6f1c5b1caf685c365b22a3f2a");
/** Expected DeviceResponse plaintext for {@link #ACCESS_DOC_HEX} —
* 372 B canonical-CBOR built around the 272 B AD. Same vector as
* {@link DeviceResponseBuilderTest#DEVICE_RESPONSE_HEX}. */
private static final byte[] DEVICE_RESPONSE_HEX = hex(
"a366737461747573006776657273696f6e63312e3069646f63756d656e"
+ "747381a267646f6354797065756f72672e69736f2e31383031332e352e"
+ "312e6d444c6c6973737565725369676e6564a26a697373756572417574"
+ "688443a10126a104488dae9624eed9280c58bca7613163312e30613267"
+ "5348412d3235366133a06134a16131a401022001215820aa3115ead5d1"
+ "fecca289aef3598790a6dba23edbe9b14e6818ac683e31a5af02225820"
+ "839dcc32bcd32924a942c3b9999f6cbf46960396e69606fe4295e83a0c"
+ "7787a2613567616c69726f2d616136a36131c074323032362d30342d31"
+ "395432303a32393a31365a6132c074323032362d30342d31395432303a"
+ "32393a31365a6133c074323032372d30342d31395432303a32393a3136"
+ "5a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac43644e9"
+ "2c8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d38df6"
+ "f1c5b1caf685c365b22a3f2a6a6e616d65537061636573a0");
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;
}
/** HKDF-SHA-256 with empty salt and single-block Expand (L=32). Mirrors