Initial snapshot: Aliro applet, harness, Nucleo NFC10A1 reader port

Three components, all bench-validated to varying depths:

- applet/: CSA Aliro v1.0 Java Card applet for J3R180. AUTH0 + AUTH1
  expedited-standard flow end-to-end green via PC/SC bench reader
  (aliro-bench-test). Userland AES-256-GCM and HMAC-SHA-256 layered
  on top of J3R180's primitives because the card lacks both natively.
  P-256 curve params seeded explicitly per J3R180's quirk.

- harness/: Python orchestrator (aliro-trustgen, aliro-personalize,
  aliro-bench-test) for trust-bundle generation, card personalization
  via PersonalizationApplet, and PC/SC AUTH0+AUTH1 transactions. 126
  pytest cases passing.

- reader/STM32CubeExpansion_ALIRO_V1_0_0/: ST X-CUBE-ALIRO V1.0.0
  with our NFC10A1 port (NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1, ST25R200
  shared with NFC09A1). nfc10-only/ project, NFC10A1 BSP shim,
  ALIRO_TRUST_OVERRIDE include into vendor's provisioning.c, and an
  ALIRO_APDU_TRACE wrapper around demoTransceiveBlocking. Boots,
  detects the J3R180, completes SELECT + AUTH0; AUTH1 currently fails
  with RFAL ERR_PROTO (0xB) — under investigation, see
  docs/plans/2026-04-20-nucleo-nfc10a1-port.md and bench-notes/.

Excluded: x-cube-aliro.zip vendor archive, harness/.venv, build dirs,
generated aliro_trust.h (contains private reader scalar), all PEMs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 10:17:46 -07:00
commit 782074f6ae
8786 changed files with 2902373 additions and 0 deletions

4
applet/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.tools/
target/
.idea/
*.iml

212
applet/INSTALL.md Normal file
View File

@@ -0,0 +1,212 @@
# Installing the Aliro applet on a real Java Card
Tested target: NXP J3R180 (JC 3.0.5, GP 2.3) over a USB PC/SC reader. Other
GP-2.2+ JC 3.0.5+ cards should work too.
## What you'll need
- A USB PC/SC reader (e.g. ACR1252U, ACR122U, OMNIKEY 5022).
- A Java Card with default GlobalPlatform test keys still in place (any
freshly-bought J3R180 from VivoKey-friendly suppliers ships this way).
- Java 11+ on the host. `pcscd` running (`sudo systemctl start pcscd` on
Linux).
- [GlobalPlatformPro](https://github.com/martinpaljak/GlobalPlatformPro)
(`gp.jar` — single fat jar, no install needed):
```
curl -sLO https://github.com/martinpaljak/GlobalPlatformPro/releases/latest/download/gp.jar
```
## Build the CAP
```
cd applet
./scripts/dt-mvn.sh clean package -DskipTests
```
Output: `applet/target/aliro-applet.cap` (~42 KB). Built inside the
`vivokey/smartcard-ci` container by the `ant-javacard` plugin against the
JC 3.0.5 SDK.
## Install
```
java -jar gp.jar --info # confirm reader sees the card
# Multi-applet CAPs: load the package once, then create each instance.
# --create needs the instance AID, the applet class AID (same as instance
# here), and the package AID (printed by --load).
PKG=A0000009094454414C49524F
java -jar gp.jar --load applet/target/aliro-applet.cap
java -jar gp.jar --create A000000909ACCE5501 --applet A000000909ACCE5501 --package $PKG
java -jar gp.jar --create A000000909ACCE5502 --applet A000000909ACCE5502 --package $PKG
java -jar gp.jar --create A000000909ACCE559901 --applet A000000909ACCE559901 --package $PKG
```
(`--install <cap>` alone fails with `"CAP contains more than one applet,
specify the right one with --[applet]"`, and `--create AID` without
`--applet`/`--package` fails with `"Need --[package, pkg] and --[applet]
or --[cap]"`.)
Default GP test keys (`404142434445464748494A4B4C4D4E4F`) are tried
automatically. For non-default keys, pass `--key <hex>` to every command.
Three applets get registered, one CAP:
| AID | Class | Role |
| -------------------------------- | ---------------------- | -------------------------------------------- |
| `A0000009 09 ACCE 55 01` | `AliroApplet` | Aliro EXPEDITED phase (spec) |
| `A0000009 09 ACCE 55 02` | `StepUpApplet` | Aliro STEP_UP phase (spec; scaffold only ATM)|
| `A0000009 09 ACCE 55 99 01` | `PersonalizationApplet`| DT-internal provisioning channel |
All three share the package AID `A0000009 09 4454414C49524F` (CSA RID +
ASCII "DTALIRO"). JavaCard requires applets in one CAP to share the
package's RID, hence the proprietary AID for personalization is also
under the CSA RID with a non-spec PIX (`99 01`).
## Verify
```
java -jar gp.jar --list
```
You should see all three AIDs in the `Application` list.
## Personalize
After install the card holds three applets but no Access Credential keys
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 |
| `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).
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/access_document.bin` → chunk into ≤255B writes
**One-shot personalization:** the harness ships an `aliro-personalize`
CLI that walks the entire INS sequence above against a PC/SC reader:
```
pip install -e harness/ # one-time, if not already installed
aliro-trustgen init --out-dir ./trust-out
aliro-personalize --trust-dir ./trust-out
```
Use `aliro-personalize --list-readers` to enumerate readers if more than
one is plugged in, then `--reader N` to pick by index.
The CLI fails fast on the first non-9000 status word and tells you which
step. After `COMMIT`, the card refuses every further write — there is no
factory-reset path in v1, so verify the trust artifacts before running.
## Validate after personalization
Once personalization has COMMITted, `aliro-bench-test` drives a full
AUTH0+AUTH1 exchange against the card from a Python orchestrator over
PC/SC. It's the real-hardware validation path that works without any
reader firmware (X-CUBE-ALIRO on X-NUCLEO-NFC09A1 is still pending).
```
pip install -e harness/ # one-time, same package that ships aliro-personalize
aliro-bench-test --trust-dir ~/aliro-trust
```
The `--trust-dir` must point at the same `aliro-trustgen init` output
that personalization used (it reads `reader.pem` to sign the AUTH1
challenge and `access_credential.pem` to verify the UD signature the
applet returns).
Successful output looks like:
```
Using reader: ACS ACR1252 1S CL Reader 0
Connected. ATR: 3B8F8001804F0CA000000306030001000000006A
Loaded trust artifacts from /home/you/aliro-trust
RESULT: OK — applet round-trip on real hardware.
0x5A credential_PubK: 65B
0x9E UD signature: 64B
0x5E signaling_bitmap: 0x0000
```
`0x5E signaling_bitmap` is `0x0000` when no Access Document was
provisioned and `0x0005` when one was (bit 0 = "Access Document
retrievable", bit 2 = "retrieval requires step-up AID SELECT" — see
Aliro Table 8-11).
Useful flags:
- `--list-readers` — enumerate PC/SC readers, then `--reader N` to pick.
Failure modes:
- **"trust dir not found" / missing `reader.pem`** — pass the right
`--trust-dir`, or regenerate with `aliro-trustgen init`.
- **"No reader with a card"** — card not powered on the field, or the
wrong reader index. `--list-readers` helps.
- **`SW=6985` on SELECT-EXPEDITED** — card wasn't personalized (no keys
COMMITted). Run `aliro-personalize` first.
- **`signature verification failed`** after AUTH1 — the `credential_PubK`
returned by the card doesn't match `access_credential.pem` in the
trust dir. Either the card was personalized from a different trust
bundle, or the bundle on disk was regenerated post-personalization.
There is no factory-reset in v1 — re-personalize on a fresh card.
- **AES-GCM decrypt failure** — ExpeditedSKDevice mismatch; usually
means the reader and applet derived different Kdh (check that
`reader_PubK` in the trust dir matches the one that was provisioned).
This exercises the full EXPEDITED protocol end-to-end — every crypto
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.
## Uninstall / re-install
`gp --delete <pkg_AID>` won't succeed while applet *instances* still
reference the package, and `--deletedeps` isn't recognised by every
gp.jar build. The portable sequence is to delete each instance first,
then the package:
```
java -jar gp.jar --delete A000000909ACCE5501
java -jar gp.jar --delete A000000909ACCE5502
java -jar gp.jar --delete A000000909ACCE559901
java -jar gp.jar --delete A0000009094454414C49524F
```
Then `--load` + the three `--create` commands above to reinstall the
fresh CAP.
`--uninstall <cap>` is also supported (parses the CAP for the AIDs and
deletes them) but only if the CAP file is reachable from the current
directory, since `gp` resolves it as a path:
```
java -jar gp.jar --uninstall ./aliro-applet.cap
```
## Troubleshooting
- **"No reader with a card found"**: confirm `pcsc_scan` (Linux/macOS)
sees both reader and card. On Linux, `pcscd` must be running.
- **`SW=6985 (Conditions not satisfied)`** on Aliro commands: provisioning
hasn't been COMMITted yet, or no AUTH0 preceded an AUTH1.
- **`SW=6A82 (File not found)`** on SELECT: applet didn't install — check
`gp --list` and re-run `--install`.
- **Auth failure on `gp --install`**: card uses non-default GP keys.
Either find the issuer keys or factory-reset (vendor-specific).
- **CAP build error "unsupported bytecode `new` in clinit"**: regression
from someone adding a `static final ... = new ...()` initializer. JC
3.0.5 forbids these. Move allocation to a constructor or to lazy init
inside an instance method.

145
applet/pom.xml Normal file
View File

