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:
@@ -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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user