Files
aliro-project/docs/plans/2026-06-12-step-up-m2-full.md

25 KiB

Step-Up Milestone 2 (full) Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans (or superpowers:subagent-driven-development) to implement this plan task-by-task. Each task ships its tests, runs them, watches them fail, then implements + commits.

Goal: Replace M1's 0xA0 stub in StepUpApplet.processEnvelope with a real spec-shape mdoc DeviceResponse carrying the personalized Access Document, with GET RESPONSE chaining, GCM session reuse, cached IssuerAuth verification, real Reader Status sub-event in EXCHANGE, honest bitmap, and stripped diagnostic INSes. Verified end-to-end against PC/SC.

Architecture: Six new modules on the applet side (CoseVerifier, StructuralCbor, StepUpSession, DeviceResponseBuilder, real handlers in StepUpApplet + PersonalizationApplet), plus four small helpers on the harness side to drive the real Step-Up round-trip from aliro-bench-test. IssuerAuth verification runs once at personalization (inside JCSystem.beginTransaction()); per-transaction Step-Up reuses cached bytes. DeviceResponse is stream-encrypted as we emit, so we never hold both plaintext and ciphertext at once.

Tech Stack: Java Card 3.0.5 (J3R452), javacard.security.{Signature,KeyBuilder,KeyAgreement,MessageDigest}, GlobalPlatform 2.3. Reader side: Python 3.11 + cryptography + pyscard against PC/SC. Tests: JUnit 5 on jcardsim, pytest on the harness.

Hardware verdict path: PC/SC (NXP PR533 + J3R452 card) via aliro-bench-test --step-up. Nucleo / X-CUBE-ALIRO is deferred — ACWG_processAUTH1ResponsePayload errors upstream of ENVELOPE, so getting Nucleo DOOR-SUCCEEDED requires ST engagement, not M2 code. See m1_pcsc_verification memory.


Phase M2A — Cache IssuerAuth verification at personalization

The Access Document is a COSE_Sign1 (RFC 9052) signed by the Credential Issuer. Re-verifying every transaction is ~100 ms wasted. Verify once at personalization, persist a flag, trust thereafter. Constrains the trust anchor to the lifetime of the card (fine for DT implants).

Task M2A.1: Persistent accessDocumentVerified flag in CredentialStore

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java:57-309
  • Modify: applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java

Step 1: Write the failing test

In CredentialStoreSerializationTest, add:

@Test
void accessDocumentVerifiedRoundTripsThroughSerialize() {
    CredentialStore s1 = new CredentialStore();
    s1.markAccessDocumentVerified();
    byte[] blob = serialize(s1);
    CredentialStore s2 = CredentialStore.deserialize(blob);
    assertTrue(s2.isAccessDocumentVerified(), "verified flag must survive serialize/deserialize round-trip");
}

Step 2: Run and verify it fails

cd applet && ./scripts/dt-mvn.sh test -Dtest=CredentialStoreSerializationTest#accessDocumentVerifiedRoundTripsThroughSerialize

Expected: compile error "cannot find symbol markAccessDocumentVerified".

Step 3: Implement the minimal change

In CredentialStore.java:

private boolean accessDocumentVerified;   // beside accessDocumentFinalized

void markAccessDocumentVerified() { accessDocumentVerified = true; }
boolean isAccessDocumentVerified() { return accessDocumentVerified; }

Add sink.write(accessDocumentVerified); next to the existing accessDocumentFinalized write in serialize and the matching readBoolean() in deserialize. Add accessDocumentVerified = false; in reset().

Step 4: Run and verify it passes

Same command as Step 2. Expected: PASS.

Step 5: Commit

git add applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java
git commit -m "feat(applet): accessDocumentVerified flag in CredentialStore (M2A.1)"

Task M2A.2: CoseVerifier.verifyCoseSign1 (RFC 9052)

Files:

  • Create: applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java
  • Create: applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java
  • Reference: applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java (Signature.init pattern)

Step 1: Write the failing tests

CoseVerifierTest with two cases:

  • verifyCoseSign1_validSignature_returnsTrue: use a fixed-key COSE_Sign1 produced by Python (harness/tests/test_cose.py already has fixtures — borrow the same vectors).
  • verifyCoseSign1_tamperedPayload_returnsFalse: flip one payload byte, assert false.

