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.
This commit is contained in:
michael
2026-06-17 18:35:26 -07:00
34 changed files with 3838 additions and 418 deletions

View File

@@ -97,21 +97,26 @@ or Access Document — every Aliro flow returns `SW_CONDITIONS_NOT_SATISFIED`.
Provision via the PersonalizationApplet (CLA `0x80`):
| INS | P1\|P2 | Data | Description |
| ------ | --------------- | --------------------------------------- | ----------------------------------- |
| ------ | --------------- | --------------------------------------- | -------------------------------------------------------------- |
| `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey |
| `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey |
| `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey |
| `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 |
Required order: SELECT provisioning AID → write all keys + AD chunks →
finalize → COMMIT. After COMMIT, every write returns
`SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1).
Required order: SELECT provisioning AID → write all keys (including
credential_issuer_PubK) + AD chunks → finalize → COMMIT. The issuer pubkey
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`:
- `out/access_credential.pem` → derive priv/pub bytes
- `out/reader.pem` → derive pub bytes
- `out/issuer.pem` → derive Credential Issuer pubkey
- `out/access_document.bin` → chunk into ≤255B writes
**One-shot personalization:** the harness ships an `aliro-personalize`
@@ -188,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
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
`gp --delete <pkg_AID>` won't succeed while applet *instances* still

View File

@@ -119,6 +119,7 @@
aid="${cap.elf.aid}"
package="com.dangerousthings.aliro"
version="0.1"
ints="true"
classes="${project.build.outputDirectory}">
<applet class="com.dangerousthings.aliro.AliroApplet"
aid="A000000909ACCE5501"/>

View File

