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:
4
applet/.gitignore
vendored
Normal file
4
applet/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.tools/
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
212
applet/INSTALL.md
Normal file
212
applet/INSTALL.md
Normal 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
145
applet/pom.xml
Normal 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
28
applet/scripts/dt-mvn.sh
Executable 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 $*"
|
||||
67
applet/scripts/install-jcardsim-gcm.sh
Executable file
67
applet/scripts/install-jcardsim-gcm.sh
Executable 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
|
||||
@@ -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);
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
/**
|
||||
* CSA Aliro v1.0 AIDs, spec §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() {}
|
||||
}
|
||||
1048
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
Normal file
1048
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
Normal file
File diff suppressed because it is too large
Load Diff
265
applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java
Normal file
265
applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java
Normal 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;
|
||||
}
|
||||
}
|
||||
286
applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
Normal file
286
applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
Normal 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 §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 §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);
|
||||
}
|
||||
}
|
||||
110
applet/src/main/java/com/dangerousthings/aliro/AliroHmac.java
Normal file
110
applet/src/main/java/com/dangerousthings/aliro/AliroHmac.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 §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();
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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 §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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
229
applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java
Normal file
229
applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
106
applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
Normal file
106
applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
410
applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java
Normal file
410
applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
48
applet/src/test/java/com/dangerousthings/aliro/TlvUtil.java
Normal file
48
applet/src/test/java/com/dangerousthings/aliro/TlvUtil.java
Normal 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 (< 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user