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

@@ -74,10 +74,24 @@ public class StepUpApplet extends Applet {
* X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the * X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the
* ENVELOPE body once the Step-Up AID is the active applet. */ * ENVELOPE body once the Step-Up AID is the active applet. */
private static final byte INS_ENVELOPE = (byte) 0xC3; 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. */ /** 12-byte AES-256-GCM IV layout (§8.3.1.8/9): 8B prefix + 4B counter. */
private static final short GCM_TAG_LEN = 16; 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 /** Holds StepUpSKDevice / StepUpSKReader and the two BE32 message
* counters, plus the IV-stamping math (spec §8.3.1.6/8/9 + §8.4.3). * counters, plus the IV-stamping math (spec §8.3.1.6/8/9 + §8.4.3).
* All four arrays inside are CLEAR_ON_DESELECT. */ * 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 FLAG_KEYS_READY = 0;
private static final short FLAGS_LEN = 1; 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) { public static void install(byte[] bArray, short bOffset, byte bLength) {
StepUpApplet applet = new StepUpApplet(); StepUpApplet applet = new StepUpApplet();
if (bArray == null || bLength == 0) { if (bArray == null || bLength == 0) {
@@ -128,6 +164,9 @@ public class StepUpApplet extends Applet {
ivScratch = JCSystem.makeTransientByteArray(StepUpSession.IV_LEN, JCSystem.CLEAR_ON_DESELECT); ivScratch = JCSystem.makeTransientByteArray(StepUpSession.IV_LEN, JCSystem.CLEAR_ON_DESELECT);
scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT); scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT);
sessionFlags = JCSystem.makeTransientByteArray(FLAGS_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); processEnvelope(apdu);
return; return;
} }
if (cla == CLA_ISO && ins == INS_GET_RESPONSE) {
processGetResponse(apdu);
return;
}
if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) { if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) {
processExchange(apdu); processExchange(apdu);
return; return;
@@ -190,7 +233,6 @@ public class StepUpApplet extends Applet {
if (cla != CLA_ISO && cla != CLA_PROPRIETARY) { if (cla != CLA_ISO && cla != CLA_PROPRIETARY) {
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED); ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
} }
// GET RESPONSE handler plugs in here in follow-up milestones.
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); 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 * ENVELOPE (CLA=0x00, INS=0xC3) handler — M2D.3 (real DeviceResponse) +
* the mdoc DeviceRequest, then return a spec-shape encrypted empty CBOR * M2D.4 (ISO 7816 response chaining).
* map (canonical RFC 8949: single byte 0xA0).
* *
* <p>Spec §8.3.1.9: inbound payload (reader→device) is decrypted with * <p>Pipeline:
* {@code StepUpSKReader}, IV {@code 0x0000000000000000 || stepup_reader_counter} * <ol>
* (8-byte zero prefix + 4-byte BE counter), empty AAD. * <li>Decrypt the inbound payload under {@code StepUpSKReader} with
* * reader-side IV {@code 0x0000000000000000 || stepup_reader_counter}
* <p>Spec §8.3.1.6: outbound payload (device→reader) is encrypted with * (§8.3.1.9). Advance {@code stepup_reader_counter}.</li>
* {@code StepUpSKDevice}, IV {@code 0x0000000000000001 || stepup_device_counter} * <li>Structurally validate the recovered DeviceRequest via
* (8-byte prefix ending in 0x01 + 4-byte BE counter), empty AAD. * {@link DeviceRequestParser#validate} — propagates SW_DATA_INVALID
* * (malformed CBOR / wrong shape) or SW_CONDITIONS_NOT_SATISFIED
* <p>The DeviceRequest body is discarded in M1: we don't build a real * (unsupported version) unchanged.</li>
* mdoc DeviceResponse yet -- that's M2's job. The 17-byte ciphertext * <li>Build the canonical-CBOR DeviceResponse via
* (1 ct + 16 tag) is enough for X-CUBE-ALIRO to see a spec-shape * {@link DeviceResponseBuilder#build} carrying the cached Access
* encrypted response and move on. Counter sequencing: both * Document at {@code documents[0].issuerSigned.issuerAuth}.</li>
* stepup_reader_counter (shared with EXCHANGE) and stepup_device_counter * <li>In-place GCM-encrypt the DeviceResponse plaintext under
* advance independently after each successful use. * {@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.</li>
* <li>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.</li>
* </ol>
*/ */
private void processEnvelope(APDU apdu) { private void processEnvelope(APDU apdu) {
if (sessionFlags[FLAG_KEYS_READY] == 0) { if (sessionFlags[FLAG_KEYS_READY] == 0) {
@@ -292,6 +342,11 @@ public class StepUpApplet extends Applet {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); 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(); short lc = apdu.setIncomingAndReceive();
byte[] buf = apdu.getBuffer(); byte[] buf = apdu.getBuffer();
short dataOff = apdu.getOffsetCdata(); 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. // Spec §8.3.1.9: reader_counter <- reader_counter + 1 after use.
session.advanceReaderCounter(); session.advanceReaderCounter();
// Wipe the decrypted DeviceRequest -- M1 has no use for it. M2 will // Structural validation. Throws ISOException on malformed CBOR /
// replace this with real mdoc parsing and a real DeviceResponse. // 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); Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
// Build the canonical-CBOR empty map plaintext (single byte 0xA0, // Build the canonical-CBOR DeviceResponse into responseBuffer. The
// RFC 8949 major type 5 (map) with length 0). One byte total. // cached AD is staged directly into the builder via copyAccessDocument;
scratchPlaintext[0] = (byte) 0xA0; // 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 // Build the device-side IV: 0x00*7 || 0x01 || device_counter (§8.3.1.6).
// (§8.3.1.6 -- 8-byte prefix ending in 0x01, then 4B BE counter).
session.deviceIv(ivScratch, (short) 0); session.deviceIv(ivScratch, (short) 0);
// Encrypt into the APDU buffer at offset 0. Safe to overwrite the // In-place encrypt: AliroGcm supports out == in at the same offset.
// inbound command bytes here because we've finished reading them. // Ciphertext overwrites the plaintext; the 16 B tag appends after.
// Output length = 1 (ct) + 16 (tag) = 17 bytes; well under the 252-byte
// APDU buffer ceiling.
short ctLen = CryptoSingletons.getAliroGcm().encrypt( short ctLen = CryptoSingletons.getAliroGcm().encrypt(
session.skDevice, (short) 0, session.skDevice, (short) 0,
ivScratch, (short) 0, ivScratch, (short) 0,
scratchPlaintext, (short) 0, (short) 1, responseBuffer, (short) 0, respLen,
buf, (short) 0); responseBuffer, (short) 0);
// Spec §8.3.1.6: device_counter <- device_counter + 1 after use. // Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
session.advanceDeviceCounter(); session.advanceDeviceCounter();
// Wipe the single plaintext byte (CLEAR_ON_DESELECT alone would // Ship the first chunk. Total ciphertext is 388 B for the standard
// leave 0xA0 sitting in transient until reader walks away). // 272 B AD case; we send CHUNK_LEN bytes and signal more via SW=61xx
scratchPlaintext[0] = 0; // (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.
*
* <p>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)));
}
} }
/** /**

View File

@@ -220,26 +220,21 @@ class StepUpAppletTest {
} }
/** /**
* After SELECT-Step-Up the X-CUBE-ALIRO firmware also sends an ENVELOPE * M2D.3 + M2D.4 end-to-end: after SELECT-Step-Up the reader sends an
* command (CLA=0x00 INS=0xC3) carrying the encrypted mdoc DeviceRequest * ENVELOPE (CLA=0x00 INS=0xC3) carrying an encrypted mdoc DeviceRequest.
* (spec §8.4 + ISO 7816 ENVELOPE). Inbound encryption per §8.3.1.9 * The applet decrypts under StepUpSKReader (§8.3.1.9 IV), runs the
* (reader-side IV {@code 0x0000000000000000 || stepup_reader_counter}, * {@link DeviceRequestParser} structural validation, builds the real
* empty AAD); response encryption per §8.3.1.6 (device-side IV * {@link DeviceResponseBuilder} output around the cached Access Document,
* {@code 0x0000000000000001 || stepup_device_counter}, empty AAD). * and encrypts under StepUpSKDevice (§8.3.1.6 IV).
* *
* <p>For Milestone 1 we decrypt-and-discard the DeviceRequest, then * <p>The encrypted response is 372 B ciphertext + 16 B GCM tag = 388 B,
* return an encrypted single-byte CBOR empty map ({@code 0xA0}) so the * which exceeds the ~252 B APDU outgoing window. The applet therefore
* X-CUBE-ALIRO firmware sees a spec-shape encrypted response. Total * uses ISO 7816 response chaining: the first ENVELOPE response carries
* response bytes: 1 ct + 16 tag = 17. * the head chunk + SW=61xx ("xx more bytes available"), and the reader
* * pulls remaining chunks via GET RESPONSE (INS=0xC0) until SW=9000.
* <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.
*/ */
@Test @Test
void envelopeAfterStepUpSelectDecryptsAndAcksWithEncryptedEmptyCborMap() throws Exception { void envelopeAfterStepUpSelectReturnsRealDeviceResponseViaChaining() throws Exception {
sim = new CardSimulator(); sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length); AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class); sim.installApplet(expeditedAid, AliroApplet.class);
@@ -250,6 +245,15 @@ class StepUpAppletTest {
ReaderSide reader = new ReaderSide(); ReaderSide reader = new ReaderSide();
reader.provision(sim, credentialKeyPair); 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. // SELECT expedited + run AUTH0 + AUTH1.
assertEquals(0x9000, sim.transmitCommand( assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(), 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(), new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
"SELECT step-up must succeed"); "SELECT step-up must succeed");
// Reader-side encrypt of a 4-byte plaintext under the reader IV // Reader-side encrypt of a valid DeviceRequest CBOR under the reader IV
// (00 00 00 00 00 00 00 00 || 00 00 00 01). The applet decrypts and // (00*8 || 00 00 00 01). The applet decrypts, validates the structure
// discards; the actual plaintext is irrelevant for M1. // via DeviceRequestParser, then emits the cached DeviceResponse.
byte[] readerIv = new byte[12]; byte[] readerIv = new byte[12];
readerIv[11] = 0x01; readerIv[11] = 0x01;
byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding"); Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcmEnc.init(Cipher.ENCRYPT_MODE, gcmEnc.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(stepUpSKReader, "AES"), new SecretKeySpec(stepUpSKReader, "AES"),
new GCMParameterSpec(128, readerIv)); new GCMParameterSpec(128, readerIv));
byte[] envelopeBody = gcmEnc.doFinal(plaintext); // 4 + 16 = 20 bytes byte[] envelopeBody = gcmEnc.doFinal(VALID_DEVICE_REQUEST);
assertEquals(20, envelopeBody.length);
// 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( ResponseAPDU envelopeResp = sim.transmitCommand(
new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256)); new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256));
assertEquals(0x9000, envelopeResp.getSW(), int sw = envelopeResp.getSW();
"ENVELOPE with valid GCM tag must return SW=9000"); 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(); // Drain chunks via GET RESPONSE (INS=0xC0) until SW=9000.
assertEquals(17, respBody.length, java.io.ByteArrayOutputStream agg = new java.io.ByteArrayOutputStream();
"M1 ENVELOPE response = 1 ciphertext byte + 16 GCM tag"); 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 byte[] respBody = agg.toByteArray();
// (device-prefix per §8.3.1.6 + device_counter=1). 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]; byte[] deviceIv = new byte[12];
deviceIv[7] = 0x01; deviceIv[7] = 0x01;
deviceIv[11] = 0x01; deviceIv[11] = 0x01;
@@ -312,8 +334,121 @@ class StepUpAppletTest {
new SecretKeySpec(stepUpSKDevice, "AES"), new SecretKeySpec(stepUpSKDevice, "AES"),
new GCMParameterSpec(128, deviceIv)); new GCMParameterSpec(128, deviceIv));
byte[] decoded = gcmDec.doFinal(respBody); byte[] decoded = gcmDec.doFinal(respBody);
assertArrayEquals(new byte[] { (byte) 0xA0 }, decoded, assertArrayEquals(DEVICE_RESPONSE_HEX, decoded,
"M1 ENVELOPE response plaintext = canonical CBOR empty map (0xA0)"); "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 /** HKDF-SHA-256 with empty salt and single-block Expand (L=32). Mirrors