@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.dangerousthings.aliro</groupId>
<artifactId>aliro-applet</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<description>
Java Card applet implementing the CSA Aliro v1.0 User Device role over NFC.
Built and tested inside the vivokey/smartcard-ci Docker image; see scripts/dt-mvn.sh.
</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- JC 3.0.5 converter supports up to 1.7 bytecode (v51.0). Source
stays at 1.7 to match. Test classes can be compiled at 1.8 via
the test compiler config below — they don't go through the JC
converter and the image's javac handles either version. -->
<maven.compiler.source>1.7</maven.compiler.source>
<maven.compiler.target>1.7</maven.compiler.target>
<junit.jupiter.version>5.10.2</junit.jupiter.version>
</properties>
<dependencies>
<!--
jcardsim is pre-built into the smartcard-ci image's local m2 repo
(see https://github.com/DangerousThings/smartcard-ci Dockerfile).
No jitpack / central resolution needed.
-->
<dependency>
<groupId>com.licel</groupId>
<artifactId>jcardsim</artifactId>
<version>3.0.5-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<!-- ant-javacard's recent releases are published via jitpack rather
than Maven Central; the Central artifact is stuck on 2019. -->
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</pluginRepository>
</pluginRepositories>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<!-- Main: 1.7 bytecode for the JC 3.0.5 converter. -->
<source>1.7</source>
<target>1.7</target>
<!-- Tests: JUnit 5 + our helpers use 1.8 features. They
don't go through the JC converter. -->
<testSource>1.8</testSource>
<testTarget>1.8</testTarget>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<!--
Builds the Java Card CAP file via ant-javacard, which wraps
Oracle's converter from the JC SDK pinned by JC_CLASSIC_HOME
(the dt-mvn.sh wrapper sets this to /app/sdks/jc305u3_kit).
Output: target/aliro-applet.cap
Package AID: A0 00 00 09 09 44 54 41 4C 49 52 4F (CSA RID + "DTALIRO")
Applet AIDs:
AliroApplet A0 00 00 09 09 AC CE 55 01 (Aliro EXPEDITED, spec)
StepUpApplet A0 00 00 09 09 AC CE 55 02 (Aliro STEP_UP, spec)
PersonalizationApplet A0 00 00 09 09 AC CE 55 99 01 (DT-internal, CSA-RID-namespaced)
JavaCard requires all applet AIDs in a CAP to share the
package's RID (first 5 bytes); using the CSA RID throughout
lets the three applets ship in one CAP.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>build-cap</id>
<phase>package</phase>
<goals><goal>run</goal></goals>
<configuration>
<target>
<taskdef name="javacard"
classname="pro.javacard.ant.JavaCard"
classpathref="maven.plugin.classpath"/>
<javacard jckit="${env.JC_CLASSIC_HOME}">
<cap output="${project.build.directory}/aliro-applet.cap"
aid="A0000009094454414C49524F"
package="com.dangerousthings.aliro"
version="0.1"
classes="${project.build.outputDirectory}">
<applet class="com.dangerousthings.aliro.AliroApplet"
aid="A000000909ACCE5501"/>
<applet class="com.dangerousthings.aliro.StepUpApplet"
aid="A000000909ACCE5502"/>
<applet class="com.dangerousthings.aliro.PersonalizationApplet"
aid="A000000909ACCE559901"/>
</cap>
</javacard>
</target>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.github.martinpaljak</groupId>
<artifactId>ant-javacard</artifactId>
<version>v26.02.22</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>

28
applet/scripts/dt-mvn.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Run Maven inside the DangerousThings smartcard-ci Docker image.
#
# The image (vivokey/smartcard-ci) ships a pre-built jcardsim-3.0.5-SNAPSHOT,
# Oracle JavaCard SDKs, Java 8, and Maven. On first run we seed a named
# Docker volume with the image's baseline .m2 so subsequent runs keep
# downloaded deps (JUnit etc.) warm between invocations.
#
# Usage: ./scripts/dt-mvn.sh <mvn args> e.g.: ./scripts/dt-mvn.sh -q test
set -euo pipefail
IMAGE="vivokey/smartcard-ci:latest"
VOLUME="aliro-smartcard-m2"
project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd)"
if ! docker volume inspect "$VOLUME" >/dev/null 2>&1; then
echo "Seeding $VOLUME with baseline .m2 from $IMAGE (one-time)" >&2
docker volume create "$VOLUME" >/dev/null
docker run --rm -v "$VOLUME:/dst" --entrypoint sh "$IMAGE" \
-c 'cp -a /root/.m2/. /dst/ && chmod -R a+rwX /dst'
fi
exec docker run --rm -t \
-v "$project_root:/work" -w /work \
-v "$VOLUME:/root/.m2" \
-e JC_CLASSIC_HOME=/app/sdks/jc305u3_kit \
"$IMAGE" "mvn $*"

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Install licel/jcardsim master (which has AES-GCM support) into the persistent
# aliro-smartcard-m2 Docker volume, shadowing the jcardsim-3.0.5-SNAPSHOT jar
# baked into vivokey/smartcard-ci:latest.
#
# Why: the DT smartcard-ci image pins a jcardsim fork that is 13 commits behind
# licel upstream and lacks the AES-GCM/CCM implementation merged in licel PR
# #187 (2022-07). Aliro §8.3.1.6 requires AES-256-GCM; without the impl,
# `Cipher.getInstance(AEADCipher.ALG_AES_GCM, false)` throws CryptoException.
#
# Rerun this when the base image or the m2 volume is recreated.
#
# Usage: ./scripts/install-jcardsim-gcm.sh
set -euo pipefail
IMAGE="vivokey/smartcard-ci:latest"
VOLUME="aliro-smartcard-m2"
UPSTREAM_COMMIT="aa60a02f" # licel/jcardsim master HEAD as of 2024-04-03 (last verified)
project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd)"
build_dir="$project_root/build-tools/jcardsim"
if [[ ! -d "$build_dir" ]]; then
echo "Cloning licel/jcardsim master into $build_dir" >&2
mkdir -p "$project_root/build-tools"
git clone --depth 50 https://github.com/licel/jcardsim.git "$build_dir"
fi
(cd "$build_dir" \
&& git fetch --depth 50 origin master \
&& git checkout master \
&& git reset --hard origin/master)
patches_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/jcardsim-patches"
if [[ -d "$patches_dir" ]]; then
echo "Applying local patches from $patches_dir" >&2
for patch in "$patches_dir"/*.patch; do
[[ -e "$patch" ]] || continue
(cd "$build_dir" && git apply "$patch")
done
fi
if ! docker volume inspect "$VOLUME" >/dev/null 2>&1; then
echo "Volume $VOLUME missing. Run ./scripts/dt-mvn.sh once first to seed it." >&2
exit 1
fi
echo "Building jcardsim inside $IMAGE..." >&2
docker run --rm -v "$build_dir:/src" -w /src \
-v "$VOLUME:/root/.m2" \
--entrypoint bash \
"$IMAGE" -c \
"export JC_CLASSIC_HOME=/app/sdks/jc305u3_kit && \
mvn -q -DskipTests initialize && \
mvn -q -DskipTests package && \
mvn -q install:install-file \
-Dfile=target/jcardsim-3.0.5-SNAPSHOT.jar \
-DgroupId=com.licel -DartifactId=jcardsim \
-Dversion=3.0.5-SNAPSHOT -Dpackaging=jar \
-DpomFile=pom.xml"
echo "Verifying AEAD implementation is present in installed jar..." >&2
docker run --rm -v "$VOLUME:/root/.m2" --entrypoint bash "$IMAGE" -c \
"jar tf /root/.m2/repository/com/licel/jcardsim/3.0.5-SNAPSHOT/jcardsim-3.0.5-SNAPSHOT.jar \
| grep -q 'AuthenticatedSymmetricCipherImpl.class'"
echo "jcardsim with AES-GCM installed. Run ./scripts/dt-mvn.sh test to verify." >&2

View File

@@ -0,0 +1,24 @@
Fix ByteContainer.setBytes to reallocate data when the incoming length
differs from the currently stored length.
Upstream licel/jcardsim master drops `this.length != length` from the
reallocation guard; the DT fork kept it. Without the check, calling
setKey(buf13, 0, 13) then setKey(buf64, 32, 32) on the same key reuses the
already-sized-13 internal data[] and triggers ArrayIndexOutOfBoundsException
in the subsequent Util.arrayCopy.
This regression hits HKDF-Extract-then-Expand on the same HMACKey, which is
exactly the shape Aliro §8.3.1.4 / §8.3.1.13 require.
diff --git a/src/main/java/com/licel/jcardsim/crypto/ByteContainer.java b/src/main/java/com/licel/jcardsim/crypto/ByteContainer.java
--- a/src/main/java/com/licel/jcardsim/crypto/ByteContainer.java
+++ b/src/main/java/com/licel/jcardsim/crypto/ByteContainer.java
@@ -106,7 +106,7 @@ public final class ByteContainer {
* @param length length of data in byte array
*/
public void setBytes(byte[] buff, short offset, short length) {
- if (data == null) {
+ if (data == null || this.length != length) {
switch (memoryType) {
case JCSystem.MEMORY_TYPE_TRANSIENT_DESELECT:
data = JCSystem.makeTransientByteArray(length, JCSystem.CLEAR_ON_DESELECT);

View File

@@ -0,0 +1,36 @@
package com.dangerousthings.aliro;
/**
* CSA Aliro v1.0 AIDs, spec &sect;10.2.1.
*/
public final class AliroAids {
/** Expedited Phase AID: A0 00 00 09 09 AC CE 55 01 */
public static final byte[] EXPEDITED = {
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x01
};
/** Step-up Phase AID: A0 00 00 09 09 AC CE 55 02 */
public static final byte[] STEP_UP = {
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x02
};
/**
* DT-internal provisioning AID. Lives under the CSA RID so this applet
* can ship in the same CAP as the spec-defined Aliro applets (Java Card
* requires all applets in a package share the package's RID). The PIX
* tail (ACCE 55 99 01) is non-spec — Aliro v1 uses 0x01/0x02 as the
* PIX-low byte; 0x99 stays clear of any future allocation.
*
* <p>Used by the PersonalizationApplet to receive Access Credential +
* reader keys + Access Document before the main Aliro flow runs. The
* applet locks itself after COMMIT.
*/
public static final byte[] PROVISIONING = {
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x99, (byte)0x01
};
private AliroAids() {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
package com.dangerousthings.aliro;
import javacard.security.ECPrivateKey;
import javacard.security.KeyAgreement;
/**
* Low-level Aliro cryptographic primitives, factored out so they can be
* unit-tested independently of the APDU dispatch in {@link AliroApplet}.
*
* <p>Intended to be instantiated once per applet (or test) and reused —
* the bundled {@link KeyAgreement} and {@link AliroHmac} objects are
* expensive to allocate.
*
* <p>HMAC-SHA-256 is supplied by {@link AliroHmac}, our userland
* implementation. The target card (NXP J3R180) does not expose
* {@code KeyBuilder.TYPE_HMAC} at any tested key size nor
* {@code Signature.ALG_HMAC_SHA_256}, so we build HMAC on top of the card's
* raw SHA-256 primitive per RFC 2104.
*/
final class AliroCrypto {
private static final short HASH_LEN = 32;
/** Maximum expanded-input buffer size for HKDF-Expand: one T(i-1) block
* (32B) + info (up to ~200B, defensively) + one counter byte. Rounded up
* to 256B. The largest info we currently pass is the 32-byte salt_volatile
* info for expedited-standard derivation; step-up uses 8-byte ASCII info. */
private static final short EXPAND_SCRATCH_LEN = 256;
private KeyAgreement ecdhPlain;
private AliroHmac aliroHmac;
/** Reusable scratch for one HMAC output (T(i)) and one counter byte. */
private byte[] hkdfPrevT;
/** Scratch for assembling the HMAC message for HKDF-Expand: T(i-1) || info || counter. */
private byte[] expandScratch;
/** Scratch for {@link #deriveKdh}: 32B for Z_AB then 32B for PRK. */
private byte[] kdfWorkbuf;
private static final short KDF_WORKBUF_LEN = 64;
/** 32 zero bytes — RFC 5869 §2.2 fallback when {@link #hkdfExtract} is
* called with an empty salt. Allocated in the constructor; Java Card
* bans {@code new} in static initializers, so we can't make this static-final. */
private byte[] emptySaltZeros;
AliroCrypto() {
try {
ecdhPlain = KeyAgreement.getInstance(KeyAgreement.ALG_EC_SVDP_DH_PLAIN, false);
} catch (Throwable t) {
javacard.framework.ISOException.throwIt((short) 0x6FC1);
}
// Userland HMAC-SHA-256 on top of MessageDigest.ALG_SHA_256. Diagnostic
// status word 0x6FC7 gives us one regression cycle of visibility on the
// real card before we strip it.
try {
aliroHmac = new AliroHmac();
} catch (Throwable t) {
javacard.framework.ISOException.throwIt((short) 0x6FC7);
}
try {
hkdfPrevT = javacard.framework.JCSystem.makeTransientByteArray(
HASH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
expandScratch = javacard.framework.JCSystem.makeTransientByteArray(
EXPAND_SCRATCH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
kdfWorkbuf = javacard.framework.JCSystem.makeTransientByteArray(
KDF_WORKBUF_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
} catch (Throwable t) {
javacard.framework.ISOException.throwIt((short) 0x6FC4);
}
try {
emptySaltZeros = new byte[32]; // persistent, defaults to all zeros
} catch (Throwable t) {
javacard.framework.ISOException.throwIt((short) 0x6FC5);
}
}
/**
* Computes the x-coordinate of the ECDH shared point, given a local
* P-256 private key and the peer's uncompressed public point
* (0x04 || x || y, 65 bytes). Returns 32 (the x-coordinate length).
*/
short computeEcdhSharedX(
ECPrivateKey priv,
byte[] peerPubUncomp, short peerPubOff,
byte[] out, short outOff) {
ecdhPlain.init(priv);
return ecdhPlain.generateSecret(peerPubUncomp, peerPubOff, (short) 65, out, outOff);
}
/**
* HKDF-Extract per RFC 5869 §2.2 with HMAC-SHA-256:
* {@code PRK = HMAC(salt, IKM)}.
* Returns 32 (HashLen).
*/
short hkdfExtract(
byte[] salt, short saltOff, short saltLen,
byte[] ikm, short ikmOff, short ikmLen,
byte[] prk, short prkOff) {
// RFC 5869 §2.2: empty salt is treated as HashLen zero bytes.
if (saltLen == 0) {
return aliroHmac.compute(
emptySaltZeros, (short) 0, HASH_LEN,
ikm, ikmOff, ikmLen,
prk, prkOff);
}
return aliroHmac.compute(
salt, saltOff, saltLen,
ikm, ikmOff, ikmLen,
prk, prkOff);
}
/**
* HKDF-Expand per RFC 5869 §2.3 with HMAC-SHA-256. Generates {@code L}
* bytes of output keying material, where {@code L} must be ≤ 255 *
* HashLen (8160 bytes — far above any Aliro need). The info region must
* be at most {@code EXPAND_SCRATCH_LEN - HASH_LEN - 1} bytes long
* (= 223 bytes with the current buffer sizing).
*/
short hkdfExpand(
byte[] prk, short prkOff, short prkLen,
byte[] info, short infoOff, short infoLen,
short L,
byte[] out, short outOff) {
// T(0) = empty; T(i) = HMAC(PRK, T(i-1) || info || i). Concatenate
// T(1)..T(N) and truncate to L bytes.
short produced = 0;
short prevTLen = 0;
byte counter = (byte) 1;
while (produced < L) {
// Assemble the HMAC message as T(i-1) || info || counter in expandScratch.
short msgLen = 0;
if (prevTLen != 0) {
javacard.framework.Util.arrayCopyNonAtomic(
hkdfPrevT, (short) 0, expandScratch, msgLen, prevTLen);
msgLen += prevTLen;
}
if (infoLen != 0) {
javacard.framework.Util.arrayCopyNonAtomic(
info, infoOff, expandScratch, msgLen, infoLen);
msgLen += infoLen;
}
expandScratch[msgLen] = counter;
msgLen++;
aliroHmac.compute(
prk, prkOff, prkLen,
expandScratch, (short) 0, msgLen,
hkdfPrevT, (short) 0);
prevTLen = HASH_LEN;
short remaining = (short) (L - produced);
short toCopy = remaining < HASH_LEN ? remaining : HASH_LEN;
javacard.framework.Util.arrayCopyNonAtomic(
hkdfPrevT, (short) 0, out, (short) (outOff + produced), toCopy);
produced += toCopy;
counter++;
}
// Wipe the assembled message scratch — it held T(i-1) values (derived
// from the HKDF PRK) and the info bytes. CLEAR_ON_DESELECT cleans up
// at deselect, but multi-APDU sessions would otherwise see stale T(i).
javacard.framework.Util.arrayFillNonAtomic(
expandScratch, (short) 0, EXPAND_SCRATCH_LEN, (byte) 0);
return produced;
}
/**
* Derives Kdh per Aliro §8.3.1.4 (with the §8.3.1.5 HKDF substitution
* noted in the spec): {@code Kdh = HKDF(IKM=ECDH_x(priv, peerPub),
* salt=txnId, info=∅, L=32)}. Writes 32 bytes to {@code out[outOff..]}
* and returns 32.
*/
short deriveKdh(
ECPrivateKey priv,
byte[] peerPubUncomp, short peerPubOff,
byte[] txnId, short txnIdOff, short txnIdLen,
byte[] out, short outOff) {
computeEcdhSharedX(priv, peerPubUncomp, peerPubOff, kdfWorkbuf, (short) 0);
hkdfExtract(
txnId, txnIdOff, txnIdLen,
kdfWorkbuf, (short) 0, HASH_LEN,
kdfWorkbuf, HASH_LEN);
hkdfExpand(
kdfWorkbuf, HASH_LEN, HASH_LEN,
kdfWorkbuf, (short) 0, (short) 0,
HASH_LEN,
out, outOff);
return HASH_LEN;
}
/** ASCII info strings for step-up session key derivation, per mdoc 9.1.1.5. */
private static final byte[] STEP_UP_INFO_SK_DEVICE = {
(byte) 'S', (byte) 'K', (byte) 'D', (byte) 'e',
(byte) 'v', (byte) 'i', (byte) 'c', (byte) 'e'
};
private static final byte[] STEP_UP_INFO_SK_READER = {
(byte) 'S', (byte) 'K', (byte) 'R', (byte) 'e',
(byte) 'a', (byte) 'd', (byte) 'e', (byte) 'r'
};
/**
* Derives {@code StepUpSKDevice} and {@code StepUpSKReader} per Aliro
* §8.4.3, which points at mdoc [6] clause 9.1.1.5 with two Aliro-specific
* changes: IKM is the {@code StepUpSK} computed in §8.3.1.13 and the salt
* is empty. Info remains the ASCII bytes of {@code "SKDevice"} and
* {@code "SKReader"}.
*
* <p>Writes 32 bytes each to {@code skDeviceOut[skDeviceOff..]} and
* {@code skReaderOut[skReaderOff..]}.
*/
short deriveStepUpSessionKeys(
byte[] stepUpSK, short stepUpSKOff,
byte[] skDeviceOut, short skDeviceOff,
byte[] skReaderOut, short skReaderOff) {
// HKDF-Extract once (shared across the two Expand calls): empty salt.
hkdfExtract(
kdfWorkbuf, (short) 0, (short) 0, // empty salt — use first 0 bytes of anything
stepUpSK, stepUpSKOff, HASH_LEN,
kdfWorkbuf, HASH_LEN); // PRK at [32..64)
hkdfExpand(
kdfWorkbuf, HASH_LEN, HASH_LEN,
STEP_UP_INFO_SK_DEVICE, (short) 0, (short) STEP_UP_INFO_SK_DEVICE.length,
HASH_LEN,
skDeviceOut, skDeviceOff);
hkdfExpand(
kdfWorkbuf, HASH_LEN, HASH_LEN,
STEP_UP_INFO_SK_READER, (short) 0, (short) STEP_UP_INFO_SK_READER.length,
HASH_LEN,
skReaderOut, skReaderOff);
return HASH_LEN;
}
/**
* Derives the 160-byte {@code derived_keys_volatile} for the
* expedited-standard phase per Aliro §8.3.1.13. Output is laid out as
* ExpeditedSKReader[0..32), ExpeditedSKDevice[32..64), StepUpSK[64..96),
* BleSK[96..128), URSK[128..160). For NFC-only operation only the
* first 96 bytes are load-bearing, but the spec mandates generating all
* 160 so the caller can discard what it doesn't need.
*/
short deriveExpeditedStandardKeys(
byte[] kdh, short kdhOff,
byte[] saltVolatile, short saltOff, short saltLen,
byte[] info, short infoOff, short infoLen,
byte[] out, short outOff) {
hkdfExtract(
saltVolatile, saltOff, saltLen,
kdh, kdhOff, HASH_LEN,
kdfWorkbuf, (short) 0);
hkdfExpand(
kdfWorkbuf, (short) 0, HASH_LEN,
info, infoOff, infoLen,
(short) 160,
out, outOff);
return (short) 160;
}
}

View File

@@ -0,0 +1,286 @@
package com.dangerousthings.aliro;
import javacard.framework.JCSystem;
import javacard.framework.Util;
import javacard.security.AESKey;
import javacard.security.KeyBuilder;
import javacardx.crypto.Cipher;
/**
* Userland AES-256-GCM, built on top of the JC 3.0.5 single-block AES-ECB
* primitive. Needed because the target card (NXP J3R180) does not expose
* {@code AEADCipher.ALG_AES_GCM} despite advertising JC 3.0.5 support, while
* {@code Cipher.ALG_AES_BLOCK_128_ECB_NOPAD} and AES-256 keys are both
* present and functional.
*
* <p>Tailored to the Aliro AUTH1 response use case (spec &sect;8.3.1.6):
* AES-256 with a 12-byte IV, empty AAD, 16-byte tag, and plaintext on the
* order of 137 bytes. The NIST SP 800-38D canonical 96-bit-IV shortcut is
* used (IV is taken as {@code IV || 0x00000001} for J<sub>0</sub>, no IV
* hashing). This is the only IV length we ever produce — see
* {@link AliroApplet#encryptResponseGcm}.
*
* <p>GHASH follows NIST SP 800-38D &sect;6.4's bit-reflected convention:
* byte 0 bit 7 (MSB) is the highest-degree polynomial coefficient; the
* reduction polynomial R = x^128 + x^7 + x^2 + x + 1 is represented as the
* 16-byte array {0xE1, 0x00, ..., 0x00}; the GHASH shift-right shifts toward
* higher byte indices (lower polynomial degrees).
*
* <p>Not constant-time — the GF(2^128) multiply branches on bits of the
* authentication tag partial state. On a secure JC platform this is
* acceptable (the key material is smartcard-protected), but callers should
* not pass attacker-chosen GHASH state.
*/
final class AliroGcm {
private static final short BLOCK_LEN = 16;
private static final short TAG_LEN = 16;
private static final short IV_LEN = 12; // 96-bit IV shortcut (NIST SP 800-38D §7.1)
private static final short AES_256_KEY_BYTES = 32;
// Scratch layout — all 16-byte slots, single transient allocation.
private static final short OFF_H = 0; // hash subkey = AES_K(0^128)
private static final short OFF_J0 = 16; // pre-counter block
private static final short OFF_CB = 32; // running counter block
private static final short OFF_GHASH = 48; // accumulated GHASH state
private static final short OFF_ECB_OUT = 64; // AES keystream block (also used for J0 encryption)
private static final short OFF_TMP = 80; // misc: GF multiply workspace (V)
private static final short OFF_LEN_BLK = 96; // length block for GHASH finalization
private static final short SCRATCH_LEN = 112;
/** Persistent ECB cipher (AES-128/192/256 single-block-NOPAD). AES-256
* is driven by the size of the {@link AESKey} passed to {@link Cipher#init}. */
private final Cipher aesEcb;
/** Reusable 256-bit AES key slot. {@link AESKey#setKey} is called per encrypt. */
private final AESKey aesKey;
/** Transient scratch — see OFF_* constants above. */
private final byte[] scratch;
AliroGcm() {
aesEcb = Cipher.getInstance(Cipher.ALG_AES_BLOCK_128_ECB_NOPAD, false);
aesKey = (AESKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
}
/**
* AES-256-GCM encrypt with the Aliro parameters: 32-byte key, 12-byte IV,
* empty AAD, 16-byte tag appended. Writes {@code ptLen} ciphertext bytes
* followed by 16 tag bytes to {@code out[outOff..]} and returns
* {@code ptLen + 16}.
*
* <p>The {@code out} buffer may overlap {@code pt} (same buffer, same
* offset is supported — we encrypt block-by-block left-to-right, the
* GHASH pass reads from {@code out}, and the tag lands past the end of
* the ciphertext).
*
* @param key 32-byte AES-256 key, at {@code key[keyOff..keyOff+32)}
* @param iv 12-byte IV, at {@code iv[ivOff..ivOff+12)}
* @param pt plaintext bytes
* @param ptOff, ptLen plaintext region
* @param out output buffer, must have at least {@code ptLen + 16} bytes
* available at {@code outOff}
* @param outOff output start offset
* @return {@code ptLen + 16}
*/
short encrypt(
byte[] key, short keyOff,
byte[] iv, short ivOff,
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);
// J0 = IV || 0x00000001 (12B IV + 4B counter). 96-bit IV canonical case.
Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN);
scratch[(short) (OFF_J0 + 12)] = 0x00;
scratch[(short) (OFF_J0 + 13)] = 0x00;
scratch[(short) (OFF_J0 + 14)] = 0x00;
scratch[(short) (OFF_J0 + 15)] = 0x01;
// cb = INC32(J0) — first counter block for the plaintext stream.
Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN);
inc32(scratch, OFF_CB);
// GCTR loop: stream plaintext through AES-CTR, writing ciphertext to out.
short produced = 0;
while (produced < ptLen) {
short blockLen = (short) (ptLen - produced);
if (blockLen > BLOCK_LEN) blockLen = BLOCK_LEN;
// keystream = AES_K(cb)
aesEcb.doFinal(scratch, OFF_CB, BLOCK_LEN, scratch, OFF_ECB_OUT);
// ct[i] = pt[i] XOR keystream[i] — partial-block safe.
for (short i = 0; i < blockLen; i++) {
out[(short) (outOff + produced + i)] =
(byte) (pt[(short) (ptOff + produced + i)]
^ scratch[(short) (OFF_ECB_OUT + i)]);
}
inc32(scratch, OFF_CB);
produced += blockLen;
}
// GHASH over (AAD=empty || ciphertext || len_block).
// state = 0^128
Util.arrayFillNonAtomic(scratch, OFF_GHASH, BLOCK_LEN, (byte) 0);
// Fold ciphertext blocks into GHASH, zero-padding the final partial block.
short consumed = 0;
while (consumed < ptLen) {
short chunk = (short) (ptLen - consumed);
if (chunk >= BLOCK_LEN) {
// XOR full 16-byte block directly from the output buffer.
for (short i = 0; i < BLOCK_LEN; i++) {
scratch[(short) (OFF_GHASH + i)] ^= out[(short) (outOff + consumed + i)];
}
consumed += BLOCK_LEN;
} else {
// Partial block: XOR the available bytes, implicit zero-pad for the rest.
for (short i = 0; i < chunk; i++) {
scratch[(short) (OFF_GHASH + i)] ^= out[(short) (outOff + consumed + i)];
}
consumed += chunk;
}
gfMul(scratch, OFF_GHASH, scratch, OFF_H);
}
// len_block = (8*|AAD|)^64 || (8*|C|)^64, big-endian. AAD is always empty
// for this use case; ptLen fits in 16 bits, so only the low 2 bytes of
// the 8-byte plaintext-bit-length field can be non-zero.
Util.arrayFillNonAtomic(scratch, OFF_LEN_BLK, BLOCK_LEN, (byte) 0);
// ptLen·8 in bits, big-endian, into the last 4 bytes of the 16-byte
// length block (bits 0-95 are zero — AAD-bits=0 + high bytes of CT-bits).
// Java Card 3.0.5 forbids `int` literals, so we compute as `short` and
// split the byte-shift into two stages to dodge the implicit `int`
// promotion that the converter rejects ("unsupported int type constant").
// ptLen ≤ 8191 ⇒ ptLen·8 ≤ 65528, fits in unsigned 16 bits. The top two
// bytes of the length field stay zero.
short ctBitsLo = (short) (ptLen << 3); // low 16 bits of bit-count
short ctBitsHi = (short) (((short)(ptLen >>> 13)) & 0x07); // bits 16-18 of bit-count
scratch[(short) (OFF_LEN_BLK + 13)] = (byte) (ctBitsHi & 0xFF);
scratch[(short) (OFF_LEN_BLK + 14)] = (byte) ((ctBitsLo >>> 8) & 0xFF);
scratch[(short) (OFF_LEN_BLK + 15)] = (byte) (ctBitsLo & 0xFF);
for (short i = 0; i < BLOCK_LEN; i++) {
scratch[(short) (OFF_GHASH + i)] ^= scratch[(short) (OFF_LEN_BLK + i)];
}
gfMul(scratch, OFF_GHASH, scratch, OFF_H);
// T = AES_K(J0) XOR GHASH
aesEcb.doFinal(scratch, OFF_J0, BLOCK_LEN, scratch, OFF_ECB_OUT);
short tagOut = (short) (outOff + ptLen);
for (short i = 0; i < TAG_LEN; i++) {
out[(short) (tagOut + i)] =
(byte) (scratch[(short) (OFF_ECB_OUT + i)]
^ scratch[(short) (OFF_GHASH + i)]);
}
// Wipe scratch: H, J0 (with IV bytes), GHASH state, and the keystream
// remnant in OFF_ECB_OUT all touched the AES key directly. CLEAR_ON_DESELECT
// would clean up at deselect, but multi-APDU sessions could otherwise
// see stale H/keystream until then.
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
return (short) (ptLen + TAG_LEN);
}
/**
* INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the
* 16-byte block, big-endian, modulo 2^32.
*/
private static void inc32(byte[] buf, short off) {
short i = (short) (off + 15);
// Carry propagation across 4 bytes.
if ((byte) (++buf[i]) != (byte) 0) return;
i--;
if ((byte) (++buf[i]) != (byte) 0) return;
i--;
if ((byte) (++buf[i]) != (byte) 0) return;
i--;
buf[i]++;
}
/**
* In-place GF(2^128) multiplication per NIST SP 800-38D §6.3, storing
* X ·_GF Y into X. Uses the bit-reflected convention:
* <ul>
* <li>byte 0 MSB = x^127 (highest-degree polynomial coefficient)
* <li>byte 15 LSB = x^0
* <li>reduction polynomial R = x^128 + x^7 + x^2 + x + 1 represented
* as {0xE1, 0x00, ..., 0x00}
* <li>"shift right" means shift toward higher byte indices
* </ul>
*
* <p>Algorithm 1 from NIST SP 800-38D:
* <pre>
* Z = 0; V = Y
* for i = 0..127:
* if bit i of X is 1: Z ^= V
* if lowest bit of V (byte 15 bit 0) is 1: V = (V >> 1) ^ R
* else: V = V >> 1
* return Z
* </pre>
*
* <p>This is the simple, portable NIST shift-XOR form. A 4-bit-table
* variant would be ~4x faster but adds 32 bytes of per-H precomputation
* and a more intricate shift; we encrypt ~137B per AUTH1, so the extra
* cycles are acceptable versus the complexity cost.
*/
private void gfMul(byte[] xBuf, short xOff, byte[] yBuf, short yOff) {
// V = Y (copied into OFF_TMP scratch region; gets shifted in place)
Util.arrayCopyNonAtomic(yBuf, yOff, scratch, OFF_TMP, BLOCK_LEN);
// Z = 0 (we'll accumulate in a second 16-byte region — reuse OFF_LEN_BLK,
// which is free until after the main GHASH loop finishes). gfMul is
// called both during GHASH accumulation AND during final-length mixing;
// at final-length mixing OFF_LEN_BLK has already been consumed, so it's
// safe to overwrite. But to avoid an aliasing bug if OFF_LEN_BLK is
// ever reused differently, we stage Z in OFF_ECB_OUT instead.
//
// Wait — OFF_ECB_OUT holds the AES keystream we need later for the
// tag (T = AES_K(J0) XOR GHASH). However, gfMul is never called
// between producing OFF_ECB_OUT (the AES_K(J0) output) and consuming
// it for the tag XOR. The last gfMul is for (state ^ len_block) ·_GF H,
// and only AFTER that do we compute AES_K(J0) into OFF_ECB_OUT. So
// using OFF_ECB_OUT as the Z accumulator is safe.
Util.arrayFillNonAtomic(scratch, OFF_ECB_OUT, BLOCK_LEN, (byte) 0);
for (short byteIdx = 0; byteIdx < BLOCK_LEN; byteIdx++) {
byte xByte = xBuf[(short) (xOff + byteIdx)];
for (short bit = 7; bit >= 0; bit--) {
// bit of X at polynomial position (byteIdx*8 + (7-bit)): byte-high bit first.
if (((xByte >> bit) & 0x01) != 0) {
for (short k = 0; k < BLOCK_LEN; k++) {
scratch[(short) (OFF_ECB_OUT + k)] ^= scratch[(short) (OFF_TMP + k)];
}
}
// V = V >> 1; if the bit that fell off (byte 15 low bit BEFORE shift) is 1,
// also XOR the reduction polynomial {0xE1, 0x00, ..., 0x00}.
boolean lsb = (scratch[(short) (OFF_TMP + 15)] & 0x01) != 0;
// Shift V right by 1 bit (toward higher byte indices).
short carry = 0;
for (short k = 0; k < BLOCK_LEN; k++) {
short cur = (short) (scratch[(short) (OFF_TMP + k)] & 0xFF);
short newCur = (short) ((cur >>> 1) | carry);
carry = (short) ((cur & 0x01) << 7);
scratch[(short) (OFF_TMP + k)] = (byte) newCur;
}
if (lsb) {
scratch[OFF_TMP] ^= (byte) 0xE1;
}
}
}
// Copy Z back into X in place.
Util.arrayCopyNonAtomic(scratch, OFF_ECB_OUT, xBuf, xOff, BLOCK_LEN);
}
}

View File

@@ -0,0 +1,110 @@
package com.dangerousthings.aliro;
import javacard.framework.JCSystem;
import javacard.framework.Util;
import javacard.security.MessageDigest;
/**
* Userland HMAC-SHA-256 per RFC 2104, built on top of the JC
* {@link MessageDigest#ALG_SHA_256} primitive. Needed because the target card
* (NXP J3R180) does not expose {@code KeyBuilder.TYPE_HMAC} at any tested key
* size (512/256/160/128 bits) nor {@code Signature.ALG_HMAC_SHA_256}, while
* raw SHA-256 is present and functional.
*
* <p>Each {@link #compute} call reads a key and a message, zero-pads the key
* to the SHA-256 block size (64 bytes) — first hashing the key down with
* SHA-256 if it exceeds the block size — then computes:
* <pre>
* HMAC(K, M) = SHA-256((K_pad XOR opad) || SHA-256((K_pad XOR ipad) || M))
* </pre>
* with {@code ipad = 0x36 × 64} and {@code opad = 0x5C × 64}. Output is
* always 32 bytes.
*
* <p>Intended to be instantiated once per applet (or test) and reused — the
* underlying {@link MessageDigest} is expensive to allocate.
*/
final class AliroHmac {
private static final short BLOCK_LEN = 64; // SHA-256 block size
private static final short HASH_LEN = 32; // SHA-256 output size
private static final byte IPAD_BYTE = (byte) 0x36;
private static final byte OPAD_BYTE = (byte) 0x5C;
// Scratch layout: 64B pad block || 32B inner digest = 96B total.
private static final short OFF_PAD = 0;
private static final short OFF_INNER = 64;
private static final short SCRATCH_LEN = 96;
/** Persistent SHA-256 digest. Always reset before reuse — JC
* {@code MessageDigest} does not auto-reset after {@link MessageDigest#doFinal}. */
private final MessageDigest sha256;
/** Transient scratch — see OFF_* constants above. */
private final byte[] scratch;
AliroHmac() {
sha256 = MessageDigest.getInstance(MessageDigest.ALG_SHA_256, false);
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
}
/**
* HMAC-SHA-256(key, msg). Writes 32 bytes to {@code out[outOff..outOff+32)}
* and returns 32. The key is copied into internal scratch before the first
* hash, and the message is consumed during the inner SHA-256 pass before
* anything is written to {@code out}, so the {@code key}, {@code msg}, and
* {@code out} buffers may alias each other freely.
*/
short compute(
byte[] key, short keyOff, short keyLen,
byte[] msg, short msgOff, short msgLen,
byte[] out, short outOff) {
// --- Build K_pad into scratch[OFF_PAD..OFF_PAD+64) ---
// Zero the 64-byte pad region first so any unused key-tail bytes stay 0.
Util.arrayFillNonAtomic(scratch, OFF_PAD, BLOCK_LEN, (byte) 0);
if (keyLen > BLOCK_LEN) {
// K' = SHA-256(K), 32 bytes; the remaining 32 bytes stay zero.
sha256.reset();
sha256.doFinal(key, keyOff, keyLen, scratch, OFF_PAD);
} else if (keyLen > 0) {
Util.arrayCopyNonAtomic(key, keyOff, scratch, OFF_PAD, keyLen);
}
// else keyLen == 0: scratch[OFF_PAD..OFF_PAD+64) stays all zeros.
// --- Inner: SHA-256((K_pad XOR ipad) || M) into scratch[OFF_INNER..) ---
for (short i = 0; i < BLOCK_LEN; i++) {
scratch[(short) (OFF_PAD + i)] ^= IPAD_BYTE;
}
sha256.reset();
sha256.update(scratch, OFF_PAD, BLOCK_LEN);
if (msgLen > 0) {
sha256.doFinal(msg, msgOff, msgLen, scratch, OFF_INNER);
} else {
// doFinal with length 0 — pass the same buffer as a harmless source.
sha256.doFinal(scratch, OFF_PAD, (short) 0, scratch, OFF_INNER);
}
// --- Recover K_pad from (K_pad XOR ipad), then XOR opad. ---
// (K_pad XOR ipad) XOR (ipad XOR opad) = K_pad XOR opad, so one pass
// over the block suffices. ipad XOR opad = 0x36 XOR 0x5C = 0x6A.
for (short i = 0; i < BLOCK_LEN; i++) {
scratch[(short) (OFF_PAD + i)] ^= (byte) 0x6A;
}
// --- Outer: SHA-256((K_pad XOR opad) || inner) into out[outOff..) ---
sha256.reset();
sha256.update(scratch, OFF_PAD, BLOCK_LEN);
sha256.doFinal(scratch, OFF_INNER, HASH_LEN, out, outOff);
// Wipe scratch — it held K_pad (derived from the HMAC key) and the
// inner digest, both of which would leak key-dependent state to a
// later HMAC invocation on a different key otherwise. CLEAR_ON_DESELECT
// cleans up at deselect, but multi-APDU sessions could see stale
// state until then.
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
return HASH_LEN;
}
}

View File

@@ -0,0 +1,190 @@
package com.dangerousthings.aliro;
/**
* Shared, package-private provisioning store. Holds the Access Credential
* private key and (eventually) reader public keys. The
* {@link PersonalizationApplet} writes into it; {@link AliroApplet} reads
* from it when it needs long-term keys for AUTH1 / step-up.
*
* <p>Two applets in the same CAP file share this class's static state
* directly — no {@code Shareable} interface needed.
*
* <p>After {@link #commit()} is called, further writes via the
* personalization interface are refused. The only way to re-unlock is
* a factory reset, which is not yet implemented.
*/
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;
/** Max Access Document blob size (serialized COSE_Sign1 bytes). 1 KB
* accommodates a typical Aliro Access Document with room to spare. */
static final short ACCESS_DOC_MAX_LEN = 1024;
/** Lazily initialized in {@link #get()}. Java Card bans {@code new} in
* static initializers, so we can't declare {@code = new CredentialStore()}. */
private static CredentialStore INSTANCE;
private final byte[] credentialPrivKey;
private boolean credentialPrivKeySet;
private final byte[] credentialPubKey;
private boolean credentialPubKeySet;
private final byte[] readerPubKey;
private boolean readerPubKeySet;
private final byte[] accessDocument;
private short accessDocumentLen;
private boolean accessDocumentFinalized;
private boolean committed;
private CredentialStore() {
credentialPrivKey = new byte[CRED_PRIV_KEY_LEN];
credentialPubKey = new byte[CRED_PUBK_LEN];
readerPubKey = new byte[READER_PUBK_LEN];
accessDocument = new byte[ACCESS_DOC_MAX_LEN];
}
static CredentialStore get() {
if (INSTANCE == null) {
INSTANCE = new CredentialStore();
}
return INSTANCE;
}
boolean isCommitted() {
return committed;
}
void commit() {
committed = true;
}
/**
* Returns true iff a credential private key has been written. Callers
* that need the actual bytes should use {@link #copyCredentialPrivKey}.
*/
boolean hasCredentialPrivKey() {
return credentialPrivKeySet;
}
void setCredentialPrivKey(byte[] src, short off) {
javacard.framework.Util.arrayCopyNonAtomic(
src, off, credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN);
credentialPrivKeySet = true;
}
short copyCredentialPrivKey(byte[] dst, short dstOff) {
javacard.framework.Util.arrayCopyNonAtomic(
credentialPrivKey, (short) 0, dst, dstOff, CRED_PRIV_KEY_LEN);
return CRED_PRIV_KEY_LEN;
}
boolean hasCredentialPubKey() {
return credentialPubKeySet;
}
void setCredentialPubKey(byte[] src, short off) {
javacard.framework.Util.arrayCopyNonAtomic(
src, off, credentialPubKey, (short) 0, CRED_PUBK_LEN);
credentialPubKeySet = true;
}
short copyCredentialPubKey(byte[] dst, short dstOff) {
javacard.framework.Util.arrayCopyNonAtomic(
credentialPubKey, (short) 0, dst, dstOff, CRED_PUBK_LEN);
return CRED_PUBK_LEN;
}
/** Copies only the 32-byte x-coordinate of credential_PubK (spec uses x-only in salt_volatile). */
short copyCredentialPubKeyX(byte[] dst, short dstOff) {
javacard.framework.Util.arrayCopyNonAtomic(
credentialPubKey, (short) 0, dst, dstOff, (short) 32);
return (short) 32;
}
boolean hasReaderPubKey() {
return readerPubKeySet;
}
void setReaderPubKey(byte[] src, short off) {
javacard.framework.Util.arrayCopyNonAtomic(
src, off, readerPubKey, (short) 0, READER_PUBK_LEN);
readerPubKeySet = true;
}
short copyReaderPubKey(byte[] dst, short dstOff) {
javacard.framework.Util.arrayCopyNonAtomic(
readerPubKey, (short) 0, dst, dstOff, READER_PUBK_LEN);
return READER_PUBK_LEN;
}
/** Copies only the 32-byte x-coordinate of the reader_PubK. */
short copyReaderPubKeyX(byte[] dst, short dstOff) {
javacard.framework.Util.arrayCopyNonAtomic(
readerPubKey, (short) 0, dst, dstOff, (short) 32);
return (short) 32;
}
/**
* Stores an Access Document chunk at the given destination offset.
* Returns true iff the write stayed within {@link #ACCESS_DOC_MAX_LEN};
* otherwise the store is unchanged.
*/
boolean writeAccessDocumentChunk(byte[] src, short srcOff, short dstOff, short len) {
if ((short) (dstOff + len) > ACCESS_DOC_MAX_LEN || dstOff < 0 || len < 0) {
return false;
}
javacard.framework.Util.arrayCopyNonAtomic(src, srcOff, accessDocument, dstOff, len);
return true;
}
/**
* 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]}.
*/
boolean finalizeAccessDocument(short totalLen) {
if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) {
return false;
}
accessDocumentLen = totalLen;
accessDocumentFinalized = true;
return true;
}
boolean hasAccessDocument() {
return accessDocumentFinalized;
}
short getAccessDocumentLen() {
return accessDocumentLen;
}
short copyAccessDocument(byte[] dst, short dstOff, short srcOff, short len) {
javacard.framework.Util.arrayCopyNonAtomic(accessDocument, srcOff, dst, dstOff, len);
return len;
}
/**
* Test-only hook. The store is a static singleton, which makes unit
* tests interfere with each other unless each test starts from a
* known-blank slate. Package-private and named for clarity.
*/
void resetForTesting() {
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(accessDocument, (short) 0, ACCESS_DOC_MAX_LEN, (byte) 0);
credentialPrivKeySet = false;
credentialPubKeySet = false;
readerPubKeySet = false;
accessDocumentLen = 0;
accessDocumentFinalized = false;
committed = false;
}
}

View File

@@ -0,0 +1,148 @@
package com.dangerousthings.aliro;
import javacard.framework.APDU;
import javacard.framework.Applet;
import javacard.framework.ISO7816;
import javacard.framework.ISOException;
/**
* Receives Access Credential long-term private key, reader public keys,
* and (later) the pre-signed Access Document bytes. Accepts writes only
* until {@link CredentialStore#commit()}; afterwards every write returns
* SW_CONDITIONS_NOT_SATISFIED.
*
* <p>This applet lives under {@link AliroAids#PROVISIONING}, a
* DangerousThings-proprietary AID — it is not part of the Aliro spec
* surface.
*/
public class PersonalizationApplet extends Applet {
private static final byte CLA = (byte) 0x80;
private static final byte INS_SET_CREDENTIAL_PRIV_KEY = (byte) 0x20;
private static final byte INS_SET_CREDENTIAL_PUBK = (byte) 0x21;
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_COMMIT = (byte) 0x2C;
public static void install(byte[] bArray, short bOffset, byte bLength) {
PersonalizationApplet applet = new PersonalizationApplet();
if (bArray == null || bLength == 0) {
applet.register();
} else {
applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]);
}
}
@Override
public void process(APDU apdu) {
if (selectingApplet()) {
return;
}
byte[] buf = apdu.getBuffer();
if (buf[ISO7816.OFFSET_CLA] != CLA) {
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
}
CredentialStore store = CredentialStore.get();
byte ins = buf[ISO7816.OFFSET_INS];
switch (ins) {
case INS_SET_CREDENTIAL_PRIV_KEY:
requireUnlocked(store);
setCredentialPrivKey(apdu, store);
return;
case INS_SET_CREDENTIAL_PUBK:
requireUnlocked(store);
setCredentialPubKey(apdu, store);
return;
case INS_SET_READER_PUBK:
requireUnlocked(store);
setReaderPubKey(apdu, store);
return;
case INS_WRITE_ACCESS_DOC:
requireUnlocked(store);
writeAccessDocChunk(apdu, store);
return;
case INS_FINALIZE_ACCESS_DOC:
requireUnlocked(store);
finalizeAccessDoc(apdu, store);
return;
case INS_COMMIT:
requireUnlocked(store);
store.commit();
return;
default:
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
}
}
private static void requireUnlocked(CredentialStore store) {
if (store.isCommitted()) {
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
}
}
private void setCredentialPrivKey(APDU apdu, CredentialStore store) {
short lc = apdu.setIncomingAndReceive();
if (lc != CredentialStore.CRED_PRIV_KEY_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
byte[] buf = apdu.getBuffer();
store.setCredentialPrivKey(buf, apdu.getOffsetCdata());
}
private void setCredentialPubKey(APDU apdu, CredentialStore store) {
short lc = apdu.setIncomingAndReceive();
if (lc != CredentialStore.CRED_PUBK_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
byte[] buf = apdu.getBuffer();
store.setCredentialPubKey(buf, apdu.getOffsetCdata());
}
private void setReaderPubKey(APDU apdu, CredentialStore store) {
short lc = apdu.setIncomingAndReceive();
if (lc != CredentialStore.READER_PUBK_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
byte[] buf = apdu.getBuffer();
store.setReaderPubKey(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
* command data field is the chunk bytes.
*/
private void writeAccessDocChunk(APDU apdu, CredentialStore store) {
short lc = apdu.setIncomingAndReceive();
byte[] buf = apdu.getBuffer();
short dstOff = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
| (buf[ISO7816.OFFSET_P2] & 0xFF));
boolean ok = store.writeAccessDocumentChunk(
buf, apdu.getOffsetCdata(), dstOff, lc);
if (!ok) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
}
/**
* Marks the Access Document as provisioned. P1|P2 is the total length
* (big-endian unsigned 16-bit). Lc must be 0.
*/
private void finalizeAccessDoc(APDU apdu, CredentialStore store) {
short lc = apdu.setIncomingAndReceive();
if (lc != 0) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
byte[] buf = apdu.getBuffer();
short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
| (buf[ISO7816.OFFSET_P2] & 0xFF));
if (!store.finalizeAccessDocument(totalLen)) {
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
}
}
}

View File

@@ -0,0 +1,78 @@
package com.dangerousthings.aliro;
import javacard.framework.JCSystem;
import javacard.framework.Util;
/**
* Cross-applet session state that survives an AID change (EXPEDITED →
* STEP_UP) but not a card reset. Since Java Card installs distinct
* applet instances per AID, {@link AliroApplet} and {@link StepUpApplet}
* need a shared holder to hand off {@code StepUpSK}, the session-bound
* 32-byte key derived during AUTH1 and consumed in the step-up phase.
*
* <p>Storage is a {@link JCSystem#CLEAR_ON_RESET} transient array,
* lazily allocated on first access so that neither applet's constructor
* assumes a particular install order. The reference is a static field;
* both applets see the same underlying array.
*
* <p>State machine:
* <ul>
* <li>Initial / post-reset: disarmed, {@code StepUpSK} zeroed.
* <li>{@link AliroApplet} SELECT or failure path: calls
* {@link #disarmStepUp()} to clear any stale state.
* <li>{@link AliroApplet} AUTH1 success: calls
* {@link #armStepUp(byte[], short)} to load the freshly-derived
* StepUpSK and flip the armed flag.
* <li>{@link StepUpApplet}: reads via {@link #isStepUpArmed()} + (future)
* an internal {@code copyStepUpSK} call to derive session keys.
* </ul>
*/
final class SessionContext {
static final short STEP_UP_SK_LEN = 32;
private static byte[] stepUpSK;
private static boolean[] armedFlag; // length 1 — boolean transient arrays are per-JC convention
private SessionContext() {}
/** Allocates the transient storage on first call. Subsequent calls are no-ops. */
static void ensureInitialized() {
if (stepUpSK == null) {
stepUpSK = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_RESET);
armedFlag = JCSystem.makeTransientBooleanArray((short) 1, JCSystem.CLEAR_ON_RESET);
}
}
static boolean isStepUpArmed() {
return armedFlag != null && armedFlag[0];
}
/** Loads {@code StepUpSK} from {@code src[srcOff..srcOff+32)} and arms the context. */
static void armStepUp(byte[] src, short srcOff) {
ensureInitialized();
Util.arrayCopyNonAtomic(src, srcOff, stepUpSK, (short) 0, STEP_UP_SK_LEN);
armedFlag[0] = true;
}
/** Zeroes {@code StepUpSK} and flips the armed flag off. */
static void disarmStepUp() {
if (stepUpSK != null) {
Util.arrayFillNonAtomic(stepUpSK, (short) 0, STEP_UP_SK_LEN, (byte) 0);
}
if (armedFlag != null) {
armedFlag[0] = false;
}
}
/** Copies the armed StepUpSK into {@code dst[dstOff..]}. Only legal if {@link #isStepUpArmed()}. */
static short copyStepUpSK(byte[] dst, short dstOff) {
Util.arrayCopyNonAtomic(stepUpSK, (short) 0, dst, dstOff, STEP_UP_SK_LEN);
return STEP_UP_SK_LEN;
}
/** Test-only accessor that skips the armed-flag precondition. */
static short copyStepUpSKForTesting(byte[] dst, short dstOff) {
return copyStepUpSK(dst, dstOff);
}
}

View File

@@ -0,0 +1,86 @@
package com.dangerousthings.aliro;
import javacard.framework.APDU;
import javacard.framework.Applet;
import javacard.framework.ISO7816;
import javacard.framework.ISOException;
import javacard.framework.Util;
/**
* Scaffold for the Aliro step-up phase (spec §8.4). Lives under
* {@link AliroAids#STEP_UP}.
*
* <p>This first pass only answers SELECT with a minimal FCI and rejects
* unknown commands cleanly. The actual step-up protocol — mdoc
* SessionData framing (DeviceRequest/DeviceResponse CBOR with
* Aliro-remapped integer keys per Tables 8-21/8-22), ENVELOPE/GET
* RESPONSE command chaining, and a fresh AES-256-GCM session keyed by
* StepUpSKDevice/StepUpSKReader derived per spec §8.4.3 — comes in
* follow-up iterations.
*
* <p>When that work lands, this applet will need access to the
* {@code StepUpSK} (a 32-byte session-bound value produced by
* {@link AliroApplet}'s derivation in §8.3.1.13). Since Java Card
* installs distinct instances per AID, that state will be exchanged via
* a shared package-private holder (not yet implemented).
*/
public class StepUpApplet extends Applet {
private static final byte CLA_PROPRIETARY = (byte) 0x80;
public static void install(byte[] bArray, short bOffset, byte bLength) {
StepUpApplet applet = new StepUpApplet();
if (bArray == null || bLength == 0) {
applet.register();
} else {
applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]);
}
}
@Override
public boolean select() {
return true;
}
@Override
public void process(APDU apdu) {
if (selectingApplet()) {
sendStepUpFci(apdu);
return;
}
byte[] buf = apdu.getBuffer();
if (buf[ISO7816.OFFSET_CLA] != CLA_PROPRIETARY) {
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
}
// Until the mdoc DeviceRequest pipeline lands, every proprietary INS
// is unrecognised. ENVELOPE + GET RESPONSE handlers plug in here.
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
}
/**
* Minimal FCI for step-up SELECT. The spec (§10.2.1.2) allows the UD to
* advertise supported APDU command/response sizes here; those get added
* when we implement ENVELOPE/GET RESPONSE.
*
* <pre>
* 6F L
* 84 09 [step-up AID]
* </pre>
*/
private void sendStepUpFci(APDU apdu) {
byte[] buf = apdu.getBuffer();
short off = 0;
buf[off++] = 0x6F;
short outerLenPos = off++;
buf[off++] = (byte) 0x84;
buf[off++] = (byte) AliroAids.STEP_UP.length;
off = Util.arrayCopyNonAtomic(AliroAids.STEP_UP, (short) 0,
buf, off, (short) AliroAids.STEP_UP.length);
buf[outerLenPos] = (byte) (off - 2);
apdu.setOutgoingAndSend((short) 0, off);
}
}

View File

@@ -0,0 +1,181 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.interfaces.ECPublicKey;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* AUTH0 happy-path tests for the Aliro expedited-standard phase (spec &sect;8.3.3.2).
*/
class AliroAppletAuth0Test {
private CardSimulator sim;
private SecureRandom rng;
@BeforeEach
void setUp() {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
ResponseAPDU selectResp = sim.transmitCommand(select);
assertEquals(0x9000, selectResp.getSW(), "SELECT must succeed before AUTH0");
rng = new SecureRandom();
}
private ResponseAPDU sendStandardAuth0() {
KeyPair readerEphem = Auth0Command.generateEphemeralKeyPair();
byte[] readerPub = Auth0Command.encodeUncompressed((ECPublicKey) readerEphem.getPublic());
byte[] groupId = new byte[16];
byte[] subId = new byte[16];
byte[] txnId = new byte[16];
rng.nextBytes(groupId);
rng.nextBytes(subId);
rng.nextBytes(txnId);
byte[] data = Auth0Command.buildStandardData(readerPub, groupId, subId, txnId);
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF,
Auth0Command.INS & 0xFF,
0x00, 0x00, data, 256);
return sim.transmitCommand(auth0);
}
@Test
void auth0StandardReturns9000() {
assertEquals(0x9000, sendStandardAuth0().getSW(),
"AUTH0 with a well-formed standard-phase command must succeed");
}
@Test
void auth0ResponseContainsCredentialEphemeralPubKeyTag() {
byte[] data = sendStandardAuth0().getData();
byte[] credEphemPub = TlvUtil.findTopLevel(data, 0x86);
assertNotNull(credEphemPub, "AUTH0 response must contain tag 0x86 (credential_ePubK)");
assertEquals(65, credEphemPub.length, "credential_ePubK must be 65 bytes (0x04 || x || y)");
assertEquals(0x04, credEphemPub[0] & 0xFF,
"credential_ePubK must start with 0x04 (uncompressed point)");
}
@Test
void credentialEphemeralKeyIsFreshPerTransaction() {
byte[] first = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
byte[] second = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
assertNotNull(first);
assertNotNull(second);
assertNotEquals(
bytesToHex(first),
bytesToHex(second),
"each AUTH0 must produce a fresh ephemeral keypair");
}
@Test
void credentialEphemeralKeyIsNotAllZero() {
byte[] credPub = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
boolean allZero = true;
for (int i = 1; i < credPub.length; i++) {
if (credPub[i] != 0) { allZero = false; break; }
}
assertFalse(allZero, "credential_ePubK coordinates must not be all-zero");
}
@Test
void auth0WithEmptyDataFieldFails() {
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, new byte[0], 256);
ResponseAPDU resp = sim.transmitCommand(auth0);
assertNotEquals(0x9000, resp.getSW(),
"AUTH0 with empty data must not succeed");
}
@Test
void auth0MissingReaderEphemPubKeyTagFails() {
byte[] data = validAuth0DataWithMutation(bytes -> {
// Null out the 0x87 tag byte so the parser sees a bogus tag where reader_ePubK should be.
int idx = findTagOffset(bytes, 0x87);
bytes[idx] = (byte) 0x00;
return bytes;
});
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
assertNotEquals(0x9000, sim.transmitCommand(auth0).getSW(),
"AUTH0 without tag 0x87 (reader_ePubK) must fail");
}
@Test
void auth0WithUnsupportedProtocolVersionFails() {
byte[] data = validAuth0DataWithMutation(bytes -> {
int idx = findTagOffset(bytes, 0x5C);
bytes[idx + 2] = (byte) 0x00;
bytes[idx + 3] = (byte) 0x99;
return bytes;
});
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
assertNotEquals(0x9000, sim.transmitCommand(auth0).getSW(),
"AUTH0 with protocol version != 0x0100 must fail");
}
@Test
void auth0WithUnknownTrailingTagIsAccepted() {
// Spec §1.4.3: unknown DER-TLVs SHALL be accepted by the receiver.
byte[] base = validAuth0Data();
byte[] withUnknown = new byte[base.length + 3];
System.arraycopy(base, 0, withUnknown, 0, base.length);
withUnknown[base.length] = (byte) 0x7F;
withUnknown[base.length + 1] = (byte) 0x01;
withUnknown[base.length + 2] = (byte) 0xFF;
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, withUnknown, 256);
assertEquals(0x9000, sim.transmitCommand(auth0).getSW(),
"unknown trailing TLVs must be accepted (spec §1.4.3)");
}
private byte[] validAuth0Data() {
KeyPair readerEphem = Auth0Command.generateEphemeralKeyPair();
byte[] readerPub = Auth0Command.encodeUncompressed((ECPublicKey) readerEphem.getPublic());
byte[] groupId = new byte[16];
byte[] subId = new byte[16];
byte[] txnId = new byte[16];
rng.nextBytes(groupId);
rng.nextBytes(subId);
rng.nextBytes(txnId);
return Auth0Command.buildStandardData(readerPub, groupId, subId, txnId);
}
private byte[] validAuth0DataWithMutation(java.util.function.Function<byte[], byte[]> mut) {
return mut.apply(validAuth0Data());
}
private static int findTagOffset(byte[] data, int tag) {
int i = 0;
while (i < data.length) {
int t = data[i] & 0xFF;
int len = data[i + 1] & 0xFF;
if (t == tag) return i;
i += 2 + len;
}
throw new AssertionError("tag 0x" + Integer.toHexString(tag) + " not found");
}
private static String bytesToHex(byte[] b) {
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
}

View File

@@ -0,0 +1,393 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.interfaces.ECPublicKey;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/**
* AUTH1 tests. Covers state-machine sequencing, input TLV validation, and
* provisioning preconditions. Real cryptographic verification of the
* reader signature and construction of the encrypted response come in
* later iterations.
*/
class AliroAppletAuth1Test {
private static final byte INS_AUTH1 = (byte) 0x81;
private CardSimulator sim;
private KeyPair credentialKeyPair;
private ReaderSide reader;
private byte[] credentialEphemPubKey; // captured from the last AUTH0 response
@BeforeEach
void setUp() {
CredentialStore.get().resetForTesting();
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
credentialKeyPair = Auth0Command.generateEphemeralKeyPair();
reader = new ReaderSide();
reader.provision(sim, credentialKeyPair);
selectExpedited();
}
private void selectExpedited() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
ResponseAPDU r = sim.transmitCommand(select);
assertEquals(0x9000, r.getSW(), "SELECT must succeed");
}
/** Runs a standard-phase AUTH0 and captures the returned credential_ePubK for later AUTH1 use. */
private void sendStandardAuth0() {
reader.startTransaction();
byte[] data = reader.buildAuth0Data();
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
ResponseAPDU r = sim.transmitCommand(auth0);
assertEquals(0x9000, r.getSW(), "AUTH0 must succeed as a precondition for AUTH1 tests");
credentialEphemPubKey = TlvUtil.findTopLevel(r.getData(), 0x86);
}
/** AUTH1 command with a correctly-signed reader signature. */
private ResponseAPDU sendValidAuth1() {
return sendAuth1(reader.buildAuth1Data(credentialEphemPubKey));
}
/** Syntactically-valid AUTH1 payload with zeroed signature (for TLV-only tests). */
private ResponseAPDU sendStubAuth1() {
byte[] data = new byte[3 + 2 + 64];
int i = 0;
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
data[i++] = (byte) 0x9E; data[i++] = 0x40;
return sendAuth1(data);
}
private ResponseAPDU sendAuth1(byte[] data) {
CommandAPDU auth1 = new CommandAPDU(
Auth0Command.CLA & 0xFF, INS_AUTH1 & 0xFF, 0x00, 0x00, data, 256);
return sim.transmitCommand(auth1);
}
@Test
void auth1WithoutPriorAuth0IsRejected() {
ResponseAPDU r = sendStubAuth1();
assertNotEquals(0x9000, r.getSW(),
"AUTH1 must fail unless immediately preceded by a successful AUTH0");
assertEquals(0x6985, r.getSW(),
"expected SW_CONDITIONS_NOT_SATISFIED (0x6985) when AUTH0 not done");
}
@Test
void auth1WithValidReaderSignatureIsAccepted() {
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW(),
"AUTH1 with a correctly-signed reader signature must succeed");
}
@Test
void auth1ReplayWithoutFreshAuth0IsRejected() {
sendStandardAuth0();
ResponseAPDU first = sendValidAuth1();
assertEquals(0x9000, first.getSW());
// Second AUTH1 without a new AUTH0 must fail — otherwise the card would
// re-encrypt with device_counter=1 under the same key, reusing the GCM
// IV and catastrophically breaking confidentiality + integrity.
ResponseAPDU replay = sendValidAuth1();
assertEquals(0x6985, replay.getSW(),
"AUTH1 after a prior successful AUTH1 must require a fresh AUTH0");
}
@Test
void auth1SuccessArmsStepUpContextWithExpectedStepUpSK() throws Exception {
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
// Reader derives the full expedited-standard 160B key material and
// extracts StepUpSK (offset 64..96, per spec §8.3.1.13).
byte[] expected = java.util.Arrays.copyOfRange(
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed(),
"successful AUTH1 must arm the step-up context");
byte[] got = new byte[32];
SessionContext.copyStepUpSKForTesting(got, (short) 0);
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, got,
"armed StepUpSK must equal derived_keys_volatile[64..96]");
}
@Test
void freshAuth0DisarmsPreviousStepUpArming() throws Exception {
// First transaction arms the step-up context.
sendStandardAuth0();
assertEquals(0x9000, sendValidAuth1().getSW());
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed());
// Starting a new transaction via AUTH0 (without re-SELECT) must
// invalidate the prior arming — otherwise a mid-transaction failure
// could leave stale StepUpSK reachable by a subsequent SELECT STEP_UP.
reader.startTransaction();
byte[] data = reader.buildAuth0Data();
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
assertEquals(0x9000, sim.transmitCommand(auth0).getSW());
org.junit.jupiter.api.Assertions.assertFalse(SessionContext.isStepUpArmed(),
"starting a new AUTH0 must disarm any prior step-up state");
}
@Test
void reSelectExpeditedDisarmsStepUpContext() throws Exception {
sendStandardAuth0();
assertEquals(0x9000, sendValidAuth1().getSW());
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed());
selectExpedited();
org.junit.jupiter.api.Assertions.assertFalse(SessionContext.isStepUpArmed(),
"re-SELECT of expedited AID starts a new transaction and must disarm step-up");
}
@Test
void auth1SignalingBitmapIsAllZeroWhenNoAccessDocProvisioned() throws Exception {
// Default setUp provisions keys but no Access Document.
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, 0x00 }, bitmap,
"no Access Document → signaling_bitmap is 0x0000");
}
@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.
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));
org.junit.jupiter.api.Assertions.assertTrue(
CredentialStore.get().finalizeAccessDocument((short) ad.length));
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E);
// bit 0 (access doc retrievable) + bit 2 (requires step-up AID on NFC)
// = 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)");
}
@Test
void auth1WithKeySlotRequestReturnsSha1KeyIdentifier() throws Exception {
sendStandardAuth0();
// Build a valid AUTH1 payload, then flip command_parameters bit 0 to 0
// (key_slot path). The reader sig is computed over Table 8-12, which
// doesn't include command_parameters, so the sig stays valid.
byte[] data = reader.buildAuth1Data(credentialEphemPubKey);
data[2] = 0x00;
ResponseAPDU r = sendAuth1(data);
assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
byte[] keySlot = TlvUtil.findTopLevel(pt, 0x4E);
byte[] credPubTag = TlvUtil.findTopLevel(pt, 0x5A);
org.junit.jupiter.api.Assertions.assertNotNull(keySlot, "0x4E key_slot must be present when cmd_params bit 0 = 0");
org.junit.jupiter.api.Assertions.assertNull(credPubTag, "0x5A credential_PubK must NOT be present when cmd_params bit 0 = 0");
assertEquals(8, keySlot.length, "key_slot is the first 8 bytes of keyIdentifier");
// Expected: first 8 bytes of SHA-1(uncompressed credential long-term pubkey)
java.security.MessageDigest sha1 = java.security.MessageDigest.getInstance("SHA-1");
byte[] credPubUncomp = new byte[65];
credPubUncomp[0] = 0x04;
byte[] x = toFixed32(credentialKeyPair.getPublic() instanceof java.security.interfaces.ECPublicKey
? ((java.security.interfaces.ECPublicKey) credentialKeyPair.getPublic()).getW().getAffineX().toByteArray()
: new byte[0]);
byte[] y = toFixed32(((java.security.interfaces.ECPublicKey) credentialKeyPair.getPublic())
.getW().getAffineY().toByteArray());
System.arraycopy(x, 0, credPubUncomp, 1, 32);
System.arraycopy(y, 0, credPubUncomp, 33, 32);
byte[] expected = java.util.Arrays.copyOf(sha1.digest(credPubUncomp), 8);
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, keySlot,
"key_slot must be SHA-1(uncompressed credential_PubK)[0:8]");
}
private static byte[] toFixed32(byte[] in) {
if (in.length == 32) return in;
byte[] out = new byte[32];
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
return out;
}
@Test
void distinctTransactionsProduceDistinctCiphertexts() throws Exception {
sendStandardAuth0();
ResponseAPDU r1 = sendValidAuth1();
assertEquals(0x9000, r1.getSW());
byte[] ct1 = r1.getData();
byte[] plaintext1 = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), ct1);
// Second full transaction: fresh AUTH0 → fresh ephemerals + txn_id.
sendStandardAuth0();
ResponseAPDU r2 = sendValidAuth1();
assertEquals(0x9000, r2.getSW());
byte[] ct2 = r2.getData();
byte[] plaintext2 = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), ct2);
org.junit.jupiter.api.Assertions.assertFalse(
java.util.Arrays.equals(ct1, ct2),
"two transactions must produce different ciphertexts — catches hardcoded salt/IV/key bugs");
// Plaintexts also differ (UD sig is over Table 8-13 which includes txn_id).
byte[] sig1 = TlvUtil.findTopLevel(plaintext1, 0x9E);
byte[] sig2 = TlvUtil.findTopLevel(plaintext2, 0x9E);
org.junit.jupiter.api.Assertions.assertFalse(
java.util.Arrays.equals(sig1, sig2),
"UD signatures over Table 8-13 must differ across transactions");
}
@Test
void auth1EncryptedResponseDecryptsAndVerifies() throws Exception {
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
byte[] ctAndTag = r.getData();
byte[] skDevice = reader.deriveExpeditedSKDevice(credentialEphemPubKey);
byte[] plaintext = ReaderSide.decryptAuth1Response(skDevice, ctAndTag);
byte[] credPub = TlvUtil.findTopLevel(plaintext, 0x5A);
byte[] udSig = TlvUtil.findTopLevel(plaintext, 0x9E);
byte[] bitmap = TlvUtil.findTopLevel(plaintext, 0x5E);
org.junit.jupiter.api.Assertions.assertNotNull(credPub, "Table 8-11 must include 0x5A (credential_PubK)");
org.junit.jupiter.api.Assertions.assertNotNull(udSig, "Table 8-11 must include 0x9E (UD signature)");
org.junit.jupiter.api.Assertions.assertNotNull(bitmap, "Table 8-11 must include 0x5E (signaling_bitmap)");
assertEquals(65, credPub.length);
assertEquals(64, udSig.length);
assertEquals(2, bitmap.length);
byte[] table813 = reader.buildTable813(credentialEphemPubKey);
org.junit.jupiter.api.Assertions.assertTrue(
ReaderSide.verifyUdSig(credPub, table813, udSig),
"UD signature in the decrypted Table 8-11 must verify under credential_PubK over Table 8-13");
}
@Test
void auth1WithInvalidReaderSignatureFails() {
sendStandardAuth0();
// Valid TLV structure but a random (definitely-not-matching) sig.
byte[] data = new byte[3 + 2 + 64];
int i = 0;
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
data[i++] = (byte) 0x9E; data[i++] = 0x40;
new SecureRandom().nextBytes(java.util.Arrays.copyOfRange(data, i, i + 64));
// Force at least one non-zero byte so the sig isn't accidentally valid.
data[i] = 0x11;
data[i + 1] = 0x22;
ResponseAPDU r = sendAuth1(data);
assertNotEquals(0x9000, r.getSW(),
"AUTH1 with a bad reader signature must not return success");
}
@Test
void reSelectClearsAuth0State() {
sendStandardAuth0();
selectExpedited();
ResponseAPDU r = sendStubAuth1();
assertEquals(0x6985, r.getSW(),
"re-SELECT must invalidate previous AUTH0 state");
}
@Test
void auth1MissingCommandParametersFails() {
sendStandardAuth0();
byte[] data = new byte[2 + 64];
data[0] = (byte) 0x9E; data[1] = 0x40;
assertEquals(0x6A80, sendAuth1(data).getSW(),
"missing tag 0x41 must be rejected with SW_WRONG_DATA");
}
@Test
void auth1MissingReaderSignatureFails() {
sendStandardAuth0();
byte[] data = new byte[] { 0x41, 0x01, 0x00 };
assertEquals(0x6A80, sendAuth1(data).getSW(),
"missing tag 0x9E (reader signature) must be rejected");
}
@Test
void auth1WithWrongReaderSignatureLengthFails() {
sendStandardAuth0();
byte[] data = new byte[3 + 2 + 63];
int i = 0;
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
data[i++] = (byte) 0x9E; data[i++] = 0x3F;
assertEquals(0x6A80, sendAuth1(data).getSW(),
"reader signature length != 64 must be rejected");
}
@Test
void auth1WithReaderCertIsRejectedInV1() {
sendStandardAuth0();
byte[] data = new byte[3 + 2 + 64 + 2 + 1];
int i = 0;
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
data[i++] = (byte) 0x9E; data[i++] = 0x40;
i += 64;
data[i++] = (byte) 0x90; data[i++] = 0x01; data[i++] = (byte) 0xFF;
assertEquals(0x6A80, sendAuth1(data).getSW(),
"reader_Cert (tag 0x90) is unsupported in v1 and must be rejected");
}
@Test
void auth1WithUnknownTrailingTagIsAccepted() {
sendStandardAuth0();
// Build a real-sig AUTH1 payload, then append an unknown TLV.
byte[] valid = reader.buildAuth1Data(credentialEphemPubKey);
byte[] data = new byte[valid.length + 3];
System.arraycopy(valid, 0, data, 0, valid.length);
int i = valid.length;
data[i++] = (byte) 0x7F; data[i++] = 0x01; data[i++] = (byte) 0xAB;
assertEquals(0x9000, sendAuth1(data).getSW(),
"unknown trailing TLVs must be accepted (spec §1.4.3)");
}
@Test
void auth1WithoutProvisionedKeysFails() {
CredentialStore.get().resetForTesting();
selectExpedited();
sendStandardAuth0();
ResponseAPDU r = sendStubAuth1();
assertEquals(0x6985, r.getSW(),
"AUTH1 must fail if CredentialStore was not provisioned (6985)");
}
}

