diff --git a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java index 2bd1793..35cc410 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java @@ -74,10 +74,24 @@ public class StepUpApplet extends Applet { * X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the * ENVELOPE body once the Step-Up AID is the active applet. */ private static final byte INS_ENVELOPE = (byte) 0xC3; + /** GET RESPONSE per ISO 7816-4 §7.6.1 — the reader pulls the rest of a + * chained response after the applet returns SW=61xx. */ + private static final byte INS_GET_RESPONSE = (byte) 0xC0; /** 12-byte AES-256-GCM IV layout (§8.3.1.8/9): 8B prefix + 4B counter. */ private static final short GCM_TAG_LEN = 16; + /** Per-chunk APDU outgoing window for the response chaining path. The + * T=0/T=1 short-APDU response buffer caps cleanly at this size — leaves + * the trailing 4 B headroom of the standard 261 B jcardsim/JC buffer + * for SW + framing. */ + private static final short CHUNK_LEN = 252; + + /** Response chaining buffer for DeviceResponse outputs > {@link #CHUNK_LEN}. + * Sized for 372 B canonical DeviceResponse + 16 B GCM tag = 388 B with + * headroom for future shape tweaks. */ + private static final short RESPONSE_BUFFER_LEN = 512; + /** Holds StepUpSKDevice / StepUpSKReader and the two BE32 message * counters, plus the IV-stamping math (spec §8.3.1.6/8/9 + §8.4.3). * All four arrays inside are CLEAR_ON_DESELECT. */ @@ -109,6 +123,28 @@ public class StepUpApplet extends Applet { private static final short FLAG_KEYS_READY = 0; private static final short FLAGS_LEN = 1; + /** Built-then-encrypted DeviceResponse staging area for the response + * chaining path. ENVELOPE writes the encrypted DeviceResponse into here, + * then sends one {@link #CHUNK_LEN}-sized chunk per APDU (the first + * inside the ENVELOPE reply, the rest pulled via GET RESPONSE). Reused + * during the build step as a scratch for the canonical-CBOR DeviceResponse + * plaintext too — encrypt-in-place isn't an option because the GCM tag + * appends after the ciphertext. CLEAR_ON_DESELECT. */ + private final byte[] responseBuffer; + + /** {@code [offset, remaining]} for the chaining drain — both reset at the + * start of every ENVELOPE. {@code remaining == 0} signals + * "no more bytes to ship": GET RESPONSE arriving in that state returns + * SW_CONDITIONS_NOT_SATISFIED. CLEAR_ON_DESELECT. */ + private final short[] responseState; + private static final short STATE_OFF = 0; + private static final short STATE_REMAINING = 1; + + /** 4-byte scratch the {@link DeviceRequestParser} writes its CBOR header + * argument into. Reused across calls — caller-supplied is the + * allocation-free convention StructuralCbor uses. CLEAR_ON_DESELECT. */ + private final byte[] parserScratch4; + public static void install(byte[] bArray, short bOffset, byte bLength) { StepUpApplet applet = new StepUpApplet(); if (bArray == null || bLength == 0) { @@ -128,6 +164,9 @@ public class StepUpApplet extends Applet { ivScratch = JCSystem.makeTransientByteArray(StepUpSession.IV_LEN, JCSystem.CLEAR_ON_DESELECT); scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT); sessionFlags = JCSystem.makeTransientByteArray(FLAGS_LEN, JCSystem.CLEAR_ON_DESELECT); + responseBuffer = JCSystem.makeTransientByteArray(RESPONSE_BUFFER_LEN, JCSystem.CLEAR_ON_DESELECT); + responseState = JCSystem.makeTransientShortArray((short) 2, JCSystem.CLEAR_ON_DESELECT); + parserScratch4 = JCSystem.makeTransientByteArray((short) 4, JCSystem.CLEAR_ON_DESELECT); } /** @@ -183,6 +222,10 @@ public class StepUpApplet extends Applet { processEnvelope(apdu); return; } + if (cla == CLA_ISO && ins == INS_GET_RESPONSE) { + processGetResponse(apdu); + return; + } if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) { processExchange(apdu); return; @@ -190,7 +233,6 @@ public class StepUpApplet extends Applet { if (cla != CLA_ISO && cla != CLA_PROPRIETARY) { ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED); } - // GET RESPONSE handler plugs in here in follow-up milestones. ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); } @@ -265,24 +307,32 @@ public class StepUpApplet extends Applet { } /** - * ENVELOPE (CLA=0x00, INS=0xC3) handler — Milestone 1 decrypt-and-discard - * the mdoc DeviceRequest, then return a spec-shape encrypted empty CBOR - * map (canonical RFC 8949: single byte 0xA0). + * ENVELOPE (CLA=0x00, INS=0xC3) handler — M2D.3 (real DeviceResponse) + + * M2D.4 (ISO 7816 response chaining). * - *

Spec §8.3.1.9: inbound payload (reader→device) is decrypted with - * {@code StepUpSKReader}, IV {@code 0x0000000000000000 || stepup_reader_counter} - * (8-byte zero prefix + 4-byte BE counter), empty AAD. - * - *

Spec §8.3.1.6: outbound payload (device→reader) is encrypted with - * {@code StepUpSKDevice}, IV {@code 0x0000000000000001 || stepup_device_counter} - * (8-byte prefix ending in 0x01 + 4-byte BE counter), empty AAD. - * - *

The DeviceRequest body is discarded in M1: we don't build a real - * mdoc DeviceResponse yet -- that's M2's job. The 17-byte ciphertext - * (1 ct + 16 tag) is enough for X-CUBE-ALIRO to see a spec-shape - * encrypted response and move on. Counter sequencing: both - * stepup_reader_counter (shared with EXCHANGE) and stepup_device_counter - * advance independently after each successful use. + *

Pipeline: + *

    + *
  1. Decrypt the inbound payload under {@code StepUpSKReader} with + * reader-side IV {@code 0x0000000000000000 || stepup_reader_counter} + * (§8.3.1.9). Advance {@code stepup_reader_counter}.
  2. + *
  3. Structurally validate the recovered DeviceRequest via + * {@link DeviceRequestParser#validate} — propagates SW_DATA_INVALID + * (malformed CBOR / wrong shape) or SW_CONDITIONS_NOT_SATISFIED + * (unsupported version) unchanged.
  4. + *
  5. Build the canonical-CBOR DeviceResponse via + * {@link DeviceResponseBuilder#build} carrying the cached Access + * Document at {@code documents[0].issuerSigned.issuerAuth}.
  6. + *
  7. In-place GCM-encrypt the DeviceResponse plaintext under + * {@code StepUpSKDevice} with device-side IV + * {@code 0x00*7 || 0x01 || stepup_device_counter} (§8.3.1.6). + * AliroGcm supports {@code in == out} at the same offset (CTR mode + + * trailing tag), so the cleartext is overwritten on the encryption + * pass — no second buffer needed.
  8. + *
  9. Advance {@code stepup_device_counter} and ship the first chunk. + * For our standard 372 B AD wrapper the response is 388 B; we send + * the first {@link #CHUNK_LEN} bytes inline with SW=61xx and the + * reader pulls the rest via GET RESPONSE.
  10. + *
*/ private void processEnvelope(APDU apdu) { if (sessionFlags[FLAG_KEYS_READY] == 0) { @@ -292,6 +342,11 @@ public class StepUpApplet extends Applet { ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); } + // Invalidate any in-flight chaining state from a previous ENVELOPE. + // A new ENVELOPE always restarts the response stream. + responseState[STATE_OFF] = 0; + responseState[STATE_REMAINING] = 0; + short lc = apdu.setIncomingAndReceive(); byte[] buf = apdu.getBuffer(); short dataOff = apdu.getOffsetCdata(); @@ -323,35 +378,99 @@ public class StepUpApplet extends Applet { // Spec §8.3.1.9: reader_counter <- reader_counter + 1 after use. session.advanceReaderCounter(); - // Wipe the decrypted DeviceRequest -- M1 has no use for it. M2 will - // replace this with real mdoc parsing and a real DeviceResponse. + // Structural validation. Throws ISOException on malformed CBOR / + // unsupported version — let it propagate; SW mapping is the parser's + // responsibility. + DeviceRequestParser.validate( + scratchPlaintext, (short) 0, ptLen, + parserScratch4, (short) 0); + + // Wipe the decrypted DeviceRequest — its content doesn't influence the + // response (M2 reader profile asks for the single cached AD), and we + // don't want plaintext loitering past CLEAR_ON_DESELECT. Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0); - // Build the canonical-CBOR empty map plaintext (single byte 0xA0, - // RFC 8949 major type 5 (map) with length 0). One byte total. - scratchPlaintext[0] = (byte) 0xA0; + // Build the canonical-CBOR DeviceResponse into responseBuffer. The + // cached AD is staged directly into the builder via copyAccessDocument; + // CredentialStore.hasAccessDocument() is asserted first so a card that + // somehow reached Step-Up without an AD doesn't synthesize an empty + // issuerAuth that would crash downstream readers. + CredentialStore store = CredentialStore.get(); + if (store == null || !store.hasAccessDocument()) { + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } + short adLen = store.getAccessDocumentLen(); + // Stage the AD into responseBuffer past the wrapper's max footprint. + // The wrapper is ~100 B for our fixed shape; staging AD at offset 128 + // gives the builder a clean target at offset 0 and the AD source at a + // distinct, non-overlapping location. + short adStage = 128; + store.copyAccessDocument(responseBuffer, adStage, (short) 0, adLen); + short respLen = DeviceResponseBuilder.build( + responseBuffer, adStage, adLen, + responseBuffer, (short) 0); - // Build the device-side IV: 0x00*7 || 0x01 || device_counter - // (§8.3.1.6 -- 8-byte prefix ending in 0x01, then 4B BE counter). + // Build the device-side IV: 0x00*7 || 0x01 || device_counter (§8.3.1.6). session.deviceIv(ivScratch, (short) 0); - // Encrypt into the APDU buffer at offset 0. Safe to overwrite the - // inbound command bytes here because we've finished reading them. - // Output length = 1 (ct) + 16 (tag) = 17 bytes; well under the 252-byte - // APDU buffer ceiling. + // In-place encrypt: AliroGcm supports out == in at the same offset. + // Ciphertext overwrites the plaintext; the 16 B tag appends after. short ctLen = CryptoSingletons.getAliroGcm().encrypt( session.skDevice, (short) 0, ivScratch, (short) 0, - scratchPlaintext, (short) 0, (short) 1, - buf, (short) 0); + responseBuffer, (short) 0, respLen, + responseBuffer, (short) 0); // Spec §8.3.1.6: device_counter <- device_counter + 1 after use. session.advanceDeviceCounter(); - // Wipe the single plaintext byte (CLEAR_ON_DESELECT alone would - // leave 0xA0 sitting in transient until reader walks away). - scratchPlaintext[0] = 0; + // Ship the first chunk. Total ciphertext is 388 B for the standard + // 272 B AD case; we send CHUNK_LEN bytes and signal more via SW=61xx + // (handled below in sendChunk). + responseState[STATE_OFF] = 0; + responseState[STATE_REMAINING] = ctLen; + sendChunk(apdu); + } - apdu.setOutgoingAndSend((short) 0, ctLen); + /** + * GET RESPONSE (CLA=0x00, INS=0xC0) handler — drains the next chunk of an + * outstanding chained DeviceResponse staged by {@link #processEnvelope}. + * Returns SW_CONDITIONS_NOT_SATISFIED if no chain is in flight (per + * ISO 7816-4: GET RESPONSE outside a chained transfer is illegal). + */ + private void processGetResponse(APDU apdu) { + if (responseState[STATE_REMAINING] <= 0) { + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } + sendChunk(apdu); + } + + /** + * Copies up to {@link #CHUNK_LEN} bytes out of {@link #responseBuffer} into + * the APDU buffer, advances the chaining offset, and either ends naturally + * with SW=9000 (last chunk) or throws {@code 0x6100 | xx} where {@code xx} + * is the {@code min(0xFF, remaining)} signal of how many bytes are still + * available via the next GET RESPONSE. + * + *

The {@code 0x6100} branch follows ISO 7816-4 §5.1.4: SW1=0x61 means + * "process completed normally, SW2 more bytes available". SW2=0x00 in + * that protocol means "256 or more remain"; we cap at 0xFF before the OR. + */ + private void sendChunk(APDU apdu) { + short off = responseState[STATE_OFF]; + short remaining = responseState[STATE_REMAINING]; + short chunk = remaining > CHUNK_LEN ? CHUNK_LEN : remaining; + + Util.arrayCopyNonAtomic(responseBuffer, off, apdu.getBuffer(), (short) 0, chunk); + responseState[STATE_OFF] = (short) (off + chunk); + responseState[STATE_REMAINING] = (short) (remaining - chunk); + + apdu.setOutgoingAndSend((short) 0, chunk); + + short stillRemaining = responseState[STATE_REMAINING]; + if (stillRemaining > 0) { + short xx = stillRemaining > (short) 0xFF ? (short) 0xFF : stillRemaining; + ISOException.throwIt((short) (0x6100 | (xx & 0xFF))); + } } /** diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java index 502586c..1b29ac7 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java @@ -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). * - *

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. - * - *

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. + *

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