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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user