docs: Step-Up implementation plans v1+v2 + inline TODOs at four opt sites

Two plans land:

- 2026-06-07-step-up-implementation.md (v1): original 6-phase plan
  written before the Path X session. Captured the four optimizations
  (opt 1: reuse AliroGcm; opt 2: structural CBOR only; opt 3:
  stream-encrypt during ENVELOPE emit; opt 4: cache IssuerAuth verify
  at personalization) and the spec citations for each. Superseded by v2
  but retained for the planning-history record.

- 2026-06-11-step-up-implementation-v2.md: revised after Path X
  resolved the three crypto bugs. Splits into Milestone 1 (~400 LOC,
  4-6 hours, gets DOOR OPERATION SUCCEEDED on stock X-CUBE-ALIRO with
  decrypt-and-discard stubs) and Milestone 2 (~1500 LOC, 1-2 weeks,
  real Access Document retrieval via mdoc DeviceResponse + CBOR + COSE).
  The four optimizations are preserved into M2 where they matter; M1
  ships without them since the demo doesn't need real document
  payload yet.

Inline TODO comments mark the four optimization sites in source:

- CredentialStore.finalizeAccessDocument -- opt 4a (verify-flag in a
  JCSystem.beginTransaction block) + opt 4b (Credential Issuer key
  rotation assumption documented).

- StepUpApplet class javadoc -- all four optimizations laid out so the
  implementer (subagent or human) lands them as they build the body.