View File

@@ -0,0 +1,95 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests covering the Aliro Expedited Phase SELECT handler, per spec &sect;10.2.1.
*/
class AliroAppletTest {
private CardSimulator sim;
@BeforeEach
void setUp() {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
}
private ResponseAPDU selectExpedited() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
return sim.transmitCommand(select);
}
@Test
void selectExpeditedAidReturns9000() {
assertEquals(0x9000, selectExpedited().getSW(),
"SELECT expedited AID must return success SW");
}
@Test
void selectExpeditedReturnsNonEmptyFci() {
byte[] data = selectExpedited().getData();
assertTrue(data.length > 0, "SELECT response data must be non-empty (FCI template)");
}
@Test
void selectExpeditedResponseIsFciTemplate() {
byte[] data = selectExpedited().getData();
assertEquals(0x6F, data[0] & 0xFF, "top-level tag must be 0x6F (FCI template)");
}
@Test
void fciContainsAidTagMatchingExpeditedAid() {
byte[] data = selectExpedited().getData();
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
assertNotNull(fci, "must contain FCI template");
byte[] aid = TlvUtil.findTopLevel(fci, 0x84);
assertNotNull(aid, "FCI must contain AID tag 0x84");
assertArrayEquals(AliroAids.EXPEDITED, aid, "AID tag value must equal expedited AID");
}
@Test
void fciContainsProprietaryInfoTagA5() {
byte[] data = selectExpedited().getData();
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
assertNotNull(propInfo, "FCI must contain Proprietary Information tag 0xA5");
}
@Test
void proprietaryInfoContainsCsaApplicationType() {
byte[] data = selectExpedited().getData();
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
byte[] type = TlvUtil.findTopLevel(propInfo, 0x80);
assertNotNull(type, "Proprietary Information must contain Type tag 0x80");
assertArrayEquals(new byte[] { 0x00, 0x00 }, type, "Type must be 0x0000 (CSA application)");
}
@Test
void proprietaryInfoContainsExpeditedProtocolVersion() {
byte[] data = selectExpedited().getData();
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
byte[] versions = TlvUtil.findTopLevel(propInfo, 0x5C);
assertNotNull(versions, "expedited SELECT must include protocol versions tag 0x5C");
assertTrue(versions.length >= 2 && versions.length % 2 == 0,
"protocol versions must be a non-empty list of 2-byte entries");
assertEquals(0x01, versions[0] & 0xFF, "expected first protocol version high byte 0x01");
assertEquals(0x00, versions[1] & 0xFF, "expected first protocol version low byte 0x00");
}
}