@@ -46,7 +46,7 @@ public class AliroApplet extends Applet {
// 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 = true;
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;
@@ -193,45 +193,10 @@ public class AliroApplet extends Applet {
'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*'
};
// secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2.
// J3R180 doesn't ship a default P-256 parameter set on its EC keys, so
// calls into genKeyPair / setS / setW / Signature.init throw
// CryptoException.ILLEGAL_VALUE until we seed the curve explicitly.
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
};
// secp256r1 / NIST P-256 curve parameters live in {@link Secp256r1Params}
// — shared with {@link CoseVerifier}. J3R180 doesn't ship a default P-256
// parameter set on its EC keys, so seedSecp256r1() must run before any
// genKeyPair / setS / setW / Signature.init.
/** NFC interface byte, spec §8.3.1.13. */
private static final byte INTERFACE_BYTE_NFC = (byte) 0x5E;
/** Table 8-13 usage constant for UD signature (spec §8.3.3.4.3). */
@@ -317,22 +282,12 @@ public class AliroApplet extends Applet {
/** 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. */
* 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) {
javacard.security.ECPublicKey pub = (javacard.security.ECPublicKey) kp.getPublic();
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);
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);
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);
Secp256r1Params.seed(kp);
}
// --- Diagnostic test vectors -------------------------------------------
@@ -436,7 +391,7 @@ public class AliroApplet extends Applet {
AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto();
for (short i = 0; i < n; i++) {
cryptoEcdh.computeEcdhSharedX(priv,
SECP256R1_G, (short) 0,
Secp256r1Params.SECP256R1_G, (short) 0,
buf, DIAG_OUT_OFF);
}
return;
@@ -840,7 +795,8 @@ public class AliroApplet extends Applet {
* credential_PubK), per §8.3.3.4.2 + ref [12]) when command_parameters
* bit 0 = 0, or {@code 0x5A credential_PubK} (full uncompressed 65B)
* 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(
byte auth1CmdParams, CredentialStore store,
@@ -869,31 +825,34 @@ public class AliroApplet extends Applet {
p += rawSigLen;
// 0x5E 0x02 [signaling_bitmap] — 16-bit big-endian. Bit 0: Access
// Document retrievable. Bit 2: retrieval requires step-up AID SELECT
// (applicable on NFC). Other bits unused in v1 (no mailbox/notify).
// Document retrievable from this credential. Bit 2: retrieval
// requires step-up AID SELECT (applicable on NFC). Other bits unused
// in v1 (no mailbox/notify).
//
// We emit 0x0005 (bits 0 + 2) when an Access Document is provisioned.
// Honest reading of the spec would say we should leave these off
// until Step-up Phase is actually implemented (CBOR + mdoc + ENVELOPE
// + GET RESPONSE + AES-GCM over StepUpSK, §8.4), but empirically the
// closed-source ACWG_processAUTH1ResponsePayload() in X-CUBE-ALIRO's
// Aliro.a errors out when bits 0 + 2 are clear and AD is provisioned
// -- it expects "AD present" to be advertised. The bits are
// informational about capabilities anyway, not enforceable
// commitments, so 0x0005 satisfies the vendor library. AliroApplet
// now returns 6D00 for any post-AUTH1 INS like 0xC9 -- StepUpApplet
// at ACCE5502 handles ENVELOPE and EXCHANGE properly per spec §10.2
// + §8.4. Revisit when we test against more readers and can lean on
// the spec literally.
short bitmap = 0;
if (store.hasAccessDocument()) {
bitmap |= 0x0001; // bit 0
bitmap |= 0x0004; // bit 2 — NFC requires step-up AID to fetch AD
// Bit 2 is always set: our architecture always uses split AID
// (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) 0x02;
out[p++] = (byte) ((bitmap >> 8) & 0xFF);
out[p++] = (byte) (bitmap & 0xFF);
out[p++] = (byte) 0x00; // high byte unused in v1
out[p++] = bitmapLo;
return (short) (p - outOff);
}

View File

@@ -144,22 +144,7 @@ final class AliroGcm {
byte[] pt, short ptOff, short ptLen,
byte[] out, short outOff) {
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;
setKeyAndIv(key, keyOff, iv, ivOff);
// cb = INC32(J0) — first counter block for the plaintext stream.
Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN);
@@ -305,23 +290,7 @@ final class AliroGcm {
short ctLen = (short) (inLen - TAG_LEN);
short tagOff = (short) (inOff + ctLen);
aesKey.setKey(key, keyOff);
aesEcb.init(aesKey, Cipher.MODE_ENCRYPT);
// H = AES_K(0^128). Same as encrypt() -- GCM is one-direction at the
// primitive level: encrypt and decrypt both run GCTR + GHASH and
// differ only in whether GHASH consumes provided ciphertext or
// freshly-emitted ciphertext, plus the tag compare/emit step.
Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0);
aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H);
buildMTable();
// J0 = IV || 0x00000001 (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;
setKeyAndIv(key, keyOff, iv, ivOff);
// GHASH over the PROVIDED ciphertext first (so tag verify doesn't
// depend on a successful decrypt). AAD is empty.
@@ -412,6 +381,35 @@ final class AliroGcm {
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
* 16-byte block, big-endian, modulo 2^32.

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

@@ -28,6 +28,7 @@ final class CredentialStore {
static final short CRED_PRIV_KEY_LEN = 32;
static final short CRED_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
* accommodates a typical Aliro Access Document with room to spare. */
@@ -35,8 +36,12 @@ final class CredentialStore {
/** Stable field order for AMD-H Element serialization. Append-only —
* never reorder or remove without bumping the package version and
* writing an explicit migration step in the new ELF's onRestore. */
static final byte FIELD_VERSION = 1;
* 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
@@ -54,9 +59,13 @@ final class CredentialStore {
private final byte[] readerPubKey;
private boolean readerPubKeySet;
private final byte[] credentialIssuerPubKey;
private boolean credentialIssuerPubKeySet;
private final byte[] accessDocument;
private short accessDocumentLen;
private boolean accessDocumentFinalized;
private boolean accessDocumentVerified;
private boolean committed;
@@ -64,6 +73,7 @@ final class CredentialStore {
credentialPrivKey = new byte[CRED_PRIV_KEY_LEN];
credentialPubKey = new byte[CRED_PUBK_LEN];
readerPubKey = new byte[READER_PUBK_LEN];
credentialIssuerPubKey = new byte[CRED_ISSUER_PUBK_LEN];
accessDocument = new byte[ACCESS_DOC_MAX_LEN];
}
@@ -159,6 +169,30 @@ final class CredentialStore {
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.
* Returns true iff the write stayed within {@link #ACCESS_DOC_MAX_LEN};
@@ -173,28 +207,60 @@ final class CredentialStore {
}
/**
* Marks the Access Document as provisioned with {@code totalLen} bytes
* of valid content starting at offset 0. Returns false (and does not
* mutate state) if {@code totalLen} is outside {@code [0, ACCESS_DOC_MAX_LEN]}.
* Finalizes the Access Document: runs the supplied {@link CoseVerifier}
* against the staged AD bytes using the stored Credential Issuer public
* 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>TODO (Step-Up impl, opt 4a): wrap this in
* {@code JCSystem.beginTransaction()} along with a one-shot IssuerAuth
* COSE_Sign1 verify against the stored Credential Issuer public key, and
* set a persistent {@code accessDocumentVerified} flag. Caching the
* verify result saves ~100 ms per Step-Up transaction at the cost of one
* extra persistent byte + the assumption that the Credential Issuer
* trust anchor is fixed for the card's lifetime (opt 4b — true for DT's
* implantable use case but document the limitation). The verify-flag
* write and the {@code accessDocumentFinalized} flip MUST land in the
* same atomic transaction so partial state can't ship an unverified
* <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) {
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;
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;
}
@@ -202,6 +268,21 @@ final class CredentialStore {
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() {
return accessDocumentLen;
}
@@ -222,11 +303,14 @@ final class CredentialStore {
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);
}
@@ -245,7 +329,9 @@ final class CredentialStore {
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();
@@ -255,10 +341,23 @@ final class CredentialStore {
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];
@@ -301,12 +400,15 @@ final class CredentialStore {
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(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);
credentialPrivKeySet = false;
credentialPubKeySet = false;
readerPubKeySet = false;
credentialIssuerPubKeySet = false;
accessDocumentLen = 0;
accessDocumentFinalized = false;
accessDocumentVerified = false;
committed = false;
}
}

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,6 +24,7 @@ public class PersonalizationApplet extends Applet {
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_FINALIZE_ACCESS_DOC = (byte) 0x24;
private static final byte INS_SET_CREDENTIAL_ISSUER_PUBK = (byte) 0x25;
private static final byte INS_COMMIT = (byte) 0x2C;
/** The owning reference to the shared CredentialStore. AliroApplet and
@@ -34,8 +35,23 @@ public class PersonalizationApplet extends Applet {
* 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) {
@@ -76,6 +92,10 @@ public class PersonalizationApplet extends Applet {
requireUnlocked(store);
setReaderPubKey(apdu, store);
return;
case INS_SET_CREDENTIAL_ISSUER_PUBK:
requireUnlocked(store);
setCredentialIssuerPubKey(apdu, store);
return;
case INS_WRITE_ACCESS_DOC:
requireUnlocked(store);
writeAccessDocChunk(apdu, store);
@@ -126,6 +146,15 @@ public class PersonalizationApplet extends Applet {
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
* within the stored Access Document (big-endian unsigned 16-bit); the
@@ -144,19 +173,36 @@ public class PersonalizationApplet extends Applet {
}
/**
* Marks the Access Document as provisioned. P1|P2 is the total length
* (big-endian unsigned 16-bit). Lc must be 0.
* Finalizes the Access Document: triggers a one-shot IssuerAuth verify
* 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) {
short lc = apdu.setIncomingAndReceive();
if (lc != 0) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
if (!store.hasCredentialIssuerPubKey()) {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
byte[] buf = apdu.getBuffer();
short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
| (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);
}
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

@@ -44,19 +44,17 @@ import javacard.framework.Util;
* the entire encrypted DeviceResponse then chunk it; pipe plaintext
* through GCM as we emit ENVELOPE response chunks. Reduces transient
* footprint AND minimizes plaintext residence in RAM.</li>
* <li><b>Opt 4 — IssuerAuth verify cached at personalization.</b> See
* {@link CredentialStore#finalizeAccessDocument(short)} TODO — the
* COSE_Sign1 verify happens once at write time, the persistent
* <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>The {@code AliroApplet.INS_EXCHANGE} stub was retired once StepUpApplet
* landed handling for 0xC9 directly — spec-conformant readers (X-CUBE-ALIRO
* included, once the upstream crypto interop is right) route EXCHANGE here
* after the step-up AID SELECT per §10.2.
* <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 {
@@ -74,23 +72,28 @@ public class StepUpApplet extends Applet {
* X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the
* ENVELOPE body once the Step-Up AID is the active applet. */
private static final byte INS_ENVELOPE = (byte) 0xC3;
/** Length of each derived Step-Up session key (spec §8.4.3). */
private static final short STEP_UP_SK_LEN = 32;
/** 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_IV_LEN = 12;
private static final short GCM_TAG_LEN = 16;
private static final short COUNTER_LEN = 4;
/** {@code StepUpSKDevice} — UD→reader leg of the Step-Up AES-256-GCM
* session, derived from {@code StepUpSK} via HKDF (§8.4.3) when SELECT
* finds an armed {@link SessionContext}. Transient, cleared on deselect. */
private final byte[] stepUpSKDevice;
/** 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;
/** {@code StepUpSKReader} — reader→UD leg of the Step-Up GCM session.
* Same derivation context as {@link #stepUpSKDevice}. */
private final byte[] stepUpSKReader;
/** Response chaining buffer for DeviceResponse outputs > {@link #CHUNK_LEN}.
* Sized for 372 B canonical DeviceResponse + 16 B GCM tag = 388 B with
* headroom for future shape tweaks. */
private static final short RESPONSE_BUFFER_LEN = 512;
/** Holds StepUpSKDevice / StepUpSKReader and the two BE32 message
* counters, plus the IV-stamping math (spec §8.3.1.6/8/9 + §8.4.3).
* All four arrays inside are CLEAR_ON_DESELECT. */
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
@@ -98,32 +101,14 @@ public class StepUpApplet extends Applet {
* outlives the call. */
private final byte[] stepUpSKScratch;
/** Session-bound {@code StepUp_reader_counter} per §8.4.3 + mdoc [6]
* §9.1.1.5: 32-bit big-endian counter starting at {@code 0x00000001} the
* first time the reader→UD direction is used in this Step-Up session,
* incremented after each successful decrypt. CLEAR_ON_DESELECT so each
* Step-Up phase entry starts fresh -- the matching SELECT re-initialises
* the counter alongside the SK derivation. Shared across ENVELOPE and
* EXCHANGE: both commands are reader→device so both consume from the
* same counter. */
private final byte[] stepUpReaderCounter;
/** Session-bound {@code StepUp_device_counter} per §8.4.3 + mdoc [6]
* §9.1.1.5: 32-bit big-endian counter for the device→reader direction,
* starting at {@code 0x00000001} on Step-Up session entry, incremented
* after each successful encrypt. In M1 only ENVELOPE returns ciphertext
* (EXCHANGE returns empty), so this advances once per ENVELOPE. */
private final byte[] stepUpDeviceCounter;
/** 12-byte scratch for the GCM IV: 8 zero bytes + 4-byte reader counter
* per §8.3.1.8. Rebuilt per EXCHANGE; CLEAR_ON_DESELECT. */
/** 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;
/** Persistent EXCHANGE plaintext sink for the decrypt-and-discard path.
* Sized to the largest reasonable Reader Status sub-event we'd see
* during M1 (X-CUBE-ALIRO observed values are well under 64 B); we'll
* resize when the real Reader Status payload size lands. CLEAR_ON_DESELECT
* so post-deselect there's no plaintext residue. */
/** 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. */
private final byte[] scratchPlaintext;
private static final short SCRATCH_PLAINTEXT_LEN = 256;
@@ -135,6 +120,28 @@ public class StepUpApplet extends Applet {
private static final short FLAG_KEYS_READY = 0;
private static final short FLAGS_LEN = 1;
/** Built-then-encrypted DeviceResponse staging area for the response
* chaining path. ENVELOPE writes the encrypted DeviceResponse into here,
* then sends one {@link #CHUNK_LEN}-sized chunk per APDU (the first
* inside the ENVELOPE reply, the rest pulled via GET RESPONSE). Reused
* during the build step as a scratch for the canonical-CBOR DeviceResponse
* plaintext too — encrypt-in-place isn't an option because the GCM tag
* appends after the ciphertext. CLEAR_ON_DESELECT. */
private final byte[] responseBuffer;
/** {@code [offset, remaining]} for the chaining drain — both reset at the
* start of every ENVELOPE. {@code remaining == 0} signals
* "no more bytes to ship": GET RESPONSE arriving in that state returns
* SW_CONDITIONS_NOT_SATISFIED. CLEAR_ON_DESELECT. */
private final short[] responseState;
private static final short STATE_OFF = 0;
private static final short STATE_REMAINING = 1;
/** 4-byte scratch the {@link DeviceRequestParser} writes its CBOR header
* argument into. Reused across calls — caller-supplied is the
* allocation-free convention StructuralCbor uses. CLEAR_ON_DESELECT. */
private final byte[] parserScratch4;
public static void install(byte[] bArray, short bOffset, byte bLength) {
StepUpApplet applet = new StepUpApplet();
if (bArray == null || bLength == 0) {
@@ -149,25 +156,24 @@ public class StepUpApplet extends Applet {
// 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.
stepUpSKDevice = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
stepUpSKReader = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
stepUpSKScratch = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
stepUpReaderCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT);
stepUpDeviceCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT);
ivScratch = JCSystem.makeTransientByteArray(GCM_IV_LEN, JCSystem.CLEAR_ON_DESELECT);
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 in M1B / M1C 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.
* 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
public boolean select() {
@@ -175,20 +181,18 @@ public class StepUpApplet extends Applet {
SessionContext.copyStepUpSK(stepUpSKScratch, (short) 0);
CryptoSingletons.getAliroCrypto().deriveStepUpSessionKeys(
stepUpSKScratch, (short) 0,
stepUpSKDevice, (short) 0,
stepUpSKReader, (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, STEP_UP_SK_LEN, (byte) 0);
Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, StepUpSession.SK_LEN, (byte) 0);
// Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound counters
// initialized to 0x00000001 on session entry, one per direction.
// initialised to 0x00000001 on session entry, one per direction.
// CLEAR_ON_DESELECT already zeroes them on each fresh select;
// rewrite explicitly so a Step-Up SELECT mid-session (without a
// deselect in between) also starts both counters at 1.
Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0);
stepUpReaderCounter[3] = (byte) 0x01;
Util.arrayFillNonAtomic(stepUpDeviceCounter, (short) 0, COUNTER_LEN, (byte) 0);
stepUpDeviceCounter[3] = (byte) 0x01;
// 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;
@@ -214,6 +218,10 @@ public class StepUpApplet extends Applet {
processEnvelope(apdu);
return;
}
if (cla == CLA_ISO && ins == INS_GET_RESPONSE) {
processGetResponse(apdu);
return;
}
if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) {
processExchange(apdu);
return;
@@ -221,21 +229,34 @@ public class StepUpApplet extends Applet {
if (cla != CLA_ISO && cla != CLA_PROPRIETARY) {
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
}
// GET RESPONSE handler plugs in here in follow-up milestones.
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
}
/**
* EXCHANGE (CLA=0x80, INS=0xC9) handler — Milestone 1 decrypt-and-discard.
* EXCHANGE (CLA=0x80, INS=0xC9) handler: request validation + encrypted
* Reader Status response.
*
* <p>Spec §8.3.3.5 / Table 8-14: the reader sends
* {@code encrypted_payload || authentication_tag} encrypted with
* {@code StepUpSKReader} per §8.3.1.8, IV layout
* {@code 0x0000000000000000 || stepup_reader_counter (4B BE)} and empty
* AAD. M1 only needs to verify the tag (proves matching session keys)
* then ACK with 9000 + empty payload so the X-CUBE-ALIRO firmware marks
* "DOOR OPERATION SUCCEEDED" and moves on. The real Reader Status
* response sub-event (encrypted with StepUpSKDevice) lands in M2.
* <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) {
@@ -253,9 +274,6 @@ public class StepUpApplet extends Applet {
if (lc < GCM_TAG_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
// M1 sink is fixed-size; reject payloads that wouldn't fit. The real
// EXCHANGE payload during M1 ack flow is tiny (X-CUBE-ALIRO sends a
// few bytes of CBOR), so this bound is comfortable.
short ptLen = (short) (lc - GCM_TAG_LEN);
if (ptLen > SCRATCH_PLAINTEXT_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
@@ -263,16 +281,14 @@ public class StepUpApplet extends Applet {
// Build the IV: 8 zero bytes (reader→device prefix per §8.3.1.8) +
// stepup_reader_counter, big-endian, in the trailing 4 bytes.
Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 8, (byte) 0);
Util.arrayCopyNonAtomic(stepUpReaderCounter, (short) 0,
ivScratch, (short) 8, COUNTER_LEN);
session.readerIv(ivScratch, (short) 0);
// Decrypt-and-discard. 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.
// 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(
stepUpSKReader, (short) 0,
session.skReader, (short) 0,
ivScratch, (short) 0,
buf, dataOff, lc,
scratchPlaintext, (short) 0);
@@ -282,40 +298,84 @@ public class StepUpApplet extends Applet {
ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
}
// Wipe the discarded plaintext immediately -- M1 has no use for it,
// and CLEAR_ON_DESELECT alone would leave it sitting around until the
// reader walks away.
// 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);
// Spec §8.3.1.8: reader_counter <- reader_counter + 1 after use.
incrementCounter(stepUpReaderCounter, (short) 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;
// Ack with SW=9000 and empty payload. If field testing on real
// X-CUBE-ALIRO firmware shows the reader rejects an empty payload,
// M1E iteration escalates this to "9000 + encrypted-empty-CBOR-map"
// per the implementation plan.
apdu.setOutgoingAndSend((short) 0, (short) 0);
// 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 — Milestone 1 decrypt-and-discard
* the mdoc DeviceRequest, then return a spec-shape encrypted empty CBOR
* map (canonical RFC 8949: single byte 0xA0).
* ENVELOPE (CLA=0x00, INS=0xC3) handler: emits the canonical-CBOR
* DeviceResponse via ISO 7816 response chaining.
*
* <p>Spec §8.3.1.9: inbound payload (reader→device) is decrypted with
* {@code StepUpSKReader}, IV {@code 0x0000000000000000 || stepup_reader_counter}
* (8-byte zero prefix + 4-byte BE counter), empty AAD.
*
* <p>Spec §8.3.1.6: outbound payload (device→reader) is encrypted with
* {@code StepUpSKDevice}, IV {@code 0x0000000000000001 || stepup_device_counter}
* (8-byte prefix ending in 0x01 + 4-byte BE counter), empty AAD.
*
* <p>The DeviceRequest body is discarded in M1: we don't build a real
* mdoc DeviceResponse yet -- that's M2's job. The 17-byte ciphertext
* (1 ct + 16 tag) is enough for X-CUBE-ALIRO to see a spec-shape
* encrypted response and move on. Counter sequencing: both
* stepup_reader_counter (shared with EXCHANGE) and stepup_device_counter
* advance independently after each successful use.
* <p>Pipeline:
* <ol>
* <li>Decrypt the inbound payload under {@code StepUpSKReader} with
* reader-side IV {@code 0x0000000000000000 || stepup_reader_counter}
* (§8.3.1.9). Advance {@code stepup_reader_counter}.</li>
* <li>Structurally validate the recovered DeviceRequest via
* {@link DeviceRequestParser#validate} — propagates SW_DATA_INVALID
* (malformed CBOR / wrong shape) or SW_CONDITIONS_NOT_SATISFIED
* (unsupported version) unchanged.</li>
* <li>Build the canonical-CBOR DeviceResponse via
* {@link DeviceResponseBuilder#build} carrying the cached Access
* Document at {@code documents[0].issuerSigned.issuerAuth}.</li>
* <li>In-place GCM-encrypt the DeviceResponse plaintext under
* {@code StepUpSKDevice} with device-side IV
* {@code 0x00*7 || 0x01 || stepup_device_counter} (§8.3.1.6).
* AliroGcm supports {@code in == out} at the same offset (CTR mode +
* trailing tag), so the cleartext is overwritten on the encryption
* pass — no second buffer needed.</li>
* <li>Advance {@code stepup_device_counter} and ship the first chunk.
* For our standard 372 B AD wrapper the response is 388 B; we send
* the first {@link #CHUNK_LEN} bytes inline with SW=61xx and the
* reader pulls the rest via GET RESPONSE.</li>
* </ol>
*/
private void processEnvelope(APDU apdu) {
if (sessionFlags[FLAG_KEYS_READY] == 0) {
@@ -325,6 +385,11 @@ public class StepUpApplet extends Applet {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
// Invalidate any in-flight chaining state from a previous ENVELOPE.
// A new ENVELOPE always restarts the response stream.
responseState[STATE_OFF] = 0;
responseState[STATE_REMAINING] = 0;
short lc = apdu.setIncomingAndReceive();
byte[] buf = apdu.getBuffer();
short dataOff = apdu.getOffsetCdata();
@@ -340,13 +405,11 @@ public class StepUpApplet extends Applet {
}
// Build the reader-side IV: 8 zero bytes (§8.3.1.9) + reader counter.
Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 8, (byte) 0);
Util.arrayCopyNonAtomic(stepUpReaderCounter, (short) 0,
ivScratch, (short) 8, COUNTER_LEN);
session.readerIv(ivScratch, (short) 0);
try {
CryptoSingletons.getAliroGcm().decrypt(
stepUpSKReader, (short) 0,
session.skReader, (short) 0,
ivScratch, (short) 0,
buf, dataOff, lc,
scratchPlaintext, (short) 0);
@@ -356,51 +419,100 @@ public class StepUpApplet extends Applet {
ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
}
// Spec §8.3.1.9: reader_counter <- reader_counter + 1 after use.
incrementCounter(stepUpReaderCounter, (short) 0);
session.advanceReaderCounter();
// Wipe the decrypted DeviceRequest -- M1 has no use for it. M2 will
// replace this with real mdoc parsing and a real DeviceResponse.
// Structural validation. Throws ISOException on malformed CBOR /
// unsupported version — let it propagate; SW mapping is the parser's
// responsibility.
DeviceRequestParser.validate(
scratchPlaintext, (short) 0, ptLen,
parserScratch4, (short) 0);
// Wipe the decrypted DeviceRequest — its content doesn't influence the
// response (M2 reader profile asks for the single cached AD), and we
// don't want plaintext loitering past CLEAR_ON_DESELECT.
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
// Build the canonical-CBOR empty map plaintext (single byte 0xA0,
// RFC 8949 major type 5 (map) with length 0). One byte total.
scratchPlaintext[0] = (byte) 0xA0;
// Build the canonical-CBOR DeviceResponse into responseBuffer. The
// cached AD is staged directly into the builder via copyAccessDocument;
// CredentialStore.hasAccessDocument() is asserted first so a card that
// somehow reached Step-Up without an AD doesn't synthesize an empty
// issuerAuth that would crash downstream readers.
CredentialStore store = CredentialStore.get();
if (store == null || !store.hasAccessDocument()) {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
short adLen = store.getAccessDocumentLen();
// Stage the AD into responseBuffer past the wrapper's max footprint.
// The wrapper is ~100 B for our fixed shape; staging AD at offset 128
// gives the builder a clean target at offset 0 and the AD source at a
// distinct, non-overlapping location.
short adStage = 128;
store.copyAccessDocument(responseBuffer, adStage, (short) 0, adLen);
short respLen = DeviceResponseBuilder.build(
responseBuffer, adStage, adLen,
responseBuffer, (short) 0);
// Build the device-side IV: 0x00*7 || 0x01 || device_counter
// (§8.3.1.6 -- 8-byte prefix ending in 0x01, then 4B BE counter).
Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 7, (byte) 0);
ivScratch[7] = (byte) 0x01;
Util.arrayCopyNonAtomic(stepUpDeviceCounter, (short) 0,
ivScratch, (short) 8, COUNTER_LEN);
// Build the device-side IV: 0x00*7 || 0x01 || device_counter (§8.3.1.6).
session.deviceIv(ivScratch, (short) 0);
// Encrypt into the APDU buffer at offset 0. Safe to overwrite the
// inbound command bytes here because we've finished reading them.
// Output length = 1 (ct) + 16 (tag) = 17 bytes; well under the 252-byte
// APDU buffer ceiling.
// In-place encrypt: AliroGcm supports out == in at the same offset.
// Ciphertext overwrites the plaintext; the 16 B tag appends after.
short ctLen = CryptoSingletons.getAliroGcm().encrypt(
stepUpSKDevice, (short) 0,
session.skDevice, (short) 0,
ivScratch, (short) 0,
scratchPlaintext, (short) 0, (short) 1,
buf, (short) 0);
responseBuffer, (short) 0, respLen,
responseBuffer, (short) 0);
// Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
incrementCounter(stepUpDeviceCounter, (short) 0);
session.advanceDeviceCounter();
// Wipe the single plaintext byte (CLEAR_ON_DESELECT alone would
// leave 0xA0 sitting in transient until reader walks away).
scratchPlaintext[0] = 0;
apdu.setOutgoingAndSend((short) 0, ctLen);
// 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);
}
/** 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
* 0xFFFF before increment (note: spec uses 0xFFFF where 0xFFFFFFFF is
* clearly meant -- 4-byte BE counter), so wrap is unreachable in
* practice during 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;
/**
* 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)));
}
}
@@ -435,10 +547,10 @@ public class StepUpApplet extends Applet {
// NOT for use outside the applet's own test module.
void copyStepUpSKDeviceForTesting(byte[] out, short outOff) {
Util.arrayCopyNonAtomic(stepUpSKDevice, (short) 0, out, outOff, STEP_UP_SK_LEN);
Util.arrayCopyNonAtomic(session.skDevice, (short) 0, out, outOff, StepUpSession.SK_LEN);
}
void copyStepUpSKReaderForTesting(byte[] out, short outOff) {
Util.arrayCopyNonAtomic(stepUpSKReader, (short) 0, out, outOff, STEP_UP_SK_LEN);
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

@@ -162,8 +162,11 @@ class AliroAppletAuth1Test {
}
@Test
void auth1SignalingBitmapIsAllZeroWhenNoAccessDocProvisioned() throws Exception {
// Default setUp provisions keys but no Access Document.
void auth1_bitmap_adNotProvisioned_returns0x0004() throws Exception {
// 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();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
@@ -172,22 +175,47 @@ class AliroAppletAuth1Test {
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E);
org.junit.jupiter.api.Assertions.assertArrayEquals(
new byte[] { 0x00, 0x00 }, bitmap,
"no Access Document → signaling_bitmap is 0x0000");
new byte[] { 0x00, 0x04 }, bitmap,
"no Access Document → bit 2 only (0x0004); bit 0 reflects actual retrievability");
}
@Test
void auth1SignalingBitmapHasAccessDocBitsWhenAdProvisioned() throws Exception {
// Provision a non-empty Access Document via CredentialStore (bypasses
// the PersonalizationApplet INS layer — that flow is tested
// separately). Content is opaque here; we only care that the
// signaling_bitmap reflects presence.
void auth1_bitmap_adProvisionedButNotVerified_returns0x0004() throws Exception {
// Provision opaque AD bytes and mark them finalized, but NOT verified.
// Post-M2A.3 the finalize flow requires IssuerAuth verify so this is a
// defensive case — bit 0 (AD retrievable) must NOT be advertised since
// we wouldn't actually serve unverified AD on the step-up 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(
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(
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();
ResponseAPDU r = sendValidAuth1();
@@ -200,7 +228,7 @@ class AliroAppletAuth1Test {
// = 2^0 + 2^2 = 0x0005 (big-endian)
org.junit.jupiter.api.Assertions.assertArrayEquals(
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

View File

@@ -95,12 +95,12 @@ class AliroAppletTest {
@Test
void auth1ExchangeInsRejectedNowThatStepUpAppletOwnsIt() {
// Once M1B.1 / M1C.1 land StepUpApplet (ACCE5502) as the proper owner
// of 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.
// 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);

View File

@@ -191,6 +191,65 @@ class AliroGcmTest {
"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
void differentIvsProduceDifferentCiphertexts() {
byte[] key = new byte[32];

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

@@ -30,6 +30,7 @@ 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];
@@ -51,6 +52,8 @@ class CredentialStoreSerializationTest {
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();
@@ -64,6 +67,15 @@ class CredentialStoreSerializationTest {
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],
@@ -81,6 +93,20 @@ class CredentialStoreSerializationTest {
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
@@ -91,7 +117,7 @@ class CredentialStoreSerializationTest {
@Test
void readFromRejectsWrongFieldVersion() {
RecordingSink buf = new RecordingSink();
buf.write((byte) 0x7F); // != FIELD_VERSION (== 1)
buf.write((byte) 0x7F); // != FIELD_VERSION
ISOException ex = assertThrows(ISOException.class,
() -> CredentialStore.readFrom(buf.toSource()));

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

@@ -25,8 +25,48 @@ class PersonalizationAppletTest {
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_FINALIZE_ACCESS_DOC = (byte) 0x24;
private static final byte INS_SET_CRED_ISSUER_PUBK = (byte) 0x25;
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;
@BeforeEach
@@ -103,12 +143,14 @@ class PersonalizationAppletTest {
@Test
void accessDocumentChunkedWriteAndFinalizeRoundTrip() {
byte[] ad = new byte[420];
for (int i = 0; i < ad.length; i++) ad[i] = (byte) ((i * 13) ^ 0xA5);
// Uses the canonical AD_GOOD vector because finalize now triggers
// 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[] 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, 200, chunk2).getSW());
assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, ad.length, null).getSW());
@@ -131,6 +173,9 @@ class PersonalizationAppletTest {
@Test
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];
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1;
@@ -185,6 +230,84 @@ class PersonalizationAppletTest {
"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. */

View File

@@ -145,21 +145,24 @@ class StepUpAppletTest {
}
/**
* After SELECT-Step-Up has armed {@code StepUpSKReader}, the X-CUBE-ALIRO
* firmware sends a "Reader Status sub-event" via the EXCHANGE command
* (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-14. The payload is
* AES-256-GCM encrypted with {@code StepUpSKReader}; IV layout from
* §8.3.1.8 is {@code 0x0000000000000000 || stepup_reader_counter (4B BE)},
* with the counter session-bound and initialized to 1 per §8.4.3 (mdoc
* [6] §9.1.1.5 derivation).
* 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>For Milestone 1 the applet only needs to decrypt-and-discard: tag
* verification proves the session keys match, then we return SW=9000 with
* empty payload. The real Step-Up "Reader Status response sub-event"
* (encrypted with StepUpSKDevice) lands in M2.
* <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 exchangeAfterStepUpSelectDecryptsAndAcksWithEmptyPayload() throws Exception {
void exchangeReturnsEncryptedReaderStatusResponse() throws Exception {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
@@ -170,8 +173,6 @@ class StepUpAppletTest {
ReaderSide reader = new ReaderSide();
reader.provision(sim, credentialKeyPair);
// SELECT expedited + run AUTH0 + AUTH1 -- mirrors the existing
// selectAfterArmedAuth1DerivesStepUpSessionKeys test.
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
"SELECT expedited must succeed");
@@ -188,58 +189,58 @@ class StepUpAppletTest {
reader.buildAuth1Data(credentialEphemPubKey), 256));
assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed");
// Compute the StepUpSKReader 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 StepUpSKReader and
// initializes its stepup_reader_counter session-bound to 0x00000001.
// 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 a 4-byte fake Reader Status payload under
// IV = 00 00 00 00 00 00 00 00 00 00 00 01 (8B zero prefix + counter=1).
byte[] iv = new byte[12];
iv[11] = 0x01;
byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
gcm.init(Cipher.ENCRYPT_MODE,
// 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, iv));
byte[] ctAndTag = gcm.doFinal(plaintext); // 4 + 16 = 20 bytes
assertEquals(20, ctAndTag.length);
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 GCM tag must return SW=9000");
assertEquals(0, exchangeResp.getData().length,
"M1 EXCHANGE handler returns empty payload (decrypt-and-discard)");
"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]");
}
/**
* After SELECT-Step-Up the X-CUBE-ALIRO firmware also sends an ENVELOPE
* command (CLA=0x00 INS=0xC3) carrying the encrypted mdoc DeviceRequest
* (spec §8.4 + ISO 7816 ENVELOPE). Inbound encryption per §8.3.1.9
* (reader-side IV {@code 0x0000000000000000 || stepup_reader_counter},
* empty AAD); response encryption per §8.3.1.6 (device-side IV
* {@code 0x0000000000000001 || stepup_device_counter}, empty AAD).
*
* <p>For Milestone 1 we decrypt-and-discard the DeviceRequest, then
* return an encrypted single-byte CBOR empty map ({@code 0xA0}) so the
* X-CUBE-ALIRO firmware sees a spec-shape encrypted response. Total
* response bytes: 1 ct + 16 tag = 17.
*
* <p>This test also pins counter sequencing: ENVELOPE consumes
* stepup_reader_counter=1 (then increments) and emits with
* stepup_device_counter=1 (then increments). The reader-counter is
* shared with EXCHANGE, but EXCHANGE isn't sent in this test so we
* only see counter=1 on each side.
* 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 envelopeAfterStepUpSelectDecryptsAndAcksWithEncryptedEmptyCborMap() throws Exception {
void exchangeWithUnknownSubEventIdReturnsDataInvalid() throws Exception {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
@@ -250,6 +251,81 @@ class StepUpAppletTest {
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(),
@@ -279,31 +355,49 @@ class StepUpAppletTest {
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
"SELECT step-up must succeed");
// Reader-side encrypt of a 4-byte plaintext under the reader IV
// (00 00 00 00 00 00 00 00 || 00 00 00 01). The applet decrypts and
// discards; the actual plaintext is irrelevant for M1.
// 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;
byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcmEnc.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(stepUpSKReader, "AES"),
new GCMParameterSpec(128, readerIv));
byte[] envelopeBody = gcmEnc.doFinal(plaintext); // 4 + 16 = 20 bytes
assertEquals(20, envelopeBody.length);
byte[] envelopeBody = gcmEnc.doFinal(VALID_DEVICE_REQUEST);
// Send ENVELOPE: CLA=0x00 INS=0xC3 (ISO-class command per Table 8-14).
// Send ENVELOPE: CLA=0x00 INS=0xC3. Le=0 maxes the inbound buffer;
// the response head chunk will come back with SW=61xx since 388 B
// exceeds one APDU.
ResponseAPDU envelopeResp = sim.transmitCommand(
new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256));
assertEquals(0x9000, envelopeResp.getSW(),
"ENVELOPE with valid GCM tag must return SW=9000");
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));
byte[] respBody = envelopeResp.getData();
assertEquals(17, respBody.length,
"M1 ENVELOPE response = 1 ciphertext byte + 16 GCM tag");
// 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");
// Device-side decrypt under IV = 00 00 00 00 00 00 00 01 || 00 00 00 01
// (device-prefix per §8.3.1.6 + device_counter=1).
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;
@@ -312,8 +406,121 @@ class StepUpAppletTest {
new SecretKeySpec(stepUpSKDevice, "AES"),
new GCMParameterSpec(128, deviceIv));
byte[] decoded = gcmDec.doFinal(respBody);
assertArrayEquals(new byte[] { (byte) 0xA0 }, decoded,
"M1 ENVELOPE response plaintext = canonical CBOR empty map (0xA0)");
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

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,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

@@ -11,6 +11,7 @@ INS_SET_CRED_PUBK = 0x21
INS_SET_READER_PUBK = 0x22
INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24
INS_SET_CRED_ISSUER_PUBK = 0x25
INS_COMMIT = 0x2C
# Provisioning AID — must match AliroAids.PROVISIONING in the applet.
@@ -21,6 +22,7 @@ PROVISIONING_AID = bytes.fromhex("A000000909ACCE559901")
CRED_PRIV_LEN = 32
CRED_PUBK_LEN = 64
READER_PUBK_LEN = 64
CRED_ISSUER_PUBK_LEN = 64
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)
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:
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"credential_PubK={len(artifacts.credential_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"
)

View File

@@ -13,6 +13,7 @@ from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.apdus import (
commit_apdu,
select_provisioning_apdu,
set_credential_issuer_pubk_apdu,
set_credential_priv_apdu,
set_credential_pubk_apdu,
set_reader_pubk_apdu,
@@ -45,21 +46,25 @@ class TrustArtifacts:
credential_priv: bytes # 32B
credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix)
reader_pubk_xy: bytes # 64B
credential_issuer_pub: bytes # 64B issuer pubkey x||y for IssuerAuth verify
access_document: bytes # COSE_Sign1 serialized
@classmethod
def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts":
cred_pem = (trust_dir / "access_credential.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()
cred_key = serialization.load_pem_private_key(cred_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(
credential_priv=extract_priv_scalar(cred_key),
credential_pubk_xy=extract_pub_xy(cred_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,
)
@@ -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_PubK", set_credential_pubk_apdu(artifacts.credential_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)):
_send(transmit, f"Access Document APDU {i}", apdu)

View File

@@ -12,7 +12,7 @@ from pathlib import Path
import click
from aliro_harness.reader.step_up import verify_step_up_m1
from aliro_harness.reader.step_up import verify_step_up_m2
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@@ -42,9 +42,10 @@ from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@click.option(
"--step-up",
is_flag=True,
help="After AUTH1, also exercise Step-Up Milestone 1: SELECT ACCE5502, "
"INS=0xC9 EXCHANGE, INS=0xC3 ENVELOPE. Verifies the StepUpApplet decrypts "
"with StepUpSKReader and encrypts the empty-CBOR-map ack with StepUpSKDevice.",
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
@@ -87,6 +88,12 @@ def main(
bundle = TrustBundle.from_trust_dir(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]:
data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2
@@ -94,7 +101,10 @@ def main(
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:
step_up_verdict = verify_step_up_m1(transmit, result.step_up_sk)
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:
connection.disconnect()
@@ -131,9 +141,9 @@ def main(
if step_up_verdict is not None:
ok, msg = step_up_verdict
if ok:
click.echo(f"STEP-UP M1: OK \u2014 {msg}")
click.echo(f"STEP-UP M2: OK \u2014 {msg}")
else:
click.echo(f"STEP-UP M1: FAIL \u2014 {msg}", err=True)
click.echo(f"STEP-UP M2: FAIL \u2014 {msg}", err=True)
raise click.exceptions.Exit(1)

View File

@@ -1,17 +1,31 @@
"""Step-Up Milestone 1 PC/SC verification.
"""Step-Up Milestone 2 PC/SC verification.
Verifies the M1 applet path post-AUTH1:
Drives the M2 applet path post-AUTH1:
- SELECT ACCE5502 (StepUpApplet)
- INS=0xC9 EXCHANGE encrypted with StepUpSKReader -> expect SW=9000, empty body
- INS=0xC3 ENVELOPE encrypted with StepUpSKReader -> expect SW=9000,
17B body that decrypts under StepUpSKDevice to single byte 0xA0.
- 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.
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
@@ -19,12 +33,34 @@ 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")
@@ -34,7 +70,36 @@ def _iv_device(counter: int) -> bytes:
return b"\x00" * 7 + b"\x01" + counter.to_bytes(4, "big")
def verify_step_up_m1(transmit: Transmit, step_up_sk: bytes) -> tuple[bool, str]:
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
@@ -45,30 +110,74 @@ def verify_step_up_m1(transmit: Transmit, step_up_sk: bytes) -> tuple[bool, str]
if sw != SW_OK:
return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}"
# M1B.1 -- EXCHANGE: any plaintext, expect 9000+empty.
pt = b"\x00" # one byte plaintext to exercise the decrypt path
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None)
# 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"M1B.1 EXCHANGE failed: SW=0x{sw:04X}"
if len(data) != 0:
return False, f"M1B.1 EXCHANGE expected empty body, got {len(data)}B: {data.hex()}"
reader_counter += 1
# M1C.1 -- ENVELOPE: any plaintext, expect 17B response decrypting to 0xA0.
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None)
apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
data, sw = transmit(apdu)
if sw != SW_OK:
return False, f"M1C.1 ENVELOPE failed: SW=0x{sw:04X}"
if len(data) != 17:
return False, f"M1C.1 ENVELOPE expected 17B response, got {len(data)}B"
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:
plaintext = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None)
exchange_pt = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None)
except Exception as e:
return False, f"M1C.1 response decrypt failed (tag/key mismatch): {e}"
if plaintext != b"\xA0":
return False, f"M1C.1 response plaintext expected 0xA0, got {plaintext.hex()}"
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
return True, "M1 step-up verified (EXCHANGE+ENVELOPE round-trip)"
# 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

@@ -5,12 +5,14 @@ import pytest
from aliro_harness.personalizer.apdus import (
CLA_PROPRIETARY,
INS_COMMIT,
INS_SET_CRED_ISSUER_PUBK,
INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK,
INS_SET_READER_PUBK,
PROVISIONING_AID,
commit_apdu,
select_provisioning_apdu,
set_credential_issuer_pubk_apdu,
set_credential_priv_apdu,
set_credential_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
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():
apdu = commit_apdu()
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 (
INS_COMMIT,
INS_FINALIZE_ACCESS_DOC,
INS_SET_CRED_ISSUER_PUBK,
INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK,
INS_SET_READER_PUBK,
@@ -46,6 +47,7 @@ def artifacts() -> TrustArtifacts:
credential_priv=extract_priv_scalar(cred),
credential_pubk_xy=extract_pub_xy(cred.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(
issuer_private_key=issuer,
access_credential_public_key=cred.public_key(),
@@ -58,17 +60,35 @@ def test_full_sequence_in_expected_order(artifacts):
personalize_card(transport, artifacts)
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[1] == INS_SET_CRED_PRIV
assert insns[2] == INS_SET_CRED_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.
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[-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):
transport = FakeTransport()
personalize_card(transport, artifacts)

View File

@@ -1,9 +1,27 @@
"""Smoke test for step-up key derivation parity with the applet."""
"""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:
@@ -37,3 +55,207 @@ def test_derive_step_up_session_keys_matches_rfc5869():
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