Compare commits

...

48 Commits

Author SHA1 Message Date
michael
62ceef89e9 test(applet): adversarial GCM regression tests (post-M2 security pass)
Pin five negative-space properties of AliroGcm.decrypt that the NIST KAT
suite doesn't exercise. Catches the implementation flaws most likely to
slip into a userland AEAD impl:

- decrypt_tamperedCiphertextByte: single bit flip in ct rejects + out[]
  unchanged (proves tag verify runs BEFORE GCTR-decrypt)
- decrypt_tamperedTagByte: single bit flip in tag rejects + out[]
  unchanged (proves no early-exit / no conditional plaintext leak)
- decrypt_wrongKey: single-bit key difference rejects (key confusion)
- decrypt_wrongIv: single-bit IV difference rejects (replay-with-rebind)
- decrypt_inputShorterThanTag: <16 B input throws ILLEGAL_VALUE
  without touching out[] (early bounds check)

13/13 AliroGcmTest tests pass (was 8 + 5 new). Full suite 152/0/0.
2026-06-17 21:22:46 -07:00
michael
c85ecd2f73 perf(applet): trim 3 over-sized transient buffers (~216 B reclaimed)
J3R452 CLEAR_ON_DESELECT pool was at ~285 B headroom post-M2G.2.
Tighten three M1/M2 conservative sizings to spec-actual values:

- AliroApplet.SALT_VOLATILE_CAPACITY: 200 -> 144
  Spec §8.3.1.13 fixes salt_volatile at 141 B. 144 = 141 + 3 B pad.
- StepUpApplet.RESPONSE_BUFFER_LEN: 512 -> 416
  Holds 388 B max (372 B canonical DeviceResponse + 16 B GCM tag).
  AD is fixed 272 B at personalization, wrapper shape is fixed.
- StepUpApplet.SCRATCH_PLAINTEXT_LEN: 256 -> 192
  M2D.1 valid DeviceRequest vector is 85 B, Reader Status request
  is 2 B; 192 leaves ~107 B headroom for larger reader requests.

No spec deviation. No security implication (sizes are upper bounds on
buffers, not crypto parameters). All 147 applet tests pass (3 documented
pre-existing GCM errors unchanged).

New pool budget: ~2,619 / ~3,120 B used, ~501 B headroom.
2026-06-17 21:01:50 -07:00
michael
f9b0c1ea81 Merge feat/step-up-m2: Step-Up Milestone 2 — real Access Document round-trip
M2 verified GREEN on real card (J3R452 04555A4A0B2190) via PC/SC. Adds
spec-conformant mdoc DeviceResponse with cached Access Document over
ENVELOPE + GET RESPONSE chaining, real Reader Status sub-event over
EXCHANGE, cached IssuerAuth verification at personalization, and honest
signaling_bitmap. 22 commits across applet + harness + docs.

Phase summary:
- M2A: accessDocumentVerified flag + CoseVerifier (RFC 9052) + INS 0x25
       issuer pubkey + verify-at-finalize inside JCSystem.beginTransaction
- M2B: StructuralCbor.{decodeHeader,elementSpan,encodeUint/Bstr/Tstr}
- M2C: StepUpSession extraction + AliroGcm.setKeyAndIv consolidation
- M2D: DeviceRequestParser + DeviceResponseBuilder + stream-encrypted
       ENVELOPE + GET RESPONSE chaining for 388 B responses
- M2E: EXCHANGE Reader Status request validation + spec-shape response
- M2F: signaling_bitmap honesty + M1 stub sweep + DIAGNOSTICS_ENABLED=false
       + CAP converter int support
- M2G: harness --step-up asserts AD round-trip; PC/SC verdict GREEN
       (docs/verdicts/2026-06-12-m2-pcsc-verdict.log); ST vendor bug
       filed (docs/vendor-bugs/2026-06-12-st-xcube-aliro-*).
- M2H: INSTALL.md M2 acceptance + step_up_implementation_notes.md memory
       (architecture + 4 optimizations + transient pool budget table)

Verdict output (J3R452 04555A4A0B2190, NXP PR533 PC/SC):
  RESULT: OK -- applet round-trip on real hardware.
    0x5E signaling_bitmap: 0x0005
    APDU latencies (ms): select 23, auth0 667, auth1 3,258
  STEP-UP M2: OK -- EXCHANGE + ENVELOPE Access Document round-trip

Tests: 147 applet pass (3 pre-existing jcardsim-lacks-GCM errors) +
133 harness pass.

Nucleo / X-CUBE-ALIRO interop deferred until ST responds to the bug
report. PC/SC is the M2 demo + verification path.
2026-06-17 18:35:26 -07:00
michael
0e0a338b26 docs(vendor-bugs): fill firmware version + ST routing for ST bug report
X-CUBE-ALIRO V1.0.0 (25-Feb-2026) + ST25 RFAL middleware V2.8.0 per
the Release_Notes.html in the SDK we built against. ST routing set to
the X-CUBE-ALIRO support form on st.com; replace with named FAE/partner
contact if/when ST advises a different routing.
2026-06-17 18:35:03 -07:00
michael
576eeca401 docs(applet): finalize INSTALL.md M2 acceptance — pin verdict log + UID (M2H.1 final)
Updates the --step-up expected-output block to the actual STEP-UP M2 OK
line emitted on 2026-06-12 verdict, and pins the verified card UID +
log path. Memory step_up_implementation_notes.md filled in the 3 TODO
markers (M2E + M2F + M2G.2 verdict) and added a J3R452 transient pool
budget table (~2,835 / 3,120 B used post-M2G.2).
2026-06-17 18:06:58 -07:00
michael
5521503258 fix(applet): CoseVerifier scratch 768B->384B for J3R452 transient pool fit (M2G.2)
The M2 verdict run on J3R452 04555A4A0B2190 tripped SW=0x6FC4 on
AUTH0 (AliroCrypto.expandScratch makeTransientByteArray failure).
Summed CLEAR_ON_DESELECT allocations exceeded the ~3,120 B pool:
- PersonalizationApplet/CoseVerifier scratch: 768 B
- AliroApplet: 838 B (sessionState + scratch + derivedKeys + saltVolatile + kdh)
- StepUpApplet + StepUpSession: ~893 B (responseBuffer + scratchPlaintext + ...)
- AliroGcm + AliroCrypto (lazy, at first AUTH0): 720 B
Total ~3,220 B vs ~3,120 B cap.

CoseVerifier's 768 B was M2A.2's conservative sizing for up to 512 B COSE
payload. Aliro's IssuerAuth payload is ~188 B today. Tighten:
- MAX_PAYLOAD 512 -> 256 (still 36% headroom over actual)
- scratch 768 -> 384 (Sig_structure ~310 B + DER sig 72 B = ~382 B)

Saved 384 B, brings total to ~2,836 B. CAP rebuilt (100,990 B),
reinstalled + repersonalized + bench-test re-run -- GREEN.

Verdict log: docs/verdicts/2026-06-12-m2-pcsc-verdict.log
  RESULT: OK -- applet round-trip on real hardware
    0x5E signaling_bitmap: 0x0005
    APDU latencies: select 23 ms, auth0 667 ms, auth1 3,258 ms
  STEP-UP M2: OK -- EXCHANGE + ENVELOPE Access Document round-trip
2026-06-17 18:05:16 -07:00
michael
377ce297f1 chore(applet): strip diagnostic INSes from prod CAP (M2F.3)
Flip DIAGNOSTICS_ENABLED static-final boolean from true to false so
the production j3r452 CAP rejects INS_DIAG_HMAC/ECDH/ECDSA_SIGN/GCM
(0xD0-0xD3) with SW_INS_NOT_SUPPORTED. javac's constant-folding
elides the diagnostic dispatch branch in process(); the JC converter
keeps the unreachable processDiag method body and DIAG_KEY_32 etc.
arrays so the byte saving is modest (the JC converter does not do
whole-program DCE on private fields/methods).

CAP size (j3r452): before 101008 B -> after 100989 B (delta -19 B,
0.02% reduction). This is smaller than the original ~2-3 KB estimate
in the plan -- the estimate assumed javac/JC-converter DCE would
prune the diag arrays and methods, but neither does. Reclaiming the
~2 KB diag-vector + diag-method footprint requires extracting the
diag code to a separate class and excluding it from the production
profile, which is out of scope for M2 (file-level surgery, not a
constant flip). The runtime SW_INS_NOT_SUPPORTED rejection still
delivers the security objective: no diag opcodes execute on a prod
build.

For CI/bench builds that need the diag opcodes, the constant can be
locally flipped to true (no test changes required; no diag tests in
the suite reference INS_DIAG_*).

Regression: 147 tests, 0 failures, 3 pre-existing GCM errors (no
new failures).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 17:07:32 -07:00
michael
dc5118f392 build(applet): enable int support in CAP converter
M2D.2 introduced StructuralCbor.encodeUint(int,...) and call sites
in DeviceResponseBuilder that pass int literals. The JC converter
rejected this without an explicit ints="true" attribute, breaking
both j3r180 and j3r452 CAP builds since 9879668.

ant-javacard's <cap ints="true"> opts the package into JCRE int
support. The CBOR encoders need it to handle uint values up to
2^32-1 per RFC 8949.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 17:07:18 -07:00
michael
d24d72e119 chore(applet): drop M1 stub comments + dead paths + naming consistency (M2F.2)
Sweep stale M1-era cruft and milestone task markers obsoleted by M2:

- StepUpApplet class Javadoc: fix stale @link target for
  finalizeAccessDocument (now takes verifier+scratch65), drop "TODO"
  prose for shipped M2A.3 verify-at-finalize, rewrite "INS_EXCHANGE stub
  was retired" past-tense note as present-tense ownership statement.
- StepUpApplet: drop "M1B / M1C" / "M2E.1" / "M2E.2" / "M2D.3+M2D.4"
  task markers from comments and method Javadocs; describe current
  behavior, not milestone provenance.
- StepUpApplet: rewrite "decrypt-and-discard" sink Javadoc — that sink
  now also stages the EXCHANGE Reader Status request plaintext.
- StepUpSession: drop "matching M1 behaviour" Javadoc trailers.
- AliroApplet bitmap comment: align with the new method name and drop
  the M1 historical aside that's now adjacent to the only behavior left.
- AliroAppletTest: rewrite "Once M1B.1 / M1C.1 land..." as present tense.
- CredentialStore.markAccessDocumentVerified() → ForTesting suffix to
  match the sibling markAccessDocumentFinalizedForTesting() and add an
  explanatory Javadoc clarifying that production callers reach the
  verified state via finalizeAccessDocument(short, CoseVerifier, byte[]).
  Two test call sites updated.

No behavior change. Tests: 147 / 0 / 3 pre-existing GCM errors. Net
diff +46/-44 = +2 LOC (the rename's explanatory Javadoc minus the cruft).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 17:01:12 -07:00
michael
ed316102bf fix(applet): signaling_bitmap reflects actual AD capability (M2F.1)
Replaces the hardcoded 0x0005 in Table 8-11 with a computed value:
- Bit 2 (step-up AID SELECT required) is always set — our architecture
  always uses split AID (5501 expedited + 5502 step-up) so the channel
  is always advertised, even when no AD is present.
- Bit 0 (AD retrievable) is set only when hasAccessDocument() AND
  isAccessDocumentVerified() — we don't advertise a capability we
  can't deliver. The "finalized but not verified" branch is defensive
  (post-M2A.3 finalize requires verify) but matches the contract.

Tests in AliroAppletAuth1Test:
- auth1_bitmap_adNotProvisioned_returns0x0004 (replaces the old
  "all-zero" expectation — bit 2 is now always on)
- auth1_bitmap_adProvisionedButNotVerified_returns0x0004 (new;
  uses markAccessDocumentFinalizedForTesting without
  markAccessDocumentVerified)
- auth1_bitmap_adProvisionedAndVerified_returns0x0005 (the previous
  M2A.3 case, now correctly gated on the verified flag)

Tests run: 147, Failures: 0, Errors: 3 (pre-existing GCM only).
2026-06-17 16:54:45 -07:00
michael
0c7d4c642e feat: EXCHANGE Reader Status request validation + spec-shape response (M2E.1, M2E.2)
M2E.1: parse the EXCHANGE plaintext as Aliro §8.3.3.5 / Table 8-19
[sub_event_id, payload_len, payload], reject SW_WRONG_LENGTH on truncation
or length mismatch and SW_DATA_INVALID on unknown sub_event_id (M2 only
supports 0x01 = ReaderStatusRequest).

M2E.2: emit a Reader Status sub-event RESPONSE (Table 8-20) plaintext
[0x01, 0x00, 0x00] in place in the APDU buffer, GCM-encrypted under
StepUpSKDevice + deviceIv(stepup_device_counter) per §8.3.1.6. Ciphertext+
tag = 19 B, single APDU, no chaining.

Harness verify_step_up_m2 + the test mock now ship the spec request shape,
decrypt the 19 B EXCHANGE response under deviceCounter=1, and decrypt the
subsequent ENVELOPE response under deviceCounter=2 (EXCHANGE consumed 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 16:50:47 -07:00
michael
f0765eb329 docs: M2 close-out draft — INSTALL.md acceptance criterion + step-up implementation notes (M2H.1 draft)
Adds the `aliro-bench-test --step-up` verification sub-section to
applet/INSTALL.md under "Validate after personalization" — explains
what the M2 path checks (SELECT-STEPUP / EXCHANGE / ENVELOPE / GET
RESPONSE / AD round-trip), what success looks like, and the three
common failure modes.

Also writes the user's auto-memory `step_up_implementation_notes.md`
(persisted outside this repo at
~/.claude/projects/.../memory/step_up_implementation_notes.md) with
the final form of the four Step-Up optimizations, spec citations, and
codebase pointers — so the operator can look up "how did Step-Up end
up" without re-reading the v2 plan. Three TODO markers left for the
M2H.1 final commit to update once M2E + M2F land.

DRAFT — final M2H.1 commit happens after M2G.2 verdict + M2E + M2F.
2026-06-17 16:43:06 -07:00
michael
1ed32d3872 feat(harness): --step-up asserts Access Document round-trip (M2G.1) 2026-06-17 16:42:50 -07:00
michael
bf65607ce5 docs(vendor-bugs): file ST X-CUBE-ALIRO processAUTH1ResponsePayload bug (M2G.3) 2026-06-17 16:40:20 -07:00
michael
9879668472 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>
2026-06-17 16:34:45 -07:00
michael
9e31da0cdc feat(applet): DeviceResponseBuilder (M2D.2)
Pure builder for the mdoc DeviceResponse (ISO 18013-5 / Aliro Table 8-22)
carrying the cached Access Document at
documents[0].issuerSigned.issuerAuth. Single fixed shape — one mDL
document, empty nameSpaces, no deviceSigned. AD bytes embedded verbatim
(not re-encoded), so the issuer's signed bytes pass through unchanged.

Map keys canonical-CBOR sorted (length-then-lex). Headers for the fixed
counts inlined (map(0/2/3), array(1)); tstr headers/values go through
StructuralCbor for the canonical length encoding.

For a 272 B AD the output is 372 B (100 B wrapper). Reference vector
generated from /home/work/aliro-trust/access_document.bin and asserted
byte-for-byte.

M2D.3 will wire this into StepUpApplet.processEnvelope behind GCM
encryption; M2D.4 adds GET RESPONSE chaining.
2026-06-17 16:24:03 -07:00
michael
a7bc7e4478 feat(applet): DeviceRequestParser structural validation (M2D.1) 2026-06-17 16:19:12 -07:00
michael
d5945be60e refactor(applet): AliroGcm.setKeyAndIv consolidates re-keying (M2C.2) 2026-06-17 16:14:53 -07:00
michael
4fb87b6200 refactor(applet): extract StepUpSession from StepUpApplet (M2C.1)
Move the four StepUp session-state fields (StepUpSKDevice, StepUpSKReader,
stepup_reader_counter, stepup_device_counter) and the IV-stamp +
counter-advance math out of StepUpApplet into a dedicated StepUpSession
holder. Adds a unit-testable surface for the spec §8.3.1.6/8/9 IV layout
and §8.4.3 counter-advance behaviour (4 new tests pinning reader/device IV
layout, carry across byte 2, and the 32-bit BE wrap at 0xFFFFFFFF).

Behaviour-preserving: full StepUpAppletTest stays green with no
test-assertion changes (the test-only getters keep their signatures and
just delegate to session.skDevice / session.skReader).

StepUpApplet shrinks 444 -> 394 LOC. Full suite: 138 tests, 0 failures,
3 errors (pre-existing GCM trio).
2026-06-17 16:10:14 -07:00
michael
047503e8c5 feat(applet): canonical CBOR uint/bstr/tstr encoders (M2B.4) 2026-06-17 16:04:05 -07:00
michael
6373bf1109 refactor(applet): CoseVerifier uses StructuralCbor (M2B.3) 2026-06-17 16:04:05 -07:00
michael
6f96c2f8a2 feat(applet): StructuralCbor.elementSpan (M2B.2) 2026-06-17 16:04:05 -07:00
michael
da4c3fa1e4 feat(applet): StructuralCbor.decodeHeader (M2B.1) 2026-06-17 16:04:05 -07:00
michael
a94e2b498e feat: IssuerAuth verify at AD finalize + issuer pubkey provisioning (M2A.3)
Expands M2A.3 from the original plan because the credential issuer pubkey
had no provisioning path. Adds INS_SET_CREDENTIAL_ISSUER_PUBK = 0x25 to
PersonalizationApplet, bumps CredentialStore FIELD_VERSION 2 -> 3 to add
a 64 B credentialIssuerPubKey slot + set-flag, and pivots
finalizeAccessDocument to run a one-shot CoseVerifier RFC 9052
COSE_Sign1 verify against the staged AD bytes before atomically setting
accessDocumentLen + accessDocumentFinalized + accessDocumentVerified
inside a JCSystem transaction.

PersonalizationApplet holds one CoseVerifier instance constructed at
install (the 768 B CLEAR_ON_DESELECT scratch only allocates once) plus
a 65 B EEPROM scratch for staging the uncompressed issuer pubkey.

Status word mapping:
- missing issuer pubkey at finalize -> SW_CONDITIONS_NOT_SATISFIED (0x6985)
- IssuerAuth signature mismatch     -> SW_DATA_INVALID            (0x6984)
- length out of range               -> SW_WRONG_DATA               (0x6A80)
On verify failure the store stays untouched -- both
accessDocumentFinalized and accessDocumentVerified remain false.

Harness side: TrustArtifacts gains credential_issuer_pub (loaded from
trust_dir/issuer.pem). The orchestrator sends INS 0x25 after the
existing key writes and before the AD chunks so the trust anchor is
staged by the time FINALIZE_AD runs.

Test vector: AD bytes + matching issuer pubkey x||y are hardcoded into
PersonalizationAppletTest from the canonical trustgen artifacts at
/home/work/aliro-trust (272 B AD against a known issuer.pem).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 16:04:05 -07:00
michael
3868678bf9 feat(applet): CoseVerifier RFC 9052 verify (M2A.2)
Adds CoseVerifier.verifyCoseSign1 — single-purpose RFC 9052 COSE_Sign1
verifier for the Aliro IssuerAuth signature, used once at personalization
(M2A.3) to set CredentialStore.accessDocumentVerified.

Wire walk uses hardcoded offsets that assume the
aliro_harness.issuer.cose shape (matches RFC 9052 §3 but is not a generic
CBOR parser). TODO marker for M2B.3 to swap in StructuralCbor.elementSpan.

Extracts SECP256R1_{P,A,B,G,R} into Secp256r1Params so AliroApplet and
CoseVerifier share one copy. seed(KeyPair) handles full keypair seeding;
seedPublic(ECPublicKey) covers the verify-only case for the new verifier.

Test vector: deterministic-seeded P-256 key + literal payload, signed
once via the harness cose helpers and self-verified, hex-pasted into
CoseVerifierTest. Two cases (good signature; flipped payload byte).

Regression: 80 tests green; the 3 pre-existing GCM errors from the
jcardsim m2-volume swap (AliroCryptoTest#jcardsimSupportsAesGcm,
AliroGcmTest#{partialFinalBlock,roundTripAgainstJcardsimAEADCipher})
still error, unrelated to this change.
2026-06-17 16:04:05 -07:00
michael
531b1d3186 feat(applet): accessDocumentVerified flag in CredentialStore (M2A.1)
Persistent boolean flag for the cached IssuerAuth verify result. Field
sits beside accessDocumentFinalized in writeTo/readFrom (FIELD_VERSION
bumped 1->2 -- schema change requires re-personalize). reset() clears.
Test pins the serialize/deserialize round-trip via the existing
RecordingSink/ReplaySource helpers.

Foundation for M2A.2 (CoseVerifier) and M2A.3 (verify-at-finalize wiring).
2026-06-17 16:03:22 -07:00
michael
4741ce7ba3 Merge feat/step-up-m1: Step-Up M1 applet implementation
Lands the M1 StepUpApplet end-to-end on main, where 9081990 already
landed the matching harness verifier. M1 was verified end-to-end via
aliro-bench-test --step-up on J3R452 04555A4A0B2190 before this merge.

Commits:
- cedefef test(applet): pin deriveStepUpSessionKeys to spec ASCII info strings (M1A.1)
- 8e999b7 feat(applet): StepUpApplet derives session keys on SELECT when armed (M1A.2)
- 96816bc feat(applet): StepUpApplet INS_EXCHANGE decrypt-and-discard with 9000 ack (M1B.1)
- e213c65 feat(applet): StepUpApplet INS_ENVELOPE (CLA=0x00) decrypt + encrypted ack (M1C.1)
- ebefc4e refactor(applet): retire AliroApplet INS_EXCHANGE stub (M1D.1)
- 87065b0 docs(applet): refresh stale comments referring to deleted INS_EXCHANGE stub
2026-06-17 16:02:59 -07:00
michael
ad53666650 docs(plans): Step-Up M2 full implementation plan 2026-06-17 13:26:47 -07:00
michael
9081990b0e feat(harness): aliro-bench-test --step-up + INSTALL.md install-recovery notes
Adds a PC/SC verification path for Step-Up Milestone 1 so M1 can be
validated against a personalized card without depending on Nucleo /
X-CUBE-ALIRO bring-up. Verdict run on J3R452 04555A4A0B2190 with M1
CAP installed: AUTH1 OK (3.4s), EXCHANGE 0xC9 OK, ENVELOPE 0xC3 OK
with response decrypting under StepUpSKDevice to the spec 0xA0 ack.

- crypto.py: derive_step_up_session_keys (HKDF parity with
  AliroCrypto.deriveStepUpSessionKeys)
- transaction.py: expose step_up_sk on TransactionResult
- step_up.py: verify_step_up_m1 -- SELECT 5502 + GCM-encrypted
  C9/C3 round-trip, IVs per StepUpApplet (0x00*8 || counter
  reader-side; 0x00*7 || 0x01 || counter device-side)
- cli.py: --step-up flag on aliro-bench-test
- tests: stdlib-RFC-5869 cross-check on the new KDF (122/122 green)
- INSTALL.md: fix multi-place PKG AID typo (missing 02 version byte),
  document partial-install recovery, document package-static
  credential store (aliro-personalize success !=> 5501/5502 installed)
2026-06-17 12:55:16 -07:00
michael
87065b0e15 docs(applet): refresh stale comments referring to deleted INS_EXCHANGE stub
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 11:04:05 -07:00
michael
ebefc4e358 refactor(applet): retire AliroApplet INS_EXCHANGE stub
The stub was added earlier this session as an X-CUBE-ALIRO compat
shim back when we (incorrectly) believed the vendor firmware ignored
the §10.2 step-up AID SELECT requirement and routed EXCHANGE directly
to the Expedited AID (ACCE5501). Today's Path X work proved the
vendor firmware actually DOES the §10.2 SELECT correctly once the
upstream crypto interop is right.

With M1B.1 / M1C.1 now landing the real EXCHANGE and ENVELOPE handlers
on StepUpApplet (ACCE5502), the AliroApplet stub is strictly wrong:
it would let stale expedited_device_counter state confuse the vendor
library if the reader ever hit it. Spec-conformant readers route
EXCHANGE to ACCE5502 after the step-up SELECT and never touch
ACCE5501 with INS=0xC9. So AliroApplet returns SW_INS_NOT_SUPPORTED
(0x6D00) for INS=0xC9 like any other unknown INS, and we ship one
less compat shim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 11:01:57 -07:00
michael
e213c65d26 feat(applet): StepUpApplet INS_ENVELOPE (CLA=0x00) decrypt + encrypted ack
X-CUBE-ALIRO firmware sends ENVELOPE (CLA=0x00 INS=0xC3) carrying the
encrypted mdoc DeviceRequest after the step-up AID SELECT. Spec §8.3.1.9
defines the inbound IV (reader-side: 0x0000000000000000 || stepup_reader_counter).
For Milestone 1 we decrypt-and-discard the DeviceRequest, then return a
spec-shape encrypted empty CBOR map (1 plaintext byte 0xA0, GCM-encrypted
with StepUpSKDevice using device-side IV per §8.3.1.6:
0x0000000000000001 || stepup_device_counter). Total response: 17 bytes
(1 ct + 16 tag).

Adds stepUpDeviceCounter[4] CLEAR_ON_DESELECT alongside the existing
stepUpReaderCounter; both init to [0,0,0,1] when StepUpApplet.select()
derives session keys (i.e. each Step-Up phase entry).

Restructures the StepUpApplet dispatch so CLA=0x00 ENVELOPE coexists
with CLA=0x80 EXCHANGE -- ENVELOPE uses the ISO-standard CLA per
spec/ISO 7816 convention, EXCHANGE remains Aliro-proprietary.

M2 will replace the empty CBOR map with a real mdoc DeviceResponse
carrying the cached-verified Access Document bytes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 10:55:09 -07:00
michael
96816bc232 feat(applet): StepUpApplet INS_EXCHANGE decrypt-and-discard with 9000 ack
X-CUBE-ALIRO firmware sends a Reader Status sub-event via EXCHANGE
(CLA=0x80, INS=0xC9) after the step-up AID SELECT, per Aliro §8.3.3.5
Table 8-14. The payload is encrypted with StepUpSKReader using AES-256-GCM
with IV = 0x0000000000000000 || stepup_reader_counter (4B BE) per §8.3.1.8.

For Milestone 1 we decrypt-and-discard: tag verification proves we have
matching session keys, then we return SW=9000 with empty payload. M2
will add a proper encrypted Reader Status response sub-event.

Extends CryptoSingletons to hold the shared AliroGcm instance too --
opt 1 sibling of the AliroCrypto sharing from M1A.2. Adds AliroGcm.decrypt
since the prior pipeline only encrypted (AUTH1 response path).

Counter starts at 1 per session-bound init (spec §8.4.3 -> mdoc [6]
§9.1.1.5), incremented after each successful decrypt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 10:45:22 -07:00
michael
8e999b770d feat(applet): StepUpApplet derives session keys on SELECT when armed
After AUTH1 parks StepUpSK in SessionContext, the reader issues SELECT
to the Step-Up AID per spec §10.2. StepUpApplet.select() now picks up
the parked SK, derives StepUpSKDevice / StepUpSKReader via the
§8.4.3 HKDF (already implemented in AliroCrypto.deriveStepUpSessionKeys),
and stages both in transient CLEAR_ON_DESELECT fields for the ENVELOPE
(M1C.1) and EXCHANGE (M1B.1) handlers.

Introduces CryptoSingletons -- a lazy package-private holder for the
single AliroCrypto instance shared between AliroApplet and StepUpApplet.
Saves ~352 B of transient (kdfWorkbuf + hkdfPrevT + expandScratch)
versus a per-applet duplicate. Java Card forbids new in <clinit> so
the singleton uses lazy null-check init. Opt 1 prelude per
docs/plans/2026-06-11-step-up-implementation-v2.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 10:33:46 -07:00
michael
cedefef70e test(applet): pin deriveStepUpSessionKeys to spec ASCII info strings
Adds a second regression test for AliroCrypto.deriveStepUpSessionKeys
that hard-codes the exact RFC 5869 reference computation (empty salt,
"SKDevice" / "SKReader" ASCII info) with a different IKM byte pattern
and independent expand-once helper. Complements the existing
deriveStepUpSessionKeysMatchesManualHkdf so a future spec misread in
shared helper code can't slip through.

Implementation itself was landed earlier in f94e416 ("fix: AUTH1 crypto
interop with stock X-CUBE-ALIRO + EXCHANGE compat stub"); this is the
M1A.1 task's tightened pin from the Step-Up v2 plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 10:23:44 -07:00
michael
9189b41e7f 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>
2026-06-11 10:19:05 -07:00
michael
f94e416c99 fix: AUTH1 crypto interop with stock X-CUBE-ALIRO + EXCHANGE compat stub
Three independent spec-misreads found via Path X investigation against
the X-CUBE-ALIRO vendor library, all causing
ACWG_Error_Crypto_EncryptDecrypt on the vendor's processAUTH1ResponsePayload.
Each was symmetric between this applet and our PC/SC reader, so
aliro-bench-test passed against our own host-side reader but failed
against any spec-compliant third-party reader. Path X also surfaced
an X-CUBE-ALIRO-specific compat shim (bitmap + EXCHANGE stub) which is
documented to be retired by the Step-Up Milestone 1 work.

1. salt_volatile dropped x(credential_long_term_pub) at the end.
   §8.3.1.13 salt_volatile ends at the 0xA5 proprietary information TLV;
   the credential key belongs in `info` (and even there it's the
   EPHEMERAL one, which buildInfo already does correctly).

2. Kdh now uses X9.63 KDF per §8.3.1.4 instead of HKDF.
   The §8.3.1.4 closing note ("actual key derivation is performed using
   §8.3.1.5") refers to subsequent session-key derivation from Kdh
   (§8.3.1.13 -> §8.3.1.5 HKDF), NOT a substitution for Kdh itself.
   For 32-byte output X9.63 KDF reduces to:
     Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
   Added a native SHA-256 instance to AliroCrypto for this one-shot.

3. salt_volatile flag uses AUTH1's command_parameters, not AUTH0's.
   §8.3.1.13 says "command_parameters || authentication_policy from the
   command data field". When §8.3.1.13 runs (after AUTH1), the active
   request is AUTH1; authentication_policy only exists in AUTH0 so it's
   still pulled from saved AUTH0 state, but command_parameters is the
   AUTH1 value (typically 0x01 = "request credential_PubK in response").

4. signaling_bitmap kept at 0x0005 when AD provisioned + INS_EXCHANGE
   stub on AliroApplet returns 9000 with empty payload. Empirically the
   X-CUBE-ALIRO vendor library errors on bitmap=0x0000 even though the
   spec allows it (separate vendor quirk worth filing); EXCHANGE stub
   exists because the firmware unconditionally sends 0xC9 post-AUTH1
   for the Reader Status sub-event report. Both shims are documented to
   be retired in Step-Up Milestone 1 -- StepUpApplet will handle 0xC9
   on its own AID (ACCE5502) per §10.2.1 after the spec-mandated
   step-up AID SELECT.

PC/SC bench-test still passes: AUTH1=9000, ~3.2 s, bitmap=0x0005.
Nucleo X-CUBE-ALIRO firmware now reports retval=ACWG_OK on
processAUTH1ResponsePayload (confirmed against j3r452 UID
04565E4A0B2190 in /tmp/nucleo-three-fixes.log). The remaining
"DOOR OPERATION FAILED" on Nucleo is downstream Step-Up not being
implemented yet -- StepUpApplet is still the scaffold and returns
6D00/6E00 to ENVELOPE / EXCHANGE. That's Milestone 1 work.

80/80 Java tests + 126/126 Python tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 10:18:42 -07:00
michael
06c00385a4 build(reader): WTX cap bump 20→255 + AUTH1 wrap instrumentation
rfal_isoDep.h: vendor RFAL_ISODEP_MAX_WTX_RETRYS is 20 by default. With
slow userland AES-GCM on the J3R-family (J3R180/J3R452), AUTH1 takes
~3.2 s of card processing -- comfortably more than the negotiated FWT,
so the card sends S(WTX) blocks to keep the link alive. At the card's
FWI of 4, the spec-default 20 cap trips before AUTH1 finishes and RFAL
aborts the transceive with ERR_PROTO 0xB. Bumping to the existing
RFAL_ISODEP_MAX_WTX_RETRYS_ULTD (=255) accepts up to 255 sequential
WTX requests, well within ISO 14443-4 limits. The card itself is fully
spec-compliant on the WTX side -- the 20-cap is ST's conservative
middleware choice. Revert to (20U) when we ship native AES-GCM hardware.

aliro_apdu_wrap.c: extends the existing AUTH1 payload wrap to log the
return value from ACWG_processAUTH1ResponsePayload (vendor library's
internal status). Was instrumental in finding the three crypto bugs we
fixed in the follow-up commit -- the vendor printed retval=ACWG_Error_*
which let us localize the spec divergences without source access to
Aliro.a or ACWG_Security.a. Two stale __real_/__wrap_ stubs for
ACWG_computeKDHandVolatileKeys and ACWG_derivePersistentKeys were
removed -- they had no matching --wrap flag in CMakeLists.txt and were
silently dead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 10:15:34 -07:00
michael
34ba616e48 build(amd-h): vendor GlobalPlatform exports for j3r452 builds
Drops gpapi-globalplatform 1.7 and gpapi-upgrade 1.1 (AMD-H Amendment H
v1.1) under applet/build-tools/gp-amdh/, sourced from
OpenJavaCard/globalplatform-exports (master snapshot 2020-09-30).

- dt-mvn.sh mounts exports/org/ into the converter's api_export_files/.
- pom.xml adds the .jar files as system-scope deps under the j3r452
  profile so javac on j3r452 builds resolves org.globalplatform.upgrade
  symbols (j3r180 builds don't see them on classpath).
- .gitignore: narrow build-tools/ exclusion to top-level only so the new
  applet/build-tools/gp-amdh/ tree is trackable while the top-level
  build-tools/jcardsim/ vendor fork stays out of the repo.

This commit alone produces zero CAP bytecode change because no current
source imports org.globalplatform.*. The infra is provably inert until
the Task 3.1+ source on feat/amd-h-epic-3-wip is merged. Verified by
diffing CAP zip components for both profiles: only the embedded
Java-Card-CAP-Creation-Time timestamp in META-INF/MANIFEST.MF differs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 16:23:57 -07:00
michael
efac7ce6e6 feat: aliro-bench-profile CLI + per-APDU latency probes
New `aliro-bench-profile` entry point drives the applet's INS_DIAG_*
commands over PC/SC at two iteration counts (N=4 / N=16 by default) and
computes per-op cost as the slope, factoring out APDU round-trip + any
one-time Signature.init setup. Reports HMAC, ECDH, ECDSA sign, and
AES-GCM 137B costs side by side, then reconstructs an AUTH1 budget
using the per-primitive counts from the applet's processAuth1 flow so
we can compare the reconstructed estimate against bench-test wall-clock
and see how much of AUTH1 is crypto vs TLV parsing / I/O.

Also extends `aliro-bench-test` to time SELECT / AUTH0 / AUTH1 / total
via time.perf_counter() bracketing each transmit() call. The TransactionResult
now carries a latencies_ms dict, threaded through all return sites so both
success and failure paths print the new "APDU latencies (ms):" block.
Made the 1.78x AUTH1 wall-clock speedup from the 4-bit GHASH commit
quantifiable on real hardware (5,795 -> 3,244 ms).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 15:53:56 -07:00
michael
b03c781b92 feat: diagnostic INSes for AUTH1 sub-op profiling (compile-gated)
Adds INS_DIAG_HMAC (0xD0), INS_DIAG_ECDH (0xD1), INS_DIAG_ECDSA_SIGN
(0xD2), and INS_DIAG_GCM (0xD3) to AliroApplet, gated by a
DIAGNOSTICS_ENABLED compile-time flag so they're trivially strippable
for production builds. Each INS runs its primitive N times (N in P3 byte)
against hardcoded card-side test vectors and returns the result. Output
lands in the APDU buffer at offset 16, never in scratch -- the AUTH
session state stays intact even if a diag INS runs mid-session.

The HMAC INS routes through a new AliroCrypto.diagHmac() wrapper that
exposes the internal AliroHmac instance without leaking it through the
class boundary. ECDH/ECDSA use a dedicated diagKeyPair (separate from
the protocol's credentialEphemeralKeyPair) so a diagnostic call can
never disturb a real AUTH flow.

These INSes are what aliro-bench-profile drives over PC/SC -- they let
us measure each AUTH1 primitive's cost in isolation and reconstruct the
AUTH1 budget against the bench-test wall-clock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 15:53:10 -07:00
michael
5f614d74a9 feat: 4-bit GHASH table + runtime native HMAC/CTR detection on j3r452
AliroGcm: replace bit-by-bit gfMul with 4-bit Shoup-style multiplication.
Adds a 16-entry M-table (256 B transient) built per-encrypt from H, plus
fixed L_BYTE0/L_BYTE1 reduction lookups (32 B EEPROM). Inner loop drops
from 128 conditional bit ops to 32 nibble lookups + a single 4-bit shift.
Measured 2.3x GHASH speedup on j3r452 (5,368 -> 2,336 ms per AUTH1 GCM)
and a 1.78x AUTH1 wall-clock speedup (5,795 -> 3,244 ms). All 80 unit
tests pass; NIST GCM TC13/14/15 vectors produce byte-identical output.

Also adds runtime probes for native HMAC-SHA-256 (AliroHmac) and native
AES-CTR (AliroGcm, currently disabled). Both run a full smoke test in
the constructor (buildKey + setKey + init + sign/doFinal) and fall back
to userland silently if any step throws. JCAlgTest reports both as
supported on j3r452 but only at the getInstance/buildKey level; setKey
throws ILLEGAL_VALUE end-to-end -- the smoke test catches this so we
never call the broken native path. Native CTR's real-encrypt hang is
unrelated to the smoke test and is left disabled pending isolation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 15:52:55 -07:00
michael
7574fc660e build: add j3r452-only src/main/java-amdh source root
Empty for now; Epic 3 fills it with OnUpgradeListener bridge classes.
2026-05-26 14:08:45 -07:00
michael
96f2b34abc docs/build: clean up stale aliro-applet.cap references after profile split
Code review of 750f570 flagged three Important issues:
- Stale 'Output: target/aliro-applet.cap' comment in pom.xml antrun
  plugin block.
- applet/INSTALL.md referenced the old single-CAP filename in three
  places, breaking the documented gp.jar --load command.
- No safeguard or hint that -Pj3r180 and -Pj3r452 are mutually exclusive.

Updates the comments and the INSTALL.md filenames to point at
aliro-applet-j3r180.cap as the default. Adds mutual-exclusion notes
to both profile blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:00:21 -07:00
michael
750f570a39 build: add j3r180 (default) and j3r452 Maven profiles
Both profiles currently produce identical CAP bytecode, differing only
in ELF AID so they can coexist on a card during bench testing. The
j3r452 profile becomes meaningfully different in a later epic when it
adds the OnUpgradeListener bridge.
2026-05-26 13:40:49 -07:00
michael
276f9f189a test: tighten CredentialStoreSerializationTest against silent reorders
Code review of 40a079a flagged two gaps:

- Round-trip test set all 5 booleans to true, so a reorder among the
  boolean writeTo lines would not break the test. Now uses a distinguishable
  mix (T/T/F/T/F) and asserts each predicate explicitly.
- FIELD_VERSION rejection branch in readFrom had zero coverage. Adds a
  test that forges an out-of-band version byte and asserts
  ISOException(SW_DATA_INVALID).

Reuses the existing hasAccessDocument() predicate (which already returns
accessDocumentFinalized) rather than adding a new test-only helper.

Manually verified the round-trip test catches a silent boolean reorder
by swapping committed/credentialPubKeySet writes in writeTo and observing
isCommitted() assertion fail (reverted before commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:37:36 -07:00
michael
40a079a539 feat: define CredentialStore serialization contract
Adds in-tree SerializationSink/Source interfaces matching the shape of
the AMD-H Element API, plus writeTo/readFrom on CredentialStore and a
round-trip test. The AMD-H adapter (added in a later epic) just bridges
Element -> these interfaces, so the field layout is testable today
without depending on a JCOP 4.5 image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:50:47 -07:00
michael
1f54a690d0 refactor: own CredentialStore from PersonalizationApplet instance
Prepares for AMD-H by moving the singleton's anchor off a static field
onto the PersonalizationApplet instance. The static INSTANCE remains
solely as an in-package publish-point so AliroApplet/StepUpApplet keep
working unchanged.

Adds CredentialStore.bootstrap() (called from PersonalizationApplet's
constructor) and CredentialStore.republish() (reserved for the upcoming
onRestore hook). Drops the lazy-init path in get() — every install path
now goes through PersonalizationApplet first, so INSTANCE is always set
by the time AliroApplet looks it up. Test setUps that previously called
CredentialStore.get().resetForTesting() before the applet was installed
are reordered or dropped: each test now gets a fresh store via the
constructor's bootstrap() call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:20:22 -07:00
62 changed files with 7738 additions and 289 deletions

7
.gitignore vendored
View File

@@ -1,6 +1,9 @@
# --- pre-existing --- # --- pre-existing ---
# Local build/test tooling, not committed (jcardsim fork etc.) # Local build/test tooling, not committed (jcardsim fork etc.). Matches the
build-tools/ # top-level build-tools/ dir only — applet/build-tools/ is a different beast
# (vendored AMD-H exports etc.) and IS committed; see negation below.
/build-tools/
!applet/build-tools/
# --- secrets / private keys --- # --- secrets / private keys ---
# aliro-trustgen emits aliro_trust.h with the reader's PRIVATE scalar. # aliro-trustgen emits aliro_trust.h with the reader's PRIVATE scalar.

View File

@@ -24,9 +24,11 @@ cd applet
./scripts/dt-mvn.sh clean package -DskipTests ./scripts/dt-mvn.sh clean package -DskipTests
``` ```
Output: `applet/target/aliro-applet.cap` (~42 KB). Built inside the Output: `applet/target/aliro-applet-j3r180.cap` (~42 KB). Built inside
`vivokey/smartcard-ci` container by the `ant-javacard` plugin against the the `vivokey/smartcard-ci` container by the `ant-javacard` plugin
JC 3.0.5 SDK. against the JC 3.0.5 SDK. Add `-Pj3r452` to build the AMD-H variant
(`aliro-applet-j3r452.cap`) instead; the j3r180 default is what's
shipped in the field today, so use it if you're not sure.
## Install ## Install
@@ -36,8 +38,8 @@ java -jar gp.jar --info # confirm reader sees the card
# Multi-applet CAPs: load the package once, then create each instance. # Multi-applet CAPs: load the package once, then create each instance.
# --create needs the instance AID, the applet class AID (same as instance # --create needs the instance AID, the applet class AID (same as instance
# here), and the package AID (printed by --load). # here), and the package AID (printed by --load).
PKG=A0000009094454414C49524F PKG=A0000009094454414C49524F02
java -jar gp.jar --load applet/target/aliro-applet.cap java -jar gp.jar --load applet/target/aliro-applet-j3r180.cap
java -jar gp.jar --create A000000909ACCE5501 --applet A000000909ACCE5501 --package $PKG java -jar gp.jar --create A000000909ACCE5501 --applet A000000909ACCE5501 --package $PKG
java -jar gp.jar --create A000000909ACCE5502 --applet A000000909ACCE5502 --package $PKG java -jar gp.jar --create A000000909ACCE5502 --applet A000000909ACCE5502 --package $PKG
java -jar gp.jar --create A000000909ACCE559901 --applet A000000909ACCE559901 --package $PKG java -jar gp.jar --create A000000909ACCE559901 --applet A000000909ACCE559901 --package $PKG
@@ -48,6 +50,21 @@ specify the right one with --[applet]"`, and `--create AID` without
`--applet`/`--package` fails with `"Need --[package, pkg] and --[applet] `--applet`/`--package` fails with `"Need --[package, pkg] and --[applet]
or --[cap]"`.) or --[cap]"`.)
**Partial install / recovery**: if a `--create` call fails mid-sequence
(card got reset, USB hiccup, RF dropout), the package stays loaded. Just
re-run the failed `--create` — no need to redo `--load` or the earlier
`--create` calls. The package only needs `--delete` + `--load` again if
the package itself is in a bad state.
**Credential store is package-static**: keys and the Access Document
written via `aliro-personalize` live on a static class shared by all
three applets. Personalization only requires `ACCE559901` to be
SELECTABLE; `5501` and `5502` can be `--create`d after personalize ran
and they'll see the same credentials. (Note: `aliro-personalize`
returning "Personalization complete" only confirms `559901` accepted the
writes — always cross-check `gp --list` to confirm `5501` and `5502`
are also SELECTABLE.)
Default GP test keys (`404142434445464748494A4B4C4D4E4F`) are tried Default GP test keys (`404142434445464748494A4B4C4D4E4F`) are tried
automatically. For non-default keys, pass `--key <hex>` to every command. automatically. For non-default keys, pass `--key <hex>` to every command.
@@ -59,10 +76,10 @@ Three applets get registered, one CAP:
| `A0000009 09 ACCE 55 02` | `StepUpApplet` | Aliro STEP_UP phase (spec; scaffold only ATM)| | `A0000009 09 ACCE 55 02` | `StepUpApplet` | Aliro STEP_UP phase (spec; scaffold only ATM)|
| `A0000009 09 ACCE 55 99 01` | `PersonalizationApplet`| DT-internal provisioning channel | | `A0000009 09 ACCE 55 99 01` | `PersonalizationApplet`| DT-internal provisioning channel |
All three share the package AID `A0000009 09 4454414C49524F` (CSA RID + All three share the package AID `A0000009 09 4454414C49524F 02` (CSA RID
ASCII "DTALIRO"). JavaCard requires applets in one CAP to share the + ASCII "DTALIRO" + version byte). JavaCard requires applets in one CAP
package's RID, hence the proprietary AID for personalization is also to share the package's RID, hence the proprietary AID for personalization
under the CSA RID with a non-spec PIX (`99 01`). is also under the CSA RID with a non-spec PIX (`99 01`).
## Verify ## Verify
@@ -80,21 +97,26 @@ or Access Document — every Aliro flow returns `SW_CONDITIONS_NOT_SATISFIED`.
Provision via the PersonalizationApplet (CLA `0x80`): Provision via the PersonalizationApplet (CLA `0x80`):
| INS | P1\|P2 | Data | Description | | INS | P1\|P2 | Data | Description |
| ------ | --------------- | --------------------------------------- | ----------------------------------- | | ------ | --------------- | --------------------------------------- | -------------------------------------------------------------- |
| `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey | | `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey |
| `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey | | `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey |
| `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey | | `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey |
| `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write | | `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write |
| `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document | | `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document — runs IssuerAuth COSE_Sign1 verify |
| `0x25` | `0000` | 64B credential_issuer_PubK (x\|\|y) | Credential Issuer pubkey — trust anchor for IssuerAuth verify |
| `0x2C` | `0000` | (none) | COMMIT — locks all writes | | `0x2C` | `0000` | (none) | COMMIT — locks all writes |
Required order: SELECT provisioning AID → write all keys + AD chunks → Required order: SELECT provisioning AID → write all keys (including
finalize → COMMIT. After COMMIT, every write returns credential_issuer_PubK) + AD chunks → finalize → COMMIT. The issuer pubkey
`SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1). MUST be set before FINALIZE — without it FINALIZE returns
`SW_CONDITIONS_NOT_SATISFIED`. A signature mismatch at FINALIZE returns
`SW_DATA_INVALID` and leaves the AD un-finalized. After COMMIT, every
write returns `SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1).
Source bytes come from `aliro-trustgen init --out-dir ./out`: Source bytes come from `aliro-trustgen init --out-dir ./out`:
- `out/access_credential.pem` → derive priv/pub bytes - `out/access_credential.pem` → derive priv/pub bytes
- `out/reader.pem` → derive pub bytes - `out/reader.pem` → derive pub bytes
- `out/issuer.pem` → derive Credential Issuer pubkey
- `out/access_document.bin` → chunk into ≤255B writes - `out/access_document.bin` → chunk into ≤255B writes
**One-shot personalization:** the harness ships an `aliro-personalize` **One-shot personalization:** the harness ships an `aliro-personalize`
@@ -171,6 +193,33 @@ step the real firmware will eventually run — so a green bench-test is
strong evidence the applet is correct independently of any future strong evidence the applet is correct independently of any future
reader implementation. reader implementation.
### Step-Up M2 verification (`--step-up`)
```
aliro-bench-test --trust-dir ~/aliro-trust --step-up
```
Adds the Step-Up phase on top of the EXPEDITED verdict: SELECT-STEPUP
(ACCE5502) derives session keys from the cached `StepUpSK`, EXCHANGE
+ ENVELOPE + chained GET RESPONSE drive a real mdoc DeviceRequest to
the card and pull the encrypted DeviceResponse back. The harness
decrypts under `StepUpSKDevice` and asserts the embedded Access
Document round-trips byte-for-byte against the personalized blob.
Successful output appends:
```
STEP-UP M2: OK — M2 step-up verified (EXCHANGE + ENVELOPE Access Document round-trip)
```
Verified against J3R452 UID `04555A4A0B2190` on 2026-06-12 — full verdict
log at `docs/verdicts/2026-06-12-m2-pcsc-verdict.log`.
A `STEP-UP M2: FAIL` line means one of: SELECT-STEPUP didn't arm
(no preceding AUTH1), GCM tag mismatch (key/counter divergence), or
the recovered AD bytes don't match. The Expedited block above still
needs to be `OK` for any of this to run.
## Uninstall / re-install ## Uninstall / re-install
`gp --delete <pkg_AID>` won't succeed while applet *instances* still `gp --delete <pkg_AID>` won't succeed while applet *instances* still
@@ -182,7 +231,7 @@ then the package:
java -jar gp.jar --delete A000000909ACCE5501 java -jar gp.jar --delete A000000909ACCE5501
java -jar gp.jar --delete A000000909ACCE5502 java -jar gp.jar --delete A000000909ACCE5502
java -jar gp.jar --delete A000000909ACCE559901 java -jar gp.jar --delete A000000909ACCE559901
java -jar gp.jar --delete A0000009094454414C49524F java -jar gp.jar --delete A0000009094454414C49524F02
``` ```
Then `--load` + the three `--create` commands above to reinstall the Then `--load` + the three `--create` commands above to reinstall the
@@ -193,7 +242,7 @@ deletes them) but only if the CAP file is reachable from the current
directory, since `gp` resolves it as a path: directory, since `gp` resolves it as a path:
``` ```
java -jar gp.jar --uninstall ./aliro-applet.cap java -jar gp.jar --uninstall ./aliro-applet-j3r180.cap
``` ```
## Troubleshooting ## Troubleshooting

View File

@@ -0,0 +1,65 @@
# GlobalPlatform Amendment H exports (vendored)
This directory ships GlobalPlatform API exports and stub JARs needed to compile
Aliro applet source against `org.globalplatform.*` and
`org.globalplatform.upgrade.*` (Amendment H — ELF Upgrade).
The `vivokey/smartcard-ci` Docker image does not include these exports, so we
vendor them here and bind-mount / classpath them in at build time.
## Files
| Path | Source artifact | Notes |
|------|-----------------|-------|
| `exports/org/globalplatform/javacard/globalplatform.exp` | `org.globalplatform-1.7/exports/...` | Converter export file, GP base API v1.7 |
| `exports/org/globalplatform/upgrade/javacard/upgrade.exp` | `org.globalplatform.upgrade-1.1/exports/...` | Converter export file, AMD-H Upgrade API v1.1 |
| `lib/gpapi-globalplatform.jar` | `org.globalplatform-1.7/gpapi-globalplatform.jar` | Stub classes for javac classpath |
| `lib/gpapi-upgrade.jar` | `org.globalplatform.upgrade-1.1/gpapi-upgrade.jar` | Stub classes for javac classpath |
## Version selection
AMD-H v1.1 targets **GlobalPlatform Card Specification v2.3.1**, which exposes
the v1.7 base API. Pairing **base 1.7 + upgrade 1.1** is the canonical combo;
see `GPC_2.3_H_ELF_Upgrade_v1.1_PublicRelease.pdf` in the project root for the
specification text.
## Provenance
All four files were extracted verbatim from the `master` snapshot of the
public repository:
https://github.com/OpenJavaCard/globalplatform-exports
specifically from these subdirectories:
* `org.globalplatform-1.7/`
* `org.globalplatform.upgrade-1.1/`
The local zip used to extract them (`globalplatform-exports-master.zip`) was
fetched on 2020-09-30 (mtime of the archive entries). File sizes match the
upstream manifest:
43749 gpapi-globalplatform.jar
12638 gpapi-upgrade.jar
6641 globalplatform.exp
1705 upgrade.exp
## License
These artifacts are GlobalPlatform-published API descriptors / stubs and are
governed by the GlobalPlatform Specification license under which AMD-H v1.1
was released. The corresponding specification PDF
(`GPC_2.3_H_ELF_Upgrade_v1.1_PublicRelease.pdf`) lives at the project root and
documents the licensing terms.
## How the build consumes these files
* `applet/scripts/dt-mvn.sh` bind-mounts `exports/org/` into the Docker
image at `/app/sdks/jc305u3_kit/api_export_files/org` so the ant-javacard
converter can resolve `org.globalplatform.*` package imports during CAP
conversion.
* `applet/pom.xml` declares the two `.jar` files as `system`-scope
dependencies under the `j3r452` Maven profile only, so javac on j3r452
builds resolves the symbols. The `j3r180` profile never adds the JARs to
its classpath — its CAP cannot depend on AMD-H because the J3R180 OS does
not implement the Upgrade API.

Binary file not shown.

View File

@@ -88,7 +88,7 @@
Oracle's converter from the JC SDK pinned by JC_CLASSIC_HOME Oracle's converter from the JC SDK pinned by JC_CLASSIC_HOME
(the dt-mvn.sh wrapper sets this to /app/sdks/jc305u3_kit). (the dt-mvn.sh wrapper sets this to /app/sdks/jc305u3_kit).
Output: target/aliro-applet.cap Output: target/aliro-applet-${cap.variant}.cap (j3r180 default; -Pj3r452 for AMD-H variant)
Package AID: A0 00 00 09 09 44 54 41 4C 49 52 4F (CSA RID + "DTALIRO") Package AID: A0 00 00 09 09 44 54 41 4C 49 52 4F (CSA RID + "DTALIRO")
Applet AIDs: Applet AIDs:
@@ -115,10 +115,11 @@
classname="pro.javacard.ant.JavaCard" classname="pro.javacard.ant.JavaCard"
classpathref="maven.plugin.classpath"/> classpathref="maven.plugin.classpath"/>
<javacard jckit="${env.JC_CLASSIC_HOME}"> <javacard jckit="${env.JC_CLASSIC_HOME}">
<cap output="${project.build.directory}/aliro-applet.cap" <cap output="${project.build.directory}/${cap.output.name}"
aid="A0000009094454414C49524F" aid="${cap.elf.aid}"
package="com.dangerousthings.aliro" package="com.dangerousthings.aliro"
version="0.1" version="0.1"
ints="true"
classes="${project.build.outputDirectory}"> classes="${project.build.outputDirectory}">
<applet class="com.dangerousthings.aliro.AliroApplet" <applet class="com.dangerousthings.aliro.AliroApplet"
aid="A000000909ACCE5501"/> aid="A000000909ACCE5501"/>
@@ -142,4 +143,80 @@
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<profile>
<!-- Mutually exclusive with j3r452. Activate exactly one. -->
<id>j3r180</id>
<activation><activeByDefault>true</activeByDefault></activation>
<properties>
<!-- cap.variant: consumed by later tasks (CI matrix, profile-conditional source roots). -->
<cap.variant>j3r180</cap.variant>
<cap.elf.aid>A0000009094454414C49524F</cap.elf.aid>
<cap.output.name>aliro-applet-j3r180.cap</cap.output.name>
</properties>
</profile>
<profile>
<!-- Mutually exclusive with j3r180. Activate exactly one. -->
<id>j3r452</id>
<properties>
<cap.variant>j3r452</cap.variant>
<!-- Last byte differs so both CAPs can coexist on the same card during testing. -->
<cap.elf.aid>A0000009094454414C49524F02</cap.elf.aid>
<cap.output.name>aliro-applet-j3r452.cap</cap.output.name>
</properties>
<dependencies>
<!--
GlobalPlatform AMD-H export JARs (system-scope, vendored
under build-tools/gp-amdh/lib/). Scoped to this profile only
so they never leak onto the j3r180 classpath. See
build-tools/gp-amdh/README.md for provenance.
system scope is deprecated in modern Maven but remains
functional in 3.x and is the canonical pattern for
locally-vendored JARs that we don't want to push through
`mvn install:install-file` as a CI ordering step.
-->
<dependency>
<groupId>org.globalplatform</groupId>
<artifactId>gpapi-globalplatform</artifactId>
<version>1.7</version>
<scope>system</scope>
<systemPath>${project.basedir}/build-tools/gp-amdh/lib/gpapi-globalplatform.jar</systemPath>
</dependency>
<dependency>
<groupId>org.globalplatform</groupId>
<artifactId>gpapi-upgrade</artifactId>
<version>1.1</version>
<scope>system</scope>
<systemPath>${project.basedir}/build-tools/gp-amdh/lib/gpapi-upgrade.jar</systemPath>
</dependency>
</dependencies>
<build>
<plugins>
<!--
AMD-H bridge classes (e.g. OnUpgradeListener implementations) live
under src/main/java-amdh and are only compiled into the j3r452 CAP.
Keeping them out of the j3r180 source set prevents org.globalplatform.upgrade
imports from leaking into the J3R180 build, which lacks the AMD-H API.
-->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>add-amdh-sources</id>
<phase>generate-sources</phase>
<goals><goal>add-source</goal></goals>
<configuration>
<sources><source>src/main/java-amdh</source></sources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project> </project>

View File

@@ -21,8 +21,16 @@ if ! docker volume inspect "$VOLUME" >/dev/null 2>&1; then
-c 'cp -a /root/.m2/. /dst/ && chmod -R a+rwX /dst' -c 'cp -a /root/.m2/. /dst/ && chmod -R a+rwX /dst'
fi fi
# GlobalPlatform Amendment H export files (vendored under build-tools/gp-amdh/).
# Mounted into the SDK's api_export_files tree so the JC 3.0.5 converter can
# resolve org.globalplatform.* imports during CAP conversion. The mount is
# unconditional: when the j3r180 profile builds, no source imports those
# packages, so the converter never opens these exports.
GP_AMDH_EXPORTS="$project_root/build-tools/gp-amdh/exports/org"
exec docker run --rm -t \ exec docker run --rm -t \
-v "$project_root:/work" -w /work \ -v "$project_root:/work" -w /work \
-v "$VOLUME:/root/.m2" \ -v "$VOLUME:/root/.m2" \
-v "$GP_AMDH_EXPORTS:/app/sdks/jc305u3_kit/api_export_files/org:ro" \
-e JC_CLASSIC_HOME=/app/sdks/jc305u3_kit \ -e JC_CLASSIC_HOME=/app/sdks/jc305u3_kit \
"$IMAGE" "mvn $*" "$IMAGE" "mvn $*"

View File

View File

@@ -34,6 +34,25 @@ public class AliroApplet extends Applet {
private static final byte INS_AUTH0 = (byte) 0x80; private static final byte INS_AUTH0 = (byte) 0x80;
private static final byte INS_AUTH1 = (byte) 0x81; private static final byte INS_AUTH1 = (byte) 0x81;
// Diagnostic INSes (CLA=0x80) for profiling AUTH1 sub-operations.
// DEV / PERFORMANCE-DEBUG ONLY. Set DIAGNOSTICS_ENABLED = false for any
// production CAP -- the JC converter dead-code-eliminates the disabled
// branches, so production binaries have zero attack surface from these.
//
// When enabled, each INS takes Lc=1 byte = iteration count N (1-255),
// runs the operation N times against hardcoded test vectors with sizes
// that match what AUTH1 actually does, then returns SW=9000. Output is
// written to the APDU buffer (per-APDU, transient -- never aliases the
// AUTH session scratch). The diagnostic keypair is allocated separately
// from the protocol's ephemeral keypair, so calling diag mid-transaction
// cannot clobber an in-flight AUTH0/AUTH1.
private static final boolean DIAGNOSTICS_ENABLED = false;
private static final byte INS_DIAG_HMAC = (byte) 0xD0;
private static final byte INS_DIAG_ECDH = (byte) 0xD1;
private static final byte INS_DIAG_ECDSA_SIGN = (byte) 0xD2;
private static final byte INS_DIAG_GCM = (byte) 0xD3;
// Session state layout (transient, CLEAR_ON_DESELECT + reset on SELECT). // Session state layout (transient, CLEAR_ON_DESELECT + reset on SELECT).
private static final short OFF_READER_EPUBK = 0; private static final short OFF_READER_EPUBK = 0;
private static final short OFF_READER_GROUP_ID = 65; private static final short OFF_READER_GROUP_ID = 65;
@@ -114,16 +133,17 @@ public class AliroApplet extends Applet {
/** SHA-1 for key_slot = first 8 bytes of SHA-1(uncompressed credential_PubK). */ /** SHA-1 for key_slot = first 8 bytes of SHA-1(uncompressed credential_PubK). */
private MessageDigest sha1; private MessageDigest sha1;
/** Userland AES-256-GCM (built on AES-ECB-NOPAD + AES-256 key). The // Userland AES-256-GCM (built on AES-ECB-NOPAD + AES-256 key) is pulled
* target card (NXP J3R180) does not expose {@code AEADCipher.ALG_AES_GCM} // from CryptoSingletons.getAliroGcm(). The target card (NXP J3R180) does
* despite advertising JC 3.0.5, so we implement GCM in userland. // not expose AEADCipher.ALG_AES_GCM despite advertising JC 3.0.5, so we
* {@link AliroGcm} owns its own persistent {@link AESKey} and // implement GCM in userland. Sharing with StepUpApplet via the singleton
* {@link javacardx.crypto.Cipher} internally — this class no longer // saves ~370 B of transient/EEPROM footprint that a per-applet duplicate
* needs a separate {@code expeditedSKDeviceKey} field. */ // would otherwise pay. See encryptResponseGcm().
private AliroGcm gcm;
/** Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation). */ // Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation)
private AliroCrypto crypto; // are pulled from CryptoSingletons.getAliroCrypto() so both AliroApplet
// and StepUpApplet share one instance — saves ~352 B transient versus a
// per-applet duplicate. Local refs in deriveSessionKeys / processDiag.
/** Persistent (1B EEPROM) — set after {@link #ensureCryptoInitialized()} runs. */ /** Persistent (1B EEPROM) — set after {@link #ensureCryptoInitialized()} runs. */
private byte cryptoInitialized; private byte cryptoInitialized;
@@ -157,7 +177,10 @@ public class AliroApplet extends Applet {
private static final short DERIVED_KEYS_LEN = 160; private static final short DERIVED_KEYS_LEN = 160;
private static final short KDH_LEN = 32; private static final short KDH_LEN = 32;
private static final short SALT_VOLATILE_CAPACITY = 200; // Spec §8.3.1.13 fixes salt_volatile at 141 B. 144 B = 141 + 3 B pad to
// keep the transient allocation arithmetic round without wasting the
// 59 B headroom the M1 conservative sizing carried.
private static final short SALT_VOLATILE_CAPACITY = 144;
/** Offsets into {@link #derivedKeys} per spec §8.3.1.13. */ /** Offsets into {@link #derivedKeys} per spec §8.3.1.13. */
private static final short OFF_EXPEDITED_SK_READER = 0; private static final short OFF_EXPEDITED_SK_READER = 0;
@@ -173,45 +196,10 @@ public class AliroApplet extends Applet {
'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*' 'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*'
}; };
// secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2. // secp256r1 / NIST P-256 curve parameters live in {@link Secp256r1Params}
// J3R180 doesn't ship a default P-256 parameter set on its EC keys, so // — shared with {@link CoseVerifier}. J3R180 doesn't ship a default P-256
// calls into genKeyPair / setS / setW / Signature.init throw // parameter set on its EC keys, so seedSecp256r1() must run before any
// CryptoException.ILLEGAL_VALUE until we seed the curve explicitly. // genKeyPair / setS / setW / Signature.init.
private static final byte[] SECP256R1_P = {
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF
};
private static final byte[] SECP256R1_A = {
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFC
};
private static final byte[] SECP256R1_B = {
0x5A,(byte)0xC6,0x35,(byte)0xD8,(byte)0xAA,0x3A,(byte)0x93,(byte)0xE7,
(byte)0xB3,(byte)0xEB,(byte)0xBD,0x55,0x76,(byte)0x98,(byte)0x86,(byte)0xBC,
0x65,0x1D,0x06,(byte)0xB0,(byte)0xCC,0x53,(byte)0xB0,(byte)0xF6,
0x3B,(byte)0xCE,0x3C,0x3E,0x27,(byte)0xD2,0x60,0x4B
};
private static final byte[] SECP256R1_G = {
0x04,
0x6B,0x17,(byte)0xD1,(byte)0xF2,(byte)0xE1,0x2C,0x42,0x47,
(byte)0xF8,(byte)0xBC,(byte)0xE6,(byte)0xE5,0x63,(byte)0xA4,0x40,(byte)0xF2,
0x77,0x03,0x7D,(byte)0x81,0x2D,(byte)0xEB,0x33,(byte)0xA0,
(byte)0xF4,(byte)0xA1,0x39,0x45,(byte)0xD8,(byte)0x98,(byte)0xC2,(byte)0x96,
0x4F,(byte)0xE3,0x42,(byte)0xE2,(byte)0xFE,0x1A,0x7F,(byte)0x9B,
(byte)0x8E,(byte)0xE7,(byte)0xEB,0x4A,0x7C,0x0F,(byte)0x9E,0x16,
0x2B,(byte)0xCE,0x33,0x57,0x6B,0x31,0x5E,(byte)0xCE,
(byte)0xCB,(byte)0xB6,0x40,0x68,0x37,(byte)0xBF,0x51,(byte)0xF5
};
private static final byte[] SECP256R1_R = {
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x00,
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,
(byte)0xBC,(byte)0xE6,(byte)0xFA,(byte)0xAD,(byte)0xA7,0x17,(byte)0x9E,(byte)0x84,
(byte)0xF3,(byte)0xB9,(byte)0xCA,(byte)0xC2,(byte)0xFC,0x63,0x25,0x51
};
/** NFC interface byte, spec §8.3.1.13. */ /** NFC interface byte, spec §8.3.1.13. */
private static final byte INTERFACE_BYTE_NFC = (byte) 0x5E; private static final byte INTERFACE_BYTE_NFC = (byte) 0x5E;
/** Table 8-13 usage constant for UD signature (spec §8.3.3.4.3). */ /** Table 8-13 usage constant for UD signature (spec §8.3.3.4.3). */
@@ -269,11 +257,16 @@ public class AliroApplet extends Applet {
ecdsaSigner = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false); ecdsaSigner = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
sha1 = MessageDigest.getInstance(MessageDigest.ALG_SHA, false); sha1 = MessageDigest.getInstance(MessageDigest.ALG_SHA, false);
try { try {
gcm = new AliroGcm(); // Force lazy alloc of the shared AliroGcm so any install-time
// failure (Cipher.getInstance / KeyBuilder.buildKey rejection)
// surfaces with the same greppable diagnostic SW as before.
CryptoSingletons.getAliroGcm();
} catch (ISOException e) { throw e; // preserve inner diagnostic SW } catch (ISOException e) { throw e; // preserve inner diagnostic SW
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); } } catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); }
try { try {
crypto = new AliroCrypto(); // Force lazy alloc of the shared AliroCrypto so the same install-
// time failure ladder applies if the constructor throws.
CryptoSingletons.getAliroCrypto();
} catch (ISOException e) { throw e; // preserve inner diagnostic SW (0x6FC1-C5) } catch (ISOException e) { throw e; // preserve inner diagnostic SW (0x6FC1-C5)
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA6); } } catch (Throwable t) { ISOException.throwIt((short) 0x6FA6); }
// Seed P-256 curve params on every keypair before any genKeyPair / setS / // Seed P-256 curve params on every keypair before any genKeyPair / setS /
@@ -292,22 +285,150 @@ public class AliroApplet extends Applet {
/** Loads the secp256r1 / NIST P-256 curve parameters into both halves of /** Loads the secp256r1 / NIST P-256 curve parameters into both halves of
* {@code kp}. Must run before any {@code genKeyPair}, {@code setS}, * {@code kp}. Must run before any {@code genKeyPair}, {@code setS},
* {@code setW}, or {@code Signature.init} on cards (like J3R180) that * {@code setW}, or {@code Signature.init} on cards (like J3R180) that
* don't preset domain parameters on freshly-allocated EC keys. */ * don't preset domain parameters on freshly-allocated EC keys.
*
* <p>Thin wrapper over {@link Secp256r1Params#seed(KeyPair)} kept for
* call-site readability; the byte arrays live in the shared class. */
private static void seedSecp256r1(KeyPair kp) { private static void seedSecp256r1(KeyPair kp) {
javacard.security.ECPublicKey pub = (javacard.security.ECPublicKey) kp.getPublic(); Secp256r1Params.seed(kp);
javacard.security.ECPrivateKey priv = (javacard.security.ECPrivateKey) kp.getPrivate(); }
pub.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length);
pub.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); // --- Diagnostic test vectors -------------------------------------------
pub.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); // Hardcoded inputs for the INS_DIAG_* operations. Sizes match what AUTH1
pub.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); // exercises: 32B HMAC key (matches HKDF PRK), 64B HMAC message (matches
pub.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); // SHA-256 block size), 12B AES-GCM IV, 137B plaintext (matches the actual
pub.setK((short) 1); // Table 8-11 plaintext length when cmd_params bit 0 = 1).
priv.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); private static final byte[] DIAG_KEY_32 = {
priv.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); (byte) 0x00, (byte) 0x01, (byte) 0x02, (byte) 0x03,
priv.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); (byte) 0x04, (byte) 0x05, (byte) 0x06, (byte) 0x07,
priv.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); (byte) 0x08, (byte) 0x09, (byte) 0x0A, (byte) 0x0B,
priv.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); (byte) 0x0C, (byte) 0x0D, (byte) 0x0E, (byte) 0x0F,
priv.setK((short) 1); (byte) 0x10, (byte) 0x11, (byte) 0x12, (byte) 0x13,
(byte) 0x14, (byte) 0x15, (byte) 0x16, (byte) 0x17,
(byte) 0x18, (byte) 0x19, (byte) 0x1A, (byte) 0x1B,
(byte) 0x1C, (byte) 0x1D, (byte) 0x1E, (byte) 0x1F
};
private static final byte[] DIAG_MSG_64 = {
(byte) 0x20, (byte) 0x21, (byte) 0x22, (byte) 0x23, (byte) 0x24, (byte) 0x25, (byte) 0x26, (byte) 0x27,
(byte) 0x28, (byte) 0x29, (byte) 0x2A, (byte) 0x2B, (byte) 0x2C, (byte) 0x2D, (byte) 0x2E, (byte) 0x2F,
(byte) 0x30, (byte) 0x31, (byte) 0x32, (byte) 0x33, (byte) 0x34, (byte) 0x35, (byte) 0x36, (byte) 0x37,
(byte) 0x38, (byte) 0x39, (byte) 0x3A, (byte) 0x3B, (byte) 0x3C, (byte) 0x3D, (byte) 0x3E, (byte) 0x3F,
(byte) 0x40, (byte) 0x41, (byte) 0x42, (byte) 0x43, (byte) 0x44, (byte) 0x45, (byte) 0x46, (byte) 0x47,
(byte) 0x48, (byte) 0x49, (byte) 0x4A, (byte) 0x4B, (byte) 0x4C, (byte) 0x4D, (byte) 0x4E, (byte) 0x4F,
(byte) 0x50, (byte) 0x51, (byte) 0x52, (byte) 0x53, (byte) 0x54, (byte) 0x55, (byte) 0x56, (byte) 0x57,
(byte) 0x58, (byte) 0x59, (byte) 0x5A, (byte) 0x5B, (byte) 0x5C, (byte) 0x5D, (byte) 0x5E, (byte) 0x5F
};
private static final byte[] DIAG_IV_12 = {
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x01,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x01
};
private static final short DIAG_GCM_PT_LEN = (short) 137;
/** Dedicated diagnostic keypair, allocated lazily on first INS_DIAG_* and
* used ONLY by the diag dispatcher. Kept separate from
* {@link #credentialEphemeralKeyPair} so a diagnostic call cannot
* clobber an in-flight AUTH0/AUTH1 transaction's ephemeral key. */
private javacard.security.KeyPair diagKeyPair;
/** Persistent flag: 1 once {@link #diagKeyPair} has been allocated and
* seeded with a random P-256 scalar via genKeyPair(). */
private byte diagInitialized;
private void ensureDiagInitialized() {
if (diagInitialized != 0) return;
diagKeyPair = new javacard.security.KeyPair(
javacard.security.KeyPair.ALG_EC_FP,
javacard.security.KeyBuilder.LENGTH_EC_FP_256);
seedSecp256r1(diagKeyPair);
diagKeyPair.genKeyPair();
diagInitialized = 1;
}
/** Dispatch for the four INS_DIAG_* profiling operations. Output is
* written into the APDU buffer (which is per-APDU and never aliases the
* AUTH session's {@link #scratch}), and all crypto runs against the
* dedicated {@link #diagKeyPair}, never the protocol's ephemeral key.
* Caller already gated on {@link #DIAGNOSTICS_ENABLED}. */
private void processDiag(APDU apdu, byte ins) {
ensureDiagInitialized();
short lc = apdu.setIncomingAndReceive();
byte[] buf = apdu.getBuffer();
if (lc != (short) 1) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
short n = (short) (buf[apdu.getOffsetCdata()] & 0xFF);
if (n == (short) 0) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
// Diagnostic output lands in the APDU buffer past the Lc/data header
// (offset 16 leaves space for HMAC/ECDH 32B output, ECDSA DER sigs up
// to 72B, and AES-GCM 153B ciphertext+tag -- all fit in the standard
// 261-byte APDU buffer). Never reuses scratch -- AUTH session state
// stays intact.
final short DIAG_OUT_OFF = (short) 16;
// AES-GCM: encrypt in place at offset 16. pt/out overlap at the same
// offset is supported by AliroGcm.encrypt. Output is 137B ciphertext
// + 16B tag = 153B, ending at offset 169 -- well inside the 261B
// APDU buffer. Old code tried pt at offset 176 (16 + 160 reserved
// for the CT region) but that ran the 137B read off the end of the
// buffer at offset 313, throwing ArrayIndexOutOfBoundsException.
final short DIAG_GCM_PT_OFF = (short) 16;
final short DIAG_GCM_OUT_OFF = (short) 16;
switch (ins) {
case INS_DIAG_HMAC: {
AliroCrypto cryptoHmac = CryptoSingletons.getAliroCrypto();
for (short i = 0; i < n; i++) {
cryptoHmac.diagHmac(
DIAG_KEY_32, (short) 0, (short) DIAG_KEY_32.length,
DIAG_MSG_64, (short) 0, (short) DIAG_MSG_64.length,
buf, DIAG_OUT_OFF);
}
return;
}
case INS_DIAG_ECDH: {
javacard.security.ECPrivateKey priv =
(javacard.security.ECPrivateKey) diagKeyPair.getPrivate();
AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto();
for (short i = 0; i < n; i++) {
cryptoEcdh.computeEcdhSharedX(priv,
Secp256r1Params.SECP256R1_G, (short) 0,
buf, DIAG_OUT_OFF);
}
return;
}
case INS_DIAG_ECDSA_SIGN: {
// Sign with the DEDICATED diag keypair, never the protocol's
// credential or ephemeral keys. The signed message is also a
// fixed test vector, so this cannot be coerced into signing
// attacker-chosen data.
ecdsaSigner.init(diagKeyPair.getPrivate(),
Signature.MODE_SIGN);
for (short i = 0; i < n; i++) {
ecdsaSigner.sign(
DIAG_MSG_64, (short) 0, (short) DIAG_MSG_64.length,
buf, DIAG_OUT_OFF);
}
return;
}
case INS_DIAG_GCM: {
// Plaintext: 137 bytes anywhere in buf past the output region.
// Contents don't affect timing.
AliroGcm gcm = CryptoSingletons.getAliroGcm();
for (short i = 0; i < n; i++) {
gcm.encrypt(
DIAG_KEY_32, (short) 0,
DIAG_IV_12, (short) 0,
buf, DIAG_GCM_PT_OFF, DIAG_GCM_PT_LEN,
buf, DIAG_GCM_OUT_OFF);
}
return;
}
default:
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
}
} }
@Override @Override
@@ -357,6 +478,20 @@ public class AliroApplet extends Applet {
case INS_AUTH1: case INS_AUTH1:
processAuth1(apdu); processAuth1(apdu);
return; return;
case INS_DIAG_HMAC:
case INS_DIAG_ECDH:
case INS_DIAG_ECDSA_SIGN:
case INS_DIAG_GCM:
// Compile-time gate: with DIAGNOSTICS_ENABLED=false the JC
// converter dead-code-eliminates the processDiag call, so a
// production CAP rejects these INSes the same way it rejects
// any other unsupported INS -- zero residual surface.
if (DIAGNOSTICS_ENABLED) {
processDiag(apdu, ins);
return;
}
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
return;
default: default:
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
} }
@@ -455,6 +590,7 @@ public class AliroApplet extends Applet {
short saltLen = buildSaltVolatile(store, saltVolatile, (short) 0); short saltLen = buildSaltVolatile(store, saltVolatile, (short) 0);
short infoLen = buildInfo(scratch, SCRATCH_READER_PUB_UNCOMP_OFF); short infoLen = buildInfo(scratch, SCRATCH_READER_PUB_UNCOMP_OFF);
AliroCrypto crypto = CryptoSingletons.getAliroCrypto();
crypto.deriveKdh( crypto.deriveKdh(
(ECPrivateKey) credentialEphemeralKeyPair.getPrivate(), (ECPrivateKey) credentialEphemeralKeyPair.getPrivate(),
sessionState, OFF_READER_EPUBK, sessionState, OFF_READER_EPUBK,
@@ -483,8 +619,16 @@ public class AliroApplet extends Applet {
* transaction_identifier 16B * transaction_identifier 16B
* flag 2B (auth0 cmd_params || authentication_policy) * flag 2B (auth0 cmd_params || authentication_policy)
* proprietary_A5_TLV 10B (from SELECT FCI) * proprietary_A5_TLV 10B (from SELECT FCI)
* x(access_credential_pub_key) 32B (long-term; from CredentialStore)
* </pre> * </pre>
*
* <p>Spec §8.3.1.13 salt_volatile ends at the 0xA5 proprietary TLV. We
* previously appended x(access_credential_pub_key) here too -- that was
* an misread of the spec text (it belongs in {@code info}, not
* salt_volatile, and {@code info} uses the EPHEMERAL credential key, not
* the long-term one). The misread was symmetric between this applet and
* our PC/SC reader, so AUTH1 succeeded against our own host-side reader
* but failed against ST's X-CUBE-ALIRO with
* {@code ACWG_Error_Crypto_EncryptDecrypt}. Removed 2026-06-11.
*/ */
private short buildSaltVolatile(CredentialStore store, byte[] out, short outOff) { private short buildSaltVolatile(CredentialStore store, byte[] out, short outOff) {
short p = outOff; short p = outOff;
@@ -514,17 +658,23 @@ public class AliroApplet extends Applet {
Util.arrayCopyNonAtomic(sessionState, OFF_TRANSACTION_ID, out, p, (short) 16); Util.arrayCopyNonAtomic(sessionState, OFF_TRANSACTION_ID, out, p, (short) 16);
p += 16; p += 16;
// flag = command_parameters || authentication_policy (both 1 byte, from AUTH0) // flag = command_parameters || authentication_policy (1 byte each).
out[p++] = sessionState[OFF_COMMAND_PARAMETERS]; // command_parameters comes from the AUTH1 command -- "from the command
// data field" in §8.3.1.13 refers to the AUTH1 command being processed
// when keys are derived (the AUTH0 cmd_params still gets used for the
// EXPEDITED-FAST path's salt_persistent per §8.3.1.12, but for the
// EXPEDITED-STANDARD §8.3.1.13 path AUTH1 is the active request).
// authentication_policy only exists in AUTH0, so it's pulled from
// the AUTH0 state we saved earlier.
// Empirical confirmation: with AUTH0's cmd_params the X-CUBE-ALIRO
// vendor library failed AES-GCM tag verify on AUTH1 response with
// ACWG_Error_Crypto_EncryptDecrypt; AUTH1's cmd_params unblocks it.
out[p++] = sessionState[OFF_AUTH1_CMD_PARAMS];
out[p++] = sessionState[OFF_AUTH_POLICY]; out[p++] = sessionState[OFF_AUTH_POLICY];
Util.arrayCopyNonAtomic(PROPRIETARY_A5_TLV, (short) 0, out, p, (short) PROPRIETARY_A5_TLV.length); Util.arrayCopyNonAtomic(PROPRIETARY_A5_TLV, (short) 0, out, p, (short) PROPRIETARY_A5_TLV.length);
p += PROPRIETARY_A5_TLV.length; p += PROPRIETARY_A5_TLV.length;
// x(access_credential_public_key) — first 32 bytes of the 64B stored credential_PubK
store.copyCredentialPubKeyX(out, p);
p += 32;
return (short) (p - outOff); return (short) (p - outOff);
} }
@@ -648,7 +798,8 @@ public class AliroApplet extends Applet {
* credential_PubK), per §8.3.3.4.2 + ref [12]) when command_parameters * credential_PubK), per §8.3.3.4.2 + ref [12]) when command_parameters
* bit 0 = 0, or {@code 0x5A credential_PubK} (full uncompressed 65B) * bit 0 = 0, or {@code 0x5A credential_PubK} (full uncompressed 65B)
* when bit 0 = 1. Then {@code 0x9E UD_signature} and a 2-byte * when bit 0 = 1. Then {@code 0x9E UD_signature} and a 2-byte
* all-zero {@code 0x5E signaling_bitmap}. * {@code 0x5E signaling_bitmap} reflecting step-up capability and
* AD retrievability (see inline comment at the bitmap emit).
*/ */
private short buildTable811Plaintext( private short buildTable811Plaintext(
byte auth1CmdParams, CredentialStore store, byte auth1CmdParams, CredentialStore store,
@@ -677,17 +828,34 @@ public class AliroApplet extends Applet {
p += rawSigLen; p += rawSigLen;
// 0x5E 0x02 [signaling_bitmap] — 16-bit big-endian. Bit 0: Access // 0x5E 0x02 [signaling_bitmap] — 16-bit big-endian. Bit 0: Access
// Document retrievable. Bit 2: retrieval requires step-up AID SELECT // Document retrievable from this credential. Bit 2: retrieval
// (applicable on NFC). Other bits unused in v1 (no mailbox/notify). // requires step-up AID SELECT (applicable on NFC). Other bits unused
short bitmap = 0; // in v1 (no mailbox/notify).
if (store.hasAccessDocument()) { //
bitmap |= 0x0001; // bit 0 // Bit 2 is always set: our architecture always uses split AID
bitmap |= 0x0004; // bit 2 — NFC requires step-up AID to fetch AD // (5501 expedited + 5502 step-up), so any AD retrieval will go
// through SELECT ACCE5502. We advertise the step-up channel even
// when nothing's there yet — readers that don't speak step-up just
// won't try.
//
// Bit 0 reflects whether we can actually serve an AD: it requires
// both hasAccessDocument() (finalized) AND isAccessDocumentVerified()
// (IssuerAuth check passed). finalizeAccessDocument() commits both
// flags atomically, so the "finalized but not verified" branch is
// defensive.
//
// Note: the closed-source ACWG_processAUTH1ResponsePayload() in
// X-CUBE-ALIRO's Aliro.a errors out when bits 0 + 2 are clear and AD
// is provisioned. With bit 2 now always set we still keep that
// library happy, and bit 0 only makes a promise we can actually keep.
byte bitmapLo = 0x04; // bit 2 — split-AID step-up architecture
if (store.hasAccessDocument() && store.isAccessDocumentVerified()) {
bitmapLo |= 0x01; // bit 0 — AD retrievable
} }
out[p++] = (byte) 0x5E; out[p++] = (byte) 0x5E;
out[p++] = (byte) 0x02; out[p++] = (byte) 0x02;
out[p++] = (byte) ((bitmap >> 8) & 0xFF); out[p++] = (byte) 0x00; // high byte unused in v1
out[p++] = (byte) (bitmap & 0xFF); out[p++] = bitmapLo;
return (short) (p - outOff); return (short) (p - outOff);
} }
@@ -733,7 +901,7 @@ public class AliroApplet extends Applet {
// device_counter big-endian in the last 4 bytes; first AUTH1 = 1 // device_counter big-endian in the last 4 bytes; first AUTH1 = 1
scratch[(short) (ivOff + 11)] = (byte) 0x01; scratch[(short) (ivOff + 11)] = (byte) 0x01;
return gcm.encrypt( return CryptoSingletons.getAliroGcm().encrypt(
derivedKeys, OFF_EXPEDITED_SK_DEVICE, derivedKeys, OFF_EXPEDITED_SK_DEVICE,
scratch, ivOff, scratch, ivOff,
plaintext, ptOff, ptLen, plaintext, ptOff, ptLen,

View File

@@ -2,6 +2,7 @@ package com.dangerousthings.aliro;
import javacard.security.ECPrivateKey; import javacard.security.ECPrivateKey;
import javacard.security.KeyAgreement; import javacard.security.KeyAgreement;
import javacard.security.MessageDigest;
/** /**
* Low-level Aliro cryptographic primitives, factored out so they can be * Low-level Aliro cryptographic primitives, factored out so they can be
@@ -30,6 +31,9 @@ final class AliroCrypto {
private KeyAgreement ecdhPlain; private KeyAgreement ecdhPlain;
private AliroHmac aliroHmac; private AliroHmac aliroHmac;
/** Native SHA-256 instance used by {@link #deriveKdh} (X9.63 KDF). */
private MessageDigest sha256;
/** Reusable scratch for one HMAC output (T(i)) and one counter byte. */ /** Reusable scratch for one HMAC output (T(i)) and one counter byte. */
private byte[] hkdfPrevT; private byte[] hkdfPrevT;
@@ -59,6 +63,11 @@ final class AliroCrypto {
} catch (Throwable t) { } catch (Throwable t) {
javacard.framework.ISOException.throwIt((short) 0x6FC7); javacard.framework.ISOException.throwIt((short) 0x6FC7);
} }
try {
sha256 = MessageDigest.getInstance(MessageDigest.ALG_SHA_256, false);
} catch (Throwable t) {
javacard.framework.ISOException.throwIt((short) 0x6FC2);
}
try { try {
hkdfPrevT = javacard.framework.JCSystem.makeTransientByteArray( hkdfPrevT = javacard.framework.JCSystem.makeTransientByteArray(
HASH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT); HASH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
@@ -89,6 +98,19 @@ final class AliroCrypto {
return ecdhPlain.generateSecret(peerPubUncomp, peerPubOff, (short) 65, out, outOff); return ecdhPlain.generateSecret(peerPubUncomp, peerPubOff, (short) 65, out, outOff);
} }
/**
* Diagnostic-only: exposes the underlying HMAC-SHA-256 primitive so
* AliroApplet's INS_DIAG_HMAC handler can profile per-call cost. Not
* used by production AUTH0/AUTH1 paths (those go through hkdfExtract
* and hkdfExpand).
*/
short diagHmac(
byte[] key, short keyOff, short keyLen,
byte[] msg, short msgOff, short msgLen,
byte[] out, short outOff) {
return aliroHmac.compute(key, keyOff, keyLen, msg, msgOff, msgLen, out, outOff);
}
/** /**
* HKDF-Extract per RFC 5869 §2.2 with HMAC-SHA-256: * HKDF-Extract per RFC 5869 §2.2 with HMAC-SHA-256:
* {@code PRK = HMAC(salt, IKM)}. * {@code PRK = HMAC(salt, IKM)}.
@@ -170,26 +192,42 @@ final class AliroCrypto {
} }
/** /**
* Derives Kdh per Aliro §8.3.1.4 (with the §8.3.1.5 HKDF substitution * Derives Kdh per Aliro §8.3.1.4: <b>X9.63 KDF</b> from BSI TR-03111 with
* noted in the spec): {@code Kdh = HKDF(IKM=ECDH_x(priv, peerPub), * H=SHA-256, ZAB = ECDH shared-secret x-coord, SharedInfo =
* salt=txnId, info=∅, L=32)}. Writes 32 bytes to {@code out[outOff..]} * transaction_identifier, K = 256 bits. For 32-byte output X9.63 KDF
* and returns 32. * reduces to a single SHA-256 invocation:
* <pre>
* Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
* </pre>
*
* <p><b>History:</b> we previously misread the §8.3.1.4 note ("actual key
* derivation is performed using §8.3.1.5") as authorizing HKDF
* substitution for Kdh itself. The note is about the subsequent
* session-key derivation (§8.3.1.13 uses §8.3.1.5 HKDF), not Kdh. The
* misread was symmetric between this applet and our PC/SC reader, so
* AUTH1 succeeded against our own host-side reader but failed against
* ST's X-CUBE-ALIRO library (which follows §8.3.1.4 correctly) with
* {@code ACWG_Error_Crypto_EncryptDecrypt}. Fixed 2026-06-11.
*
* <p>Writes 32 bytes to {@code out[outOff..]} and returns 32.
*/ */
short deriveKdh( short deriveKdh(
ECPrivateKey priv, ECPrivateKey priv,
byte[] peerPubUncomp, short peerPubOff, byte[] peerPubUncomp, short peerPubOff,
byte[] txnId, short txnIdOff, short txnIdLen, byte[] txnId, short txnIdOff, short txnIdLen,
byte[] out, short outOff) { byte[] out, short outOff) {
// Stage ZAB || counter(0x00000001) at kdfWorkbuf[0..36).
computeEcdhSharedX(priv, peerPubUncomp, peerPubOff, kdfWorkbuf, (short) 0); computeEcdhSharedX(priv, peerPubUncomp, peerPubOff, kdfWorkbuf, (short) 0);
hkdfExtract( kdfWorkbuf[32] = 0;
txnId, txnIdOff, txnIdLen, kdfWorkbuf[33] = 0;
kdfWorkbuf, (short) 0, HASH_LEN, kdfWorkbuf[34] = 0;
kdfWorkbuf, HASH_LEN); kdfWorkbuf[35] = 1;
hkdfExpand( // Kdh = SHA-256(ZAB || counter || txnId) -- one shot.
kdfWorkbuf, HASH_LEN, HASH_LEN, sha256.reset();
kdfWorkbuf, (short) 0, (short) 0, sha256.update(kdfWorkbuf, (short) 0, (short) 36);
HASH_LEN, sha256.doFinal(txnId, txnIdOff, txnIdLen, out, outOff);
out, outOff); // Wipe ZAB from working memory.
javacard.framework.Util.arrayFillNonAtomic(kdfWorkbuf, (short) 0, (short) 36, (byte) 0);
return HASH_LEN; return HASH_LEN;
} }

View File

@@ -3,6 +3,7 @@ package com.dangerousthings.aliro;
import javacard.framework.JCSystem; import javacard.framework.JCSystem;
import javacard.framework.Util; import javacard.framework.Util;
import javacard.security.AESKey; import javacard.security.AESKey;
import javacard.security.CryptoException;
import javacard.security.KeyBuilder; import javacard.security.KeyBuilder;
import javacardx.crypto.Cipher; import javacardx.crypto.Cipher;
@@ -48,8 +49,35 @@ final class AliroGcm {
private static final short OFF_LEN_BLK = 96; // length block for GHASH finalization private static final short OFF_LEN_BLK = 96; // length block for GHASH finalization
private static final short SCRATCH_LEN = 112; private static final short SCRATCH_LEN = 112;
// 4-bit GHASH lookup tables. M is built per-H in mTable[0..256), L is the
// fixed shift-right-by-4 reduction lookup (bytes 2..15 of each L[i] are
// always zero for the NIST GHASH reduction polynomial R = 0xE1 00...00,
// so only byte 0 and byte 1 are stored).
private static final short M_TABLE_LEN = (short) (16 * BLOCK_LEN); // 256 B
// L_BYTE0[i] / L_BYTE1[i] = byte 0 / byte 1 of (R XOR R>>1 XOR R>>2 XOR R>>3)
// selected by which bits of nibble i are set:
// bit 0 set -> XOR R >> 3 = 0x1C 0x20
// bit 1 set -> XOR R >> 2 = 0x38 0x40
// bit 2 set -> XOR R >> 1 = 0x70 0x80
// bit 3 set -> XOR R = 0xE1 0x00
private static final byte[] L_BYTE0 = {
(byte)0x00, (byte)0x1C, (byte)0x38, (byte)0x24,
(byte)0x70, (byte)0x6C, (byte)0x48, (byte)0x54,
(byte)0xE1, (byte)0xFD, (byte)0xD9, (byte)0xC5,
(byte)0x91, (byte)0x8D, (byte)0xA9, (byte)0xB5
};
private static final byte[] L_BYTE1 = {
(byte)0x00, (byte)0x20, (byte)0x40, (byte)0x60,
(byte)0x80, (byte)0xA0, (byte)0xC0, (byte)0xE0,
(byte)0x00, (byte)0x20, (byte)0x40, (byte)0x60,
(byte)0x80, (byte)0xA0, (byte)0xC0, (byte)0xE0
};
/** Persistent ECB cipher (AES-128/192/256 single-block-NOPAD). AES-256 /** Persistent ECB cipher (AES-128/192/256 single-block-NOPAD). AES-256
* is driven by the size of the {@link AESKey} passed to {@link Cipher#init}. */ * is driven by the size of the {@link AESKey} passed to {@link Cipher#init}.
* Always allocated -- required for computing H = AES_K(0^128) and the
* per-tag AES_K(J0), regardless of whether CTR is native or userland. */
private final Cipher aesEcb; private final Cipher aesEcb;
/** Reusable 256-bit AES key slot. {@link AESKey#setKey} is called per encrypt. */ /** Reusable 256-bit AES key slot. {@link AESKey#setKey} is called per encrypt. */
@@ -58,11 +86,36 @@ final class AliroGcm {
/** Transient scratch — see OFF_* constants above. */ /** Transient scratch — see OFF_* constants above. */
private final byte[] scratch; private final byte[] scratch;
/** Transient 4-bit GHASH M-table: mTable[i*16..(i+1)*16) = M[i] = i_poly · H,
* rebuilt at the start of every encrypt() once H is computed from the
* fresh AES key. */
private final byte[] mTable;
/** 1 if Cipher.ALG_AES_CTR is available on this JCRE (J3R452 etc.), 0 if
* not (J3R180). When 1, the GCTR pass is one native doFinal call instead
* of ceil(ptLen/16) block-by-block ECB calls -- saves the per-block
* init/init/encrypt overhead. */
private final byte usesNativeCtr;
/** Native CTR cipher; null when {@link #usesNativeCtr} == 0. */
private Cipher aesCtr;
AliroGcm() { AliroGcm() {
aesEcb = Cipher.getInstance(Cipher.ALG_AES_BLOCK_128_ECB_NOPAD, false); aesEcb = Cipher.getInstance(Cipher.ALG_AES_BLOCK_128_ECB_NOPAD, false);
aesKey = (AESKey) KeyBuilder.buildKey( aesKey = (AESKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false); KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT); scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
mTable = JCSystem.makeTransientByteArray(M_TABLE_LEN, JCSystem.CLEAR_ON_DESELECT);
// Native AES-CTR detection is temporarily disabled. On the J3R452 the
// smoke test passed but the real encrypt path hangs (>60 s for what
// should take ~22 s) -- something about Cipher.ALG_AES_CTR on that
// runtime interacts badly with the GHASH loop that follows it (best
// guess: shared AES engine state we're not flushing). Forcing the
// ECB-block-by-block path keeps the behaviour identical to J3R180
// baseline; we'll revisit native CTR after the 4-bit GHASH table
// lands and we have a baseline to compare against.
usesNativeCtr = 0;
} }
/** /**
@@ -91,25 +144,20 @@ final class AliroGcm {
byte[] pt, short ptOff, short ptLen, byte[] pt, short ptOff, short ptLen,
byte[] out, short outOff) { byte[] out, short outOff) {
aesKey.setKey(key, keyOff); setKeyAndIv(key, keyOff, iv, ivOff);
aesEcb.init(aesKey, Cipher.MODE_ENCRYPT);
// H = AES_K(0^128). Zero scratch[OFF_H..OFF_H+16) and encrypt in place.
Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0);
aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H);
// J0 = IV || 0x00000001 (12B IV + 4B counter). 96-bit IV canonical case.
Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN);
scratch[(short) (OFF_J0 + 12)] = 0x00;
scratch[(short) (OFF_J0 + 13)] = 0x00;
scratch[(short) (OFF_J0 + 14)] = 0x00;
scratch[(short) (OFF_J0 + 15)] = 0x01;
// cb = INC32(J0) — first counter block for the plaintext stream. // cb = INC32(J0) — first counter block for the plaintext stream.
Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN); Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN);
inc32(scratch, OFF_CB); inc32(scratch, OFF_CB);
// GCTR loop: stream plaintext through AES-CTR, writing ciphertext to out. if (usesNativeCtr != 0) {
// Native AES-CTR: single call encrypts the whole plaintext (handles
// partial final block internally). Saves the per-block init/doFinal
// overhead of the ECB fallback loop below.
aesCtr.init(aesKey, Cipher.MODE_ENCRYPT, scratch, OFF_CB, BLOCK_LEN);
aesCtr.doFinal(pt, ptOff, ptLen, out, outOff);
} else {
// GCTR fallback: stream plaintext through block-by-block ECB.
short produced = 0; short produced = 0;
while (produced < ptLen) { while (produced < ptLen) {
short blockLen = (short) (ptLen - produced); short blockLen = (short) (ptLen - produced);
@@ -128,6 +176,7 @@ final class AliroGcm {
inc32(scratch, OFF_CB); inc32(scratch, OFF_CB);
produced += blockLen; produced += blockLen;
} }
}
// GHASH over (AAD=empty || ciphertext || len_block). // GHASH over (AAD=empty || ciphertext || len_block).
// state = 0^128 // state = 0^128
@@ -150,7 +199,11 @@ final class AliroGcm {
} }
consumed += chunk; consumed += chunk;
} }
gfMul(scratch, OFF_GHASH, scratch, OFF_H); // GHASH state · H using 4-bit table. gfMul4Bit writes to OFF_ECB_OUT;
// copy result back into OFF_GHASH to match the existing protocol.
gfMul4Bit(scratch, OFF_GHASH);
Util.arrayCopyNonAtomic(scratch, OFF_ECB_OUT,
scratch, OFF_GHASH, BLOCK_LEN);
} }
// len_block = (8*|AAD|)^64 || (8*|C|)^64, big-endian. AAD is always empty // len_block = (8*|AAD|)^64 || (8*|C|)^64, big-endian. AAD is always empty
@@ -193,10 +246,329 @@ final class AliroGcm {
return (short) (ptLen + TAG_LEN); return (short) (ptLen + TAG_LEN);
} }
/**
* AES-256-GCM decrypt with the Aliro parameters: 32-byte key, 12-byte IV,
* empty AAD, 16-byte tag appended. Input is
* {@code in[inOff..inOff+inLen)} laid out as
* {@code ciphertext || tag} where the trailing 16 bytes are the tag and
* the preceding {@code inLen - 16} bytes are the ciphertext. The tag is
* verified before any plaintext is emitted; if verification fails the
* method throws a {@link CryptoException} ({@code ILLEGAL_VALUE}) and does
* NOT write to {@code out}. On success {@code inLen - 16} plaintext bytes
* are written to {@code out[outOff..]} and the same value is returned.
*
* <p>The buffer-aliasing rules mirror {@link #encrypt}: {@code in} and
* {@code out} may be the same buffer at the same offset (we GHASH the
* ciphertext BEFORE we touch the output region, then GCTR overwrites it
* left-to-right).
*
* <p>Used by the Step-Up phase EXCHANGE / ENVELOPE handlers for the
* reader→device direction per spec §8.3.1.9. The expedited-phase
* decrypt path on the reader side is not exercised by the applet; this
* exists to verify the GCM tag on inbound traffic so the applet knows
* the reader holds the matching session keys.
*
* @param key 32-byte AES-256 key
* @param iv 12-byte IV
* @param in input buffer, layout {@code ciphertext || tag}
* @param inOff, inLen input region; {@code inLen >= 16} required
* @param out output buffer, must have at least {@code inLen - 16} bytes
* available at {@code outOff}
* @return plaintext length = {@code inLen - 16}
* @throws CryptoException with reason {@code ILLEGAL_VALUE} on bad input
* length or tag mismatch
*/
short decrypt(
byte[] key, short keyOff,
byte[] iv, short ivOff,
byte[] in, short inOff, short inLen,
byte[] out, short outOff) {
if (inLen < TAG_LEN) {
CryptoException.throwIt(CryptoException.ILLEGAL_VALUE);
}
short ctLen = (short) (inLen - TAG_LEN);
short tagOff = (short) (inOff + ctLen);
setKeyAndIv(key, keyOff, iv, ivOff);
// GHASH over the PROVIDED ciphertext first (so tag verify doesn't
// depend on a successful decrypt). AAD is empty.
Util.arrayFillNonAtomic(scratch, OFF_GHASH, BLOCK_LEN, (byte) 0);
short consumed = 0;
while (consumed < ctLen) {
short chunk = (short) (ctLen - consumed);
if (chunk >= BLOCK_LEN) {
for (short i = 0; i < BLOCK_LEN; i++) {
scratch[(short) (OFF_GHASH + i)] ^= in[(short) (inOff + consumed + i)];
}
consumed += BLOCK_LEN;
} else {
for (short i = 0; i < chunk; i++) {
scratch[(short) (OFF_GHASH + i)] ^= in[(short) (inOff + consumed + i)];
}
consumed += chunk;
}
gfMul4Bit(scratch, OFF_GHASH);
Util.arrayCopyNonAtomic(scratch, OFF_ECB_OUT,
scratch, OFF_GHASH, BLOCK_LEN);
}
// len_block = 0^64 || (8*ctLen)^64. AAD bits = 0; same byte-shift
// dance as encrypt() to dodge JC's int-promotion conversion failure.
Util.arrayFillNonAtomic(scratch, OFF_LEN_BLK, BLOCK_LEN, (byte) 0);
short ctBitsLo = (short) (ctLen << 3);
short ctBitsHi = (short) (((short)(ctLen >>> 13)) & 0x07);
scratch[(short) (OFF_LEN_BLK + 13)] = (byte) (ctBitsHi & 0xFF);
scratch[(short) (OFF_LEN_BLK + 14)] = (byte) ((ctBitsLo >>> 8) & 0xFF);
scratch[(short) (OFF_LEN_BLK + 15)] = (byte) (ctBitsLo & 0xFF);
for (short i = 0; i < BLOCK_LEN; i++) {
scratch[(short) (OFF_GHASH + i)] ^= scratch[(short) (OFF_LEN_BLK + i)];
}
gfMul(scratch, OFF_GHASH, scratch, OFF_H);
// T_expected = AES_K(J0) XOR GHASH. Compare against received tag in
// constant-ish time (XOR-then-OR; no early exit). On JC this isn't
// truly constant-time at the bytecode level, but the smartcard SE
// doesn't expose timing channels at the resolution that would matter
// for a 128-bit tag forgery anyway.
aesEcb.doFinal(scratch, OFF_J0, BLOCK_LEN, scratch, OFF_ECB_OUT);
byte diff = 0;
for (short i = 0; i < TAG_LEN; i++) {
byte expected = (byte) (scratch[(short) (OFF_ECB_OUT + i)]
^ scratch[(short) (OFF_GHASH + i)]);
diff |= (byte) (expected ^ in[(short) (tagOff + i)]);
}
if (diff != 0) {
// Wipe scratch before throwing so a tag-mismatch doesn't leave H,
// GHASH state, or AES_K(J0) sitting in transient.
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
CryptoException.throwIt(CryptoException.ILLEGAL_VALUE);
}
// Tag verified -- now GCTR-decrypt the ciphertext into out[]. cb is
// INC32(J0) just like encrypt(). Reusable OFF_CB slot has been free
// since J0 was last referenced for the tag XOR.
Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN);
inc32(scratch, OFF_CB);
if (usesNativeCtr != 0) {
aesCtr.init(aesKey, Cipher.MODE_ENCRYPT, scratch, OFF_CB, BLOCK_LEN);
// CTR is symmetric: encrypting the ciphertext with the same
// keystream produces the plaintext.
aesCtr.doFinal(in, inOff, ctLen, out, outOff);
} else {
short produced = 0;
while (produced < ctLen) {
short blockLen = (short) (ctLen - produced);
if (blockLen > BLOCK_LEN) blockLen = BLOCK_LEN;
aesEcb.doFinal(scratch, OFF_CB, BLOCK_LEN, scratch, OFF_ECB_OUT);
for (short i = 0; i < blockLen; i++) {
out[(short) (outOff + produced + i)] =
(byte) (in[(short) (inOff + produced + i)]
^ scratch[(short) (OFF_ECB_OUT + i)]);
}
inc32(scratch, OFF_CB);
produced += blockLen;
}
}
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
return ctLen;
}
/**
* Per-(key, IV) GCM setup, shared by {@link #encrypt} and {@link #decrypt}:
* loads the AES-256 key, derives H = AES_K(0^128), rebuilds the 4-bit
* GHASH M-table, and lays down J0 = IV || 0x00000001 at OFF_J0. M2D.3's
* stream-encrypt path calls {@link #encrypt} twice per Step-Up session
* with a different (key, IV) each time; consolidating the rekey path
* keeps that contract pinned to one method.
*/
private void setKeyAndIv(
byte[] key, short keyOff,
byte[] iv, short ivOff) {
aesKey.setKey(key, keyOff);
aesEcb.init(aesKey, Cipher.MODE_ENCRYPT);
// H = AES_K(0^128). Zero scratch[OFF_H..OFF_H+16) and encrypt in place.
Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0);
aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H);
// Build the 4-bit GHASH M-table from the fresh H. One-time cost per
// encrypt (~700 JC bytecodes), amortized over the ~10 gfMul4Bit calls.
buildMTable();
// J0 = IV || 0x00000001 (12B IV + 4B counter). 96-bit IV canonical case.
Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN);
scratch[(short) (OFF_J0 + 12)] = 0x00;
scratch[(short) (OFF_J0 + 13)] = 0x00;
scratch[(short) (OFF_J0 + 14)] = 0x00;
scratch[(short) (OFF_J0 + 15)] = 0x01;
}
/** /**
* INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the * INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the
* 16-byte block, big-endian, modulo 2^32. * 16-byte block, big-endian, modulo 2^32.
*/ */
/**
* Builds the 16-entry M-table from H in scratch[OFF_H..OFF_H+16). Each
* entry M[i] = i_poly · H where i_poly is the polynomial whose bit-j
* coefficient is (i >> (3-j)) & 1, i.e. MSB of i (bit 3) is the α^0 term.
*
* <p>Iterative construction needing 3 shifts + 11 XORs:
* M[8] = H, M[4] = M[8] >> 1, M[2] = M[4] >> 1, M[1] = M[2] >> 1
* M[i] for non-power-of-two i = XOR of M[2^k] for each set bit k.
*/
private void buildMTable() {
// M[0] = 0
Util.arrayFillNonAtomic(mTable, (short) 0, BLOCK_LEN, (byte) 0);
// M[8] = H
Util.arrayCopyNonAtomic(scratch, OFF_H,
mTable, (short) (8 * BLOCK_LEN), BLOCK_LEN);
// M[4] = M[8] >> 1
shiftRight1WithR(mTable, (short) (8 * BLOCK_LEN),
mTable, (short) (4 * BLOCK_LEN));
// M[2] = M[4] >> 1
shiftRight1WithR(mTable, (short) (4 * BLOCK_LEN),
mTable, (short) (2 * BLOCK_LEN));
// M[1] = M[2] >> 1
shiftRight1WithR(mTable, (short) (2 * BLOCK_LEN),
mTable, (short) (1 * BLOCK_LEN));
// M[3, 5, 6, 7, 9..15] = XOR combinations of M[1], M[2], M[4], M[8].
// Inner loop unrolled to dodge JC's "int promotion" rejection on
// expressions like `1 << bit` and `(1 << bit) * BLOCK_LEN`.
for (short i = 3; i < 16; i++) {
if (i == 4 || i == 8) continue; // pure powers already built
short destOff = (short) (i * BLOCK_LEN);
Util.arrayFillNonAtomic(mTable, destOff, BLOCK_LEN, (byte) 0);
// bit 0 set -> XOR M[1]
if ((i & 0x01) != 0) {
short srcOff = (short) (1 * BLOCK_LEN);
for (short k = 0; k < BLOCK_LEN; k++) {
mTable[(short) (destOff + k)] ^= mTable[(short) (srcOff + k)];
}
}
// bit 1 set -> XOR M[2]
if ((i & 0x02) != 0) {
short srcOff = (short) (2 * BLOCK_LEN);
for (short k = 0; k < BLOCK_LEN; k++) {
mTable[(short) (destOff + k)] ^= mTable[(short) (srcOff + k)];
}
}
// bit 2 set -> XOR M[4]
if ((i & 0x04) != 0) {
short srcOff = (short) (4 * BLOCK_LEN);
for (short k = 0; k < BLOCK_LEN; k++) {
mTable[(short) (destOff + k)] ^= mTable[(short) (srcOff + k)];
}
}
// bit 3 set -> XOR M[8]
if ((i & 0x08) != 0) {
short srcOff = (short) (8 * BLOCK_LEN);
for (short k = 0; k < BLOCK_LEN; k++) {
mTable[(short) (destOff + k)] ^= mTable[(short) (srcOff + k)];
}
}
}
}
/**
* Shifts 16 bytes right by 1 bit (toward higher indices in NIST-reflected
* representation) and applies the GHASH reduction polynomial R =
* 0xE1 || 0^120 at byte 0 if the dropped LSB was set. Used only by
* {@link #buildMTable}; the per-multiply path uses shiftRight4WithL.
*/
private static void shiftRight1WithR(
byte[] srcBuf, short srcOff,
byte[] dstBuf, short dstOff) {
boolean lsb = (srcBuf[(short) (srcOff + 15)] & 0x01) != 0;
short carry = 0;
for (short k = 0; k < BLOCK_LEN; k++) {
short cur = (short) (srcBuf[(short) (srcOff + k)] & 0xFF);
short newCur = (short) ((cur >>> 1) | carry);
carry = (short) ((cur & 0x01) << 7);
dstBuf[(short) (dstOff + k)] = (byte) newCur;
}
if (lsb) {
dstBuf[dstOff] ^= (byte) 0xE1;
}
}
/**
* In-place shift of scratch[OFF_ECB_OUT..OFF_ECB_OUT+16) right by 4 bits,
* applying the L-table reduction for whatever 4 bits dropped off the LSB
* end. This is the per-multiply hot loop's shift primitive, ~3-4× faster
* than four consecutive shiftRight1WithR calls because the byte shift is
* one pass and the reduction is two byte XORs from precomputed L tables.
*/
private void shiftRight4WithL() {
// The 4 bits about to be dropped = low nibble of byte 15.
short droppedNibble = (short) (scratch[(short) (OFF_ECB_OUT + 15)] & 0x0F);
// Shift bytes right by 4 bits: byte k becomes (byte_{k-1} << 4) | (byte_k >> 4).
// Walk high to low so we don't read overwritten data.
for (short k = 15; k > 0; k--) {
short cur = (short) (scratch[(short) (OFF_ECB_OUT + k)] & 0xFF);
short prev = (short) (scratch[(short) (OFF_ECB_OUT + k - 1)] & 0xFF);
scratch[(short) (OFF_ECB_OUT + k)] =
(byte) ((cur >>> 4) | ((prev & 0x0F) << 4));
}
short cur0 = (short) (scratch[OFF_ECB_OUT] & 0xFF);
scratch[OFF_ECB_OUT] = (byte) (cur0 >>> 4);
// Apply reduction via L-table: bytes 2..15 of L[n] are always 0 for
// the NIST GHASH polynomial, so we only XOR bytes 0 and 1.
scratch[OFF_ECB_OUT] ^= L_BYTE0[droppedNibble];
scratch[(short) (OFF_ECB_OUT + 1)] ^= L_BYTE1[droppedNibble];
}
/**
* 4-bit GHASH multiply: Z = X · H using the precomputed {@link #mTable}.
* Reads X from {@code xBuf[xOff..xOff+16)} and writes the result to
* scratch[OFF_ECB_OUT..OFF_ECB_OUT+16). Caller is responsible for copying
* the result back to OFF_GHASH between multiplies (mirrors the existing
* {@link #gfMul} contract).
*
* <p>Algorithm (LSB-first Horner):
* <pre>
* Z = 0
* for each nibble of X, LSB-first:
* Z = (Z >> 4 with L reduction) XOR M[nibble]
* </pre>
* 32 iterations vs the bit-by-bit form's 128, plus the inner shift is
* ~4× cheaper. Net ~3-4× speedup on the J3R-family bytecode interpreter.
*/
private void gfMul4Bit(byte[] xBuf, short xOff) {
Util.arrayFillNonAtomic(scratch, OFF_ECB_OUT, BLOCK_LEN, (byte) 0);
for (short byteIdx = 15; byteIdx >= 0; byteIdx--) {
short xByte = (short) (xBuf[(short) (xOff + byteIdx)] & 0xFF);
// Low nibble first (LSB of byte, processed before high nibble).
short nLo = (short) (xByte & 0x0F);
shiftRight4WithL();
if (nLo != 0) {
short mOff = (short) (nLo * BLOCK_LEN);
for (short k = 0; k < BLOCK_LEN; k++) {
scratch[(short) (OFF_ECB_OUT + k)] ^=
mTable[(short) (mOff + k)];
}
}
// High nibble.
short nHi = (short) ((xByte >>> 4) & 0x0F);
shiftRight4WithL();
if (nHi != 0) {
short mOff = (short) (nHi * BLOCK_LEN);
for (short k = 0; k < BLOCK_LEN; k++) {
scratch[(short) (OFF_ECB_OUT + k)] ^=
mTable[(short) (mOff + k)];
}
}
}
}
private static void inc32(byte[] buf, short off) { private static void inc32(byte[] buf, short off) {
short i = (short) (off + 15); short i = (short) (off + 15);
// Carry propagation across 4 bytes. // Carry propagation across 4 bytes.

View File

@@ -2,26 +2,37 @@ package com.dangerousthings.aliro;
import javacard.framework.JCSystem; import javacard.framework.JCSystem;
import javacard.framework.Util; import javacard.framework.Util;
import javacard.security.CryptoException;
import javacard.security.HMACKey;
import javacard.security.KeyBuilder;
import javacard.security.MessageDigest; import javacard.security.MessageDigest;
import javacard.security.Signature;
/** /**
* Userland HMAC-SHA-256 per RFC 2104, built on top of the JC * HMAC-SHA-256 with runtime native-or-userland selection.
* {@link MessageDigest#ALG_SHA_256} primitive. Needed because the target card
* (NXP J3R180) does not expose {@code KeyBuilder.TYPE_HMAC} at any tested key
* size (512/256/160/128 bits) nor {@code Signature.ALG_HMAC_SHA_256}, while
* raw SHA-256 is present and functional.
* *
* <p>Each {@link #compute} call reads a key and a message, zero-pads the key * <p>The constructor probes for {@link Signature#ALG_HMAC_SHA_256} plus
* to the SHA-256 block size (64 bytes) — first hashing the key down with * {@link KeyBuilder#TYPE_HMAC_TRANSIENT_DESELECT} at length
* SHA-256 if it exceeds the block size — then computes: * {@link KeyBuilder#LENGTH_HMAC_SHA_256_BLOCK_64}. If both succeed (J3R452 and
* other newer NXP J3R-family cards), {@link #compute} delegates to the native
* Signature engine, which is dramatically faster than computing HMAC by hand
* on top of SHA-256 in JC bytecode (measured ~5 ms native vs ~30 ms userland
* per call on the J3R-family). If the probe throws CryptoException (J3R180
* lacks any TYPE_HMAC slot at any tested size, and lacks ALG_HMAC_SHA_256
* entirely), we fall back to the original RFC 2104 implementation on top of
* {@link MessageDigest#ALG_SHA_256}, which is present on both.
*
* <p>HMAC per RFC 2104:
* <pre> * <pre>
* HMAC(K, M) = SHA-256((K_pad XOR opad) || SHA-256((K_pad XOR ipad) || M)) * HMAC(K, M) = SHA-256((K_pad XOR opad) || SHA-256((K_pad XOR ipad) || M))
* </pre> * </pre>
* with {@code ipad = 0x36 × 64} and {@code opad = 0x5C × 64}. Output is * with {@code ipad = 0x36 × 64} and {@code opad = 0x5C × 64}, key padded
* always 32 bytes. * with zeros to the 64-byte SHA-256 block size (or pre-hashed if &gt; 64 B).
* Output is always 32 bytes.
* *
* <p>Intended to be instantiated once per applet (or test) and reused — the * <p>Intended to be instantiated once per applet and reused — both the native
* underlying {@link MessageDigest} is expensive to allocate. * Signature/HMACKey objects and the userland MessageDigest are expensive to
* allocate.
*/ */
final class AliroHmac { final class AliroHmac {
@@ -31,34 +42,133 @@ final class AliroHmac {
private static final byte IPAD_BYTE = (byte) 0x36; private static final byte IPAD_BYTE = (byte) 0x36;
private static final byte OPAD_BYTE = (byte) 0x5C; private static final byte OPAD_BYTE = (byte) 0x5C;
// Scratch layout: 64B pad block || 32B inner digest = 96B total. // Scratch layout: 64B pad block || 32B inner digest = 96B total. Reused
// by both paths -- the native path uses OFF_PAD as the zero-padded key
// staging buffer (HMACKey requires a fixed 64-byte slot).
private static final short OFF_PAD = 0; private static final short OFF_PAD = 0;
private static final short OFF_INNER = 64; private static final short OFF_INNER = 64;
private static final short SCRATCH_LEN = 96; private static final short SCRATCH_LEN = 96;
/** Persistent SHA-256 digest. Always reset before reuse — JC /** 1 if the constructor successfully allocated native HMAC primitives, 0
* {@code MessageDigest} does not auto-reset after {@link MessageDigest#doFinal}. */ * if we fell back to the SHA-256-on-top-of-MessageDigest userland path. */
private final MessageDigest sha256; private final byte usesNative;
/** Transient scratch — see OFF_* constants above. */ // Native path (J3R452 etc.) -- null when usesNative == 0.
private Signature hmacSig;
private HMACKey hmacKey;
// Always allocated: needed by the userland path AND by the native path's
// ">64 byte key" pre-hash branch.
private final MessageDigest sha256;
private final byte[] scratch; private final byte[] scratch;
AliroHmac() { AliroHmac() {
sha256 = MessageDigest.getInstance(MessageDigest.ALG_SHA_256, false); sha256 = MessageDigest.getInstance(MessageDigest.ALG_SHA_256, false);
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT); scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
// Probe for native HMAC end-to-end. The two-stage check matters:
// JCAlgTest reports "supported" if buildKey succeeds, but some cards
// (J3R452) accept buildKey then reject setKey with ILLEGAL_VALUE on
// every key length we try. So we run a full smoke-test (buildKey ->
// setKey -> init -> sign with one byte of throwaway data) and only
// mark native if the whole flow runs without throwing. Anything that
// misfires falls us back to userland, which always works.
Signature tmpSig = null;
HMACKey tmpKey = null;
byte nativeOk = 0;
try {
tmpSig = Signature.getInstance(Signature.ALG_HMAC_SHA_256, false);
tmpKey = (HMACKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_HMAC,
KeyBuilder.LENGTH_HMAC_SHA_256_BLOCK_64, false);
// Smoke test: zero-filled 64-byte key, sign a 1-byte message,
// discard the digest. If any of these throw, native is unusable
// on this card and we fall through to userland.
Util.arrayFillNonAtomic(scratch, OFF_PAD, BLOCK_LEN, (byte) 0);
tmpKey.setKey(scratch, OFF_PAD, BLOCK_LEN);
tmpSig.init(tmpKey, Signature.MODE_SIGN);
tmpSig.sign(scratch, OFF_PAD, (short) 1, scratch, OFF_INNER);
nativeOk = 1;
} catch (CryptoException e) {
nativeOk = 0;
} catch (Throwable t) {
nativeOk = 0;
}
if (nativeOk == 1) {
usesNative = 1;
hmacSig = tmpSig;
hmacKey = tmpKey;
} else {
usesNative = 0;
// tmpSig / tmpKey become garbage-collectable; no need to retain.
}
// Wipe any smoke-test bytes left in scratch.
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
} }
/** /**
* HMAC-SHA-256(key, msg). Writes 32 bytes to {@code out[outOff..outOff+32)} * HMAC-SHA-256(key, msg). Writes 32 bytes to {@code out[outOff..outOff+32)}
* and returns 32. The key is copied into internal scratch before the first * and returns 32. The key is staged through internal scratch before any
* hash, and the message is consumed during the inner SHA-256 pass before * hashing, and the message is consumed before anything is written to
* anything is written to {@code out}, so the {@code key}, {@code msg}, and * {@code out}, so the {@code key}, {@code msg}, and {@code out} buffers
* {@code out} buffers may alias each other freely. * may alias each other freely.
*/ */
short compute( short compute(
byte[] key, short keyOff, short keyLen, byte[] key, short keyOff, short keyLen,
byte[] msg, short msgOff, short msgLen, byte[] msg, short msgOff, short msgLen,
byte[] out, short outOff) { byte[] out, short outOff) {
if (usesNative != 0) {
return computeNative(key, keyOff, keyLen, msg, msgOff, msgLen, out, outOff);
}
return computeUserland(key, keyOff, keyLen, msg, msgOff, msgLen, out, outOff);
}
/**
* Native HMAC path: stage K as a fixed 64-byte block in scratch (zero-pad
* if shorter, SHA-256 hash if longer per RFC 2104), then setKey with the
* full block length. The J3R452's HMACKey rejects variable-length setKey
* with ILLEGAL_VALUE; the runtime wants the slot fully populated. RFC 2104
* specifies zero-padding shorter keys to block size before the inner/outer
* XOR, so zero-padding here and passing the full 64 bytes yields the
* correct HMAC output.
*/
private short computeNative(
byte[] key, short keyOff, short keyLen,
byte[] msg, short msgOff, short msgLen,
byte[] out, short outOff) {
// Build the 64-byte staged key in scratch[OFF_PAD..OFF_PAD+64).
Util.arrayFillNonAtomic(scratch, OFF_PAD, BLOCK_LEN, (byte) 0);
if (keyLen > BLOCK_LEN) {
// K' = SHA-256(K), 32 bytes; remaining 32 bytes stay zero.
sha256.reset();
sha256.doFinal(key, keyOff, keyLen, scratch, OFF_PAD);
} else if (keyLen > 0) {
Util.arrayCopyNonAtomic(key, keyOff, scratch, OFF_PAD, keyLen);
}
// else keyLen == 0: scratch[OFF_PAD..OFF_PAD+64) stays all zeros,
// which is the valid (if degenerate) HMAC key derived from the empty
// input -- matches our userland path's behavior for empty keys.
hmacKey.setKey(scratch, OFF_PAD, BLOCK_LEN);
hmacSig.init(hmacKey, Signature.MODE_SIGN);
short outLen = hmacSig.sign(msg, msgOff, msgLen, out, outOff);
// Wipe the staged key -- HMACKey now holds it internally, but the
// scratch copy would otherwise outlive this call until next compute().
Util.arrayFillNonAtomic(scratch, OFF_PAD, BLOCK_LEN, (byte) 0);
return outLen;
}
/**
* Userland HMAC path (J3R180 and any card without native HMAC). Original
* RFC 2104 implementation on top of MessageDigest.ALG_SHA_256, unchanged
* from the pre-refactor version.
*/
private short computeUserland(
byte[] key, short keyOff, short keyLen,
byte[] msg, short msgOff, short msgLen,
byte[] out, short outOff) {
// --- Build K_pad into scratch[OFF_PAD..OFF_PAD+64) --- // --- Build K_pad into scratch[OFF_PAD..OFF_PAD+64) ---
// Zero the 64-byte pad region first so any unused key-tail bytes stay 0. // Zero the 64-byte pad region first so any unused key-tail bytes stay 0.
@@ -82,7 +192,7 @@ final class AliroHmac {
if (msgLen > 0) { if (msgLen > 0) {
sha256.doFinal(msg, msgOff, msgLen, scratch, OFF_INNER); sha256.doFinal(msg, msgOff, msgLen, scratch, OFF_INNER);
} else { } else {
// doFinal with length 0 pass the same buffer as a harmless source. // doFinal with length 0 -- pass the same buffer as a harmless source.
sha256.doFinal(scratch, OFF_PAD, (short) 0, scratch, OFF_INNER); sha256.doFinal(scratch, OFF_PAD, (short) 0, scratch, OFF_INNER);
} }
@@ -98,7 +208,7 @@ final class AliroHmac {
sha256.update(scratch, OFF_PAD, BLOCK_LEN); sha256.update(scratch, OFF_PAD, BLOCK_LEN);
sha256.doFinal(scratch, OFF_INNER, HASH_LEN, out, outOff); sha256.doFinal(scratch, OFF_INNER, HASH_LEN, out, outOff);
// Wipe scratch it held K_pad (derived from the HMAC key) and the // Wipe scratch -- it held K_pad (derived from the HMAC key) and the
// inner digest, both of which would leak key-dependent state to a // inner digest, both of which would leak key-dependent state to a
// later HMAC invocation on a different key otherwise. CLEAR_ON_DESELECT // later HMAC invocation on a different key otherwise. CLEAR_ON_DESELECT
// cleans up at deselect, but multi-APDU sessions could see stale // cleans up at deselect, but multi-APDU sessions could see stale

View File

@@ -0,0 +1,274 @@
package com.dangerousthings.aliro;
import javacard.framework.JCSystem;
import javacard.framework.Util;
import javacard.security.ECPublicKey;
import javacard.security.KeyBuilder;
import javacard.security.Signature;
/**
* Verifies an Aliro IssuerAuth COSE_Sign1 (RFC 9052) over ECDSA-P256 +
* SHA-256. Single use case: at personalization we verify the Access
* Document signature ONCE, cache the verdict (M2A.1's
* {@code accessDocumentVerified} flag), and trust thereafter.
*
* <p>COSE_Sign1 wire format (RFC 9052 §3):
* <pre>
* [ protected_bstr, unprotected_map, payload_bstr, signature_bstr ]
* </pre>
* Sig_structure that was signed (RFC 9052 §4.4):
* <pre>
* [ "Signature1", body_protected, external_aad, payload ]
* </pre>
* Signature is raw {@code r || s} (64 bytes for P-256), not DER —
* the JC {@code Signature.ALG_ECDSA_SHA_256} primitive expects DER, so
* we transcode raw → DER before {@link Signature#verify}.
*
* <p>The CBOR walk uses {@link StructuralCbor#decodeHeader} +
* {@link StructuralCbor#elementSpan} for the 4-element COSE_Sign1 array:
* decode the outer array header, decode each bstr header at elements 0/2/3
* (protected / payload / signature) to capture value offsets, and span-skip
* the unprotected map at element 1. Any malformed input throws
* {@code ISOException(SW_DATA_INVALID)} which the outer try/catch collapses
* to "not verified".
*
* <p>The verifier owns a reusable {@link ECPublicKey} slot, seeded with
* P-256 curve params at construction. Each call calls {@code setW} with
* the caller-supplied uncompressed (0x04 || X || Y) issuer key — no
* per-call allocation.
*/
final class CoseVerifier {
/** Maximum supported COSE_Sign1 payload length. The Aliro Access
* Document's inner COSE payload is ~188 B today; we cap at 256 to
* keep the Sig_structure working buffer inside the J3R452 transient
* pool (~3,120 B) once the other applets' allocations are summed in. */
static final short MAX_PAYLOAD = (short) 256;
/** Maximum supported protected bstr length. Aliro IssuerAuth uses a
* single {alg: ES256} map (3 bytes), but we cap at 32 for slack. */
static final short MAX_PROTECTED = (short) 32;
/** Length of a raw P-256 ECDSA signature: r(32) || s(32). */
private static final short RAW_SIG_LEN = (short) 64;
/** Length of an uncompressed SEC1 P-256 public key: 0x04 || X(32) || Y(32). */
private static final short UNCOMP_PUB_LEN = (short) 65;
/**
* Sig_structure working buffer layout:
* <pre>
* 0x84 array(4)
* 0x6A tstr(10) — "Signature1"
* 53 69 67 6E 61 "Signature1" (10 bytes)
* 74 75 72 65 31
* [protected_bstr CBOR header + bytes]
* 0x40 bstr(0) — external_aad (empty)
* [payload_bstr CBOR header + bytes]
* </pre>
* "Signature1" prefix is fixed; cache it.
*/
private static final byte[] SIG_STRUCT_PREFIX = {
(byte) 0x84, // array(4)
(byte) 0x6A, // tstr(10)
(byte) 0x53, (byte) 0x69, (byte) 0x67, (byte) 0x6E, // "Sign"
(byte) 0x61, (byte) 0x74, (byte) 0x75, (byte) 0x72, // "atur"
(byte) 0x65, (byte) 0x31 // "e1"
};
private final ECPublicKey issuerPubKey;
private final Signature ecdsaVerifier;
/** Transient scratch for building the Sig_structure and DER signature.
* Sized for MAX_PAYLOAD + MAX_PROTECTED + headers + DER overhead. */
private final byte[] scratch;
CoseVerifier() {
issuerPubKey = (ECPublicKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_EC_FP_PUBLIC,
KeyBuilder.LENGTH_EC_FP_256,
false);
Secp256r1Params.seedPublic(issuerPubKey);
ecdsaVerifier = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
// Sig_structure size = prefix(12) + prot_hdr(<=3) + prot_bytes(<=32)
// + ext_aad(1) + pl_hdr(<=3) + pl_bytes(<=256)
// DER sig max = 2 + 2 + 33 + 2 + 33 = 72. Total ~381. Round to 384.
// Was 768 (M2A.2) -- blew the J3R452 transient pool budget once all
// applets' allocations summed in (~3,120 B cap). M2G.2 verdict run
// tripped 0x6FC4 from AliroCrypto.expandScratch.
scratch = JCSystem.makeTransientByteArray(
(short) 384, JCSystem.CLEAR_ON_DESELECT);
}
/**
* Verifies a COSE_Sign1 IssuerAuth blob against the supplied issuer
* public key.
*
* @param coseSign1 buffer holding the CBOR-encoded COSE_Sign1
* @param coseOff offset of the outer array tag (0x84)
* @param coseLen total length of the COSE_Sign1 blob
* @param issuerPubUncomp buffer holding the 65-byte uncompressed SEC1
* pubkey (0x04 || X || Y)
* @param pubOff offset of the 0x04 tag
* @return {@code true} iff the signature verifies; {@code false} on any
* malformed input, mismatched key, or invalid signature.
*/
boolean verifyCoseSign1(
byte[] coseSign1, short coseOff, short coseLen,
byte[] issuerPubUncomp, short pubOff) {
try {
return verifyInternal(coseSign1, coseOff, coseLen, issuerPubUncomp, pubOff);
} catch (Throwable t) {
// Any malformed CBOR, length overrun, crypto exception, etc.
// collapses to "not verified". The personalization caller will
// refuse to install the access document.
return false;
}
}
private boolean verifyInternal(
byte[] coseSign1, short coseOff, short coseLen,
byte[] issuerPubUncomp, short pubOff) {
short p = coseOff;
short remaining = coseLen;
// 4-byte scratch shared across decodeHeader / elementSpan calls.
// Reuse the end of `scratch` so we don't allocate.
final short argOff = (short) (scratch.length - 4);
// Outer array(4): expect major type 4, argument == 4.
short hdr = StructuralCbor.decodeHeader(
coseSign1, p, remaining, scratch, argOff);
if ((short) ((hdr >> 8) & 0x07) != 4) return false;
if (readArg(scratch, argOff) != 4) return false;
short consumed = (short) (hdr & 0xFF);
p += consumed;
remaining -= consumed;
// Element 0: protected_bstr — decode header, capture value off/len.
hdr = StructuralCbor.decodeHeader(
coseSign1, p, remaining, scratch, argOff);
if ((short) ((hdr >> 8) & 0x07) != 2) return false;
short protLen = readArg(scratch, argOff);
if (protLen > MAX_PROTECTED) return false;
consumed = (short) (hdr & 0xFF);
short protValOff = (short) (p + consumed);
if ((short) (consumed + protLen) > remaining) return false;
p += (short) (consumed + protLen);
remaining -= (short) (consumed + protLen);
// Element 1: unprotected_map — span-skip, contents don't enter Sig_structure.
short mapSpan = StructuralCbor.elementSpan(
coseSign1, p, remaining, scratch, argOff);
p += mapSpan;
remaining -= mapSpan;
// Element 2: payload_bstr — decode header, capture value off/len.
hdr = StructuralCbor.decodeHeader(
coseSign1, p, remaining, scratch, argOff);
if ((short) ((hdr >> 8) & 0x07) != 2) return false;
short payloadLen = readArg(scratch, argOff);
if (payloadLen > MAX_PAYLOAD) return false;
consumed = (short) (hdr & 0xFF);
short payloadValOff = (short) (p + consumed);
if ((short) (consumed + payloadLen) > remaining) return false;
p += (short) (consumed + payloadLen);
remaining -= (short) (consumed + payloadLen);
// Element 3: signature_bstr — raw 64-byte P-256 ECDSA (r||s).
hdr = StructuralCbor.decodeHeader(
coseSign1, p, remaining, scratch, argOff);
if ((short) ((hdr >> 8) & 0x07) != 2) return false;
short sigLen = readArg(scratch, argOff);
if (sigLen != RAW_SIG_LEN) return false;
consumed = (short) (hdr & 0xFF);
short sigValOff = (short) (p + consumed);
if ((short) (consumed + sigLen) > remaining) return false;
// Build Sig_structure into scratch.
short s = (short) 0;
Util.arrayCopyNonAtomic(SIG_STRUCT_PREFIX, (short) 0,
scratch, s, (short) SIG_STRUCT_PREFIX.length);
s += (short) SIG_STRUCT_PREFIX.length;
// protected_bstr (re-emit header so it matches input verbatim).
s = writeBstr(coseSign1, protValOff, protLen, scratch, s);
// external_aad = empty bstr (h''): single byte 0x40.
scratch[s++] = (byte) 0x40;
// payload bstr.
s = writeBstr(coseSign1, payloadValOff, payloadLen, scratch, s);
// Place the DER-encoded signature after the Sig_structure.
short derLen = rawSigToDer(coseSign1, sigValOff, scratch, s);
short derOff = s;
// Load issuer pubkey.
issuerPubKey.setW(issuerPubUncomp, pubOff, UNCOMP_PUB_LEN);
ecdsaVerifier.init(issuerPubKey, Signature.MODE_VERIFY);
return ecdsaVerifier.verify(
scratch, (short) 0, s,
scratch, derOff, derLen);
}
/** Reads the 4-byte big-endian argument that
* {@link StructuralCbor#decodeHeader} wrote into the scratch as a short.
* StructuralCbor itself caps argument values at {@code Short.MAX_VALUE}
* before they can reach us, so the high half is guaranteed zero. */
private static short readArg(byte[] buf, short off) {
return (short) (((buf[(short) (off + 2)] & 0xFF) << 8)
| (buf[(short) (off + 3)] & 0xFF));
}
/** Copies {@code len} bytes from {@code src[srcOff..]} into {@code dst}
* prefixed by a freshly-emitted CBOR bstr header. Returns the post-write
* offset into {@code dst}. */
private static short writeBstr(byte[] src, short srcOff, short len,
byte[] dst, short dstOff) {
if (len <= 23) {
dst[dstOff++] = (byte) (0x40 | len);
} else if (len <= 0xFF) {
dst[dstOff++] = (byte) 0x58;
dst[dstOff++] = (byte) len;
} else {
dst[dstOff++] = (byte) 0x59;
dst[dstOff++] = (byte) ((len >> 8) & 0xFF);
dst[dstOff++] = (byte) (len & 0xFF);
}
Util.arrayCopyNonAtomic(src, srcOff, dst, dstOff, len);
return (short) (dstOff + len);
}
/**
* Converts a 64-byte raw ECDSA (r||s) signature into ASN.1 DER:
* {@code SEQUENCE { INTEGER r, INTEGER s }}. Both r and s are written
* without leading-zero stripping, and a 0x00 is prepended if the high
* bit of the first byte is set (to keep the INTEGER positive).
*
* <p>Duplicates {@code AliroApplet.rawSigToDer} verbatim — both
* callers are private and Java Card 1.7 has no facility for a shared
* package-level helper without a separate utility class.
*/
private static short rawSigToDer(byte[] raw, short rawOff,
byte[] out, short outOff) {
boolean rPad = (raw[rawOff] & 0x80) != 0;
boolean sPad = (raw[(short) (rawOff + 32)] & 0x80) != 0;
short rLen = rPad ? (short) 33 : (short) 32;
short sLen = sPad ? (short) 33 : (short) 32;
short contentLen = (short) (2 + rLen + 2 + sLen);
short p = outOff;
out[p++] = (byte) 0x30;
out[p++] = (byte) contentLen;
out[p++] = (byte) 0x02;
out[p++] = (byte) rLen;
if (rPad) out[p++] = (byte) 0x00;
Util.arrayCopyNonAtomic(raw, rawOff, out, p, (short) 32);
p += 32;
out[p++] = (byte) 0x02;
out[p++] = (byte) sLen;
if (sPad) out[p++] = (byte) 0x00;
Util.arrayCopyNonAtomic(raw, (short) (rawOff + 32), out, p, (short) 32);
p += 32;
return (short) (p - outOff);
}
}

View File

@@ -6,8 +6,18 @@ package com.dangerousthings.aliro;
* {@link PersonalizationApplet} writes into it; {@link AliroApplet} reads * {@link PersonalizationApplet} writes into it; {@link AliroApplet} reads
* from it when it needs long-term keys for AUTH1 / step-up. * from it when it needs long-term keys for AUTH1 / step-up.
* *
* <p>Two applets in the same CAP file share this class's static state * <p>Ownership: the single live instance is owned by the
* directly — no {@code Shareable} interface needed. * {@link PersonalizationApplet} instance (created in its constructor via
* {@link #bootstrap()}). The static {@link #INSTANCE} field is just an
* in-package publish-point so AliroApplet/StepUpApplet can find it via
* {@link #get()} without going through a {@code Shareable} SIO.
*
* <p>The on-instance ownership matters for GlobalPlatform Amendment H
* (Executable Load File Upgrade): AMD-H preserves registered applet
* instances and their reachable object graph but wipes static fields. With
* the live reference held on the PersonalizationApplet instance, an upgrade
* keeps enrollment data; PersonalizationApplet's restore hook then calls
* {@link #republish(CredentialStore)} to re-establish the static alias.
* *
* <p>After {@link #commit()} is called, further writes via the * <p>After {@link #commit()} is called, further writes via the
* personalization interface are refused. The only way to re-unlock is * personalization interface are refused. The only way to re-unlock is
@@ -18,13 +28,26 @@ final class CredentialStore {
static final short CRED_PRIV_KEY_LEN = 32; static final short CRED_PRIV_KEY_LEN = 32;
static final short CRED_PUBK_LEN = 64; static final short CRED_PUBK_LEN = 64;
static final short READER_PUBK_LEN = 64; static final short READER_PUBK_LEN = 64;
static final short CRED_ISSUER_PUBK_LEN = 64;
/** Max Access Document blob size (serialized COSE_Sign1 bytes). 1 KB /** Max Access Document blob size (serialized COSE_Sign1 bytes). 1 KB
* accommodates a typical Aliro Access Document with room to spare. */ * accommodates a typical Aliro Access Document with room to spare. */
static final short ACCESS_DOC_MAX_LEN = 1024; static final short ACCESS_DOC_MAX_LEN = 1024;
/** Lazily initialized in {@link #get()}. Java Card bans {@code new} in /** Stable field order for AMD-H Element serialization. Append-only —
* static initializers, so we can't declare {@code = new CredentialStore()}. */ * never reorder or remove without bumping the package version and
* writing an explicit migration step in the new ELF's onRestore.
*
* <p>v3 (M2A.3): adds {@code credentialIssuerPubKey} (64 B x||y) +
* {@code credentialIssuerPubKeySet} flag for IssuerAuth verify at
* finalize. */
static final byte FIELD_VERSION = 3;
/** Publish-point read by AliroApplet/StepUpApplet via {@link #get()}.
* PersonalizationApplet owns the actual instance; this is just an alias
* so other applets in the same package can find it without SIO. The
* reference is re-published after an AMD-H restore (see
* PersonalizationApplet.onRestore). */
private static CredentialStore INSTANCE; private static CredentialStore INSTANCE;
private final byte[] credentialPrivKey; private final byte[] credentialPrivKey;
@@ -36,9 +59,13 @@ final class CredentialStore {
private final byte[] readerPubKey; private final byte[] readerPubKey;
private boolean readerPubKeySet; private boolean readerPubKeySet;
private final byte[] credentialIssuerPubKey;
private boolean credentialIssuerPubKeySet;
private final byte[] accessDocument; private final byte[] accessDocument;
private short accessDocumentLen; private short accessDocumentLen;
private boolean accessDocumentFinalized; private boolean accessDocumentFinalized;
private boolean accessDocumentVerified;
private boolean committed; private boolean committed;
@@ -46,13 +73,25 @@ final class CredentialStore {
credentialPrivKey = new byte[CRED_PRIV_KEY_LEN]; credentialPrivKey = new byte[CRED_PRIV_KEY_LEN];
credentialPubKey = new byte[CRED_PUBK_LEN]; credentialPubKey = new byte[CRED_PUBK_LEN];
readerPubKey = new byte[READER_PUBK_LEN]; readerPubKey = new byte[READER_PUBK_LEN];
credentialIssuerPubKey = new byte[CRED_ISSUER_PUBK_LEN];
accessDocument = new byte[ACCESS_DOC_MAX_LEN]; accessDocument = new byte[ACCESS_DOC_MAX_LEN];
} }
static CredentialStore get() { /** Called once from {@link PersonalizationApplet}'s constructor. */
if (INSTANCE == null) { static CredentialStore bootstrap() {
INSTANCE = new CredentialStore(); INSTANCE = new CredentialStore();
return INSTANCE;
} }
/** Called by {@code PersonalizationApplet.onRestore} to re-publish a
* restored store after an AMD-H Executable Load File upgrade, where
* static fields are wiped but the PersonalizationApplet instance (and
* its CredentialStore reference) survive. */
static void republish(CredentialStore restored) {
INSTANCE = restored;
}
static CredentialStore get() {
return INSTANCE; return INSTANCE;
} }
@@ -130,6 +169,30 @@ final class CredentialStore {
return (short) 32; return (short) 32;
} }
boolean hasCredentialIssuerPubKey() {
return credentialIssuerPubKeySet;
}
/** Writes the 64-byte x||y issuer public key (no 0x04 prefix). */
void setCredentialIssuerPubKey(byte[] src, short off) {
javacard.framework.Util.arrayCopyNonAtomic(
src, off, credentialIssuerPubKey, (short) 0, CRED_ISSUER_PUBK_LEN);
credentialIssuerPubKeySet = true;
}
/**
* Emits the credential issuer public key as 65-byte SEC1 uncompressed
* point (0x04 || X || Y) into {@code dst[dstOff..dstOff+65)}. This is
* the shape {@link CoseVerifier#verifyCoseSign1} expects for its issuer
* pubkey parameter.
*/
short copyCredentialIssuerPubKeyUncomp(byte[] dst, short dstOff) {
dst[dstOff] = (byte) 0x04;
javacard.framework.Util.arrayCopyNonAtomic(
credentialIssuerPubKey, (short) 0, dst, (short) (dstOff + 1), CRED_ISSUER_PUBK_LEN);
return (short) (CRED_ISSUER_PUBK_LEN + 1);
}
/** /**
* Stores an Access Document chunk at the given destination offset. * Stores an Access Document chunk at the given destination offset.
* Returns true iff the write stayed within {@link #ACCESS_DOC_MAX_LEN}; * Returns true iff the write stayed within {@link #ACCESS_DOC_MAX_LEN};
@@ -144,16 +207,60 @@ final class CredentialStore {
} }
/** /**
* Marks the Access Document as provisioned with {@code totalLen} bytes * Finalizes the Access Document: runs the supplied {@link CoseVerifier}
* of valid content starting at offset 0. Returns false (and does not * against the staged AD bytes using the stored Credential Issuer public
* mutate state) if {@code totalLen} is outside {@code [0, ACCESS_DOC_MAX_LEN]}. * key. On success, atomically sets {@code accessDocumentLen},
* {@code accessDocumentFinalized}, and {@code accessDocumentVerified}
* inside a {@link javacard.framework.JCSystem#beginTransaction} so
* partial state can never ship.
*
* <p>Pre-conditions enforced by the caller (PersonalizationApplet):
* <ul>
* <li>The issuer pubkey must be set ({@link #hasCredentialIssuerPubKey}) —
* callers that omit this get SW_CONDITIONS_NOT_SATISFIED.</li>
* </ul>
*
* <p>This is the M2A.3 expansion of opt 4a from the Step-Up plan: caching
* the verify result saves ~100 ms per Step-Up transaction. The
* verify-flag write and the {@code accessDocumentFinalized} flip land in
* the same atomic transaction so partial state can't ship an unverified
* document marked verified.
*
* @param totalLen length of staged AD bytes at {@code accessDocument[0..)}
* @param verifier IssuerAuth verifier (held as a field on
* PersonalizationApplet; never per-call constructed)
* @param scratch65 caller-supplied 65 B scratch into which this method
* writes the uncompressed issuer pubkey before handing
* it to {@code verifier.verifyCoseSign1}
* @return true iff verify succeeded AND state was atomically committed;
* false on bad length OR verify failure (state untouched).
*/ */
boolean finalizeAccessDocument(short totalLen) { boolean finalizeAccessDocument(short totalLen, CoseVerifier verifier, byte[] scratch65) {
if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) { if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) {
return false; return false;
} }
// Load the issuer pubkey into the caller-supplied scratch as
// 0x04 || x || y (CoseVerifier needs SEC1 uncompressed).
copyCredentialIssuerPubKeyUncomp(scratch65, (short) 0);
boolean verified = verifier.verifyCoseSign1(
accessDocument, (short) 0, totalLen, scratch65, (short) 0);
if (!verified) {
return false;
}
try {
javacard.framework.JCSystem.beginTransaction();
accessDocumentLen = totalLen; accessDocumentLen = totalLen;
accessDocumentFinalized = true; accessDocumentFinalized = true;
accessDocumentVerified = true;
javacard.framework.JCSystem.commitTransaction();
} catch (Throwable t) {
if (javacard.framework.JCSystem.getTransactionDepth() != 0) {
javacard.framework.JCSystem.abortTransaction();
}
return false;
}
return true; return true;
} }
@@ -161,6 +268,21 @@ final class CredentialStore {
return accessDocumentFinalized; return accessDocumentFinalized;
} }
/**
* Test-only: forces the verified flag without running the IssuerAuth
* COSE_Sign1 verify. Used by tests that exercise downstream behavior
* (signaling bitmap, serialization round-trip) with opaque AD bytes that
* aren't signed by a real issuer key. Production code reaches the verified
* state via {@link #finalizeAccessDocument(short, CoseVerifier, byte[])}.
*/
void markAccessDocumentVerifiedForTesting() {
accessDocumentVerified = true;
}
boolean isAccessDocumentVerified() {
return accessDocumentVerified;
}
short getAccessDocumentLen() { short getAccessDocumentLen() {
return accessDocumentLen; return accessDocumentLen;
} }
@@ -170,6 +292,105 @@ final class CredentialStore {
return len; return len;
} }
/**
* Writes the full credential store state to the given sink in
* {@link #FIELD_VERSION} layout. The field order is locked — see the
* FIELD_VERSION javadoc.
*/
void writeTo(SerializationSink sink) {
sink.write(FIELD_VERSION);
sink.write(committed);
sink.write(credentialPrivKeySet);
sink.write(credentialPubKeySet);
sink.write(readerPubKeySet);
sink.write(credentialIssuerPubKeySet);
sink.write(accessDocumentFinalized);
sink.write(accessDocumentVerified);
sink.write(accessDocumentLen);
sink.write(credentialPrivKey);
sink.write(credentialPubKey);
sink.write(readerPubKey);
sink.write(credentialIssuerPubKey);
sink.write(accessDocument);
}
/**
* Reads a full credential store snapshot from the given source written
* by {@link #writeTo(SerializationSink)}.
*/
static CredentialStore readFrom(SerializationSource src) {
byte v = src.readByte();
if (v != FIELD_VERSION) {
javacard.framework.ISOException.throwIt(
javacard.framework.ISO7816.SW_DATA_INVALID);
}
CredentialStore s = new CredentialStore();
s.committed = src.readBoolean();
s.credentialPrivKeySet = src.readBoolean();
s.credentialPubKeySet = src.readBoolean();
s.readerPubKeySet = src.readBoolean();
s.credentialIssuerPubKeySet = src.readBoolean();
s.accessDocumentFinalized = src.readBoolean();
s.accessDocumentVerified = src.readBoolean();
s.accessDocumentLen = src.readShort();
byte[] a;
a = src.readByteArray();
javacard.framework.Util.arrayCopy(a, (short) 0, s.credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN);
a = src.readByteArray();
javacard.framework.Util.arrayCopy(a, (short) 0, s.credentialPubKey, (short) 0, CRED_PUBK_LEN);
a = src.readByteArray();
javacard.framework.Util.arrayCopy(a, (short) 0, s.readerPubKey, (short) 0, READER_PUBK_LEN);
a = src.readByteArray();
javacard.framework.Util.arrayCopy(a, (short) 0, s.credentialIssuerPubKey, (short) 0, CRED_ISSUER_PUBK_LEN);
a = src.readByteArray();
javacard.framework.Util.arrayCopy(a, (short) 0, s.accessDocument, (short) 0, ACCESS_DOC_MAX_LEN);
return s;
}
/**
* Test-only: marks the staged AD bytes as finalized without running the
* IssuerAuth verify. Used by AliroApplet tests that exercise downstream
* behavior (signaling bitmap etc.) with opaque AD bytes that aren't
* signed by a real issuer key.
*/
void markAccessDocumentFinalizedForTesting(short totalLen) {
accessDocumentLen = totalLen;
accessDocumentFinalized = true;
}
/** Test-only: returns a fresh byte[] copy of the credential private key. */
byte[] copyCredentialPrivKey() {
byte[] out = new byte[CRED_PRIV_KEY_LEN];
javacard.framework.Util.arrayCopyNonAtomic(
credentialPrivKey, (short) 0, out, (short) 0, CRED_PRIV_KEY_LEN);
return out;
}
/** Test-only: returns a fresh byte[] copy of the credential public key. */
byte[] copyCredentialPubKey() {
byte[] out = new byte[CRED_PUBK_LEN];
javacard.framework.Util.arrayCopyNonAtomic(
credentialPubKey, (short) 0, out, (short) 0, CRED_PUBK_LEN);
return out;
}
/** Test-only: returns a fresh byte[] copy of the reader public key. */
byte[] copyReaderPubKey() {
byte[] out = new byte[READER_PUBK_LEN];
javacard.framework.Util.arrayCopyNonAtomic(
readerPubKey, (short) 0, out, (short) 0, READER_PUBK_LEN);
return out;
}
/** Test-only: returns a fresh byte[] copy of the active Access Document
* bytes (length = {@link #accessDocumentLen}, not the full buffer). */
byte[] copyAccessDocument() {
byte[] out = new byte[accessDocumentLen];
javacard.framework.Util.arrayCopyNonAtomic(
accessDocument, (short) 0, out, (short) 0, accessDocumentLen);
return out;
}
/** /**
* Test-only hook. The store is a static singleton, which makes unit * Test-only hook. The store is a static singleton, which makes unit
* tests interfere with each other unless each test starts from a * tests interfere with each other unless each test starts from a
@@ -179,12 +400,15 @@ final class CredentialStore {
javacard.framework.Util.arrayFillNonAtomic(credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN, (byte) 0); javacard.framework.Util.arrayFillNonAtomic(credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN, (byte) 0);
javacard.framework.Util.arrayFillNonAtomic(credentialPubKey, (short) 0, CRED_PUBK_LEN, (byte) 0); javacard.framework.Util.arrayFillNonAtomic(credentialPubKey, (short) 0, CRED_PUBK_LEN, (byte) 0);
javacard.framework.Util.arrayFillNonAtomic(readerPubKey, (short) 0, READER_PUBK_LEN, (byte) 0); javacard.framework.Util.arrayFillNonAtomic(readerPubKey, (short) 0, READER_PUBK_LEN, (byte) 0);
javacard.framework.Util.arrayFillNonAtomic(credentialIssuerPubKey, (short) 0, CRED_ISSUER_PUBK_LEN, (byte) 0);
javacard.framework.Util.arrayFillNonAtomic(accessDocument, (short) 0, ACCESS_DOC_MAX_LEN, (byte) 0); javacard.framework.Util.arrayFillNonAtomic(accessDocument, (short) 0, ACCESS_DOC_MAX_LEN, (byte) 0);
credentialPrivKeySet = false; credentialPrivKeySet = false;
credentialPubKeySet = false; credentialPubKeySet = false;
readerPubKeySet = false; readerPubKeySet = false;
credentialIssuerPubKeySet = false;
accessDocumentLen = 0; accessDocumentLen = 0;
accessDocumentFinalized = false; accessDocumentFinalized = false;
accessDocumentVerified = false;
committed = false; committed = false;
} }
} }

View File

@@ -0,0 +1,45 @@
package com.dangerousthings.aliro;
/**
* Lazy holders for crypto objects shared between {@link AliroApplet} and
* {@link StepUpApplet}. Both applets live in the same package and only one
* is selected at a time, so the shared instances are safe — Java Card runs
* one applet at a time and the JCRE serializes APDU dispatch.
*
* <p>Java Card forbids {@code new} in {@code <clinit>}, so the getter lazy-
* creates on first call. Subsequent calls return the cached instance. The
* combined transient footprint of one shared {@link AliroCrypto} is roughly
* 352 B (kdfWorkbuf 64 + hkdfPrevT 32 + expandScratch 256); without this
* sharing each applet would allocate its own.
*/
final class CryptoSingletons {
private static AliroCrypto aliroCrypto;
private static AliroGcm aliroGcm;
private CryptoSingletons() { }
/** Returns the process-wide {@link AliroCrypto} instance. Allocates on
* first call; cheap thereafter. */
static AliroCrypto getAliroCrypto() {
if (aliroCrypto == null) {
aliroCrypto = new AliroCrypto();
}
return aliroCrypto;
}
/** Returns the process-wide {@link AliroGcm} instance. Same Java-Card
* {@code <clinit>}-cannot-{@code new} rationale as {@link #getAliroCrypto()}:
* lazy-allocate on first call so both {@link AliroApplet} and
* {@link StepUpApplet} share one userland-GCM machine. Each shared
* instance saves ~370 B of transient/EEPROM footprint that a per-applet
* duplicate would otherwise pay. The two applets are never selected
* simultaneously and the JCRE serializes APDU dispatch, so the shared
* scratch and {@code AESKey} slot don't race. */
static AliroGcm getAliroGcm() {
if (aliroGcm == null) {
aliroGcm = new AliroGcm();
}
return aliroGcm;
}
}

View File

@@ -0,0 +1,317 @@
package com.dangerousthings.aliro;
import javacard.framework.ISO7816;
import javacard.framework.ISOException;
import javacard.framework.Util;
/**
* Structural validator for the inbound mdoc DeviceRequest carried in M2's
* Step-Up ENVELOPE chain (ISO 18013-5 §8.3.2.1):
*
* <pre>
* DeviceRequest = {
* "version": tstr, ; must be "1.0"
* "docRequests": [+ DocRequest]
* }
*
* DocRequest = {
* "itemsRequest": bstr, ; encoded ItemsRequest — opaque to us
* ? "readerAuth": COSE_Sign1
* }
* </pre>
*
* <p>Aliro's M2 reader profile asks for a fixed Access Document; we don't
* interpret docType / nameSpaces / readerAuth. The job here is to lock the
* wire shape so malformed input fails fast with the right SW instead of being
* silently swallowed.
*
* <p>Validation policy:
* <ul>
* <li>Top-level must be a CBOR map (major type 5).</li>
* <li>Recognized keys: {@code "version"} (tstr) and {@code "docRequests"}
* (array). Both required. Any other top-level key is skipped via
* {@link StructuralCbor#elementSpan} for forward compatibility.</li>
* <li>{@code "version"} value must equal {@code "1.0"} —
* {@code SW_CONDITIONS_NOT_SATISFIED (0x6985)} otherwise.</li>
* <li>{@code "docRequests"} must be a non-empty array; each entry must be
* a map containing an {@code "itemsRequest"} bstr. Other entry keys
* (e.g. {@code "readerAuth"}) skip via {@code elementSpan}.</li>
* <li>Anything else — wrong major type, missing required key, truncated
* input, unknown CBOR header form — propagates as
* {@code SW_DATA_INVALID (0x6984)} via {@link StructuralCbor}.</li>
* </ul>
*
* <p>Allocation-free: the caller passes the 4-byte scratch used by
* {@link StructuralCbor#decodeHeader}'s argument output. M2D.3 will wire
* {@link StepUpApplet} to call this before discarding the request body and
* returning the single Access Document we hold.
*/
final class DeviceRequestParser {
// ASCII bytes for CBOR tstr keys / values. Using literal arrays keeps the
// parser allocation-free and avoids any UTF-8 encoder dependency on-card.
private static final byte[] KEY_VERSION =
{ 'v', 'e', 'r', 's', 'i', 'o', 'n' };
private static final byte[] KEY_DOC_REQUESTS =
{ 'd', 'o', 'c', 'R', 'e', 'q', 'u', 'e', 's', 't', 's' };
private static final byte[] KEY_ITEMS_REQUEST =
{ 'i', 't', 'e', 'm', 's', 'R', 'e', 'q', 'u', 'e', 's', 't' };
private static final byte[] VERSION_1_0 = { '1', '.', '0' };
private static final short MAJOR_BSTR = 2;
private static final short MAJOR_TSTR = 3;
private static final short MAJOR_ARRAY = 4;
private static final short MAJOR_MAP = 5;
private DeviceRequestParser() {
// Utility class — no instances.
}
/**
* Structurally validates an mdoc DeviceRequest at
* {@code buf[off..off+len)}. Returns normally iff the request is
* shape-valid for M2's "return the only Access Document we have"
* behavior — no value is returned because no information from the
* request body affects the response.
*
* @param buf buffer holding the encoded DeviceRequest
* @param off offset of the first CBOR byte
* @param len length of the request in bytes
* @param scratch4 4-byte scratch for {@link StructuralCbor#decodeHeader}
* @param scratch4Off offset within {@code scratch4}
* @throws ISOException SW_CONDITIONS_NOT_SATISFIED (0x6985) on unsupported
* version; SW_DATA_INVALID (0x6984) on malformed CBOR
* or wrong shape (incl. missing required keys).
*/
static void validate(
byte[] buf, short off, short len,
byte[] scratch4, short scratch4Off) {
// Top-level header: must be a map.
short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off);
short major = (short) ((header >> 8) & 0x07);
short consumed = (short) (header & 0xFF);
if (major != MAJOR_MAP) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short entries = readArgAsShort(scratch4, scratch4Off);
short cursor = (short) (off + consumed);
short remaining = (short) (len - consumed);
boolean sawVersion = false;
boolean sawDocRequests = false;
for (short i = 0; i < entries; i++) {
// --- Key ---
short keyHeader = StructuralCbor.decodeHeader(
buf, cursor, remaining, scratch4, scratch4Off);
short keyMajor = (short) ((keyHeader >> 8) & 0x07);
short keyConsumed = (short) (keyHeader & 0xFF);
short keyLen = readArgAsShort(scratch4, scratch4Off);
short keyBodyOff = (short) (cursor + keyConsumed);
short keyTotal = (short) (keyConsumed + keyLen);
if (keyMajor != MAJOR_TSTR || keyTotal > remaining) {
// Non-string keys are out of spec for DeviceRequest; treat as
// malformed. (DeviceRequest is a string-keyed map.)
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
cursor = (short) (cursor + keyTotal);
remaining = (short) (remaining - keyTotal);
// --- Value: dispatch on the key string ---
if (matches(buf, keyBodyOff, keyLen, KEY_VERSION)) {
short valConsumed = validateVersionValue(
buf, cursor, remaining, scratch4, scratch4Off);
cursor = (short) (cursor + valConsumed);
remaining = (short) (remaining - valConsumed);
sawVersion = true;
} else if (matches(buf, keyBodyOff, keyLen, KEY_DOC_REQUESTS)) {
short valConsumed = validateDocRequestsValue(
buf, cursor, remaining, scratch4, scratch4Off);
cursor = (short) (cursor + valConsumed);
remaining = (short) (remaining - valConsumed);
sawDocRequests = true;
} else {
// Unknown top-level key — skip its value for
// forward-compatibility with spec additions.
short skip = StructuralCbor.elementSpan(
buf, cursor, remaining, scratch4, scratch4Off);
cursor = (short) (cursor + skip);
remaining = (short) (remaining - skip);
}
}
if (!sawVersion || !sawDocRequests) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
}
/**
* Validates the value associated with the {@code "version"} key: must be
* a tstr equal to {@code "1.0"}. Returns the number of bytes consumed by
* the value (header + payload). Mismatched value → SW_CONDITIONS_NOT_SATISFIED.
*/
private static short validateVersionValue(
byte[] buf, short off, short len,
byte[] scratch4, short scratch4Off) {
short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off);
short major = (short) ((header >> 8) & 0x07);
short consumed = (short) (header & 0xFF);
short payload = readArgAsShort(scratch4, scratch4Off);
if (major != MAJOR_TSTR) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short total = (short) (consumed + payload);
if (total > len) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
if (!matches(buf, (short) (off + consumed), payload, VERSION_1_0)) {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
return total;
}
/**
* Validates the value associated with the {@code "docRequests"} key: must
* be a non-empty array where every entry is a map containing an
* {@code "itemsRequest"} bstr. Returns total bytes consumed.
*/
private static short validateDocRequestsValue(
byte[] buf, short off, short len,
byte[] scratch4, short scratch4Off) {
short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off);
short major = (short) ((header >> 8) & 0x07);
short consumed = (short) (header & 0xFF);
short count = readArgAsShort(scratch4, scratch4Off);
if (major != MAJOR_ARRAY || count < 1) {
// Empty docRequests array is illegal per the [+ DocRequest]
// one-or-more CDDL marker.
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short cursor = (short) (off + consumed);
short remaining = (short) (len - consumed);
short total = consumed;
for (short i = 0; i < count; i++) {
short entrySpan = validateDocRequestEntry(
buf, cursor, remaining, scratch4, scratch4Off);
cursor = (short) (cursor + entrySpan);
remaining = (short) (remaining - entrySpan);
total = (short) (total + entrySpan);
}
return total;
}
/**
* Validates a single DocRequest map. Walks its keys; on
* {@code "itemsRequest"} asserts the value is a bstr (contents opaque),
* other keys (e.g. {@code "readerAuth"}) skip via {@code elementSpan}.
* Returns the total bytes the entry occupies.
*/
private static short validateDocRequestEntry(
byte[] buf, short off, short len,
byte[] scratch4, short scratch4Off) {
short header = StructuralCbor.decodeHeader(buf, off, len, scratch4, scratch4Off);
short major = (short) ((header >> 8) & 0x07);
short consumed = (short) (header & 0xFF);
short entries = readArgAsShort(scratch4, scratch4Off);
if (major != MAJOR_MAP) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short cursor = (short) (off + consumed);
short remaining = (short) (len - consumed);
short total = consumed;
boolean sawItemsRequest = false;
for (short i = 0; i < entries; i++) {
// Key (must be tstr per DeviceRequest CDDL).
short keyHeader = StructuralCbor.decodeHeader(
buf, cursor, remaining, scratch4, scratch4Off);
short keyMajor = (short) ((keyHeader >> 8) & 0x07);
short keyConsumed = (short) (keyHeader & 0xFF);
short keyLen = readArgAsShort(scratch4, scratch4Off);
short keyBodyOff = (short) (cursor + keyConsumed);
short keyTotal = (short) (keyConsumed + keyLen);
if (keyMajor != MAJOR_TSTR || keyTotal > remaining) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
cursor = (short) (cursor + keyTotal);
remaining = (short) (remaining - keyTotal);
total = (short) (total + keyTotal);
if (matches(buf, keyBodyOff, keyLen, KEY_ITEMS_REQUEST)) {
// Value must be a bstr — contents are an opaque encoded
// ItemsRequest we deliberately do not decode.
short vHeader = StructuralCbor.decodeHeader(
buf, cursor, remaining, scratch4, scratch4Off);
short vMajor = (short) ((vHeader >> 8) & 0x07);
short vConsumed = (short) (vHeader & 0xFF);
short vLen = readArgAsShort(scratch4, scratch4Off);
short vTotal = (short) (vConsumed + vLen);
if (vMajor != MAJOR_BSTR || vTotal > remaining) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
cursor = (short) (cursor + vTotal);
remaining = (short) (remaining - vTotal);
total = (short) (total + vTotal);
sawItemsRequest = true;
} else {
// Skip unrecognized key's value (e.g. readerAuth COSE_Sign1).
short skip = StructuralCbor.elementSpan(
buf, cursor, remaining, scratch4, scratch4Off);
cursor = (short) (cursor + skip);
remaining = (short) (remaining - skip);
total = (short) (total + skip);
}
}
if (!sawItemsRequest) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
return total;
}
/**
* Constant-shape ASCII string compare: returns true iff
* {@code buf[off..off+len) == expected[0..expected.length)}. Used to match
* CBOR tstr keys / values against the literal byte arrays at the top of
* this file.
*/
private static boolean matches(byte[] buf, short off, short len, byte[] expected) {
if (len != (short) expected.length) {
return false;
}
return Util.arrayCompare(buf, off, expected, (short) 0, len) == 0;
}
/**
* Reads the 4-byte big-endian argument {@link StructuralCbor#decodeHeader}
* wrote into {@code scratch4} and returns it as a short. Values > 0x7FFF
* are rejected as malformed input — Aliro mdoc payloads fit in a
* short-bounded buffer, mirroring StructuralCbor's own bound.
*/
private static short readArgAsShort(byte[] scratch, short off) {
short hi = (short) (((scratch[off] & 0xFF) << 8)
| (scratch[(short) (off + 1)] & 0xFF));
if (hi != 0) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short v = (short) (((scratch[(short) (off + 2)] & 0xFF) << 8)
| (scratch[(short) (off + 3)] & 0xFF));
if (v < 0) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
return v;
}
}

View File

@@ -0,0 +1,164 @@
package com.dangerousthings.aliro;
import javacard.framework.Util;
/**
* Builds the mdoc DeviceResponse (ISO 18013-5 §8.3.2.1.2.2 / Aliro Table 8-22)
* carrying the cached Access Document at
* {@code documents[0].issuerSigned.issuerAuth}.
*
* <p>The fixed shape (Aliro Step-Up only ever returns a single mDL document
* with empty nameSpaces and no deviceSigned):
* <pre>
* DeviceResponse = {
* "status": 0,
* "version": "1.0",
* "documents": [
* {
* "docType": "org.iso.18013.5.1.mDL",
* "issuerSigned": {
* "issuerAuth": &lt;Access Document bytes verbatim&gt;,
* "nameSpaces": {}
* }
* }
* ]
* }
* </pre>
*
* <p>Map keys are canonical-CBOR sorted (RFC 8949 §4.2.1: encoded-key-length
* then lexicographic). Both 10-char issuerSigned keys tie on length; 'i' &lt; 'n'
* so issuerAuth precedes nameSpaces.
*
* <p>The Access Document is embedded VERBATIM via
* {@link Util#arrayCopyNonAtomic} — it was verified as a 4-element COSE_Sign1
* at personalization (see {@code CoseVerifier}), so the applet trusts its
* encoding and must not re-encode it (re-encoding could alter the very bytes
* the issuer signed over).
*
* <p>All map / array headers are single-byte for our fixed counts
* ({@code map(0)=0xA0, map(2)=0xA2, map(3)=0xA3, array(1)=0x81}) and inlined
* — extending {@link StructuralCbor} with encodeMapHeader / encodeArrayHeader
* would add a static helper used only here. Tstr key/value bodies go through
* {@link StructuralCbor#encodeTstrHeader} so the header / payload split stays
* canonical-CBOR-correct (1-byte header for these tiny strings, but the
* encoder picks the right size on its own).
*
* @see CoseVerifier for the personalization-time AD shape check this builder
* relies on.
*/
final class DeviceResponseBuilder {
// Canonical CBOR map / array headers used by the fixed Aliro DeviceResponse
// shape. All entry counts are < 24, so each fits in the immediate
// 1-byte form (major type bits 7..5, additional info bits 4..0).
private static final byte CBOR_MAP_3 = (byte) 0xA3; // map, 3 entries
private static final byte CBOR_MAP_2 = (byte) 0xA2; // map, 2 entries
private static final byte CBOR_MAP_0 = (byte) 0xA0; // map, 0 entries (empty)
private static final byte CBOR_ARRAY_1 = (byte) 0x81; // array, 1 element
// Top-level DeviceResponse keys, sorted canonical (length-then-lex).
private static final byte[] KEY_STATUS = { 's', 't', 'a', 't', 'u', 's' };
private static final byte[] KEY_VERSION = { 'v', 'e', 'r', 's', 'i', 'o', 'n' };
private static final byte[] KEY_DOCUMENTS = { 'd', 'o', 'c', 'u', 'm', 'e', 'n', 't', 's' };
// Document entry keys.
private static final byte[] KEY_DOC_TYPE = { 'd', 'o', 'c', 'T', 'y', 'p', 'e' };
private static final byte[] KEY_ISSUER_SIGNED = {
'i', 's', 's', 'u', 'e', 'r', 'S', 'i', 'g', 'n', 'e', 'd' };
// issuerSigned entry keys — issuerAuth before nameSpaces ('i' < 'n').
private static final byte[] KEY_ISSUER_AUTH = {
'i', 's', 's', 'u', 'e', 'r', 'A', 'u', 't', 'h' };
private static final byte[] KEY_NAME_SPACES = {
'n', 'a', 'm', 'e', 'S', 'p', 'a', 'c', 'e', 's' };
// Constants for the only docType Aliro Step-Up ever emits.
private static final byte[] VAL_VERSION = { '1', '.', '0' };
private static final byte[] VAL_MDL_DOC_TYPE = {
'o', 'r', 'g', '.', 'i', 's', 'o', '.', '1', '8', '0', '1', '3',
'.', '5', '.', '1', '.', 'm', 'D', 'L' };
private DeviceResponseBuilder() {
// Utility class — no instances.
}
/**
* Builds a canonical-CBOR DeviceResponse carrying {@code ad} at
* {@code documents[0].issuerSigned.issuerAuth}. Writes into {@code out}
* starting at {@code outOff} and returns the total bytes written.
*
* <p>For a 272-byte AD the output is 372 bytes (100 B wrapper); the
* caller should provision {@code out} with at least {@code adLen + 128}
* bytes for headroom against future shape tweaks. This builder is
* allocation-free and side-effect-free; the only state it touches is
* the slice {@code out[outOff..outOff+return)}.
*
* @param ad Access Document buffer (treated as a single CBOR blob,
* embedded verbatim — caller has already validated shape)
* @param adOff AD start offset
* @param adLen AD length
* @param out output buffer
* @param outOff output start offset
* @return total bytes written
*/
static short build(
byte[] ad, short adOff, short adLen,
byte[] out, short outOff) {
short p = outOff;
// DeviceResponse map header — 3 entries: status, version, documents.
out[p++] = CBOR_MAP_3;
// "status": 0
p += StructuralCbor.encodeTstrHeader((short) KEY_STATUS.length, out, p);
p = arrayCopy(KEY_STATUS, out, p);
p += StructuralCbor.encodeUint(0, out, p);
// "version": "1.0"
p += StructuralCbor.encodeTstrHeader((short) KEY_VERSION.length, out, p);
p = arrayCopy(KEY_VERSION, out, p);
p += StructuralCbor.encodeTstrHeader((short) VAL_VERSION.length, out, p);
p = arrayCopy(VAL_VERSION, out, p);
// "documents": [ <document> ]
p += StructuralCbor.encodeTstrHeader((short) KEY_DOCUMENTS.length, out, p);
p = arrayCopy(KEY_DOCUMENTS, out, p);
out[p++] = CBOR_ARRAY_1;
// document map header — 2 entries: docType, issuerSigned.
out[p++] = CBOR_MAP_2;
// "docType": "org.iso.18013.5.1.mDL"
p += StructuralCbor.encodeTstrHeader((short) KEY_DOC_TYPE.length, out, p);
p = arrayCopy(KEY_DOC_TYPE, out, p);
p += StructuralCbor.encodeTstrHeader((short) VAL_MDL_DOC_TYPE.length, out, p);
p = arrayCopy(VAL_MDL_DOC_TYPE, out, p);
// "issuerSigned": { ... }
p += StructuralCbor.encodeTstrHeader((short) KEY_ISSUER_SIGNED.length, out, p);
p = arrayCopy(KEY_ISSUER_SIGNED, out, p);
// issuerSigned map header — 2 entries: issuerAuth, nameSpaces.
out[p++] = CBOR_MAP_2;
// "issuerAuth": <AD bytes verbatim>
p += StructuralCbor.encodeTstrHeader((short) KEY_ISSUER_AUTH.length, out, p);
p = arrayCopy(KEY_ISSUER_AUTH, out, p);
Util.arrayCopyNonAtomic(ad, adOff, out, p, adLen);
p = (short) (p + adLen);
// "nameSpaces": {}
p += StructuralCbor.encodeTstrHeader((short) KEY_NAME_SPACES.length, out, p);
p = arrayCopy(KEY_NAME_SPACES, out, p);
out[p++] = CBOR_MAP_0;
return (short) (p - outOff);
}
/** Copy a small constant byte[] into {@code out[off..)} and return the new write cursor. */
private static short arrayCopy(byte[] src, byte[] out, short off) {
Util.arrayCopyNonAtomic(src, (short) 0, out, off, (short) src.length);
return (short) (off + src.length);
}
}

View File

@@ -24,8 +24,36 @@ public class PersonalizationApplet extends Applet {
private static final byte INS_SET_READER_PUBK = (byte) 0x22; private static final byte INS_SET_READER_PUBK = (byte) 0x22;
private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23; private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23;
private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24; private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24;
private static final byte INS_SET_CREDENTIAL_ISSUER_PUBK = (byte) 0x25;
private static final byte INS_COMMIT = (byte) 0x2C; private static final byte INS_COMMIT = (byte) 0x2C;
/** The owning reference to the shared CredentialStore. AliroApplet and
* StepUpApplet reach it via the {@link CredentialStore#get()} static
* publish-point; we keep the actual reference on this applet instance
* so AMD-H Executable Load File upgrades (which wipe static fields but
* preserve registered applet instances and their reachable object
* state) don't lose enrollment data. */
private final CredentialStore store;
/** One-shot IssuerAuth COSE_Sign1 verifier — constructed at install so
* the 768 B CLEAR_ON_DESELECT transient scratch is allocated once.
* Reconstructing it on every finalize would eventually exhaust the
* transient pool on real hardware. */
private final CoseVerifier issuerVerifier;
/** Persistent 65 B scratch the issuer pubkey is rebuilt into before
* every {@link CoseVerifier#verifyCoseSign1} call. Held on the applet
* instance so it survives AMD-H upgrades — we'd rather pay the 65 B
* of EEPROM than risk transient-pool exhaustion or per-finalize
* allocation. */
private final byte[] issuerPubScratch;
public PersonalizationApplet() {
this.store = CredentialStore.bootstrap();
this.issuerVerifier = new CoseVerifier();
this.issuerPubScratch = new byte[65];
}
public static void install(byte[] bArray, short bOffset, byte bLength) { public static void install(byte[] bArray, short bOffset, byte bLength) {
PersonalizationApplet applet = new PersonalizationApplet(); PersonalizationApplet applet = new PersonalizationApplet();
if (bArray == null || bLength == 0) { if (bArray == null || bLength == 0) {
@@ -46,7 +74,9 @@ public class PersonalizationApplet extends Applet {
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED); ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
} }
CredentialStore store = CredentialStore.get(); // Use the instance-owned reference rather than the static publish-point
// so this applet keeps working even before/around an AMD-H restore.
CredentialStore store = this.store;
byte ins = buf[ISO7816.OFFSET_INS]; byte ins = buf[ISO7816.OFFSET_INS];
switch (ins) { switch (ins) {
@@ -62,6 +92,10 @@ public class PersonalizationApplet extends Applet {
requireUnlocked(store); requireUnlocked(store);
setReaderPubKey(apdu, store); setReaderPubKey(apdu, store);
return; return;
case INS_SET_CREDENTIAL_ISSUER_PUBK:
requireUnlocked(store);
setCredentialIssuerPubKey(apdu, store);
return;
case INS_WRITE_ACCESS_DOC: case INS_WRITE_ACCESS_DOC:
requireUnlocked(store); requireUnlocked(store);
writeAccessDocChunk(apdu, store); writeAccessDocChunk(apdu, store);
@@ -112,6 +146,15 @@ public class PersonalizationApplet extends Applet {
store.setReaderPubKey(buf, apdu.getOffsetCdata()); store.setReaderPubKey(buf, apdu.getOffsetCdata());
} }
private void setCredentialIssuerPubKey(APDU apdu, CredentialStore store) {
short lc = apdu.setIncomingAndReceive();
if (lc != CredentialStore.CRED_ISSUER_PUBK_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
byte[] buf = apdu.getBuffer();
store.setCredentialIssuerPubKey(buf, apdu.getOffsetCdata());
}
/** /**
* Writes an Access Document chunk. P1|P2 is the destination offset * Writes an Access Document chunk. P1|P2 is the destination offset
* within the stored Access Document (big-endian unsigned 16-bit); the * within the stored Access Document (big-endian unsigned 16-bit); the
@@ -130,19 +173,36 @@ public class PersonalizationApplet extends Applet {
} }
/** /**
* Marks the Access Document as provisioned. P1|P2 is the total length * Finalizes the Access Document: triggers a one-shot IssuerAuth verify
* (big-endian unsigned 16-bit). Lc must be 0. * against the stored Credential Issuer pubkey and atomically flips
* {@code accessDocumentFinalized} + {@code accessDocumentVerified} on
* success. P1|P2 is the total length (big-endian unsigned 16-bit).
* Lc must be 0.
*
* <p>Pre-conditions: the issuer pubkey must have been set first
* (INS_SET_CREDENTIAL_ISSUER_PUBK = 0x25); without it we have no trust
* anchor and reject with {@code SW_CONDITIONS_NOT_SATISFIED}. A verify
* failure (signature didn't match) returns {@code SW_DATA_INVALID}.
*/ */
private void finalizeAccessDoc(APDU apdu, CredentialStore store) { private void finalizeAccessDoc(APDU apdu, CredentialStore store) {
short lc = apdu.setIncomingAndReceive(); short lc = apdu.setIncomingAndReceive();
if (lc != 0) { if (lc != 0) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA); ISOException.throwIt(ISO7816.SW_WRONG_DATA);
} }
if (!store.hasCredentialIssuerPubKey()) {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
byte[] buf = apdu.getBuffer(); byte[] buf = apdu.getBuffer();
short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8) short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
| (buf[ISO7816.OFFSET_P2] & 0xFF)); | (buf[ISO7816.OFFSET_P2] & 0xFF));
if (!store.finalizeAccessDocument(totalLen)) { // Bound-check first so the existing oversize test stays at
// SW_WRONG_DATA. Once length is valid, only IssuerAuth verify can
// fail — and that's the tamper case the brief maps to SW_DATA_INVALID.
if (totalLen < 0 || totalLen > CredentialStore.ACCESS_DOC_MAX_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA); ISOException.throwIt(ISO7816.SW_WRONG_DATA);
} }
if (!store.finalizeAccessDocument(totalLen, issuerVerifier, issuerPubScratch)) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
} }
} }

View File

@@ -0,0 +1,84 @@
package com.dangerousthings.aliro;
/**
* secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2.
*
* <p>Shared across {@link AliroApplet} and {@link CoseVerifier} (and any
* future Aliro applet that needs to seed an EC key). J3R180 ships ECC keys
* with no default domain parameters; without explicitly seeding the curve,
* calls into {@code genKeyPair} / {@code setS} / {@code setW} /
* {@code Signature.init} throw {@code CryptoException.ILLEGAL_VALUE}.
*
* <p>Package-private — only intra-package callers should depend on these.
*/
final class Secp256r1Params {
private Secp256r1Params() { }
static final byte[] SECP256R1_P = {
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF
};
static final byte[] SECP256R1_A = {
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFC
};
static final byte[] SECP256R1_B = {
0x5A,(byte)0xC6,0x35,(byte)0xD8,(byte)0xAA,0x3A,(byte)0x93,(byte)0xE7,
(byte)0xB3,(byte)0xEB,(byte)0xBD,0x55,0x76,(byte)0x98,(byte)0x86,(byte)0xBC,
0x65,0x1D,0x06,(byte)0xB0,(byte)0xCC,0x53,(byte)0xB0,(byte)0xF6,
0x3B,(byte)0xCE,0x3C,0x3E,0x27,(byte)0xD2,0x60,0x4B
};
static final byte[] SECP256R1_G = {
0x04,
0x6B,0x17,(byte)0xD1,(byte)0xF2,(byte)0xE1,0x2C,0x42,0x47,
(byte)0xF8,(byte)0xBC,(byte)0xE6,(byte)0xE5,0x63,(byte)0xA4,0x40,(byte)0xF2,
0x77,0x03,0x7D,(byte)0x81,0x2D,(byte)0xEB,0x33,(byte)0xA0,
(byte)0xF4,(byte)0xA1,0x39,0x45,(byte)0xD8,(byte)0x98,(byte)0xC2,(byte)0x96,
0x4F,(byte)0xE3,0x42,(byte)0xE2,(byte)0xFE,0x1A,0x7F,(byte)0x9B,
(byte)0x8E,(byte)0xE7,(byte)0xEB,0x4A,0x7C,0x0F,(byte)0x9E,0x16,
0x2B,(byte)0xCE,0x33,0x57,0x6B,0x31,0x5E,(byte)0xCE,
(byte)0xCB,(byte)0xB6,0x40,0x68,0x37,(byte)0xBF,0x51,(byte)0xF5
};
static final byte[] SECP256R1_R = {
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x00,
(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,
(byte)0xBC,(byte)0xE6,(byte)0xFA,(byte)0xAD,(byte)0xA7,0x17,(byte)0x9E,(byte)0x84,
(byte)0xF3,(byte)0xB9,(byte)0xCA,(byte)0xC2,(byte)0xFC,0x63,0x25,0x51
};
/**
* Loads the secp256r1 / NIST P-256 curve parameters into both halves of
* {@code kp}. Must run before any {@code genKeyPair}, {@code setS},
* {@code setW}, or {@code Signature.init} on cards (like J3R180) that
* don't preset domain parameters on freshly-allocated EC keys.
*/
static void seed(javacard.security.KeyPair kp) {
javacard.security.ECPublicKey pub = (javacard.security.ECPublicKey) kp.getPublic();
javacard.security.ECPrivateKey priv = (javacard.security.ECPrivateKey) kp.getPrivate();
seedPublic(pub);
priv.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length);
priv.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length);
priv.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length);
priv.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length);
priv.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length);
priv.setK((short) 1);
}
/**
* Seeds curve params on a standalone public key (for verify-only flows
* like {@link CoseVerifier} that never need a matching private key).
*/
static void seedPublic(javacard.security.ECPublicKey pub) {
pub.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length);
pub.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length);
pub.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length);
pub.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length);
pub.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length);
pub.setK((short) 1);
}
}

View File

@@ -0,0 +1,23 @@
package com.dangerousthings.aliro;
/**
* Minimal write-side abstraction matching the shape of the GlobalPlatform
* Amendment H {@code org.globalplatform.upgrade.Element} write API. The
* AMD-H adapter (added in a later epic, J3R452-only) is a one-class bridge
* that implements this interface against the real {@code Element}.
*
* <p>Keeping the interface in the main source tree means
* {@link CredentialStore#writeTo(SerializationSink)} can be authored,
* tested, and locked down today without pulling in the AMD-H package, which
* only exists on JCOP 4.5 / J3R452-class cards.
*
* <p>Element semantics: once a byte[] reference is written, the storage
* provider treats it as immutable from the caller's perspective. Test
* fakes that buffer writes should make defensive copies to mirror this.
*/
interface SerializationSink {
SerializationSink write(byte b);
SerializationSink write(short s);
SerializationSink write(boolean z);
SerializationSink write(byte[] arr);
}

View File

@@ -0,0 +1,13 @@
package com.dangerousthings.aliro;
/**
* Minimal read-side abstraction matching the shape of the GlobalPlatform
* Amendment H {@code org.globalplatform.upgrade.Element} read API. See
* {@link SerializationSink} for the rationale.
*/
interface SerializationSource {
byte readByte();
short readShort();
boolean readBoolean();
byte[] readByteArray();
}

View File

@@ -4,6 +4,7 @@ import javacard.framework.APDU;
import javacard.framework.Applet; import javacard.framework.Applet;
import javacard.framework.ISO7816; import javacard.framework.ISO7816;
import javacard.framework.ISOException; import javacard.framework.ISOException;
import javacard.framework.JCSystem;
import javacard.framework.Util; import javacard.framework.Util;
/** /**
@@ -23,10 +24,127 @@ import javacard.framework.Util;
* {@link AliroApplet}'s derivation in §8.3.1.13). Since Java Card * {@link AliroApplet}'s derivation in §8.3.1.13). Since Java Card
* installs distinct instances per AID, that state will be exchanged via * installs distinct instances per AID, that state will be exchanged via
* a shared package-private holder (not yet implemented). * 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>
* {@link CredentialStore#finalizeAccessDocument} runs the COSE_Sign1
* verify 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>StepUpApplet handles {@code INS_EXCHANGE} (0xC9) directly: spec-conformant
* readers route EXCHANGE here after the step-up AID SELECT per §10.2.
*/ */
public class StepUpApplet extends Applet { public class StepUpApplet extends Applet {
/** ISO 7816 CLA byte (0x00) for ISO-standardized commands. ENVELOPE
* (INS=0xC3) per ISO 7816 / Table 8-14 in the Aliro spec uses this CLA,
* not the Aliro-proprietary 0x80, because ENVELOPE is the standard
* ISO command for carrying chained data. */
private static final byte CLA_ISO = (byte) 0x00;
private static final byte CLA_PROPRIETARY = (byte) 0x80; private static final byte CLA_PROPRIETARY = (byte) 0x80;
/** EXCHANGE command per spec §8.3.3.5 / Table 8-14. The reader sends a
* Reader Status sub-event under this INS once the Step-Up AID is the
* active applet. */
private static final byte INS_EXCHANGE = (byte) 0xC9;
/** ENVELOPE command per ISO 7816 + spec §8.4 (Step-Up entry point). The
* X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the
* ENVELOPE body once the Step-Up AID is the active applet. */
private static final byte INS_ENVELOPE = (byte) 0xC3;
/** GET RESPONSE per ISO 7816-4 §7.6.1 — the reader pulls the rest of a
* chained response after the applet returns SW=61xx. */
private static final byte INS_GET_RESPONSE = (byte) 0xC0;
/** 12-byte AES-256-GCM IV layout (§8.3.1.8/9): 8B prefix + 4B counter. */
private static final short GCM_TAG_LEN = 16;
/** Per-chunk APDU outgoing window for the response chaining path. The
* T=0/T=1 short-APDU response buffer caps cleanly at this size — leaves
* the trailing 4 B headroom of the standard 261 B jcardsim/JC buffer
* for SW + framing. */
private static final short CHUNK_LEN = 252;
/** Response chaining buffer for DeviceResponse outputs > {@link #CHUNK_LEN}.
* Sized for 372 B canonical DeviceResponse + 16 B GCM tag = 388 B with
* 28 B headroom. AD is fixed 272 B at personalization; wrapper shape
* is fixed (see DeviceResponseBuilder). Was 512 B in M2D.4; shrunk to
* reclaim transient pool budget on J3R452. */
private static final short RESPONSE_BUFFER_LEN = 416;
/** Holds StepUpSKDevice / StepUpSKReader and the two BE32 message
* counters, plus the IV-stamping math (spec §8.3.1.6/8/9 + §8.4.3).
* All four arrays inside are CLEAR_ON_DESELECT. */
private final StepUpSession session;
/** 32-byte scratch used only during {@link #select()} to stage the
* StepUpSK copied out of {@link SessionContext} before HKDF derives the
* two session keys. Wiped after derivation so the parked StepUpSK never
* outlives the call. */
private final byte[] stepUpSKScratch;
/** 12-byte scratch for the GCM IV: filled by {@link StepUpSession#readerIv}
* or {@link StepUpSession#deviceIv} before each en/decrypt. CLEAR_ON_DESELECT. */
private final byte[] ivScratch;
/** Transient plaintext sink for ENVELOPE DeviceRequest and EXCHANGE
* Reader Status request decrypts. Sized for the largest reasonable
* reader-supplied payload; CLEAR_ON_DESELECT so post-deselect there's
* no plaintext residue. M2D.1 valid DeviceRequest vector is 85 B,
* Reader Status request is 2 B — 192 B leaves ~107 B headroom for
* larger reader requests. Was 256 B; shrunk to reclaim pool budget. */
private final byte[] scratchPlaintext;
private static final short SCRATCH_PLAINTEXT_LEN = 192;
/** Transient single-slot flag: 1 once {@link #select()} successfully
* derived the Step-Up session keys (i.e. the SELECT found
* {@link SessionContext} armed). EXCHANGE / ENVELOPE handlers refuse to
* run if this is 0. CLEAR_ON_DESELECT, alongside the keys themselves. */
private final byte[] sessionFlags;
private static final short FLAG_KEYS_READY = 0;
private static final short FLAGS_LEN = 1;
/** Built-then-encrypted DeviceResponse staging area for the response
* chaining path. ENVELOPE writes the encrypted DeviceResponse into here,
* then sends one {@link #CHUNK_LEN}-sized chunk per APDU (the first
* inside the ENVELOPE reply, the rest pulled via GET RESPONSE). Reused
* during the build step as a scratch for the canonical-CBOR DeviceResponse
* plaintext too — encrypt-in-place isn't an option because the GCM tag
* appends after the ciphertext. CLEAR_ON_DESELECT. */
private final byte[] responseBuffer;
/** {@code [offset, remaining]} for the chaining drain — both reset at the
* start of every ENVELOPE. {@code remaining == 0} signals
* "no more bytes to ship": GET RESPONSE arriving in that state returns
* SW_CONDITIONS_NOT_SATISFIED. CLEAR_ON_DESELECT. */
private final short[] responseState;
private static final short STATE_OFF = 0;
private static final short STATE_REMAINING = 1;
/** 4-byte scratch the {@link DeviceRequestParser} writes its CBOR header
* argument into. Reused across calls — caller-supplied is the
* allocation-free convention StructuralCbor uses. CLEAR_ON_DESELECT. */
private final byte[] parserScratch4;
public static void install(byte[] bArray, short bOffset, byte bLength) { public static void install(byte[] bArray, short bOffset, byte bLength) {
StepUpApplet applet = new StepUpApplet(); StepUpApplet applet = new StepUpApplet();
@@ -37,8 +155,52 @@ public class StepUpApplet extends Applet {
} }
} }
private StepUpApplet() {
// CLEAR_ON_DESELECT: the next deselect (e.g. reader moves back to
// EXPEDITED or pulls the field) zeroes the session keys, matching the
// "fresh keys per Step-Up phase" invariant we'll need when ENVELOPE /
// EXCHANGE handlers run AES-GCM.
session = new StepUpSession();
stepUpSKScratch = JCSystem.makeTransientByteArray(StepUpSession.SK_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);
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);
}
/**
* SELECT entry point. If {@link SessionContext} is armed (i.e. AUTH1 on
* {@link AliroApplet} just succeeded and parked {@code StepUpSK}), copy
* it out and derive {@code StepUpSKDevice}/{@code StepUpSKReader} via
* the §8.4.3 HKDF so the ENVELOPE / EXCHANGE handlers can AES-256-GCM
* with them. Always returns true — an un-armed SELECT (e.g. a reader
* that touches the Step-Up AID before AUTH1) still gets a successful
* FCI; downstream handlers will reject commands that need a live session.
*/
@Override @Override
public boolean select() { public boolean select() {
if (SessionContext.isStepUpArmed()) {
SessionContext.copyStepUpSK(stepUpSKScratch, (short) 0);
CryptoSingletons.getAliroCrypto().deriveStepUpSessionKeys(
stepUpSKScratch, (short) 0,
session.skDevice, (short) 0,
session.skReader, (short) 0);
// Wipe the staged StepUpSK — the derived keys are sufficient
// from here on and we don't want the IKM lingering in transient.
Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, StepUpSession.SK_LEN, (byte) 0);
// Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound counters
// initialised to 0x00000001 on session entry, one per direction.
// CLEAR_ON_DESELECT already zeroes them on each fresh select;
// session.reset() rewrites explicitly so a Step-Up SELECT
// mid-session (without a deselect in between) also starts both
// counters at 1.
session.reset();
sessionFlags[FLAG_KEYS_READY] = (byte) 1;
} else {
sessionFlags[FLAG_KEYS_READY] = (byte) 0;
}
return true; return true;
} }
@@ -50,15 +212,314 @@ public class StepUpApplet extends Applet {
} }
byte[] buf = apdu.getBuffer(); byte[] buf = apdu.getBuffer();
if (buf[ISO7816.OFFSET_CLA] != CLA_PROPRIETARY) { byte cla = buf[ISO7816.OFFSET_CLA];
byte ins = buf[ISO7816.OFFSET_INS];
// INS-first dispatch: ENVELOPE is ISO-class (0x00) per ISO 7816 + spec
// Table 8-14, EXCHANGE is Aliro-proprietary (0x80). Don't blanket
// reject CLA=0x00 -- it's a legitimate ENVELOPE entry point.
if (cla == CLA_ISO && ins == INS_ENVELOPE) {
processEnvelope(apdu);
return;
}
if (cla == CLA_ISO && ins == INS_GET_RESPONSE) {
processGetResponse(apdu);
return;
}
if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) {
processExchange(apdu);
return;
}
if (cla != CLA_ISO && cla != CLA_PROPRIETARY) {
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED); ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
} }
// Until the mdoc DeviceRequest pipeline lands, every proprietary INS
// is unrecognised. ENVELOPE + GET RESPONSE handlers plug in here.
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
} }
/**
* EXCHANGE (CLA=0x80, INS=0xC9) handler: request validation + encrypted
* Reader Status response.
*
* <p>Spec §8.3.3.5 / Tables 8-19 + 8-20. The reader sends
* {@code encrypted_payload || authentication_tag} under
* {@code StepUpSKReader} (IV {@code 0x00*8 || stepup_reader_counter},
* §8.3.1.8), empty AAD. The decrypted plaintext is the Reader Status
* sub-event REQUEST:
* <pre>
* sub_event_id : 1B ; 0x01 = ReaderStatusRequest (M2 only supports this)
* payload_len : 1B
* payload : Lb ; empty for 0x01
* </pre>
*
* <p>The applet validates the structure, then emits a Reader Status
* sub-event RESPONSE (Table 8-20) plaintext:
* <pre>
* sub_event_id : 1B ; echoes 0x01
* status : 1B ; 0x00 = OK
* payload_len : 1B ; 0 for M2
* </pre>
* GCM-encrypted under {@code StepUpSKDevice} + device IV
* ({@code 0x00*7 || 0x01 || stepup_device_counter}, §8.3.1.6). Ciphertext+
* tag is 3 + 16 = 19 B — fits in one APDU, no chaining needed.
*/
private void processExchange(APDU apdu) {
if (sessionFlags[FLAG_KEYS_READY] == 0) {
// SELECT hit StepUpApplet without an armed SessionContext (i.e.
// no successful AUTH1 ran on AliroApplet first). Spec §8.4 says
// the Step-Up phase is only entered post-AUTH1; reject cleanly.
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
short lc = apdu.setIncomingAndReceive();
byte[] buf = apdu.getBuffer();
short dataOff = apdu.getOffsetCdata();
// Need at least the 16-byte tag.
if (lc < GCM_TAG_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
short ptLen = (short) (lc - GCM_TAG_LEN);
if (ptLen > SCRATCH_PLAINTEXT_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
// Build the IV: 8 zero bytes (reader→device prefix per §8.3.1.8) +
// stepup_reader_counter, big-endian, in the trailing 4 bytes.
session.readerIv(ivScratch, (short) 0);
// Decrypt. AliroGcm.decrypt throws CryptoException on tag mismatch;
// remap to a security SW so an attacker can't tell tag-mismatch from
// any other failure mode.
try {
CryptoSingletons.getAliroGcm().decrypt(
session.skReader, (short) 0,
ivScratch, (short) 0,
buf, dataOff, lc,
scratchPlaintext, (short) 0);
} catch (ISOException e) {
throw e;
} catch (Throwable t) {
ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
}
// Spec §8.3.1.8: reader_counter <- reader_counter + 1 after use.
// Advance now so an early exit from request validation still leaves
// the counter at the post-decrypt value (the reader counter advances
// on every successful decrypt regardless of whether the request
// semantically validates).
session.advanceReaderCounter();
// Parse the request shape: [sub_event_id, payload_len, payload].
// Need at least sub_event_id + payload_len = 2 bytes.
if (ptLen < 2) {
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
byte subEventId = scratchPlaintext[0];
short payloadLen = (short) (scratchPlaintext[1] & 0xFF);
if (payloadLen != (short) (ptLen - 2)) {
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
// Only sub_event_id 0x01 (ReaderStatusRequest) is supported in M2.
// M2 ignores the payload contents for 0x01 (just length-validated above).
if (subEventId != (byte) 0x01) {
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
// Wipe the request plaintext — content not needed past validation.
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
// Build the Reader Status response plaintext directly into the APDU
// buffer at offset 0, then encrypt in place. AliroGcm.encrypt supports
// out == pt at the same offset (CTR mode + appended tag).
buf[0] = (byte) 0x01; // sub_event_id (echoes request)
buf[1] = (byte) 0x00; // status = OK
buf[2] = (byte) 0x00; // payload_len = 0
short respPtLen = 3;
// Device-side IV: 0x00*7 || 0x01 || stepup_device_counter (§8.3.1.6).
session.deviceIv(ivScratch, (short) 0);
short ctLen = CryptoSingletons.getAliroGcm().encrypt(
session.skDevice, (short) 0,
ivScratch, (short) 0,
buf, (short) 0, respPtLen,
buf, (short) 0);
// Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
session.advanceDeviceCounter();
apdu.setOutgoingAndSend((short) 0, ctLen);
}
/**
* ENVELOPE (CLA=0x00, INS=0xC3) handler: emits the canonical-CBOR
* DeviceResponse via ISO 7816 response chaining.
*
* <p>Pipeline:
* <ol>
* <li>Decrypt the inbound payload under {@code StepUpSKReader} with
* reader-side IV {@code 0x0000000000000000 || stepup_reader_counter}
* (§8.3.1.9). Advance {@code stepup_reader_counter}.</li>
* <li>Structurally validate the recovered DeviceRequest via
* {@link DeviceRequestParser#validate} — propagates SW_DATA_INVALID
* (malformed CBOR / wrong shape) or SW_CONDITIONS_NOT_SATISFIED
* (unsupported version) unchanged.</li>
* <li>Build the canonical-CBOR DeviceResponse via
* {@link DeviceResponseBuilder#build} carrying the cached Access
* Document at {@code documents[0].issuerSigned.issuerAuth}.</li>
* <li>In-place GCM-encrypt the DeviceResponse plaintext under
* {@code StepUpSKDevice} with device-side IV
* {@code 0x00*7 || 0x01 || stepup_device_counter} (§8.3.1.6).
* AliroGcm supports {@code in == out} at the same offset (CTR mode +
* trailing tag), so the cleartext is overwritten on the encryption
* pass — no second buffer needed.</li>
* <li>Advance {@code stepup_device_counter} and ship the first chunk.
* For our standard 372 B AD wrapper the response is 388 B; we send
* the first {@link #CHUNK_LEN} bytes inline with SW=61xx and the
* reader pulls the rest via GET RESPONSE.</li>
* </ol>
*/
private void processEnvelope(APDU apdu) {
if (sessionFlags[FLAG_KEYS_READY] == 0) {
// SELECT-Step-Up landed without an armed SessionContext (i.e. no
// successful AUTH1 on AliroApplet first). Spec §8.4 keeps Step-Up
// strictly post-AUTH1; reject cleanly.
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
// Invalidate any in-flight chaining state from a previous ENVELOPE.
// A new ENVELOPE always restarts the response stream.
responseState[STATE_OFF] = 0;
responseState[STATE_REMAINING] = 0;
short lc = apdu.setIncomingAndReceive();
byte[] buf = apdu.getBuffer();
short dataOff = apdu.getOffsetCdata();
// Need at least the 16-byte tag (zero-byte plaintext is degenerate
// but spec-legal); reject anything that can't possibly carry a tag.
if (lc < GCM_TAG_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
short ptLen = (short) (lc - GCM_TAG_LEN);
if (ptLen > SCRATCH_PLAINTEXT_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
// Build the reader-side IV: 8 zero bytes (§8.3.1.9) + reader counter.
session.readerIv(ivScratch, (short) 0);
try {
CryptoSingletons.getAliroGcm().decrypt(
session.skReader, (short) 0,
ivScratch, (short) 0,
buf, dataOff, lc,
scratchPlaintext, (short) 0);
} catch (ISOException e) {
throw e;
} catch (Throwable t) {
ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
}
// Spec §8.3.1.9: reader_counter <- reader_counter + 1 after use.
session.advanceReaderCounter();
// Structural validation. Throws ISOException on malformed CBOR /
// unsupported version — let it propagate; SW mapping is the parser's
// responsibility.
DeviceRequestParser.validate(
scratchPlaintext, (short) 0, ptLen,
parserScratch4, (short) 0);
// Wipe the decrypted DeviceRequest — its content doesn't influence the
// response (M2 reader profile asks for the single cached AD), and we
// don't want plaintext loitering past CLEAR_ON_DESELECT.
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
// Build the canonical-CBOR DeviceResponse into responseBuffer. The
// cached AD is staged directly into the builder via copyAccessDocument;
// CredentialStore.hasAccessDocument() is asserted first so a card that
// somehow reached Step-Up without an AD doesn't synthesize an empty
// issuerAuth that would crash downstream readers.
CredentialStore store = CredentialStore.get();
if (store == null || !store.hasAccessDocument()) {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
short adLen = store.getAccessDocumentLen();
// Stage the AD into responseBuffer past the wrapper's max footprint.
// The wrapper is ~100 B for our fixed shape; staging AD at offset 128
// gives the builder a clean target at offset 0 and the AD source at a
// distinct, non-overlapping location.
short adStage = 128;
store.copyAccessDocument(responseBuffer, adStage, (short) 0, adLen);
short respLen = DeviceResponseBuilder.build(
responseBuffer, adStage, adLen,
responseBuffer, (short) 0);
// Build the device-side IV: 0x00*7 || 0x01 || device_counter (§8.3.1.6).
session.deviceIv(ivScratch, (short) 0);
// In-place encrypt: AliroGcm supports out == in at the same offset.
// Ciphertext overwrites the plaintext; the 16 B tag appends after.
short ctLen = CryptoSingletons.getAliroGcm().encrypt(
session.skDevice, (short) 0,
ivScratch, (short) 0,
responseBuffer, (short) 0, respLen,
responseBuffer, (short) 0);
// Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
session.advanceDeviceCounter();
// Ship the first chunk. Total ciphertext is 388 B for the standard
// 272 B AD case; we send CHUNK_LEN bytes and signal more via SW=61xx
// (handled below in sendChunk).
responseState[STATE_OFF] = 0;
responseState[STATE_REMAINING] = ctLen;
sendChunk(apdu);
}
/**
* 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)));
}
}
/** /**
* Minimal FCI for step-up SELECT. The spec (§10.2.1.2) allows the UD to * Minimal FCI for step-up SELECT. The spec (§10.2.1.2) allows the UD to
* advertise supported APDU command/response sizes here; those get added * advertise supported APDU command/response sizes here; those get added
@@ -83,4 +544,17 @@ public class StepUpApplet extends Applet {
buf[outerLenPos] = (byte) (off - 2); buf[outerLenPos] = (byte) (off - 2);
apdu.setOutgoingAndSend((short) 0, off); apdu.setOutgoingAndSend((short) 0, off);
} }
// --- Testing hooks ------------------------------------------------------
// Package-private accessors that let tests verify SELECT-time key
// derivation without exposing the session keys to any production caller.
// NOT for use outside the applet's own test module.
void copyStepUpSKDeviceForTesting(byte[] out, short outOff) {
Util.arrayCopyNonAtomic(session.skDevice, (short) 0, out, outOff, StepUpSession.SK_LEN);
}
void copyStepUpSKReaderForTesting(byte[] out, short outOff) {
Util.arrayCopyNonAtomic(session.skReader, (short) 0, out, outOff, StepUpSession.SK_LEN);
}
} }

View File

@@ -0,0 +1,123 @@
package com.dangerousthings.aliro;
import javacard.framework.JCSystem;
import javacard.framework.Util;
/**
* Holder for all Step-Up AES-256-GCM session state (spec §8.4.3 + mdoc [6]
* §9.1.1.5): the two derived session keys ({@code StepUpSKDevice},
* {@code StepUpSKReader}) and the two big-endian 32-bit message counters
* ({@code stepup_reader_counter}, {@code stepup_device_counter}).
*
* <p>Extracted out of {@link StepUpApplet} so the IV-construction +
* counter-advance math live in one place and can be unit-tested without
* spinning up the whole applet. {@link StepUpApplet} owns exactly one
* instance; there is no singleton.
*
* <p>Lifecycle:
* <ul>
* <li>{@link #reset()} re-initialises both counters to {@code 0x00000001}.
* It does NOT zero the session keys -- {@code CLEAR_ON_DESELECT}
* handles that on deselect.</li>
* <li>{@link #readerIv} / {@link #deviceIv} stamp the current counter into
* a 12-byte IV per spec §8.3.1.8/9 (reader) / §8.3.1.6 (device).</li>
* <li>{@link #advanceReaderCounter} / {@link #advanceDeviceCounter} apply
* a 32-bit big-endian +1 with carry across all 4 bytes; behaviour
* wraps mod 2^32 (spec §8.3.3.5.4 says the counter SHALL never
* reach the limit, so wrap is unreachable in normal protocol flow).</li>
* </ul>
*/
final class StepUpSession {
/** Length of each derived Step-Up session key (spec §8.4.3). */
static final short SK_LEN = 32;
/** 12-byte AES-256-GCM IV total length. */
static final short IV_LEN = 12;
/** Trailing 4-byte big-endian counter portion of the IV. */
static final short COUNTER_LEN = 4;
/** {@code StepUpSKDevice} — device->reader leg of the GCM session,
* derived from {@code StepUpSK} via HKDF (§8.4.3) by
* {@link StepUpApplet#select()}. Transient, CLEAR_ON_DESELECT. */
final byte[] skDevice;
/** {@code StepUpSKReader} — reader->device leg of the GCM session.
* Same derivation context as {@link #skDevice}. */
final byte[] skReader;
/** {@code stepup_reader_counter} per §8.4.3 + mdoc [6] §9.1.1.5:
* 32-bit big-endian counter, initialised to {@code 0x00000001} on
* session entry, incremented after each successful reader-side decrypt.
* Shared across ENVELOPE and EXCHANGE (both reader->device). */
final byte[] readerCounter;
/** {@code stepup_device_counter} per §8.4.3 + mdoc [6] §9.1.1.5:
* 32-bit big-endian counter for the device->reader direction, init
* to {@code 0x00000001}, incremented after each successful encrypt. */
final byte[] deviceCounter;
StepUpSession() {
skDevice = JCSystem.makeTransientByteArray(SK_LEN, JCSystem.CLEAR_ON_DESELECT);
skReader = JCSystem.makeTransientByteArray(SK_LEN, JCSystem.CLEAR_ON_DESELECT);
readerCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT);
deviceCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT);
}
/**
* Re-initialise both counters to {@code 0x00000001}. Called from
* {@link StepUpApplet#select()} whenever the SELECT lands armed.
*
* <p>Keys are NOT zeroed: CLEAR_ON_DESELECT handles that on deselect,
* and the surrounding SELECT immediately re-derives them from a fresh
* StepUpSK anyway.
*/
void reset() {
Util.arrayFillNonAtomic(readerCounter, (short) 0, COUNTER_LEN, (byte) 0);
readerCounter[3] = (byte) 0x01;
Util.arrayFillNonAtomic(deviceCounter, (short) 0, COUNTER_LEN, (byte) 0);
deviceCounter[3] = (byte) 0x01;
}
/**
* Write the 12-byte reader-side IV at {@code out[outOff..outOff+12]}:
* {@code 0x00 * 8 || stepup_reader_counter (4B BE)} per spec §8.3.1.8/9.
*/
void readerIv(byte[] out, short outOff) {
Util.arrayFillNonAtomic(out, outOff, (short) 8, (byte) 0);
Util.arrayCopyNonAtomic(readerCounter, (short) 0,
out, (short) (outOff + 8), COUNTER_LEN);
}
/**
* Write the 12-byte device-side IV at {@code out[outOff..outOff+12]}:
* {@code 0x00 * 7 || 0x01 || stepup_device_counter (4B BE)} per spec §8.3.1.6.
*/
void deviceIv(byte[] out, short outOff) {
Util.arrayFillNonAtomic(out, outOff, (short) 7, (byte) 0);
out[(short) (outOff + 7)] = (byte) 0x01;
Util.arrayCopyNonAtomic(deviceCounter, (short) 0,
out, (short) (outOff + 8), COUNTER_LEN);
}
/** stepup_reader_counter += 1 (32-bit BE, wraps mod 2^32). */
void advanceReaderCounter() {
incrementCounter(readerCounter, (short) 0);
}
/** stepup_device_counter += 1 (32-bit BE, wraps mod 2^32). */
void advanceDeviceCounter() {
incrementCounter(deviceCounter, (short) 0);
}
/** 32-bit big-endian counter increment with carry across all 4 bytes.
* Wraps mod 2^32; spec §8.3.3.5.4 says the counter SHALL never reach
* the limit, so wrap is unreachable in normal protocol flow. */
private static void incrementCounter(byte[] buf, short off) {
for (short i = (short) (off + 3); i >= off; i--) {
buf[i]++;
if (buf[i] != 0) return;
}
}
}

View File

@@ -0,0 +1,349 @@
package com.dangerousthings.aliro;
import javacard.framework.ISO7816;
import javacard.framework.ISOException;
/**
* Structural CBOR utilities for the Aliro Access Document / DeviceResponse
* walk. CBOR is defined by RFC 8949; we implement only the canonical subset
* actually used by Aliro / ISO 18013-5 mdoc messages:
*
* <ul>
* <li>Major types 0..6 (uint, nint, bstr, tstr, array, map, tag) — major
* type 7 (floats / null / true / false) is rejected.</li>
* <li>Argument sizes immediate (0..23), 1-byte, 2-byte, 4-byte — the
* 8-byte form is rejected because Aliro mdoc payloads fit comfortably
* in a short-bounded buffer and supporting it would force long
* arithmetic across the rest of the codec.</li>
* <li>Definite lengths only — indefinite-length (additional info 31) is
* rejected; canonical Aliro mdoc encoding never uses it.</li>
* </ul>
*
* <p>All rejection paths throw {@code ISOException(SW_DATA_INVALID = 0x6984)},
* matching the codebase's "fail closed on malformed input" convention
* (see {@link PersonalizationApplet} and {@link CoseVerifier}).
*
* <p>M2B.1 implements {@link #decodeHeader}; M2B.2 adds {@link #elementSpan}.
* IssuerAuth location and canonical encoders land in M2B.3/M2B.4.
*/
final class StructuralCbor {
private StructuralCbor() {
// Utility class — no instances.
}
/**
* Decodes a single CBOR header at {@code buf[bufOff..bufOff+bufLen)} per
* RFC 8949 §3.
*
* <p>The header byte's top 3 bits are the major type and the bottom 5
* bits are the additional info; values 0..23 are the immediate argument,
* 24/25/26 mean a 1/2/4-byte big-endian uint argument follows. All
* other additional-info values are rejected.
*
* <p>Result encoding (single short, lazy):
* <ul>
* <li>high byte = major type (0..6)</li>
* <li>low byte = bytes consumed (1, 2, 3, or 5)</li>
* </ul>
* The argument value is written big-endian into
* {@code argOut[argOff..argOff+4)}. For arguments smaller than 4 bytes
* the result is zero-extended at the high end (so the caller can always
* read 4 bytes BE and get the correct integer).
*
* @param buf buffer holding the CBOR stream
* @param bufOff offset of the header byte
* @param bufLen number of valid bytes remaining at {@code bufOff}
* @param argOut destination for the 4-byte big-endian argument
* @param argOff offset within {@code argOut} to write the argument
* @return packed (major type &lt;&lt; 8) | bytesConsumed
* @throws ISOException SW_DATA_INVALID on any of: empty buffer, truncated
* argument, major type 7, indefinite length, reserved
* additional info (28..30), or 8-byte argument (27).
*/
static short decodeHeader(
byte[] buf, short bufOff, short bufLen,
byte[] argOut, short argOff) {
if (bufLen < 1) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
byte b0 = buf[bufOff];
short major = (short) ((b0 >> 5) & 0x07);
short addInfo = (short) (b0 & 0x1F);
// Major type 7 (floats / null / true / false / break) — out of
// scope for Aliro mdoc which uses only the data-bearing major
// types 0..6.
if (major == 7) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
// Zero-extend the 4-byte big-endian argument slot up front. For
// immediate / 1B / 2B arguments the upper bytes must read as 0.
argOut[argOff] = (byte) 0;
argOut[(short) (argOff + 1)] = (byte) 0;
argOut[(short) (argOff + 2)] = (byte) 0;
argOut[(short) (argOff + 3)] = (byte) 0;
short consumed;
if (addInfo <= 23) {
// Immediate argument: the additional-info bits ARE the value.
argOut[(short) (argOff + 3)] = (byte) addInfo;
consumed = 1;
} else if (addInfo == 24) {
// 1-byte uint argument follows.
if (bufLen < 2) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 1)];
consumed = 2;
} else if (addInfo == 25) {
// 2-byte big-endian uint argument follows.
if (bufLen < 3) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
argOut[(short) (argOff + 2)] = buf[(short) (bufOff + 1)];
argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 2)];
consumed = 3;
} else if (addInfo == 26) {
// 4-byte big-endian uint argument follows.
if (bufLen < 5) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
argOut[argOff] = buf[(short) (bufOff + 1)];
argOut[(short) (argOff + 1)] = buf[(short) (bufOff + 2)];
argOut[(short) (argOff + 2)] = buf[(short) (bufOff + 3)];
argOut[(short) (argOff + 3)] = buf[(short) (bufOff + 4)];
consumed = 5;
} else {
// additional info 27 (8-byte arg), 28..30 (reserved), 31
// (indefinite length) — all rejected.
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
return 0; // unreachable; satisfies javac.
}
return (short) ((major << 8) | consumed);
}
/**
* Returns the total byte length of the CBOR data item that begins at
* {@code buf[bufOff]}, recursing into arrays / maps / tags so the result
* spans the complete (possibly nested) element.
*
* <p>By major type (per RFC 8949 §3):
* <ul>
* <li>0 (uint), 1 (nint): span = header bytes consumed</li>
* <li>2 (bstr), 3 (tstr): span = header + payload length (argument)</li>
* <li>4 (array): span = header + sum(elementSpan of {@code argument} children)</li>
* <li>5 (map): span = header + sum(elementSpan of {@code 2*argument} items)</li>
* <li>6 (tag): span = header + elementSpan of the single tagged element</li>
* <li>7: unreachable — {@link #decodeHeader} already rejects it.</li>
* </ul>
*
* <p>Implementation is recursive. Worst-case Aliro mdoc nesting depth is
* ~5 (DeviceResponse map → documents array → entry map → issuerSigned map
* → issuerAuth array), so the JC stack budget is comfortable. Length /
* count fields use the 4B big-endian argument written into
* {@code argScratch[argScratchOff..argScratchOff+4)} by {@code decodeHeader}.
* The scratch is overwritten at every recursive descent — that's fine
* because the parent has already consumed its argument before recursing.
*
* @param buf buffer holding the CBOR stream
* @param bufOff offset of the first byte of the element
* @param bufLen number of valid bytes remaining at {@code bufOff}
* @param argScratch 4-byte scratch for {@code decodeHeader}'s argument
* @param argScratchOff offset within {@code argScratch} (4 bytes needed)
* @return total bytes the element (including children) occupies
* @throws ISOException SW_DATA_INVALID on malformed input — either
* {@link #decodeHeader} rejected a header, the
* declared bstr / tstr payload runs past {@code bufLen},
* or a child element runs past the parent's bounds.
*/
static short elementSpan(
byte[] buf, short bufOff, short bufLen,
byte[] argScratch, short argScratchOff) {
short header = decodeHeader(buf, bufOff, bufLen, argScratch, argScratchOff);
short major = (short) ((header >> 8) & 0x07);
short consumed = (short) (header & 0xFF);
// 4-byte big-endian read of the argument decodeHeader just wrote.
// Aliro mdoc payloads fit in a short-sized buffer, so we cap argument
// values at Short.MAX_VALUE when used as a length / count; any larger
// value is malformed input and rejected.
short argument = readArgAsShort(argScratch, argScratchOff);
// Span starts with the header bytes; children (if any) extend it.
short span = consumed;
if (major == 0 || major == 1) {
// uint / nint — header is the whole element.
return span;
}
if (major == 2 || major == 3) {
// bstr / tstr — header + payload bytes.
short total = (short) (span + argument);
// Reject if the declared payload extends past bufLen, otherwise a
// truncated bstr would silently report a span larger than the
// available buffer and confuse later callers.
if (argument < 0 || total < 0 || total > bufLen) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
return total;
}
if (major == 4) {
// Array of `argument` children.
for (short i = 0; i < argument; i++) {
short childOff = (short) (bufOff + span);
short childLen = (short) (bufLen - span);
if (childLen <= 0) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short childSpan = elementSpan(
buf, childOff, childLen, argScratch, argScratchOff);
span = (short) (span + childSpan);
}
return span;
}
if (major == 5) {
// Map of `argument` key/value pairs — 2 * argument items.
short items = (short) (argument << 1);
for (short i = 0; i < items; i++) {
short childOff = (short) (bufOff + span);
short childLen = (short) (bufLen - span);
if (childLen <= 0) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short childSpan = elementSpan(
buf, childOff, childLen, argScratch, argScratchOff);
span = (short) (span + childSpan);
}
return span;
}
// major == 6 (tag) — header + single tagged element.
short childOff = (short) (bufOff + span);
short childLen = (short) (bufLen - span);
if (childLen <= 0) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short childSpan = elementSpan(
buf, childOff, childLen, argScratch, argScratchOff);
return (short) (span + childSpan);
}
/**
* Writes a canonical CBOR unsigned integer (major type 0) into
* {@code out[outOff..)}.
*
* <p>Picks the shortest argument size that fits per RFC 8949 §4.2.1:
* <ul>
* <li>0..23 → immediate (1 byte total)</li>
* <li>24..255 → 1-byte argument (2 bytes total)</li>
* <li>256..65535 → 2-byte argument (3 bytes total)</li>
* <li>65536..(2^32-1) → 4-byte argument (5 bytes total)</li>
* </ul>
* Argument type is {@code int} because the 4-byte form covers the full
* unsigned 32-bit range; {@code >>>} is used so a value with the sign bit
* set still serializes correctly. Negative values are rejected because
* Aliro mdoc CBOR never uses negative integers (those would be major
* type 1, which we don't emit).
*
* @return number of bytes written
* @throws ISOException SW_DATA_INVALID if {@code value} is negative.
*/
static short encodeUint(int value, byte[] out, short outOff) {
return encodeTypeAndArg((short) 0, value, out, outOff);
}
/**
* Writes a canonical CBOR byte-string header (major type 2) for a payload
* of {@code len} bytes into {@code out[outOff..)}.
*
* <p>Same argument-size rules as {@link #encodeUint}. {@code short} arg is
* sufficient: Aliro mdoc payloads are short-bounded. Negative lengths are
* rejected as malformed input.
*
* @return number of bytes written (the header only — caller appends payload)
* @throws ISOException SW_DATA_INVALID if {@code len} is negative.
*/
static short encodeBstrHeader(short len, byte[] out, short outOff) {
return encodeTypeAndArg((short) 2, len, out, outOff);
}
/**
* Writes a canonical CBOR text-string header (major type 3) for a payload
* of {@code len} UTF-8 bytes into {@code out[outOff..)}. Same shape as
* {@link #encodeBstrHeader}.
*
* @return number of bytes written (the header only — caller appends payload)
* @throws ISOException SW_DATA_INVALID if {@code len} is negative.
*/
static short encodeTstrHeader(short len, byte[] out, short outOff) {
return encodeTypeAndArg((short) 3, len, out, outOff);
}
/**
* Shared backend for {@link #encodeUint} / {@link #encodeBstrHeader} /
* {@link #encodeTstrHeader}. Picks the shortest argument size that fits
* {@code value}, writes the header byte (major-type bits 7..5, additional
* info bits 4..0), then the big-endian argument bytes.
*/
private static short encodeTypeAndArg(short major, int value, byte[] out, short outOff) {
if (value < 0) {
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
byte mt = (byte) (major << 5);
if (value <= 23) {
// Immediate argument — additional info bits hold the value.
out[outOff] = (byte) (mt | value);
return 1;
}
if (value <= 0xFF) {
// 1-byte argument (additional info 24).
out[outOff] = (byte) (mt | 24);
out[(short) (outOff + 1)] = (byte) value;
return 2;
}
if (value <= 0xFFFF) {
// 2-byte big-endian argument (additional info 25).
out[outOff] = (byte) (mt | 25);
out[(short) (outOff + 1)] = (byte) (value >>> 8);
out[(short) (outOff + 2)] = (byte) value;
return 3;
}
// 4-byte big-endian argument (additional info 26). >>> keeps it unsigned
// so values >= 0x80000000 (impossible here because `value < 0` is
// already rejected) would still serialize correctly via the 4-byte form.
out[outOff] = (byte) (mt | 26);
out[(short) (outOff + 1)] = (byte) (value >>> 24);
out[(short) (outOff + 2)] = (byte) (value >>> 16);
out[(short) (outOff + 3)] = (byte) (value >>> 8);
out[(short) (outOff + 4)] = (byte) value;
return 5;
}
/**
* Reads the 4-byte big-endian argument {@code decodeHeader} wrote into
* the scratch and returns it as a short. Argument values larger than
* {@code Short.MAX_VALUE} (32767) are out of buffer range for Aliro mdoc
* payloads and rejected as malformed input.
*/
private static short readArgAsShort(byte[] scratch, short off) {
short hi = (short) (((scratch[off] & 0xFF) << 8)
| (scratch[(short) (off + 1)] & 0xFF));
if (hi != 0) {
// 32-bit argument with any of the upper 16 bits set won't fit
// a short used as length / count.
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
short v = (short) (((scratch[(short) (off + 2)] & 0xFF) << 8)
| (scratch[(short) (off + 3)] & 0xFF));
if (v < 0) {
// Top bit set = value > Short.MAX_VALUE.
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
return v;
}
}

View File

@@ -30,7 +30,9 @@ class AliroAppletAuth1Test {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
CredentialStore.get().resetForTesting(); // reader.provision() below installs PersonalizationApplet, whose
// constructor calls CredentialStore.bootstrap() and gives us a fresh
// store for every test — no explicit reset needed any more.
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);
@@ -160,8 +162,11 @@ class AliroAppletAuth1Test {
} }
@Test @Test
void auth1SignalingBitmapIsAllZeroWhenNoAccessDocProvisioned() throws Exception { void auth1_bitmap_adNotProvisioned_returns0x0004() throws Exception {
// Default setUp provisions keys but no Access Document. // Default setUp provisions keys but no Access Document. Bit 2
// (step-up AID SELECT required) is still set — the split-AID step-up
// architecture is always advertised, even when there's nothing
// behind it.
sendStandardAuth0(); sendStandardAuth0();
ResponseAPDU r = sendValidAuth1(); ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW()); assertEquals(0x9000, r.getSW());
@@ -170,22 +175,47 @@ class AliroAppletAuth1Test {
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData()); reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E); byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E);
org.junit.jupiter.api.Assertions.assertArrayEquals( org.junit.jupiter.api.Assertions.assertArrayEquals(
new byte[] { 0x00, 0x00 }, bitmap, new byte[] { 0x00, 0x04 }, bitmap,
"no Access Document → signaling_bitmap is 0x0000"); "no Access Document → bit 2 only (0x0004); bit 0 reflects actual retrievability");
} }
@Test @Test
void auth1SignalingBitmapHasAccessDocBitsWhenAdProvisioned() throws Exception { void auth1_bitmap_adProvisionedButNotVerified_returns0x0004() throws Exception {
// Provision a non-empty Access Document via CredentialStore (bypasses // Provision opaque AD bytes and mark them finalized, but NOT verified.
// the PersonalizationApplet INS layer — that flow is tested // Post-M2A.3 the finalize flow requires IssuerAuth verify so this is a
// separately). Content is opaque here; we only care that the // defensive case — bit 0 (AD retrievable) must NOT be advertised since
// signaling_bitmap reflects presence. // we wouldn't actually serve unverified AD on the step-up path.
byte[] ad = new byte[120]; byte[] ad = new byte[120];
for (int i = 0; i < ad.length; i++) ad[i] = (byte) (i ^ 0x5A); for (int i = 0; i < ad.length; i++) ad[i] = (byte) (i ^ 0x5A);
org.junit.jupiter.api.Assertions.assertTrue( org.junit.jupiter.api.Assertions.assertTrue(
CredentialStore.get().writeAccessDocumentChunk(ad, (short) 0, (short) 0, (short) ad.length)); CredentialStore.get().writeAccessDocumentChunk(ad, (short) 0, (short) 0, (short) ad.length));
CredentialStore.get().markAccessDocumentFinalizedForTesting((short) ad.length);
// Deliberately do NOT mark verified.
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E);
org.junit.jupiter.api.Assertions.assertArrayEquals(
new byte[] { 0x00, 0x04 }, bitmap,
"AD finalized but not verified → bit 2 only (0x0004); bit 0 requires verified flag");
}
@Test
void auth1_bitmap_adProvisionedAndVerified_returns0x0005() throws Exception {
// Provision a non-empty Access Document via CredentialStore (bypasses
// the PersonalizationApplet INS layer — that flow is tested
// separately). Mark both finalized AND verified to mirror the
// post-M2A.3 happy path.
byte[] ad = new byte[120];
for (int i = 0; i < ad.length; i++) ad[i] = (byte) (i ^ 0x5A);
org.junit.jupiter.api.Assertions.assertTrue( org.junit.jupiter.api.Assertions.assertTrue(
CredentialStore.get().finalizeAccessDocument((short) ad.length)); CredentialStore.get().writeAccessDocumentChunk(ad, (short) 0, (short) 0, (short) ad.length));
CredentialStore.get().markAccessDocumentFinalizedForTesting((short) ad.length);
CredentialStore.get().markAccessDocumentVerifiedForTesting();
sendStandardAuth0(); sendStandardAuth0();
ResponseAPDU r = sendValidAuth1(); ResponseAPDU r = sendValidAuth1();
@@ -198,7 +228,7 @@ class AliroAppletAuth1Test {
// = 2^0 + 2^2 = 0x0005 (big-endian) // = 2^0 + 2^2 = 0x0005 (big-endian)
org.junit.jupiter.api.Assertions.assertArrayEquals( org.junit.jupiter.api.Assertions.assertArrayEquals(
new byte[] { 0x00, 0x05 }, bitmap, new byte[] { 0x00, 0x05 }, bitmap,
"Access Document provisioned on NFC → bitmap bits 0 and 2 set (0x0005)"); "AD finalized AND verified → bits 0 + 2 set (0x0005)");
} }
@Test @Test
@@ -213,7 +243,7 @@ class AliroAppletAuth1Test {
assertEquals(0x9000, r.getSW()); assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response( byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData()); reader.deriveExpeditedSKDevice(credentialEphemPubKey, (byte) 0x00), r.getData());
byte[] keySlot = TlvUtil.findTopLevel(pt, 0x4E); byte[] keySlot = TlvUtil.findTopLevel(pt, 0x4E);
byte[] credPubTag = TlvUtil.findTopLevel(pt, 0x5A); byte[] credPubTag = TlvUtil.findTopLevel(pt, 0x5A);

View File

@@ -92,4 +92,19 @@ class AliroAppletTest {
assertEquals(0x01, versions[0] & 0xFF, "expected first protocol version high byte 0x01"); assertEquals(0x01, versions[0] & 0xFF, "expected first protocol version high byte 0x01");
assertEquals(0x00, versions[1] & 0xFF, "expected first protocol version low byte 0x00"); assertEquals(0x00, versions[1] & 0xFF, "expected first protocol version low byte 0x00");
} }
@Test
void auth1ExchangeInsRejectedNowThatStepUpAppletOwnsIt() {
// StepUpApplet (ACCE5502) owns INS_EXCHANGE (0xC9); AliroApplet must
// NOT also answer 0xC9 — a spec-conformant reader routes EXCHANGE to
// ACCE5502 after the §10.2 step-up AID SELECT. AliroApplet receiving
// 0xC9 is a reader bug or a stale flow, and must be rejected with
// SW_INS_NOT_SUPPORTED so it can never accidentally interact with
// expedited session state.
selectExpedited();
CommandAPDU exchange = new CommandAPDU(0x80, 0xC9, 0x00, 0x00);
ResponseAPDU resp = sim.transmitCommand(exchange);
assertEquals(0x6D00, resp.getSW(),
"AliroApplet must reject INS_EXCHANGE (0xC9) — StepUpApplet (ACCE5502) owns it now");
}
} }

View File

@@ -167,6 +167,50 @@ class AliroCryptoTest {
} }
} }
/**
* Independent pin against an inline RFC 5869 reference (one HKDF-Expand
* block: T(1) = HMAC(PRK, info || 0x01)). Complements
* {@link #deriveStepUpSessionKeysMatchesManualHkdf} by anchoring on the
* exact ASCII info strings the spec specifies ("SKDevice" / "SKReader")
* with a different IKM byte pattern, so a future spec misread can't be
* masked by sharing helper code with the equivalent test above.
*/
@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 HKDF-SHA-256 with empty salt
javax.crypto.Mac extractMac = javax.crypto.Mac.getInstance("HmacSHA256");
extractMac.init(new javax.crypto.spec.SecretKeySpec(new byte[32], "HmacSHA256"));
byte[] prk = extractMac.doFinal(stepUpSK);
byte[] expectedDevice = expandOnce(prk, "SKDevice".getBytes("US-ASCII"));
byte[] expectedReader = expandOnce(prk, "SKReader".getBytes("US-ASCII"));
assertArrayEquals(expectedDevice, skDevice,
"StepUpSKDevice = HKDF-Expand(PRK, info=ASCII(\"SKDevice\"), L=32)");
assertArrayEquals(expectedReader, skReader,
"StepUpSKReader = HKDF-Expand(PRK, info=ASCII(\"SKReader\"), L=32)");
}
private static byte[] expandOnce(byte[] prk, byte[] info) throws Exception {
// T(1) = HMAC-SHA-256(PRK, info || 0x01); first 32 bytes is the output
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
mac.update(info);
mac.update((byte) 0x01);
return mac.doFinal();
}
@Test @Test
void hkdfExpandRfc5869TestCase1() { void hkdfExpandRfc5869TestCase1() {
AliroCrypto crypto = new AliroCrypto(); AliroCrypto crypto = new AliroCrypto();
@@ -207,14 +251,15 @@ class AliroCryptoTest {
} }
/** /**
* Kdh is the session-key seed from Aliro §8.3.1.4. The spec note says * Kdh is the session-key seed from Aliro §8.3.1.4: X9.63 KDF (BSI
* the procedure in §8.3.1.5 (HKDF-SHA-256) supersedes the X9.63 KDF in * TR-03111) with H=SHA-256, ZAB = ECDH shared-secret x-coord,
* §8.3.1.4 — concretely, Kdh = HKDF(IKM=ECDH_x(ePriv, peerEPub), * SharedInfo = transaction_identifier, K = 256 bits. For 32-byte output
* salt=transaction_identifier, info=∅, L=32). This test checks that * X9.63 KDF reduces to:
* deriveKdh produces exactly what the manual HKDF chain produces. * Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
* This test pins that exact construction.
*/ */
@Test @Test
void deriveKdhMatchesManualHkdfChain() { void deriveKdhMatchesX963OneShotSha256() throws Exception {
AliroCrypto crypto = new AliroCrypto(); AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256(); KeyPair kp1 = freshP256();
@@ -236,20 +281,16 @@ class AliroCryptoTest {
(ECPrivateKey) kp1.getPrivate(), (ECPrivateKey) kp1.getPrivate(),
pub2, (short) 0, pub2, (short) 0,
zab, (short) 0); zab, (short) 0);
byte[] prk = new byte[32];
crypto.hkdfExtract(
txnId, (short) 0, (short) txnId.length,
zab, (short) 0, (short) 32,
prk, (short) 0);
byte[] okm = new byte[32];
crypto.hkdfExpand(
prk, (short) 0, (short) 32,
new byte[0], (short) 0, (short) 0,
(short) 32,
okm, (short) 0);
assertArrayEquals(okm, kdh, // Manual X9.63 KDF reference: SHA-256(ZAB || 0x00000001 || txnId)
"deriveKdh must equal HKDF(IKM=ECDH_x, salt=txnId, info=empty, L=32)"); java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
md.update(zab);
md.update(new byte[] { 0x00, 0x00, 0x00, 0x01 });
md.update(txnId);
byte[] expected = md.digest();
assertArrayEquals(expected, kdh,
"deriveKdh must equal X9.63 KDF: SHA-256(ZAB || 0x00000001 || transaction_id)");
} }
/** /**

View File

@@ -1,6 +1,7 @@
package com.dangerousthings.aliro; package com.dangerousthings.aliro;
import javacard.security.AESKey; import javacard.security.AESKey;
import javacard.security.CryptoException;
import javacard.security.KeyBuilder; import javacard.security.KeyBuilder;
import javacardx.crypto.AEADCipher; import javacardx.crypto.AEADCipher;
import javacardx.crypto.Cipher; import javacardx.crypto.Cipher;
@@ -9,6 +10,8 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Tests for {@link AliroGcm} — our userland AES-256-GCM, built on the JC * Tests for {@link AliroGcm} — our userland AES-256-GCM, built on the JC
@@ -191,6 +194,65 @@ class AliroGcmTest {
"different keys must produce different tags"); "different keys must produce different tags");
} }
/**
* Rekey-across-encrypts: M2D.3's stream-encrypt path calls
* {@link AliroGcm#encrypt} twice in a single session (once for the
* EXCHANGE Reader Status ack, once for the ENVELOPE DeviceResponse) with
* a different (key, IV) pair each time. This test pins that no leftover
* state from the first call (cached H, M-table, AES key slot) corrupts
* the second. M2C.2 extracts the rekey path into {@code setKeyAndIv};
* this test is the regression spec for that refactor.
*/
@Test
void setKeyAndIvAllowsRekeyAcrossMultipleEncrypts() {
AliroGcm gcm = new AliroGcm();
// Encrypt block A under key1 + iv1
byte[] keyA = makeKey((byte) 0xA0);
byte[] ivA = makeIv((byte) 0xAA);
byte[] ptA = bytes("hello A");
byte[] outA = new byte[ptA.length + 16];
gcm.encrypt(keyA, (short) 0, ivA, (short) 0,
ptA, (short) 0, (short) ptA.length, outA, (short) 0);
// Rekey + encrypt block B under key2 + iv2
byte[] keyB = makeKey((byte) 0xB0);
byte[] ivB = makeIv((byte) 0xBB);
byte[] ptB = bytes("hello B");
byte[] outB = new byte[ptB.length + 16];
gcm.encrypt(keyB, (short) 0, ivB, (short) 0,
ptB, (short) 0, (short) ptB.length, outB, (short) 0);
// Both must round-trip cleanly through decrypt under their own key+iv
byte[] ptAroundTrip = new byte[ptA.length];
gcm.decrypt(keyA, (short) 0, ivA, (short) 0,
outA, (short) 0, (short) outA.length, ptAroundTrip, (short) 0);
assertArrayEquals(ptA, ptAroundTrip,
"block A must decrypt under (keyA, ivA) after gcm has been re-keyed to B");
byte[] ptBroundTrip = new byte[ptB.length];
gcm.decrypt(keyB, (short) 0, ivB, (short) 0,
outB, (short) 0, (short) outB.length, ptBroundTrip, (short) 0);
assertArrayEquals(ptB, ptBroundTrip,
"block B must decrypt under (keyB, ivB) — no leftover state from A");
}
private static byte[] makeKey(byte seed) {
byte[] k = new byte[32];
for (int i = 0; i < 32; i++) k[i] = (byte) (seed + i);
return k;
}
private static byte[] makeIv(byte seed) {
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (seed + i);
return iv;
}
private static byte[] bytes(String s) {
return s.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
@Test @Test
void differentIvsProduceDifferentCiphertexts() { void differentIvsProduceDifferentCiphertexts() {
byte[] key = new byte[32]; byte[] key = new byte[32];
@@ -218,6 +280,148 @@ class AliroGcmTest {
"different IVs must produce different ciphertexts (CTR stream differs)"); "different IVs must produce different ciphertexts (CTR stream differs)");
} }
// ---- Adversarial regression tests (post-M2 security pass) ---------------
//
// These pin the documented defensive properties of AliroGcm.decrypt:
// 1. Any single-byte mutation in the ciphertext rejects (forgery).
// 2. Any single-byte mutation in the tag rejects (forgery).
// 3. Wrong key under same (IV, ct, tag) rejects (key confusion).
// 4. Wrong IV under same (key, ct, tag) rejects (replay-with-rebind).
// 5. Truncated input (< TAG_LEN) rejects without touching the output.
//
// None of these are NIST KAT cases; they are negative-space coverage for
// implementation flaws like early-exit tag compare, conditional writes
// before tag verify, or missing bounds checks.
/** Encrypt a fixed plaintext under fixed (key, IV) and return ct||tag. */
private static byte[] freshCiphertext(byte[] key, byte[] iv, byte[] pt) {
byte[] out = new byte[pt.length + 16];
AliroGcm gcm = new AliroGcm();
gcm.encrypt(key, (short) 0, iv, (short) 0,
pt, (short) 0, (short) pt.length,
out, (short) 0);
return out;
}
@Test
void decrypt_tamperedCiphertextByte_throwsAndOutputUntouched() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (0x10 + i);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xA0 + i);
byte[] pt = hex("00112233445566778899aabbccddeeff" + "0102030405");
byte[] ctTag = freshCiphertext(key, iv, pt);
// Flip one bit in the ciphertext (NOT the tag).
ctTag[7] ^= (byte) 0x01;
byte[] outSentinel = new byte[pt.length];
java.util.Arrays.fill(outSentinel, (byte) 0xCC);
AliroGcm gcm = new AliroGcm();
assertThrows(CryptoException.class, () -> gcm.decrypt(
key, (short) 0, iv, (short) 0,
ctTag, (short) 0, (short) ctTag.length,
outSentinel, (short) 0));
// Tag verify happens BEFORE GCTR-decrypt into out[]; output buffer
// must be byte-identical to what we filled before the call.
for (int i = 0; i < pt.length; i++) {
assertEquals((byte) 0xCC, outSentinel[i],
"tampered-ct decrypt must not write plaintext to out[" + i + "]");
}
}
@Test
void decrypt_tamperedTagByte_throwsAndOutputUntouched() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (0x20 + i);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xB0 + i);
byte[] pt = hex("deadbeefcafe1234");
byte[] ctTag = freshCiphertext(key, iv, pt);
// Flip one bit in the LAST byte of the 16-byte tag.
ctTag[ctTag.length - 1] ^= (byte) 0x80;
byte[] outSentinel = new byte[pt.length];
java.util.Arrays.fill(outSentinel, (byte) 0xDD);
AliroGcm gcm = new AliroGcm();
assertThrows(CryptoException.class, () -> gcm.decrypt(
key, (short) 0, iv, (short) 0,
ctTag, (short) 0, (short) ctTag.length,
outSentinel, (short) 0));
for (int i = 0; i < pt.length; i++) {
assertEquals((byte) 0xDD, outSentinel[i],
"tampered-tag decrypt must not write plaintext to out[" + i + "]");
}
}
@Test
void decrypt_wrongKey_throws() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (0x30 + i);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xC0 + i);
byte[] pt = hex("11223344");
byte[] ctTag = freshCiphertext(key, iv, pt);
byte[] wrongKey = key.clone();
wrongKey[5] ^= (byte) 0x01; // single-bit difference
byte[] out = new byte[pt.length];
AliroGcm gcm = new AliroGcm();
assertThrows(CryptoException.class, () -> gcm.decrypt(
wrongKey, (short) 0, iv, (short) 0,
ctTag, (short) 0, (short) ctTag.length,
out, (short) 0));
}
@Test
void decrypt_wrongIv_throws() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (0x40 + i);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xD0 + i);
byte[] pt = hex("55667788");
byte[] ctTag = freshCiphertext(key, iv, pt);
byte[] wrongIv = iv.clone();
wrongIv[3] ^= (byte) 0x01;
byte[] out = new byte[pt.length];
AliroGcm gcm = new AliroGcm();
assertThrows(CryptoException.class, () -> gcm.decrypt(
key, (short) 0, wrongIv, (short) 0,
ctTag, (short) 0, (short) ctTag.length,
out, (short) 0));
}
@Test
void decrypt_inputShorterThanTag_throwsIllegalValueWithoutOutput() {
byte[] key = new byte[32];
byte[] iv = new byte[12];
// 15 bytes — one short of the minimum (16-byte tag with empty pt).
byte[] tooShort = new byte[15];
byte[] outSentinel = new byte[1];
outSentinel[0] = (byte) 0xEE;
AliroGcm gcm = new AliroGcm();
CryptoException ex = assertThrows(CryptoException.class, () -> gcm.decrypt(
key, (short) 0, iv, (short) 0,
tooShort, (short) 0, (short) tooShort.length,
outSentinel, (short) 0));
assertEquals(CryptoException.ILLEGAL_VALUE, ex.getReason(),
"input < TAG_LEN must surface CryptoException.ILLEGAL_VALUE");
assertEquals((byte) 0xEE, outSentinel[0],
"early reject must not touch the output buffer");
}
private static byte[] hex(String s) { private static byte[] hex(String s) {
s = s.replaceAll("\\s+", ""); s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2]; byte[] out = new byte[s.length() / 2];

View File

@@ -0,0 +1,86 @@
package com.dangerousthings.aliro;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests for {@link CoseVerifier} — RFC 9052 COSE_Sign1 verification over
* ECDSA P-256 + SHA-256, used to authenticate the Aliro Access Document
* IssuerAuth signature.
*
* <p>Test vector generated once by harness/src/aliro_harness/issuer/cose.py
* using a deterministic private key seed ("M2A.2-cose-verifier-test-key!!!_"
* reduced mod n) and the literal payload "M2A.2 known-good payload".
* Self-verified via verify_cose_sign1 before being copied here.
*
* <p>ECDSA k is randomised inside the python {@code cryptography} library;
* regenerating the vector would produce a different signature. The hex
* constants below are the canonical M2A.2 known-answer pair.
*/
class CoseVerifierTest {
/** secp256r1 public key, uncompressed SEC1 (0x04 || X(32) || Y(32)). */
private static final byte[] ISSUER_PUB_UNCOMP = hex(
"04d28f7bbdb8bc7c4d1bbcb7b76e6bdffaa4994f978f316df8d5341bf164e36b82"
+ "76f90f72b5680bfd21901de452ea133f35ef44fd34e5b14ddb9a7e14ca700a0a");
/**
* COSE_Sign1 wire bytes (CBOR-encoded 4-element array) for the test
* vector above. Layout:
* <pre>
* [ 0] 0x84 outer array(4)
* [ 1] 0x43 protected bstr header (len 3)
* [ 2..4] a1 01 26 {1: -7} → ES256 alg id
* [ 5] 0xa1 unprotected map(1)
* [ 6] 0x04 key 4 (kid)
* [ 7] 0x48 bstr(8)
* [ 8..15] kid 01 02 03 04 05 06 07 08
* [ 16] 0x58 payload bstr 1-byte len
* [ 17] 0x18 len = 24
* [ 18..41] "M2A.2 known-good payload" (24 bytes)
* [ 42] 0x58 sig bstr 1-byte len
* [ 43] 0x40 len = 64
* [ 44..107] raw r||s ECDSA-P256 signature (64 bytes)
* </pre>
*/
private static final byte[] COSE_SIGN1_GOOD = hex(
"8443a10126a1044801020304050607085818"
+ "4d32412e32206b6e6f776e2d676f6f64207061796c6f6164"
+ "5840"
+ "02e1c7996e16b3c47c020d7894f38f2c3850abc10a1a53dd56051c80412ba203"
+ "447598813bab380e0d286dc55985c24d123a7bc98f1f31419679d4f2dde0e916");
@Test
void verifyCoseSign1_validSignature_returnsTrue() {
CoseVerifier verifier = new CoseVerifier();
boolean ok = verifier.verifyCoseSign1(
COSE_SIGN1_GOOD, (short) 0, (short) COSE_SIGN1_GOOD.length,
ISSUER_PUB_UNCOMP, (short) 0);
assertTrue(ok, "known-good COSE_Sign1 must verify against issuer pubkey");
}
@Test
void verifyCoseSign1_tamperedPayload_returnsFalse() {
// Flip one bit in the payload region (offset 25 = mid-payload).
byte[] tampered = new byte[COSE_SIGN1_GOOD.length];
System.arraycopy(COSE_SIGN1_GOOD, 0, tampered, 0, COSE_SIGN1_GOOD.length);
tampered[25] ^= (byte) 0x01;
CoseVerifier verifier = new CoseVerifier();
boolean ok = verifier.verifyCoseSign1(
tampered, (short) 0, (short) tampered.length,
ISSUER_PUB_UNCOMP, (short) 0);
assertFalse(ok, "tampered payload must NOT verify");
}
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;
}
}

View File

@@ -0,0 +1,199 @@
package com.dangerousthings.aliro;
import java.util.ArrayList;
import java.util.List;
import javacard.framework.ISO7816;
import javacard.framework.ISOException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Locks down the byte-for-byte field order written by
* {@link CredentialStore#writeTo(SerializationSink)} / read back by
* {@link CredentialStore#readFrom(SerializationSource)}.
*
* <p>This test exists today against an in-tree
* {@link SerializationSink}/{@link SerializationSource} pair (see
* {@code RecordingSink} below) so the field shape is testable before the
* GP Amendment H {@code org.globalplatform.upgrade.Element} adapter is
* wired in. When the AMD-H adapter lands, it just bridges {@code Element}
* to those two interfaces and this test stays the regression spec.
*/
class CredentialStoreSerializationTest {
private static final byte[] KNOWN_32_BYTES = makeRange(32, 0xA0);
private static final byte[] KNOWN_64_BYTES_B = makeRange(64, 0x80);
private static final byte[] KNOWN_64_BYTES_ISSUER = makeRange(64, 0x40);
private static byte[] makeRange(int len, int start) {
byte[] out = new byte[len];
for (int i = 0; i < len; i++) {
out[i] = (byte) (start + i);
}
return out;
}
@Test
void credentialStoreRoundTripsViaSerializationContract() {
// Deliberately distinguishable mix (T/T/F/T/F) so that a silent
// reorder among the boolean writeTo lines would change which field
// ends up true/false after readFrom — and fail the per-predicate
// assertions below. If all five booleans were true the reorder
// would be undetectable.
CredentialStore src = CredentialStore.bootstrap();
src.resetForTesting();
src.setCredentialPrivKey(KNOWN_32_BYTES, (short) 0);
// Intentionally do NOT setCredentialPubKey: leave credentialPubKeySet=false.
src.setReaderPubKey(KNOWN_64_BYTES_B, (short) 0);
// Exercise the new credentialIssuerPubKey slot (M2A.3, FIELD_VERSION=3).
src.setCredentialIssuerPubKey(KNOWN_64_BYTES_ISSUER, (short) 0);
// Intentionally do NOT finalizeAccessDocument: leave accessDocumentFinalized=false
// and accessDocumentLen=0. With len=0, copyAccessDocument() returns an empty array.
src.commit();
RecordingSink buf = new RecordingSink();
src.writeTo(buf);
CredentialStore restored = CredentialStore.readFrom(buf.toSource());
// Byte-array fields that were set must round-trip exactly.
assertArrayEquals(KNOWN_32_BYTES, restored.copyCredentialPrivKey());
assertArrayEquals(KNOWN_64_BYTES_B, restored.copyReaderPubKey());
// Issuer pubkey round-trips as 0x04 || x || y (uncompressed SEC1).
byte[] expectedIssuerUncomp = new byte[65];
expectedIssuerUncomp[0] = 0x04;
System.arraycopy(KNOWN_64_BYTES_ISSUER, 0, expectedIssuerUncomp, 1, 64);
byte[] gotIssuerUncomp = new byte[65];
restored.copyCredentialIssuerPubKeyUncomp(gotIssuerUncomp, (short) 0);
assertArrayEquals(expectedIssuerUncomp, gotIssuerUncomp);
assertTrue(restored.hasCredentialIssuerPubKey());
// The unset credential pubkey buffer should round-trip as all-zero —
// resetForTesting cleared it and we never wrote to it.
assertArrayEquals(new byte[CredentialStore.CRED_PUBK_LEN],
restored.copyCredentialPubKey());
// accessDocumentLen=0, so copyAccessDocument() returns an empty array.
assertArrayEquals(new byte[0], restored.copyAccessDocument());
// Per-predicate assertions: any swap in the boolean section of
// writeTo flips at least one of these.
assertTrue(restored.isCommitted());
assertTrue(restored.hasCredentialPrivKey());
assertFalse(restored.hasCredentialPubKey());
assertTrue(restored.hasReaderPubKey());
assertFalse(restored.hasAccessDocument());
}
@Test
void accessDocumentVerifiedRoundTripsThroughSerialize() {
CredentialStore s1 = CredentialStore.bootstrap();
s1.resetForTesting();
s1.markAccessDocumentVerifiedForTesting();
RecordingSink buf = new RecordingSink();
s1.writeTo(buf);
CredentialStore s2 = CredentialStore.readFrom(buf.toSource());
assertTrue(s2.isAccessDocumentVerified(),
"verified flag must survive serialize/deserialize round-trip");
}
/**
* Forges a payload with a bogus FIELD_VERSION byte and asserts that
* {@link CredentialStore#readFrom} rejects it with
* {@code ISOException(SW_DATA_INVALID)}. The version check is the very
* first read in readFrom, so no further padding is required — the
* replay source is never read past the version byte.
*/
@Test
void readFromRejectsWrongFieldVersion() {
RecordingSink buf = new RecordingSink();
buf.write((byte) 0x7F); // != FIELD_VERSION
ISOException ex = assertThrows(ISOException.class,
() -> CredentialStore.readFrom(buf.toSource()));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason());
}
/**
* Test-only buffer that captures sink writes as a typed event log and
* replays them as a {@link SerializationSource}. Mirrors AMD-H
* {@code Element} semantics by making a defensive copy of every
* {@code byte[]} handed to {@link #write(byte[])} — once written, the
* stored data must not be affected by subsequent caller mutations.
*/
private static final class RecordingSink implements SerializationSink {
private final List<Object> events = new ArrayList<Object>();
@Override
public SerializationSink write(byte b) {
events.add(Byte.valueOf(b));
return this;
}
@Override
public SerializationSink write(short s) {
events.add(Short.valueOf(s));
return this;
}
@Override
public SerializationSink write(boolean z) {
events.add(Boolean.valueOf(z));
return this;
}
@Override
public SerializationSink write(byte[] arr) {
// Defensive copy: Element treats stored references as
// immutable from the caller's perspective.
byte[] copy = new byte[arr.length];
System.arraycopy(arr, 0, copy, 0, arr.length);
events.add(copy);
return this;
}
SerializationSource toSource() {
return new ReplaySource(events);
}
}
private static final class ReplaySource implements SerializationSource {
private final List<Object> events;
private int idx;
ReplaySource(List<Object> events) {
this.events = events;
this.idx = 0;
}
@Override
public byte readByte() {
return ((Byte) events.get(idx++)).byteValue();
}
@Override
public short readShort() {
return ((Short) events.get(idx++)).shortValue();
}
@Override
public boolean readBoolean() {
return ((Boolean) events.get(idx++)).booleanValue();
}
@Override
public byte[] readByteArray() {
return (byte[]) events.get(idx++);
}
}
}

View File

@@ -0,0 +1,91 @@
package com.dangerousthings.aliro;
import javacard.framework.ISO7816;
import javacard.framework.ISOException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Tests for {@link DeviceRequestParser} — structural validation of the inbound
* mdoc DeviceRequest (ISO 18013-5 §8.3.2.1).
*
* <p>M2D's ENVELOPE handler receives a DeviceRequest, must confirm its shape
* before discarding the contents (we always return our single Access Document),
* and reject malformed input with the canonical SW codes:
* <ul>
* <li>{@code SW_DATA_INVALID (0x6984)} — malformed CBOR or wrong shape</li>
* <li>{@code SW_CONDITIONS_NOT_SATISFIED (0x6985)} — unsupported version</li>
* </ul>
*
* <p>Test vectors were generated via cbor2 (canonical encoding); see the
* generator script in the M2D.1 plan. The Python equivalents:
* <pre>
* VALID = cbor2.dumps({"version":"1.0","docRequests":[{"itemsRequest":cbor2.dumps({"docType":"org.iso.18013.5.1.mDL","nameSpaces":{}})}]}, canonical=True)
* BAD_VERSION = cbor2.dumps({"version":"2.0","docRequests":[{"itemsRequest":b"\x40"}]}, canonical=True)
* TRUNCATED = VALID[:8]
* </pre>
*/
class DeviceRequestParserTest {
/**
* Canonical CBOR for a minimal-but-valid DeviceRequest:
* {@code {"version":"1.0","docRequests":[{"itemsRequest":<bstr>}]}}
* with itemsRequest containing an inner CBOR
* {@code {"docType":"org.iso.18013.5.1.mDL","nameSpaces":{}}}.
*/
private static final byte[] VALID = hex(
"a26776657273696f6e63312e306b646f63526571756573747381"
+ "a16c6974656d7352657175657374582ba267646f6354797065"
+ "756f72672e69736f2e31383031332e352e312e6d444c"
+ "6a6e616d65537061636573a0");
/** Same shape as VALID but version tstr value is "2.0". */
private static final byte[] BAD_VERSION = hex(
"a26776657273696f6e63322e306b646f63526571756573747381"
+ "a16c6974656d73526571756573744140");
/** First 8 bytes of VALID — truncated mid-key, malformed CBOR. */
private static final byte[] TRUNCATED = hex("a26776657273696f");
@Test
void parseDeviceRequest_validShape_returnsOk() {
byte[] scratch = new byte[4];
// Should not throw.
DeviceRequestParser.validate(
VALID, (short) 0, (short) VALID.length,
scratch, (short) 0);
}
@Test
void parseDeviceRequest_unknownVersion_throwsConditionsNotSatisfied() {
byte[] scratch = new byte[4];
ISOException ex = assertThrows(ISOException.class, () ->
DeviceRequestParser.validate(
BAD_VERSION, (short) 0, (short) BAD_VERSION.length,
scratch, (short) 0));
assertEquals(ISO7816.SW_CONDITIONS_NOT_SATISFIED, ex.getReason(),
"unknown version must throw SW_CONDITIONS_NOT_SATISFIED (0x6985)");
}
@Test
void parseDeviceRequest_malformedCbor_throwsDataInvalid() {
byte[] scratch = new byte[4];
ISOException ex = assertThrows(ISOException.class, () ->
DeviceRequestParser.validate(
TRUNCATED, (short) 0, (short) TRUNCATED.length,
scratch, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason(),
"truncated CBOR must throw SW_DATA_INVALID (0x6984)");
}
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;
}
}

View File

@@ -0,0 +1,147 @@
package com.dangerousthings.aliro;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for {@link DeviceResponseBuilder} — emits the mdoc DeviceResponse
* (ISO 18013-5 §8.3.2.1.2.2 / Aliro Table 8-22) carrying a cached Access
* Document at {@code documents[0].issuerSigned.issuerAuth}.
*
* <p>The reference {@link #DEVICE_RESPONSE_HEX} was generated against the
* real Access Document at {@code /home/work/aliro-trust/access_document.bin}
* via a manual canonical-CBOR builder (NOT {@code cbor2.dumps(canonical=True)}
* on the whole structure — that would re-encode the inner COSE_Sign1 and the
* applet doesn't re-encode it; the AD bytes are embedded verbatim). See the
* M2D.2 plan for the generator script.
*
* <p>Canonical key ordering at each map level (RFC 8949 §4.2.1: sort by
* encoded-key-length then lexicographically):
* <ul>
* <li>DeviceResponse: {@code "status"} (7B), {@code "version"} (8B),
* {@code "documents"} (11B)</li>
* <li>document: {@code "docType"} (8B), {@code "issuerSigned"} (13B)</li>
* <li>issuerSigned: {@code "issuerAuth"} (11B), {@code "nameSpaces"} (11B,
* 'i' &lt; 'n' so issuerAuth first)</li>
* </ul>
*/
class DeviceResponseBuilderTest {
/**
* Real Access Document from {@code /home/work/aliro-trust/access_document.bin}
* — a 272-byte COSE_Sign1 (4-element array, tag-less per Aliro Table 8-22's
* literal "COSE_Sign1" embedding).
*/
private static final byte[] ACCESS_DOC_HEX = hex(
"8443a10126a104488dae9624eed9280c58bca7613163312e30613267534"
+ "8412d3235366133a06134a16131a401022001215820aa3115ead5d1fec"
+ "ca289aef3598790a6dba23edbe9b14e6818ac683e31a5af0222582083"
+ "9dcc32bcd32924a942c3b9999f6cbf46960396e69606fe4295e83a0c7"
+ "787a2613567616c69726f2d616136a36131c074323032362d30342d3"
+ "1395432303a32393a31365a6132c074323032362d30342d3139543230"
+ "3a32393a31365a6133c074323032372d30342d31395432303a32393a3"
+ "1365a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac436"
+ "44e92c8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d"
+ "38df6f1c5b1caf685c365b22a3f2a");
/**
* Expected DeviceResponse bytes for {@link #ACCESS_DOC_HEX}. 372 bytes
* total (272 AD + 100 B wrapper).
*/
private static final byte[] DEVICE_RESPONSE_HEX = hex(
"a366737461747573006776657273696f6e63312e3069646f63756d656e"
+ "747381a267646f6354797065756f72672e69736f2e31383031332e352e"
+ "312e6d444c6c6973737565725369676e6564a26a697373756572417574"
+ "688443a10126a104488dae9624eed9280c58bca7613163312e30613267"
+ "5348412d3235366133a06134a16131a401022001215820aa3115ead5d1"
+ "fecca289aef3598790a6dba23edbe9b14e6818ac683e31a5af02225820"
+ "839dcc32bcd32924a942c3b9999f6cbf46960396e69606fe4295e83a0c"
+ "7787a2613567616c69726f2d616136a36131c074323032362d30342d31"
+ "395432303a32393a31365a6132c074323032362d30342d31395432303a"
+ "32393a31365a6133c074323032372d30342d31395432303a32393a3136"
+ "5a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac43644e9"
+ "2c8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d38df6"
+ "f1c5b1caf685c365b22a3f2a6a6e616d65537061636573a0");
@Test
void buildDeviceResponse_outputBytes_matchPythonReference() {
// Buffer sized generously — actual is 372 B for a 272 B AD.
byte[] out = new byte[ACCESS_DOC_HEX.length + 128];
short written = DeviceResponseBuilder.build(
ACCESS_DOC_HEX, (short) 0, (short) ACCESS_DOC_HEX.length,
out, (short) 0);
assertEquals(DEVICE_RESPONSE_HEX.length, written,
"DeviceResponse byte count must match Python canonical-CBOR reference");
assertArrayEquals(
DEVICE_RESPONSE_HEX,
Arrays.copyOfRange(out, 0, written),
"DeviceResponse bytes must match Python canonical-CBOR reference byte-for-byte");
}
/**
* Decodes the builder output and confirms the spec shape: top-level
* 3-entry map, single document with docType + issuerSigned, issuerSigned
* has empty nameSpaces and the AD embedded verbatim at issuerAuth.
*
* <p>Uses {@link StructuralCbor#elementSpan} to walk — that's the same
* tooling M2D.3 will use on the receiving side, so a passing structural
* walk here also confirms the output is parseable by our own parser.
*/
@Test
void buildDeviceResponse_shape_matchesAliroTable8_22() {
byte[] out = new byte[ACCESS_DOC_HEX.length + 128];
short written = DeviceResponseBuilder.build(
ACCESS_DOC_HEX, (short) 0, (short) ACCESS_DOC_HEX.length,
out, (short) 0);
byte[] scratch = new byte[4];
// Whole-response span must equal `written` — no trailing garbage.
short span = StructuralCbor.elementSpan(
out, (short) 0, written, scratch, (short) 0);
assertEquals(written, span,
"DeviceResponse span must consume exactly the written bytes");
// Top-level: map(3) — major type 5, additional info 3.
assertEquals((byte) 0xA3, out[0],
"top-level must be a 3-entry CBOR map (0xA3)");
// Locate the AD inside the output and confirm it's byte-identical.
// Brute-force scan for the AD's first byte (0x84 — COSE_Sign1 array
// header) and verify the run matches. Only one such run should exist
// because the wrapper itself doesn't contain that byte sequence.
int adOffset = indexOf(out, 0, written, ACCESS_DOC_HEX);
assertEquals(true, adOffset >= 0,
"Access Document bytes must appear verbatim somewhere in the output");
assertArrayEquals(
ACCESS_DOC_HEX,
Arrays.copyOfRange(out, adOffset, adOffset + ACCESS_DOC_HEX.length),
"Embedded Access Document must be byte-identical to the input");
}
/** Naive byte-array indexOf — sufficient for the small test buffers. */
private static int indexOf(byte[] haystack, int from, int to, byte[] needle) {
outer:
for (int i = from; i <= to - needle.length; i++) {
for (int j = 0; j < needle.length; j++) {
if (haystack[i + j] != needle[j]) continue outer;
}
return i;
}
return -1;
}
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;
}
}

View File

@@ -2,12 +2,15 @@ package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator; import com.licel.jcardsim.smartcardio.CardSimulator;
import javacard.framework.AID; import javacard.framework.AID;
import javacard.framework.Applet;
import javax.smartcardio.CommandAPDU; import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU; import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
/** /**
* Tests for the PersonalizationApplet — our proprietary provisioning * Tests for the PersonalizationApplet — our proprietary provisioning
@@ -22,14 +25,54 @@ class PersonalizationAppletTest {
private static final byte INS_SET_READER_PUBK = (byte) 0x22; private static final byte INS_SET_READER_PUBK = (byte) 0x22;
private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23; private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23;
private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24; private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24;
private static final byte INS_SET_CRED_ISSUER_PUBK = (byte) 0x25;
private static final byte INS_COMMIT = (byte) 0x2C; private static final byte INS_COMMIT = (byte) 0x2C;
/**
* Canonical Aliro Access Document COSE_Sign1 bytes (272 B) — generated
* by harness/aliro_harness.issuer.cose against the trustgen-issued
* issuer.pem. Layout is the bare 4-element COSE_Sign1 array (no wrapper);
* CoseVerifier.verifyCoseSign1 verifies these bytes verbatim against the
* uncompressed issuer pubkey below.
*
* <p>Reproducibility: dump via
* <pre>
* python - &lt;&lt;'EOF'
* from pathlib import Path
* from cryptography.hazmat.primitives import serialization
* ad = Path("/home/work/aliro-trust/access_document.bin").read_bytes()
* iss = serialization.load_pem_private_key(
* Path("/home/work/aliro-trust/issuer.pem").read_bytes(), password=None)
* pub = iss.public_key().public_bytes(
* serialization.Encoding.X962,
* serialization.PublicFormat.UncompressedPoint)
* print(ad.hex()); print(pub[1:].hex())
* EOF
* </pre>
*/
private static final byte[] AD_GOOD = hex(
"8443a10126a104488dae9624eed9280c58bca7613163312e306132675348412d3235"
+ "366133a06134a16131a401022001215820aa3115ead5d1fecca289aef3598790a6"
+ "dba23edbe9b14e6818ac683e31a5af02225820839dcc32bcd32924a942c3b9999f"
+ "6cbf46960396e69606fe4295e83a0c7787a2613567616c69726f2d616136a36131"
+ "c074323032362d30342d31395432303a32393a31365a6132c074323032362d3034"
+ "2d31395432303a32393a31365a6133c074323032372d30342d31395432303a3239"
+ "3a31365a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac43644e92c"
+ "8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d38df6f1c5b1caf6"
+ "85c365b22a3f2a");
/** Issuer pubkey x||y (64 B) corresponding to ISSUER_PEM at trustgen time.
* This is what the applet INS_SET_CRED_ISSUER_PUBK accepts (no 0x04 prefix). */
private static final byte[] ISSUER_PUBK_XY = hex(
"ebee35bacdfc585295da337b29b6f5e8b86d4056e22c793e4bf033e19e9ba31d"
+ "6b8679bb41a64c4f7f8a37972b6315b4fe91248527c1487caf3a6fe31f75bbc4");
private CardSimulator sim; private CardSimulator sim;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
CredentialStore.get().resetForTesting(); // installApplet → PersonalizationApplet.<init> → CredentialStore.bootstrap()
// gives us a fresh store for every test — no explicit reset needed any more.
sim = new CardSimulator(); sim = new CardSimulator();
AID aid = new AID(AliroAids.PROVISIONING, (short) 0, (byte) AliroAids.PROVISIONING.length); AID aid = new AID(AliroAids.PROVISIONING, (short) 0, (byte) AliroAids.PROVISIONING.length);
sim.installApplet(aid, PersonalizationApplet.class); sim.installApplet(aid, PersonalizationApplet.class);
@@ -100,12 +143,14 @@ class PersonalizationAppletTest {
@Test @Test
void accessDocumentChunkedWriteAndFinalizeRoundTrip() { void accessDocumentChunkedWriteAndFinalizeRoundTrip() {
byte[] ad = new byte[420]; // Uses the canonical AD_GOOD vector because finalize now triggers
for (int i = 0; i < ad.length; i++) ad[i] = (byte) ((i * 13) ^ 0xA5); // IssuerAuth verify — random bytes wouldn't verify. The shape under
// test here is still the multi-chunk write path + final length.
assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW());
// Two chunks: [0..200), [200..420) byte[] ad = AD_GOOD;
byte[] chunk1 = java.util.Arrays.copyOfRange(ad, 0, 200); byte[] chunk1 = java.util.Arrays.copyOfRange(ad, 0, 200);
byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, 420); byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, ad.length);
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW()); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW());
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW()); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW());
assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, ad.length, null).getSW()); assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, ad.length, null).getSW());
@@ -128,6 +173,9 @@ class PersonalizationAppletTest {
@Test @Test
void accessDocumentFinalizeBeyondMaxSizeFails() { void accessDocumentFinalizeBeyondMaxSizeFails() {
// Set issuer pubkey so the precondition check passes — we want to
// test the length bound specifically, not the missing-pubkey path.
assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW());
byte[] chunk = new byte[10]; byte[] chunk = new byte[10];
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW()); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1; int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1;
@@ -162,4 +210,117 @@ class PersonalizationAppletTest {
assertEquals(0x6985, send(INS_COMMIT, null).getSW(), assertEquals(0x6985, send(INS_COMMIT, null).getSW(),
"a second COMMIT must also be refused"); "a second COMMIT must also be refused");
} }
@Test
void personalizationAppletOwnsCredentialStoreInstance() throws Exception {
// setUp() has already installed PersonalizationApplet via jcardsim.
// Reach into the runtime and pull out the installed applet instance,
// then assert it owns a non-null `store` instance field and that the
// static publish-point in CredentialStore aliases the same object.
Applet applet = getInstalledApplet(sim, AliroAids.PROVISIONING);
org.junit.jupiter.api.Assertions.assertTrue(applet instanceof PersonalizationApplet,
"installed applet at PROVISIONING AID must be a PersonalizationApplet");
java.lang.reflect.Field storeField = PersonalizationApplet.class.getDeclaredField("store");
storeField.setAccessible(true);
Object instanceStore = storeField.get(applet);
assertNotNull(instanceStore,
"PersonalizationApplet must own a CredentialStore instance field");
assertSame(instanceStore, CredentialStore.get(),
"Static publish-point must alias the instance-owned store");
}
@Test
void finalizeAccessDocument_validIssuerAuth_setsVerifiedFlag() {
// Push the issuer pubkey, write the AD, then finalize with the exact
// total length. The applet should run CoseVerifier internally, see
// the signature verify, and atomically flip both the finalized and
// verified flags.
assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW());
// Single chunk fits in a short-form APDU (255B max) — except AD is
// 272B. Split into two chunks like the orchestrator does.
byte[] chunk1 = java.util.Arrays.copyOfRange(AD_GOOD, 0, 200);
byte[] chunk2 = java.util.Arrays.copyOfRange(AD_GOOD, 200, AD_GOOD.length);
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW());
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW());
assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, AD_GOOD.length, null).getSW(),
"finalize with valid IssuerAuth must succeed");
CredentialStore s = CredentialStore.get();
org.junit.jupiter.api.Assertions.assertTrue(s.hasAccessDocument(),
"finalized flag must be set after successful verify");
org.junit.jupiter.api.Assertions.assertTrue(s.isAccessDocumentVerified(),
"verified flag must be set after successful verify");
}
@Test
void finalizeAccessDocument_tamperedIssuerAuth_returnsErrorAndLeavesUnverified() {
assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW());
// Flip one byte in the payload region (around offset 30 — well
// inside the issuer-signed payload, away from outer CBOR headers).
byte[] tampered = new byte[AD_GOOD.length];
System.arraycopy(AD_GOOD, 0, tampered, 0, AD_GOOD.length);
tampered[30] ^= (byte) 0x01;
byte[] chunk1 = java.util.Arrays.copyOfRange(tampered, 0, 200);
byte[] chunk2 = java.util.Arrays.copyOfRange(tampered, 200, tampered.length);
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW());
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW());
// Spec says verify failure → SW_DATA_INVALID (0x6984).
assertEquals(0x6984, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, tampered.length, null).getSW(),
"finalize with tampered IssuerAuth must return SW_DATA_INVALID");
CredentialStore s = CredentialStore.get();
org.junit.jupiter.api.Assertions.assertFalse(s.isAccessDocumentVerified(),
"verified flag must stay false on tamper");
org.junit.jupiter.api.Assertions.assertFalse(s.hasAccessDocument(),
"finalized flag must stay false on tamper — atomic store guards both");
}
@Test
void finalizeAccessDocument_missingIssuerPubkey_returnsConditionsNotSatisfied() {
// Skip the SET_CRED_ISSUER_PUBK step entirely. Even with a valid AD
// loaded, finalize cannot run verify without the trust anchor and
// must refuse with SW_CONDITIONS_NOT_SATISFIED.
byte[] chunk1 = java.util.Arrays.copyOfRange(AD_GOOD, 0, 200);
byte[] chunk2 = java.util.Arrays.copyOfRange(AD_GOOD, 200, AD_GOOD.length);
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW());
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW());
assertEquals(0x6985, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, AD_GOOD.length, null).getSW(),
"finalize without an issuer pubkey must return SW_CONDITIONS_NOT_SATISFIED");
CredentialStore s = CredentialStore.get();
org.junit.jupiter.api.Assertions.assertFalse(s.hasAccessDocument());
org.junit.jupiter.api.Assertions.assertFalse(s.isAccessDocumentVerified());
}
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;
}
/** Reaches through jcardsim's protected runtime/applet APIs to fetch the
* installed Applet instance for a given AID. Reflection-only — this is
* test infrastructure, not production code. */
private static Applet getInstalledApplet(CardSimulator sim, byte[] aidBytes) throws Exception {
java.lang.reflect.Field runtimeField = null;
for (Class<?> c = sim.getClass(); c != null && runtimeField == null; c = c.getSuperclass()) {
try { runtimeField = c.getDeclaredField("runtime"); } catch (NoSuchFieldException ignore) {}
}
runtimeField.setAccessible(true);
Object runtime = runtimeField.get(sim);
java.lang.reflect.Method getApplet = runtime.getClass().getDeclaredMethod("getApplet", AID.class);
getApplet.setAccessible(true);
AID aid = new AID(aidBytes, (short) 0, (byte) aidBytes.length);
return (Applet) getApplet.invoke(runtime, aid);
}
} }

View File

@@ -198,8 +198,14 @@ final class ReaderSide {
* @param credentialEphemPubKey65 the 0x86 TLV value from the AUTH0 response * @param credentialEphemPubKey65 the 0x86 TLV value from the AUTH0 response
*/ */
byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65) { byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65) {
return deriveExpeditedSKDevice(credentialEphemPubKey65, (byte) 0x01);
}
/** Same but lets the test specify the AUTH1 command_parameters byte that
* was actually sent (matters for §8.3.1.13 salt_volatile). */
byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65, byte auth1CmdParams) {
return java.util.Arrays.copyOfRange( return java.util.Arrays.copyOfRange(
deriveExpeditedKeyMaterial(credentialEphemPubKey65), 32, 64); deriveExpeditedKeyMaterial(credentialEphemPubKey65, auth1CmdParams), 32, 64);
} }
/** /**
@@ -209,11 +215,26 @@ final class ReaderSide {
* URSK[128..160). * URSK[128..160).
*/ */
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65) { byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65) {
return deriveExpeditedKeyMaterial(credentialEphemPubKey65, (byte) 0x01);
}
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65, byte auth1CmdParams) {
byte[] zab = ecdhSharedX( byte[] zab = ecdhSharedX(
(ECPrivateKey) ephemeral.getPrivate(), (ECPrivateKey) ephemeral.getPrivate(),
credentialEphemPubKey65); credentialEphemPubKey65);
byte[] kdh = hkdf(zab, transactionId, new byte[0], 32); // Kdh per §8.3.1.4: X9.63 KDF -> for 32B output one SHA-256:
byte[] salt = buildSaltVolatile(credentialEphemPubKey65); // Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
byte[] kdh;
try {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
md.update(zab);
md.update(new byte[] { 0x00, 0x00, 0x00, 0x01 });
md.update(transactionId);
kdh = md.digest();
} catch (java.security.NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
byte[] salt = buildSaltVolatile(credentialEphemPubKey65, auth1CmdParams);
byte[] info = java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33); byte[] info = java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33);
return hkdf(kdh, salt, info, 160); return hkdf(kdh, salt, info, 160);
} }
@@ -279,6 +300,14 @@ final class ReaderSide {
// ---------- helpers ---------- // ---------- helpers ----------
private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65) { private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65) {
return buildSaltVolatile(credentialEphemPubKey65, (byte) 0x01);
}
private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65, byte auth1CmdParams) {
// Spec §8.3.1.13 salt_volatile ends at the 0xA5 proprietary TLV. The
// credentialEphemPubKey65 parameter is unused (previously this
// method appended x(credential_long_term_pub), which was a misread of
// the spec).
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
try { try {
// x(reader_group_identifier_key) = this reader's long-term pubkey.x // x(reader_group_identifier_key) = this reader's long-term pubkey.x
@@ -294,30 +323,23 @@ final class ReaderSide {
ECPoint re = ((ECPublicKey) ephemeral.getPublic()).getW(); ECPoint re = ((ECPublicKey) ephemeral.getPublic()).getW();
out.write(toFixed32(re.getAffineX().toByteArray())); out.write(toFixed32(re.getAffineX().toByteArray()));
out.write(transactionId); out.write(transactionId);
out.write((byte) 0x00); // command_parameters = standard // flag = AUTH1 command_parameters || AUTH0 authentication_policy.
// AUTH1 cmd_params is passed in (default 0x01 = "request
// credential_PubK"). X-CUBE-ALIRO uses AUTH1's cmd_params here.
out.write(auth1CmdParams);
out.write((byte) 0x00); // authentication_policy = none out.write((byte) 0x00); // authentication_policy = none
out.write(PROPRIETARY_A5_TLV); out.write(PROPRIETARY_A5_TLV);
ECPoint cl = credentialLongTermPubX();
out.write(toFixed32(cl.getAffineX().toByteArray()));
} catch (java.io.IOException e) { } catch (java.io.IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
return out.toByteArray(); return out.toByteArray();
} }
/** Returns the credential's long-term public key as an ECPoint. Set via {@link #setCredentialLongTermPublic}. */ /** No-op since the salt no longer depends on the credential's long-term
private ECPoint credentialLongTermPubX() { * key. Kept so existing test call sites still link. */
if (credentialLongTermPub == null) {
throw new IllegalStateException("credentialLongTermPub not set — call setCredentialLongTermPublic()");
}
return credentialLongTermPub.getW();
}
private ECPublicKey credentialLongTermPub;
/** Test wiring: tells this reader what credential_PubK the card holds so salt_volatile can include x(). */
void setCredentialLongTermPublic(ECPublicKey pub) { void setCredentialLongTermPublic(ECPublicKey pub) {
this.credentialLongTermPub = pub; // intentional: salt_volatile dropped x(credential_long_term_pub) when
// the spec misread was fixed; this wiring is no longer needed.
} }
private static byte[] ecdhSharedX(ECPrivateKey priv, byte[] peerPubUncomp65) { private static byte[] ecdhSharedX(ECPrivateKey priv, byte[] peerPubUncomp65) {

View File

@@ -1,12 +1,19 @@
package com.dangerousthings.aliro; package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator; import com.licel.jcardsim.smartcardio.CardSimulator;
import java.security.KeyPair;
import javacard.framework.AID; import javacard.framework.AID;
import javacard.framework.Applet;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.smartcardio.CommandAPDU; import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU; import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
/** /**
@@ -21,7 +28,9 @@ class StepUpAppletTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
CredentialStore.get().resetForTesting(); // StepUpApplet doesn't touch CredentialStore yet; no need to reset
// it here. Once StepUpApplet starts reading credentials, this test
// will need to install PersonalizationApplet first.
sim = new CardSimulator(); sim = new CardSimulator();
AID aid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length); AID aid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
sim.installApplet(aid, StepUpApplet.class); sim.installApplet(aid, StepUpApplet.class);
@@ -64,6 +73,484 @@ class StepUpAppletTest {
"wrong CLA must return SW_CLA_NOT_SUPPORTED"); "wrong CLA must return SW_CLA_NOT_SUPPORTED");
} }
/**
* After AUTH1 succeeds on {@link AliroApplet}, {@code SessionContext} is
* armed with the 32-byte {@code StepUpSK}. When the reader subsequently
* SELECTs the Step-Up AID (per spec §10.2.x and the empirical X-CUBE-ALIRO
* flow), {@link StepUpApplet#select()} must derive
* {@code StepUpSKDevice}/{@code StepUpSKReader} via
* {@link AliroCrypto#deriveStepUpSessionKeys} and stage both in
* {@code CLEAR_ON_DESELECT} transient fields. This pins the wiring end-to-end.
*/
@Test
void selectAfterArmedAuth1DerivesStepUpSessionKeys() throws Exception {
// Full applet stack: PersonalizationApplet (via ReaderSide.provision),
// AliroApplet under EXPEDITED, StepUpApplet under STEP_UP. The bare
// StepUpApplet install done by setUp() is overwritten by re-installing
// here; jcardsim treats the AID install as idempotent for our purposes.
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);
// SELECT expedited, run AUTH0 + AUTH1 (mirrors
// AliroAppletAuth1Test.auth1SuccessArmsStepUpContextWithExpectedStepUpSK).
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
"SELECT expedited must succeed");
reader.startTransaction();
ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
reader.buildAuth0Data(), 256));
assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed");
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(), "AUTH1 must succeed");
// Compute the StepUpSK the test knows the card holds: it's bytes
// 64..96 of the expedited-standard derived_keys_volatile material.
byte[] stepUpSK = java.util.Arrays.copyOfRange(
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
// SELECT the Step-Up AID — this should trigger StepUpApplet.select(),
// which sees SessionContext armed, copies out StepUpSK, derives the
// two session keys, and stashes them.
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
"SELECT step-up must succeed regardless of armed state");
// Reference HKDF-SHA-256 with empty salt — matches AliroCrypto.deriveStepUpSessionKeys.
byte[] expectedDevice = hkdfStepUp(stepUpSK, "SKDevice");
byte[] expectedReader = hkdfStepUp(stepUpSK, "SKReader");
byte[] gotDevice = new byte[32];
byte[] gotReader = new byte[32];
StepUpApplet appletInstance = stepUpAppletInstance(sim, stepUpAid);
appletInstance.copyStepUpSKDeviceForTesting(gotDevice, (short) 0);
appletInstance.copyStepUpSKReaderForTesting(gotReader, (short) 0);
assertArrayEquals(expectedDevice, gotDevice,
"StepUpSKDevice = HKDF-Expand(HKDF-Extract(empty, StepUpSK), \"SKDevice\", 32)");
assertArrayEquals(expectedReader, gotReader,
"StepUpSKReader = HKDF-Expand(HKDF-Extract(empty, StepUpSK), \"SKReader\", 32)");
}
/**
* After SELECT-Step-Up has armed {@code StepUpSKReader}/{@code StepUpSKDevice},
* the X-CUBE-ALIRO firmware sends a "Reader Status sub-event request" via
* the EXCHANGE command (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-19.
*
* <p>M2 wire shape (plaintext under GCM):
* <pre>
* sub_event_id : 1B ; 0x01 = ReaderStatusRequest
* payload_len : 1B
* payload : Lb ; empty for ReaderStatusRequest
* </pre>
*
* <p>The applet validates the structure, then emits a Reader Status response
* sub-event (Table 8-20) plaintext {@code [sub_event_id=0x01, status=0x00,
* payload_len=0x00]}, GCM-encrypted under {@code StepUpSKDevice} +
* device IV (counter=1). Ciphertext+tag = 3 + 16 = 19 B; fits in one APDU.
*/
@Test
void exchangeReturnsEncryptedReaderStatusResponse() 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);
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
"SELECT expedited must succeed");
reader.startTransaction();
ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
reader.buildAuth0Data(), 256));
assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed");
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(), "AUTH1 must succeed");
byte[] stepUpSK = java.util.Arrays.copyOfRange(
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
byte[] stepUpSKDevice = hkdfStepUp(stepUpSK, "SKDevice");
// SELECT the Step-Up AID -- arms session keys + initialises both
// counters to 0x00000001.
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
"SELECT step-up must succeed");
// Reader-side encrypt of the spec Reader Status request plaintext:
// sub_event_id=0x01, payload_len=0x00 (no payload bytes follow).
byte[] readerIv = new byte[12];
readerIv[11] = 0x01;
byte[] requestPt = new byte[] { 0x01, 0x00 };
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcmEnc.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(stepUpSKReader, "AES"),
new GCMParameterSpec(128, readerIv));
byte[] ctAndTag = gcmEnc.doFinal(requestPt); // 2 + 16 = 18 bytes
ResponseAPDU exchangeResp = sim.transmitCommand(
new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256));
assertEquals(0x9000, exchangeResp.getSW(),
"EXCHANGE with valid Reader Status request must return SW=9000");
byte[] respCt = exchangeResp.getData();
assertEquals(19, respCt.length,
"Reader Status response plaintext is 3 B + 16 B GCM tag = 19 B");
// Decrypt under StepUpSKDevice + device IV (counter=1 — this is the
// first device-side message in the Step-Up session).
byte[] deviceIv = new byte[12];
deviceIv[7] = 0x01;
deviceIv[11] = 0x01;
Cipher gcmDec = Cipher.getInstance("AES/GCM/NoPadding");
gcmDec.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(stepUpSKDevice, "AES"),
new GCMParameterSpec(128, deviceIv));
byte[] respPt = gcmDec.doFinal(respCt);
assertArrayEquals(new byte[] { 0x01, 0x00, 0x00 }, respPt,
"Reader Status response plaintext = [sub_event_id=0x01, status=0x00, payload_len=0x00]");
}
/**
* EXCHANGE with an unknown {@code sub_event_id} must return
* {@code SW_DATA_INVALID (0x6984)} per M2 spec §8.3.3.5 — M2 only supports
* sub_event_id 0x01 (ReaderStatusRequest); 0x02 (TransactionEnd) and
* higher are reserved / not implemented.
*/
@Test
void exchangeWithUnknownSubEventIdReturnsDataInvalid() 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);
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
"SELECT expedited must succeed");
reader.startTransaction();
ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
reader.buildAuth0Data(), 256));
assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed");
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(), "AUTH1 must succeed");
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(),
"SELECT step-up must succeed");
// Unknown sub_event_id (0x02 = TransactionEnd, reserved for M3+).
byte[] readerIv = new byte[12];
readerIv[11] = 0x01;
byte[] requestPt = new byte[] { 0x02, 0x00 };
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcmEnc.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(stepUpSKReader, "AES"),
new GCMParameterSpec(128, readerIv));
byte[] ctAndTag = gcmEnc.doFinal(requestPt);
ResponseAPDU exchangeResp = sim.transmitCommand(
new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256));
assertEquals(0x6984, exchangeResp.getSW(),
"unknown sub_event_id must return SW_DATA_INVALID");
}
/**
* M2D.3 + M2D.4 end-to-end: after SELECT-Step-Up the reader sends an
* ENVELOPE (CLA=0x00 INS=0xC3) carrying an encrypted mdoc DeviceRequest.
* The applet decrypts under StepUpSKReader (§8.3.1.9 IV), runs the
* {@link DeviceRequestParser} structural validation, builds the real
* {@link DeviceResponseBuilder} output around the cached Access Document,
* and encrypts under StepUpSKDevice (§8.3.1.6 IV).
*
* <p>The encrypted response is 372 B ciphertext + 16 B GCM tag = 388 B,
* which exceeds the ~252 B APDU outgoing window. The applet therefore
* uses ISO 7816 response chaining: the first ENVELOPE response carries
* the head chunk + SW=61xx ("xx more bytes available"), and the reader
* pulls remaining chunks via GET RESPONSE (INS=0xC0) until SW=9000.
*/
@Test
void envelopeAfterStepUpSelectReturnsRealDeviceResponseViaChaining() 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);
// 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.
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
"SELECT expedited must succeed");
reader.startTransaction();
ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
reader.buildAuth0Data(), 256));
assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed");
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(), "AUTH1 must succeed");
// Compute both StepUpSK leg keys the card now holds.
byte[] stepUpSK = java.util.Arrays.copyOfRange(
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
byte[] stepUpSKDevice = hkdfStepUp(stepUpSK, "SKDevice");
// SELECT the Step-Up AID -- arms StepUpApplet's session keys and
// initialises both counters to 0x00000001.
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
"SELECT step-up must succeed");
// Reader-side encrypt of a valid DeviceRequest CBOR under the reader IV
// (00*8 || 00 00 00 01). The applet decrypts, validates the structure
// via DeviceRequestParser, then emits the cached DeviceResponse.
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(VALID_DEVICE_REQUEST);
// 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(
new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256));
int sw = envelopeResp.getSW();
org.junit.jupiter.api.Assertions.assertEquals(
0x6100, sw & 0xFF00,
"ENVELOPE response > APDU window must yield SW=61xx; got 0x" + Integer.toHexString(sw));
// Drain chunks via GET RESPONSE (INS=0xC0) until SW=9000.
java.io.ByteArrayOutputStream agg = new java.io.ByteArrayOutputStream();
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");
byte[] respBody = agg.toByteArray();
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];
deviceIv[7] = 0x01;
deviceIv[11] = 0x01;
Cipher gcmDec = Cipher.getInstance("AES/GCM/NoPadding");
gcmDec.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(stepUpSKDevice, "AES"),
new GCMParameterSpec(128, deviceIv));
byte[] decoded = gcmDec.doFinal(respBody);
assertArrayEquals(DEVICE_RESPONSE_HEX, decoded,
"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
* AliroCrypto.deriveStepUpSessionKeys's spec-pinned HKDF computation. */
private static byte[] hkdfStepUp(byte[] ikm, String info) throws Exception {
Mac extract = Mac.getInstance("HmacSHA256");
extract.init(new SecretKeySpec(new byte[32], "HmacSHA256"));
byte[] prk = extract.doFinal(ikm);
Mac expand = Mac.getInstance("HmacSHA256");
expand.init(new SecretKeySpec(prk, "HmacSHA256"));
expand.update(info.getBytes("US-ASCII"));
expand.update((byte) 0x01);
return expand.doFinal();
}
/** Pull the live StepUpApplet instance out of jcardsim so the test can
* reach the package-private testing accessors. Mirrors the reflection
* pattern in {@code PersonalizationAppletTest#getInstalledApplet}. */
private static StepUpApplet stepUpAppletInstance(CardSimulator sim, AID aid) throws Exception {
java.lang.reflect.Field runtimeField = null;
for (Class<?> c = sim.getClass(); c != null && runtimeField == null; c = c.getSuperclass()) {
try { runtimeField = c.getDeclaredField("runtime"); } catch (NoSuchFieldException ignore) {}
}
runtimeField.setAccessible(true);
Object runtime = runtimeField.get(sim);
java.lang.reflect.Method getApplet = runtime.getClass().getDeclaredMethod("getApplet", AID.class);
getApplet.setAccessible(true);
return (StepUpApplet) (Applet) getApplet.invoke(runtime, aid);
}
/** Unwraps the outer 6F FCI template to expose its nested TLVs. */ /** Unwraps the outer 6F FCI template to expose its nested TLVs. */
private static byte[] unwrap6F(byte[] fci) { private static byte[] unwrap6F(byte[] fci) {
if (fci.length < 2 || fci[0] != 0x6F) { if (fci.length < 2 || fci[0] != 0x6F) {

View File

@@ -0,0 +1,103 @@
package com.dangerousthings.aliro;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
/**
* Tests for {@link StepUpSession} — the session-state holder extracted out
* of {@link StepUpApplet} for the Step-Up AES-256-GCM session (spec §8.4.3
* + §8.3.1.6/8/9). Covers IV layout, counter advance, and 32-bit wrap.
*
* <p>Note: jcardsim is initialised lazily by {@link StepUpSession}'s
* {@code makeTransientByteArray} calls. The {@link CardSimulator} static
* initialiser does that wiring; instantiating one here is sufficient.
*/
class StepUpSessionTest {
private static StepUpSession freshSession() {
// CardSimulator's static init installs the jcardsim runtime that
// StepUpSession needs for makeTransientByteArray. Construct one and
// discard — only the static-init side-effect matters.
new com.licel.jcardsim.smartcardio.CardSimulator();
StepUpSession s = new StepUpSession();
s.reset();
return s;
}
/**
* Spec §8.3.1.8/9 reader-side IV layout: 8-byte zero prefix +
* 4-byte big-endian stepup_reader_counter. After {@link StepUpSession#reset()}
* the counter is {@code 0x00000001} per §8.4.3 + mdoc [6] §9.1.1.5.
*/
@Test
void readerIv_counter1_returnsAllZerosThen0001() {
StepUpSession s = freshSession();
byte[] iv = new byte[12];
s.readerIv(iv, (short) 0);
assertArrayEquals(
new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 },
iv,
"fresh-session reader IV = 00*8 || 00 00 00 01");
}
/**
* Advance reader counter 255 times from 1 -> 256 (= 0x00000100). Pins
* carry propagation from the low byte into byte 2 of the counter — the
* IV trailing 4 bytes must read {@code 00 00 01 00}.
*/
@Test
void readerIv_counter256_handlesCarryIntoByte2() {
StepUpSession s = freshSession();
// 1 -> 2 -> ... -> 256: 255 advances.
for (int i = 0; i < 255; i++) {
s.advanceReaderCounter();
}
byte[] iv = new byte[12];
s.readerIv(iv, (short) 0);
assertArrayEquals(
new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 },
iv,
"after 255 advances reader IV trails 00 00 01 00 (BE 256)");
}
/**
* Spec §8.3.1.6 device-side IV layout: 7-byte zero prefix + 0x01 +
* 4-byte big-endian stepup_device_counter. After reset the device
* counter is {@code 0x00000001} per §8.4.3.
*/
@Test
void deviceIv_counter1_returnsZeros_then01_then0001() {
StepUpSession s = freshSession();
byte[] iv = new byte[12];
s.deviceIv(iv, (short) 0);
assertArrayEquals(
new byte[] { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1 },
iv,
"fresh-session device IV = 00*7 || 01 || 00 00 00 01");
}
/**
* Spec §8.3.3.5.4 says the counter SHALL never reach the limit, but the
* applet has no explicit overflow guard (YAGNI: the protocol's wire-speed
* ceiling makes this unreachable in practice). Lock the 32-bit BE wrap
* behaviour here so a future "let's add a check" doesn't silently change
* the math layer.
*/
@Test
void incrementCounterWrapsAt0xFFFFFFFF() {
StepUpSession s = freshSession();
// Force the reader counter to 0xFFFFFFFF, then advance.
s.readerCounter[0] = (byte) 0xFF;
s.readerCounter[1] = (byte) 0xFF;
s.readerCounter[2] = (byte) 0xFF;
s.readerCounter[3] = (byte) 0xFF;
s.advanceReaderCounter();
byte[] iv = new byte[12];
s.readerIv(iv, (short) 0);
assertArrayEquals(
new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
iv,
"0xFFFFFFFF + 1 wraps to 0x00000000 (mod 2^32)");
}
}

View File

@@ -0,0 +1,515 @@
package com.dangerousthings.aliro;
import javacard.framework.ISOException;
import javacard.framework.ISO7816;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Tests for {@link StructuralCbor#decodeHeader} — RFC 8949 §3 header
* decoding (major type + argument) used by the M2 DeviceResponse / IssuerAuth
* walker.
*
* <p>Header byte layout (RFC 8949 §3):
* <pre>
* bits 7..5: major type (0..7)
* bits 4..0: additional info
* 0..23: immediate argument value
* 24: 1-byte uint argument follows
* 25: 2-byte uint argument follows (big-endian)
* 26: 4-byte uint argument follows (big-endian)
* 27: 8-byte uint argument follows (REJECTED — exceeds short range)
* 28..30: reserved (REJECTED)
* 31: indefinite length (REJECTED — Aliro mdoc is canonical)
* </pre>
*
* <p>Result encoding (matches {@link StructuralCbor#decodeHeader} javadoc):
* <ul>
* <li>return value (short): high byte = major type, low byte = bytes consumed
* <li>argument: written big-endian into {@code argOut[argOff..argOff+4)}
* </ul>
*/
class StructuralCborTest {
/** Helper: decode at offset 0 and return major type. */
private static short majorOf(short result) {
return (short) ((result >> 8) & 0x07);
}
/** Helper: decode at offset 0 and return bytes consumed. */
private static short consumedOf(short result) {
return (short) (result & 0xFF);
}
/** Helper: read the 4-byte big-endian argument the codec wrote. */
private static long argOf(byte[] argOut) {
return ((long) (argOut[0] & 0xFF) << 24)
| ((long) (argOut[1] & 0xFF) << 16)
| ((long) (argOut[2] & 0xFF) << 8)
| ((long) (argOut[3] & 0xFF));
}
@Test
void decodeHeader_uintImmediateZero() {
byte[] buf = hex("00");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(0, majorOf(r), "major type 0 (uint)");
assertEquals(1, consumedOf(r), "1 byte consumed");
assertEquals(0L, argOf(arg), "argument value 0");
}
@Test
void decodeHeader_uintImmediateMax() {
// Additional info 23 is the largest immediate-argument value.
byte[] buf = hex("17");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(0, majorOf(r));
assertEquals(1, consumedOf(r));
assertEquals(23L, argOf(arg));
}
@Test
void decodeHeader_uintOneByteArgument() {
// 0x18 = uint with 1-byte argument follow; argument = 0xff (255).
byte[] buf = hex("18ff");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(0, majorOf(r));
assertEquals(2, consumedOf(r));
assertEquals(255L, argOf(arg));
}
@Test
void decodeHeader_uintTwoByteArgument() {
// 0x19 = uint with 2-byte argument; 0x0100 = 256.
byte[] buf = hex("190100");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(0, majorOf(r));
assertEquals(3, consumedOf(r));
assertEquals(256L, argOf(arg));
}
@Test
void decodeHeader_uintFourByteArgument() {
// 0x1a = uint with 4-byte argument; 0x00010000 = 65536.
byte[] buf = hex("1a00010000");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(0, majorOf(r));
assertEquals(5, consumedOf(r));
assertEquals(65536L, argOf(arg));
}
@Test
void decodeHeader_bstrOneByteLen() {
// 0x58 = bstr major type (2) + additional info 24 (1-byte len follows).
// Length 200 won't overflow short.
byte[] buf = hex("58c8");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(2, majorOf(r), "major type 2 (bstr)");
assertEquals(2, consumedOf(r));
assertEquals(200L, argOf(arg));
}
@Test
void decodeHeader_tstrImmediate() {
// 0x65 = tstr major type (3) + immediate length 5.
byte[] buf = hex("65");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(3, majorOf(r), "major type 3 (tstr)");
assertEquals(1, consumedOf(r));
assertEquals(5L, argOf(arg));
}
@Test
void decodeHeader_arrayImmediate() {
// 0x83 = array major type (4) + immediate count 3.
byte[] buf = hex("83");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(4, majorOf(r), "major type 4 (array)");
assertEquals(1, consumedOf(r));
assertEquals(3L, argOf(arg));
}
@Test
void decodeHeader_mapImmediate() {
// 0xa2 = map major type (5) + immediate count 2.
byte[] buf = hex("a2");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(5, majorOf(r), "major type 5 (map)");
assertEquals(1, consumedOf(r));
assertEquals(2L, argOf(arg));
}
@Test
void decodeHeader_tagImmediate() {
// 0xc0 = tag major type (6) + immediate tag 0.
byte[] buf = hex("c0");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0);
assertEquals(6, majorOf(r), "major type 6 (tag)");
assertEquals(1, consumedOf(r));
assertEquals(0L, argOf(arg));
}
@Test
void decodeHeader_atNonzeroOffset() {
// Verify decodeHeader honours bufOff: pad with junk, decode at offset 3.
byte[] buf = hex("deadbeef" + "190100");
byte[] arg = new byte[4];
short r = StructuralCbor.decodeHeader(buf, (short) 4, (short) (buf.length - 4), arg, (short) 0);
assertEquals(0, majorOf(r));
assertEquals(3, consumedOf(r));
assertEquals(256L, argOf(arg));
}
@Test
void decodeHeader_argOutAtNonzeroOffset() {
// Verify argOff: write argument into arg[2..6) and leave arg[0..2) zero.
byte[] buf = hex("190100");
byte[] arg = new byte[8];
short r = StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 2);
assertEquals(0, majorOf(r));
assertEquals(3, consumedOf(r));
assertEquals(0, arg[0]);
assertEquals(0, arg[1]);
// Argument bytes land at arg[2..6).
long argVal = ((long) (arg[2] & 0xFF) << 24)
| ((long) (arg[3] & 0xFF) << 16)
| ((long) (arg[4] & 0xFF) << 8)
| ((long) (arg[5] & 0xFF));
assertEquals(256L, argVal);
}
@Test
void decodeHeader_indefiniteLengthRejected() {
// 0x1f = uint major type + additional info 31 (indefinite length).
// We refuse indefinite-length in Aliro mdoc — canonical CBOR only.
byte[] buf = hex("1f");
byte[] arg = new byte[4];
ISOException ex = assertThrows(ISOException.class,
() -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF,
"indefinite-length must throw SW_DATA_INVALID");
}
@Test
void decodeHeader_eightByteArgumentRejected() {
// 0x1b = uint with 8-byte argument follow. Out of short-buffer scope.
byte[] buf = hex("1b00000000000000ff");
byte[] arg = new byte[4];
ISOException ex = assertThrows(ISOException.class,
() -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
}
@Test
void decodeHeader_majorTypeSevenRejected() {
// 0xf5 = major type 7 (CBOR true). We don't handle floats/null/bool.
byte[] buf = hex("f5");
byte[] arg = new byte[4];
ISOException ex = assertThrows(ISOException.class,
() -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
}
@Test
void decodeHeader_reservedAdditionalInfoRejected() {
// 0x1c = additional info 28 (reserved by RFC 8949).
byte[] buf = hex("1c");
byte[] arg = new byte[4];
ISOException ex = assertThrows(ISOException.class,
() -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
}
@Test
void decodeHeader_truncatedArgumentRejected() {
// 0x19 says "2-byte argument follows" but only 1 byte is present.
byte[] buf = hex("1901");
byte[] arg = new byte[4];
ISOException ex = assertThrows(ISOException.class,
() -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
}
@Test
void decodeHeader_emptyBufferRejected() {
byte[] buf = new byte[0];
byte[] arg = new byte[4];
ISOException ex = assertThrows(ISOException.class,
() -> StructuralCbor.decodeHeader(buf, (short) 0, (short) buf.length, arg, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
}
// ---------------------------------------------------------------------
// elementSpan tests (M2B.2) — span of a complete CBOR element including
// nested children, used by M2B.3 / M2D.1 to walk IssuerSigned / docRequests.
// ---------------------------------------------------------------------
/** Shared 4B scratch for elementSpan's decodeHeader calls. */
private final byte[] scratch = new byte[4];
@Test
void elementSpan_uintImmediate() {
// 0x17 = uint 23, immediate argument. One header byte, no payload.
byte[] buf = hex("17");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(1, span);
}
@Test
void elementSpan_uintOneByteArgument() {
// 0x18 0x64 = uint 100. Header byte + 1B argument, no payload.
byte[] buf = hex("1864");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(2, span);
}
@Test
void elementSpan_bstrEmpty() {
// 0x40 = bstr len 0. One header byte, zero payload.
byte[] buf = hex("40");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(1, span);
}
@Test
void elementSpan_bstrLen3() {
// 0x43 0x01 0x02 0x03 = bstr h'010203'. Header + 3 payload bytes.
byte[] buf = hex("43010203");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(4, span);
}
@Test
void elementSpan_tstrHello() {
// 0x65 + "hello" = tstr "hello". Header + 5 payload bytes.
byte[] buf = hex("6568656c6c6f");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(6, span);
}
@Test
void elementSpan_arrayOfThreeUints() {
// 0x83 0x01 0x02 0x03 = [1, 2, 3]. Header + 3 single-byte children.
byte[] buf = hex("83010203");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(4, span);
}
@Test
void elementSpan_nestedArray() {
// 0x82 0x82 0x01 0x02 0x03 = [[1, 2], 3]. Exercises recursion.
byte[] buf = hex("8282010203");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(5, span);
}
@Test
void elementSpan_mapTwoUintPairs() {
// 0xa2 0x01 0x02 0x03 0x04 = {1: 2, 3: 4}. Header + 4 single-byte items.
byte[] buf = hex("a201020304");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(5, span);
}
@Test
void elementSpan_mapWithBstrUintPairs() {
// 0xa2 0x43 010203 0x1864 0x41 ff 0x17
// = { h'010203': 100, h'ff': 23 }
// Header(1) + bstr(4) + uint(2) + bstr(2) + uint(1) = 10.
byte[] buf = hex("a2 43 010203 1864 41 ff 17");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(10, span);
}
@Test
void elementSpan_tagUint() {
// 0xc1 0x01 = tag(1, 1). Tag header + tagged element span.
byte[] buf = hex("c101");
short span = StructuralCbor.elementSpan(
buf, (short) 0, (short) buf.length, scratch, (short) 0);
assertEquals(2, span);
}
@Test
void elementSpan_honoursBufOff() {
// Pad with 4 junk bytes, then a 4-byte array [1,2,3]; span starts at offset 4.
byte[] buf = hex("deadbeef" + "83010203");
short span = StructuralCbor.elementSpan(
buf, (short) 4, (short) (buf.length - 4), scratch, (short) 0);
assertEquals(4, span);
}
// ---------------------------------------------------------------------
// Canonical encoder tests (M2B.4) — uint / bstr-header / tstr-header
// writers used by M2D.2 (DeviceResponseBuilder).
//
// Canonical CBOR (RFC 8949 §4.2.1): shortest argument size that fits the
// value. Boundary at each size step (0..23 immediate, 24..0xff one-byte,
// 0x100..0xffff two-byte, 0x10000..0xffffffff four-byte).
// ---------------------------------------------------------------------
@Test
void encodeUint_zeroImmediate() {
// 0 fits in the immediate range — single header byte 0x00.
byte[] out = new byte[8];
short n = StructuralCbor.encodeUint(0, out, (short) 0);
assertEquals(1, n);
assertArrayPrefix(out, "00", n);
}
@Test
void encodeUint_twentyThreeImmediate() {
// 23 is the largest immediate value — header byte 0x17.
byte[] out = new byte[8];
short n = StructuralCbor.encodeUint(23, out, (short) 0);
assertEquals(1, n);
assertArrayPrefix(out, "17", n);
}
@Test
void encodeUint_twentyFourOneByteBoundary() {
// 24 is the lower boundary of the 1-byte argument form — 0x18 0x18.
// Canonical rule forbids encoding 24 as 0x19 0x00 0x18 etc.
byte[] out = new byte[8];
short n = StructuralCbor.encodeUint(24, out, (short) 0);
assertEquals(2, n);
assertArrayPrefix(out, "1818", n);
}
@Test
void encodeUint_twoByteBoundary() {
// 0x100 = 256 is the lower boundary of the 2-byte argument form.
byte[] out = new byte[8];
short n = StructuralCbor.encodeUint(0x100, out, (short) 0);
assertEquals(3, n);
assertArrayPrefix(out, "190100", n);
}
@Test
void encodeUint_fourByteBoundary() {
// 0x10000 = 65536 is the lower boundary of the 4-byte argument form.
byte[] out = new byte[8];
short n = StructuralCbor.encodeUint(0x10000, out, (short) 0);
assertEquals(5, n);
assertArrayPrefix(out, "1a00010000", n);
}
@Test
void encodeUint_negativeRejected() {
// Aliro mdoc CBOR never uses negative integers (those are major type 1).
byte[] out = new byte[8];
ISOException ex = assertThrows(ISOException.class,
() -> StructuralCbor.encodeUint(-1, out, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
}
@Test
void encodeBstrHeader_zero() {
// bstr len 0 — header byte 0x40 (major type 2 | additional info 0).
byte[] out = new byte[8];
short n = StructuralCbor.encodeBstrHeader((short) 0, out, (short) 0);
assertEquals(1, n);
assertArrayPrefix(out, "40", n);
}
@Test
void encodeBstrHeader_twentyThreeImmediate() {
// Largest immediate-length bstr header — 0x57.
byte[] out = new byte[8];
short n = StructuralCbor.encodeBstrHeader((short) 23, out, (short) 0);
assertEquals(1, n);
assertArrayPrefix(out, "57", n);
}
@Test
void encodeBstrHeader_twentyFourOneByteBoundary() {
// 24 forces into 1-byte argument form — 0x58 0x18.
byte[] out = new byte[8];
short n = StructuralCbor.encodeBstrHeader((short) 24, out, (short) 0);
assertEquals(2, n);
assertArrayPrefix(out, "5818", n);
}
@Test
void encodeBstrHeader_twoByteBoundary() {
// 0x100 = 256 forces into 2-byte argument form — 0x59 0x01 0x00.
byte[] out = new byte[8];
short n = StructuralCbor.encodeBstrHeader((short) 0x100, out, (short) 0);
assertEquals(3, n);
assertArrayPrefix(out, "590100", n);
}
@Test
void encodeBstrHeader_negativeLenRejected() {
byte[] out = new byte[8];
ISOException ex = assertThrows(ISOException.class,
() -> StructuralCbor.encodeBstrHeader((short) -1, out, (short) 0));
assertEquals(ISO7816.SW_DATA_INVALID, ex.getReason() & 0xFFFF);
}
@Test
void encodeTstrHeader_zero() {
// tstr len 0 — header byte 0x60 (major type 3 | additional info 0).
byte[] out = new byte[8];
short n = StructuralCbor.encodeTstrHeader((short) 0, out, (short) 0);
assertEquals(1, n);
assertArrayPrefix(out, "60", n);
}
@Test
void encodeTstrHeader_twentyFourOneByteBoundary() {
// 24 forces into 1-byte argument form — 0x78 0x18.
byte[] out = new byte[8];
short n = StructuralCbor.encodeTstrHeader((short) 24, out, (short) 0);
assertEquals(2, n);
assertArrayPrefix(out, "7818", n);
}
@Test
void encodeTstrHeader_twoByteBoundary() {
// 0x100 forces into 2-byte argument form — 0x79 0x01 0x00.
byte[] out = new byte[8];
short n = StructuralCbor.encodeTstrHeader((short) 0x100, out, (short) 0);
assertEquals(3, n);
assertArrayPrefix(out, "790100", n);
}
/** Helper: assert {@code out[0..n)} equals the hex-decoded expected bytes. */
private static void assertArrayPrefix(byte[] out, String expectedHex, short n) {
byte[] expected = hex(expectedHex);
assertEquals(expected.length, n, "expected " + expectedHex + " (" + expected.length + " bytes)");
for (int i = 0; i < expected.length; i++) {
assertEquals(expected[i], out[i], "byte " + i + " mismatch");
}
}
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;
}
}

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?

View File

@@ -0,0 +1,496 @@
# 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:
```java
@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`:
```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:
```java
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:
```java
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 tests**`verify_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.

View File

@@ -0,0 +1,55 @@
# ST X-CUBE-ALIRO: `ACWG_processAUTH1ResponsePayload` errors against spec-compliant card
**Status:** open
**Reporter:** Dangerous Things (ops@dangerousthings.com)
**Date filed:** 2026-06-12
**Severity:** blocking — prevents Aliro Expedited phase from completing against any third-party Java Card applet
## Summary
`ACWG_processAUTH1ResponsePayload` (shipped precompiled in `Aliro.a` / `ACWG_Security.a`) returns `ACWG_Error_Crypto_EncryptDecrypt` on every iteration when processing AUTH1 responses from our open-source Java Card Aliro applet, despite the applet emitting bytes that an independent CSA Aliro v1.0 reference reader decrypts cleanly. Because the failing routine is closed-source, we cannot localize where ST's interpretation diverges from ours. We are asking for source access, a concrete statement of which §8.3.1 interpretation ST uses, or confirmation that ours is correct plus a vendor patch.
## Reproducer
- **Reader hardware:** NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1 (ST25R200), STM32U535/U545 target.
- **Reader firmware:** X-CUBE-ALIRO V1.0.0 (released 25-February-2026) + ST25 RFAL middleware V2.8.0 (T=CL).
- **Card:** NXP J3R452 (also reproduces on J3R180) running our open-source Aliro applet, implementing CSA Aliro v1.0 §8.3.1.4 (Kdh via X9.63 KDF / BSI TR-03111 §4.3.3 single-step KDF with SHA-256), §8.3.1.5.13 (HKDF salt expansion), §8.3.1.6 (AES-256-GCM AUTH1 response encryption: 12-byte IV, 16-byte tag), §8.3.1.13 (`salt_volatile` layout).
- **Expected:** Expedited Standard AUTH1 completes; reader extracts `credential_PubK` and UD signature.
- **Observed:** every AUTH1 response into `ACWG_processAUTH1ResponsePayload` returns `ACWG_Error_Crypto_EncryptDecrypt`. UART trace shows the retval at the call boundary; no internal context exposed.
## Evidence
- **UART trace:** `ACWG_processAUTH1ResponsePayload -> ACWG_Error_Crypto_EncryptDecrypt`, repeatable across power cycles and across both J3R180 and J3R452.
- **Card output:** AUTH1 response APDU captured; structurally a single AES-256-GCM blob per §8.3.1.6.
- **Independent decrypt:** the same captured bytes fed into our PC/SC reader (`aliro-bench-test`, full Aliro spec implementation in Python) decrypt cleanly. Plaintext TLV:
- `signaling_bitmap = 0x0005`
- `0x5A credential_PubK` = 65 B (uncompressed P-256, `04 || X || Y`)
- `0x9E UD signature` = 64 B (raw ECDSA `r || s` over P-256)
- PC/SC path completes Expedited Standard end-to-end against the same card, same applet, same personalization. The card therefore emits well-formed §8.3.1 output by at least one reasonable reading of the spec.
## What we ruled out
Three crypto fixes ("the trifecta") landed on the applet 2026-06-11; none changed Nucleo behavior:
1. **`salt_volatile` structure** — verified byte-for-byte against §8.3.1.13. Same bytes accepted by PC/SC.
2. **`Kdh` derivation** — corrected from HKDF to X9.63 KDF per §8.3.1.4. The corrected `Kdh` is what now decrypts cleanly under PC/SC.
3. **AUTH1 `cmd_params` byte** — confirmed the parameter byte the card hashes into its transcript matches what the Nucleo transmits on-air (RFAL frame capture).
All three were necessary for PC/SC; none were sufficient for the Nucleo library.
## What we can't rule out
The vendor library's interpretation of §8.3.1.4 / §8.3.1.6 / §8.3.1.13 or the surrounding transcript construction may differ from ours in a way the spec text permits but does not require — e.g. AAD composition, IV layout, or transcript hash boundaries. Without source or symbols for `Aliro.a` / `ACWG_Security.a` we cannot identify which step raises `ACWG_Error_Crypto_EncryptDecrypt`. Reverse-engineering the archive is months of work for a vendor support question.
## Request
Any of the following resolves the block:
1. **Source access** to `Aliro.a` / `ACWG_Security.a` — at minimum `ACWG_processAUTH1ResponsePayload` and its crypto helpers — under whatever NDA ST requires.
2. **Concrete spec interpretation** the vendor library uses for §8.3.1.4 / §8.3.1.6 / §8.3.1.13 in byte terms (key schedule, AAD, IV, tag).
3. **Confirmation** that our interpretation (matching the PC/SC reference) is correct, plus a vendor patch.
4. **Partner-program acceptance** if a beta firmware fix is already in flight.
We can provide the personalized card, the captured AUTH1 request/response APDU pair, and the PC/SC reader's decrypted plaintext for ST to validate against an independent reference.
**ST contact for this report:** filing via the X-CUBE-ALIRO support form at https://www.st.com/content/st_com/en/products/embedded-software/mcu-mpu-embedded-software/stm32-embedded-software/stm32cube-expansion-packages/x-cube-aliro.html. If routing-to-engineering needs a named FAE/partner contact, please advise — happy to re-send through whichever channel ST prefers.

View File

@@ -0,0 +1,13 @@
Using reader: NXP PR533 (3.70) 00 00
Connected. ATR: 3b8a80014a444e4752665334353258
Loaded trust artifacts from /home/work/aliro-trust
RESULT: OK — applet round-trip on real hardware.
0x5A credential_PubK: 65B
0x9E UD signature: 64B
0x5E signaling_bitmap: 0x0005
APDU latencies (ms):
select 23.1
auth0 667.1
auth1 3258.3
total 3948.4
STEP-UP M2: OK — M2 step-up verified (EXCHANGE + ENVELOPE Access Document round-trip)

View File

@@ -22,6 +22,7 @@ dev = [
aliro-trustgen = "aliro_harness.trustgen.cli:main" aliro-trustgen = "aliro_harness.trustgen.cli:main"
aliro-personalize = "aliro_harness.personalizer.cli:main" aliro-personalize = "aliro_harness.personalizer.cli:main"
aliro-bench-test = "aliro_harness.reader.cli:main" aliro-bench-test = "aliro_harness.reader.cli:main"
aliro-bench-profile = "aliro_harness.profile.cli:main"
[build-system] [build-system]
requires = ["setuptools>=68"] requires = ["setuptools>=68"]

View File

@@ -11,6 +11,7 @@ INS_SET_CRED_PUBK = 0x21
INS_SET_READER_PUBK = 0x22 INS_SET_READER_PUBK = 0x22
INS_WRITE_ACCESS_DOC = 0x23 INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24 INS_FINALIZE_ACCESS_DOC = 0x24
INS_SET_CRED_ISSUER_PUBK = 0x25
INS_COMMIT = 0x2C INS_COMMIT = 0x2C
# Provisioning AID — must match AliroAids.PROVISIONING in the applet. # Provisioning AID — must match AliroAids.PROVISIONING in the applet.
@@ -21,6 +22,7 @@ PROVISIONING_AID = bytes.fromhex("A000000909ACCE559901")
CRED_PRIV_LEN = 32 CRED_PRIV_LEN = 32
CRED_PUBK_LEN = 64 CRED_PUBK_LEN = 64
READER_PUBK_LEN = 64 READER_PUBK_LEN = 64
CRED_ISSUER_PUBK_LEN = 64
def select_provisioning_apdu() -> bytes: def select_provisioning_apdu() -> bytes:
@@ -46,6 +48,16 @@ def set_reader_pubk_apdu(pubk_xy: bytes) -> bytes:
return _short_apdu(INS_SET_READER_PUBK, 0, 0, pubk_xy) return _short_apdu(INS_SET_READER_PUBK, 0, 0, pubk_xy)
def set_credential_issuer_pubk_apdu(pubk_xy: bytes) -> bytes:
"""Push the Credential Issuer public key (P-256 x||y, no 0x04 prefix).
The applet stages it for IssuerAuth COSE_Sign1 verify at FINALIZE_AD."""
if len(pubk_xy) != CRED_ISSUER_PUBK_LEN:
raise ValueError(
f"credential_issuer_PubK must be {CRED_ISSUER_PUBK_LEN}B (x||y), got {len(pubk_xy)}"
)
return _short_apdu(INS_SET_CRED_ISSUER_PUBK, 0, 0, pubk_xy)
def commit_apdu() -> bytes: def commit_apdu() -> bytes:
return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00]) return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])

View File

@@ -80,6 +80,7 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
f"Loaded artifacts: credential_PrivK={len(artifacts.credential_priv)}B, " f"Loaded artifacts: credential_PrivK={len(artifacts.credential_priv)}B, "
f"credential_PubK={len(artifacts.credential_pubk_xy)}B, " f"credential_PubK={len(artifacts.credential_pubk_xy)}B, "
f"reader_PubK={len(artifacts.reader_pubk_xy)}B, " f"reader_PubK={len(artifacts.reader_pubk_xy)}B, "
f"credential_issuer_PubK={len(artifacts.credential_issuer_pub)}B, "
f"Access Document={len(artifacts.access_document)}B" f"Access Document={len(artifacts.access_document)}B"
) )

View File

@@ -13,6 +13,7 @@ from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.apdus import ( from aliro_harness.personalizer.apdus import (
commit_apdu, commit_apdu,
select_provisioning_apdu, select_provisioning_apdu,
set_credential_issuer_pubk_apdu,
set_credential_priv_apdu, set_credential_priv_apdu,
set_credential_pubk_apdu, set_credential_pubk_apdu,
set_reader_pubk_apdu, set_reader_pubk_apdu,
@@ -45,21 +46,25 @@ class TrustArtifacts:
credential_priv: bytes # 32B credential_priv: bytes # 32B
credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix) credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix)
reader_pubk_xy: bytes # 64B reader_pubk_xy: bytes # 64B
credential_issuer_pub: bytes # 64B issuer pubkey x||y for IssuerAuth verify
access_document: bytes # COSE_Sign1 serialized access_document: bytes # COSE_Sign1 serialized
@classmethod @classmethod
def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts": def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts":
cred_pem = (trust_dir / "access_credential.pem").read_bytes() cred_pem = (trust_dir / "access_credential.pem").read_bytes()
reader_pem = (trust_dir / "reader.pem").read_bytes() reader_pem = (trust_dir / "reader.pem").read_bytes()
issuer_pem = (trust_dir / "issuer.pem").read_bytes()
ad = (trust_dir / "access_document.bin").read_bytes() ad = (trust_dir / "access_document.bin").read_bytes()
cred_key = serialization.load_pem_private_key(cred_pem, password=None) cred_key = serialization.load_pem_private_key(cred_pem, password=None)
reader_key = serialization.load_pem_private_key(reader_pem, password=None) reader_key = serialization.load_pem_private_key(reader_pem, password=None)
issuer_key = serialization.load_pem_private_key(issuer_pem, password=None)
return cls( return cls(
credential_priv=extract_priv_scalar(cred_key), credential_priv=extract_priv_scalar(cred_key),
credential_pubk_xy=extract_pub_xy(cred_key.public_key()), credential_pubk_xy=extract_pub_xy(cred_key.public_key()),
reader_pubk_xy=extract_pub_xy(reader_key.public_key()), reader_pubk_xy=extract_pub_xy(reader_key.public_key()),
credential_issuer_pub=extract_pub_xy(issuer_key.public_key()),
access_document=ad, access_document=ad,
) )
@@ -73,6 +78,14 @@ def personalize_card(transmit: Transmit, artifacts: TrustArtifacts) -> None:
_send(transmit, "SET credential_PrivK", set_credential_priv_apdu(artifacts.credential_priv)) _send(transmit, "SET credential_PrivK", set_credential_priv_apdu(artifacts.credential_priv))
_send(transmit, "SET credential_PubK", set_credential_pubk_apdu(artifacts.credential_pubk_xy)) _send(transmit, "SET credential_PubK", set_credential_pubk_apdu(artifacts.credential_pubk_xy))
_send(transmit, "SET reader_PubK", set_reader_pubk_apdu(artifacts.reader_pubk_xy)) _send(transmit, "SET reader_PubK", set_reader_pubk_apdu(artifacts.reader_pubk_xy))
# Issuer pubkey must land BEFORE the AD chunks so that by the time
# FINALIZE_AD runs and triggers IssuerAuth COSE_Sign1 verify, the trust
# anchor is already staged.
_send(
transmit,
"SET credential_issuer_PubK",
set_credential_issuer_pubk_apdu(artifacts.credential_issuer_pub),
)
for i, apdu in enumerate(access_document_apdus(artifacts.access_document)): for i, apdu in enumerate(access_document_apdus(artifacts.access_document)):
_send(transmit, f"Access Document APDU {i}", apdu) _send(transmit, f"Access Document APDU {i}", apdu)

View File

@@ -0,0 +1,8 @@
"""Drives the AliroApplet's INS_DIAG_* profiling commands.
Each INS_DIAG_* operation runs a single primitive (HMAC-SHA-256, ECDH,
ECDSA-SHA-256 sign, or full AES-256-GCM encrypt of 137B) N times against
hardcoded test vectors on the card. We measure wall-clock latency for two
different N values; the per-op cost is the slope of the line, isolating
APDU framing overhead from the primitive's actual cost.
"""

View File

@@ -0,0 +1,208 @@
"""``aliro-bench-profile`` -- per-primitive latency profiling of the Aliro
applet on real hardware via PC/SC.
Selects the Aliro Expedited AID, then drives the four INS_DIAG_* commands
the applet exposes for profiling. Each command runs an operation N times
against hardcoded card-side test vectors. We measure two N values per
operation and compute per-op cost as the slope, which factors out the
fixed APDU round-trip and any one-time setup (Signature.init etc.).
Output: a breakdown of per-op cost in milliseconds for HMAC, ECDH, ECDSA
sign, and full AES-256-GCM encrypt of 137 bytes -- the same primitives
AUTH1 invokes. Plus a reconstructed AUTH1 budget computed from the
measured per-op costs, so we can compare against the bench-test wall-clock.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
import click
# Aliro Expedited AID -- must match AliroAids.EXPEDITED in the applet.
EXPEDITED_AID = bytes.fromhex("A000000909ACCE5501")
INS_DIAG_HMAC = 0xD0
INS_DIAG_ECDH = 0xD1
INS_DIAG_ECDSA_SIGN = 0xD2
INS_DIAG_GCM = 0xD3
# AUTH1 invokes each primitive this many times. From the applet source:
# ECDSA verify (reader sig over Table 8-12): 1 -- modeled as ECDSA sign
# ECDH (Kdh derivation): 1
# HMAC (HKDF Extract + Expand for 160B): 6 -- 1 Extract + 5 Expand
# ECDSA sign (UD signature over Table 8-13): 1
# AES-GCM encrypt of 137B Table 8-11: 1
AUTH1_OP_COUNTS = {
"ecdsa_verify": 1, # use ECDSA-sign cost as proxy; same algorithm
"ecdh": 1,
"hmac": 6,
"ecdsa_sign": 1,
"gcm_137": 1,
}
@dataclass
class OpResult:
name: str
per_op_ms: float
n_low: int
t_low_ms: float
n_high: int
t_high_ms: float
@click.command()
@click.option(
"--reader",
"reader_index",
type=int,
default=0,
show_default=True,
help="Index into PC/SC reader list",
)
@click.option(
"--list-readers",
is_flag=True,
help="List PC/SC readers and exit",
)
@click.option(
"--n-low",
type=int,
default=4,
show_default=True,
help="Low iteration count for slope measurement",
)
@click.option(
"--n-high",
type=int,
default=16,
show_default=True,
help="High iteration count for slope measurement",
)
def main(reader_index: int, list_readers: bool, n_low: int, n_high: int) -> None:
"""Profile AUTH1 sub-operations on the Aliro applet via PC/SC.
Card must have AliroApplet installed (personalization not required for
diagnostics -- they use hardcoded test vectors).
"""
from smartcard.System import readers
available = readers()
if list_readers:
for i, r in enumerate(available):
click.echo(f"[{i}] {r}")
return
if not available:
raise click.ClickException("No PC/SC readers found. Is pcscd running?")
if not (0 <= reader_index < len(available)):
raise click.ClickException(f"--reader {reader_index} out of range")
if n_low >= n_high:
raise click.ClickException("--n-low must be smaller than --n-high")
if not (1 <= n_low <= 255 and 1 <= n_high <= 255):
raise click.ClickException("N values must be in [1, 255]")
reader = available[reader_index]
click.echo(f"Using reader: {reader}")
connection = reader.createConnection()
connection.connect()
try:
click.echo(f"Connected. ATR: {bytes(connection.getATR()).hex()}")
def transmit(apdu: bytes) -> tuple[bytes, int]:
data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2
# SELECT Aliro Expedited AID
select_apdu = bytes([0x00, 0xA4, 0x04, 0x00, len(EXPEDITED_AID)]) + EXPEDITED_AID
_, sw = transmit(select_apdu)
if sw != 0x9000:
raise click.ClickException(f"SELECT failed: SW=0x{sw:04X}")
click.echo(f"SELECT Aliro Expedited OK (SW=0x{sw:04X})\n")
ops = [
("hmac", INS_DIAG_HMAC, "HMAC-SHA-256 (32B key, 64B msg)"),
("ecdh", INS_DIAG_ECDH, "ECDH P-256 (native)"),
("ecdsa_sign", INS_DIAG_ECDSA_SIGN, "ECDSA-SHA-256 sign (native)"),
("gcm_137", INS_DIAG_GCM, "AES-256-GCM encrypt 137B (userland)"),
]
results: list[OpResult] = []
for key, ins, label in ops:
r = _profile_one(transmit, ins, n_low, n_high, key)
results.append(r)
click.echo(
f" {label:<48} "
f"N={n_low:>3}: {r.t_low_ms:>7.1f} ms "
f"N={n_high:>3}: {r.t_high_ms:>7.1f} ms "
f"-> {r.per_op_ms:>6.1f} ms/op"
)
click.echo()
_print_auth1_reconstruction(results)
finally:
connection.disconnect()
def _profile_one(
transmit, ins: int, n_low: int, n_high: int, name: str
) -> OpResult:
"""Send the diagnostic INS twice with different iteration counts and
return per-op cost computed from the slope. The slope cancels out APDU
overhead and any one-time setup that doesn't depend on N."""
# APDU: CLA=80 INS=ins P1=00 P2=00 Lc=01 [N]
def measure(n: int) -> float:
apdu = bytes([0x80, ins, 0x00, 0x00, 0x01, n & 0xFF])
t = time.perf_counter()
_, sw = transmit(apdu)
dt = (time.perf_counter() - t) * 1000.0
if sw != 0x9000:
raise click.ClickException(
f"Diag INS 0x{ins:02X} (N={n}) failed: SW=0x{sw:04X}"
)
return dt
t_low = measure(n_low)
t_high = measure(n_high)
per_op = (t_high - t_low) / (n_high - n_low)
return OpResult(
name=name,
per_op_ms=per_op,
n_low=n_low,
t_low_ms=t_low,
n_high=n_high,
t_high_ms=t_high,
)
def _print_auth1_reconstruction(results: list[OpResult]) -> None:
"""Reconstruct an AUTH1 budget from the measured per-op costs. Compares
the sum to the bench-test wall-clock so we can see if the primitives
fully account for the AUTH1 latency (or if there's additional overhead
we haven't profiled, like TLV parsing, key loading, etc.)."""
by_name = {r.name: r.per_op_ms for r in results}
# Use ecdsa_sign cost as ECDSA verify proxy (same algorithm, similar work).
by_name["ecdsa_verify"] = by_name["ecdsa_sign"]
click.echo("AUTH1 reconstructed budget (using AUTH1_OP_COUNTS):")
total = 0.0
for op, count in AUTH1_OP_COUNTS.items():
per_op = by_name.get(op, 0.0)
sub_total = per_op * count
total += sub_total
click.echo(
f" {op:<14} x {count:>2} -> {sub_total:>7.1f} ms "
f"({per_op:.1f} ms/op)"
)
click.echo(f" {'estimated_total':<19} {total:>7.1f} ms")
click.echo(
f"\nCompare against `aliro-bench-test` AUTH1 wall-clock. Gap "
f"= TLV parsing + key load + apdu I/O on card."
)
if __name__ == "__main__":
main()

View File

@@ -12,6 +12,7 @@ from pathlib import Path
import click import click
from aliro_harness.reader.step_up import verify_step_up_m2
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@@ -38,7 +39,17 @@ from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
is_flag=True, is_flag=True,
help="List PC/SC readers visible to the host and exit.", help="List PC/SC readers visible to the host and exit.",
) )
def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None: @click.option(
"--step-up",
is_flag=True,
help="After AUTH1, also exercise Step-Up Milestone 2: SELECT ACCE5502, "
"INS=0xC9 EXCHANGE, INS=0xC3 ENVELOPE (DeviceRequest) + GET RESPONSE "
"chaining. Decrypts the DeviceResponse under StepUpSKDevice and asserts "
"documents[0].issuerSigned.issuerAuth == access_document.bin from --trust-dir.",
)
def main(
trust_dir: Path | None, reader_index: int, list_readers: bool, step_up: bool
) -> None:
"""Run a single Aliro AUTH0+AUTH1 transaction against a personalized """Run a single Aliro AUTH0+AUTH1 transaction against a personalized
card via PC/SC. Decrypts the response and verifies the UD signature. card via PC/SC. Decrypts the response and verifies the UD signature.
Exits 0 on success, non-zero with a clear error on any failure.""" Exits 0 on success, non-zero with a clear error on any failure."""
@@ -77,11 +88,23 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
bundle = TrustBundle.from_trust_dir(trust_dir) bundle = TrustBundle.from_trust_dir(trust_dir)
click.echo(f"Loaded trust artifacts from {trust_dir}") click.echo(f"Loaded trust artifacts from {trust_dir}")
# Lazy: only load the AD if --step-up is set — otherwise this would
# gratuitously require access_document.bin for the AUTH1-only path.
expected_ad: bytes | None = None
if step_up:
expected_ad = (trust_dir / "access_document.bin").read_bytes()
def transmit(apdu: bytes) -> tuple[bytes, int]: def transmit(apdu: bytes) -> tuple[bytes, int]:
data, sw1, sw2 = connection.transmit(list(apdu)) data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2 return bytes(data), (sw1 << 8) | sw2
result = run_aliro_transaction(transmit=transmit, bundle=bundle) result = run_aliro_transaction(transmit=transmit, bundle=bundle)
step_up_verdict: tuple[bool, str] | None = None
if step_up and result.ok and result.step_up_sk is not None:
assert expected_ad is not None # set above whenever step_up is true
step_up_verdict = verify_step_up_m2(
transmit, result.step_up_sk, expected_ad
)
finally: finally:
connection.disconnect() connection.disconnect()
@@ -95,6 +118,7 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
f"RESULT: FAIL \u2014 {result.error}", err=True f"RESULT: FAIL \u2014 {result.error}", err=True
) )
_echo_tlv_breakdown(result) _echo_tlv_breakdown(result)
_echo_latencies(result)
click.echo( click.echo(
"Hint: the AUTH1 response decrypted, so session keys are " "Hint: the AUTH1 response decrypted, so session keys are "
"correct. The signature verification failed — check that the " "correct. The signature verification failed — check that the "
@@ -103,10 +127,24 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
err=True, err=True,
) )
raise click.exceptions.Exit(1) raise click.exceptions.Exit(1)
raise click.ClickException(result.error or "Aliro transaction failed.") # Early failure (e.g. non-9000 SW on SELECT/AUTH0/AUTH1) — print
# whatever per-APDU latencies we collected before bailing so the
# operator can see how long the failing step actually took.
click.echo(f"RESULT: FAIL — {result.error}", err=True)
_echo_latencies(result)
raise click.exceptions.Exit(1)
click.echo("RESULT: OK \u2014 applet round-trip on real hardware.") click.echo("RESULT: OK \u2014 applet round-trip on real hardware.")
_echo_tlv_breakdown(result) _echo_tlv_breakdown(result)
_echo_latencies(result)
if step_up_verdict is not None:
ok, msg = step_up_verdict
if ok:
click.echo(f"STEP-UP M2: OK \u2014 {msg}")
else:
click.echo(f"STEP-UP M2: FAIL \u2014 {msg}", err=True)
raise click.exceptions.Exit(1)
def _echo_tlv_breakdown(result) -> None: def _echo_tlv_breakdown(result) -> None:
@@ -116,3 +154,16 @@ def _echo_tlv_breakdown(result) -> None:
click.echo(f" 0x5A credential_PubK: {cred_pub_len}B") click.echo(f" 0x5A credential_PubK: {cred_pub_len}B")
click.echo(f" 0x9E UD signature: {ud_sig_len}B") click.echo(f" 0x9E UD signature: {ud_sig_len}B")
click.echo(f" 0x5E signaling_bitmap: 0x{bitmap_hex}") click.echo(f" 0x5E signaling_bitmap: 0x{bitmap_hex}")
def _echo_latencies(result) -> None:
"""Per-APDU wall-clock latencies. Surfaces card-side processing time \u2014
the AUTH1 number is what matters for the RFAL WTX-cap analysis."""
if not result.latencies_ms:
return
click.echo(" APDU latencies (ms):")
for step in ("select", "auth0", "auth1"):
if step in result.latencies_ms:
click.echo(f" {step:<6} {result.latencies_ms[step]:7.1f}")
total = sum(result.latencies_ms.values())
click.echo(f" {'total':<6} {total:7.1f}")

View File

@@ -36,7 +36,39 @@ def derive_kdh(
credential_ephem_pub_uncompressed: bytes, credential_ephem_pub_uncompressed: bytes,
transaction_id: bytes, transaction_id: bytes,
) -> bytes: ) -> bytes:
"""§8.3.1.4 (with §8.3.1.5 substitution): Kdh = HKDF(IKM=ECDH_x, """§8.3.1.4: X9.63 KDF (BSI TR-03111) with H=SHA-256, ZAB = ECDH
salt=transaction_id, info=∅, L=32).""" shared-secret x-coord, SharedInfo = transaction_identifier, K = 256 bits.
For 32-byte output reduces to a single SHA-256:
Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
History: previously we used HKDF here, misreading the §8.3.1.4 note
("actual key derivation is performed using §8.3.1.5") as authorizing
HKDF substitution for Kdh itself. The note is about subsequent
session-key derivation (§8.3.1.13 -> §8.3.1.5 HKDF), not Kdh. The
misread was symmetric with the applet so AUTH1 succeeded against our
own card but failed against ST's X-CUBE-ALIRO library (which follows
§8.3.1.4 correctly) with ACWG_Error_Crypto_EncryptDecrypt. Fixed
2026-06-11.
"""
import hashlib
z_ab = ecdh_shared_x(reader_ephem_priv, credential_ephem_pub_uncompressed) z_ab = ecdh_shared_x(reader_ephem_priv, credential_ephem_pub_uncompressed)
return hkdf_sha256(z_ab, transaction_id, b"", 32) h = hashlib.sha256()
h.update(z_ab) # ZAB (32 B)
h.update(b"\x00\x00\x00\x01") # counter = 1 (4 B BE)
h.update(transaction_id) # SharedInfo (16 B)
return h.digest() # 32 B
def derive_step_up_session_keys(step_up_sk: bytes) -> tuple[bytes, bytes]:
"""§8.4.3 (mdoc 9.1.1.5 with Aliro changes): HKDF-SHA-256 with IKM =
StepUpSK, empty salt, info = ``b"SKDevice"`` / ``b"SKReader"``, L = 32.
Returns ``(sk_device, sk_reader)``. Mirrors
``AliroCrypto.deriveStepUpSessionKeys`` byte-for-byte.
"""
if len(step_up_sk) != 32:
raise ValueError(f"StepUpSK must be 32B, got {len(step_up_sk)}")
sk_device = hkdf_sha256(step_up_sk, b"", b"SKDevice", 32)
sk_reader = hkdf_sha256(step_up_sk, b"", b"SKReader", 32)
return sk_device, sk_reader

View File

@@ -29,9 +29,17 @@ def build_salt_volatile(
transaction_id: bytes, transaction_id: bytes,
command_parameters: int, command_parameters: int,
authentication_policy: int, authentication_policy: int,
credential_long_term_pub_x: bytes,
) -> bytes: ) -> bytes:
"""Per spec §8.3.1.13. Mirrors AliroApplet.buildSaltVolatile (lines 406-446).""" """Per spec §8.3.1.13. Mirrors AliroApplet.buildSaltVolatile.
Spec §8.3.1.13 salt_volatile ends at the 0xA5 proprietary TLV. We
previously appended x(credential_long_term_pub_key) here; that was a
misread of the spec (the credential pubkey belongs in `info`, and even
there it's the *ephemeral* pubkey, not the long-term one). The misread
was symmetric between this reader and the applet, so AUTH1 succeeded
against our own card but failed against ST's X-CUBE-ALIRO with
ACWG_Error_Crypto_EncryptDecrypt. Fixed 2026-06-11.
"""
if not (0 <= command_parameters <= 0xFF and 0 <= authentication_policy <= 0xFF): if not (0 <= command_parameters <= 0xFF and 0 <= authentication_policy <= 0xFF):
raise ValueError( raise ValueError(
"command_parameters and authentication_policy must each fit in one byte" "command_parameters and authentication_policy must each fit in one byte"
@@ -48,11 +56,10 @@ def build_salt_volatile(
out.write(transaction_id) # 16 out.write(transaction_id) # 16
out.write(bytes([command_parameters, authentication_policy])) # 2 out.write(bytes([command_parameters, authentication_policy])) # 2
out.write(PROPRIETARY_A5_TLV) # 10 out.write(PROPRIETARY_A5_TLV) # 10
out.write(credential_long_term_pub_x) # 32
salt = out.getvalue() salt = out.getvalue()
if len(salt) != 173: if len(salt) != 141:
raise ValueError( raise ValueError(
f"salt_volatile must be 173 bytes (caller passed wrong-length x-coord or ID); " f"salt_volatile must be 141 bytes (caller passed wrong-length x-coord or ID); "
f"got {len(salt)}. Each 32B field must be exactly 32B; each 16B field must be 16B." f"got {len(salt)}. Each 32B field must be exactly 32B; each 16B field must be 16B."
) )
return salt return salt

View File

@@ -0,0 +1,183 @@
"""Step-Up Milestone 2 PC/SC verification.
Drives the M2 applet path post-AUTH1:
- SELECT ACCE5502 (StepUpApplet)
- INS=0xC9 EXCHANGE encrypted with StepUpSKReader carrying a Reader Status
sub-event REQUEST plaintext (spec §8.3.3.5 / Table 8-19):
sub_event_id = 0x01 (ReaderStatusRequest), payload_len = 0
-> applet returns 19 B = 3 B response plaintext + 16 B GCM tag, decrypts
under StepUpSKDevice + deviceIv(1) to:
sub_event_id = 0x01, status = 0x00, payload_len = 0 (Table 8-20).
- INS=0xC3 ENVELOPE encrypted with StepUpSKReader carrying a canonical CBOR
mdoc DeviceRequest -> applet returns the first chunk of an encrypted
DeviceResponse with SW=61xx.
- INS=0xC0 GET RESPONSE repeated until SW=9000 — concatenated body decrypts
under StepUpSKDevice + deviceIv(2) (EXCHANGE consumed deviceIv(1)).
- Plaintext is a canonical CBOR DeviceResponse; we walk
``documents[0].issuerSigned.issuerAuth`` and assert it round-trips the
Access Document the reader was provisioned with.
IV layout per applet (StepUpApplet.processExchange / processEnvelope):
reader -> device : 0x00*8 || counter(4B BE)
device -> reader : 0x00*7 || 0x01 || counter(4B BE)
Both counters init to 1; each advances by 1 after use. EXCHANGE now consumes
deviceCounter=1 (encrypted Reader Status response), so the ENVELOPE response
ciphertext decrypts under deviceCounter=2.
"""
import cbor2
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.crypto import derive_step_up_session_keys
from aliro_harness.reader.transaction import Transmit
STEP_UP_AID = bytes.fromhex("A000000909ACCE5502")
SW_OK = 0x9000
SW1_MORE_DATA = 0x61
INS_GET_RESPONSE = 0xC0
CLA_ISO = 0x00
CLA_PROPRIETARY = 0x80
INS_EXCHANGE = 0xC9
INS_ENVELOPE = 0xC3
# Canonical-CBOR DeviceRequest the M2D.1 applet parser accepts:
# {"version":"1.0",
# "docRequests":[{"itemsRequest": <bstr-wrapped {"docType":...,"nameSpaces":{}}>}]}
# Built once at import so each call ships the same byte sequence the
# applet's DeviceRequestParserTest pins as VALID.
_DEVICE_REQUEST = cbor2.dumps(
{
"version": "1.0",
"docRequests": [
{
"itemsRequest": cbor2.dumps(
{"docType": "org.iso.18013.5.1.mDL", "nameSpaces": {}},
canonical=True,
)
}
],
},
canonical=True,
)
def _iv_reader(counter: int) -> bytes:
return b"\x00" * 8 + counter.to_bytes(4, "big")
def _iv_device(counter: int) -> bytes:
return b"\x00" * 7 + b"\x01" + counter.to_bytes(4, "big")
def _drain_chaining(transmit: Transmit, first_body: bytes, first_sw: int) -> tuple[bytes, int]:
"""Follow ISO 7816-4 SW=61xx response chaining until a terminal SW. Returns
the concatenated body and the final SW. SW2 advertises bytes remaining
(0x00 means "256 or more"; the applet caps at 0xFF). On a non-61xx SW
we return whatever's been accumulated so far so the caller can produce a
clear error message."""
body = bytearray(first_body)
sw = first_sw
while (sw >> 8) == SW1_MORE_DATA:
le = sw & 0xFF # 0x00 -> request 256, else the advertised count
apdu = bytes([CLA_ISO, INS_GET_RESPONSE, 0x00, 0x00, le])
chunk, sw = transmit(apdu)
body.extend(chunk)
return bytes(body), sw
def verify_step_up_m2(
transmit: Transmit, step_up_sk: bytes, expected_ad: bytes
) -> tuple[bool, str]:
"""Drives the M2 step-up round-trip and asserts the applet hands back our
Access Document inside the DeviceResponse.
Args:
transmit: PC/SC transport (bytes APDU -> (response, SW)).
step_up_sk: 32 B StepUpSK from AUTH1 (decrypted derived_keys_volatile).
expected_ad: Access Document bytes the reader was provisioned with;
compared against the issuerAuth field of the decrypted DeviceResponse.
Returns ``(ok, message)``. On failure ``message`` names the failed step.
"""
sk_device, sk_reader = derive_step_up_session_keys(step_up_sk)
reader_counter = 1
device_counter = 1
# SELECT ACCE5502 -- M1A.2 derives StepUpSKReader/Device on this select().
apdu = bytes([CLA_ISO, 0xA4, 0x04, 0x00, len(STEP_UP_AID)]) + STEP_UP_AID
_, sw = transmit(apdu)
if sw != SW_OK:
return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}"
# M2E.1 + M2E.2 -- EXCHANGE: ship a Reader Status sub-event REQUEST
# ([sub_event_id=0x01, payload_len=0x00]), expect a 19 B encrypted Reader
# Status sub-event RESPONSE back ([sub_event_id=0x01, status=0x00,
# payload_len=0x00] under SKDevice + deviceIv(1)).
request_pt = b"\x01\x00"
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), request_pt, None)
apdu = bytes([CLA_PROPRIETARY, INS_EXCHANGE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
data, sw = transmit(apdu)
if sw != SW_OK:
return False, f"M2 EXCHANGE failed: SW=0x{sw:04X}"
if len(data) != 19:
return False, f"M2 EXCHANGE expected 19B response, got {len(data)}B: {data.hex()}"
try:
exchange_pt = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None)
except Exception as e:
return False, f"M2 EXCHANGE response decrypt failed (tag/key mismatch): {e}"
if exchange_pt != b"\x01\x00\x00":
return False, (
"M2 EXCHANGE response plaintext mismatch: expected "
f"[0x01, 0x00, 0x00], got {exchange_pt.hex()}"
)
reader_counter += 1
device_counter += 1
# M2 -- ENVELOPE: ship a valid CBOR DeviceRequest, drain GET RESPONSE
# chaining, decrypt under deviceCounter=2, and assert the round-tripped AD.
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), _DEVICE_REQUEST, None)
apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
first_body, first_sw = transmit(apdu)
full_body, sw = _drain_chaining(transmit, first_body, first_sw)
if sw != SW_OK:
return False, f"M2 ENVELOPE/GET RESPONSE failed: SW=0x{sw:04X} after {len(full_body)}B"
try:
plaintext = AESGCM(sk_device).decrypt(_iv_device(device_counter), full_body, None)
except Exception as e:
return False, f"M2 ENVELOPE response decrypt failed (tag/key mismatch): {e}"
try:
resp = cbor2.loads(plaintext)
except Exception as e:
return False, f"M2 ENVELOPE plaintext is not valid CBOR: {e}"
try:
documents = resp["documents"]
issuer_signed = documents[0]["issuerSigned"]
issuer_auth = issuer_signed["issuerAuth"]
except (KeyError, TypeError, IndexError) as e:
return False, f"M2 DeviceResponse missing documents[0].issuerSigned.issuerAuth: {e}"
# The applet splices the AD verbatim under issuerAuth, but cbor2 decoded it
# into a 4-element list. Re-encode canonically for byte-equality with the
# provisioned AD (trustgen produces canonical CBOR ADs). Fall back to
# element-wise comparison on the decoded list to avoid false negatives
# from harmless canonical-encoding drift (e.g. tag wrappers).
ad_round_trip = cbor2.dumps(issuer_auth, canonical=True)
if ad_round_trip != expected_ad:
try:
expected_decoded = cbor2.loads(expected_ad)
except Exception:
return False, (
"M2 issuerAuth mismatch: round-tripped bytes differ from "
f"expected AD and expected AD isn't valid CBOR ({len(expected_ad)}B)"
)
if issuer_auth != expected_decoded:
return False, (
"M2 issuerAuth mismatch vs. provisioned Access Document "
f"(got {len(ad_round_trip)}B, expected {len(expected_ad)}B)"
)
return True, "M2 step-up verified (EXCHANGE + ENVELOPE Access Document round-trip)"

View File

@@ -7,8 +7,9 @@ a mocked/in-process card.
""" """
import secrets import secrets
import time
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
@@ -30,6 +31,7 @@ from aliro_harness.reader.auth1_response import (
from aliro_harness.reader.crypto import derive_kdh from aliro_harness.reader.crypto import derive_kdh
from aliro_harness.reader.key_derivation import ( from aliro_harness.reader.key_derivation import (
OFF_EXPEDITED_SK_DEVICE, OFF_EXPEDITED_SK_DEVICE,
OFF_STEP_UP_SK,
build_salt_volatile, build_salt_volatile,
derive_expedited_session_keys, derive_expedited_session_keys,
) )
@@ -122,7 +124,12 @@ class TransactionResult:
credential_pub: bytes | None = None # 0x5A value (65B uncompressed) when present credential_pub: bytes | None = None # 0x5A value (65B uncompressed) when present
ud_sig: bytes | None = None # 0x9E value (64B raw r||s) ud_sig: bytes | None = None # 0x9E value (64B raw r||s)
signaling_bitmap: bytes | None = None # 0x5E value (2B) signaling_bitmap: bytes | None = None # 0x5E value (2B)
step_up_sk: bytes | None = None # 32B StepUpSK from derived_keys_volatile[64..96)
error: str | None = None # populated on failure error: str | None = None # populated on failure
# Wall-clock per-APDU latencies in milliseconds. Populated as each step
# runs, so a partial result on failure still surfaces what we measured
# before the failing step. Keys: "select", "auth0", "auth1".
latencies_ms: dict[str, float] = field(default_factory=dict)
def run_aliro_transaction( def run_aliro_transaction(
@@ -140,14 +147,22 @@ def run_aliro_transaction(
Any non-9000 SW or signature mismatch returns ok=False with ``error`` set. Any non-9000 SW or signature mismatch returns ok=False with ``error`` set.
""" """
# Collect per-APDU wall-clock latencies; mutated in place and threaded
# through every TransactionResult so partial timings surface on failure.
latencies: dict[str, float] = {}
# SELECT EXPEDITED # SELECT EXPEDITED
select_apdu = ( select_apdu = (
bytes([0x00, 0xA4, 0x04, 0x00, len(EXPEDITED_AID)]) + EXPEDITED_AID bytes([0x00, 0xA4, 0x04, 0x00, len(EXPEDITED_AID)]) + EXPEDITED_AID
) )
_t = time.perf_counter()
_, sw = transmit(select_apdu) _, sw = transmit(select_apdu)
latencies["select"] = (time.perf_counter() - _t) * 1000.0
if sw != SW_OK: if sw != SW_OK:
return TransactionResult( return TransactionResult(
ok=False, error=f"SELECT EXPEDITED failed: SW=0x{sw:04X}" ok=False,
error=f"SELECT EXPEDITED failed: SW=0x{sw:04X}",
latencies_ms=latencies,
) )
# Generate reader ephemeral + transaction state # Generate reader ephemeral + transaction state
@@ -168,9 +183,15 @@ def run_aliro_transaction(
command_parameters=0x00, command_parameters=0x00,
authentication_policy=0x00, authentication_policy=0x00,
) )
_t = time.perf_counter()
auth0_resp_data, sw = transmit(build_auth0_apdu(auth0_data)) auth0_resp_data, sw = transmit(build_auth0_apdu(auth0_data))
latencies["auth0"] = (time.perf_counter() - _t) * 1000.0
if sw != SW_OK: if sw != SW_OK:
return TransactionResult(ok=False, error=f"AUTH0 failed: SW=0x{sw:04X}") return TransactionResult(
ok=False,
error=f"AUTH0 failed: SW=0x{sw:04X}",
latencies_ms=latencies,
)
cred_ephem_pub_uncompressed = find_top_level(auth0_resp_data, 0x86) cred_ephem_pub_uncompressed = find_top_level(auth0_resp_data, 0x86)
if ( if (
@@ -180,25 +201,31 @@ def run_aliro_transaction(
return TransactionResult( return TransactionResult(
ok=False, ok=False,
error="AUTH0 response missing or malformed 0x86 credential_ePubK", error="AUTH0 response missing or malformed 0x86 credential_ePubK",
latencies_ms=latencies,
) )
cred_ephem_pub_x = cred_ephem_pub_uncompressed[1:33] cred_ephem_pub_x = cred_ephem_pub_uncompressed[1:33]
# Key derivation # Key derivation
kdh = derive_kdh(reader_ephem, cred_ephem_pub_uncompressed, txn_id) kdh = derive_kdh(reader_ephem, cred_ephem_pub_uncompressed, txn_id)
# flag = command_parameters || authentication_policy per §8.3.1.13.
# command_parameters here is the AUTH1 command's value (0x01 for
# "request credential_PubK in response"), not AUTH0's. authentication_policy
# only appears in AUTH0; v1 hardcodes 0x00 (no policy enforced).
# See the AUTH1 build_auth1_data call below -- same value must round-trip.
salt_volatile = build_salt_volatile( salt_volatile = build_salt_volatile(
reader_long_term_pub_x=bundle.reader_long_term_pub_x, reader_long_term_pub_x=bundle.reader_long_term_pub_x,
reader_group_id=bundle.reader_group_id, reader_group_id=bundle.reader_group_id,
reader_group_sub_id=bundle.reader_group_sub_id, reader_group_sub_id=bundle.reader_group_sub_id,
reader_ephem_pub_x=reader_ephem_pub_x, reader_ephem_pub_x=reader_ephem_pub_x,
transaction_id=txn_id, transaction_id=txn_id,
command_parameters=0x00, command_parameters=0x01,
authentication_policy=0x00, authentication_policy=0x00,
credential_long_term_pub_x=bundle.credential_long_term_pub_x,
) )
derived_keys = derive_expedited_session_keys( derived_keys = derive_expedited_session_keys(
kdh, salt_volatile, cred_ephem_pub_x kdh, salt_volatile, cred_ephem_pub_x
) )
sk_device = derived_keys[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32] sk_device = derived_keys[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32]
step_up_sk = derived_keys[OFF_STEP_UP_SK : OFF_STEP_UP_SK + 32]
# AUTH1 # AUTH1
reader_id_32 = bundle.reader_group_id + bundle.reader_group_sub_id reader_id_32 = bundle.reader_group_id + bundle.reader_group_sub_id
@@ -210,16 +237,24 @@ def run_aliro_transaction(
) )
raw_sig = sign_table_812_raw(table_812, bundle.reader_priv) raw_sig = sign_table_812_raw(table_812, bundle.reader_priv)
auth1_data = build_auth1_data(raw_sig, command_parameters=0x01) auth1_data = build_auth1_data(raw_sig, command_parameters=0x01)
_t = time.perf_counter()
auth1_resp_data, sw = transmit(build_auth1_apdu(auth1_data)) auth1_resp_data, sw = transmit(build_auth1_apdu(auth1_data))
latencies["auth1"] = (time.perf_counter() - _t) * 1000.0
if sw != SW_OK: if sw != SW_OK:
return TransactionResult(ok=False, error=f"AUTH1 failed: SW=0x{sw:04X}") return TransactionResult(
ok=False,
error=f"AUTH1 failed: SW=0x{sw:04X}",
latencies_ms=latencies,
)
# Decrypt + verify # Decrypt + verify
try: try:
plaintext = decrypt_auth1_response(sk_device, auth1_resp_data) plaintext = decrypt_auth1_response(sk_device, auth1_resp_data)
except Exception as e: except Exception as e:
return TransactionResult( return TransactionResult(
ok=False, error=f"AUTH1 response decrypt failed: {e}" ok=False,
error=f"AUTH1 response decrypt failed: {e}",
latencies_ms=latencies,
) )
cred_pub_5a = find_top_level(plaintext, 0x5A) cred_pub_5a = find_top_level(plaintext, 0x5A)
@@ -231,12 +266,14 @@ def run_aliro_transaction(
ok=False, ok=False,
error="Table 8-11 missing 0x9E UD signature", error="Table 8-11 missing 0x9E UD signature",
plaintext=plaintext, plaintext=plaintext,
latencies_ms=latencies,
) )
if bitmap is None: if bitmap is None:
return TransactionResult( return TransactionResult(
ok=False, ok=False,
error="Table 8-11 missing 0x5E signaling_bitmap", error="Table 8-11 missing 0x5E signaling_bitmap",
plaintext=plaintext, plaintext=plaintext,
latencies_ms=latencies,
) )
table_813 = build_table_813( table_813 = build_table_813(
@@ -258,6 +295,7 @@ def run_aliro_transaction(
credential_pub=cred_pub_5a, credential_pub=cred_pub_5a,
ud_sig=ud_sig, ud_sig=ud_sig,
signaling_bitmap=bitmap, signaling_bitmap=bitmap,
latencies_ms=latencies,
) )
return TransactionResult( return TransactionResult(
@@ -266,4 +304,6 @@ def run_aliro_transaction(
credential_pub=cred_pub_5a, credential_pub=cred_pub_5a,
ud_sig=ud_sig, ud_sig=ud_sig,
signaling_bitmap=bitmap, signaling_bitmap=bitmap,
step_up_sk=step_up_sk,
latencies_ms=latencies,
) )

View File

@@ -280,6 +280,11 @@ class FakeAliroCard:
if not self._verify_reader_sig(table_812, reader_raw_sig): if not self._verify_reader_sig(table_812, reader_raw_sig):
return b"", 0x6A80 return b"", 0x6A80
# §8.3.1.13 flag uses the AUTH1 command_parameters (not AUTH0's).
# Overwrite the AUTH0 value stored on the session so _derive_sk_device
# builds salt_volatile with the right byte.
self.session.command_parameters = cmd_params
# Derive session keys # Derive session keys
sk_device = self._derive_sk_device() sk_device = self._derive_sk_device()
@@ -362,9 +367,8 @@ class FakeAliroCard:
txn_id, txn_id,
) )
# x-coords # x-coord for the reader long-term key
reader_long_term_x = self._pub_x(self.reader_pub) reader_long_term_x = self._pub_x(self.reader_pub)
credential_long_term_x = self._pub_x(self.credential_pub)
salt = build_salt_volatile( salt = build_salt_volatile(
reader_long_term_pub_x=reader_long_term_x, reader_long_term_pub_x=reader_long_term_x,
@@ -374,7 +378,6 @@ class FakeAliroCard:
transaction_id=txn_id, transaction_id=txn_id,
command_parameters=self.session.command_parameters, command_parameters=self.session.command_parameters,
authentication_policy=self.session.authentication_policy, authentication_policy=self.session.authentication_policy,
credential_long_term_pub_x=credential_long_term_x,
) )
info = cred_ephem_pub[1:33] info = cred_ephem_pub[1:33]

View File

@@ -5,12 +5,14 @@ import pytest
from aliro_harness.personalizer.apdus import ( from aliro_harness.personalizer.apdus import (
CLA_PROPRIETARY, CLA_PROPRIETARY,
INS_COMMIT, INS_COMMIT,
INS_SET_CRED_ISSUER_PUBK,
INS_SET_CRED_PRIV, INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK, INS_SET_CRED_PUBK,
INS_SET_READER_PUBK, INS_SET_READER_PUBK,
PROVISIONING_AID, PROVISIONING_AID,
commit_apdu, commit_apdu,
select_provisioning_apdu, select_provisioning_apdu,
set_credential_issuer_pubk_apdu,
set_credential_priv_apdu, set_credential_priv_apdu,
set_credential_pubk_apdu, set_credential_pubk_apdu,
set_reader_pubk_apdu, set_reader_pubk_apdu,
@@ -55,6 +57,23 @@ def test_set_reader_pubk_apdu():
assert apdu == bytes([CLA_PROPRIETARY, INS_SET_READER_PUBK, 0x00, 0x00, 0x40]) + pub assert apdu == bytes([CLA_PROPRIETARY, INS_SET_READER_PUBK, 0x00, 0x00, 0x40]) + pub
def test_set_credential_issuer_pubk_apdu():
pub = bytes(range(64))
apdu = set_credential_issuer_pubk_apdu(pub)
# CLA INS P1 P2 Lc data — INS 0x25, same 64B x||y shape as cred_pubk
assert apdu == bytes([CLA_PROPRIETARY, INS_SET_CRED_ISSUER_PUBK, 0x00, 0x00, 0x40]) + pub
def test_set_credential_issuer_pubk_rejects_wrong_length():
with pytest.raises(ValueError, match="64B"):
set_credential_issuer_pubk_apdu(bytes(63))
def test_ins_set_cred_issuer_pubk_constant_matches_applet():
"""Lock-down: applet PersonalizationApplet.INS_SET_CREDENTIAL_ISSUER_PUBK = 0x25."""
assert INS_SET_CRED_ISSUER_PUBK == 0x25
def test_commit_apdu_has_no_data_field(): def test_commit_apdu_has_no_data_field():
apdu = commit_apdu() apdu = commit_apdu()
assert apdu == bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00]) assert apdu == bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])

View File

@@ -8,6 +8,7 @@ from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.personalizer.apdus import ( from aliro_harness.personalizer.apdus import (
INS_COMMIT, INS_COMMIT,
INS_FINALIZE_ACCESS_DOC, INS_FINALIZE_ACCESS_DOC,
INS_SET_CRED_ISSUER_PUBK,
INS_SET_CRED_PRIV, INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK, INS_SET_CRED_PUBK,
INS_SET_READER_PUBK, INS_SET_READER_PUBK,
@@ -46,6 +47,7 @@ def artifacts() -> TrustArtifacts:
credential_priv=extract_priv_scalar(cred), credential_priv=extract_priv_scalar(cred),
credential_pubk_xy=extract_pub_xy(cred.public_key()), credential_pubk_xy=extract_pub_xy(cred.public_key()),
reader_pubk_xy=extract_pub_xy(reader.public_key()), reader_pubk_xy=extract_pub_xy(reader.public_key()),
credential_issuer_pub=extract_pub_xy(issuer.public_key()),
access_document=build_access_document( access_document=build_access_document(
issuer_private_key=issuer, issuer_private_key=issuer,
access_credential_public_key=cred.public_key(), access_credential_public_key=cred.public_key(),
@@ -58,17 +60,35 @@ def test_full_sequence_in_expected_order(artifacts):
personalize_card(transport, artifacts) personalize_card(transport, artifacts)
insns = [apdu[1] for apdu in transport.sent] insns = [apdu[1] for apdu in transport.sent]
# SELECT (0xA4) → SET_PRIV → SET_PUBK → SET_READER_PUBK → WRITE_AD+ → FINALIZE_AD → COMMIT # SELECT (0xA4) → SET_PRIV → SET_PUBK → SET_READER_PUBK →
# SET_CRED_ISSUER_PUBK → WRITE_AD+ → FINALIZE_AD → COMMIT.
# SET_CRED_ISSUER_PUBK must land BEFORE WRITE_AD chunks so that by
# the time FINALIZE runs, the issuer trust anchor is staged.
assert insns[0] == 0xA4 assert insns[0] == 0xA4
assert insns[1] == INS_SET_CRED_PRIV assert insns[1] == INS_SET_CRED_PRIV
assert insns[2] == INS_SET_CRED_PUBK assert insns[2] == INS_SET_CRED_PUBK
assert insns[3] == INS_SET_READER_PUBK assert insns[3] == INS_SET_READER_PUBK
assert insns[4] == INS_SET_CRED_ISSUER_PUBK
# AD writes (variable count) then a single finalize, then commit. # AD writes (variable count) then a single finalize, then commit.
assert all(i == INS_WRITE_ACCESS_DOC for i in insns[4:-2]) assert all(i == INS_WRITE_ACCESS_DOC for i in insns[5:-2])
assert insns[-2] == INS_FINALIZE_ACCESS_DOC assert insns[-2] == INS_FINALIZE_ACCESS_DOC
assert insns[-1] == INS_COMMIT assert insns[-1] == INS_COMMIT
def test_credential_issuer_pubk_apdu_carries_exact_bytes(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
# Find the INS_SET_CRED_ISSUER_PUBK APDU.
issuer_apdu = next(a for a in transport.sent if a[1] == INS_SET_CRED_ISSUER_PUBK)
# CLA INS P1 P2 Lc data
assert issuer_apdu[0] == 0x80
assert issuer_apdu[2] == 0x00
assert issuer_apdu[3] == 0x00
assert issuer_apdu[4] == 0x40 # Lc = 64
assert issuer_apdu[5:] == artifacts.credential_issuer_pub
assert len(artifacts.credential_issuer_pub) == 64
def test_select_apdu_carries_provisioning_aid(artifacts): def test_select_apdu_carries_provisioning_aid(artifacts):
transport = FakeTransport() transport = FakeTransport()
personalize_card(transport, artifacts) personalize_card(transport, artifacts)

View File

@@ -13,7 +13,6 @@ def test_salt_volatile_layout_per_spec_8_3_1_13():
transaction_id=b"\xcc" * 16, transaction_id=b"\xcc" * 16,
command_parameters=0x00, command_parameters=0x00,
authentication_policy=0x00, authentication_policy=0x00,
credential_long_term_pub_x=b"\x55" * 32,
) )
p = 0 p = 0
assert salt[p : p + 32] == b"\x01" * 32 # x(reader_group_identifier_key) assert salt[p : p + 32] == b"\x01" * 32 # x(reader_group_identifier_key)
@@ -38,9 +37,10 @@ def test_salt_volatile_layout_per_spec_8_3_1_13():
p += 2 p += 2
assert salt[p : p + 10] == bytes.fromhex("A50880020000 5C020100".replace(" ", "")) assert salt[p : p + 10] == bytes.fromhex("A50880020000 5C020100".replace(" ", ""))
p += 10 p += 10
assert salt[p : p + 32] == b"\x55" * 32 # x(credential_long_term) # salt_volatile ends at the 0xA5 TLV per spec §8.3.1.13. The
p += 32 # credential_long_term_pub_x previously appended here was a misread of
assert len(salt) == p == 173 # the spec (caused vendor library ACWG_Error_Crypto_EncryptDecrypt).
assert len(salt) == p == 141
def test_salt_volatile_threads_flag_bytes_in_correct_order(): def test_salt_volatile_threads_flag_bytes_in_correct_order():
@@ -53,7 +53,6 @@ def test_salt_volatile_threads_flag_bytes_in_correct_order():
transaction_id=b"\x00" * 16, transaction_id=b"\x00" * 16,
command_parameters=0xAB, command_parameters=0xAB,
authentication_policy=0xCD, authentication_policy=0xCD,
credential_long_term_pub_x=b"\x00" * 32,
) )
# flag offset: 32 + 12 + 16 + 16 + 1 + 2 + 2 + 32 + 16 = 129 # flag offset: 32 + 12 + 16 + 16 + 1 + 2 + 2 + 32 + 16 = 129
assert salt[129] == 0xAB assert salt[129] == 0xAB
@@ -62,7 +61,7 @@ def test_salt_volatile_threads_flag_bytes_in_correct_order():
def test_derive_expedited_session_keys_returns_160_bytes_deterministic(): def test_derive_expedited_session_keys_returns_160_bytes_deterministic():
kdh = bytes.fromhex("11" * 32) kdh = bytes.fromhex("11" * 32)
salt = bytes.fromhex("22" * 173) salt = bytes.fromhex("22" * 141)
info = bytes.fromhex("33" * 65) # x(credential_ephem_pub) is 32B in practice; any bytes OK here info = bytes.fromhex("33" * 65) # x(credential_ephem_pub) is 32B in practice; any bytes OK here
out1 = derive_expedited_session_keys(kdh, salt, info) out1 = derive_expedited_session_keys(kdh, salt, info)
out2 = derive_expedited_session_keys(kdh, salt, info) out2 = derive_expedited_session_keys(kdh, salt, info)
@@ -72,7 +71,7 @@ def test_derive_expedited_session_keys_returns_160_bytes_deterministic():
def test_derive_expedited_session_keys_changes_with_inputs(): def test_derive_expedited_session_keys_changes_with_inputs():
kdh = bytes.fromhex("11" * 32) kdh = bytes.fromhex("11" * 32)
salt_a = bytes.fromhex("22" * 173) salt_a = bytes.fromhex("22" * 141)
salt_b = bytes.fromhex("23" + "22" * 172) salt_b = bytes.fromhex("23" + "22" * 140)
info = bytes.fromhex("33" * 32) info = bytes.fromhex("33" * 32)
assert derive_expedited_session_keys(kdh, salt_a, info) != derive_expedited_session_keys(kdh, salt_b, info) assert derive_expedited_session_keys(kdh, salt_a, info) != derive_expedited_session_keys(kdh, salt_b, info)

View File

@@ -0,0 +1,261 @@
"""Smoke tests for step-up reader verification.
Covers:
- StepUpSK -> SKDevice/SKReader HKDF parity with stdlib RFC 5869.
- M2G.1: `verify_step_up_m2` drives the full M2 ENVELOPE round-trip:
ENVELOPE(CBOR DeviceRequest) -> 252B chunk + SW=61xx
GET RESPONSE -> remainder + SW=9000
decrypt(StepUpSKDevice, deviceIv(1)) -> canonical DeviceResponse
extract documents[0].issuerSigned.issuerAuth, assert == expected_ad.
The M2G.1 test uses a mock transmit driven by a canned ciphertext that the
test itself synthesizes via AESGCM — so it pins the reader's framing /
chaining / decrypt / CBOR-walk logic without depending on the applet build.
M2G.2 covers the real-hardware verdict.
"""
import hashlib
import hmac
import cbor2
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.crypto import derive_step_up_session_keys
from aliro_harness.reader.step_up import verify_step_up_m2
def _hkdf_manual(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
"""Bare-stdlib RFC 5869 reference. Independent of `cryptography` so the
cross-check actually catches a wrong-info / wrong-salt regression in
derive_step_up_session_keys."""
use_salt = salt if salt else b"\x00" * 32
prk = hmac.new(use_salt, ikm, hashlib.sha256).digest()
out = b""
t = b""
counter = 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
out += t
counter += 1
return out[:length]
def test_derive_step_up_session_keys_matches_rfc5869():
# Same IKM as AliroCryptoTest.deriveStepUpSessionKeysMatchesManualHkdf:
# bytes 0xC0..0xDF -- so this Python test pins the same vector the
# applet test pins.
step_up_sk = bytes(range(0xC0, 0xE0))
sk_device, sk_reader = derive_step_up_session_keys(step_up_sk)
assert len(sk_device) == 32
assert len(sk_reader) == 32
assert sk_device != sk_reader
assert sk_device != step_up_sk
assert sk_device == _hkdf_manual(step_up_sk, b"", b"SKDevice", 32)
assert sk_reader == _hkdf_manual(step_up_sk, b"", b"SKReader", 32)
# ------------------------------------------------------------------------- #
# M2G.1: full ENVELOPE round-trip with GET RESPONSE chaining + AD assert. #
# ------------------------------------------------------------------------- #
# Same canonical-CBOR shape the applet's DeviceResponseBuilder produces and
# the M2G.1 verify function walks. Used to build a synthetic
# (decrypts-cleanly) ciphertext for the mock transmit.
def _build_fixed_device_response(ad_bytes: bytes) -> bytes:
"""Mirror of applet DeviceResponseBuilder.build for the M2 fixed shape.
`issuerAuth` is the raw AD bytes spliced verbatim — we decode them so
cbor2 re-encodes canonically (matches the applet which assumes the AD
was canonical at personalization)."""
return cbor2.dumps(
{
"status": 0,
"version": "1.0",
"documents": [
{
"docType": "org.iso.18013.5.1.mDL",
"issuerSigned": {
"issuerAuth": cbor2.loads(ad_bytes),
"nameSpaces": {},
},
}
],
},
canonical=True,
)
def _fake_ad() -> bytes:
"""A valid 4-element COSE_Sign1 stand-in, sized so the wrapped DeviceResponse
exceeds 252 B and forces GET RESPONSE chaining (the real applet's 388 B
case). The M2G.1 verifier doesn't crypto-verify the AD, only round-trips
its bytes — so a 4-element CBOR array of the right size is enough."""
return cbor2.dumps(
[
b"\xa0", # protected (empty map, bstr-wrapped)
{}, # unprotected (empty map)
b"\x11" * 200, # payload — pad to push the response over CHUNK_LEN
b"\x00" * 64, # signature (raw r||s, fixed P-256 width)
],
canonical=True,
)
def _iv_reader(counter: int) -> bytes:
return b"\x00" * 8 + counter.to_bytes(4, "big")
def _iv_device(counter: int) -> bytes:
return b"\x00" * 7 + b"\x01" + counter.to_bytes(4, "big")
class _MockTransmit:
"""Records APDUs and replies from a scripted SELECT/EXCHANGE/ENVELOPE/
GET RESPONSE sequence.
EXCHANGE replies with the spec Reader Status sub-event RESPONSE
([0x01, 0x00, 0x00]) encrypted under SKDevice + deviceIv(1). The applet
consumes deviceCounter=1 on this encrypt, so the subsequent ENVELOPE
response uses deviceCounter=2.
ENVELOPE replies with the first 252 B of a pre-encrypted DeviceResponse
and SW=61xx; GET RESPONSE drains the rest."""
CHUNK_LEN = 252
EXCHANGE_RESPONSE_PT = b"\x01\x00\x00"
def __init__(
self,
sk_device: bytes,
sk_reader: bytes,
device_response_plaintext: bytes,
):
self.sk_device = sk_device
self.sk_reader = sk_reader
# Pre-encrypt the EXCHANGE response under SKDevice + deviceIv(1). The
# applet's EXCHANGE handler consumes deviceCounter=1 first.
self.exchange_ct = AESGCM(sk_device).encrypt(
_iv_device(1), self.EXCHANGE_RESPONSE_PT, None
)
# Pre-encrypt the DeviceResponse under SKDevice + deviceIv(2) — after
# EXCHANGE advanced the device counter from 1 -> 2.
self.ct = AESGCM(sk_device).encrypt(
_iv_device(2), device_response_plaintext, None
)
self.ct_off = 0
self.apdus: list[bytes] = []
self.reader_counter = 1
self.device_counter = 1
# The Reader Status sub-event REQUEST the verifier is expected to send.
self.expected_exchange_pt = b"\x01\x00"
# The DeviceRequest the verifier is expected to send.
self.expected_request_pt = cbor2.dumps(
{
"version": "1.0",
"docRequests": [
{
"itemsRequest": cbor2.dumps(
{
"docType": "org.iso.18013.5.1.mDL",
"nameSpaces": {},
},
canonical=True,
)
}
],
},
canonical=True,
)
def __call__(self, apdu: bytes) -> tuple[bytes, int]:
self.apdus.append(apdu)
cla, ins = apdu[0], apdu[1]
# SELECT 5502 (CLA=0x00 INS=0xA4 P1=0x04 P2=0x00 Lc=09 ...)
if cla == 0x00 and ins == 0xA4:
return b"", 0x9000
# EXCHANGE — decrypt [0x01, 0x00], reply with [0x01, 0x00, 0x00]
# encrypted under SKDevice + deviceIv(device_counter=1).
if cla == 0x80 and ins == 0xC9:
lc = apdu[4]
body = apdu[5 : 5 + lc]
pt = AESGCM(self.sk_reader).decrypt(
_iv_reader(self.reader_counter), body, None
)
assert pt == self.expected_exchange_pt, (
f"EXCHANGE pt mismatch:\n got={pt.hex()}\n want={self.expected_exchange_pt.hex()}"
)
self.reader_counter += 1
self.device_counter += 1
return self.exchange_ct, 0x9000
# ENVELOPE — decrypt DeviceRequest under reader-counter=2, ship first
# chunk of the response encrypted under deviceCounter=2.
if cla == 0x00 and ins == 0xC3:
lc = apdu[4]
body = apdu[5 : 5 + lc]
pt = AESGCM(self.sk_reader).decrypt(
_iv_reader(self.reader_counter), body, None
)
assert pt == self.expected_request_pt, (
f"ENVELOPE pt mismatch:\n got={pt.hex()}\n want={self.expected_request_pt.hex()}"
)
self.reader_counter += 1
chunk = self.ct[: self.CHUNK_LEN]
self.ct_off = self.CHUNK_LEN
remaining = len(self.ct) - self.CHUNK_LEN
assert remaining > 0, "test fixture must produce a chained response"
sw2 = 0xFF if remaining >= 0x100 else remaining
return chunk, 0x6100 | sw2
# GET RESPONSE — drain whatever's left.
if cla == 0x00 and ins == 0xC0:
chunk = self.ct[self.ct_off :]
self.ct_off = len(self.ct)
return chunk, 0x9000
raise AssertionError(f"unexpected APDU: {apdu.hex()}")
def _setup_fixture() -> tuple[bytes, bytes, _MockTransmit]:
step_up_sk = bytes(range(0xC0, 0xE0))
sk_device, sk_reader = derive_step_up_session_keys(step_up_sk)
ad = _fake_ad()
device_response = _build_fixed_device_response(ad)
mock = _MockTransmit(sk_device, sk_reader, device_response)
return step_up_sk, ad, mock
def test_verify_step_up_m2_round_trips_access_document():
step_up_sk, expected_ad, mock = _setup_fixture()
ok, msg = verify_step_up_m2(mock, step_up_sk, expected_ad)
assert ok, f"verify failed: {msg}"
# Quick shape audit on the captured APDUs.
sent = mock.apdus
# SELECT, EXCHANGE, ENVELOPE, GET RESPONSE -> 4 APDUs.
assert len(sent) == 4, [a.hex() for a in sent]
assert sent[0][0:2] == bytes([0x00, 0xA4]) # SELECT
assert sent[1][0:2] == bytes([0x80, 0xC9]) # EXCHANGE
assert sent[2][0:2] == bytes([0x00, 0xC3]) # ENVELOPE
assert sent[3][0:2] == bytes([0x00, 0xC0]) # GET RESPONSE
# GET RESPONSE Le must equal the SW2 the applet handed us (136 remaining).
expected_le = len(mock.ct) - _MockTransmit.CHUNK_LEN
assert sent[3][4] == expected_le
def test_verify_step_up_m2_detects_ad_mismatch():
step_up_sk, _, mock = _setup_fixture()
# Pass a different AD than the one wrapped into the canned DeviceResponse.
bogus_ad = cbor2.dumps([b"\xa0", {}, b"different", b"\x00" * 64], canonical=True)
ok, msg = verify_step_up_m2(mock, step_up_sk, bogus_ad)
assert not ok
assert "issuerAuth" in msg or "Access Document" in msg or "AD" in msg, msg

View File

@@ -133,7 +133,7 @@
#define RFAL_ISODEP_MAX_I_RETRYS (2U) /*!< Number of retries for a I-Block Digital 2.0 16.2.5.4 */ #define RFAL_ISODEP_MAX_I_RETRYS (2U) /*!< Number of retries for a I-Block Digital 2.0 16.2.5.4 */
#define RFAL_ISODEP_MAX_R_RETRYS (3U) /*!< Number of retries for a R-Block Digital 2.0 B9 - nRETRY ACK/NAK: [2,5] */ #define RFAL_ISODEP_MAX_R_RETRYS (3U) /*!< Number of retries for a R-Block Digital 2.0 B9 - nRETRY ACK/NAK: [2,5] */
#define RFAL_ISODEP_MAX_WTX_NACK_RETRYS (3U) /*!< Number of S(WTX) replied with NACK Digital 2.0 B9 - nRETRY WTX[2,5] */ #define RFAL_ISODEP_MAX_WTX_NACK_RETRYS (3U) /*!< Number of S(WTX) replied with NACK Digital 2.0 B9 - nRETRY WTX[2,5] */
#define RFAL_ISODEP_MAX_WTX_RETRYS (20U) /*!< Number of overall S(WTX) retries Digital 2.0 16.2.5.2 */ #define RFAL_ISODEP_MAX_WTX_RETRYS (255U) /* DT: bumped from stock 20 to ULTD (255U) so AUTH1 against our slow userland-GCM applet on j3r452 can finish; reverts to (20U) once we have native GCM */
#define RFAL_ISODEP_MAX_WTX_RETRYS_ULTD (255U) /*!< Use unlimited number of overall S(WTX) */ #define RFAL_ISODEP_MAX_WTX_RETRYS_ULTD (255U) /*!< Use unlimited number of overall S(WTX) */
#define RFAL_ISODEP_MAX_DSL_RETRYS (0U) /*!< Number of retries for a S(DESELECT) Digital 2.0 B9 - nRETRY DESELECT: [0,5] */ #define RFAL_ISODEP_MAX_DSL_RETRYS (0U) /*!< Number of retries for a S(DESELECT) Digital 2.0 B9 - nRETRY DESELECT: [0,5] */
#define RFAL_ISODEP_RATS_RETRIES (1U) /*!< RATS retries upon fail Digital 2.0 B7 - nRETRY RATS [0,1] */ #define RFAL_ISODEP_RATS_RETRIES (1U) /*!< RATS retries upon fail Digital 2.0 B7 - nRETRY RATS [0,1] */

View File

@@ -32,10 +32,24 @@ ACWG_Status_t __real_ACWG_processAUTH1ResponsePayload(
ACWG_Status_t __wrap_ACWG_processAUTH1ResponsePayload( ACWG_Status_t __wrap_ACWG_processAUTH1ResponsePayload(
ACWG_SessionContext_t *context, uint8_t *payload, uint32_t payloadSize) ACWG_SessionContext_t *context, uint8_t *payload, uint32_t payloadSize)
{ {
printf("[AUTH1-RESPONSE len=%u] ", (unsigned)payloadSize); printf("[AUTH1-RESPONSE-IN len=%u] ", (unsigned)payloadSize);
for (uint32_t i = 0; i < payloadSize && i < 256; i++) { for (uint32_t i = 0; i < payloadSize && i < 256; i++) {
printf("%02x", payload[i]); printf("%02x", payload[i]);
} }
printf("\r\n"); printf("\r\n");
return __real_ACWG_processAUTH1ResponsePayload(context, payload, payloadSize); ACWG_Status_t ret = __real_ACWG_processAUTH1ResponsePayload(context, payload, payloadSize);
printf("[AUTH1-RESPONSE-OUT ret=%d (0x%X)]\r\n", (int)ret, (unsigned)ret);
return ret;
} }
/* NOTE: tracing ACWG_computeKDHandVolatileKeys / ACWG_derivePersistentKeys
* was attempted via additional __wrap_ functions here, but the linker --wrap
* flags in CMakeLists.txt only redirect the two AUTH1 entry points. Adding
* the additional wraps without matching CMakeLists --wrap= flags produced a
* binary that boots but appears to hang during AUTH1 processing (suspected
* unresolved __real_* references being silently turned into broken calls
* by --gc-sections). If we need to instrument those inner functions later,
* add the matching -Wl,--wrap= flags in CMakeLists.txt first, then restore
* the corresponding __wrap_ definitions. For now, the AUTH1-RESPONSE-OUT
* return code is enough to identify which subsystem failed inside the
* vendor library. */