View File

@@ -0,0 +1,470 @@
package com.dangerousthings.aliro;
import javacard.security.AESKey;
import javacard.security.ECPrivateKey;
import javacard.security.ECPublicKey;
import javacard.security.KeyBuilder;
import javacard.security.KeyPair;
import javacard.security.Signature;
import javacardx.crypto.AEADCipher;
import javacardx.crypto.Cipher;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Direct tests for {@link AliroCrypto} primitives, exercising them outside
* the applet's APDU dispatch.
*/
class AliroCryptoTest {
/** Pulled from RFC 5869 Test Case 1 (HKDF-SHA-256). */
private static final byte[] RFC5869_T1_IKM = hex(
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
private static final byte[] RFC5869_T1_SALT = hex(
"000102030405060708090a0b0c");
private static final byte[] RFC5869_T1_INFO = hex(
"f0f1f2f3f4f5f6f7f8f9");
private static final byte[] RFC5869_T1_PRK = hex(
"077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5");
private static final byte[] RFC5869_T1_OKM_42 = hex(
"3cb25f25faacd57a90434f64d0362f2a"
+ "2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
+ "34007208d5b887185865");
/** RFC 5869 Test Case 3 — empty salt, empty info. Guard for the
* Aliro step-up path (§8.4.3) which specifies an empty salt. */
private static final byte[] RFC5869_T3_IKM = hex(
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
private static final byte[] RFC5869_T3_PRK = hex(
"19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04");
private static final byte[] RFC5869_T3_OKM_42 = hex(
"8da4e775a563c18f715f802a063c5a31"
+ "b8a11f5c5ee1879ec3454e5f3c738d2d"
+ "9d201395faa4b61a96c8");
@Test
void ecdhIsCommutative() {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
KeyPair kp2 = freshP256();
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
byte[] x1 = new byte[32];
crypto.computeEcdhSharedX(
(ECPrivateKey) kp1.getPrivate(),
pub2, (short) 0,
x1, (short) 0);
byte[] x2 = new byte[32];
crypto.computeEcdhSharedX(
(ECPrivateKey) kp2.getPrivate(),
pub1, (short) 0,
x2, (short) 0);
assertArrayEquals(x1, x2,
"ECDH(priv1, pub2) must equal ECDH(priv2, pub1)");
}
@Test
void hkdfExtractRfc5869TestCase1() {
AliroCrypto crypto = new AliroCrypto();
byte[] prk = new byte[32];
short prkLen = crypto.hkdfExtract(
RFC5869_T1_SALT, (short) 0, (short) RFC5869_T1_SALT.length,
RFC5869_T1_IKM, (short) 0, (short) RFC5869_T1_IKM.length,
prk, (short) 0);
assertEquals(32, prkLen, "HKDF-Extract output is HashLen (32) bytes");
assertArrayEquals(RFC5869_T1_PRK, prk, "matches RFC 5869 Test Case 1 PRK");
}
@Test
void hkdfWithEmptySaltAndInfoMatchesRfc5869TestCase3() {
AliroCrypto crypto = new AliroCrypto();
byte[] prk = new byte[32];
crypto.hkdfExtract(
new byte[0], (short) 0, (short) 0,
RFC5869_T3_IKM, (short) 0, (short) RFC5869_T3_IKM.length,
prk, (short) 0);
assertArrayEquals(RFC5869_T3_PRK, prk,
"HKDF-Extract with empty salt (per RFC 5869 §2.2: key becomes HashLen zeros)");
byte[] okm = new byte[42];
crypto.hkdfExpand(
prk, (short) 0, (short) 32,
new byte[0], (short) 0, (short) 0,
(short) 42,
okm, (short) 0);
assertArrayEquals(RFC5869_T3_OKM_42, okm,
"HKDF-Expand with empty info");
}
/**
* Step-up session keys per Aliro §8.4.3 + mdoc 9.1.1.5: derive
* StepUpSKDevice and StepUpSKReader from StepUpSK via HKDF-SHA-256 with
* empty salt and info = "SKDevice" or "SKReader". This test drives
* {@link AliroCrypto#deriveStepUpSessionKeys} against the equivalent
* manual HKDF chain.
*/
@Test
void deriveStepUpSessionKeysMatchesManualHkdf() {
AliroCrypto crypto = new AliroCrypto();
byte[] stepUpSK = new byte[32];
for (int i = 0; i < 32; i++) stepUpSK[i] = (byte) (0xC0 + i);
byte[] skDevice = new byte[32];
byte[] skReader = new byte[32];
crypto.deriveStepUpSessionKeys(stepUpSK, (short) 0,
skDevice, (short) 0, skReader, (short) 0);
org.junit.jupiter.api.Assertions.assertFalse(
java.util.Arrays.equals(skDevice, skReader),
"SKDevice and SKReader must differ");
org.junit.jupiter.api.Assertions.assertFalse(
java.util.Arrays.equals(skDevice, stepUpSK),
"SKDevice must not equal the IKM");
// Manual: HKDF(ikm=StepUpSK, salt=empty, info="SKDevice"/"SKReader", L=32)
byte[] manualDevice = manualHkdf(stepUpSK, new byte[0], "SKDevice".getBytes(), 32);
byte[] manualReader = manualHkdf(stepUpSK, new byte[0], "SKReader".getBytes(), 32);
assertArrayEquals(manualDevice, skDevice,
"StepUpSKDevice must equal HKDF(ikm=StepUpSK, salt=∅, info=\"SKDevice\", L=32)");
assertArrayEquals(manualReader, skReader,
"StepUpSKReader must equal HKDF(ikm=StepUpSK, salt=∅, info=\"SKReader\", L=32)");
}
private static byte[] manualHkdf(byte[] ikm, byte[] salt, byte[] info, int L) {
try {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
byte[] useSalt = salt.length == 0 ? new byte[32] : salt;
mac.init(new javax.crypto.spec.SecretKeySpec(useSalt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
byte[] out = new byte[L];
byte[] t = new byte[0];
int produced = 0;
byte counter = 1;
while (produced < L) {
mac.update(t);
mac.update(info);
mac.update(counter);
t = mac.doFinal();
int copy = Math.min(L - produced, t.length);
System.arraycopy(t, 0, out, produced, copy);
produced += copy;
counter++;
mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
}
return out;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Test
void hkdfExpandRfc5869TestCase1() {
AliroCrypto crypto = new AliroCrypto();
byte[] okm = new byte[42];
short okmLen = crypto.hkdfExpand(
RFC5869_T1_PRK, (short) 0, (short) RFC5869_T1_PRK.length,
RFC5869_T1_INFO, (short) 0, (short) RFC5869_T1_INFO.length,
(short) 42,
okm, (short) 0);
assertEquals(42, okmLen);
assertArrayEquals(RFC5869_T1_OKM_42, okm, "matches RFC 5869 Test Case 1 OKM");
}
/**
* Regression guard for the upstream licel/jcardsim ByteContainer.setBytes
* bug: without the ByteContainer patch in scripts/jcardsim-patches/,
* reusing an HMACKey across setKey calls of different lengths corrupts
* the internal data array and throws AIOOBE on the second arrayCopy.
* This exact shape — setKey(13), sign, setKey(32) — is what HKDF-Extract
* followed by HKDF-Expand does on the same key.
*/
@Test
void hmacKeyReuseAcrossDifferentKeyLengths() {
javacard.security.HMACKey k = (javacard.security.HMACKey)
javacard.security.KeyBuilder.buildKey(
javacard.security.KeyBuilder.TYPE_HMAC, (short) 512, false);
javacard.security.Signature h = javacard.security.Signature.getInstance(
javacard.security.Signature.ALG_HMAC_SHA_256, false);
k.setKey(RFC5869_T1_SALT, (short) 0, (short) RFC5869_T1_SALT.length);
h.init(k, javacard.security.Signature.MODE_SIGN);
byte[] prk = new byte[32];
h.sign(RFC5869_T1_IKM, (short) 0, (short) RFC5869_T1_IKM.length, prk, (short) 0);
byte[] wide = new byte[64];
System.arraycopy(RFC5869_T1_PRK, 0, wide, 32, 32);
k.setKey(wide, (short) 32, (short) 32);
}
/**
* Kdh is the session-key seed from Aliro §8.3.1.4. The spec note says
* the procedure in §8.3.1.5 (HKDF-SHA-256) supersedes the X9.63 KDF in
* §8.3.1.4 — concretely, Kdh = HKDF(IKM=ECDH_x(ePriv, peerEPub),
* salt=transaction_identifier, info=∅, L=32). This test checks that
* deriveKdh produces exactly what the manual HKDF chain produces.
*/
@Test
void deriveKdhMatchesManualHkdfChain() {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
KeyPair kp2 = freshP256();
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
byte[] txnId = new byte[16];
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x10 + i);
byte[] kdh = new byte[32];
crypto.deriveKdh(
(ECPrivateKey) kp1.getPrivate(),
pub2, (short) 0,
txnId, (short) 0, (short) txnId.length,
kdh, (short) 0);
byte[] zab = new byte[32];
crypto.computeEcdhSharedX(
(ECPrivateKey) kp1.getPrivate(),
pub2, (short) 0,
zab, (short) 0);
byte[] prk = new byte[32];
crypto.hkdfExtract(
txnId, (short) 0, (short) txnId.length,
zab, (short) 0, (short) 32,
prk, (short) 0);
byte[] okm = new byte[32];
crypto.hkdfExpand(
prk, (short) 0, (short) 32,
new byte[0], (short) 0, (short) 0,
(short) 32,
okm, (short) 0);
assertArrayEquals(okm, kdh,
"deriveKdh must equal HKDF(IKM=ECDH_x, salt=txnId, info=empty, L=32)");
}
/**
* §8.3.1.13 expedited-standard key material derivation is just
* HKDF(IKM=Kdh, salt=salt_volatile, info=info, L=160). This test checks
* the wrapper produces the same bytes as the explicit Extract→Expand
* chain and that both transaction sides agree.
*/
@Test
void deriveExpeditedStandardKeysMatchesManualHkdf() {
AliroCrypto crypto = new AliroCrypto();
byte[] kdh = new byte[32];
for (int i = 0; i < 32; i++) kdh[i] = (byte) (0xA0 + i);
byte[] salt = new byte[40];
for (int i = 0; i < 40; i++) salt[i] = (byte) i;
byte[] info = new byte[16];
for (int i = 0; i < 16; i++) info[i] = (byte) (i * 3);
byte[] derived = new byte[160];
crypto.deriveExpeditedStandardKeys(
kdh, (short) 0,
salt, (short) 0, (short) salt.length,
info, (short) 0, (short) info.length,
derived, (short) 0);
byte[] prk = new byte[32];
crypto.hkdfExtract(
salt, (short) 0, (short) salt.length,
kdh, (short) 0, (short) 32,
prk, (short) 0);
byte[] manual = new byte[160];
crypto.hkdfExpand(
prk, (short) 0, (short) 32,
info, (short) 0, (short) info.length,
(short) 160,
manual, (short) 0);
assertArrayEquals(manual, derived,
"deriveExpeditedStandardKeys must equal HKDF(Kdh, salt, info, 160)");
}
@Test
void deriveExpeditedStandardKeysAgreesOnBothSides() {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
KeyPair kp2 = freshP256();
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
byte[] txnId = new byte[16];
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x11 * i);
byte[] salt = new byte[80];
for (int i = 0; i < 80; i++) salt[i] = (byte) (i ^ 0x55);
byte[] info = new byte[32];
for (int i = 0; i < 32; i++) info[i] = (byte) (i ^ 0xAA);
byte[] outA = new byte[160];
byte[] outB = new byte[160];
byte[] kdhA = new byte[32];
byte[] kdhB = new byte[32];
crypto.deriveKdh((ECPrivateKey) kp1.getPrivate(), pub2, (short) 0,
txnId, (short) 0, (short) txnId.length, kdhA, (short) 0);
crypto.deriveExpeditedStandardKeys(kdhA, (short) 0,
salt, (short) 0, (short) salt.length,
info, (short) 0, (short) info.length,
outA, (short) 0);
crypto.deriveKdh((ECPrivateKey) kp2.getPrivate(), pub1, (short) 0,
txnId, (short) 0, (short) txnId.length, kdhB, (short) 0);
crypto.deriveExpeditedStandardKeys(kdhB, (short) 0,
salt, (short) 0, (short) salt.length,
info, (short) 0, (short) info.length,
outB, (short) 0);
assertArrayEquals(outA, outB,
"reader and device must derive identical ExpeditedSK material");
}
@Test
void deriveKdhAgreesOnBothSides() {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
KeyPair kp2 = freshP256();
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
byte[] txnId = new byte[16];
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x42 ^ i);
byte[] kdhA = new byte[32];
byte[] kdhB = new byte[32];
crypto.deriveKdh((ECPrivateKey) kp1.getPrivate(), pub2, (short) 0,
txnId, (short) 0, (short) txnId.length, kdhA, (short) 0);
crypto.deriveKdh((ECPrivateKey) kp2.getPrivate(), pub1, (short) 0,
txnId, (short) 0, (short) txnId.length, kdhB, (short) 0);
assertArrayEquals(kdhA, kdhB, "both sides must derive the same Kdh");
}
/**
* Proves the load-a-known-scalar-into-a-JC-ECPrivateKey pattern we need
* for credential_PrivK: create a JC KeyPair to inherit P-256 curve
* parameters, overwrite S with the externally-supplied scalar, then
* sign — the signature must verify under the matching (also externally
* supplied, loaded via setW) public key.
*/
@Test
void ecPrivateKeyLoadedViaKeyPairPatternSignsAndVerifies() throws Exception {
java.security.KeyPairGenerator g = java.security.KeyPairGenerator.getInstance("EC");
g.initialize(new java.security.spec.ECGenParameterSpec("secp256r1"));
java.security.KeyPair jdk = g.generateKeyPair();
byte[] s = toFixed32(
((java.security.interfaces.ECPrivateKey) jdk.getPrivate()).getS().toByteArray());
byte[] w = uncompressedFromJdk((java.security.interfaces.ECPublicKey) jdk.getPublic());
KeyPair privKp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
privKp.genKeyPair();
((ECPrivateKey) privKp.getPrivate()).setS(s, (short) 0, (short) s.length);
KeyPair pubKp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
pubKp.genKeyPair();
((ECPublicKey) pubKp.getPublic()).setW(w, (short) 0, (short) w.length);
byte[] msg = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55 };
Signature signer = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
signer.init(privKp.getPrivate(), Signature.MODE_SIGN);
byte[] der = new byte[80];
short sigLen = signer.sign(msg, (short) 0, (short) msg.length, der, (short) 0);
Signature verifier = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
verifier.init(pubKp.getPublic(), Signature.MODE_VERIFY);
assertTrue(verifier.verify(msg, (short) 0, (short) msg.length, der, (short) 0, sigLen),
"sig from loaded privkey must verify under loaded pubkey");
}
private static byte[] toFixed32(byte[] in) {
if (in.length == 32) return in;
byte[] out = new byte[32];
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
return out;
}
private static byte[] uncompressedFromJdk(java.security.interfaces.ECPublicKey pub) {
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
byte[] out = new byte[65];
out[0] = 0x04;
System.arraycopy(x, 0, out, 1, 32);
System.arraycopy(y, 0, out, 33, 32);
return out;
}
/**
* Probe: can jcardsim actually instantiate AES-256-GCM and round-trip a
* block? Aliro §8.3.1.6 requires AES-GCM with a 32-byte session key;
* if jcardsim doesn't support it, we need userland GCM instead.
*/
@Test
void jcardsimSupportsAesGcm() {
Cipher c = Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
assertTrue(c instanceof AEADCipher, "expected an AEADCipher instance");
AEADCipher gcm = (AEADCipher) c;
AESKey k = (AESKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
byte[] keyBytes = new byte[32];
for (int i = 0; i < 32; i++) keyBytes[i] = (byte) i;
k.setKey(keyBytes, (short) 0);
byte[] iv = new byte[12];
iv[11] = 0x01;
byte[] pt = new byte[] { 0x11, 0x22, 0x33, 0x44 };
// jcardsim's AEADCipher impl writes ct||tag into the doFinal output
// buffer; size it accordingly, then also call retrieveTag.
byte[] ctAndTag = new byte[pt.length + 16];
byte[] tag = new byte[16];
gcm.init(k, Cipher.MODE_ENCRYPT, iv, (short) 0, (short) iv.length);
short outLen = gcm.doFinal(pt, (short) 0, (short) pt.length, ctAndTag, (short) 0);
gcm.retrieveTag(tag, (short) 0, (short) tag.length);
assertEquals(pt.length + 16, outLen);
byte[] tagFromOutput = new byte[16];
System.arraycopy(ctAndTag, pt.length, tagFromOutput, 0, 16);
assertArrayEquals(tag, tagFromOutput,
"tag via retrieveTag must match tag appended by doFinal");
byte[] dec = new byte[pt.length + 16];
gcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
short decLen = gcm.doFinal(ctAndTag, (short) 0, (short) (pt.length + 16), dec, (short) 0);
assertEquals(pt.length, decLen, "decrypt doFinal returns plaintext length");
byte[] ptOut = new byte[pt.length];
System.arraycopy(dec, 0, ptOut, 0, pt.length);
assertArrayEquals(pt, ptOut);
}
private static KeyPair freshP256() {
KeyPair kp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
kp.genKeyPair();
return kp;
}
private static byte[] uncompressed(ECPublicKey pub) {
byte[] out = new byte[65];
short len = pub.getW(out, (short) 0);
if (len != 65) throw new IllegalStateException("expected 65B uncompressed point");
return out;
}
private static byte[] hex(String s) {
s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2];
for (int i = 0; i < out.length; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}

View File

@@ -0,0 +1,229 @@
package com.dangerousthings.aliro;
import javacard.security.AESKey;
import javacard.security.KeyBuilder;
import javacardx.crypto.AEADCipher;
import javacardx.crypto.Cipher;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Tests for {@link AliroGcm} — our userland AES-256-GCM, built on the JC
* single-block AES-ECB primitive. Needed because the target card (NXP J3R180)
* does not expose {@code AEADCipher.ALG_AES_GCM}. The NIST test vectors come
* from {@code gcmEncryptExtIV256.rsp} (the CMVP AEAD AES-256 suite).
*/
class AliroGcmTest {
@Test
void nistTestCase13_emptyMessage_emptyAad_returnsTagOnly() {
// K=0^256, IV=0^96, P=A=empty, expected T = 530f8afbc74536b9a963b4f1c4cb738b
byte[] key = new byte[32];
byte[] iv = new byte[12];
byte[] pt = new byte[0];
byte[] expectedTag = hex("530f8afbc74536b9a963b4f1c4cb738b");
byte[] out = new byte[16];
AliroGcm gcm = new AliroGcm();
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) 0, out, (short) 0);
assertEquals(16, n, "empty plaintext → 16 bytes output (just the tag)");
assertArrayEquals(expectedTag, out, "NIST GCM AES-256 Test Case 13 tag");
}
@Test
void nistTestCase14_singleZeroBlock_returnsCiphertextAndTag() {
// K=0^256, IV=0^96, P=0^128, A=empty
// C = cea7403d4d606b6e074ec5d3baf39d18
// T = d0d1c8a799996bf0265b98b5d48ab919
byte[] key = new byte[32];
byte[] iv = new byte[12];
byte[] pt = new byte[16];
byte[] expectedCt = hex("cea7403d4d606b6e074ec5d3baf39d18");
byte[] expectedTag = hex("d0d1c8a799996bf0265b98b5d48ab919");
byte[] out = new byte[32];
AliroGcm gcm = new AliroGcm();
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out, (short) 0);
assertEquals(32, n, "16B plaintext + 16B tag");
byte[] ct = new byte[16];
System.arraycopy(out, 0, ct, 0, 16);
byte[] tag = new byte[16];
System.arraycopy(out, 16, tag, 0, 16);
assertArrayEquals(expectedCt, ct, "NIST GCM AES-256 Test Case 14 ciphertext");
assertArrayEquals(expectedTag, tag, "NIST GCM AES-256 Test Case 14 tag");
}
@Test
void nistTestCase15_fourBlocks_realKey() {
// K = feffe9928665731c6d6a8f9467308308 feffe9928665731c6d6a8f9467308308
// IV = cafebabefacedbaddecaf888
// P = 4 blocks, A = empty
byte[] key = hex("feffe9928665731c6d6a8f9467308308"
+ "feffe9928665731c6d6a8f9467308308");
byte[] iv = hex("cafebabefacedbaddecaf888");
byte[] pt = hex("d9313225f88406e5a55909c5aff5269a"
+ "86a7a9531534f7da2e4c303d8a318a72"
+ "1c3c0c95956809532fcf0e2449a6b525"
+ "b16aedf5aa0de657ba637b391aafd255");
byte[] expectedCt = hex("522dc1f099567d07f47f37a32a84427d"
+ "643a8cdcbfe5c0c97598a2bd2555d1aa"
+ "8cb08e48590dbb3da7b08b1056828838"
+ "c5f61e6393ba7a0abcc9f662898015ad");
byte[] expectedTag = hex("b094dac5d93471bdec1a502270e3cc6c");
byte[] out = new byte[pt.length + 16];
AliroGcm gcm = new AliroGcm();
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out, (short) 0);
assertEquals(pt.length + 16, n);
byte[] ct = new byte[pt.length];
System.arraycopy(out, 0, ct, 0, pt.length);
byte[] tag = new byte[16];
System.arraycopy(out, pt.length, tag, 0, 16);
assertArrayEquals(expectedCt, ct, "NIST GCM AES-256 Test Case 15 ciphertext");
assertArrayEquals(expectedTag, tag, "NIST GCM AES-256 Test Case 15 tag");
}
/**
* End-to-end: encrypt with AliroGcm, decrypt with jcardsim's AEADCipher
* (available in tests via the patched jcardsim master in m2 volume).
* Confirms wire-compatibility with the standard implementation.
*/
@Test
void roundTripAgainstJcardsimAEADCipher() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (i * 7 + 3);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xA0 ^ i);
byte[] pt = new byte[137]; // realistic Aliro Table 8-11 size
for (int i = 0; i < pt.length; i++) pt[i] = (byte) (i ^ 0x5C);
byte[] out = new byte[pt.length + 16];
AliroGcm gcm = new AliroGcm();
gcm.encrypt(key, (short) 0, iv, (short) 0,
pt, (short) 0, (short) pt.length, out, (short) 0);
// Decrypt with jcardsim's AEADCipher.
AESKey k = (AESKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
k.setKey(key, (short) 0);
AEADCipher jcGcm = (AEADCipher) Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
jcGcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
byte[] dec = new byte[pt.length + 16];
short decLen = jcGcm.doFinal(out, (short) 0, (short) out.length, dec, (short) 0);
assertEquals(pt.length, decLen, "jcardsim GCM decrypt returns pt length");
byte[] ptOut = new byte[pt.length];
System.arraycopy(dec, 0, ptOut, 0, pt.length);
assertArrayEquals(pt, ptOut,
"AliroGcm ciphertext must decrypt correctly under jcardsim AEADCipher");
}
/**
* Partial final block: plaintext whose length isn't a multiple of 16.
* GCTR must handle the partial block correctly (XOR only |P| mod 16 bytes
* of the last keystream block), and GHASH must zero-pad.
*/
@Test
void partialFinalBlock() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) i;
byte[] iv = new byte[12];
iv[11] = 0x01;
byte[] pt = new byte[17]; // 1 full block + 1 byte
for (int i = 0; i < 17; i++) pt[i] = (byte) (0xAA + i);
byte[] out = new byte[pt.length + 16];
AliroGcm gcm = new AliroGcm();
short n = gcm.encrypt(key, (short) 0, iv, (short) 0,
pt, (short) 0, (short) pt.length, out, (short) 0);
assertEquals(pt.length + 16, n,
"ciphertext length must equal plaintext length (GCM is length-preserving)");
// Round-trip through jcardsim for correctness.
AESKey k = (AESKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
k.setKey(key, (short) 0);
AEADCipher jcGcm = (AEADCipher) Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
jcGcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
byte[] dec = new byte[pt.length + 16];
short decLen = jcGcm.doFinal(out, (short) 0, (short) out.length, dec, (short) 0);
assertEquals(pt.length, decLen);
byte[] ptOut = new byte[pt.length];
System.arraycopy(dec, 0, ptOut, 0, pt.length);
assertArrayEquals(pt, ptOut, "17-byte plaintext must round-trip");
}
@Test
void differentKeysProduceDifferentTags() {
byte[] iv = new byte[12];
iv[11] = 0x01;
byte[] pt = new byte[] { 0x11, 0x22, 0x33, 0x44 };
byte[] key1 = new byte[32];
byte[] key2 = new byte[32];
key2[0] = 0x01; // differ in one bit
AliroGcm gcm = new AliroGcm();
byte[] out1 = new byte[pt.length + 16];
byte[] out2 = new byte[pt.length + 16];
gcm.encrypt(key1, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out1, (short) 0);
gcm.encrypt(key2, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out2, (short) 0);
// Extract tags (last 16 bytes).
byte[] tag1 = new byte[16];
byte[] tag2 = new byte[16];
System.arraycopy(out1, pt.length, tag1, 0, 16);
System.arraycopy(out2, pt.length, tag2, 0, 16);
assertFalse(java.util.Arrays.equals(tag1, tag2),
"different keys must produce different tags");
}
@Test
void differentIvsProduceDifferentCiphertexts() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) i;
byte[] pt = new byte[32];
for (int i = 0; i < 32; i++) pt[i] = (byte) (0x10 + i);
byte[] iv1 = new byte[12];
iv1[11] = 0x01;
byte[] iv2 = new byte[12];
iv2[11] = 0x02;
AliroGcm gcm = new AliroGcm();
byte[] out1 = new byte[pt.length + 16];
byte[] out2 = new byte[pt.length + 16];
gcm.encrypt(key, (short) 0, iv1, (short) 0, pt, (short) 0, (short) pt.length, out1, (short) 0);
gcm.encrypt(key, (short) 0, iv2, (short) 0, pt, (short) 0, (short) pt.length, out2, (short) 0);
byte[] ct1 = new byte[pt.length];
byte[] ct2 = new byte[pt.length];
System.arraycopy(out1, 0, ct1, 0, pt.length);
System.arraycopy(out2, 0, ct2, 0, pt.length);
assertFalse(java.util.Arrays.equals(ct1, ct2),
"different IVs must produce different ciphertexts (CTR stream differs)");
}
private static byte[] hex(String s) {
s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2];
for (int i = 0; i < out.length; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}

