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
* 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).
*
* <p>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.
*
* <p>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.
*
* <p>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.
* <p>Pipeline:
* <ol>
* <li>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}.</li>
* <li>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.</li>
* <li>Build the canonical-CBOR DeviceResponse via
* {@link DeviceResponseBuilder#build} carrying the cached Access
* Document at {@code documents[0].issuerSigned.issuerAuth}.</li>
* <li>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.</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) {
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.
*
* <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)));
}
}
/**