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:
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
274
applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java
Normal file
274
applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": <Access Document bytes verbatim>,
|
||||
* "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' < '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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 << 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' < '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;
|
||||
}
|
||||
}
|
||||
@@ -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 - <<'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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
13
docs/verdicts/2026-06-12-m2-pcsc-verdict.log
Normal file
13
docs/verdicts/2026-06-12-m2-pcsc-verdict.log
Normal 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)
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user