View File

@@ -0,0 +1,181 @@
package com.dangerousthings.aliro;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for {@link AliroHmac} — userland HMAC-SHA-256, built on the JC
* {@code MessageDigest.ALG_SHA_256} primitive. Needed because the target card
* (NXP J3R180) does not support {@code KeyBuilder.TYPE_HMAC} or
* {@code Signature.ALG_HMAC_SHA_256} despite advertising JC 3.0.5.
*
* <p>Known-answer vectors come from RFC 4231 (HMAC-SHA-256 test cases).
*/
class AliroHmacTest {
/** RFC 4231 TC1: K = 0x0b × 20, M = "Hi There". */
@Test
void rfc4231TestCase1() {
byte[] key = new byte[20];
for (int i = 0; i < 20; i++) key[i] = 0x0b;
byte[] msg = "Hi There".getBytes();
byte[] expected = hex(
"b0344c61d8db38535ca8afceaf0bf12b"
+ "881dc200c9833da726e9376c2e32cff7");
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
short n = hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertEquals(32, n);
assertArrayEquals(expected, out, "RFC 4231 Test Case 1 HMAC-SHA-256");
}
/** RFC 4231 TC2: K = "Jefe", M = "what do ya want for nothing?". */
@Test
void rfc4231TestCase2() {
byte[] key = "Jefe".getBytes();
byte[] msg = "what do ya want for nothing?".getBytes();
byte[] expected = hex(
"5bdcc146bf60754e6a042426089575c7"
+ "5a003f089d2739839dec58b964ec3843");
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertArrayEquals(expected, out, "RFC 4231 Test Case 2 HMAC-SHA-256");
}
/** RFC 4231 TC4: K = 0x01..0x19 (25 bytes), M = 0xcd × 50. */
@Test
void rfc4231TestCase4() {
byte[] key = hex("0102030405060708090a0b0c0d0e0f10111213141516171819");
byte[] msg = new byte[50];
for (int i = 0; i < 50; i++) msg[i] = (byte) 0xcd;
byte[] expected = hex(
"82558a389a443c0ea4cc819899f2083a"
+ "85f0faa3e578f8077a2e3ff46729665b");
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertArrayEquals(expected, out, "RFC 4231 Test Case 4 HMAC-SHA-256");
}
/** RFC 4231 TC6: K = 0xaa × 131 (> 64B, triggers K' = SHA-256(K) branch). */
@Test
void rfc4231TestCase6_keyLongerThanBlock() {
byte[] key = new byte[131];
for (int i = 0; i < 131; i++) key[i] = (byte) 0xaa;
byte[] msg = "Test Using Larger Than Block-Size Key - Hash Key First".getBytes();
byte[] expected = hex(
"60e431591ee0b67f0d8a26aacbf5b77f"
+ "8e0bc6213728c5140546040f0ee37f54");
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertArrayEquals(expected, out,
"RFC 4231 Test Case 6 HMAC-SHA-256 — long key (K > B) must be hashed");
}
/**
* Calling compute twice with the same inputs must produce the same output —
* catches internal-state leaks (e.g. failing to reset the SHA-256 digest,
* or forgetting to zero the pad block on reuse).
*/
@Test
void repeatedComputesMatch() {
byte[] key = new byte[20];
for (int i = 0; i < 20; i++) key[i] = 0x0b;
byte[] msg = "Hi There".getBytes();
AliroHmac hmac = new AliroHmac();
byte[] out1 = new byte[32];
byte[] out2 = new byte[32];
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out1, (short) 0);
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out2, (short) 0);
assertArrayEquals(out1, out2,
"same key+msg must produce same HMAC on repeat invocation");
}
/**
* Empty message with a non-empty key — must still produce a valid,
* deterministic HMAC (HMAC is defined for zero-length messages).
* Reference value computed via JDK HmacSHA256 with the same key.
*/
@Test
void emptyMessage() {
byte[] key = new byte[20];
for (int i = 0; i < 20; i++) key[i] = 0x0b;
byte[] expected = jdkHmacSha256(key, new byte[0]);
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
key, (short) 0, (short) key.length,
new byte[0], (short) 0, (short) 0,
out, (short) 0);
assertArrayEquals(expected, out, "HMAC of empty message matches JDK reference");
}
/**
* Empty key — RFC 2104 allows zero-length keys (K' is zero-padded to B);
* some buggy implementations trip over this. Reference value from the JDK.
*/
@Test
void emptyKey() {
byte[] msg = "Hi There".getBytes();
byte[] expected = jdkHmacSha256(new byte[0], msg);
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
new byte[0], (short) 0, (short) 0,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertArrayEquals(expected, out, "HMAC with empty key matches JDK reference");
}
private static byte[] jdkHmacSha256(byte[] key, byte[] msg) {
try {
// HmacSHA256 rejects zero-length keys in some JDKs; normalize by
// zero-padding to 1 byte when empty — equivalent under HMAC's
// "zero-pad K to B bytes" rule (all-zero key → all-zero K').
byte[] useKey = key.length == 0 ? new byte[1] : key;
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(useKey, "HmacSHA256"));
return mac.doFinal(msg);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static byte[] hex(String s) {
s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2];
for (int i = 0; i < out.length; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}

View File

@@ -0,0 +1,106 @@
package com.dangerousthings.aliro;
import java.io.ByteArrayOutputStream;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
/**
* Test-only builder for Aliro AUTH0 command payloads, per spec Table 8-4.
*
* <pre>
* 0x41 01 [command_parameters]
* 0x42 01 [authentication_policy]
* 0x5C 02 [expedited_phase_protocol_version]
* 0x87 41 04 || x || y (reader_ePubK uncompressed, 65 bytes)
* 0x4C 10 [transaction_identifier]
* 0x4D 20 [reader_group_id 16 || reader_group_sub_id 16]
* </pre>
*/
final class Auth0Command {
static final byte CLA = (byte) 0x80;
static final byte INS = (byte) 0x80;
static final short PROTOCOL_V1_0 = (short) 0x0100;
static final byte CMD_PARAM_STANDARD = (byte) 0x00;
static final byte CMD_PARAM_FAST = (byte) 0x01;
static final byte AUTH_POLICY_NONE = (byte) 0x00;
private Auth0Command() {}
/** Generates a fresh P-256 keypair for use by tests simulating the reader. */
static KeyPair generateEphemeralKeyPair() {
try {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(new ECGenParameterSpec("secp256r1"), new SecureRandom());
return kpg.generateKeyPair();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** Encodes a P-256 public key to uncompressed 65-byte form (0x04 || x || y). */
static byte[] encodeUncompressed(ECPublicKey pub) {
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
byte[] out = new byte[65];
out[0] = 0x04;
System.arraycopy(x, 0, out, 1, 32);
System.arraycopy(y, 0, out, 33, 32);
return out;
}
private static byte[] toFixed32(byte[] b) {
if (b.length == 32) return b;
byte[] out = new byte[32];
if (b.length > 32) {
System.arraycopy(b, b.length - 32, out, 0, 32);
} else {
System.arraycopy(b, 0, out, 32 - b.length, b.length);
}
return out;
}
/** Builds a standard-phase AUTH0 command data field with the given parts. */
static byte[] buildStandardData(
byte[] readerEphemPubKeyUncompressed,
byte[] readerGroupId,
byte[] readerGroupSubId,
byte[] transactionId) {
if (readerEphemPubKeyUncompressed.length != 65) {
throw new IllegalArgumentException("reader_ePubK must be 65 bytes (uncompressed)");
}
if (readerGroupId.length != 16 || readerGroupSubId.length != 16) {
throw new IllegalArgumentException("reader_group_{id,sub_id} must be 16 bytes each");
}
if (transactionId.length != 16) {
throw new IllegalArgumentException("transaction_identifier must be 16 bytes");
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
writeTlv(out, 0x41, new byte[] { CMD_PARAM_STANDARD });
writeTlv(out, 0x42, new byte[] { AUTH_POLICY_NONE });
writeTlv(out, 0x5C, new byte[] {
(byte) ((PROTOCOL_V1_0 >> 8) & 0xFF),
(byte) (PROTOCOL_V1_0 & 0xFF)
});
writeTlv(out, 0x87, readerEphemPubKeyUncompressed);
writeTlv(out, 0x4C, transactionId);
byte[] readerIdentifier = new byte[32];
System.arraycopy(readerGroupId, 0, readerIdentifier, 0, 16);
System.arraycopy(readerGroupSubId, 0, readerIdentifier, 16, 16);
writeTlv(out, 0x4D, readerIdentifier);
return out.toByteArray();
}
private static void writeTlv(ByteArrayOutputStream out, int tag, byte[] value) {
out.write(tag);
out.write(value.length);
out.write(value, 0, value.length);
}
}

View File

@@ -0,0 +1,165 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for the PersonalizationApplet — our proprietary provisioning
* channel that loads the Access Credential long-term private key and
* reader public keys before the Aliro flow runs.
*/
class PersonalizationAppletTest {
private static final byte CLA = (byte) 0x80;
private static final byte INS_SET_CRED_PRIV = (byte) 0x20;
private static final byte INS_SET_CRED_PUBK = (byte) 0x21;
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_COMMIT = (byte) 0x2C;
private CardSimulator sim;
@BeforeEach
void setUp() {
CredentialStore.get().resetForTesting();
sim = new CardSimulator();
AID aid = new AID(AliroAids.PROVISIONING, (short) 0, (byte) AliroAids.PROVISIONING.length);
sim.installApplet(aid, PersonalizationApplet.class);
selectProvisioning();
}
private void selectProvisioning() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.PROVISIONING, 256);
ResponseAPDU r = sim.transmitCommand(select);
assertEquals(0x9000, r.getSW(), "SELECT provisioning AID must succeed");
}
private ResponseAPDU send(byte ins, byte[] data) {
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF, 0x00, 0x00,
data == null ? new byte[0] : data, 256);
return sim.transmitCommand(c);
}
private ResponseAPDU sendWithP1P2(byte ins, int p1p2, byte[] data) {
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF,
(p1p2 >> 8) & 0xFF, p1p2 & 0xFF,
data == null ? new byte[0] : data, 256);
return sim.transmitCommand(c);
}
@Test
void setCredentialPrivKeyWith32BytesSucceeds() {
byte[] key = new byte[32];
for (int i = 0; i < key.length; i++) key[i] = (byte) i;
assertEquals(0x9000, send(INS_SET_CRED_PRIV, key).getSW());
}
@Test
void setCredentialPrivKeyWithWrongLengthFails() {
byte[] key = new byte[31];
assertEquals(0x6A80, send(INS_SET_CRED_PRIV, key).getSW(),
"credential private key length != 32 must be rejected");
}
@Test
void setCredentialPubKeyWith64BytesSucceeds() {
byte[] pub = new byte[64];
for (int i = 0; i < pub.length; i++) pub[i] = (byte) (i * 3);
assertEquals(0x9000, send(INS_SET_CRED_PUBK, pub).getSW());
}
@Test
void setCredentialPubKeyWithWrongLengthFails() {
byte[] pub = new byte[63];
assertEquals(0x6A80, send(INS_SET_CRED_PUBK, pub).getSW(),
"credential public key length != 64 must be rejected");
}
@Test
void setCredentialPubKeyAfterCommitIsLocked() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
byte[] pub = new byte[64];
assertEquals(0x6985, send(INS_SET_CRED_PUBK, pub).getSW(),
"writes after COMMIT must return SW_CONDITIONS_NOT_SATISFIED");
}
@Test
void setReaderPubKeyWith64BytesSucceeds() {
byte[] pub = new byte[64];
for (int i = 0; i < pub.length; i++) pub[i] = (byte) (i * 7);
assertEquals(0x9000, send(INS_SET_READER_PUBK, pub).getSW());
}
@Test
void accessDocumentChunkedWriteAndFinalizeRoundTrip() {
byte[] ad = new byte[420];
for (int i = 0; i < ad.length; i++) ad[i] = (byte) ((i * 13) ^ 0xA5);
// Two chunks: [0..200), [200..420)
byte[] chunk1 = java.util.Arrays.copyOfRange(ad, 0, 200);
byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, 420);
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());
// Verify via the test-only hook: bytes must match.
byte[] stored = new byte[ad.length];
CredentialStore.get().copyAccessDocument(stored, (short) 0, (short) 0, (short) ad.length);
org.junit.jupiter.api.Assertions.assertArrayEquals(ad, stored);
assertEquals(ad.length, CredentialStore.get().getAccessDocumentLen());
org.junit.jupiter.api.Assertions.assertTrue(CredentialStore.get().hasAccessDocument());
}
@Test
void accessDocumentWriteBeyondMaxSizeFails() {
byte[] payload = new byte[50];
short maxOff = (short) (CredentialStore.ACCESS_DOC_MAX_LEN - 10); // 10 bytes short of max
assertEquals(0x6A80, sendWithP1P2(INS_WRITE_ACCESS_DOC, maxOff, payload).getSW(),
"a 50-byte chunk at offset MAX-10 overruns and must be rejected");
}
@Test
void accessDocumentFinalizeBeyondMaxSizeFails() {
byte[] chunk = new byte[10];
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1;
assertEquals(0x6A80, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, tooBig, null).getSW(),
"finalize length > ACCESS_DOC_MAX_LEN must be rejected");
}
@Test
void accessDocumentWriteAfterCommitIsLocked() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
byte[] chunk = new byte[8];
assertEquals(0x6985, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
assertEquals(0x6985, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, 8, null).getSW());
}
@Test
void commitReturns9000() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
}
@Test
void setAfterCommitIsLocked() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
byte[] key = new byte[32];
assertEquals(0x6985, send(INS_SET_CRED_PRIV, key).getSW(),
"writes after COMMIT must return SW_CONDITIONS_NOT_SATISFIED");
}
@Test
void secondCommitIsLocked() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
assertEquals(0x6985, send(INS_COMMIT, null).getSW(),
"a second COMMIT must also be refused");
}
}