Step 2: Run + verify fail

cd applet && ./scripts/dt-mvn.sh test -Dtest=CoseVerifierTest

Expected: "class CoseVerifier not found".

Step 3: Implement

CoseVerifier exposes:

boolean verifyCoseSign1(
    byte[] coseSign1, short coseOff, short coseLen,
    byte[] issuerPubUncomp, short pubOff)

Internal: walks the four-element COSE_Sign1 array, constructs the Sig_structure per RFC 9052 §4.4, runs ECDSA-P256 verify via Signature.ALG_ECDSA_SHA_256. Reuses the EC keypair seeding pattern from AliroCrypto (P-256 curve params).

Uses StructuralCbor (built next, in M2B). For now, hardcode the CBOR offsets from the spec-shape COSE_Sign1 the test vector produces, with a TODO to swap for StructuralCbor.elementSpan once M2B lands. (M2B.3 will replace the TODO.)

Step 4: Run + verify pass

Same command. Expected: both tests pass.

Step 5: Commit

git commit -am "feat(applet): CoseVerifier RFC 9052 verify (M2A.2)"

Task M2A.3: Wire IssuerAuth verify into PersonalizationApplet.finalizeAccessDocument

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java:158
  • Modify: applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java:192 (finalizeAccessDocument)
  • Modify: applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java

Step 1: Failing tests

Two test cases:

  • finalizeAccessDocument_validIssuerAuth_setsVerifiedFlag
  • finalizeAccessDocument_tamperedIssuerAuth_throws6985_andLeavesUnverified

Use ProvisioningHelper to drive the personalization sequence with a valid then tampered AD; assert state of accessDocumentVerified after each.

Step 2: Run + verify fail

./scripts/dt-mvn.sh test -Dtest=PersonalizationAppletTest

Expected: both new tests fail (current finalize doesn't verify).

Step 3: Implement

In CredentialStore.finalizeAccessDocument, accept the issuer pubkey + an AliroCrypto (or extracted CoseVerifier) and wrap the state transitions in a single transaction:

boolean finalizeAccessDocument(short totalLen, CoseVerifier verifier, byte[] issuerPubUncomp, short pubOff) {
    if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) return false;
    boolean verified = verifier.verifyCoseSign1(accessDocument, (short) 0, totalLen, issuerPubUncomp, pubOff);
    if (!verified) return false;
    javacard.framework.JCSystem.beginTransaction();
    try {
        accessDocumentLen = totalLen;
        accessDocumentFinalized = true;
        accessDocumentVerified = true;
        javacard.framework.JCSystem.commitTransaction();
    } catch (Throwable t) {
        javacard.framework.JCSystem.abortTransaction();
        return false;
    }
    return true;
}

In PersonalizationApplet, pass the credential-issuer pubkey from the credential store + the shared CoseVerifier. Update the existing finalizeAccessDocument caller and any test fixtures.

Step 4: Run + verify pass

Expected: both tests pass.

Step 5: Commit

git commit -am "feat(applet): IssuerAuth verify cached at personalization (M2A.3)"

Phase M2B — Structural CBOR codec

Only what we need: header decode, element span, locate IssuerAuth, canonical uint/bstr/tstr encoders. No floats, no indefinite-length, no tags. Per RFC 8949 §3.

Task M2B.1: StructuralCbor.decodeHeader

Files:

  • Create: applet/src/main/java/com/dangerousthings/aliro/StructuralCbor.java
  • Create: applet/src/test/java/com/dangerousthings/aliro/StructuralCborTest.java

Step 1: Failing tests — 8 cases covering each major type (0..6, skip 7 floats/special) and each argument size (immediate, 1B, 2B, 4B; reject 8B as out-of-scope for short-bounded buffers).