(The AliroApplet INS_EXCHANGE comment that references the plans is
in the previous fix commit; it'll move/retire when M1 lands.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
michael
2026-06-11 10:19:05 -07:00
parent f94e416c99
commit 9189b41e7f
4 changed files with 918 additions and 0 deletions

View File

@@ -176,6 +176,18 @@ final class CredentialStore {
* Marks the Access Document as provisioned with {@code totalLen} bytes
* of valid content starting at offset 0. Returns false (and does not
* mutate state) if {@code totalLen} is outside {@code [0, ACCESS_DOC_MAX_LEN]}.
*
* <p>TODO (Step-Up impl, opt 4a): wrap this in
* {@code JCSystem.beginTransaction()} along with a one-shot IssuerAuth
* COSE_Sign1 verify against the stored Credential Issuer public key, and
* set a persistent {@code accessDocumentVerified} flag. Caching the
* verify result saves ~100 ms per Step-Up transaction at the cost of one
* extra persistent byte + the assumption that the Credential Issuer
* trust anchor is fixed for the card's lifetime (opt 4b — true for DT's
* implantable use case but document the limitation). The verify-flag
* write and the {@code accessDocumentFinalized} flip MUST land in the
* same atomic transaction so partial state can't ship an unverified
* document marked verified.
*/
boolean finalizeAccessDocument(short totalLen) {
if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) {

View File

@@ -23,6 +23,40 @@ import javacard.framework.Util;
* {@link AliroApplet}'s derivation in §8.3.1.13). Since Java Card
* installs distinct instances per AID, that state will be exchanged via
* a shared package-private holder (not yet implemented).
*
* <p><b>Implementation notes for the Step-Up milestone (see
* {@code docs/plans/2026-06-07-step-up-implementation.md}):</b>
* <ol>
* <li><b>Opt 1 — Reuse AliroGcm.</b> Don't allocate a second
* {@link AliroGcm} for Step-Up; reuse {@link AliroApplet}'s instance
* via {@code aesKey.setKey(stepUpSKDevice)} between phases. Saves
* ~368 B transient. ExpeditedSK and StepUpSK are distinct so the
* key/IV uniqueness invariant holds across rekey.</li>
* <li><b>Opt 2 — Structural CBOR only.</b> The Access Document is opaque
* transport bytes to us; the reader does field-level parsing. We only
* need a structural CBOR codec (major type + length boundaries) for
* the SessionData wrapper and to extract {@code IssuerAuth} for
* verification. Do not implement field-level mdoc parsing — wastes
* bytecode and RAM. CBOR encoding MUST be deterministic per RFC 8949
* §4.2.1 anywhere we emit.</li>
* <li><b>Opt 3 — Stream-encrypt during ENVELOPE emit.</b> Don't buffer
* the entire encrypted DeviceResponse then chunk it; pipe plaintext
* through GCM as we emit ENVELOPE response chunks. Reduces transient
* footprint AND minimizes plaintext residence in RAM.</li>
* <li><b>Opt 4 — IssuerAuth verify cached at personalization.</b> See
* {@link CredentialStore#finalizeAccessDocument(short)} TODO — the
* COSE_Sign1 verify happens once at write time, the persistent
* {@code accessDocumentVerified} flag is checked here at read time.
* Saves ~100 ms per transaction. Trusts that the Credential Issuer
* trust anchor is fixed for the card's lifetime — true for DT's
* implantable target but document the limitation.</li>
* </ol>
*
* <p>Also when this lands, revisit {@link AliroApplet}'s {@code INS_EXCHANGE}
* stub: with real Step-Up at this AID, the stub becomes either (a) unused
* if {@code signaling_bitmap} bit 2 stays set and spec-conformant readers
* route EXCHANGE here, or (b) replaceable with a proper "Reader Status
* sub-event" reply if X-CUBE-ALIRO's vendor bug persists.
*/
public class StepUpApplet extends Applet {

View File

@@ -0,0 +1,497 @@
# Step-Up Phase Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implement Aliro v1.0 §8.4 Step-Up Phase on the Java Card applet so the Access Document and Revocation Document become retrievable over NFC, replacing the current `INS_EXCHANGE` stub with real ENVELOPE/GET RESPONSE handling against any spec-conformant reader.
**Architecture:** All Step-Up code lives in `StepUpApplet` (`A000000909ACCE5502`). The Step-Up phase shares the `AliroGcm` instance with `AliroApplet` (rekeyed between phases — saves ~368 B transient; relies on ExpeditedSK ≠ StepUpSK to preserve key/IV uniqueness). Access Document parsing is *structural only* — we validate CBOR major-type/length boundaries and locate `IssuerAuth`, but do not interpret mdoc field semantics. `IssuerAuth` COSE_Sign1 verification is performed *once at personalization* (inside `JCSystem.beginTransaction()` with the document write) and cached as a persistent verified-flag, saving ~100 ms per transaction. The DeviceResponse is stream-encrypted as ENVELOPE response chunks are emitted, never buffering the full ciphertext.
**Tech Stack:** Java Card 3.0.5 (target J3R452), userland AES-256-GCM with 4-bit GHASH (existing), native SHA-256 + ECDSA-P256 verify, structural CBOR codec (RFC 8949 §4.2.1 deterministic encoding for emit paths), COSE_Sign1 (RFC 9052) over `IssuerAuth`, mdoc DeviceRequest/DeviceResponse CBOR shapes with Aliro-remapped integer keys (spec Tables 8-21, 8-22).
**Reference Material:**
- Aliro 1.0 spec §8.4 (Step-up phase), §8.3.3.5 (EXCHANGE), §10.2.1 (SELECT), Tables 8-14/8-19/8-20/8-21/8-22
- RFC 8949 (CBOR core deterministic encoding)
- RFC 9052 (COSE_Sign1)
- ISO/IEC 18013-5 §8 (mdoc DeviceRequest/DeviceResponse base; Aliro remaps the integer keys)
- Existing applet code: `AliroApplet.java` (Expedited phase), `AliroGcm.java` (rekeyable target), `CredentialStore.java` (Access Document storage), `StepUpApplet.java` (scaffold + extensive TODO comments)
- Session memory: `MEMORY.md` includes the Nucleo bring-up state, j3r452 chip-target decision, transient RAM budget
- This-session traces and decisions: vendor firmware (X-CUBE-ALIRO) violates §10.2 SHALL on step-up AID SELECT — our work should still be spec-conformant; the existing `AliroApplet.INS_EXCHANGE` stub is vendor-specific and gets retired when this lands
**Out of scope for this plan:**
- BLE Step-Up (separate transport layer; only NFC here)
- mdoc field-level interpretation (we transport bytes; reader interprets)
- Revocation Document handling beyond exposing it via the same path (signaling_bitmap bit 1; same shape as Access Document — implementation is identical, just a different blob)
---
## Phase A — Foundation: cache IssuerAuth verification at personalization
Unblocks every later phase: Step-Up just emits the bytes and trusts the cached verification.
### Task A1: Add persistent verified-flag to CredentialStore
**Files:**
- Modify: [applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java](applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java)
- Test: [applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java](applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java)
**Step 1:** Write failing test for `accessDocumentVerified` getter.
```java
@Test
void freshlyConstructedStoreReportsAccessDocumentNotVerified() {
CredentialStore store = CredentialStore.bootstrap();
assertFalse(store.isAccessDocumentVerified());
}
```
**Step 2:** Run test, expect FAIL with "method not defined."
`./scripts/dt-mvn.sh test -Dtest=CredentialStoreSerializationTest#freshlyConstructedStoreReportsAccessDocumentNotVerified`
**Step 3:** Add the field + getter to `CredentialStore`.
```java
// Inside the class — alongside accessDocumentFinalized
private boolean accessDocumentVerified; // persistent EEPROM byte
boolean isAccessDocumentVerified() {
return accessDocumentVerified;
}
```
**Step 4:** Run test, expect PASS.
**Step 5:** Update `CredentialStoreSerializationTest`'s contract test to include the new field in the serialization layout (the test asserts byte-stable layout to catch silent reorders — adjust the expected layout bytes).
**Step 6:** Run full test suite, expect 81/81 PASS.
`./scripts/dt-mvn.sh test`
**Step 7:** Commit.
```bash
git add applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java
git commit -m "feat: add accessDocumentVerified persistent flag to CredentialStore"
```
---
### Task A2: Add ECDSA-P256 COSE_Sign1 verifier method
**Files:**
- Create: `applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java`
- Test: `applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java`
**Step 1:** Write failing test using a known-good COSE_Sign1 + key.
```java
@Test
void verifiesValidCoseSign1AgainstP256Key() throws Exception {
// Use a fixed test vector — generate offline with cose-go or pycose,
// bake in. payload + signature + EC point of signer.
byte[] coseSign1 = HEX.decode("8443a10126a05446b6173685f74657374..."); // full vector
byte[] signerPubXY = HEX.decode("04abcd..."); // 65 B uncompressed P-256
boolean ok = CoseVerifier.verifyCoseSign1(coseSign1, (short)0, (short)coseSign1.length,
signerPubXY, (short)0);
assertTrue(ok);
}
```
**Step 2:** Run, expect FAIL ("class not found").
**Step 3:** Implement `CoseVerifier.verifyCoseSign1`:
- Parse the COSE_Sign1 outer CBOR array: `[protected_header, unprotected_header, payload, signature]`
- Construct the Sig_structure: `["Signature1", protected_header, external_aad=empty bstr, payload]` per RFC 9052 §4.4
- CBOR-encode the Sig_structure deterministically (RFC 8949 §4.2.1)
- SHA-256 the encoded Sig_structure
- ECDSA-P256 verify the signature against the digest using the signer pubkey
Uses existing `MessageDigest.ALG_SHA_256` and `Signature.ALG_ECDSA_SHA_256` instances — refactor `AliroApplet.ecdsaVerifier` access if needed (move to a shared helper class).
**Step 4:** Run, expect PASS.
**Step 5:** Add 3 more test vectors: invalid signature (verify returns false), tampered payload (verify returns false), wrong signer key (verify returns false).
**Step 6:** Run all 4 tests, expect PASS.
**Step 7:** Commit.
```bash
git add applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java
git commit -m "feat: add COSE_Sign1 verifier for IssuerAuth (P-256)"
```
---
### Task A3: Wire IssuerAuth verification into PersonalizationApplet.finalizeAccessDocument
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java`
- Modify: `applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java` (`finalizeAccessDocument` becomes `finalizeAccessDocumentVerified` taking the Credential Issuer pubkey)
- Test: `applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java`
**Step 1:** Write failing test.
```java
@Test
void finalizeAccessDocumentSetsVerifiedFlagAtomically() throws Exception {
// Provision Access Document with a valid IssuerAuth COSE_Sign1
byte[] validAd = TestVectors.validAccessDocumentWithIssuerAuth();
// ... write chunks ...
boolean ok = store.finalizeAccessDocumentVerified((short) validAd.length);
assertTrue(ok);
assertTrue(store.isAccessDocumentVerified());
}
@Test
void finalizeAccessDocumentRejectsInvalidIssuerAuth() throws Exception {
byte[] tamperedAd = TestVectors.validAccessDocumentWithIssuerAuth();
tamperedAd[100] ^= 0x01; // flip a payload byte
// ... write chunks ...
boolean ok = store.finalizeAccessDocumentVerified((short) tamperedAd.length);
assertFalse(ok);
assertFalse(store.isAccessDocumentVerified());
assertFalse(store.hasAccessDocument(), "tampered AD must not be marked finalized either");
}
```
**Step 2:** Run, expect FAIL.
**Step 3:** Replace `CredentialStore.finalizeAccessDocument(short)` with `finalizeAccessDocumentVerified(short, byte[] issuerKeyBuf, short issuerKeyOff)`:
```java
boolean finalizeAccessDocumentVerified(short totalLen, byte[] issuerKey, short issuerKeyOff) {
if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) return false;
// Locate the IssuerAuth COSE_Sign1 inside accessDocument[0..totalLen)
// (structural CBOR walk — opt 2 — implemented in Phase B; for now use a
// helper that walks the outer CBOR map looking for the well-known key)
short[] iaSpan = StructuralCbor.locateIssuerAuth(accessDocument, (short) 0, totalLen);
if (iaSpan == null) return false;
boolean verified = CoseVerifier.verifyCoseSign1(
accessDocument, iaSpan[0], iaSpan[1], issuerKey, issuerKeyOff);
if (!verified) return false;
JCSystem.beginTransaction();
accessDocumentLen = totalLen;
accessDocumentFinalized = true;
accessDocumentVerified = true;
JCSystem.commitTransaction();
return true;
}
```
**Step 4:** Update `PersonalizationApplet.process()` call site to pass the Credential Issuer pubkey (held in trust anchors).
**Step 5:** Run tests, expect PASS.
**Step 6:** Sanity check — `PersonalizationAppletTest` still passes 14/14 with the updated finalize signature.
**Step 7:** Commit.
```bash
git add applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java
git commit -m "feat: verify IssuerAuth at personalization, cache result (opt 4a)"
```
---
## Phase B — Structural CBOR codec
Boundary-walker only — we don't interpret mdoc field semantics (opt 2).
### Task B1: CBOR major-type / argument decoder
**Files:**
- Create: `applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java`
- Test: `applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java`
**Step 1:** Write failing tests for the 8 CBOR major types' length decoding (uint, nint, bstr, tstr, array, map, tag, simple/float).
```java
@Test void decodesSingleByteUint() { /* 0x07 → value=7, len=1 */ }
@Test void decodesOneByteArg() { /* 0x18 0xFF → value=0xFF, len=2 */ }
@Test void decodesTwoByteArg() { /* 0x19 0x01 0x00 → value=0x100, len=3 */ }
@Test void decodesFourByteArg() { /* 0x1A 0x00 0x00 0x10 0x00 → value=0x1000, len=5 */ }
@Test void rejectsIndefiniteLengthInDeterministicMode() { /* 0x9F (start-array, indefinite) → throws */ }
// ... 4 more for the remaining major types
```
**Step 2:** Run, expect FAIL.
**Step 3:** Implement `StructuralCbor.decodeHeader(byte[] buf, short off)` returning `{majorType, value, headerLen}` as a `short[3]`. RFC 8949 §3 mapping. Reject indefinite-length encodings (canonical-only).
**Step 4:** Run, expect PASS.
**Step 5:** Commit.
```bash
git add applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java
git commit -m "feat: structural CBOR header decoder (RFC 8949 §3, canonical only)"
```
---
### Task B2: CBOR element-span walker
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java`
- Modify: `applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java`
**Step 1:** Write failing test for "given an offset to a CBOR element, return the offset+length of that element including nested children."
```java
@Test
void elementSpanIncludesNestedArrayChildren() {
// [1, [2, 3], 4] = 0x83 01 82 02 03 04 — total 6 bytes
byte[] buf = HEX.decode("8301820203 04");
short[] span = StructuralCbor.elementSpan(buf, (short) 0);
assertEquals(0, span[0]);
assertEquals(6, span[1]);
}
```
**Step 2-5:** Implement, test, commit recursive span walker. Add 4 more tests: nested map, byte string with payload bytes, tagged value, deep nesting (5 levels).
```bash
git commit -m "feat: structural CBOR element-span walker (recursive)"
```
---
### Task B3: Locate IssuerAuth inside Access Document
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java`
- Modify: `applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java`
**Step 1:** Write failing test using a sample Access Document (mdoc IssuerSigned wrapper). Walk to the well-known map key for IssuerAuth.
```java
@Test
void locatesIssuerAuthInValidAccessDocument() throws Exception {
byte[] ad = TestVectors.validAccessDocumentWithIssuerAuth();
short[] span = StructuralCbor.locateIssuerAuth(ad, (short) 0, (short) ad.length);
assertNotNull(span);
// First byte of IssuerAuth COSE_Sign1 must be 0x84 (array of 4 items)
assertEquals((byte) 0x84, ad[span[0]]);
}
```
**Step 2-5:** Implement, test, commit. The IssuerAuth key in mdoc IssuerSigned per ISO/IEC 18013-5 is the text string "issuerAuth" — walk the outer map keys looking for that string and return the span of the corresponding value.
```bash
git commit -m "feat: locate IssuerAuth COSE_Sign1 inside mdoc IssuerSigned wrapper"
```
---
### Task B4: Canonical CBOR encoder for the small primitives we emit
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java`
- Modify: `applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java`
**Step 1:** Write failing tests for encoding uint, bstr, tstr (the only types we emit in DeviceResponse).
**Step 2-5:** Implement RFC 8949 §4.2.1 deterministic encoding of those three primitives. (No need for arrays/maps as host-side built — we only emit small primitives interleaved with pre-built CBOR blobs.) Test, commit.
```bash
git commit -m "feat: canonical CBOR encoder for uint/bstr/tstr emission"
```
---
## Phase C — GCM session rekey for Step-Up (opt 1)
Reuse the existing `AliroGcm` instance via `aesKey.setKey(stepUpSKDevice)` between phases.
### Task C1: Expose AliroGcm key-rekey + AES-GCM stream interface
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java`
- Test: `applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java`
**Step 1:** Write failing test: same AliroGcm instance encrypts with key K1 then K2, both round-trip correctly via NIST GCM test vectors.
**Step 2-5:** Add a `setKeyAndIv()` method that rebuilds H + M-table + J0 from a new key + IV. Verify with NIST TC vectors for both keys. Commit.
```bash
git commit -m "feat: AliroGcm supports key rekey for Step-Up phase reuse (opt 1)"
```
---
### Task C2: Step-Up GCM IV derivation per spec §8.4.3
**Files:**
- Create: `applet/src/main/java/com/dangerousthings/aliro/StepUpSession.java`
- Test: `applet/src/test/java/com/dangerousthings/aliro/StepUpSessionTest.java`
**Step 1:** Write failing test for IV construction (12-byte IV = 4-byte salt + 4-byte counter + 4-byte device/reader identifier per §8.4.3 — confirm exact bytes against spec).
**Step 2-5:** Implement IV builder + monotonic counter. Test, commit. The counter MUST persist within the session — `CLEAR_ON_DESELECT` transient. Reuse across rekey forbidden (would break GCM nonce-uniqueness).
```bash
git commit -m "feat: Step-Up GCM IV derivation + monotonic counter (§8.4.3)"
```
---
## Phase D — ENVELOPE / GET RESPONSE INS handling on StepUpApplet
### Task D1: SELECT FCI for StepUpApplet (improve existing scaffold)
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java`
- Modify: `applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java`
**Step 1:** Write failing test for full FCI per §10.2.1.2 (must advertise APDU command/response sizes).
**Step 2-5:** Extend `sendStepUpFci` to include the proprietary information TLV. Test, commit.
```bash
git commit -m "feat: StepUpApplet SELECT FCI advertises APDU sizes (§10.2.1.2)"
```
---
### Task D2: INS_ENVELOPE handler skeleton (no chaining yet)
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java`
- Modify: `applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java`
**Step 1:** Write failing test: single-shot ENVELOPE (no chain bit set) with a known DeviceRequest payload returns a DeviceResponse with the cached-verified Access Document encrypted.
```java
@Test
void singleShotEnvelopeReturnsEncryptedDeviceResponse() throws Exception {
// Personalize with valid AD + IssuerAuth (Phase A)
// Drive AUTH0 + AUTH1 to derive StepUpSK
// Send SELECT to ACCE5502
// Send ENVELOPE C3 00 00 [encrypted DeviceRequest]
ResponseAPDU r = sendEnvelope(deviceRequestBytes);
assertEquals(0x9000, r.getSW());
byte[] devResp = decryptStepUpResponse(stepUpSKDevice, r.getData());
assertContainsAccessDocument(devResp);
}
```
**Step 2-5:** Implement `INS_ENVELOPE = 0xC3`. Decrypt the payload with StepUpSKReader. Discard the DeviceRequest (we don't parse mdoc fields — opt 2; just trust the reader asked correctly). Emit a DeviceResponse: top-level CBOR map containing the cached-verified Access Document bytes, encrypted with StepUpSKDevice. Test, commit.
```bash
git commit -m "feat: StepUpApplet ENVELOPE single-shot handler (no chain yet)"
```
---
### Task D3: ENVELOPE response chaining via GET RESPONSE (opt 3 — stream encrypt)
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java`
- Modify: `applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java`
**Step 1:** Write failing tests:
- Large DeviceResponse triggers GET RESPONSE (`INS = 0xC0`) flow
- Multiple chained ENVELOPE commands accumulate the request payload before responding
- Response chunks come out via repeated GET RESPONSE
**Step 2-5:** Implement chaining. Critical: stream-encrypt during emit (opt 3). Maintain transient state for "GCM partial state, plaintext remaining offset, response bytes emitted so far." Each GET RESPONSE picks up where the last chunk left off, runs more plaintext through GCM, emits up to APDU response size. Final chunk includes the authentication tag and SW=9000; intermediate chunks return SW=61xx (more data available). Test, commit.
```bash
git commit -m "feat: ENVELOPE/GET RESPONSE chaining with streaming GCM (opt 3)"
```
---
## Phase E — Wire up the bitmap honesty + retire the stub
### Task E1: Remove the X-CUBE-ALIRO-specific INS_EXCHANGE stub from AliroApplet
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java`
- Modify: `applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java`
**Step 1:** Write failing test: `AliroApplet` returns SW_INS_NOT_SUPPORTED for INS 0xC9. (Spec-conformant readers route EXCHANGE to ACCE5502 now that it works there.)
**Step 2-5:** Remove the INS_EXCHANGE case from `AliroApplet.process()`. Update the comment cluster. Test, commit. (Note: this may regress X-CUBE-ALIRO behavior; if so, document explicitly in the commit + flag in the Nucleo bring-up memory.)
```bash
git commit -m "refactor: retire INS_EXCHANGE stub on AliroApplet (Step-Up now real)"
```
---
### Task E2: End-to-end integration test on real hardware
**Step 1:** Build the j3r452 CAP. Verify all 80+ existing tests still pass + new ones land green.
**Step 2:** Reinstall on the personalized j3r452. Run `aliro-bench-test` — Expedited AUTH1 still 3.2 s.
**Step 3:** Add an `aliro-bench-stepup` CLI to the harness that drives the full sequence (AUTH0 + AUTH1 + SELECT step-up AID + ENVELOPE/GET RESPONSE for Access Document retrieval). Verify the returned Access Document bytes match what `aliro-personalize` wrote.
**Step 4:** Move card to Nucleo. Capture UART. Confirm `DOOR OPERATION SUCCEEDED`.
**Step 5:** Update Nucleo bring-up memory with the success record.
**Step 6:** Commit.
```bash
git commit -m "feat: end-to-end Step-Up verified on Nucleo NFC10A1 + j3r452"
```
---
## Phase F — Cleanup + documentation
### Task F1: Update memory + plan close-out
- Mark this plan complete in `MEMORY.md`
- Update `nucleo_nfc10a1_bringup_state.md` with the Step-Up end-to-end success
- Add a `step_up_implementation_notes.md` memory capturing the four optimizations' final form + any deviations from this plan
### Task F2: Documentation pass on the public README
- Document the four optimizations + their security/spec deviations
- Note the bitmap-honesty change (no longer claiming Step-Up before it worked)
- Reference the §10.2 vendor bug discussion as a closed-out concern
---
## Risk register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| j3r452 transient RAM doesn't fit additional Step-Up buffers | Medium | High | Phase 2 (RAM diet from main todo list) lands ~488 B before this work; stream-encrypting (opt 3) limits the worst case. Re-measure transient usage after Phase D2. |
| CBOR canonical encoding subtleties (negative zero floats, NaN canonicalization) | Low | Medium | We don't emit floats — only uint/bstr/tstr. RFC 8949 §4.2.1 is fully unambiguous for those three. |
| ECDSA-P256 verify on j3r452 is "native" but quirky like HMAC was | Low | High | We already use ECDSA verify in AUTH1 reader-signature path (works). No new risk introduced. |
| Vendor firmware (X-CUBE-ALIRO) still bypasses step-up AID SELECT even with bitmap=0x0005 | High | Medium | Already known. Once Step-Up is real, the vendor bug becomes the vendor's problem — our applet is spec-conformant on `ACCE5502` and any reader that honors §10.2 will work. X-CUBE-ALIRO users will need to file the bug we already characterized. |
| EEPROM wear from `accessDocumentVerified` flag flips | Low | Low | Set once at personalization, never updated. Single-write to a single byte; no wear concern. |
---
## Acceptance criteria
- Step-Up phase end-to-end works against a spec-conformant Aliro reader (PC/SC bench reader after harness extension)
- Step-Up end-to-end works against X-CUBE-ALIRO Nucleo firmware after vendor firmware bug is fixed (or we document the workaround in `app_aliro.c` if we patch it ourselves)
- `signaling_bitmap` reflects actual capabilities (no advertising what we don't serve)
- All four optimizations land as documented:
- Opt 1: only one `AliroGcm` instance for both phases
- Opt 2: no field-level mdoc parsing on the card
- Opt 3: stream-encrypt during ENVELOPE emit (no full-buffer staging)
- Opt 4: IssuerAuth verified once at personalization; per-transaction skip
- Transient RAM headroom: AliroApplet + StepUpApplet combined ≤ 2,500 B `CLEAR_ON_DESELECT` (i.e., ≥ 600 B headroom on j3r452's 3,120 B pool)
- All 80+ existing tests + new Step-Up tests pass under the existing `./scripts/dt-mvn.sh test` harness
- INS_EXCHANGE stub on AliroApplet removed (no vendor-firmware-specific compat shim shipped)
- Plan + this risk register reviewed by a code-reviewer subagent before merge
---
Plan complete and saved to `docs/plans/2026-06-07-step-up-implementation.md`. Two execution options:
**1. Subagent-Driven (this session)** — I dispatch fresh subagent per task, review between tasks, fast iteration.
**2. Parallel Session (separate)** — Open new session with executing-plans, batch execution with checkpoints.
Which approach?

View File

@@ -0,0 +1,375 @@
# Step-Up Phase Implementation Plan (v2 — supersedes 2026-06-07)
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implement Aliro v1.0 §8.4 Step-Up Phase end-to-end against stock X-CUBE-ALIRO firmware so the j3r452 returns `DOOR OPERATION SUCCEEDED` on a real NFC reader, then incrementally extend to full Access Document retrieval over the same code path.
**Architecture:** Step-Up handlers live on `StepUpApplet` (`A000000909ACCE5502`). The `StepUpSK` 32-byte session secret is already derived during AUTH1 by `AliroApplet` and parked in the shared `SessionContext` (`armStepUp()`); `StepUpApplet` reads it via `copyStepUpSK()` after the vendor firmware issues the spec-mandated step-up AID SELECT. The same `AliroGcm` instance gets re-keyed for the Step-Up phase (one shared object across both phases — saves ~368 B transient). CBOR parsing is structural-only (major-type + length boundaries). `IssuerAuth` COSE_Sign1 verification is cached at personalization. The plan ships in **two demoable milestones**: Milestone 1 = minimum to make X-CUBE-ALIRO say "DOOR SUCCEEDED" (no real document retrieval, just spec-shaped acks), Milestone 2 = real Access Document retrieval.
**Tech Stack:** Java Card 3.0.5 (target J3R452), userland AES-256-GCM with 4-bit GHASH (existing), native SHA-256 + ECDSA-P256 verify, structural CBOR codec (RFC 8949 §4.2.1 deterministic encoding for emit), COSE_Sign1 (RFC 9052), mdoc DeviceRequest/DeviceResponse shapes (ISO/IEC 18013-5 §8 with Aliro-remapped integer keys per spec Tables 8-21/8-22).
**What today's Path X session locked in (no longer in scope to investigate):**
1. **Crypto baseline is interop-correct.** Three bugs found and fixed:
- `salt_volatile` had an extra 32-byte `x(credential_long_term_pub)` (commit pending; line 720-723 of `AliroApplet.buildSaltVolatile`)
- `Kdh` derived via HKDF instead of X9.63 KDF per §8.3.1.4 (commit pending; `AliroCrypto.deriveKdh`)
- `salt_volatile` used AUTH0's `command_parameters` instead of AUTH1's (commit pending; same `buildSaltVolatile`)
Vendor library's `ACWG_processAUTH1ResponsePayload` now returns `ACWG_OK` on a freshly personalized j3r452 — confirmed in `/tmp/nucleo-three-fixes.log` against UID `04565E4A0B2190`.
2. **X-CUBE-ALIRO firmware DOES honor §10.2 step-up AID SELECT.** Once `processAUTH1ResponsePayload` succeeds and `signaling_bitmap` bit 2 is set, the firmware correctly issues `00 A4 04 00 09 A0 00 00 09 09 AC CE 55 02 00`. The "vendor SHALL violation" I was planning to file with ST was actually downstream of the GCM decrypt failure — one less vendor bug to file. Our existing `StepUpApplet.sendStepUpFci` already responds 9000 with a minimal FCI.
3. **Vendor firmware sends TWO post-AUTH1 commands on Step-Up:**
- `00 C3 00 00 <len> ENVELOPE` — encrypted mdoc DeviceRequest (Table 8-21 keys)
- `80 C9 00 00 <len> EXCHANGE` — encrypted Reader Status sub-event (§8.3.3.5.x)
Both arrive after the SELECT to `ACCE5502`. Both need to land on `StepUpApplet` (not `AliroApplet`). ENVELOPE's CLA is `0x00` — unusual for a proprietary command but matches the spec table. EXCHANGE's CLA is `0x80`.
4. **StepUpSK derivation is wired.** `AliroApplet.processAuth1` extracts bytes 64..96 of `derived_keys_volatile` and calls `SessionContext.armStepUp(stepUpSK, 0)`. The consumer side (`StepUpApplet`) just hasn't been written yet.
**Out of scope for this plan:**
- BLE Step-Up (separate transport)
- mdoc field-level interpretation beyond locating IssuerAuth + extracting nameSpaces (we transport bytes; the reader interprets)
- Revocation Document handling (same shape as Access Document; just a different blob — covered implicitly by the same emit path)
**Reference material:**
- Aliro 1.0 spec §8.3.1.5 (HKDF), §8.3.1.13 (key material), §8.4 (Step-up phase), §10.2 (Transaction), Tables 8-14 / 8-21 / 8-22
- RFC 8949 (CBOR core deterministic encoding)
- RFC 9052 (COSE_Sign1)
- ISO/IEC 18013-5 §8 (mdoc DeviceRequest/DeviceResponse; Aliro Tables 8-21/8-22 remap the integer keys)
- Existing code: `AliroApplet.java`, `AliroGcm.java`, `AliroCrypto.java`, `SessionContext.java`, `StepUpApplet.java` (scaffold with TODOs that name each optimization)
- Today's interop trace: `/tmp/nucleo-three-fixes.log` (shows the exact ENVELOPE + EXCHANGE bytes the firmware sends)
- Original Step-Up plan: `docs/plans/2026-06-07-step-up-implementation.md` (this plan supersedes it; reuses the four optimization names + spec citations)
---
## Milestone 1 — "DOOR OPERATION SUCCEEDED" on X-CUBE-ALIRO (~400 LOC, 4-6 hours)
**Result:** The Nucleo prints `DOOR OPERATION SUCCEEDED` against our card. No real Access Document is served — we acknowledge the ENVELOPE and EXCHANGE with spec-shape empty responses. Enough for fundraising demo, leaves the real protocol body for Milestone 2.
### Phase M1A — Wire `StepUpSKDevice`/`StepUpSKReader` into `StepUpApplet`
Per §8.4.3, the Step-Up phase uses fresh GCM session keys derived from `StepUpSK`. Spec text:
> Compute `StepUpSKDevice = HKDF(IKM=StepUpSK, salt=∅, info="SKDevice", L=32)`
> Compute `StepUpSKReader = HKDF(IKM=StepUpSK, salt=∅, info="SKReader", L=32)`
The HKDF helpers already exist on `AliroCrypto` (Extract/Expand are public-package-visible). The info strings are 8 ASCII bytes each.
### Task M1A.1: Add a `deriveStepUpSessionKeys` helper to `AliroCrypto`
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java`
- Modify: `applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java`
**Step 1: Write a failing test for the device/reader derivations.**
```java
@Test
void deriveStepUpSessionKeysMatchesRfc5869WithSpecInfoStrings() throws Exception {
AliroCrypto crypto = new AliroCrypto();
byte[] stepUpSK = new byte[32];
for (byte i = 0; i < 32; i++) stepUpSK[i] = (byte) (0x10 + i);
byte[] skDevice = new byte[32];
byte[] skReader = new byte[32];
crypto.deriveStepUpSessionKeys(stepUpSK, (short) 0, skDevice, (short) 0, skReader, (short) 0);
// Reference: RFC 5869 HKDF-SHA-256, salt=∅, info=ASCII("SKDevice")/("SKReader")
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(new byte[32], "HmacSHA256")); // empty salt
byte[] prk = mac.doFinal(stepUpSK);
byte[] expectedDevice = hkdfExpandOnce(prk, "SKDevice".getBytes("ASCII"), 32);
byte[] expectedReader = hkdfExpandOnce(prk, "SKReader".getBytes("ASCII"), 32);
assertArrayEquals(expectedDevice, skDevice);
assertArrayEquals(expectedReader, skReader);
}
```
(`hkdfExpandOnce` is a tiny BouncyCastle-free helper at the bottom of the test class.)
**Step 2: Run, expect FAIL with "method not defined."**
```bash
./scripts/dt-mvn.sh test -Dtest=AliroCryptoTest#deriveStepUpSessionKeysMatchesRfc5869WithSpecInfoStrings
```
**Step 3: Implement `AliroCrypto.deriveStepUpSessionKeys`.**
Uses existing `hkdfExtract` + `hkdfExpand`. Info strings are 8-byte static `byte[]` fields. The "empty salt" passes a zero-length range to `hkdfExtract`, which already falls back to `emptySaltZeros`.
```java
private static final byte[] STEP_UP_INFO_SK_DEVICE = {
'S','K','D','e','v','i','c','e'
};
private static final byte[] STEP_UP_INFO_SK_READER = {
'S','K','R','e','a','d','e','r'
};
short deriveStepUpSessionKeys(
byte[] stepUpSK, short stepUpSKOff,
byte[] skDeviceOut, short skDeviceOff,
byte[] skReaderOut, short skReaderOff) {
// PRK = HKDF-Extract(salt=∅, IKM=StepUpSK)
hkdfExtract(
emptySaltZeros, (short) 0, (short) emptySaltZeros.length,
stepUpSK, stepUpSKOff, HASH_LEN,
hkdfPrevT, (short) 0);
// SKDevice = HKDF-Expand(PRK, info="SKDevice", L=32)
hkdfExpand(
hkdfPrevT, (short) 0, HASH_LEN,
STEP_UP_INFO_SK_DEVICE, (short) 0, (short) STEP_UP_INFO_SK_DEVICE.length,
HASH_LEN,
skDeviceOut, skDeviceOff);
// SKReader = HKDF-Expand(PRK, info="SKReader", L=32)
hkdfExpand(
hkdfPrevT, (short) 0, HASH_LEN,
STEP_UP_INFO_SK_READER, (short) 0, (short) STEP_UP_INFO_SK_READER.length,
HASH_LEN,
skReaderOut, skReaderOff);
return HASH_LEN;
}
```
**Step 4: Run test, expect PASS.**
**Step 5: Run full suite, expect 81/81 PASS (one new test).**
**Step 6: Commit.**
```bash
git add applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java \
applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java
git commit -m "feat: AliroCrypto.deriveStepUpSessionKeys per §8.4.3"
```
### Task M1A.2: Have `StepUpApplet` derive its session keys on demand
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java`
- Modify: `applet/src/main/java/com/dangerousthings/aliro/SessionContext.java` (expose `armStepUpAlreadyArmed()` if needed; current `isStepUpArmed()` works)
- Test: `applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java`
**Step 1: Write failing test — StepUpApplet, freshly selected after an armed SessionContext, can derive its SKDevice/SKReader.**
Re-uses existing test plumbing in `AliroAppletAuth1Test.auth1SuccessArmsStepUpContextWithExpectedStepUpSK`.
**Step 2-5:** Add private fields `stepUpSKDevice[32]`, `stepUpSKReader[32]` to `StepUpApplet` (transient, CLEAR_ON_DESELECT). On `select()`: if `SessionContext.isStepUpArmed()`, copy `StepUpSK` via `copyStepUpSK()` then call `AliroCrypto.deriveStepUpSessionKeys(...)`. Re-use the singleton `AliroCrypto` already used by `AliroApplet` (move it to a package-private static getter on a `CryptoSingletons` class so both applets share the same instance — opt 1 prelude).
**Step 6: Commit.**
```bash
git commit -m "feat: StepUpApplet derives SKDevice/SKReader on select when StepUpSK armed"
```
### Phase M1B — Decrypt-and-discard `EXCHANGE` (INS 0xC9) on `StepUpApplet`
X-CUBE-ALIRO sends a "Reader Status sub-event" via EXCHANGE after the Step-Up SELECT. From today's trace: 20 bytes of payload (4-byte plaintext after AES-GCM strip-tag-16). Per §8.3.3.5 the response is a Reader Status response sub-event — for our purposes an empty acknowledgment works.
### Task M1B.1: Decrypt the inbound EXCHANGE payload with `StepUpSKReader`
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java`
- Test: `applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java`
**Step 1: Write failing test that drives a fake X-CUBE-ALIRO-shape EXCHANGE.**
Set up: AUTH0+AUTH1 via existing test harness. Use `ReaderSide` to derive `StepUpSKReader`, encrypt a 4-byte payload with `expedited_reader_counter=1` per §8.3.1.8, frame as `80 C9 00 00 <len> <ct||tag> 00`, send to StepUpApplet via JCardSim. Assert SW=9000.
**Step 2-5:** Implement INS_EXCHANGE handler that:
- Reads encrypted_payload via `apdu.setIncomingAndReceive()`
- Builds IV = `0x0000000000000000 || expedited_reader_counter` (4B BE) per §8.3.1.8
- Calls `gcm.decrypt(StepUpSKReader, IV, ct, tag, plaintext_scratch)`
- Discards plaintext (we don't care what the Reader Status said — log/ack only)
- Increments `expedited_reader_counter`
- Returns `apdu.setOutgoingAndSend((short) 0, (short) 0)` — 9000 with no payload
But wait: spec says Reader needs the EXCHANGE response in same secure channel. Vendor may or may not accept empty. **Test first whether 9000-empty is enough; if not, encrypt-and-return an empty CBOR map (`0xA0`).** Note `apdu.setOutgoingAndSend` returns after sending — extend with a one-shot encrypt that produces 17 bytes (1B `0xA0` + 16B tag) if needed.
**Step 6: Commit.**
```bash
git commit -m "feat: StepUpApplet INS_EXCHANGE decrypt/discard/ack (minimal Reader Status)"
```
### Phase M1C — Decrypt-and-discard `ENVELOPE` (INS 0xC3) on `StepUpApplet`
X-CUBE-ALIRO sends the mdoc DeviceRequest via ENVELOPE with CLA=0x00, INS=0xC3. From today's trace: 75 bytes of payload (the encrypted DeviceRequest CBOR with `expedited_device_counter`-derived IV).
### Task M1C.1: Accept CLA=0x00 on `StepUpApplet.process`
Current code rejects anything not `CLA_PROPRIETARY` (0x80). ENVELOPE is `0x00 C3 ...` per spec Table 10-? (see `app_aliro.c` ENVELOPE_POS for the byte values we just observed).
**Step 1-5:** Add INS dispatch handling for `CLA=0x00 INS=0xC3`. Same decrypt-and-discard pattern as EXCHANGE but with the device-side key + the device counter starting from 1 + §8.3.1.6 IV format (`0x0000000000000001 || expedited_device_counter`). Return empty-encrypted DeviceResponse (`0xA0` CBOR empty map encrypted with `StepUpSKDevice`).
**Step 6: Commit.**
```bash
git commit -m "feat: StepUpApplet INS_ENVELOPE (CLA=00) decrypt/discard, return empty CBOR map ack"
```
### Phase M1D — Retire the `AliroApplet.INS_EXCHANGE` stub
Now that `StepUpApplet` handles EXCHANGE on its own AID, the bitmap=0x0005 + `AliroApplet` EXCHANGE stub from June 7 is obsolete. The vendor firmware will route post-AUTH1 commands to `ACCE5502` correctly.
### Task M1D.1: Remove the stub
**Files:**
- Modify: `applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java` (delete the `case INS_EXCHANGE:` block)
- Modify: `applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java` (assert `AliroApplet` now returns `SW_INS_NOT_SUPPORTED` for INS 0xC9)
**Step 1-5:** TDD as usual. Spec-conformant readers route EXCHANGE to `ACCE5502` after step-up SELECT; the stub on `AliroApplet` was vendor-firmware-bug-specific compat and is now strictly wrong (would let stale `expedited_device_counter` confuse the vendor).
**Step 6: Commit.**
```bash
git commit -m "refactor: retire AliroApplet INS_EXCHANGE stub now that StepUpApplet handles it"
```
### Phase M1E — End-to-end Nucleo retest
### Task M1E.1: Rebuild, reinstall, retest on real hardware
1. Rebuild j3r452 CAP: `./scripts/dt-mvn.sh -Pj3r452 clean package -DskipTests`
2. Verify the card on PC/SC: `aliro-bench-test --trust-dir ~/aliro-trust` (AUTH1=9000, ~3.2s)
3. Move to Nucleo + capture via `python3 -c "import serial,time; ..."` (pyserial — reliable; bash `cat` is flaky after USB re-enumeration)
4. **Expected verdict:**
```
NFCA Passive ISO-DEP device found. UID: 04565E4A0B2190
SELECT (ACCE5501) → 9000
AUTH0 → 9000
AUTH1 → 9000
SELECT STEPUP (ACCE5502) → 9000
ENVELOPE (00 C3 ...) → 9000 + encrypted empty CBOR map
EXCHANGE (80 C9 ...) → 9000 + (empty or encrypted-empty)
DOOR OPERATION SUCCEEDED ← THE MILESTONE
```
5. **If different failure mode:** capture full UART, identify the next vendor error class (`retval=ACWG_Error_*`), iterate.
### Task M1E.2: Update memory + commit milestone
- Update `nucleo_nfc10a1_bringup_state.md` with the Milestone 1 success and note: real Access Document retrieval is deferred to Milestone 2.
- Commit any docs changes.
```bash
git commit -m "feat: Step-Up Milestone 1 - DOOR OPERATION SUCCEEDED on X-CUBE-ALIRO"
```
---
## Milestone 2 — Real Access Document retrieval (~1500 LOC, 1-2 weeks)
**Result:** Spec-conformant Step-Up phase. The Reader receives our actual provisioned Access Document via the mdoc DeviceResponse over ENVELOPE/GET RESPONSE chaining. IssuerAuth verification is cached at personalization time so per-transaction latency stays at ~3.2 s AUTH1 + ~500ms-1s Step-Up. Bitmap goes back to fully honest about capability.
### Phase M2A — Foundation: cache IssuerAuth verification at personalization (opt 4a + 4b)
(Tasks A1-A3 from the original 2026-06-07 plan, mostly unchanged.)
- **Task M2A.1:** Add `accessDocumentVerified` persistent boolean to `CredentialStore` + getter (~30 lines + test)
- **Task M2A.2:** Add `CoseVerifier.verifyCoseSign1` (RFC 9052, ECDSA-P256) (~150 lines + test vectors)
- **Task M2A.3:** Wire IssuerAuth verification into `PersonalizationApplet.finalizeAccessDocument` inside `JCSystem.beginTransaction()` (~50 lines + tests for valid/tampered)
### Phase M2B — Structural CBOR codec (opt 2)
(Tasks B1-B4 from the original plan, unchanged.)
- **Task M2B.1:** `StructuralCbor.decodeHeader` — RFC 8949 §3 major-type + argument decoder (~80 lines + 8 tests)
- **Task M2B.2:** `StructuralCbor.elementSpan` — recursive span walker (~50 lines + 5 tests)
- **Task M2B.3:** `StructuralCbor.locateIssuerAuth` — walk mdoc IssuerSigned wrapper (~30 lines + test)
- **Task M2B.4:** Canonical CBOR encoder for uint/bstr/tstr only (the primitives we emit) (~50 lines + tests)
### Phase M2C — GCM session reuse for Step-Up (opt 1)
- **Task M2C.1:** Add `AliroGcm.setKeyAndIv` rekey method (~30 lines)
- **Task M2C.2:** `StepUpSession` IV builder for both directions (~50 lines)
(Note: deriving keys + decrypting the inbound `EXCHANGE` and `ENVELOPE` payloads is already done at Milestone 1; M2C just consolidates the GCM instance to share with `AliroApplet`.)
### Phase M2D — Real ENVELOPE handler with DeviceResponse + chaining (opt 3)
- **Task M2D.1:** mdoc DeviceRequest structural parse (don't interpret fields, just confirm shape + extract docRequest namespace IDs) (~80 lines + tests)
- **Task M2D.2:** Build DeviceResponse with cached-verified Access Document bytes (~120 lines + tests)
- Top-level CBOR map per spec Table 8-22
- Embed cached `accessDocument` bytes verbatim (already CBOR-encoded by issuer)
- **Task M2D.3:** Stream-encrypt during ENVELOPE emit (opt 3) (~80 lines + tests)
- **Task M2D.4:** GET RESPONSE chaining when DeviceResponse exceeds APDU size (~60 lines + tests)
### Phase M2E — Real EXCHANGE Reader Status response
- **Task M2E.1:** Decrypt Reader Status sub-event request (~30 lines + tests)
- **Task M2E.2:** Build Reader Status response sub-event (status=OK, no payload) per §8.3.3.5 Table 8-20 (~50 lines)
### Phase M2F — Bitmap honesty + cleanup
- **Task M2F.1:** Switch bitmap from hardcoded 0x0005 back to a computed value reflecting actual capability (bit 0 set iff Access Document provisioned, bit 2 set per spec since we use split AID) (~5 lines + test)
- **Task M2F.2:** Delete the Milestone 1 stub responses, replace with real ones (~50 lines net)
- **Task M2F.3:** Strip diagnostic INSes (`INS_DIAG_*`) — flip `DIAGNOSTICS_ENABLED=false`, run prod build, verify CAP shrinks ~3 KB (M2F.3 is a quick win — Phase 6 of the older RAM-diet/cleanup todo list)
### Phase M2G — Cross-reader interop
- **Task M2G.1:** Run end-to-end on stock X-CUBE-ALIRO: DOOR SUCCEEDED + Access Document bytes round-tripped (verified by adding a print hook to the firmware that dumps the received DeviceResponse, comparing to what we provisioned)
- **Task M2G.2:** Run end-to-end on `aliro-bench-test` Python reader (after extending the harness with `aliro-bench-stepup` CLI that drives ENVELOPE/GET RESPONSE)
- **Task M2G.3:** File the WTX-cap bug + one or both remaining vendor issues with ST and/or CSA (see "vendor bug reports" backlog item below)
### Phase M2H — Documentation + memory
- Update `MEMORY.md` with Milestone 2 completion
- Mark this plan complete; add `step_up_implementation_notes.md` memory capturing the four optimizations' final form + spec citations
- README pass on the Step-Up architecture decisions
---
## Risk register (revised based on today's learnings)
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| j3r452 transient RAM doesn't fit additional Step-Up buffers | Low (revised down) | High | Today's measurements show ~1.7 KB used / 3.1 KB available; Milestone 1 adds ~150 B for SK keys; opt 3 streaming caps Milestone 2 at +400 B max. Comfortable headroom. |
| CBOR canonical encoding subtleties | Low | Medium | We only emit uint/bstr/tstr — fully unambiguous per RFC 8949 §4.2.1. No floats, no special cases. |
| Vendor library rejects "empty" Reader Status response | Medium | Low | If 9000-empty doesn't work in Milestone 1, fall back to a 17-byte encrypted-empty-CBOR-map. Adds ~30 lines if needed. |
| Vendor library rejects "empty" DeviceResponse map | Medium | Low | Same fallback: encrypt `0xA0` (empty map) instead of returning empty payload. |
| ECDSA-P256 verify on j3r452 quirks | Low | High | We already use it for AUTH0 reader-sig verification (works). No new risk. |
| Spec ambiguity on Reader Status sub-event response shape | Medium | Medium | Try empty first; if vendor errors, capture the new error class + iterate. Same Path-X-style debugging we just demonstrated end-to-end. |
---
## Acceptance criteria
**Milestone 1:**
- Stock X-CUBE-ALIRO firmware (with only the documented `RFAL_ISODEP_MAX_WTX_RETRYS` cap bump) prints `DOOR OPERATION SUCCEEDED` against our card
- PC/SC `aliro-bench-test` still passes (≥ regression check)
- All existing 80+ unit tests pass + new StepUpApplet tests
- AliroApplet INS_EXCHANGE stub retired
- Transient RAM usage on j3r452 remains ≤ 2,200 B `CLEAR_ON_DESELECT` (≥ 900 B headroom on the 3,120 B pool)
**Milestone 2 (additional):**
- Real Access Document bytes round-trip through ENVELOPE/GET RESPONSE
- All four optimizations land as documented (reuse AliroGcm, structural CBOR only, stream-encrypt, IssuerAuth verify cached at personalization)
- `signaling_bitmap` honestly reflects capability
- Diagnostic INSes stripped for production CAP
- Code-reviewer subagent pass before merge
---
## Vendor bug reports backlog (separate from this plan)
These were genuinely identified during the AUTH1 bring-up; they remain real even after today's progress:
1. **WTX retry cap default of 20 is too aggressive.** Card running spec-compliant userland GCM on the most permissive ISO 7816 secure element (j3r452) needs ~11 WTX cycles for AUTH1. ST's 20-cap is conservative; a 50-cap default would cover ecosystem. File with ST as middleware enhancement request.
2. **(Possible — verify during Milestone 1) X-CUBE-ALIRO may still reject empty-payload Reader Status responses.** If observed, file with ST as spec-clarification (RFC the Reader Status response shape with CSA).
Item 1 is real; item 2 we'll know during M1E.
---
Plan complete and saved to `docs/plans/2026-06-11-step-up-implementation-v2.md`. Two execution options:
**1. Subagent-Driven (this session)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Parallel Session (separate)** — Open new session with executing-plans, batch execution with checkpoints.
Which approach?