View File

@@ -0,0 +1,72 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import java.security.KeyPair;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Test-only helper that installs {@link PersonalizationApplet}, pushes the
* Access Credential long-term key pair and reader public key into the
* shared {@link CredentialStore}, and COMMITs so later Aliro flow tests
* can assume a fully-provisioned card.
*/
final class ProvisioningHelper {
private static final byte CLA = (byte) 0x80;
private static final byte INS_SET_CRED_PRIV = (byte) 0x20;
private static final byte INS_SET_CRED_PUBK = (byte) 0x21;
private static final byte INS_SET_READER_PUBK = (byte) 0x22;
private static final byte INS_COMMIT = (byte) 0x2C;
private ProvisioningHelper() {}
/** Installs PersonalizationApplet, provisions both keys, commits, and deselects. */
static void provision(CardSimulator sim, KeyPair credential, ECPublicKey readerPub) {
AID aid = new AID(AliroAids.PROVISIONING, (short) 0, (byte) AliroAids.PROVISIONING.length);
sim.installApplet(aid, PersonalizationApplet.class);
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.PROVISIONING, 256);
assertEquals(0x9000, sim.transmitCommand(select).getSW(), "SELECT provisioning AID");
byte[] credPriv = toFixed32(((ECPrivateKey) credential.getPrivate()).getS().toByteArray());
send(sim, INS_SET_CRED_PRIV, credPriv);
byte[] credPubRaw = extractXY((ECPublicKey) credential.getPublic());
send(sim, INS_SET_CRED_PUBK, credPubRaw);
byte[] readerPubRaw = extractXY(readerPub);
send(sim, INS_SET_READER_PUBK, readerPubRaw);
send(sim, INS_COMMIT, new byte[0]);
}
private static void send(CardSimulator sim, byte ins, byte[] data) {
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF, 0x00, 0x00, data, 256);
ResponseAPDU r = sim.transmitCommand(c);
assertEquals(0x9000, r.getSW(), "provisioning INS 0x" + Integer.toHexString(ins & 0xFF) + " failed");
}
/** Returns the 64-byte x||y representation (no 0x04 prefix). */
static byte[] extractXY(ECPublicKey pub) {
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
byte[] out = new byte[64];
System.arraycopy(x, 0, out, 0, 32);
System.arraycopy(y, 0, out, 32, 32);
return out;
}
static byte[] toFixed32(byte[] b) {
if (b.length == 32) return b;
byte[] out = new byte[32];
if (b.length > 32) System.arraycopy(b, b.length - 32, out, 0, 32);
else System.arraycopy(b, 0, out, 32 - b.length, b.length);
return out;
}
}