Step 2: Run + verify fail (-Dtest=StructuralCborTest#decodeHeader_*).

Step 3: Implement — single static method returning a packed short (majorType << 8 | argByteLen) plus an out-parameter for the argument value (or use a tiny 6B scratch). See harness/src/aliro_harness/issuer/cbor.py for the algorithm.

Step 4: Pass.

Step 5: Commit feat(applet): StructuralCbor.decodeHeader (M2B.1).

Task M2B.2: StructuralCbor.elementSpan

Files: Same as M2B.1.

Step 1: Failing tests — 5 cases: uint, bstr, tstr, array-of-3-uints, map-with-two-bstr-uint pairs. Each asserts the returned span equals the test vector's known length.

Step 2-4: Recursive walker built on decodeHeader. Throws ISOException(SW_DATA_INVALID) on indefinite-length or major-type-7.

Step 5: Commit feat(applet): StructuralCbor.elementSpan (M2B.2).

Task M2B.3: StructuralCbor.locateIssuerAuth

Files: Same as M2B.1. Also: swap M2A.2's hardcoded offsets in CoseVerifier for this helper.

Step 1: Failing test — feed the mdoc IssuerSigned wrapper bytes (from harness/tests/test_cose.py fixtures), assert returned (offset, length) matches the known IssuerAuth element.

Step 2-4: Walks the IssuerSigned map looking for key b"issuerAuth", returns the element span.

Step 5: Commit feat(applet): locate IssuerAuth + drop CoseVerifier offset TODO (M2B.3).

Task M2B.4: Canonical CBOR encoder

Files: Same as M2B.1.

Step 1: Failing tests — canonical encoding of:

  • encodeUint(0), (23), (24), (0xff), (0x100), (0xffff), (0x10000) — verify each picks the shortest argument size per RFC 8949 §4.2.1
  • encodeBstrHeader(0), (1), (23), (24), (0xff), (0x100) — header bytes only, caller writes the payload
  • encodeTstrHeader(...) — same shape as bstr but major-type 3

Step 2-4: Three static methods, all writing into a caller-supplied byte[] out, short outOff and returning bytes written.

Step 5: Commit feat(applet): canonical CBOR uint/bstr/tstr encoders (M2B.4).


Phase M2C — GCM session reuse for Step-Up

M1's processExchange/processEnvelope already call CryptoSingletons.getAliroGcm() directly. M2C wraps the IV construction (counter management, prefix) so M2D doesn't open-code it again. Smallest possible factoring.

Task M2C.1: StepUpSession IV builder

Files:

  • Create: applet/src/main/java/com/dangerousthings/aliro/StepUpSession.java
  • Create: applet/src/test/java/com/dangerousthings/aliro/StepUpSessionTest.java

Step 1: Failing tests — 4 cases:

  • readerIv_counter1_returnsAllZerosThen0001
  • readerIv_counter256_handlesCarryIntoByte2
  • deviceIv_counter1_returnsZeros_then01_then0001
  • incrementCounter_wrapsAt0xFFFFFFFF (no-op behavior — see existing StepUpApplet.incrementCounter)

Step 2-4: Lift the IV-build + counter logic from M1's StepUpApplet into instance methods on a tiny StepUpSession holding stepUpReaderCounter[4], stepUpDeviceCounter[4], and the two 32B keys (CLEAR_ON_DESELECT). Provide readerIv(byte[] out, short outOff), deviceIv(byte[] out, short outOff), advanceReaderCounter(), advanceDeviceCounter().

StepUpApplet consumes the new session: its select() constructs/refreshes one, and processExchange/processEnvelope ask the session for the IV instead of building it inline. Net change in StepUpApplet is ~30 lines simpler.

Step 5: Commit refactor(applet): extract StepUpSession from StepUpApplet (M2C.1).

Task M2C.2: AliroGcm.setKeyAndIv rekey helper

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
  • Modify: applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java

Step 1: Failing test — set key+iv once, encrypt block A; rekey via the new method, encrypt block B; assert both round-trip through decrypt. Confirms no leftover state.

Step 2-4: Add setKeyAndIv(byte[] key, short keyOff, byte[] iv, short ivOff) that performs the aesKey.setKey + ECB init + GHASH H computation + M-table build. Existing encrypt/decrypt paths call it instead of inlining those steps. Net change ~20 lines moved, no new state.

Step 5: Commit refactor(applet): AliroGcm.setKeyAndIv consolidates re-keying (M2C.2).


Phase M2D — Real ENVELOPE handler with DeviceResponse + chaining

This is the heart of M2. Replaces M1's 0xA0 stub.

Task M2D.1: mdoc DeviceRequest structural parse

Files:

  • Create: applet/src/main/java/com/dangerousthings/aliro/DeviceRequestParser.java
  • Create: applet/src/test/java/com/dangerousthings/aliro/DeviceRequestParserTest.java

We do NOT interpret field requests — we have one Access Document, we'll return it. We just structurally walk the request to confirm shape (per Aliro Table 8-19) and extract the namespace+itemRequests offsets in case M3 wants them. Keeps the parser honest about what's been seen.

Step 1: Failing tests — 3 cases:

  • parseDeviceRequest_validShape_returnsOk
  • parseDeviceRequest_unknownVersion_throws6985
  • parseDeviceRequest_malformedCbor_throws6A80

Step 2-4: Walks the top-level map looking for b"version" and b"docRequests", asserts version "1.0", that docRequests is an array with ≥1 entry, and that each entry has an itemsRequest bstr. Uses StructuralCbor exclusively.

Step 5: Commit feat(applet): DeviceRequestParser structural validation (M2D.1).

Task M2D.2: DeviceResponseBuilder

Files:

  • Create: applet/src/main/java/com/dangerousthings/aliro/DeviceResponseBuilder.java
  • Create: applet/src/test/java/com/dangerousthings/aliro/DeviceResponseBuilderTest.java

Step 1: Failing tests — 2 cases:

  • buildDeviceResponse_shape_matchesAliroTable8_22: assert top-level keys "version"="1.0", "documents" array of 1, then drill into [0]["issuerSigned"]["issuerAuth"] and confirm the bytes equal the personalized AD bytes verbatim.
  • buildDeviceResponse_outputLength_matchesPython: cross-check against harness/tests/test_device_response.py using the same trust bundle.

Step 2-4: Builds the response into a caller-supplied buffer using StructuralCbor canonical encoders. Embeds the cached AD bytes verbatim (already a complete COSE_Sign1 / IssuerAuth). Returns total byte length. Pure helper — no state.

Step 5: Commit feat(applet): DeviceResponseBuilder (M2D.2).

Task M2D.3: Stream-encrypt during ENVELOPE emit

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java (processEnvelope)
  • Modify: applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java

The lazy-but-correct shape: AliroGcm.encrypt already encrypts block-by-block in one pass. We pass a slice of DeviceResponseBuilder's output. The "stream" terminology in the v2 plan means avoiding double-buffering the whole plaintext: we build the DeviceResponse straight into a scratch buffer, encrypt in place to the APDU buffer, append the tag.

Step 1: Failing test — drive the full Step-Up sequence in StepUpAppletTest:

  1. AUTH0/AUTH1 via existing harness
  2. SELECT 5502
  3. INS=0xC9 EXCHANGE with valid Reader Status sub-event (M2E)
  4. INS=0xC3 ENVELOPE with a minimal DeviceRequest
  5. Decrypt response with StepUpSKDevice
  6. Assert decrypted bytes == known-good DeviceResponse bytes from harness/tests/test_device_response.py

Step 2-4: Replace the 0xA0 literal in processEnvelope with a call sequence:

  1. DeviceRequestParser on the decrypted inbound — reject on shape error.
  2. DeviceResponseBuilder into scratchPlaintext.
  3. Build device IV via stepUpSession.deviceIv(...).
  4. CryptoSingletons.getAliroGcm().encrypt(...) from scratch to apdu buffer.
  5. Advance device counter.

If the DeviceResponse exceeds APDU outgoing capacity, the encrypt completes (ciphertext lives in a side buffer) and we return SW=61xx for GET RESPONSE chaining — set up in M2D.4.

Step 5: Commit feat(applet): real DeviceResponse via stream-encrypted ENVELOPE (M2D.3).

Task M2D.4: GET RESPONSE chaining for >248B responses

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java
  • Modify: applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java

Our AD is 272 B today; wrapped in DeviceResponse + GCM-tagged it's ~370 B. Exceeds the 256 B APDU response cap. Need ISO 7816 GET RESPONSE chaining (SW=61 xx, INS=C0).

Step 1: Failing test — drive a full Step-Up where the DeviceResponse is 400 B. Assert:

  • First ENVELOPE returns SW=61 90 (144 more bytes available) with 256 B body
  • GET RESPONSE returns the remaining 144 B + SW=9000
  • Concatenated body decrypts cleanly

Step 2-4: Add a responseBuffer[400] CLEAR_ON_DESELECT plus offset/remaining counters. In processEnvelope, after stream-encrypt:

  • If total ≤ 252 B: send all + SW=9000.
  • If > 252: send first 252, store rest, return SW=61 ll (ll = remaining).

Add processGetResponse(APDU apdu) for INS=0xC0 on CLA=0x00 — sends next chunk, advances offsets, sets SW=61 ll if more remains or 9000 on the last chunk.

Step 5: Commit feat(applet): GET RESPONSE chaining on StepUpApplet (M2D.4).


Phase M2E — Real EXCHANGE Reader Status response

M1's EXCHANGE returns 9000 + empty. Spec §8.3.3.5 Table 8-20 says the response is a Reader Status sub-event payload (status=OK, optional payload). Vendor library may or may not accept the empty form; the real form is short and unambiguous, so just ship it.

Task M2E.1: Decrypt + structurally validate Reader Status request

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java (processExchange)
  • Modify: applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java

Step 1: Failing test — send valid Reader Status request (1B sub-event ID + 0B payload), assert 9000+spec-shape response (built in M2E.2). Send malformed payload, assert 6A80.

Step 2-4: After decrypt, parse the Reader Status sub-event header per Table 8-19: 1B sub-event ID, 1B payload length, payload. Reject unknown sub-event IDs with SW_DATA_INVALID.

Step 5: Commit feat(applet): EXCHANGE Reader Status request validation (M2E.1).

Task M2E.2: Build Reader Status response sub-event

Files: Same as M2E.1.

Step 1: Failing test — assert response bytes (after GCM decrypt with StepUpSKDevice) equal [0x01, 0x00, 0x00] (sub-event ID=AccessGranted=0x01, status=OK=0x00, payload-len=0).

Step 2-4: Build the 3 B Reader Status response sub-event in scratchPlaintext, encrypt with stepUpSession.deviceIv(...) + StepUpSKDevice, send 3+16=19 B with SW=9000.

Step 5: Commit feat(applet): real Reader Status response sub-event (M2E.2).


Phase M2F — Bitmap honesty + cleanup

Task M2F.1: Compute bitmap from capability

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java (the constant or builder for signaling_bitmap)
  • Modify: applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java

Step 1: Failing tests — 3 cases:

  • AD provisioned → bitmap = 0x0005 (bit 0 = AD retrievable, bit 2 = step-up AID required since we use the split-AID architecture)
  • AD not provisioned → bitmap = 0x0004 (bit 2 still set, but bit 0 cleared)
  • AD provisioned + not verified → bitmap = 0x0004 (treat unverified as not-available)

Step 2-4: Replace hardcoded 0x0005 with a computed value reading CredentialStore.hasAccessDocument() && isAccessDocumentVerified().

Step 5: Commit fix(applet): signaling_bitmap reflects actual AD capability (M2F.1).

Task M2F.2: Delete M1 placeholder responses

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java

Strip remaining // TODO M1: … comments and any dead code paths from the M1 stub form. Net deletion ~50 lines.

Step 1-2: No new tests — existing M2D/M2E tests cover behavior. Step 3: Delete only the comments + dead branches. Step 4: Full applet test suite stays green. Step 5: Commit chore(applet): drop M1 stub comments + dead paths (M2F.2).

Task M2F.3: Strip diagnostic INSes for production build

Files:

  • Modify: applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java (DIAGNOSTICS_ENABLED constant)
  • Modify: applet/pom.xml (verify j3r452 profile flips it off)

Step 1: Build CAP with DIAGNOSTICS_ENABLED=false; record before/after CAP size. Step 2-4: Flip the boolean default to false; gate test-only diag-INS tests on a system property so they skip in prod build. Expected CAP shrink: ~3 KB. Step 5: Commit chore(applet): strip diagnostic INSes from prod CAP (M2F.3).


Phase M2G — Cross-reader interop

Task M2G.1: Extend aliro-bench-test --step-up to assert AD round-trip

Files:

  • Modify: harness/src/aliro_harness/reader/step_up.py
  • Modify: harness/src/aliro_harness/reader/cli.py (no flag change — same --step-up does the new thing)
  • Modify: harness/tests/test_reader_step_up.py

Step 1: Failing testsverify_step_up_m2(transmit, step_up_sk, expected_ad) parses the decrypted DeviceResponse, locates documents[0].issuerSigned.issuerAuth, asserts bytes-equal to expected_ad.

Step 2-4:

  • Add verify_step_up_m2 next to verify_step_up_m1.
  • Extend CLI to load the expected AD from <trust-dir>/access_document.bin and pass it in.
  • Implement GET RESPONSE chaining in the transmit helper (while sw1==0x61: send INS=0xC0 P3=sw2).
  • Implement minimal DeviceRequest builder + the structural CBOR decoder for the response (reuse harness/src/aliro_harness/issuer/cbor.py).

Step 5: Commit feat(harness): --step-up asserts Access Document round-trip (M2G.1).

Task M2G.2: Run end-to-end verdict on real card

Files: None (verdict capture).

Step 1: Install M2 CAP per applet/INSTALL.md on a personalized card. Verify UID first per feedback_verify_card_uid. Step 2: Run aliro-bench-test --trust-dir ~/aliro-trust --step-up. Save output to docs/verdicts/2026-06-DD-m2-pcsc-verdict.log. Step 3: Expected: RESULT: OK for Expedited + STEP-UP M2: OK — Access Document round-trip verified, ABC bytes match. Step 4: If green: commit the verdict log. If red: open a debugging-skill session before patching anything.

Step 5: Commit chore(verdict): M2 PC/SC verdict — Access Document round-trip OK (M2G.2).

Task M2G.3: File vendor bug reports (parked until M2G.2 green)

Open issues with ST (X-CUBE-ALIRO ACWG_processAUTH1ResponsePayload error) and CSA (spec ambiguity on Reader Status sub-event response shape, if any). Independent of M2 code shipping — track in docs/vendor-bugs/.

Step 1: Commit docs(vendor-bugs): file ST X-CUBE-ALIRO bug + CSA spec questions (M2G.3).


Phase M2H — Documentation + memory

Task M2H.1: Memory + plan close-out

Files:

  • New: /home/work/.claude/projects/-home-work-VSCodeProjects-aliro-project/memory/step_up_implementation_notes.md (final form of all four optimizations + spec citations)
  • Modify: /home/work/.claude/projects/-home-work-VSCodeProjects-aliro-project/memory/MEMORY.md (index entry)
  • Modify: docs/plans/2026-06-12-step-up-m2-full.md (this file — add "Status: COMPLETE — verdict at docs/verdicts/...")
  • Modify: applet/INSTALL.md (add M2 acceptance criterion: AD round-trip)

Step 1: Commit docs: M2 close-out — memory, plan status, INSTALL.md (M2H.1).


Risk register (post-M1 adjustments)

Risk Likelihood Impact Mitigation
Transient RAM headroom blown by responseBuffer[400] for chaining Low High Current j3r452 free pool ~1,400 B post-M1. 400 B brings us to ~1,000 B. Comfortable.
CBOR encode of bytestr ≥ 24 B picks wrong argument-size class Low Medium M2B.4 tests cover every boundary (23/24/0xff/0x100/0xffff/0x10000).
Signature.ALG_ECDSA_SHA_256 on J3R452 quirks during IssuerAuth verify Low High Already used for AUTH0 reader-sig verify. No new risk.
processGetResponse race with another applet selection wiping state None StepUp keys are CLEAR_ON_DESELECT; a SELECT to another applet correctly invalidates the GET RESPONSE chain. By design.
Vendor X-CUBE-ALIRO rejects spec-shape DeviceResponse Deferred Out of scope until ST engagement. M2G.2 verdict is PC/SC only.

Acceptance criteria

  • All applet tests green (~130 cases including new ones)
  • All harness tests green (~125 cases)
  • aliro-bench-test --step-up on a personalized J3R452: RESULT: OK for Expedited + STEP-UP M2: OK — Access Document round-trip verified
  • Production CAP (DIAGNOSTICS_ENABLED=false) shrinks ~3 KB vs M1
  • Final code-reviewer subagent pass — no spec-conformance or quality blockers
  • Memory updated; step_up_implementation_notes.md published

Execution

After the plan is saved, the controller decides:

1. Subagent-Driven (this session)superpowers:subagent-driven-development dispatches one implementer subagent per task, two-stage review (spec → quality) per task, fresh context per task.

2. Parallel Session (separate) — open a new session in this worktree, run superpowers:executing-plans with checkpoints.

Either way: install + verdict on real hardware after every phase boundary (M2A done → bench-test still green; M2B done → still green; …) so we catch regressions early instead of at M2G.2.