View File

@@ -0,0 +1,410 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* Test-only model of the Reader side of an Aliro transaction.
*
* <p>Holds the reader's long-term keypair (bound to the card's
* {@link CredentialStore}), a per-transaction ephemeral keypair, the
* reader_identifier, and the transaction_identifier. Produces AUTH0 / AUTH1
* command payloads with correctly-computed signatures over the fields
* specified in spec Tables 8-12 (reader auth data).
*/
final class ReaderSide {
/** Fixed usage constant for reader's AUTH1 signature, spec Table 8-12. */
static final byte[] USAGE_READER_AUTH1 = new byte[] {
(byte) 0x41, (byte) 0x5D, (byte) 0x95, (byte) 0x69
};
/** Fixed usage constant for UD's AUTH1 signature, spec Table 8-13. */
static final byte[] USAGE_UD_AUTH1 = new byte[] {
(byte) 0x4E, (byte) 0x88, (byte) 0x7B, (byte) 0x4C
};
/** Proprietary 0xA5 TLV that our applet returns in its SELECT FCI. Must
* match what AliroApplet.PROPRIETARY_A5_TLV emits byte-for-byte. */
static final byte[] PROPRIETARY_A5_TLV = {
(byte) 0xA5, (byte) 0x08,
(byte) 0x80, (byte) 0x02, (byte) 0x00, (byte) 0x00,
(byte) 0x5C, (byte) 0x02, (byte) 0x01, (byte) 0x00
};
private final KeyPair longTerm;
private final byte[] readerGroupId = new byte[16];
private final byte[] readerGroupSubId = new byte[16];
private KeyPair ephemeral;
private byte[] transactionId;
ReaderSide() {
longTerm = Auth0Command.generateEphemeralKeyPair();
SecureRandom rng = new SecureRandom();
rng.nextBytes(readerGroupId);
rng.nextBytes(readerGroupSubId);
}
ECPublicKey longTermPublic() {
return (ECPublicKey) longTerm.getPublic();
}
/** Provisions this reader's long-term pubkey and a caller-owned credential keypair onto the card. */
void provision(CardSimulator sim, KeyPair credential) {
ProvisioningHelper.provision(sim, credential, longTermPublic());
setCredentialLongTermPublic((ECPublicKey) credential.getPublic());
}
/** Fresh ephemeral keypair + transaction_id for a new transaction. */
void startTransaction() {
ephemeral = Auth0Command.generateEphemeralKeyPair();
transactionId = new byte[16];
new SecureRandom().nextBytes(transactionId);
}
byte[] buildAuth0Data() {
byte[] readerPubUncomp = Auth0Command.encodeUncompressed((ECPublicKey) ephemeral.getPublic());
return Auth0Command.buildStandardData(
readerPubUncomp, readerGroupId, readerGroupSubId, transactionId);
}
/**
* Builds the AUTH1 command data for the current transaction, computing
* a proper ECDSA-SHA-256 signature over Table 8-12 using the reader's
* long-term key. {@code credentialEphemPubKey65} is the 65-byte
* uncompressed credential_ePubK returned by the card in the AUTH0
* response (tag 0x86 value).
*/
byte[] buildAuth1Data(byte[] credentialEphemPubKey65) {
byte[] toSign = buildTable812(credentialEphemPubKey65);
byte[] rawSig = signEcdsaSha256Raw(toSign, (ECPrivateKey) longTerm.getPrivate());
byte[] out = new byte[3 + 2 + 64];
int i = 0;
// command_parameters bit 0 = 1 → AUTH1 response returns credential_PubK
// (tag 0x5A, full 65B uncompressed). Simpler for the test harness than
// the key_slot (0x4E) path, which requires the reader to maintain a
// lookup table keyed by SHA-1(credential_PubK)[0:8].
out[i++] = 0x41; out[i++] = 0x01; out[i++] = 0x01;
out[i++] = (byte) 0x9E; out[i++] = 0x40;
System.arraycopy(rawSig, 0, out, i, 64);
return out;
}
/**
* Constructs the Table 8-12 byte sequence that the reader signs:
* <pre>
* 0x4D 0x20 [reader_identifier 32B]
* 0x86 0x20 [credential_ePubK.x 32B]
* 0x87 0x20 [reader_ePubK.x 32B]
* 0x4C 0x10 [transaction_identifier 16B]
* 0x93 0x04 0x41 0x5D 0x95 0x69
* </pre>
*/
private byte[] buildTable812(byte[] credEphemPubKey65) {
byte[] readerEphemUncomp = Auth0Command.encodeUncompressed(
(ECPublicKey) ephemeral.getPublic());
ByteArrayOutputStream out = new ByteArrayOutputStream();
write(out, 0x4D, concat(readerGroupId, readerGroupSubId));
write(out, 0x86, java.util.Arrays.copyOfRange(credEphemPubKey65, 1, 33));
write(out, 0x87, java.util.Arrays.copyOfRange(readerEphemUncomp, 1, 33));
write(out, 0x4C, transactionId);
write(out, 0x93, USAGE_READER_AUTH1);
return out.toByteArray();
}
private static void write(ByteArrayOutputStream out, int tag, byte[] value) {
out.write(tag);
out.write(value.length);
out.write(value, 0, value.length);
}
private static byte[] concat(byte[] a, byte[] b) {
byte[] out = new byte[a.length + b.length];
System.arraycopy(a, 0, out, 0, a.length);
System.arraycopy(b, 0, out, a.length, b.length);
return out;
}
/**
* Signs with SHA256withECDSA and returns the 64-byte raw (r||s) form.
* JDK returns DER-encoded signatures; we strip the ASN.1 envelope.
*/
static byte[] signEcdsaSha256Raw(byte[] data, ECPrivateKey priv) {
try {
Signature s = Signature.getInstance("SHA256withECDSA");
s.initSign(priv);
s.update(data);
return derToRaw(s.sign(), 32);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** ASN.1 DER ECDSA sig → fixed-length {@code coordLen} bytes of r || s. */
static byte[] derToRaw(byte[] der, int coordLen) {
int i = 0;
if (der[i++] != 0x30) throw new IllegalArgumentException("expected SEQUENCE");
int seqLen = der[i++] & 0xFF;
if (seqLen >= 0x80) throw new IllegalArgumentException("unexpected long-form length");
if (der[i++] != 0x02) throw new IllegalArgumentException("expected INTEGER for r");
int rLen = der[i++] & 0xFF;
byte[] r = java.util.Arrays.copyOfRange(der, i, i + rLen);
i += rLen;
if (der[i++] != 0x02) throw new IllegalArgumentException("expected INTEGER for s");
int sLen = der[i++] & 0xFF;
byte[] s = java.util.Arrays.copyOfRange(der, i, i + sLen);
byte[] out = new byte[coordLen * 2];
copyFixed(r, out, 0, coordLen);
copyFixed(s, out, coordLen, coordLen);
return out;
}
private static void copyFixed(byte[] src, byte[] dst, int dstOff, int coordLen) {
if (src.length == coordLen) {
System.arraycopy(src, 0, dst, dstOff, coordLen);
} else if (src.length == coordLen + 1 && src[0] == 0) {
System.arraycopy(src, 1, dst, dstOff, coordLen);
} else if (src.length < coordLen) {
System.arraycopy(src, 0, dst, dstOff + (coordLen - src.length), src.length);
} else {
throw new IllegalArgumentException(
"coord length " + src.length + " > " + coordLen);
}
}
/**
* Derives the 32-byte ExpeditedSKDevice on the reader side (mirroring
* AliroApplet.deriveSessionKeys). Used by integration tests to decrypt
* the UD's AUTH1 response.
*
* @param credentialEphemPubKey65 the 0x86 TLV value from the AUTH0 response
*/
byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65) {
return java.util.Arrays.copyOfRange(
deriveExpeditedKeyMaterial(credentialEphemPubKey65), 32, 64);
}
/**
* Derives the full 160B {@code derived_keys_volatile} per spec §8.3.1.13
* — same as the applet computes. Layout: ExpeditedSKReader[0..32),
* ExpeditedSKDevice[32..64), StepUpSK[64..96), BleSK[96..128),
* URSK[128..160).
*/
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65) {
byte[] zab = ecdhSharedX(
(ECPrivateKey) ephemeral.getPrivate(),
credentialEphemPubKey65);
byte[] kdh = hkdf(zab, transactionId, new byte[0], 32);
byte[] salt = buildSaltVolatile(credentialEphemPubKey65);
byte[] info = java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33);
return hkdf(kdh, salt, info, 160);
}
/**
* Decrypts an AUTH1 response (encrypted_payload || auth_tag, 12-byte IV
* = 0x00..00 0x01 || device_counter=1 per §8.3.1.6) with
* ExpeditedSKDevice. Throws on tag-mismatch.
*/
static byte[] decryptAuth1Response(byte[] skDevice, byte[] ctAndTag) {
try {
byte[] iv = new byte[12];
iv[7] = 0x01;
iv[11] = 0x01; // device_counter starts at 1 for the first response
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
gcm.init(Cipher.DECRYPT_MODE, new SecretKeySpec(skDevice, "AES"),
new GCMParameterSpec(128, iv));
return gcm.doFinal(ctAndTag);
} catch (Exception e) {
throw new RuntimeException("GCM decrypt failed", e);
}
}
/**
* Builds Table 8-13 from this reader's transaction state + the UD's
* ephemeral public key. The UD's long-term public key is not needed here
* — this is the bytes the UD signed.
*/
byte[] buildTable813(byte[] credentialEphemPubKey65) {
byte[] readerEphemUncomp = Auth0Command.encodeUncompressed(
(ECPublicKey) ephemeral.getPublic());
ByteArrayOutputStream out = new ByteArrayOutputStream();
writeTlv(out, 0x4D, concat(readerGroupId, readerGroupSubId));
writeTlv(out, 0x86, java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33));
writeTlv(out, 0x87, java.util.Arrays.copyOfRange(readerEphemUncomp, 1, 33));
writeTlv(out, 0x4C, transactionId);
writeTlv(out, 0x93, USAGE_UD_AUTH1);
return out.toByteArray();
}
/**
* Verifies an ECDSA-SHA-256 signature in raw r||s form using a public
* key in uncompressed 0x04||x||y form. Returns true iff valid.
*/
static boolean verifyUdSig(byte[] credPubUncompressed65, byte[] table813, byte[] rawSig64) {
try {
BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(credPubUncompressed65, 1, 33));
BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(credPubUncompressed65, 33, 65));
java.security.spec.ECParameterSpec params =
((ECPublicKey) Auth0Command.generateEphemeralKeyPair().getPublic()).getParams();
ECPublicKey pub = (ECPublicKey) KeyFactory.getInstance("EC")
.generatePublic(new ECPublicKeySpec(new ECPoint(x, y), params));
Signature v = Signature.getInstance("SHA256withECDSA");
v.initVerify(pub);
v.update(table813);
return v.verify(rawToDer(rawSig64));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// ---------- helpers ----------
private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try {
// x(reader_group_identifier_key) = this reader's long-term pubkey.x
ECPoint lt = ((ECPublicKey) longTerm.getPublic()).getW();
out.write(toFixed32(lt.getAffineX().toByteArray()));
out.write(new byte[] { 'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*' });
out.write(readerGroupId);
out.write(readerGroupSubId);
out.write((byte) 0x5E); // interface_byte = NFC
out.write((byte) 0x5C);
out.write((byte) 0x02);
out.write((byte) 0x01); out.write((byte) 0x00); // protocol_version 0x0100
ECPoint re = ((ECPublicKey) ephemeral.getPublic()).getW();
out.write(toFixed32(re.getAffineX().toByteArray()));
out.write(transactionId);
out.write((byte) 0x00); // command_parameters = standard
out.write((byte) 0x00); // authentication_policy = none
out.write(PROPRIETARY_A5_TLV);
ECPoint cl = credentialLongTermPubX();
out.write(toFixed32(cl.getAffineX().toByteArray()));
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
return out.toByteArray();
}
/** Returns the credential's long-term public key as an ECPoint. Set via {@link #setCredentialLongTermPublic}. */
private ECPoint credentialLongTermPubX() {
if (credentialLongTermPub == null) {
throw new IllegalStateException("credentialLongTermPub not set — call setCredentialLongTermPublic()");
}
return credentialLongTermPub.getW();
}
private ECPublicKey credentialLongTermPub;
/** Test wiring: tells this reader what credential_PubK the card holds so salt_volatile can include x(). */
void setCredentialLongTermPublic(ECPublicKey pub) {
this.credentialLongTermPub = pub;
}
private static byte[] ecdhSharedX(ECPrivateKey priv, byte[] peerPubUncomp65) {
try {
BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(peerPubUncomp65, 1, 33));
BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(peerPubUncomp65, 33, 65));
java.security.spec.ECParameterSpec params = priv.getParams();
ECPublicKey peer = (ECPublicKey) KeyFactory.getInstance("EC")
.generatePublic(new ECPublicKeySpec(new ECPoint(x, y), params));
javax.crypto.KeyAgreement ka = javax.crypto.KeyAgreement.getInstance("ECDH");
ka.init(priv);
ka.doPhase(peer, true);
return ka.generateSecret(); // raw 32B x-coord
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** HKDF-SHA-256 (RFC 5869). */
private static byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int L) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt.length == 0 ? new byte[32] : salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
byte[] out = new byte[L];
byte[] t = new byte[0];
int produced = 0;
byte counter = 1;
while (produced < L) {
mac.update(t);
mac.update(info);
mac.update(counter);
t = mac.doFinal();
int copy = Math.min(L - produced, t.length);
System.arraycopy(t, 0, out, produced, copy);
produced += copy;
counter++;
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
}
return out;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static byte[] toFixed32(byte[] in) {
if (in.length == 32) return in;
byte[] out = new byte[32];
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
return out;
}
private static void writeTlv(ByteArrayOutputStream out, int tag, byte[] value) {
out.write(tag);
out.write(value.length);
out.write(value, 0, value.length);
}
/** Raw r||s → DER SEQUENCE { INTEGER r, INTEGER s } for JDK Signature.verify. */
static byte[] rawToDer(byte[] raw64) {
byte[] r = java.util.Arrays.copyOfRange(raw64, 0, 32);
byte[] s = java.util.Arrays.copyOfRange(raw64, 32, 64);
byte[] rInt = toAsn1Integer(r);
byte[] sInt = toAsn1Integer(s);
int seqLen = 2 + rInt.length + 2 + sInt.length;
byte[] out = new byte[2 + seqLen];
int o = 0;
out[o++] = 0x30;
out[o++] = (byte) seqLen;
out[o++] = 0x02; out[o++] = (byte) rInt.length;
System.arraycopy(rInt, 0, out, o, rInt.length); o += rInt.length;
out[o++] = 0x02; out[o++] = (byte) sInt.length;
System.arraycopy(sInt, 0, out, o, sInt.length);
return out;
}
private static byte[] toAsn1Integer(byte[] in) {
int start = 0;
while (start < in.length - 1 && in[start] == 0 && (in[start + 1] & 0x80) == 0) start++;
boolean needPad = (in[start] & 0x80) != 0;
byte[] out = new byte[(needPad ? 1 : 0) + (in.length - start)];
int o = 0;
if (needPad) out[o++] = 0x00;
System.arraycopy(in, start, out, o, in.length - start);
return out;
}
}

View File

@@ -0,0 +1,77 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for {@link StepUpApplet} — scaffold for the Aliro step-up phase
* (spec §8.4). This first pass only covers AID selection and
* INS-not-supported handling; the mdoc DeviceRequest/DeviceResponse +
* StepUpSK key derivation pipelines come in follow-up iterations.
*/
class StepUpAppletTest {
private CardSimulator sim;
@BeforeEach
void setUp() {
CredentialStore.get().resetForTesting();
sim = new CardSimulator();
AID aid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
sim.installApplet(aid, StepUpApplet.class);
}
@Test
void selectStepUpAidSucceeds() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
ResponseAPDU r = sim.transmitCommand(select);
assertEquals(0x9000, r.getSW(), "SELECT of step-up AID must succeed");
}
@Test
void selectStepUpAidReturnsFciWithAid() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
ResponseAPDU r = sim.transmitCommand(select);
byte[] aidFromFci = TlvUtil.findTopLevel(unwrap6F(r.getData()), 0x84);
org.junit.jupiter.api.Assertions.assertArrayEquals(AliroAids.STEP_UP, aidFromFci,
"FCI 84 tag must echo the step-up AID");
}
@Test
void unsupportedInsReturns6D00() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
sim.transmitCommand(select);
// Some arbitrary unsupported INS under the proprietary class.
CommandAPDU bogus = new CommandAPDU(0x80, 0xFE, 0x00, 0x00, new byte[0], 256);
assertEquals(0x6D00, sim.transmitCommand(bogus).getSW(),
"unrecognized INS on step-up applet must return SW_INS_NOT_SUPPORTED");
}
@Test
void wrongClaReturns6E00() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
sim.transmitCommand(select);
CommandAPDU wrongCla = new CommandAPDU(0x81, 0xC3, 0x00, 0x00, new byte[0], 256);
assertEquals(0x6E00, sim.transmitCommand(wrongCla).getSW(),
"wrong CLA must return SW_CLA_NOT_SUPPORTED");
}
/** Unwraps the outer 6F FCI template to expose its nested TLVs. */
private static byte[] unwrap6F(byte[] fci) {
if (fci.length < 2 || fci[0] != 0x6F) {
throw new IllegalArgumentException("expected a 6F-wrapped FCI template");
}
int len = fci[1] & 0xFF;
byte[] inner = new byte[len];
System.arraycopy(fci, 2, inner, 0, len);
return inner;
}
}

View File

@@ -0,0 +1,48 @@
package com.dangerousthings.aliro;
/**
* Minimal TLV reader for tests. Supports single-byte tags, multi-byte tags
* with BER continuation encoding, and short-form lengths (&lt; 128) plus
* 1- and 2-byte long-form lengths.
*/
final class TlvUtil {
private TlvUtil() {}
/**
* Finds the value bytes of the first TLV with the given tag within the
* given data, searching only at the top level (not recursing into nested
* constructed TLVs).
*
* @return the value bytes of the first match, or {@code null} if not found.
*/
static byte[] findTopLevel(byte[] data, int tag) {
int i = 0;
while (i < data.length) {
int tagStart = i;
int currentTag = data[i++] & 0xFF;
if ((currentTag & 0x1F) == 0x1F) {
int next;
do {
next = data[i++] & 0xFF;
currentTag = (currentTag << 8) | next;
} while ((next & 0x80) != 0);
}
int length = data[i++] & 0xFF;
if (length == 0x81) {
length = data[i++] & 0xFF;
} else if (length == 0x82) {
length = ((data[i++] & 0xFF) << 8) | (data[i++] & 0xFF);
}
if (currentTag == tag) {
byte[] value = new byte[length];
System.arraycopy(data, i, value, 0, length);
return value;
}
i += length;
// Silence unused-variable warning
if (tagStart < 0) break;
}
return null;
}
}