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

Three components, all bench-validated to varying depths:

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

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

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

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

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

87
.gitignore vendored Normal file
View File

@@ -0,0 +1,87 @@
# --- pre-existing ---
# Local build/test tooling, not committed (jcardsim fork etc.)
build-tools/
# --- secrets / private keys ---
# aliro-trustgen emits aliro_trust.h with the reader's PRIVATE scalar.
# Never commit, anywhere in the tree. Regeneratable from the trust bundle.
aliro_trust.h
**/aliro_trust.h
# Trust bundle artifacts (PEMs, group IDs, access docs) — these are produced
# outside the repo by aliro-trustgen and should never be inside it.
*.pem
*.key
issuer.pem
reader.pem
access_credential.pem
reader_group_id.bin
reader_group_sub_id.bin
access_document.bin
device_response.bin
# Misc credential / env material
.env
.env.*
secrets/
credentials/
# --- vendor archives (large, redownloadable) ---
x-cube-aliro.zip
*.zip
# --- vendor source clones (regeneratable via git clone in setup) ---
reader/vendor-sources/
# --- Python / harness ---
**/.venv/
**/__pycache__/
**/*.pyc
**/*.pyo
**/.pytest_cache/
**/.mypy_cache/
**/.ruff_cache/
**/*.egg-info/
**/.coverage
htmlcov/
harness/dist/
harness/out/
# --- Java / Maven applet build ---
applet/target/
applet/.tools/
**/*.class
# --- C / firmware build outputs ---
# CMake/Make/Ninja build dirs anywhere
**/build/
**/build-check/
**/build-debug/
**/build-release/
**/CMakeCache.txt
**/CMakeFiles/
# Compiled outputs (but NOT vendor's precompiled .a libraries — those are
# distributed as part of X-CUBE-ALIRO and live under Middlewares/ST/*/lib/
# and Projects/Common/Aliro/Lib/. They are vendor-shipped, not our build
# output, so we keep them.)
*.elf
*.bin
*.hex
*.o
*.obj
*.lst
*.map
*.su
*.d
# Java Card .cap (build product of applet/)
*.cap
# --- IDE / OS ---
.vscode/
.idea/
*.iml
*.swp
*.swo
.DS_Store
Thumbs.db

Binary file not shown.

4
applet/.gitignore vendored Normal file
View File

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

212
applet/INSTALL.md Normal file
View File

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

145
applet/pom.xml Normal file
View File

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

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,242 @@
# Aliro Credential — Design
**Date:** 2026-04-17
**Status:** Design agreed; ready for implementation planning.
## 1. Goal
Build a Java Card applet that implements the CSA **Aliro v1.0** User Device role over NFC, plus a hardware test fixture capable of exercising it end-to-end. Target deployment is dangerousthings.com's implantable Java Card products (VivoKey Apex family); generic J3R180 cards and jcardsim serve as intermediate validation stages.
## 2. Scope
### In scope (v1)
- **Transport:** NFC only (ISO-DEP / T4AT over NFC-A, per Aliro spec §10).
- **Protocol phases:** Expedited-standard, Expedited-fast, Step-up — the full NFC User Device role.
- **Roles provided by this project:**
- User Device (Java Card applet).
- Reader (instrumented test fixture on ST hardware).
- Credential Issuer (PC-side test CA).
- **Test fixture:** NUCLEO-U545RE-Q + X-NUCLEO-NFC09A1 (ST25R200) running a modified build of ST's X-CUBE-ALIRO reference. (Earlier draft of this doc said NFC10A1 — that was a part-number mistake. NFC10A1 carries ST25R3916, which is *not* supported by X-CUBE-ALIRO V1.0.0; only NFC09A1/ST25R200 and NFC12A1/ST25R500 ship with BSP drivers.)
- **Development progression for the applet:** jcardsim (fast loop) → J3R180 dev card (real hardware) → VivoKey Apex (final target).
### Explicitly out of scope (v1)
- BLE and BLE+UWB transports (spec §11, §12).
- Production-grade reader infrastructure. The reader is a test rig, not a product.
- Runtime reader provisioning. Trust anchors are compiled into reader firmware.
- Access Manager, back-end systems.
- Revocation Document generation as a CI-integrated workflow (generated manually from the test CA when a revocation test case is needed).
- **X.509 certificates of any kind.** v1 uses:
- `reader_PubK` authentication (spec §6.3) — the applet is provisioned with the raw Reader public key. No Reader certificate, no Reader System Issuer CA.
- `kid` header in the Access Document's IssuerAuth (spec §7.2.1) — the Reader is provisioned with the raw Credential Issuer public key. No `x5chain`, no Credential Issuer certificate.
- The `create_self_signed_ca` helper in `harness/issuer/ca.py` is retained for future v2 use but is not exercised in the v1 flow.
## 3. Architecture
Three cleanly separated components:
```
┌─────────────────────┐ NFC-A / T4AT ┌────────────────────┐
│ PC Harness (Py) │ │ Reader │
│ - Test CA / Issuer │◀── USB-CDC (logs) ──────────│ Nucleo-U545RE-Q │
│ - Personalizer │ │ + NFC09A1 │
│ - Trust-anchor gen │ │ mod X-CUBE-ALIRO │
│ - Reader log diff │ │ │
│ - E2E pytest │ └──────────┬─────────┘
└──────────┬──────────┘ │
│ PC/SC (pyscard) │ 13.56 MHz
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ User Device (Java Card applet) │
│ - AliroApplet (AIDs …5501 / …5502) │
│ - PersonalizationApplet (separate AID, locks after COMMIT) │
│ - jcardsim / J3R180 / Apex │
└─────────────────────────────────────────────────────────────┘
```
## 4. The Reader (test device)
### Hardware
- **MCU board:** NUCLEO-U545RE-Q (STM32U545RE).
- **NFC board:** X-NUCLEO-NFC09A1 (ST25R200), stacked via Arduino UNO R3 headers.
- Both officially supported by X-CUBE-ALIRO V1.0.0 (`Projects/nucleo-u5/Applications/Aliro/nfc-only/`); no wiring.
> **Hardware caveat:** earlier drafts of this doc paired NUCLEO-U545RE-Q with X-NUCLEO-**NFC10A1** (ST25R3916). That pairing was wrong — X-CUBE-ALIRO V1.0.0 does **not** include an ST25R3916 driver. The supported shields for the nucleo-u5 project are NFC09A1 (ST25R200, `nfc-only/`) and NFC12A1 (ST25R500, `nfc12-only/`).
### Firmware baseline
Start from `STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc-only`. Vendor the whole tree under `reader/vendor/` untouched. Modifications live beside it; a separate unmodified build (`reader/vendor-stock/`) stays flashable so the applet can be smoke-tested against reference firmware when instrumentation is suspect.
### Modifications
1. **Build-time trust anchors.** The PC harness generates `reader/aliro_trust.h` containing:
- Credential Issuer CA public key (`issuer_ca_pubkey_x/y`, 32 bytes each).
- Reader key pair (raw ECC P-256 private / public).
- Optional Reader System Issuer CA public key for reader_Cert validation.
Upstream hardcoded values in `Projects/Common/Aliro/Src/provisioning.c` are replaced via a `#include` hook. CMake depends on the generated file so regenerating forces a rebuild. `aliro_trust.h` is gitignored; only the generating inputs are version-controlled.
2. **Structured logging.** One tagged line per protocol event, emitted over the ST-LINK virtual COM port at 115200 8N1:
```
[ISO8601] [tag] [hex-payload] [short-note]
```
Hook points (all at layer boundaries, none inside crypto):
- Raw APDU in/out at T4AT.
- Phase transitions in `app_aliro.c`: `AUTH0_START`, `AUTH0_SENT`, `AUTH1_SENT`, `PRIVATE_CHANNEL_ESTABLISHED`, `USER_DEVICE_VERIFIED`, `EXCHANGE_IN/OUT`, `STEP_UP_START`, `DEVICE_REQUEST`, `DEVICE_RESPONSE`, `CONTROL_FLOW_SENT`.
- Plaintext of private_channel payloads, captured just after the stock code's AES-GCM decrypt and just before dispatch. Accepted because this is a test rig; never ship this path to production.
Implementation: a single `log_event(tag, buf, len, fmt, …)` wrapper funneling to `CDC_Transmit_FS`.
3. **LEDs preserved.** LD1 = grant (green blink), LD2 = deny (red blink), LD3 = transaction active. Eyes-only smoke testing without the PC.
## 5. The Applet (system under test)
### Targets
| Target | Role | JC version | Notes |
|---|---|---|---|
| jcardsim | Development / fast loop | 3.0.5 API | No hardware needed. |
| J3R180 | On-metal validation | 3.0.5 | Contactless dev card. |
| VivoKey Apex | Production | 3.0.5 | Same `.cap` where possible. |
### Structure
One applet class (`AliroApplet`), registered twice per spec §10.2.1:
- `A000000909ACCE5501` — Expedited Phase AID.
- `A000000909ACCE5502` — Step-up Phase AID.
A second, separate applet (`PersonalizationApplet`) under a non-Aliro AID handles provisioning. It accepts `SET_CRED_KEY`, `SET_ISSUER_PUBKEY`, `SET_ACCESS_DOC`, `COMMIT`, and `RESET` commands, rejects all state-modifying commands after `COMMIT`, and re-unlocks only via `RESET` authenticated with a personalization secret.
### State
- **Transient (RAM):** Active phase, per-transaction ephemeral keypair, derived session keys, GCM nonce counters.
- **Persistent (EEPROM/Flash):** Access Credential keypair (ECC P-256), Credential Issuer trust anchors, pre-signed Access Document bytes, up to 16 Kpersistent slots (each: reader_group_identifier 16 B + Kpersistent 32 B + iteration + expiry). Spec §6.2 mandates a minimum of 16 Kpersistent bindings per Access Credential.
### Crypto primitives
All of the following are required by Aliro v1.0 and present on Java Card 3.0.5 platforms of interest:
| Primitive | Spec use | JC API |
|---|---|---|
| ECC P-256 keygen | Ephemeral & long-term keys | `KeyBuilder.TYPE_EC_FP_*` |
| ECDH (raw point out) | §8.3.1.4 shared-key derivation | `KeyAgreement.ALG_EC_SVDP_DH_PLAIN_XY` |
| ECDSA-SHA-256 | §8.3.1.2 signing in AUTH1 | `Signature.ALG_ECDSA_SHA_256` |
| SHA-256 | Digests, HKDF compressor | `MessageDigest.ALG_SHA_256` |
| HMAC-SHA-256 | HKDF primitive | `Signature.ALG_HMAC_SHA_256` |
| HKDF | §8.3.1.5 key derivation | Built from HMAC-SHA-256 (~30 LOC). |
| AES-128-GCM | private_channel confidentiality/integrity | `Cipher.ALG_AES_GCM` (Apex support to confirm; CTR+GMAC fallback planned) |
| TRNG | §8.3.1.1 RNG | `RandomData.ALG_TRNG` |
### CBOR strategy
The applet does **not** sign CBOR. The Access Document arrives at personalization time already signed by the test CA; the applet stores the bytes and reuses the IssuerAuth verbatim.
During step-up, the applet parses just enough of the reader's `DeviceRequest` (CBOR map lookup by NameSpace + ElementIdentifier) to emit the matching `IssuerSignedItem`s and reuses the stored IssuerAuth unchanged. A hand-rolled decoder (~200 LOC, deterministic-encoding subset only, per spec §7.2 / RFC 8949 §4.2.1) avoids pulling a library into the applet.
### Flash budget (estimate)
- Applet code: ~12 KB.
- Persistent state: ~3 KB (keys + 16 Kpersistent slots + ~1.5 KB Access Document).
- Well within J3R180's 180 KB.
## 6. The PC Harness
### Language choices
- **Python 3.11** for everything PC-side (issuer / personalizer / log capture / E2E tests).
- **Java + JUnit** for the jcardsim fast loop (bolting Python onto jcardsim adds cost without value).
### Python libraries
| Purpose | Library |
|---|---|
| ECC P-256, ECDSA-SHA256, ECDH, HKDF, AES-GCM | `cryptography` |
| CBOR with deterministic encoding | `cbor2` |
| COSE_Sign1 for IssuerAuth | `pycose` |
| Direct PC/SC applet I/O | `pyscard` |
| Reader VCP capture | `pyserial` |
| Test CA X.509 | `cryptography.x509` |
### Test CA (`harness/issuer/`)
Deterministic and on-disk. Inputs version-controlled; outputs clearly marked TEST-ONLY. A single `trustgen` command regenerates:
- Credential Issuer CA key pair + root cert.
- Reader System Issuer CA key pair + root cert.
- A test Reader cert.
- A test Access Credential key pair.
- A default Access Document: permissive access rule, no schedule restriction, `TimeVerificationRequired=false`.
Outputs drop into `trustgen/out/`, where both (a) the applet personalizer and (b) the reader trust-anchor header generator consume them.
## 7. Repo layout
```
aliro_project/
├── applet/ # Java Card source (Gradle + ant-javacard)
│ ├── src/main/java/... # AliroApplet, PersonalizationApplet, CBOR, HKDF
│ └── src/test/java/... # JUnit + jcardsim tests
├── reader/
│ ├── vendor/ # STM32CubeExpansion_ALIRO_V1_0_0/ (modified)
│ ├── vendor-stock/ # Unmodified reference copy for diagnosis
│ └── aliro_trust.h # Generated, gitignored
├── harness/ # Python 3.11 + uv
│ ├── issuer/ # Test CA & Access Document builder
│ ├── personalize/ # pyscard-based loader for PersonalizationApplet
│ ├── reader_log/ # pyserial capture + tag parser + trace differ
│ ├── trustgen/ # Emits reader/aliro_trust.h from CA + reader keys
│ └── tests/ # End-to-end pytest (hardware required)
├── spec/ # Aliro 1.0 PDF + extracted AID/OID cheatsheet
└── docs/plans/ # This file.
```
## 8. Test strategy
Two complementary loops.
### Fast loop — jcardsim + JUnit
- Runs on every applet change. Seconds. No hardware.
- Covers protocol correctness, crypto math, state machine transitions, CBOR edge cases, Kpersistent slot eviction, step-up `DeviceRequest` variants.
- Drives the applet through the same APDUs the real reader would send.
### Hardware loop — pytest
- Personalize a card via PC/SC (pyscard) → place at reader antenna → capture reader VCP log → assert expected trace.
- Slow. Catches jcardsim-vs-real-card divergence (GCM availability, transient-object lifetimes, T=CL framing quirks).
- Gatekeeper before every card port (J3R180 → Apex).
### Top-level build targets
- `make applet` — JUnit + jcardsim tests, build `.cap`.
- `make reader` — regenerate trust header, build firmware, flash.
- `make e2e` — expects card on desk and reader enumerated on USB.
## 9. Security & safety notes
- The reader firmware logs private_channel plaintext. **Test-only.** The production branch (if any is ever cut from this code) must `#ifdef` those logs out. Flagged here so a future reader isn't tempted to ship this image.
- Test CA keys live in-repo, clearly marked. They exist to make flashes and resets reproducible; they have no production use.
- Personalization secret for the `PersonalizationApplet` `RESET` command lives in the harness config alongside the test CA — also test-only.
## 10. Open / deferred
- **AES-GCM availability on VivoKey Apex.** Needs verification before J3R180 → Apex port. If absent, the CTR+GMAC fallback path activates; that fallback is listed in §5 crypto primitives but not yet prototyped.
- **Exact `PersonalizationApplet` wire format.** Intentionally deferred; it's a private interface between our harness and our applet, not spec-driven, so the format gets designed during implementation with no external constraints.
- **CI.** No CI in v1; everything runs on the developer workstation. Revisit once the JUnit suite is substantial enough to want on every push.
- **Expedited-fast Kpersistent expiration policy.** Spec leaves this largely to the User Device. Default: LRU eviction at the 16-slot cap, no time-based expiry. Revisit if deployments need one.
## 11. Glossary (for quick reference)
- **User Device** — the thing being tapped (our Java Card applet).
- **Reader** — the thing tapping (our test fixture).
- **Access Credential** — the keypair unique to the User Device.
- **Access Document** — the CBOR/COSE blob signed by the Credential Issuer, carrying the Access Credential public key and any access rules.
- **IssuerAuth** — COSE_Sign1 structure inside the Access Document (spec §7).
- **Kpersistent** — symmetric key shared with a specific Reader group, used for the Expedited-fast cryptogram path.
- **reader_group_identifier** — 16-byte value identifying a set of Readers that share a trust context.

View File

@@ -0,0 +1,874 @@
# PC/SC Bench Reader Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a Python "fake reader" that drives the personalized J3R180 through a complete Aliro AUTH0+AUTH1 transaction over the user's existing PC/SC reader, decrypts the response, verifies the UD signature, and reports pass/fail. Validates the applet end-to-end on real hardware with zero firmware work.
**Architecture:** Port the Java reference reader (`applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java`, 410 LOC) to Python, wire it to the existing harness's pyscard transport (already used by `aliro-personalize`), expose as `aliro-bench-test` CLI. The protocol is agnostic to whether bytes flow over PC/SC contactless or via embedded NFC firmware — same APDUs either way.
**Tech Stack:** Python 3.11+, `cryptography` (P-256 ECDH/ECDSA, AES-GCM, HKDF, MessageDigest), `pyscard` (already in deps), `click` (CLI). All deps already present in `harness/pyproject.toml`.
---
## Pre-work: read the reference
**Required reading before implementing:**
- `applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java` — the Java reference. Every method has a Python equivalent in this plan.
- `applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java` — AUTH0 TLV builder.
- `applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java``processAuth0`, `processAuth1`, `buildSaltVolatile`, `buildTable813`. The reader mirrors this exactly.
- `harness/src/aliro_harness/personalizer/orchestrator.py` — pyscard transport pattern to reuse.
- 26-42802-001_Aliro_1.0_specification §8.3.1.13 (salt_volatile composition), §8.3.1.6 (AES-GCM details), §8.3.3.4.3 (Tables 8-12, 8-13).
**Reference trust constants (from our applet — used to compute salt_volatile bit-for-bit identically):**
- `PROPRIETARY_A5_TLV` = `A5 08 80 02 00 00 5C 02 01 00` (10 bytes)
- `INTERFACE_BYTE_NFC` = `0x5E`
- `SALT_VOLATILE_TAG` = ASCII `"Volatile****"` (12 bytes)
- `UD_SIGN_USAGE` = `4E 88 7B 4C` (Table 8-13 usage tag)
- Reader sig usage = `41 5D 95 69` (Table 8-12 usage tag)
- Protocol version = `0x0100`
---
## Task 1: ReaderSession scaffold + project layout
**Files:**
- Create: `harness/src/aliro_harness/reader/__init__.py`
- Create: `harness/src/aliro_harness/reader/session.py`
- Create: `harness/tests/test_reader_session.py`
**Step 1: Create the package directory**
```bash
mkdir -p harness/src/aliro_harness/reader
```
**Step 2: Write the failing test**
```python
# harness/tests/test_reader_session.py
from aliro_harness.reader.session import ReaderSession
def test_session_starts_with_no_transaction_state():
s = ReaderSession.fresh()
assert s.reader_ephem_priv is None
assert s.transaction_id is None
assert s.credential_ephem_pubk is None
```
**Step 3: Run — expect ImportError**
```bash
cd harness && . .venv/bin/activate && pytest tests/test_reader_session.py -v
```
Expected: `ModuleNotFoundError: No module named 'aliro_harness.reader.session'`
**Step 4: Minimal implementation**
```python
# harness/src/aliro_harness/reader/session.py
"""Per-transaction reader-side state for an Aliro NFC transaction."""
from dataclasses import dataclass, field
from typing import Optional
from cryptography.hazmat.primitives.asymmetric import ec
@dataclass
class ReaderSession:
"""Mutable per-transaction state. Mirrors fields kept in the Java
ReaderSide test fixture (applet/src/test/.../ReaderSide.java)."""
reader_ephem_priv: Optional[ec.EllipticCurvePrivateKey] = None
transaction_id: Optional[bytes] = None
credential_ephem_pubk: Optional[bytes] = None # 65B uncompressed, captured from AUTH0 response
@classmethod
def fresh(cls) -> "ReaderSession":
return cls()
```
**Step 5: Run — expect PASS**
```bash
pytest tests/test_reader_session.py::test_session_starts_with_no_transaction_state -v
```
**Step 6: Commit**
```bash
git add harness/src/aliro_harness/reader/ harness/tests/test_reader_session.py
git commit -m "feat(reader): scaffold ReaderSession dataclass for Aliro bench reader"
```
---
## Task 2: Auth0 command builder
**Files:**
- Create: `harness/src/aliro_harness/reader/auth0.py`
- Create: `harness/tests/test_reader_auth0.py`
**Step 1: Failing test for the data-field shape**
```python
# harness/tests/test_reader_auth0.py
from aliro_harness.reader.auth0 import build_auth0_data
def test_auth0_data_field_carries_six_mandatory_tlvs_in_spec_order():
reader_ephem_uncomp = bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32
data = build_auth0_data(
reader_ephem_pub_uncompressed=reader_ephem_uncomp,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
command_parameters=0x00,
authentication_policy=0x00,
)
assert data[0:3] == bytes([0x41, 0x01, 0x00]) # command_parameters
assert data[3:6] == bytes([0x42, 0x01, 0x00]) # authentication_policy
assert data[6:10] == bytes([0x5C, 0x02, 0x01, 0x00]) # protocol_version
assert data[10:12] == bytes([0x87, 0x41]) # reader_ePubK header (65B)
assert data[12:77] == reader_ephem_uncomp
assert data[77:79] == bytes([0x4C, 0x10]) # transaction_id header
assert data[79:95] == b"\xcc" * 16
assert data[95:97] == bytes([0x4D, 0x20]) # reader_identifier (32B = group||sub)
assert data[97:113] == b"\xaa" * 16
assert data[113:129] == b"\xbb" * 16
assert len(data) == 129
```
**Step 2: Run — expect ImportError → fail**
**Step 3: Implementation**
```python
# harness/src/aliro_harness/reader/auth0.py
"""AUTH0 command-data-field builder (spec §8.3.3.2.1, Table 8-4).
Mirrors applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
buildStandardData(). The applet validates this in
AliroApplet.validateAuth0Data — both sides must agree byte-for-byte.
"""
from io import BytesIO
CLA = 0x80
INS_AUTH0 = 0x80
PROTOCOL_VERSION_1_0 = 0x0100
def build_auth0_data(
*,
reader_ephem_pub_uncompressed: bytes,
reader_group_id: bytes,
reader_group_sub_id: bytes,
transaction_id: bytes,
command_parameters: int = 0x00,
authentication_policy: int = 0x00,
) -> bytes:
if len(reader_ephem_pub_uncompressed) != 65 or reader_ephem_pub_uncompressed[0] != 0x04:
raise ValueError("reader_ephem_pub must be 65B uncompressed (0x04 || x || y)")
if len(reader_group_id) != 16 or len(reader_group_sub_id) != 16:
raise ValueError("reader_group_{id,sub_id} must each be 16B")
if len(transaction_id) != 16:
raise ValueError("transaction_identifier must be 16B")
out = BytesIO()
_write_tlv(out, 0x41, bytes([command_parameters]))
_write_tlv(out, 0x42, bytes([authentication_policy]))
_write_tlv(out, 0x5C, PROTOCOL_VERSION_1_0.to_bytes(2, "big"))
_write_tlv(out, 0x87, reader_ephem_pub_uncompressed)
_write_tlv(out, 0x4C, transaction_id)
_write_tlv(out, 0x4D, reader_group_id + reader_group_sub_id)
return out.getvalue()
def _write_tlv(out: BytesIO, tag: int, value: bytes) -> None:
out.write(bytes([tag, len(value)]))
out.write(value)
def build_auth0_apdu(data: bytes) -> bytes:
"""Wraps AUTH0 data-field in a short-form APDU: 80 80 00 00 Lc data."""
return bytes([CLA, INS_AUTH0, 0x00, 0x00, len(data)]) + data
```
**Step 4: Run — expect PASS**
**Step 5: Add APDU-shape test + length-validation tests, run, expect PASS**
```python
def test_build_auth0_apdu_wraps_data_in_short_form():
apdu = build_auth0_apdu(b"\x41\x01\x00")
assert apdu == bytes([0x80, 0x80, 0x00, 0x00, 0x03, 0x41, 0x01, 0x00])
def test_reader_ephem_must_be_uncompressed():
import pytest
with pytest.raises(ValueError, match="65B uncompressed"):
build_auth0_data(
reader_ephem_pub_uncompressed=b"\x02" + b"\x11" * 32, # compressed
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
)
```
**Step 6: Commit**
```bash
git add harness/src/aliro_harness/reader/auth0.py harness/tests/test_reader_auth0.py
git commit -m "feat(reader): AUTH0 command builder per spec §8.3.3.2.1 Table 8-4"
```
---
## Task 3: TLV reader for AUTH0 response parsing
**Files:**
- Create: `harness/src/aliro_harness/reader/tlv.py`
- Create: `harness/tests/test_reader_tlv.py`
**Step 1: Failing test**
```python
# harness/tests/test_reader_tlv.py
from aliro_harness.reader.tlv import find_top_level
def test_find_top_level_returns_value_bytes():
# 86 41 [65B value] 9D 02 [2B value]
blob = bytes([0x86, 0x41]) + b"\xaa" * 65 + bytes([0x9D, 0x02, 0xff, 0xee])
assert find_top_level(blob, 0x86) == b"\xaa" * 65
assert find_top_level(blob, 0x9D) == bytes([0xff, 0xee])
assert find_top_level(blob, 0x42) is None
```
**Step 2: Run, expect FAIL**
**Step 3: Implementation (mirror `applet/src/test/java/com/dangerousthings/aliro/TlvUtil.java`)**
```python
# harness/src/aliro_harness/reader/tlv.py
"""Minimal TLV reader for AUTH0/AUTH1 response parsing.
Mirrors applet/src/test/.../TlvUtil.java. Single-byte tags only
(Aliro uses BER-TLV but in v1 every tag we care about is single-byte).
Short-form lengths plus 0x81 / 0x82 long-form for sizes ≥ 128.
"""
def find_top_level(data: bytes, tag: int) -> bytes | None:
"""Returns the value bytes of the first TLV whose tag matches, or None.
Does not recurse into constructed (0xA*) values."""
i = 0
while i < len(data):
cur_tag = data[i]
i += 1
length = data[i]
i += 1
if length == 0x81:
length = data[i]
i += 1
elif length == 0x82:
length = (data[i] << 8) | data[i + 1]
i += 2
if cur_tag == tag:
return data[i : i + length]
i += length
return None
```
**Step 4: Run, expect PASS. Add length-edge tests (0x81 long-form, 0x82 long-form, missing tag), commit.**
```bash
git add harness/src/aliro_harness/reader/tlv.py harness/tests/test_reader_tlv.py
git commit -m "feat(reader): minimal TLV reader for response parsing"
```
---
## Task 4: HKDF, ECDH, signing primitives wrapper
**Files:**
- Create: `harness/src/aliro_harness/reader/crypto.py`
- Create: `harness/tests/test_reader_crypto.py`
These thin wrappers exist purely so the rest of the reader code can do
`derive_kdh(...)` instead of repeating `cryptography` boilerplate everywhere
and so we have one place to lock the formula against a known vector.
**Step 1: Failing tests against RFC 5869 vectors + a Kdh consistency check**
```python
# harness/tests/test_reader_crypto.py
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.reader.crypto import (
derive_kdh,
ecdh_shared_x,
hkdf_sha256,
)
# RFC 5869 Test Case 1
RFC5869_T1_IKM = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
RFC5869_T1_SALT = bytes.fromhex("000102030405060708090a0b0c")
RFC5869_T1_INFO = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
RFC5869_T1_OKM_42 = bytes.fromhex(
"3cb25f25faacd57a90434f64d0362f2a"
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
"34007208d5b887185865"
)
def test_hkdf_sha256_matches_rfc5869_test_case_1():
assert hkdf_sha256(RFC5869_T1_IKM, RFC5869_T1_SALT, RFC5869_T1_INFO, 42) == RFC5869_T1_OKM_42
def test_ecdh_commutative():
a = ec.generate_private_key(ec.SECP256R1())
b = ec.generate_private_key(ec.SECP256R1())
a_pub_uncomp = bytes([0x04]) + a.public_key().public_numbers().x.to_bytes(32, "big") + a.public_key().public_numbers().y.to_bytes(32, "big")
b_pub_uncomp = bytes([0x04]) + b.public_key().public_numbers().x.to_bytes(32, "big") + b.public_key().public_numbers().y.to_bytes(32, "big")
assert ecdh_shared_x(a, b_pub_uncomp) == ecdh_shared_x(b, a_pub_uncomp)
def test_derive_kdh_agrees_on_both_sides():
a = ec.generate_private_key(ec.SECP256R1())
b = ec.generate_private_key(ec.SECP256R1())
a_pub = bytes([0x04]) + a.public_key().public_numbers().x.to_bytes(32, "big") + a.public_key().public_numbers().y.to_bytes(32, "big")
b_pub = bytes([0x04]) + b.public_key().public_numbers().x.to_bytes(32, "big") + b.public_key().public_numbers().y.to_bytes(32, "big")
txn = b"\x01" * 16
assert derive_kdh(a, b_pub, txn) == derive_kdh(b, a_pub, txn)
```
**Step 2: Run — expect ImportError**
**Step 3: Implementation**
```python
# harness/src/aliro_harness/reader/crypto.py
"""Reader-side crypto primitives for the Aliro NFC transaction.
Wraps cryptography's HKDF, ECDH, ECDSA, AES-GCM into Aliro-shaped helpers.
Intentionally NOT a re-export — these wrappers fix the algorithm choices
specified by Aliro §8.3.1 so callers can't pick the wrong hash, padding,
etc. by mistake.
"""
from cryptography.hazmat.primitives import hashes, hmac, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
"""HKDF-SHA-256 per RFC 5869. Empty salt is handled per §2.2 of the RFC
(treated as HashLen zeros)."""
return HKDF(
algorithm=hashes.SHA256(),
length=length,
salt=salt or b"\x00" * 32,
info=info,
).derive(ikm)
def ecdh_shared_x(priv: ec.EllipticCurvePrivateKey, peer_pub_uncompressed_65: bytes) -> bytes:
"""ECDH P-256: returns the 32-byte x-coordinate of the shared point."""
if len(peer_pub_uncompressed_65) != 65 or peer_pub_uncompressed_65[0] != 0x04:
raise ValueError("peer pubkey must be 65B uncompressed")
peer = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), peer_pub_uncompressed_65)
return priv.exchange(ec.ECDH(), peer) # cryptography returns the 32B x-coord
def derive_kdh(
reader_ephem_priv: ec.EllipticCurvePrivateKey,
credential_ephem_pub_uncompressed: bytes,
transaction_id: bytes,
) -> bytes:
"""§8.3.1.4 (with §8.3.1.5 substitution): Kdh = HKDF(IKM=ECDH_x,
salt=transaction_id, info=∅, L=32)."""
z_ab = ecdh_shared_x(reader_ephem_priv, credential_ephem_pub_uncompressed)
return hkdf_sha256(z_ab, transaction_id, b"", 32)
```
**Step 4: Run, expect PASS for all three tests.**
**Step 5: Commit**
```bash
git add harness/src/aliro_harness/reader/crypto.py harness/tests/test_reader_crypto.py
git commit -m "feat(reader): HKDF/ECDH/Kdh primitives, locked to Aliro algorithm choices"
```
---
## Task 5: salt_volatile + ExpeditedSKDevice derivation
**Files:**
- Create: `harness/src/aliro_harness/reader/key_derivation.py`
- Create: `harness/tests/test_reader_key_derivation.py`
**Important:** salt_volatile MUST be byte-identical to what the applet builds in `AliroApplet.buildSaltVolatile`. If even one byte differs, the keys diverge and decryption fails. Cross-reference both implementations as you write.
**Step 1: Failing test that asserts salt_volatile shape per §8.3.1.13**
```python
# harness/tests/test_reader_key_derivation.py
from aliro_harness.reader.key_derivation import build_salt_volatile
def test_salt_volatile_layout_per_spec_8_3_1_13():
salt = build_salt_volatile(
reader_long_term_pub_x=b"\x01" * 32,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
reader_ephem_pub_x=b"\x33" * 32,
transaction_id=b"\xcc" * 16,
command_parameters=0x00,
authentication_policy=0x00,
credential_long_term_pub_x=b"\x55" * 32,
)
p = 0
assert salt[p : p + 32] == b"\x01" * 32; p += 32 # x(reader_group_identifier_key)
assert salt[p : p + 12] == b"Volatile****"; p += 12
assert salt[p : p + 16] == b"\xaa" * 16; p += 16 # group_id
assert salt[p : p + 16] == b"\xbb" * 16; p += 16 # sub_id
assert salt[p] == 0x5E; p += 1 # interface_byte = NFC
assert salt[p : p + 2] == bytes([0x5C, 0x02]); p += 2 # literal
assert salt[p : p + 2] == bytes([0x01, 0x00]); p += 2 # protocol_version
assert salt[p : p + 32] == b"\x33" * 32; p += 32 # x(reader_ephem)
assert salt[p : p + 16] == b"\xcc" * 16; p += 16 # txn_id
assert salt[p : p + 2] == bytes([0x00, 0x00]); p += 2 # flag = cmd_params || auth_policy
assert salt[p : p + 10] == bytes.fromhex("A50880020000 5C020100".replace(" ", "")); p += 10
assert salt[p : p + 32] == b"\x55" * 32; p += 32 # x(credential_long_term)
assert len(salt) == p == 173
```
**Step 2: Run, expect FAIL**
**Step 3: Implementation**
```python
# harness/src/aliro_harness/reader/key_derivation.py
"""Reader-side mirror of AliroApplet.buildSaltVolatile / deriveSessionKeys.
Keep this BYTE-IDENTICAL to the applet implementation in
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
(buildSaltVolatile). Drift here = decryption fails on real card.
"""
from io import BytesIO
from aliro_harness.reader.crypto import hkdf_sha256
SALT_VOLATILE_TAG = b"Volatile****"
INTERFACE_BYTE_NFC = 0x5E
PROPRIETARY_A5_TLV = bytes.fromhex("A50880020000" "5C020100")
PROTOCOL_VERSION_1_0 = bytes([0x01, 0x00])
# Offsets into the 160B derived_keys_volatile (§8.3.1.13).
OFF_EXPEDITED_SK_READER = 0
OFF_EXPEDITED_SK_DEVICE = 32
OFF_STEP_UP_SK = 64
def build_salt_volatile(
*,
reader_long_term_pub_x: bytes,
reader_group_id: bytes,
reader_group_sub_id: bytes,
reader_ephem_pub_x: bytes,
transaction_id: bytes,
command_parameters: int,
authentication_policy: int,
credential_long_term_pub_x: bytes,
) -> bytes:
"""Per spec §8.3.1.13. Mirrors AliroApplet.buildSaltVolatile."""
out = BytesIO()
out.write(reader_long_term_pub_x) # 32
out.write(SALT_VOLATILE_TAG) # 12
out.write(reader_group_id) # 16
out.write(reader_group_sub_id) # 16
out.write(bytes([INTERFACE_BYTE_NFC])) # 1
out.write(bytes([0x5C, 0x02])) # 2
out.write(PROTOCOL_VERSION_1_0) # 2
out.write(reader_ephem_pub_x) # 32
out.write(transaction_id) # 16
out.write(bytes([command_parameters, authentication_policy])) # 2
out.write(PROPRIETARY_A5_TLV) # 10
out.write(credential_long_term_pub_x) # 32
return out.getvalue()
def derive_expedited_session_keys(
kdh: bytes,
salt_volatile: bytes,
info_credential_ephem_pub_x: bytes,
) -> bytes:
"""§8.3.1.13: derived_keys_volatile (160B). info = x(credential_ephem_pub) +
AUTH0 vendor extensions (none in v1)."""
return hkdf_sha256(kdh, salt_volatile, info_credential_ephem_pub_x, 160)
```
**Step 4: Run, expect PASS. Add a derive_expedited_session_keys test that round-trips against a fixed kdh+salt+info, expect PASS, commit.**
```bash
git add harness/src/aliro_harness/reader/key_derivation.py harness/tests/test_reader_key_derivation.py
git commit -m "feat(reader): salt_volatile + expedited session key derivation"
```
---
## Task 6: AUTH1 command builder + Table 8-12 signing
**Files:**
- Create: `harness/src/aliro_harness/reader/auth1.py`
- Create: `harness/tests/test_reader_auth1.py`
**Step 1: Failing test for Table 8-12 byte-shape**
```python
# harness/tests/test_reader_auth1.py
from aliro_harness.reader.auth1 import build_table_812
def test_table_812_layout_per_spec_table_8_12():
t = build_table_812(
reader_id_32=b"\x4d" * 32,
credential_ephem_pub_x=b"\x86" * 32,
reader_ephem_pub_x=b"\x87" * 32,
transaction_id=b"\x4c" * 16,
)
assert t[0:2] == bytes([0x4D, 0x20])
assert t[2:34] == b"\x4d" * 32
assert t[34:36] == bytes([0x86, 0x20])
assert t[36:68] == b"\x86" * 32
assert t[68:70] == bytes([0x87, 0x20])
assert t[70:102] == b"\x87" * 32
assert t[102:104] == bytes([0x4C, 0x10])
assert t[104:120] == b"\x4c" * 16
assert t[120:122] == bytes([0x93, 0x04])
assert t[122:126] == bytes.fromhex("415D9569") # reader sig usage
assert len(t) == 126 # 5 TLVs: 34+34+34+18+6 (NOT 128 — earlier draft was off-by-2)
```
**Step 2: Run, FAIL**
**Step 3: Implementation**
```python
# harness/src/aliro_harness/reader/auth1.py
"""AUTH1 command builder + Table 8-12 reader-signature input.
Mirrors applet/src/test/.../ReaderSide.java buildTable812 + buildAuth1Data.
"""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
CLA = 0x80
INS_AUTH1 = 0x81
USAGE_READER_AUTH1 = bytes([0x41, 0x5D, 0x95, 0x69])
USAGE_UD_AUTH1 = bytes([0x4E, 0x88, 0x7B, 0x4C])
def build_table_812(
*,
reader_id_32: bytes,
credential_ephem_pub_x: bytes,
reader_ephem_pub_x: bytes,
transaction_id: bytes,
) -> bytes:
return _build_table_8_12_or_13(
reader_id_32, credential_ephem_pub_x, reader_ephem_pub_x,
transaction_id, USAGE_READER_AUTH1,
)
def build_table_813(
*,
reader_id_32: bytes,
credential_ephem_pub_x: bytes,
reader_ephem_pub_x: bytes,
transaction_id: bytes,
) -> bytes:
"""Same TLV shape as Table 8-12, only the usage tag differs."""
return _build_table_8_12_or_13(
reader_id_32, credential_ephem_pub_x, reader_ephem_pub_x,
transaction_id, USAGE_UD_AUTH1,
)
def _build_table_8_12_or_13(rid, cred_x, reader_x, txn, usage) -> bytes:
return (
bytes([0x4D, 0x20]) + rid
+ bytes([0x86, 0x20]) + cred_x
+ bytes([0x87, 0x20]) + reader_x
+ bytes([0x4C, 0x10]) + txn
+ bytes([0x93, 0x04]) + usage
)
def sign_table_812_raw(table_812: bytes, reader_priv: ec.EllipticCurvePrivateKey) -> bytes:
"""Returns 64B raw r||s ECDSA-SHA-256 signature."""
der = reader_priv.sign(table_812, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der)
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
def build_auth1_data(raw_sig_64: bytes, command_parameters: int = 0x01) -> bytes:
"""AUTH1 command data field per spec Table 8-10. cmd_params bit 0 = 1
means 'return credential_PubK' (simpler than the SHA-1 key_slot path)."""
if len(raw_sig_64) != 64:
raise ValueError("reader signature must be 64B raw r||s")
return (
bytes([0x41, 0x01, command_parameters])
+ bytes([0x9E, 0x40]) + raw_sig_64
)
def build_auth1_apdu(data: bytes) -> bytes:
return bytes([CLA, INS_AUTH1, 0x00, 0x00, len(data)]) + data
```
**Step 4: Run + add tests for the sign-then-verify round trip and apdu wrapping. Commit.**
```bash
git add harness/src/aliro_harness/reader/auth1.py harness/tests/test_reader_auth1.py
git commit -m "feat(reader): Table 8-12/8-13 builders + reader sig + AUTH1 APDU"
```
---
## Task 7: AUTH1 response decryption + UD signature verification
**Files:**
- Create: `harness/src/aliro_harness/reader/auth1_response.py`
- Create: `harness/tests/test_reader_auth1_response.py`
**Step 1: Failing test that round-trips a stub plaintext**
```python
# harness/tests/test_reader_auth1_response.py
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.auth1_response import decrypt_auth1_response
def test_decrypt_auth1_response_roundtrips_with_device_counter_1():
sk = b"\x42" * 32
iv = b"\x00" * 7 + b"\x01" + b"\x00\x00\x00\x01"
plaintext = b"\xAB" * 17
ct_and_tag = AESGCM(sk).encrypt(iv, plaintext, associated_data=None)
assert decrypt_auth1_response(sk, ct_and_tag) == plaintext
```
**Step 2: Run, FAIL**
**Step 3: Implementation**
```python
# harness/src/aliro_harness/reader/auth1_response.py
"""AUTH1 response: AES-256-GCM decrypt + UD signature verification.
device_counter == 1 because we are the FIRST step-up-eligible response in
the session (spec §8.3.1.13 initializes expedited_device_counter = 1).
"""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_auth1_response(sk_device: bytes, ciphertext_with_tag: bytes) -> bytes:
"""IV per §8.3.1.6: 0x00..00 0x01 || expedited_device_counter (4B BE)."""
iv = b"\x00" * 7 + b"\x01" + (1).to_bytes(4, "big")
return AESGCM(sk_device).decrypt(iv, ciphertext_with_tag, associated_data=None)
def verify_ud_signature(
credential_long_term_pub_uncompressed: bytes,
table_813: bytes,
raw_sig_64: bytes,
) -> bool:
if len(raw_sig_64) != 64:
raise ValueError("UD signature must be 64B raw r||s")
pub = ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), credential_long_term_pub_uncompressed
)
r = int.from_bytes(raw_sig_64[:32], "big")
s = int.from_bytes(raw_sig_64[32:], "big")
der = encode_dss_signature(r, s)
try:
pub.verify(der, table_813, ec.ECDSA(hashes.SHA256()))
return True
except Exception:
return False
```
**Step 4: Run, PASS. Add a verify-roundtrip test (sign with random priv, verify with matching pub). Commit.**
```bash
git add harness/src/aliro_harness/reader/auth1_response.py harness/tests/test_reader_auth1_response.py
git commit -m "feat(reader): AUTH1 response decrypt + UD signature verification"
```
---
## Task 8: Top-level transaction orchestrator (offline / mockable transport)
**Files:**
- Create: `harness/src/aliro_harness/reader/transaction.py`
- Create: `harness/tests/test_reader_transaction_offline.py`
**Step 1: Failing test driven by an in-process fake card**
The fake card mirrors AliroApplet.processAuth0/processAuth1 in Python so
we can validate the full reader pipeline without ANY card connected. This
is the first end-to-end proof the reader is correct independent of pyscard.
```python
# harness/tests/test_reader_transaction_offline.py
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.reader.transaction import (
TrustBundle,
run_aliro_transaction,
)
from tests.fake_card import FakeAliroCard # built next
def test_transaction_against_in_process_fake_card_succeeds():
issuer = ec.generate_private_key(ec.SECP256R1())
cred = ec.generate_private_key(ec.SECP256R1())
reader = ec.generate_private_key(ec.SECP256R1())
bundle = TrustBundle.synthesize(reader_priv=reader, credential_pub=cred.public_key())
fake = FakeAliroCard.provisioned(
reader_pub=reader.public_key(),
credential_priv=cred,
credential_pub=cred.public_key(),
)
result = run_aliro_transaction(transmit=fake.transmit, bundle=bundle)
assert result.ok
assert result.signaling_bitmap == bytes([0x00, 0x00]) # no AD provisioned in fake
```
**Step 2-N:** Build the orchestrator step by step. Each substep is its own commit:
- 8a: TrustBundle dataclass loading PEMs from a trust dir (mirror personalizer's TrustArtifacts pattern)
- 8b: TransactionResult dataclass (ok, plaintext_bytes, ud_sig, signaling_bitmap, error?)
- 8c: run_aliro_transaction() that orchestrates: SELECT EXPEDITED → AUTH0 → parse 0x86 → derive_kdh → derive_session_keys → build Table 8-12 → sign → AUTH1 → decrypt response → extract 0x9E → build Table 8-13 → verify_ud_signature
- 8d: tests/fake_card.py — Python port of AliroApplet just for testing (does enough of processAuth0/processAuth1 to validate the reader)
The fake card is non-trivial (~200 LOC) but it's the only way to validate the reader before flashing real card. Without it, the first time we'd know the reader works is on the J3R180 — too late to debug efficiently.
**Verification:** `pytest harness/tests/test_reader_transaction_offline.py -v` → green.
**Commit:** one commit per substep.
---
## Task 9: pyscard CLI: `aliro-bench-test`
**Files:**
- Create: `harness/src/aliro_harness/reader/cli.py`
- Modify: `harness/pyproject.toml` (add `aliro-bench-test` entry point)
- Create: `harness/tests/test_reader_cli.py`
**Step 1: Mirror the personalizer CLI's `--reader / --list-readers` shape**
Use `aliro_harness.personalizer.cli` as reference — it already does pyscard
discovery, lazy import, and click error mapping. Reuse that pattern.
**Step 2: CLI structure**
```python
# harness/src/aliro_harness/reader/cli.py
@click.command()
@click.option("--trust-dir", required=False, ...)
@click.option("--reader", "reader_index", default=0)
@click.option("--list-readers", is_flag=True)
def main(trust_dir, reader_index, list_readers):
"""Run a single Aliro AUTH0+AUTH1 transaction against a personalized
card via PC/SC. Decrypts the response and verifies the UD signature.
Exits 0 on success, non-zero with a clear error on any failure."""
# ... lazy import smartcard.System, build TrustBundle, connect,
# wrap connection.transmit as Transmit callable, call
# run_aliro_transaction, pretty-print the result.
```
**Step 3-N:** Tests via CliRunner + monkeypatched smartcard (mirror `tests/test_personalizer_cli.py`).
**Step 4: Add entry point to pyproject.toml**
```toml
[project.scripts]
aliro-trustgen = "aliro_harness.trustgen.cli:main"
aliro-personalize = "aliro_harness.personalizer.cli:main"
aliro-bench-test = "aliro_harness.reader.cli:main"
```
**Step 5: Reinstall + verify CLI registers**
```bash
pip install -e . -q
aliro-bench-test --help
```
**Commit each substep.**
---
## Task 10: Real-card smoke test + documentation
**Pre-req:** card has been personalized via `aliro-personalize --trust-dir ~/aliro-trust`.
**Step 1: Run against real J3R180**
```bash
aliro-bench-test --trust-dir ~/aliro-trust
```
Expected output (approx):
```
Using reader: ACS ACR1252 1S CL Reader 0
Connected. ATR: 3b8a80014a546178436f7265563150
SELECT EXPEDITED: 9000 (FCI 21 bytes)
AUTH0: 9000 (credential_ePubK 65B)
AUTH1: 9000 (encrypted response 153B)
Decrypted plaintext: 137B
0x5A credential_PubK: 65B
0x9E UD signature: 64B → verifies under credential_PubK
0x5E signaling_bitmap: 0x0005 (access_doc available, requires step-up)
RESULT: OK — applet round-trip on real hardware.
```
**Step 2: Update `applet/INSTALL.md`** with a "Validate after personalization" section pointing at `aliro-bench-test`.
**Step 3: Save a memory entry** (`~/.claude/projects/.../memory/`) documenting that `aliro-bench-test` is the real-hardware validation path until firmware is ready.
**Step 4: Commit + open the discussion about Track 2 (firmware port).**
---
## Verification (whole-plan acceptance)
After all tasks:
- `cd harness && . .venv/bin/activate && pytest -q` → all tests pass (existing 80 + new ~25 from Tasks 1-9 = ~105 total)
- `aliro-bench-test --list-readers` enumerates the PC/SC reader
- `aliro-bench-test --trust-dir ~/aliro-trust` runs a full transaction against the personalized J3R180 and prints "RESULT: OK"
- The Java applet test suite (`./scripts/dt-mvn.sh test` from `applet/`) still passes — this plan touches no applet code
## Risks / known unknowns
1. **PC/SC reader contactless capabilities.** Some readers (ACR122U) have known APDU-length quirks at the T=CL layer. If our 153B encrypted AUTH1 response is fragmented or rejected, we'll need to investigate `--bs` / extended-length handling. Mitigation: use ACR1252U or similar modern reader.
2. **Card timing.** ECDSA sign on the J3R180 takes 100-300ms. The `connection.transmit` call will block; that's normal. If we hit a 2s+ delay it's a sign genKeyPair() is being called per AUTH1 instead of once-only — bug in our lazy init.
3. **Salt_volatile drift.** If we change `AliroApplet.PROPRIETARY_A5_TLV` / `INTERFACE_BYTE_NFC` / `SALT_VOLATILE_TAG` on one side without the other, decryption fails with no useful error. Cross-reference both implementations whenever either is touched. This plan uses the constants that exist in the applet today; verify they haven't drifted before running Task 10.

View File

@@ -0,0 +1,513 @@
# Bring up X-NUCLEO-NFC10A1 as an Aliro Reader on NUCLEO-U545RE-Q Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Produce a second independent end-to-end validation of the Aliro applet by running ST's X-CUBE-ALIRO reader firmware on NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1, hitting AUTH0 + AUTH1 green against our personalized J3R180. The PC/SC bench reader (`aliro-bench-test`) already proves the applet — this adds an MCU-driven reader path.
**Architecture:** X-NUCLEO-NFC10A1 and X-NUCLEO-NFC09A1 both carry the **same** ST25R200 NFC reader IC (ST UM3414 covers both boards). X-CUBE-ALIRO already ships a working NFC09A1 project at `Projects/nucleo-u5/Applications/Aliro/nfc-only/` that uses `Drivers/BSP/Components/st25r200/` — the chip driver is reusable verbatim. The port is board-level only: clone the `nfc-only/` project, remap pin/LED assignments from NFC09A1's Arduino header layout to NFC10A1's (plus add an explicit reset pin that NFC10A1 exposes), wire `aliro-trustgen`'s generated header into the build so the reader's long-term keys match what the J3R180 was personalized with, and add instrumentation. **No new chip driver fetch is required** (an earlier draft of this plan wrongly claimed NFC10A1 was ST25R3916 and would need X-CUBE-NFC7/NFC6 ported in — memory [j3r180_ec_curve_seeding.md](.claude/projects/-home-work-VSCodeProjects-aliro-project/memory/x_cube_aliro_supported_boards.md) now captures the correct chip pairings).
**Tech Stack:** NUCLEO-U545RE-Q (STM32U545RE MCU), X-NUCLEO-NFC10A1 (ST25R200 reader IC), CMake + `arm-none-eabi-gcc` 13.2+, STM32CubeProgrammer (or `st-flash`/OpenOCD) for flashing, ST-LINK onboard the Nucleo, RFAL + ST25R200 driver (both already in the X-CUBE-ALIRO vendor tree).
---
## Pre-flight toolchain gaps
Before Task 4 (build) the host needs:
```bash
sudo apt install ninja-build
```
Before Task 5 (flash) the host needs one of:
- **STM32CubeProgrammer** (preferred — ST-LINK out of the box): [st.com/en/development-tools/stm32cubeprog.html](https://www.st.com/en/development-tools/stm32cubeprog.html)
- `sudo apt install stlink-tools` (provides `st-flash`)
- `sudo apt install openocd`
`arm-none-eabi-gcc` 13.2.1, `cmake` 3.28 are already present (verified).
---
## Critical files and locations
**Template project (DO NOT MODIFY — clone from here):**
- [reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc-only/](reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc-only/)
**Shared code (DO NOT MODIFY — inherited as-is by new project):**
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/Components/st25r200/` — chip driver
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC09A1/` — BSP shim (the current project uses NFC09A1's macros; we'll add an NFC10A1 one)
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Middlewares/ST/rfal/` — RFAL middleware
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/` — Aliro protocol layer (where trust is injected in Task 4)
**New dirs we will create:**
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC10A1/` — BSP shim copied + renamed from NFC09A1
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc10-only/` — project root cloned from `nfc-only/`
**NFC10A1 pin-map reference (stm32duino):**
- `/tmp/x-nucleo-nfc10a1-ref/examples/X_NUCLEO_NFC10A1_HelloWorld/X_NUCLEO_NFC10A1_HelloWorld.ino` (already cloned this session)
- The `#define` block in that file is the authoritative Arduino-header pin map for NFC10A1.
**Trust bundle:** `~/aliro-trust/` (from `aliro-trustgen init`, same bundle the J3R180 was personalized with).
**Harness-side comparison point:** `aliro-bench-test --trust-dir ~/aliro-trust``RESULT: OK` is the known-good reference.
---
## Task 1: Clone the BSP shim for NFC10A1
**Files:**
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC10A1/nfc10a1.h`
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC10A1/nfc10a1.c`
- Reference (read-only): `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC09A1/nfc09a1.{h,c}`
**Step 1: Copy the NFC09A1 shim**
```bash
cd reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP
cp -r NFC09A1 NFC10A1
cd NFC10A1
mv nfc09a1.h nfc10a1.h
mv nfc09a1.c nfc10a1.c
```
**Step 2: Rename identifiers inside the files**
```bash
sed -i 's/NFC09A1/NFC10A1/g; s/nfc09a1/nfc10a1/g' nfc10a1.h nfc10a1.c
```
The chip driver include (`st25r200.h`) must STAY unchanged — same chip. Grep to confirm:
```bash
grep -c st25r200 nfc10a1.{h,c} # should be ≥1 in each file
grep -c st25r500 nfc10a1.{h,c} # must be 0
```
**Step 3: Verify BSP API surface is unchanged**
The top-level `Projects/Common/Aliro/Src/app_nfc.c` calls e.g. `BSP_NFC0XCOMM_SendRecv(...)` — those symbol names are generic (NFC0X prefix) and don't change between NFC09A1 and NFC10A1. Confirm by grepping `nfc10a1.c` for `BSP_NFC0XCOMM_` — symbols must match the NFC09A1 original.
**Step 4: Commit (if reader/ is under git; currently the project isn't a git repo — create a note file instead)**
Since [/home/work/VSCodeProjects/aliro_project](.) isn't a git repository, track changes in a plain ledger file at `reader/bench-notes/nfc10a1-port-log.md`: append a line noting Task 1 completion with a timestamp.
---
## Task 2: Clone `nfc-only/` → `nfc10-only/` project
**Files:**
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc10-only/` (whole subtree)
**Step 1: Recursive copy**
```bash
cd reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro
cp -r nfc-only nfc10-only
```
**Step 2: Rename the per-board Target directory**
```bash
cd nfc10-only
mv X-CUBE-NFC9 X-CUBE-NFC10
```
**Step 3: Update nfc_conf.h for NFC10A1 layout**
Edit `nfc10-only/X-CUBE-NFC10/Target/nfc_conf.h`:
- Change include guard: `__NFC09A1_CONF_H__``__NFC10A1_CONF_H__`
- Rename every `NFC09A1_*` macro to `NFC10A1_*` (LED1-LED6 RCC clock enables + pin definitions)
- **Pin remap per NFC10A1 Arduino header:**
| Function | NFC09A1 (current) | NFC10A1 (new) | Nucleo-U545RE-Q pin |
|----------|-------------------|---------------|---------------------|
| SPI CS (`ST25R_SS`) | PC9 (line 87-88) | D10 → PB6 (verify against `stm32u5xx_nucleo_bus.h`) | **update** |
| SPI IRQ (`ST25R_INT`) | PA0 (line 89-90) | A0 → PA0 (same) | unchanged |
| **Reset** (NEW — NFC10A1 exposes) | — | D8 → PA9 (typical U5 mapping; verify) | **add new** |
| LED A | PB6 | A2 → PA4 (verify) | **update** |
| LED B | PB0 | A3 → PB0 (may be same) | **verify** |
| LED F | PB10 | D5 → PB4 (verify) | **update** |
| LED V | PA8 | D7 → PA8 (may be same) | **verify** |
| LED AP2P | PB10 | D6 → PB10 (may be same) | **verify** |
| LED FIELD | PA1 | A1 → PA1 (may be same) | **verify** |
**Authoritative source for Arduino→MCU mapping:** `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NUCLEO-U545RE-Q/stm32u5xx_nucleo_bus.h` — open it and grep for `ARDUINO_D10_PIN`, `ARDUINO_A0_PIN`, etc. If the header uses macros like `ARDUINO_Dx_Pin` / `ARDUINO_Ax_Pin`, reference those directly (avoids hardcoding PAx literals that could drift).
- Add reset pin definitions (NFC10A1-specific, absent from NFC09A1):
```c
#define NFC10A1_RESET_PIN GPIO_PIN_9 /* or whatever D8 resolves to */
#define NFC10A1_RESET_PIN_PORT GPIOA
#define NFC10A1_RESET_PIN_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
```
Plumb the reset line through to the `st25r200` driver if the driver has a reset hook (check `st25r200.c` for a function like `st25r200_Reset()` or similar; the NFC12 template uses `BUS_SPI1_RST_GPIO_PIN` per the explorer's diff).
- Include path update: `#include "rfal_defConfig.h"` stays the same (shared); the board-specific bits should now reference `nfc10a1.h` (not `nfc09a1.h`) if included.
**Step 4: Update rfal_platform.h**
Open `nfc10-only/X-CUBE-NFC10/Target/rfal_platform.h`. The platform macros (`platformSpiSelect`, `platformSpiTxRx`, `platformProtectST25RComm`) bind to `ST25R_SS_PIN/PORT` etc. — those come from nfc_conf.h so if the pins there are right, this file just needs the header guard rename (and any `NFC09A1`/`NFC9` references → `NFC10A1`/`NFC10`).
**Step 5: Update CMake**
`nfc10-only/cmake/stm32cubemx/CMakeLists.txt`:
- Change project/target name: any `nfc-only` or `nfc_only` → `nfc10-only` / `nfc10_only`
- Swap include dir: `Drivers/BSP/NFC09A1` → `Drivers/BSP/NFC10A1`
- Update Target include: `X-CUBE-NFC9/Target` → `X-CUBE-NFC10/Target`
The `ST25R200` CMake define stays — same chip.
`nfc10-only/CMakeLists.txt` (top-level): update project name.
**Step 6: Update main.c init**
In `nfc10-only/Core/Src/main.c`, search for `NFC09A1_` or `BSP_NFC09A1_` and replace with `NFC10A1_` / `BSP_NFC10A1_` equivalents. The `BSP_NFC0XCOMM_*` generic names stay.
**Step 7: Append to the port-log ledger**
```
reader/bench-notes/nfc10a1-port-log.md
```
---
## Task 3: First build — iterate on errors
**Files:** no new files; fix whatever the build surfaces.
**Step 1: Configure**
```bash
cd reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc10-only
cmake -B build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/stm32cubemx/gcc-arm-none-eabi.cmake \
-DCMAKE_BUILD_TYPE=Debug
```
(If ninja wasn't installed, fall back to `-G "Unix Makefiles"` and use `cmake --build build` / `make -j`.)
**Step 2: Build and triage**
```bash
cmake --build build 2>&1 | tee build.log
```
Expected first-pass errors (iterate):
- **Undefined reference to `BSP_NFC10A1_Init`** or similar → Task 1 rename incomplete; grep for stale `NFC09A1` in the new shim.
- **`nfc09a1.h: No such file`** → stale include somewhere in nfc10-only; replace with `nfc10a1.h`.
- **`st25r200.h: No such file`** should NOT appear — if it does, the Components include path was accidentally dropped in Task 2 Step 5.
- **`ARDUINO_Dx_Pin undefined`** → the pin macro doesn't exist on this board's `stm32u5xx_nucleo_bus.h`; fall back to hardcoded PAx literals per NUCLEO-U545RE-Q schematic (UM3414 App A for pinout tables).
- **Reset pin symbol unused** → you defined `NFC10A1_RESET_*` but didn't wire it into init. Either remove the def if the driver doesn't need it, or add a `HAL_GPIO_WritePin` pulse in `nfc10a1.c::BSP_NFC10A1_Init()`.
Fix one class of error at a time.
**Step 3: Confirm artifacts produced**
```bash
ls -la build/nfc10-only.elf build/nfc10-only.bin
```
Expected: both exist, `.bin` ~100-200 KB.
**Step 4: Record build success**
Append to `reader/bench-notes/nfc10a1-port-log.md`.
---
## Task 4: Wire `aliro-trustgen` output into the firmware build
**Files:**
- Possibly modify: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/Src/provisioning.c` (or equivalent — find the hardcoded trust first)
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc10-only/Core/Inc/aliro_trust.h` (gitignored, generated)
- Possibly modify: `harness/src/aliro_harness/trustgen/header.py` (to add an `emit-header` CLI subcommand if missing)
- Modify: `nfc10-only/cmake/stm32cubemx/CMakeLists.txt` (add custom_command)
**Step 1: Find the hardcoded trust**
```bash
grep -rln "reader_priv\|reader_pub\|issuer_pub\|credential_issuer" \
reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/
```
Expected: one or two files in `Src/` with `static const uint8_t` byte arrays. Note extern names — the generated header must match.
**Step 2: Verify `aliro-trustgen` can emit the expected header**
```bash
aliro-trustgen --help
aliro-trustgen init --out-dir /tmp/trust-test
ls /tmp/trust-test/
```
If there's already an `emit-header` subcommand (or `init` writes `aliro_trust.h`), use it. If not, add a one-liner subcommand that reads `reader.pem` + `access_credential.pem` + `reader_group_id.bin` + `reader_group_sub_id.bin` and writes a C header matching the vendor's extern names.
A bare-minimum template at `harness/src/aliro_harness/trustgen/header.py::emit_c_header(trust_dir, out_path)`:
```python
def emit_c_header(trust_dir: Path, out_path: Path) -> None:
rp = load_priv(trust_dir / "reader.pem") # 32 bytes
rP = load_pubk(trust_dir / "reader.pem") # 64 bytes x||y
iP = load_pubk(trust_dir / "access_credential.pem") # 64 bytes x||y
gid = (trust_dir / "reader_group_id.bin").read_bytes() # 16 bytes
sid = (trust_dir / "reader_group_sub_id.bin").read_bytes() # 16 bytes
with out_path.open("w") as f:
f.write("/* Auto-generated by aliro-trustgen — do not edit */\n")
f.write("#pragma once\n#include <stdint.h>\n\n")
f.write(f"static const uint8_t reader_priv_key[32] = {{ {fmt(rp)} }};\n")
f.write(f"static const uint8_t reader_pub_key[64] = {{ {fmt(rP)} }};\n")
f.write(f"static const uint8_t issuer_pub_key[64] = {{ {fmt(iP)} }};\n")
f.write(f"static const uint8_t reader_group_id[16] = {{ {fmt(gid)} }};\n")
f.write(f"static const uint8_t reader_group_sub_id[16] = {{ {fmt(sid)} }};\n")
```
Plus a Click command that calls it. Write tests in `harness/tests/test_trustgen_header.py`.
**Step 3: Replace vendor hardcoded bytes with the generated header**
In the file found in Step 1:
```c
// was: static const uint8_t reader_priv_key[32] = { 0xAA, 0xBB, ... };
#include "aliro_trust.h"
```
(If vendor declared its trust bytes as `static const` and consumed them locally, the header defining the same names with `static const` linkage is a drop-in. If vendor declared them `extern`, swap the header to match.)
**Step 4: CMake custom_command to regenerate on trust change**
In `nfc10-only/cmake/stm32cubemx/CMakeLists.txt`:
```cmake
set(ALIRO_TRUST_DIR "$ENV{HOME}/aliro-trust" CACHE PATH "Trust bundle directory")
set(ALIRO_TRUST_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/../../Core/Inc/aliro_trust.h")
add_custom_command(
OUTPUT "${ALIRO_TRUST_HEADER}"
COMMAND aliro-trustgen emit-header --trust-dir "${ALIRO_TRUST_DIR}" --out "${ALIRO_TRUST_HEADER}"
DEPENDS "${ALIRO_TRUST_DIR}/reader.pem" "${ALIRO_TRUST_DIR}/access_credential.pem"
COMMENT "Regenerating aliro_trust.h from ${ALIRO_TRUST_DIR}"
VERBATIM
)
add_custom_target(aliro_trust_header DEPENDS "${ALIRO_TRUST_HEADER}")
add_dependencies(${CMAKE_PROJECT_NAME}.elf aliro_trust_header)
```
**Step 5: Ignore the generated header**
```bash
# Since project isn't git, create a LOCAL .gitignore-equivalent ledger:
echo "Projects/nucleo-u5/Applications/Aliro/nfc10-only/Core/Inc/aliro_trust.h" \
>> reader/STM32CubeExpansion_ALIRO_V1_0_0/.distignore
```
(Documentational — mostly a reminder.)
**Step 6: Rebuild + verify the bytes made it into the ELF**
```bash
rm -rf build && cmake -B build -G Ninja ...
cmake --build build
arm-none-eabi-objdump -s -j .rodata build/nfc10-only.elf | grep -A2 reader_priv
```
Expected: 32 bytes matching the first 32 bytes of the PEM-decoded reader private scalar.
---
## Task 5: Flash + first-boot verification (HARDWARE REQUIRED)
**Files:** none — on-device only.
**Step 1: Connect hardware**
- NUCLEO-U545RE-Q to host USB (enumerates as `/dev/ttyACM0` for VCP + ST-LINK DFU)
- X-NUCLEO-NFC10A1 plugged onto Arduino header (ensure correct orientation — D0 pin aligned)
**Step 2: Flash**
```bash
# With STM32CubeProgrammer:
STM32_Programmer_CLI -c port=SWD -w build/nfc10-only.elf -v -rst
# Or with st-flash:
arm-none-eabi-objcopy -O binary build/nfc10-only.elf build/nfc10-only.bin
st-flash write build/nfc10-only.bin 0x08000000
```
**Step 3: Open UART console**
```bash
minicom -D /dev/ttyACM0 -b 115200
# or: picocom -b 115200 /dev/ttyACM0
```
Expected: within a second of reset, banner text like `Aliro Reader — NFC10A1 / ST25R200` followed by init messages and a polling loop.
**Step 4: Verify NFC field activates**
Within a few seconds of boot the ST25R200 should drive the antenna. NFC10A1 has a FIELD LED — expect it to light solid (or blink during active polling).
**Step 5: If the FIELD LED doesn't light**
Common causes — debug in order:
1. Chip-ID read returns 0xFF → SPI isn't talking (check CS pin resolves to the right MCU GPIO; probe with logic analyzer if available)
2. Reset pin held low → the reset wiring added in Task 2 isn't pulsed correctly
3. Antenna tuning failed → check UART log for specific RFAL init error code
**Step 6: Stop here if field isn't coming up — no point chasing the protocol layer yet.**
---
## Task 6: Empty-field + random-card discovery (HARDWARE REQUIRED)
**Step 1: With no card present** — UART should log periodic `polling, no target` (or equivalent). Every ~500ms.
**Step 2: Present a random NFC-A card (transit, hotel keycard)** — UART should log `target detected: NFC-A, UID=...`. Proves polling + anti-collision.
**Step 3: Present the J3R180** — UART should log target detected and attempt a SELECT of the Aliro AID. Expected either success (proceed to Task 7) or an SW that points at which APDU exchange failed.
**Step 4: Capture UART log to `reader/bench-notes/nfc10a1-discovery.log`.**
---
## Task 7: Full AUTH0+AUTH1 against J3R180 (HARDWARE REQUIRED)
**Step 1: Confirm baseline still green**
```bash
aliro-bench-test --trust-dir ~/aliro-trust
```
Expected: `RESULT: OK`. If not, stop — something regressed in the applet.
**Step 2: Ensure card personalized from same trust bundle**
(No action if card hasn't been re-personalized since Task 4 wired the same bundle into the firmware. If uncertain, re-run `aliro-personalize --trust-dir ~/aliro-trust` on a fresh card.)
**Step 3: Present J3R180 to the Nucleo reader**
Expected UART log sequence:
```
target detected: NFC-A
SELECT Aliro AID → SW=9000
AUTH0 (lc=N) → response N bytes, SW=9000
AUTH1 (lc=N) → response N bytes, SW=9000
AUTH1 decrypt OK
UD signature verify OK
GRANT — signaling_bitmap=0x0005
```
**Step 4: Failure triage**
- **`SW=6A82` on SELECT** — AID mismatch. Vendor firmware's SELECT target must be `A0000009 09 ACCE 55 01`.
- **`SW=6F00` on AUTH0** — uncaught applet exception; correlate with bench-test behavior, should NOT appear if bench-test is green.
- **AUTH1 decrypt fail** — ExpeditedSKDevice mismatch. Either firmware's trust bundle differs from the card's, or Table 8-12 bytes are assembled differently. Compare Table 8-12 hex from firmware UART log vs bench-test `--debug` output.
- **Signature verify fail after decrypt** — firmware's `issuer_pub_key` / `credential_pub_key` extern doesn't match the trust bundle used to personalize the card.
**Step 5: On success**
Capture UART log to `reader/bench-notes/nfc10a1-auth1-green.log`. Update memory [pcsc_bench_reader.md](.claude/projects/-home-work-VSCodeProjects-aliro-project/memory/pcsc_bench_reader.md): append a note that Nucleo-NFC10A1 is now a second validation path.
---
## Task 8: Instrumentation — UART APDU trace + LED state machine
**Files:**
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/Inc/aliro_log.h`
- Modify: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/Src/app_nfc.c` — add APDU log points around each transceive
- Modify: `nfc10-only/Core/Src/main.c` — LED helpers
**Step 1: aliro_log.h**
```c
#pragma once
#include <stdio.h>
#include <stdint.h>
#define ALIRO_LOG_INFO(fmt, ...) printf("[INFO] " fmt "\n", ##__VA_ARGS__)
#define ALIRO_LOG_ERROR(fmt, ...) printf("[ERROR] " fmt "\n", ##__VA_ARGS__)
static inline void aliro_log_hex(const char *dir, const uint8_t *buf, uint16_t len) {
printf("[APDU %s] ", dir);
for (uint16_t i = 0; i < len; i++) printf("%02x", buf[i]);
printf("\n");
}
```
**Step 2: Instrument the APDU pump**
In `app_nfc.c` (or wherever ISO-DEP TX/RX happens), wrap:
```c
aliro_log_hex(">", txBuf, txLen);
// ... transceive
aliro_log_hex("<", rxBuf, rxLen);
```
Match the format the bench-test logs emit (see [harness/src/aliro_harness/reader/transaction.py](harness/src/aliro_harness/reader/transaction.py)).
**Step 3: LED helpers**
In `nfc10-only/Core/Src/main.c`:
```c
static void aliro_led_grant(void) { HAL_GPIO_WritePin(PLATFORM_LED_A_PORT, PLATFORM_LED_A_PIN, GPIO_PIN_SET); }
static void aliro_led_deny(void) { HAL_GPIO_WritePin(PLATFORM_LED_B_PORT, PLATFORM_LED_B_PIN, GPIO_PIN_SET); }
static void aliro_led_active(bool on) { HAL_GPIO_WritePin(PLATFORM_LED_FIELD_PORT, PLATFORM_LED_FIELD_PIN, on ? GPIO_PIN_SET : GPIO_PIN_RESET); }
```
Call from the Aliro state machine: `aliro_led_active(true)` at AUTH0 start, grant/deny at AUTH1 finish.
**Step 4: Verify visually**
J3R180 → FIELD LED (A1) lights during transaction, LED A (A2) lights for ~1s on grant, clears.
Random card → LED B (A3) lights on AUTH0 failure.
---
## Task 9: A/B compare — Nucleo vs PC/SC bench reader
**Files:**
- Create: `reader/bench-notes/ab-compare-2026-04-XX.md`
- Possibly modify: [harness/src/aliro_harness/reader/cli.py](harness/src/aliro_harness/reader/cli.py) — add `--debug` flag that dumps APDU TX/RX to stderr
**Step 1: Bench-test transcript with APDU trace**
If `--debug` doesn't exist, add it (one or two lines in the transmit wrapper at cli.py to `click.echo(f"[APDU >] {apdu.hex()}", err=True)` + same for response).
```bash
aliro-bench-test --trust-dir ~/aliro-trust --debug 2> /tmp/bench-trace.log
```
**Step 2: Nucleo transcript**
```bash
# Present card DURING this capture:
(timeout 10 cat /dev/ttyACM0) > /tmp/nucleo-trace.log
```
**Step 3: Diff APDU streams**
```bash
diff <(grep '^\[APDU' /tmp/bench-trace.log) <(grep '^\[APDU' /tmp/nucleo-trace.log)
```
Expected: differences only in randomized fields (reader ephemeral pubkey, transaction_identifier, encrypted payload ciphertext). Fixed fields (SELECT AID, command_parameters, protocol_version, reader_identifier, response TLV tags) match byte-for-byte.
**Step 4: Document findings**
`reader/bench-notes/ab-compare-2026-04-XX.md` captures:
- Same card + same trust bundle → both GRANT
- APDU diff: expected-only deltas
- Any surprise (extra TLV, unexpected field order, SW difference)
---
## Follow-up work NOT in this plan
- Step-up phase on the Nucleo (SELECT STEP_UP + CBOR ENVELOPE — large, separate plan)
- Antenna tuning against DT's implantable form factor (different tuning than external cards)
- CI build: compile the ELF in CI to catch regressions (no probe needed, just compile)
- Structured binary telemetry log format (replace `printf` — nice-to-have, not needed for bench)
---
## Verification checkpoint after all tasks complete
- [ ] `nfc10-only.elf` builds cleanly from the vendor tree
- [ ] Flasher writes and reader boots with NFC field active
- [ ] `aliro-bench-test --trust-dir ~/aliro-trust` still green (no applet regressions)
- [ ] Nucleo reader → J3R180 → GRANT + full APDU log + LED state
- [ ] A/B diff shows only expected randomness
- [ ] Memory entry updated: Nucleo-NFC10A1 is a second validation path alongside PC/SC

8
harness/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.egg-info/
build/
dist/
.pytest_cache/
.coverage
out/

29
harness/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Aliro test harness
PC-side tools for the Aliro Java Card applet project.
## Layout
```
src/aliro_harness/
├── issuer/ Test CA: keypairs, X.509 certs, signed Access Documents
├── personalize/ pyscard-based loader driving the PersonalizationApplet
├── reader_log/ pyserial VCP capture, tag parser, trace differ
└── trustgen/ CLI emitting reader/aliro_trust.h from CA + reader keys
tests/ pytest suite (unit + end-to-end)
```
## Setup
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
pytest
```
## Not for production
All keys generated or accepted by this harness are TEST-ONLY. Do not reuse in a
production Aliro deployment.

42
harness/pyproject.toml Normal file
View File

@@ -0,0 +1,42 @@
[project]
name = "aliro-harness"
version = "0.1.0"
description = "PC-side test harness for the Aliro Java Card applet: test CA, personalizer, reader log capture"
requires-python = ">=3.11"
dependencies = [
"cryptography>=42.0",
"cbor2>=5.6",
"pyscard>=2.0",
"pyserial>=3.5",
"click>=8.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.4",
]
[project.scripts]
aliro-trustgen = "aliro_harness.trustgen.cli:main"
aliro-personalize = "aliro_harness.personalizer.cli:main"
aliro-bench-test = "aliro_harness.reader.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM"]

View File

View File

@@ -0,0 +1,82 @@
"""Minimal Aliro Access Document builder.
v1 scope: an Access Document with no Access Data Elements. The MSO carries
the Access Credential public key (the User Device's long-term key) signed
by the Credential Issuer. A Reader provisioned with the Credential Issuer's
public key can verify the Access Document and trust the contained device key.
Aliro spec \u00a77.2 mandates integer-replaced map keys encoded as text strings
(Table 7-1). The COSE_Key encoding (RFC 8152) inside deviceKeyInfo uses its
own integer keys unchanged.
"""
from datetime import UTC, datetime, timedelta
import cbor2
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.cose import sign_cose_sign1
from aliro_harness.issuer.kid import compute_issuer_kid
MSO_KEY_VERSION = "1"
MSO_KEY_DIGEST_ALG = "2"
MSO_KEY_VALUE_DIGESTS = "3"
MSO_KEY_DEVICE_KEY_INFO = "4"
MSO_KEY_DOC_TYPE = "5"
MSO_KEY_VALIDITY_INFO = "6"
MSO_KEY_TIME_VERIFICATION_REQUIRED = "7"
DEVICE_KEY_INFO_KEY_DEVICE_KEY = "1"
VALIDITY_KEY_SIGNED = "1"
VALIDITY_KEY_VALID_FROM = "2"
VALIDITY_KEY_VALID_UNTIL = "3"
ALIRO_DOCTYPE_ACCESS = "aliro-a"
ALIRO_NAMESPACE_ACCESS = "aliro-a"
_COSE_KEY_KTY = 1
_COSE_KEY_KTY_EC2 = 2
_COSE_KEY_CRV = -1
_COSE_KEY_CRV_P256 = 1
_COSE_KEY_X = -2
_COSE_KEY_Y = -3
_P256_COORD_LEN = 32
def _cose_key_p256(public_key: ec.EllipticCurvePublicKey) -> dict:
numbers = public_key.public_numbers()
return {
_COSE_KEY_KTY: _COSE_KEY_KTY_EC2,
_COSE_KEY_CRV: _COSE_KEY_CRV_P256,
_COSE_KEY_X: numbers.x.to_bytes(_P256_COORD_LEN, "big"),
_COSE_KEY_Y: numbers.y.to_bytes(_P256_COORD_LEN, "big"),
}
def build_access_document(
*,
issuer_private_key: ec.EllipticCurvePrivateKey,
access_credential_public_key: ec.EllipticCurvePublicKey,
validity_days: int = 365,
) -> bytes:
now = datetime.now(UTC).replace(microsecond=0)
mso = {
MSO_KEY_VERSION: "1.0",
MSO_KEY_DIGEST_ALG: "SHA-256",
MSO_KEY_VALUE_DIGESTS: {},
MSO_KEY_DEVICE_KEY_INFO: {
DEVICE_KEY_INFO_KEY_DEVICE_KEY: _cose_key_p256(access_credential_public_key),
},
MSO_KEY_DOC_TYPE: ALIRO_DOCTYPE_ACCESS,
MSO_KEY_VALIDITY_INFO: {
VALIDITY_KEY_SIGNED: now,
VALIDITY_KEY_VALID_FROM: now,
VALIDITY_KEY_VALID_UNTIL: now + timedelta(days=validity_days),
},
MSO_KEY_TIME_VERIFICATION_REQUIRED: False,
}
payload = cbor2.dumps(mso, canonical=True)
kid = compute_issuer_kid(issuer_private_key.public_key())
return sign_cose_sign1(private_key=issuer_private_key, payload=payload, kid=kid)

View File

@@ -0,0 +1,45 @@
from datetime import UTC, datetime, timedelta
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.keys import generate_p256_keypair
def create_self_signed_ca(
*,
common_name: str,
validity_days: int = 3650,
) -> tuple[x509.Certificate, ec.EllipticCurvePrivateKey]:
key = generate_p256_keypair()
subject = issuer = x509.Name(
[x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name)]
)
now = datetime.now(UTC)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=validity_days))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.sign(private_key=key, algorithm=hashes.SHA256())
)
return cert, key

View File

@@ -0,0 +1,90 @@
"""Minimal COSE_Sign1 implementation sufficient for Aliro IssuerAuth.
Reference: RFC 8152 (COSE), \u00a74.4 for the Sig_structure.
COSE_Sign1 wire format:
[ protected_bstr, unprotected_map, payload_bstr, signature_bstr ]
Sig_structure (what gets signed):
[ "Signature1", body_protected, external_aad, payload ]
serialised with deterministic CBOR, then ECDSA-SHA-256.
COSE ECDSA signatures are raw r||s (64 bytes for P-256), not DER.
"""
import cbor2
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature,
encode_dss_signature,
)
COSE_ALG_ES256 = -7
COSE_HDR_ALG = 1
COSE_HDR_KID = 4
_P256_COORD_LEN = 32
class CoseVerificationError(Exception):
pass
def _sig_structure(protected_bstr: bytes, payload: bytes) -> bytes:
return cbor2.dumps(["Signature1", protected_bstr, b"", payload], canonical=True)
def _der_to_raw(der_sig: bytes) -> bytes:
r, s = decode_dss_signature(der_sig)
return r.to_bytes(_P256_COORD_LEN, "big") + s.to_bytes(_P256_COORD_LEN, "big")
def _raw_to_der(raw_sig: bytes) -> bytes:
if len(raw_sig) != 2 * _P256_COORD_LEN:
raise CoseVerificationError(f"expected 64-byte raw signature, got {len(raw_sig)}")
r = int.from_bytes(raw_sig[:_P256_COORD_LEN], "big")
s = int.from_bytes(raw_sig[_P256_COORD_LEN:], "big")
return encode_dss_signature(r, s)
def sign_cose_sign1(
*,
private_key: ec.EllipticCurvePrivateKey,
payload: bytes,
kid: bytes,
) -> bytes:
protected_bstr = cbor2.dumps({COSE_HDR_ALG: COSE_ALG_ES256}, canonical=True)
unprotected = {COSE_HDR_KID: kid}
to_be_signed = _sig_structure(protected_bstr, payload)
der_sig = private_key.sign(to_be_signed, ec.ECDSA(hashes.SHA256()))
raw_sig = _der_to_raw(der_sig)
return cbor2.dumps([protected_bstr, unprotected, payload, raw_sig], canonical=True)
def verify_cose_sign1(
signed: bytes,
public_key: ec.EllipticCurvePublicKey,
) -> bytes:
try:
decoded = cbor2.loads(signed)
except Exception as e:
raise CoseVerificationError(f"not valid CBOR: {e}") from e
if not isinstance(decoded, list) or len(decoded) != 4:
raise CoseVerificationError("COSE_Sign1 must be a 4-element array")
protected_bstr, _unprotected, payload, raw_sig = decoded
if not isinstance(protected_bstr, bytes):
raise CoseVerificationError("protected header must be bstr")
if not isinstance(payload, bytes):
raise CoseVerificationError("payload must be bstr")
if not isinstance(raw_sig, bytes):
raise CoseVerificationError("signature must be bstr")
to_be_signed = _sig_structure(protected_bstr, payload)
der_sig = _raw_to_der(raw_sig)
try:
public_key.verify(der_sig, to_be_signed, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as e:
raise CoseVerificationError("signature verification failed") from e
return payload

View File

@@ -0,0 +1,61 @@
"""Aliro-flavored mdoc DeviceResponse for the step-up phase.
Per Aliro §8.4.2, the User Device returns an ISO 18013-5 DeviceResponse
with two changes: readerAuth / documentErrors / errors / deviceSigned MUST
NOT be present, and map keys are replaced by the integer aliases in
Table 8-22 (still encoded as text strings).
For v1 (no Access Data Elements; Access Document carries only the
credential long-term public key inside IssuerAuth), the DeviceResponse
is effectively static once the AD is generated:
{
"1": "1.0", # version
"2": [ # documents
{
"1": { # issuerSigned
"1": {}, # nameSpaces (empty — no ADEs)
"2": <AD> # IssuerAuth — raw COSE_Sign1 bytes
},
"5": "aliro-a" # docType
}
],
"3": 0 # status (0 = OK)
}
The Access Document is already a serialized COSE_Sign1, which is itself
valid CBOR (a 4-element array). Splicing its bytes into the DeviceResponse
at the IssuerAuth position yields valid CBOR — no decode/re-encode needed.
"""
import cbor2
def build_device_response(access_document_bytes: bytes) -> bytes:
"""Returns a deterministic-CBOR DeviceResponse wrapping the given
Access Document as IssuerAuth.
``access_document_bytes`` must be the serialized COSE_Sign1 produced
by :func:`build_access_document` — it is embedded verbatim.
"""
# cbor2.dumps with canonical=True produces deterministic CBOR. The AD is
# loaded first so it lives in the structure as real Python objects that
# cbor2 re-encodes — this gives us canonical ordering guarantees even if
# the source AD was encoded differently.
ad = cbor2.loads(access_document_bytes)
return cbor2.dumps(
{
"1": "1.0",
"2": [
{
"1": {
"1": {},
"2": ad,
},
"5": "aliro-a",
}
],
"3": 0,
},
canonical=True,
)

View File

@@ -0,0 +1,24 @@
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
def generate_p256_keypair() -> ec.EllipticCurvePrivateKey:
return ec.generate_private_key(ec.SECP256R1())
def save_private_key_pem(path: Path, key: ec.EllipticCurvePrivateKey) -> None:
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
path.write_bytes(pem)
def load_private_key_pem(path: Path) -> ec.EllipticCurvePrivateKey:
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(key, ec.EllipticCurvePrivateKey):
raise ValueError(f"expected an EC private key at {path}, got {type(key).__name__}")
return key

View File

@@ -0,0 +1,16 @@
"""Aliro IssuerAuth `kid` computation (spec \u00a77.2.1).
kid = SHA256("key-identifier" || 0x04 || IssuerKey_PubK.x || IssuerKey_PubK.y)[:8]
"""
import hashlib
from cryptography.hazmat.primitives.asymmetric import ec
def compute_issuer_kid(public_key: ec.EllipticCurvePublicKey) -> bytes:
numbers = public_key.public_numbers()
x_bytes = numbers.x.to_bytes(32, "big")
y_bytes = numbers.y.to_bytes(32, "big")
payload = b"key-identifier" + b"\x04" + x_bytes + y_bytes
return hashlib.sha256(payload).digest()[:8]

View File

@@ -0,0 +1,21 @@
"""Produce the APDU command sequences that drive PersonalizationApplet.
This module intentionally only builds byte-level APDU commands; actually
sending them is the caller's job (pyscard for real cards, or the Java test
harness via CardSimulator). Keeping transport out means these functions are
trivially unit-testable without any card or simulator available.
"""
from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.orchestrator import (
PersonalizationError,
TrustArtifacts,
personalize_card,
)
__all__ = [
"access_document_apdus",
"personalize_card",
"PersonalizationError",
"TrustArtifacts",
]

View File

@@ -0,0 +1,51 @@
"""APDU sequence for provisioning an Access Document via PersonalizationApplet.
The applet's PersonalizationApplet (DT-proprietary AID) accepts the AD via
two INSes:
INS 0x23 WRITE_ACCESS_DOC — P1|P2 = destination offset, data = chunk
INS 0x24 FINALIZE_ACCESS_DOC — P1|P2 = total length, Lc = 0
The AD must fit within CredentialStore.ACCESS_DOC_MAX_LEN (1024 bytes).
"""
CLA_PROPRIETARY = 0x80
INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24
# Max command data field in a short-form APDU (ISO 7816-4).
APDU_CHUNK_MAX = 255
# Must match CredentialStore.ACCESS_DOC_MAX_LEN on the applet side.
ACCESS_DOC_MAX_LEN = 1024
def access_document_apdus(ad_bytes: bytes, chunk_size: int = APDU_CHUNK_MAX) -> list[bytes]:
"""Returns the ordered list of APDU commands that load ``ad_bytes`` onto
a provisioning-channel-selected applet.
The caller should have already SELECTed the PersonalizationApplet AID
and not yet called COMMIT.
"""
if len(ad_bytes) > ACCESS_DOC_MAX_LEN:
raise ValueError(
f"Access Document is {len(ad_bytes)} bytes; exceeds {ACCESS_DOC_MAX_LEN}-byte applet budget"
)
if not (1 <= chunk_size <= APDU_CHUNK_MAX):
raise ValueError(f"chunk_size must be in [1, {APDU_CHUNK_MAX}]")
apdus: list[bytes] = []
for offset in range(0, len(ad_bytes), chunk_size):
chunk = ad_bytes[offset : offset + chunk_size]
apdus.append(_apdu(INS_WRITE_ACCESS_DOC, offset >> 8, offset & 0xFF, chunk))
total = len(ad_bytes)
apdus.append(_apdu(INS_FINALIZE_ACCESS_DOC, total >> 8, total & 0xFF, b""))
return apdus
def _apdu(ins: int, p1: int, p2: int, data: bytes) -> bytes:
# Short-form APDU: CLA INS P1 P2 Lc data
header = bytes([CLA_PROPRIETARY, ins, p1, p2])
if data:
return header + bytes([len(data)]) + data
return header

View File

@@ -0,0 +1,59 @@
"""APDU constructors for PersonalizationApplet.
Mirrors the INS table in applet/INSTALL.md. See AliroAids.PROVISIONING for
the AID and PersonalizationApplet.java for the INS handlers.
"""
CLA_PROPRIETARY = 0x80
INS_SET_CRED_PRIV = 0x20
INS_SET_CRED_PUBK = 0x21
INS_SET_READER_PUBK = 0x22
INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24
INS_COMMIT = 0x2C
# Provisioning AID — must match AliroAids.PROVISIONING in the applet.
# (CSA RID + ACCE 55 99 01 — DT-internal, namespaced under the CSA RID so
# all three applets fit in one CAP.)
PROVISIONING_AID = bytes.fromhex("A000000909ACCE559901")
CRED_PRIV_LEN = 32
CRED_PUBK_LEN = 64
READER_PUBK_LEN = 64
def select_provisioning_apdu() -> bytes:
"""ISO 7816-4 SELECT by name: 00 A4 04 00 Lc <AID>."""
return bytes([0x00, 0xA4, 0x04, 0x00, len(PROVISIONING_AID)]) + PROVISIONING_AID
def set_credential_priv_apdu(priv: bytes) -> bytes:
if len(priv) != CRED_PRIV_LEN:
raise ValueError(f"credential_PrivK must be {CRED_PRIV_LEN}B, got {len(priv)}")
return _short_apdu(INS_SET_CRED_PRIV, 0, 0, priv)
def set_credential_pubk_apdu(pubk_xy: bytes) -> bytes:
if len(pubk_xy) != CRED_PUBK_LEN:
raise ValueError(f"credential_PubK must be {CRED_PUBK_LEN}B (x||y), got {len(pubk_xy)}")
return _short_apdu(INS_SET_CRED_PUBK, 0, 0, pubk_xy)
def set_reader_pubk_apdu(pubk_xy: bytes) -> bytes:
if len(pubk_xy) != READER_PUBK_LEN:
raise ValueError(f"reader_PubK must be {READER_PUBK_LEN}B (x||y), got {len(pubk_xy)}")
return _short_apdu(INS_SET_READER_PUBK, 0, 0, pubk_xy)
def commit_apdu() -> bytes:
return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])
def _short_apdu(ins: int, p1: int, p2: int, data: bytes) -> bytes:
"""Short-form APDU: CLA INS P1 P2 Lc data."""
if not data:
return bytes([CLA_PROPRIETARY, ins, p1, p2])
if len(data) > 255:
raise ValueError(f"data too long for short-form APDU: {len(data)}B")
return bytes([CLA_PROPRIETARY, ins, p1, p2, len(data)]) + data

View File

@@ -0,0 +1,101 @@
"""``aliro-personalize`` — load credential keys + Access Document onto a
freshly-installed Aliro applet via a PC/SC reader.
The card must already have the CAP installed and all three applets created
(see ``applet/INSTALL.md``). This tool drives the PersonalizationApplet
(AID ``A0000009 09 ACCE 55 99 01``) through its INS sequence:
SELECT → SET_CRED_PRIV → SET_CRED_PUBK → SET_READER_PUBK
→ WRITE_ACCESS_DOC × N → FINALIZE_ACCESS_DOC → COMMIT
After COMMIT the applet refuses every further write, so this is a one-shot
per card. Uncommit isn't supported.
"""
from pathlib import Path
import click
from aliro_harness.personalizer.orchestrator import (
PersonalizationError,
TrustArtifacts,
personalize_card,
)
@click.command()
@click.option(
"--trust-dir",
required=False,
default=None,
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Directory written by 'aliro-trustgen init'. Must contain "
"access_credential.pem, reader.pem, access_document.bin. "
"Required unless --list-readers is given.",
)
@click.option(
"--reader",
"reader_index",
type=int,
default=0,
show_default=True,
help="Index into the list of PC/SC readers (use --list-readers to see).",
)
@click.option(
"--list-readers",
is_flag=True,
help="List PC/SC readers visible to the host and exit.",
)
def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
"""Personalize an Aliro card with the trust artifacts in TRUST_DIR."""
# pyscard is imported lazily so --help and unit tests don't require pcscd.
from smartcard.System import readers
available = readers()
if list_readers:
if not available:
click.echo("No PC/SC readers found.", err=True)
raise SystemExit(1)
for i, r in enumerate(available):
click.echo(f"[{i}] {r}")
return
if trust_dir is None:
raise click.UsageError(
"--trust-dir is required unless --list-readers is given."
)
if not available:
raise click.ClickException("No PC/SC readers found. Is pcscd running?")
if reader_index >= len(available):
raise click.ClickException(
f"--reader {reader_index} out of range; only {len(available)} reader(s) available."
)
reader = available[reader_index]
click.echo(f"Using reader: {reader}")
artifacts = TrustArtifacts.from_trust_dir(trust_dir)
click.echo(
f"Loaded artifacts: credential_PrivK={len(artifacts.credential_priv)}B, "
f"credential_PubK={len(artifacts.credential_pubk_xy)}B, "
f"reader_PubK={len(artifacts.reader_pubk_xy)}B, "
f"Access Document={len(artifacts.access_document)}B"
)
connection = reader.createConnection()
connection.connect()
click.echo(f"Connected. ATR: {bytes(connection.getATR()).hex()}")
def transmit(apdu: bytes) -> tuple[bytes, int]:
data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2
try:
personalize_card(transmit, artifacts)
except PersonalizationError as e:
raise click.ClickException(str(e)) from e
finally:
connection.disconnect()
click.echo("Personalization complete. Card is locked (no further writes accepted).")

View File

@@ -0,0 +1,31 @@
"""Extract the byte representations PersonalizationApplet expects from
PEM-encoded P-256 keys produced by ``aliro-trustgen init``.
The applet stores raw scalars / raw uncompressed-point coordinates with
fixed lengths (32 / 64 bytes), so we strip PEM/DER framing and BigInteger
sign-padding here.
"""
from cryptography.hazmat.primitives.asymmetric import ec
P256_COORD_LEN = 32
def extract_priv_scalar(private_key: ec.EllipticCurvePrivateKey) -> bytes:
"""Return the 32-byte big-endian P-256 private scalar."""
if not isinstance(private_key.curve, ec.SECP256R1):
raise ValueError(f"expected P-256 (secp256r1), got {private_key.curve.name}")
return _to_fixed_be(private_key.private_numbers().private_value, P256_COORD_LEN)
def extract_pub_xy(public_key: ec.EllipticCurvePublicKey) -> bytes:
"""Return the 64-byte uncompressed point (x || y) without the 0x04 tag."""
if not isinstance(public_key.curve, ec.SECP256R1):
raise ValueError(f"expected P-256 (secp256r1), got {public_key.curve.name}")
nums = public_key.public_numbers()
return _to_fixed_be(nums.x, P256_COORD_LEN) + _to_fixed_be(nums.y, P256_COORD_LEN)
def _to_fixed_be(value: int, length: int) -> bytes:
"""Big-endian unsigned, zero-padded to exactly ``length`` bytes."""
return value.to_bytes(length, "big")

View File

@@ -0,0 +1,87 @@
"""Orchestrate the full personalization sequence over an abstract APDU
transport. Decoupled from pyscard so unit tests can drive a mock transport
and verify the exact byte sequence produced.
"""
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.apdus import (
commit_apdu,
select_provisioning_apdu,
set_credential_priv_apdu,
set_credential_pubk_apdu,
set_reader_pubk_apdu,
)
from aliro_harness.personalizer.credentials import (
extract_priv_scalar,
extract_pub_xy,
)
SW_OK = 0x9000
# A transmit callable takes one APDU (bytes) and returns (response_data, sw).
Transmit = Callable[[bytes], tuple[bytes, int]]
class PersonalizationError(RuntimeError):
"""Raised when an APDU returns a non-9000 status word."""
def __init__(self, step: str, sw: int):
super().__init__(f"{step} failed: SW=0x{sw:04X}")
self.step = step
self.sw = sw
@dataclass(frozen=True)
class TrustArtifacts:
"""Bytes the applet expects, extracted from a ``trustgen init`` output dir."""
credential_priv: bytes # 32B
credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix)
reader_pubk_xy: bytes # 64B
access_document: bytes # COSE_Sign1 serialized
@classmethod
def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts":
cred_pem = (trust_dir / "access_credential.pem").read_bytes()
reader_pem = (trust_dir / "reader.pem").read_bytes()
ad = (trust_dir / "access_document.bin").read_bytes()
cred_key = serialization.load_pem_private_key(cred_pem, password=None)
reader_key = serialization.load_pem_private_key(reader_pem, password=None)
return cls(
credential_priv=extract_priv_scalar(cred_key),
credential_pubk_xy=extract_pub_xy(cred_key.public_key()),
reader_pubk_xy=extract_pub_xy(reader_key.public_key()),
access_document=ad,
)
def personalize_card(transmit: Transmit, artifacts: TrustArtifacts) -> None:
"""Drive the full provisioning sequence on a card already powered up
behind ``transmit``. Raises :class:`PersonalizationError` on the first
APDU that doesn't return 0x9000.
"""
_send(transmit, "SELECT provisioning AID", select_provisioning_apdu())
_send(transmit, "SET credential_PrivK", set_credential_priv_apdu(artifacts.credential_priv))
_send(transmit, "SET credential_PubK", set_credential_pubk_apdu(artifacts.credential_pubk_xy))
_send(transmit, "SET reader_PubK", set_reader_pubk_apdu(artifacts.reader_pubk_xy))
for i, apdu in enumerate(access_document_apdus(artifacts.access_document)):
_send(transmit, f"Access Document APDU {i}", apdu)
_send(transmit, "COMMIT", commit_apdu())
def _send(transmit: Transmit, step: str, apdu: bytes) -> bytes:
data, sw = transmit(apdu)
if sw != SW_OK:
raise PersonalizationError(step, sw)
return data

View File

@@ -0,0 +1,48 @@
"""AUTH0 command-data-field builder (spec §8.3.3.2.1, Table 8-4).
Mirrors applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
buildStandardData(). The applet validates this in
AliroApplet.validateAuth0Data -- both sides must agree byte-for-byte.
"""
from io import BytesIO
CLA = 0x80
INS_AUTH0 = 0x80
PROTOCOL_VERSION_1_0 = 0x0100
def build_auth0_data(
*,
reader_ephem_pub_uncompressed: bytes,
reader_group_id: bytes,
reader_group_sub_id: bytes,
transaction_id: bytes,
command_parameters: int = 0x00,
authentication_policy: int = 0x00,
) -> bytes:
if len(reader_ephem_pub_uncompressed) != 65 or reader_ephem_pub_uncompressed[0] != 0x04:
raise ValueError("reader_ephem_pub must be 65B uncompressed (0x04 || x || y)")
if len(reader_group_id) != 16 or len(reader_group_sub_id) != 16:
raise ValueError("reader_group_{id,sub_id} must each be 16B")
if len(transaction_id) != 16:
raise ValueError("transaction_identifier must be 16B")
out = BytesIO()
_write_tlv(out, 0x41, bytes([command_parameters]))
_write_tlv(out, 0x42, bytes([authentication_policy]))
_write_tlv(out, 0x5C, PROTOCOL_VERSION_1_0.to_bytes(2, "big"))
_write_tlv(out, 0x87, reader_ephem_pub_uncompressed)
_write_tlv(out, 0x4C, transaction_id)
_write_tlv(out, 0x4D, reader_group_id + reader_group_sub_id)
return out.getvalue()
def _write_tlv(out: BytesIO, tag: int, value: bytes) -> None:
out.write(bytes([tag, len(value)]))
out.write(value)
def build_auth0_apdu(data: bytes) -> bytes:
"""Wraps AUTH0 data-field in a short-form APDU: 80 80 00 00 Lc data."""
return bytes([CLA, INS_AUTH0, 0x00, 0x00, len(data)]) + data

View File

@@ -0,0 +1,86 @@
"""AUTH1 command builder + Table 8-12 reader-signature input.
Mirrors applet/src/test/.../ReaderSide.java buildTable812 + buildAuth1Data.
"""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
CLA = 0x80
INS_AUTH1 = 0x81
USAGE_READER_AUTH1 = bytes([0x41, 0x5D, 0x95, 0x69])
USAGE_UD_AUTH1 = bytes([0x4E, 0x88, 0x7B, 0x4C])
def build_table_812(
*,
reader_id_32: bytes,
credential_ephem_pub_x: bytes,
reader_ephem_pub_x: bytes,
transaction_id: bytes,
) -> bytes:
return _build_table_8_12_or_13(
reader_id_32, credential_ephem_pub_x, reader_ephem_pub_x,
transaction_id, USAGE_READER_AUTH1,
)
def build_table_813(
*,
reader_id_32: bytes,
credential_ephem_pub_x: bytes,
reader_ephem_pub_x: bytes,
transaction_id: bytes,
) -> bytes:
"""Same TLV shape as Table 8-12, only the usage tag differs."""
return _build_table_8_12_or_13(
reader_id_32, credential_ephem_pub_x, reader_ephem_pub_x,
transaction_id, USAGE_UD_AUTH1,
)
def _build_table_8_12_or_13(
rid: bytes, cred_x: bytes, reader_x: bytes, txn: bytes, usage: bytes,
) -> bytes:
# Match auth0.py / build_auth1_data discipline: validate fixed-length
# inputs up front. Java callers get an ArrayIndexOutOfBoundsException
# naturally; Python slicing would silently produce a wrong-length blob.
if len(rid) != 32:
raise ValueError(f"reader_id_32 must be 32B (got {len(rid)})")
if len(cred_x) != 32:
raise ValueError(f"credential_ephem_pub_x must be 32B (got {len(cred_x)})")
if len(reader_x) != 32:
raise ValueError(f"reader_ephem_pub_x must be 32B (got {len(reader_x)})")
if len(txn) != 16:
raise ValueError(f"transaction_id must be 16B (got {len(txn)})")
return (
bytes([0x4D, 0x20]) + rid
+ bytes([0x86, 0x20]) + cred_x
+ bytes([0x87, 0x20]) + reader_x
+ bytes([0x4C, 0x10]) + txn
+ bytes([0x93, 0x04]) + usage
)
def sign_table_812_raw(table_812: bytes, reader_priv: ec.EllipticCurvePrivateKey) -> bytes:
"""Returns 64B raw r||s ECDSA-SHA-256 signature."""
der = reader_priv.sign(table_812, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der)
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
def build_auth1_data(raw_sig_64: bytes, command_parameters: int = 0x01) -> bytes:
"""AUTH1 command data field per spec Table 8-10. cmd_params bit 0 = 1
means 'return credential_PubK' (simpler than the SHA-1 key_slot path)."""
if len(raw_sig_64) != 64:
raise ValueError("reader signature must be 64B raw r||s")
return (
bytes([0x41, 0x01, command_parameters])
+ bytes([0x9E, 0x40]) + raw_sig_64
)
def build_auth1_apdu(data: bytes) -> bytes:
return bytes([CLA, INS_AUTH1, 0x00, 0x00, len(data)]) + data

View File

@@ -0,0 +1,54 @@
"""AUTH1 response: AES-256-GCM decrypt + UD signature verification.
device_counter == 1 because we are the FIRST step-up-eligible response in
the session (spec §8.3.1.13 initializes expedited_device_counter = 1).
"""
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_auth1_response(sk_device: bytes, ciphertext_with_tag: bytes) -> bytes:
"""Decrypt the FIRST AUTH1 response per spec §8.3.1.6.
IV = 0x00*7 || 0x01 || expedited_device_counter (4B BE).
device_counter is HARDCODED to 1: v1 does exactly one AUTH1 per session
(the applet clears FLAG_AUTH0_DONE after a successful AUTH1 — see
AliroApplet.processAuth1 — so the next AUTH1 needs a fresh AUTH0,
fresh sk_device, and the counter resets to 1). If a future revision
loops AUTH1 within a session, this must take counter as a parameter.
Raises ``cryptography.exceptions.InvalidTag`` on auth-tag mismatch.
"""
iv = b"\x00" * 7 + b"\x01" + (1).to_bytes(4, "big")
return AESGCM(sk_device).decrypt(iv, ciphertext_with_tag, associated_data=None)
def verify_ud_signature(
credential_long_term_pub_uncompressed: bytes,
table_813: bytes,
raw_sig_64: bytes,
) -> bool:
"""Verify the UD's ECDSA-SHA-256 signature over Table 8-13.
Returns True on valid sig, False on invalid sig.
Raises ``ValueError`` on malformed inputs (wrong sig length, malformed
pubkey encoding — the latter from ``from_encoded_point`` on a non-65B
or off-curve point).
"""
if len(raw_sig_64) != 64:
raise ValueError("UD signature must be 64B raw r||s")
pub = ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), credential_long_term_pub_uncompressed
)
r = int.from_bytes(raw_sig_64[:32], "big")
s = int.from_bytes(raw_sig_64[32:], "big")
der = encode_dss_signature(r, s)
try:
pub.verify(der, table_813, ec.ECDSA(hashes.SHA256()))
return True
except InvalidSignature:
return False

View File

@@ -0,0 +1,118 @@
"""``aliro-bench-test`` — run a single Aliro AUTH0+AUTH1 transaction against
a personalized card via a PC/SC reader.
Drives the Aliro EXPEDITED phase end-to-end:
SELECT EXPEDITED -> AUTH0 -> AUTH1 -> decrypt -> verify UD signature
Exits 0 on success, non-zero with a clear error on any failure.
"""
from pathlib import Path
import click
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@click.command()
@click.option(
"--trust-dir",
required=False,
default=None,
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Directory written by 'aliro-trustgen init'. Must contain "
"reader.pem, access_credential.pem, reader_group_id.bin, "
"reader_group_sub_id.bin. Required unless --list-readers is given.",
)
@click.option(
"--reader",
"reader_index",
type=int,
default=0,
show_default=True,
help="Index into the list of PC/SC readers (use --list-readers to see).",
)
@click.option(
"--list-readers",
is_flag=True,
help="List PC/SC readers visible to the host and exit.",
)
def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
"""Run a single Aliro AUTH0+AUTH1 transaction against a personalized
card via PC/SC. Decrypts the response and verifies the UD signature.
Exits 0 on success, non-zero with a clear error on any failure."""
# pyscard is imported lazily so --help and unit tests don't require pcscd.
from smartcard.System import readers
available = readers()
if list_readers:
if not available:
click.echo("No PC/SC readers found.", err=True)
raise SystemExit(1)
for i, r in enumerate(available):
click.echo(f"[{i}] {r}")
return
if trust_dir is None:
raise click.UsageError(
"--trust-dir is required unless --list-readers is given."
)
if not available:
raise click.ClickException("No PC/SC readers found. Is pcscd running?")
if reader_index < 0 or reader_index >= len(available):
raise click.ClickException(
f"--reader {reader_index} out of range; only {len(available)} reader(s) available."
)
reader = available[reader_index]
click.echo(f"Using reader: {reader}")
connection = reader.createConnection()
connection.connect()
try:
click.echo(f"Connected. ATR: {bytes(connection.getATR()).hex()}")
bundle = TrustBundle.from_trust_dir(trust_dir)
click.echo(f"Loaded trust artifacts from {trust_dir}")
def transmit(apdu: bytes) -> tuple[bytes, int]:
data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2
result = run_aliro_transaction(transmit=transmit, bundle=bundle)
finally:
connection.disconnect()
if not result.ok:
# If decrypt succeeded but verify (or another late step) failed,
# the orchestrator populates the TLV fields anyway — print them so
# the user at the bench can see WHERE in the pipeline the failure
# happened rather than just the last error message.
if result.plaintext is not None:
click.echo(
f"RESULT: FAIL \u2014 {result.error}", err=True
)
_echo_tlv_breakdown(result)
click.echo(
"Hint: the AUTH1 response decrypted, so session keys are "
"correct. The signature verification failed — check that the "
"credential public key in the trust dir matches the one "
"provisioned on the card.",
err=True,
)
raise click.exceptions.Exit(1)
raise click.ClickException(result.error or "Aliro transaction failed.")
click.echo("RESULT: OK \u2014 applet round-trip on real hardware.")
_echo_tlv_breakdown(result)
def _echo_tlv_breakdown(result) -> None:
cred_pub_len = len(result.credential_pub) if result.credential_pub else 0
ud_sig_len = len(result.ud_sig) if result.ud_sig else 0
bitmap_hex = result.signaling_bitmap.hex() if result.signaling_bitmap else ""
click.echo(f" 0x5A credential_PubK: {cred_pub_len}B")
click.echo(f" 0x9E UD signature: {ud_sig_len}B")
click.echo(f" 0x5E signaling_bitmap: 0x{bitmap_hex}")

View File

@@ -0,0 +1,42 @@
"""Reader-side crypto primitives for the Aliro NFC transaction.
Wraps cryptography's HKDF, ECDH, ECDSA, AES-GCM into Aliro-shaped helpers.
Intentionally NOT a re-export — these wrappers fix the algorithm choices
specified by Aliro §8.3.1 so callers can't pick the wrong hash, padding,
etc. by mistake.
"""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
"""HKDF-SHA-256 per RFC 5869. An empty (or None) salt is substituted
with HashLen (32) zero bytes, per RFC 5869 §2.2."""
return HKDF(
algorithm=hashes.SHA256(),
length=length,
salt=salt or b"\x00" * 32,
info=info,
).derive(ikm)
def ecdh_shared_x(priv: ec.EllipticCurvePrivateKey, peer_pub_uncompressed_65: bytes) -> bytes:
"""ECDH P-256: returns the 32-byte x-coordinate of the shared point.
Point-on-curve validation is enforced by ``from_encoded_point``."""
if len(peer_pub_uncompressed_65) != 65 or peer_pub_uncompressed_65[0] != 0x04:
raise ValueError("peer pubkey must be 65B uncompressed")
peer = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), peer_pub_uncompressed_65)
return priv.exchange(ec.ECDH(), peer) # cryptography returns the 32B x-coord
def derive_kdh(
reader_ephem_priv: ec.EllipticCurvePrivateKey,
credential_ephem_pub_uncompressed: bytes,
transaction_id: bytes,
) -> bytes:
"""§8.3.1.4 (with §8.3.1.5 substitution): Kdh = HKDF(IKM=ECDH_x,
salt=transaction_id, info=∅, L=32)."""
z_ab = ecdh_shared_x(reader_ephem_priv, credential_ephem_pub_uncompressed)
return hkdf_sha256(z_ab, transaction_id, b"", 32)

View File

@@ -0,0 +1,68 @@
"""Reader-side mirror of AliroApplet.buildSaltVolatile / deriveSessionKeys.
Keep this BYTE-IDENTICAL to the applet implementation in
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
(buildSaltVolatile). Drift here = decryption fails on real card.
"""
from io import BytesIO
from aliro_harness.reader.crypto import hkdf_sha256
SALT_VOLATILE_TAG = b"Volatile****"
INTERFACE_BYTE_NFC = 0x5E
PROPRIETARY_A5_TLV = bytes.fromhex("A50880020000" "5C020100")
PROTOCOL_VERSION_1_0 = bytes([0x01, 0x00])
# Offsets into the 160B derived_keys_volatile (§8.3.1.13).
OFF_EXPEDITED_SK_READER = 0
OFF_EXPEDITED_SK_DEVICE = 32
OFF_STEP_UP_SK = 64
def build_salt_volatile(
*,
reader_long_term_pub_x: bytes,
reader_group_id: bytes,
reader_group_sub_id: bytes,
reader_ephem_pub_x: bytes,
transaction_id: bytes,
command_parameters: int,
authentication_policy: int,
credential_long_term_pub_x: bytes,
) -> bytes:
"""Per spec §8.3.1.13. Mirrors AliroApplet.buildSaltVolatile (lines 406-446)."""
if not (0 <= command_parameters <= 0xFF and 0 <= authentication_policy <= 0xFF):
raise ValueError(
"command_parameters and authentication_policy must each fit in one byte"
)
out = BytesIO()
out.write(reader_long_term_pub_x) # 32
out.write(SALT_VOLATILE_TAG) # 12
out.write(reader_group_id) # 16
out.write(reader_group_sub_id) # 16
out.write(bytes([INTERFACE_BYTE_NFC])) # 1
out.write(bytes([0x5C, 0x02])) # 2
out.write(PROTOCOL_VERSION_1_0) # 2
out.write(reader_ephem_pub_x) # 32
out.write(transaction_id) # 16
out.write(bytes([command_parameters, authentication_policy])) # 2
out.write(PROPRIETARY_A5_TLV) # 10
out.write(credential_long_term_pub_x) # 32
salt = out.getvalue()
if len(salt) != 173:
raise ValueError(
f"salt_volatile must be 173 bytes (caller passed wrong-length x-coord or ID); "
f"got {len(salt)}. Each 32B field must be exactly 32B; each 16B field must be 16B."
)
return salt
def derive_expedited_session_keys(
kdh: bytes,
salt_volatile: bytes,
info_credential_ephem_pub_x: bytes,
) -> bytes:
"""§8.3.1.13: derived_keys_volatile (160B). info = x(credential_ephem_pub) +
AUTH0 vendor extensions (none in v1)."""
return hkdf_sha256(kdh, salt_volatile, info_credential_ephem_pub_x, 160)

View File

@@ -0,0 +1,20 @@
"""Per-transaction reader-side state for an Aliro NFC transaction."""
from dataclasses import dataclass
from typing import Optional
from cryptography.hazmat.primitives.asymmetric import ec
@dataclass
class ReaderSession:
"""Mutable per-transaction state. Mirrors fields kept in the Java
ReaderSide test fixture (applet/src/test/.../ReaderSide.java)."""
reader_ephem_priv: Optional[ec.EllipticCurvePrivateKey] = None
transaction_id: Optional[bytes] = None
credential_ephem_pubk: Optional[bytes] = None # 65B uncompressed, captured from AUTH0 response
@classmethod
def fresh(cls) -> "ReaderSession":
return cls()

View File

@@ -0,0 +1,38 @@
"""Minimal TLV reader for AUTH0/AUTH1 response parsing.
Mirrors applet/src/test/.../TlvUtil.java. Single-byte tags only
(Aliro uses BER-TLV but every tag in AUTH0/AUTH1 responses — 0x86, 0x9E,
0x5A, 0x5E, 0x4D, 0x4B, 0x4C, 0x91, 0x92 — is single-byte). The Java
reference additionally supports BER multi-byte tag continuation
((tag & 0x1F) == 0x1F); revisit if a future Aliro spec revision starts
emitting extended tags.
Short-form lengths plus 0x81 / 0x82 long-form for sizes >= 128. Returned
slice is a fresh ``bytes`` object (not aliased into ``data``).
Inputs are assumed well-formed BER-TLV from a trusted card response;
truncated input raises ``IndexError`` (left unwrapped because the
upstream transaction orchestrator treats any parse failure as a protocol
error and surfaces a clear message there).
"""
def find_top_level(data: bytes, tag: int) -> bytes | None:
"""Returns the value bytes of the first TLV whose tag matches, or None.
Does not recurse into constructed (0xA*) values."""
i = 0
while i < len(data):
cur_tag = data[i]
i += 1
length = data[i]
i += 1
if length == 0x81:
length = data[i]
i += 1
elif length == 0x82:
length = (data[i] << 8) | data[i + 1]
i += 2
if cur_tag == tag:
return data[i : i + length]
i += length
return None

View File

@@ -0,0 +1,269 @@
"""Top-level Aliro reader transaction orchestrator.
Runs SELECT EXPEDITED -> AUTH0 -> AUTH1 against an abstract APDU transport,
decrypts the AUTH1 response, and verifies the UD signature against the
configured credential_PubK. Decoupled from pyscard so unit tests can drive
a mocked/in-process card.
"""
import secrets
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.personalizer.credentials import extract_pub_xy
from aliro_harness.reader.auth0 import build_auth0_apdu, build_auth0_data
from aliro_harness.reader.auth1 import (
build_auth1_apdu,
build_auth1_data,
build_table_812,
build_table_813,
sign_table_812_raw,
)
from aliro_harness.reader.auth1_response import (
decrypt_auth1_response,
verify_ud_signature,
)
from aliro_harness.reader.crypto import derive_kdh
from aliro_harness.reader.key_derivation import (
OFF_EXPEDITED_SK_DEVICE,
build_salt_volatile,
derive_expedited_session_keys,
)
from aliro_harness.reader.tlv import find_top_level
# Aliro EXPEDITED phase AID -- must match AliroAids.EXPEDITED in the applet.
EXPEDITED_AID = bytes.fromhex("A000000909ACCE5501")
Transmit = Callable[[bytes], tuple[bytes, int]]
SW_OK = 0x9000
@dataclass(frozen=True)
class TrustBundle:
"""Trust artifacts the reader needs for a transaction. Mirrors what
personalizer.TrustArtifacts loads but adds reader_priv (which the
personalizer doesn't need, since it never signs)."""
reader_priv: ec.EllipticCurvePrivateKey
reader_long_term_pub_x: bytes # 32B (used in salt_volatile)
reader_group_id: bytes # 16B
reader_group_sub_id: bytes # 16B
credential_long_term_pub_uncompressed: bytes # 65B (for verify_ud_signature)
credential_long_term_pub_x: bytes # 32B (for salt_volatile)
@classmethod
def synthesize(
cls,
*,
reader_priv: ec.EllipticCurvePrivateKey,
credential_pub: ec.EllipticCurvePublicKey,
reader_group_id: bytes | None = None,
reader_group_sub_id: bytes | None = None,
) -> "TrustBundle":
"""Convenience for tests: synthesize a TrustBundle from in-process keys."""
reader_pub_xy = extract_pub_xy(reader_priv.public_key()) # 64B (x||y)
reader_pub_x = reader_pub_xy[:32]
credential_pub_xy = extract_pub_xy(credential_pub) # 64B
credential_pub_x = credential_pub_xy[:32]
credential_pub_uncompressed = b"\x04" + credential_pub_xy
if reader_group_id is None:
reader_group_id = secrets.token_bytes(16)
if reader_group_sub_id is None:
reader_group_sub_id = secrets.token_bytes(16)
if len(reader_group_id) != 16:
raise ValueError("reader_group_id must be 16B")
if len(reader_group_sub_id) != 16:
raise ValueError("reader_group_sub_id must be 16B")
return cls(
reader_priv=reader_priv,
reader_long_term_pub_x=reader_pub_x,
reader_group_id=reader_group_id,
reader_group_sub_id=reader_group_sub_id,
credential_long_term_pub_uncompressed=credential_pub_uncompressed,
credential_long_term_pub_x=credential_pub_x,
)
@classmethod
def from_trust_dir(cls, trust_dir: Path) -> "TrustBundle":
"""Loads from ``aliro-trustgen init`` output:
- reader.pem -> reader_priv
- access_credential.pem -> credential public key
- reader_group_id.bin -> reader_group_id
- reader_group_sub_id.bin -> reader_group_sub_id
"""
reader_pem = (trust_dir / "reader.pem").read_bytes()
cred_pem = (trust_dir / "access_credential.pem").read_bytes()
reader_group_id = (trust_dir / "reader_group_id.bin").read_bytes()
reader_group_sub_id = (trust_dir / "reader_group_sub_id.bin").read_bytes()
reader_priv = serialization.load_pem_private_key(reader_pem, password=None)
cred_priv = serialization.load_pem_private_key(cred_pem, password=None)
return cls.synthesize(
reader_priv=reader_priv,
credential_pub=cred_priv.public_key(),
reader_group_id=reader_group_id,
reader_group_sub_id=reader_group_sub_id,
)
@dataclass(frozen=True)
class TransactionResult:
ok: bool
plaintext: bytes | None = None # decrypted Table 8-11 bytes
credential_pub: bytes | None = None # 0x5A value (65B uncompressed) when present
ud_sig: bytes | None = None # 0x9E value (64B raw r||s)
signaling_bitmap: bytes | None = None # 0x5E value (2B)
error: str | None = None # populated on failure
def run_aliro_transaction(
*, transmit: Transmit, bundle: TrustBundle
) -> TransactionResult:
"""Orchestrate one Aliro AUTH0+AUTH1 transaction.
Steps:
1. SELECT EXPEDITED
2. AUTH0 -- generate ephemeral, send, parse 0x86 (credential_ePubK)
3. Derive Kdh + ExpeditedSKDevice
4. Build + sign Table 8-12 -> AUTH1 command
5. Decrypt response, extract 0x5A/0x9E/0x5E TLVs
6. Verify UD signature over Table 8-13 against credential_PubK
Any non-9000 SW or signature mismatch returns ok=False with ``error`` set.
"""
# SELECT EXPEDITED
select_apdu = (
bytes([0x00, 0xA4, 0x04, 0x00, len(EXPEDITED_AID)]) + EXPEDITED_AID
)
_, sw = transmit(select_apdu)
if sw != SW_OK:
return TransactionResult(
ok=False, error=f"SELECT EXPEDITED failed: SW=0x{sw:04X}"
)
# Generate reader ephemeral + transaction state
reader_ephem = ec.generate_private_key(ec.SECP256R1())
reader_ephem_pub_uncompressed = reader_ephem.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
reader_ephem_pub_x = reader_ephem_pub_uncompressed[1:33]
txn_id = secrets.token_bytes(16)
# AUTH0
auth0_data = build_auth0_data(
reader_ephem_pub_uncompressed=reader_ephem_pub_uncompressed,
reader_group_id=bundle.reader_group_id,
reader_group_sub_id=bundle.reader_group_sub_id,
transaction_id=txn_id,
command_parameters=0x00,
authentication_policy=0x00,
)
auth0_resp_data, sw = transmit(build_auth0_apdu(auth0_data))
if sw != SW_OK:
return TransactionResult(ok=False, error=f"AUTH0 failed: SW=0x{sw:04X}")
cred_ephem_pub_uncompressed = find_top_level(auth0_resp_data, 0x86)
if (
cred_ephem_pub_uncompressed is None
or len(cred_ephem_pub_uncompressed) != 65
):
return TransactionResult(
ok=False,
error="AUTH0 response missing or malformed 0x86 credential_ePubK",
)
cred_ephem_pub_x = cred_ephem_pub_uncompressed[1:33]
# Key derivation
kdh = derive_kdh(reader_ephem, cred_ephem_pub_uncompressed, txn_id)
salt_volatile = build_salt_volatile(
reader_long_term_pub_x=bundle.reader_long_term_pub_x,
reader_group_id=bundle.reader_group_id,
reader_group_sub_id=bundle.reader_group_sub_id,
reader_ephem_pub_x=reader_ephem_pub_x,
transaction_id=txn_id,
command_parameters=0x00,
authentication_policy=0x00,
credential_long_term_pub_x=bundle.credential_long_term_pub_x,
)
derived_keys = derive_expedited_session_keys(
kdh, salt_volatile, cred_ephem_pub_x
)
sk_device = derived_keys[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32]
# AUTH1
reader_id_32 = bundle.reader_group_id + bundle.reader_group_sub_id
table_812 = build_table_812(
reader_id_32=reader_id_32,
credential_ephem_pub_x=cred_ephem_pub_x,
reader_ephem_pub_x=reader_ephem_pub_x,
transaction_id=txn_id,
)
raw_sig = sign_table_812_raw(table_812, bundle.reader_priv)
auth1_data = build_auth1_data(raw_sig, command_parameters=0x01)
auth1_resp_data, sw = transmit(build_auth1_apdu(auth1_data))
if sw != SW_OK:
return TransactionResult(ok=False, error=f"AUTH1 failed: SW=0x{sw:04X}")
# Decrypt + verify
try:
plaintext = decrypt_auth1_response(sk_device, auth1_resp_data)
except Exception as e:
return TransactionResult(
ok=False, error=f"AUTH1 response decrypt failed: {e}"
)
cred_pub_5a = find_top_level(plaintext, 0x5A)
ud_sig = find_top_level(plaintext, 0x9E)
bitmap = find_top_level(plaintext, 0x5E)
if ud_sig is None or len(ud_sig) != 64:
return TransactionResult(
ok=False,
error="Table 8-11 missing 0x9E UD signature",
plaintext=plaintext,
)
if bitmap is None:
return TransactionResult(
ok=False,
error="Table 8-11 missing 0x5E signaling_bitmap",
plaintext=plaintext,
)
table_813 = build_table_813(
reader_id_32=reader_id_32,
credential_ephem_pub_x=cred_ephem_pub_x,
reader_ephem_pub_x=reader_ephem_pub_x,
transaction_id=txn_id,
)
ud_pub = (
cred_pub_5a
if cred_pub_5a
else bundle.credential_long_term_pub_uncompressed
)
if not verify_ud_signature(ud_pub, table_813, ud_sig):
return TransactionResult(
ok=False,
error="UD signature verification failed",
plaintext=plaintext,
credential_pub=cred_pub_5a,
ud_sig=ud_sig,
signaling_bitmap=bitmap,
)
return TransactionResult(
ok=True,
plaintext=plaintext,
credential_pub=cred_pub_5a,
ud_sig=ud_sig,
signaling_bitmap=bitmap,
)

View File

@@ -0,0 +1,93 @@
"""Command-line entrypoint: `aliro-trustgen init --out-dir PATH`.
Produces a complete TEST-ONLY trust set: issuer keypair, reader keypair,
access credential keypair, reader group identifiers, a minimal signed
Access Document, and a C header the reader firmware includes at build
time.
"""
import os
import sys
from pathlib import Path
import click
from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.issuer.device_response import build_device_response
from aliro_harness.issuer.keys import (
generate_p256_keypair,
save_private_key_pem,
)
from aliro_harness.trustgen.header import TrustBundle, render_trust_header
_ARTIFACTS = (
"issuer.pem",
"reader.pem",
"access_credential.pem",
"reader_group_id.bin",
"reader_group_sub_id.bin",
"access_document.bin",
"device_response.bin",
"aliro_trust.h",
)
@click.group()
def main() -> None:
pass
@main.command()
@click.option(
"--out-dir",
required=True,
type=click.Path(file_okay=False),
help="Directory to write artifacts to (will be created if missing).",
)
@click.option(
"--force",
is_flag=True,
default=False,
help="Overwrite existing artifacts in --out-dir.",
)
def init(out_dir: str, force: bool) -> None:
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
existing = [name for name in _ARTIFACTS if (out / name).exists()]
if existing and not force:
click.echo(
f"Refusing to overwrite existing artifacts in {out}: "
f"{', '.join(existing)}. Pass --force to overwrite.",
err=True,
)
sys.exit(2)
issuer_key = generate_p256_keypair()
reader_key = generate_p256_keypair()
access_credential_key = generate_p256_keypair()
reader_group_id = os.urandom(16)
reader_group_sub_id = os.urandom(16)
save_private_key_pem(out / "issuer.pem", issuer_key)
save_private_key_pem(out / "reader.pem", reader_key)
save_private_key_pem(out / "access_credential.pem", access_credential_key)
(out / "reader_group_id.bin").write_bytes(reader_group_id)
(out / "reader_group_sub_id.bin").write_bytes(reader_group_sub_id)
access_doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_key.public_key(),
)
(out / "access_document.bin").write_bytes(access_doc)
(out / "device_response.bin").write_bytes(build_device_response(access_doc))
bundle = TrustBundle(
issuer_public_key=issuer_key.public_key(),
reader_private_key=reader_key,
reader_group_id=reader_group_id,
reader_group_sub_id=reader_group_sub_id,
)
(out / "aliro_trust.h").write_text(render_trust_header(bundle))
click.echo(f"Wrote {len(_ARTIFACTS)} artifacts to {out}")

View File

@@ -0,0 +1,102 @@
"""Render a C header embedding the v1 Aliro trust anchors.
Consumed at build time by the reader firmware. Format intentionally matches
the byte-array conventions used by X-CUBE-ALIRO's provisioning.c: 32-byte
raw private scalar, 64-byte x||y public point (no 0x04 prefix).
"""
from dataclasses import dataclass
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.kid import compute_issuer_kid
_P256_COORD_LEN = 32
@dataclass
class TrustBundle:
issuer_public_key: ec.EllipticCurvePublicKey
reader_private_key: ec.EllipticCurvePrivateKey
reader_group_id: bytes
reader_group_sub_id: bytes
def _c_byte_array(data: bytes) -> str:
return "{ " + ", ".join(f"0x{b:02x}" for b in data) + " }"
def _priv_scalar(key: ec.EllipticCurvePrivateKey) -> bytes:
return key.private_numbers().private_value.to_bytes(_P256_COORD_LEN, "big")
def _pub_xy(key: ec.EllipticCurvePublicKey) -> tuple[bytes, bytes]:
n = key.public_numbers()
return (
n.x.to_bytes(_P256_COORD_LEN, "big"),
n.y.to_bytes(_P256_COORD_LEN, "big"),
)
def render_trust_header(bundle: TrustBundle) -> str:
reader_priv = _priv_scalar(bundle.reader_private_key)
reader_x, reader_y = _pub_xy(bundle.reader_private_key.public_key())
issuer_x, issuer_y = _pub_xy(bundle.issuer_public_key)
kid = compute_issuer_kid(bundle.issuer_public_key)
lines = [
"/* aliro_trust.h - AUTO-GENERATED by aliro-trustgen. DO NOT EDIT. */",
"/* Regenerate from the canonical keys under harness/trustgen/out/. */",
"",
"#ifndef ALIRO_TRUST_H_",
"#define ALIRO_TRUST_H_",
"",
"/* Reader long-term ECC P-256 key pair. */",
"/* Private: 32-byte raw scalar. */",
"/* Public: 64-byte x||y (no 0x04 prefix). */",
f"#define ALIRO_READER_PRIVATE_KEY {_c_byte_array(reader_priv)}",
f"#define ALIRO_READER_PUBLIC_KEY {_c_byte_array(reader_x + reader_y)}",
"",
"/* Reader group identifiers (spec \u00a76.2). */",
f"#define ALIRO_READER_GROUP_ID {_c_byte_array(bundle.reader_group_id)}",
f"#define ALIRO_READER_GROUP_SUB_ID {_c_byte_array(bundle.reader_group_sub_id)}",
"",
"/* Credential Issuer trust anchor (raw ECC P-256 public key). */",
f"#define ALIRO_ISSUER_PUB_X {_c_byte_array(issuer_x)}",
f"#define ALIRO_ISSUER_PUB_Y {_c_byte_array(issuer_y)}",
f"#define ALIRO_ISSUER_PUB {_c_byte_array(issuer_x + issuer_y)}",
"",
"/* kid for IssuerAuth header (spec \u00a77.2.1, 8 bytes). */",
f"#define ALIRO_ISSUER_KID {_c_byte_array(kid)}",
"",
"/* ----------------------------------------------------------------- */",
"/* X-CUBE-ALIRO vendor-compat overrides for User_provisioning.h. */",
"/* Gated by ALIRO_TRUST_OVERRIDE so firmware projects that don't */",
"/* opt in keep the vendor defaults. */",
"#ifdef ALIRO_TRUST_OVERRIDE",
"",
"/* READER_ID_* = group_id || group_sub_id (32B concatenated). */",
"#undef READER_ID_2",
"#undef READER_ID",
f"#define READER_ID_2 {_c_byte_array(bundle.reader_group_id + bundle.reader_group_sub_id)}",
f"#define READER_ID {_c_byte_array(bundle.reader_group_id + bundle.reader_group_sub_id)}",
"",
"#undef READER_PUB_KEY_2",
"#undef READER_PUB_KEY",
f"#define READER_PUB_KEY_2 {_c_byte_array(reader_x + reader_y)}",
f"#define READER_PUB_KEY {_c_byte_array(reader_x + reader_y)}",
"",
"#undef READER_PRIVATE_KEY_2",
"#undef READER_PRIVATE_KEY",
f"#define READER_PRIVATE_KEY_2 {_c_byte_array(reader_priv)}",
f"#define READER_PRIVATE_KEY {_c_byte_array(reader_priv)}",
"",
"#undef CREDENTIAL_ISSUER_PUB_KEY_2",
f"#define CREDENTIAL_ISSUER_PUB_KEY_2 {_c_byte_array(issuer_x + issuer_y)}",
"",
"#endif /* ALIRO_TRUST_OVERRIDE */",
"",
"#endif /* ALIRO_TRUST_H_ */",
"",
]
return "\n".join(lines)

View File

425
harness/tests/fake_card.py Normal file
View File

@@ -0,0 +1,425 @@
"""In-process Python port of AliroApplet's AUTH0+AUTH1 paths.
Used by test_reader_transaction_offline.py to validate the reader without
needing a real card or pyscard. Mirrors AliroApplet processAuth0 +
processAuth1 -- but only enough to satisfy a one-shot reader transaction.
Skips: provisioning, replay protection (we never call AUTH1 twice in tests),
step-up, mailbox, vendor extensions, error paths beyond happy-path returns.
"""
from dataclasses import dataclass
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature,
encode_dss_signature,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.auth1 import (
build_table_812,
build_table_813,
)
from aliro_harness.reader.crypto import derive_kdh
from aliro_harness.reader.key_derivation import (
OFF_EXPEDITED_SK_DEVICE,
build_salt_volatile,
derive_expedited_session_keys,
)
# AIDs and INS values -- match the applet
EXPEDITED_AID = bytes.fromhex("A000000909ACCE5501")
INS_SELECT = 0xA4
INS_AUTH0 = 0x80
INS_AUTH1 = 0x81
# FCI bytes the real applet returns from sendExpeditedFci. Pre-computed so
# the fake card returns a byte-identical FCI.
# 6F 15 -- FCI template, outer len = 21
# 84 09 A0 00 00 09 09 AC CE 55 01 -- DF name = EXPEDITED_AID
# A5 08 -- proprietary, inner len = 8
# 80 02 00 00 -- application type = CSA (0x0000)
# 5C 02 01 00 -- protocol version = 0x0100
EXPEDITED_FCI = bytes.fromhex(
"6F15"
"8409" "A000000909ACCE5501"
"A508" "80020000" "5C020100"
)
# AUTH0 TLV tags (AliroApplet)
TAG_COMMAND_PARAMETERS = 0x41
TAG_AUTHENTICATION_POLICY = 0x42
TAG_PROTOCOL_VERSION = 0x5C
TAG_READER_EPUBK = 0x87
TAG_TRANSACTION_ID = 0x4C
TAG_READER_IDENTIFIER = 0x4D
TAG_CREDENTIAL_EPUBK = 0x86
# AUTH1 TLV tags
TAG_READER_SIGNATURE = 0x9E
TAG_READER_CERT = 0x90
PROTOCOL_VERSION_1_0 = 0x0100
@dataclass
class _FakeSession:
"""Per-transaction state captured during AUTH0, consumed by AUTH1."""
reader_ephem_pub_uncompressed: bytes
reader_group_id: bytes
reader_group_sub_id: bytes
transaction_id: bytes
command_parameters: int
authentication_policy: int
credential_ephem_pub_uncompressed: bytes | None = None
@dataclass
class FakeAliroCard:
"""Holds provisioned trust state + per-transaction state.
Mirrors just enough of AliroApplet to drive the reader through
SELECT -> AUTH0 -> AUTH1 successfully.
"""
# Provisioned trust state
reader_pub: ec.EllipticCurvePublicKey
credential_priv: ec.EllipticCurvePrivateKey
credential_pub: ec.EllipticCurvePublicKey
has_access_document: bool = False
# Per-transaction state, set during processAuth0
auth0_done: bool = False
session: _FakeSession | None = None
credential_ephem_priv: ec.EllipticCurvePrivateKey | None = None
@classmethod
def provisioned(
cls,
*,
reader_pub: ec.EllipticCurvePublicKey,
credential_priv: ec.EllipticCurvePrivateKey,
credential_pub: ec.EllipticCurvePublicKey,
has_access_document: bool = False,
) -> "FakeAliroCard":
return cls(
reader_pub=reader_pub,
credential_priv=credential_priv,
credential_pub=credential_pub,
has_access_document=has_access_document,
)
# ---------------- Transport ----------------
def transmit(self, apdu: bytes) -> tuple[bytes, int]:
"""Implements ISO 7816 SELECT + AUTH0 + AUTH1 INSes."""
if len(apdu) < 4:
return b"", 0x6700
cla, ins, p1, _p2 = apdu[0], apdu[1], apdu[2], apdu[3]
# SELECT is CLA=0x00
if cla == 0x00 and ins == INS_SELECT and p1 == 0x04:
if len(apdu) < 5:
return b"", 0x6700
lc = apdu[4]
aid = apdu[5 : 5 + lc]
if aid == EXPEDITED_AID:
# Fresh SELECT clears any prior session state.
self.auth0_done = False
self.session = None
self.credential_ephem_priv = None
return EXPEDITED_FCI, 0x9000
return b"", 0x6A82 # file not found
if cla == 0x80 and ins == INS_AUTH0:
return self._process_auth0(apdu)
if cla == 0x80 and ins == INS_AUTH1:
return self._process_auth1(apdu)
if cla != 0x80:
return b"", 0x6E00 # CLA not supported
return b"", 0x6D00 # INS not supported
# ---------------- AUTH0 ----------------
def _process_auth0(self, apdu: bytes) -> tuple[bytes, int]:
"""Validate AUTH0 TLVs, capture session state, generate ephemeral,
return 0x86 TLV (uncompressed 65B credential_ePubK)."""
if len(apdu) < 5:
return b"", 0x6700
lc = apdu[4]
data = apdu[5 : 5 + lc]
if len(data) != lc:
return b"", 0x6700
try:
parsed = self._parse_auth0(data)
except _WrongData:
return b"", 0x6A80
# Capture session state
self.session = _FakeSession(
reader_ephem_pub_uncompressed=parsed["reader_ephem_pub"],
reader_group_id=parsed["reader_group_id"],
reader_group_sub_id=parsed["reader_group_sub_id"],
transaction_id=parsed["transaction_id"],
command_parameters=parsed["command_parameters"],
authentication_policy=parsed["authentication_policy"],
)
# Generate fresh credential ephemeral keypair
self.credential_ephem_priv = ec.generate_private_key(ec.SECP256R1())
ephem_pub_uncompressed = self.credential_ephem_priv.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
self.session.credential_ephem_pub_uncompressed = ephem_pub_uncompressed
# Build response: 0x86 0x41 [65B]
resp = bytes([TAG_CREDENTIAL_EPUBK, len(ephem_pub_uncompressed)]) + ephem_pub_uncompressed
self.auth0_done = True
return resp, 0x9000
def _parse_auth0(self, data: bytes) -> dict:
if len(data) == 0:
raise _WrongData()
seen = 0
out = {}
i = 0
while i < len(data):
if i + 2 > len(data):
raise _WrongData()
tag = data[i]
length = data[i + 1]
value_off = i + 2
if value_off + length > len(data):
raise _WrongData()
value = data[value_off : value_off + length]
if tag == TAG_COMMAND_PARAMETERS:
if length != 1:
raise _WrongData()
out["command_parameters"] = value[0]
seen |= 0x01
elif tag == TAG_AUTHENTICATION_POLICY:
if length != 1:
raise _WrongData()
out["authentication_policy"] = value[0]
seen |= 0x02
elif tag == TAG_PROTOCOL_VERSION:
if length != 2:
raise _WrongData()
version = (value[0] << 8) | value[1]
if version != PROTOCOL_VERSION_1_0:
raise _WrongData()
seen |= 0x04
elif tag == TAG_READER_EPUBK:
if length != 65 or value[0] != 0x04:
raise _WrongData()
out["reader_ephem_pub"] = value
seen |= 0x08
elif tag == TAG_TRANSACTION_ID:
if length != 16:
raise _WrongData()
out["transaction_id"] = value
seen |= 0x10
elif tag == TAG_READER_IDENTIFIER:
if length != 32:
raise _WrongData()
out["reader_group_id"] = value[:16]
out["reader_group_sub_id"] = value[16:]
seen |= 0x20
# else: unknown tag, silently accept (spec 1.4.3)
i = value_off + length
if seen != 0x3F:
raise _WrongData()
return out
# ---------------- AUTH1 ----------------
def _process_auth1(self, apdu: bytes) -> tuple[bytes, int]:
"""Verify reader sig, derive keys, sign Table 8-13 with credential
priv, build Table 8-11 plaintext, GCM-encrypt, return."""
if not self.auth0_done:
return b"", 0x6985 # CONDITIONS_NOT_SATISFIED
# Mirror applet: clear AUTH0_DONE before crypto, to prevent replay.
self.auth0_done = False
if len(apdu) < 5:
return b"", 0x6700
lc = apdu[4]
data = apdu[5 : 5 + lc]
if len(data) != lc:
return b"", 0x6700
try:
auth1 = self._parse_auth1(data)
except _WrongData:
return b"", 0x6A80
cmd_params = auth1["command_parameters"]
reader_raw_sig = auth1["reader_signature"]
# Reconstruct Table 8-12 and verify against captured reader pub
reader_ephem_pub = self.session.reader_ephem_pub_uncompressed
cred_ephem_pub = self.session.credential_ephem_pub_uncompressed
txn_id = self.session.transaction_id
reader_id_32 = (
self.session.reader_group_id + self.session.reader_group_sub_id
)
table_812 = build_table_812(
reader_id_32=reader_id_32,
credential_ephem_pub_x=cred_ephem_pub[1:33],
reader_ephem_pub_x=reader_ephem_pub[1:33],
transaction_id=txn_id,
)
if not self._verify_reader_sig(table_812, reader_raw_sig):
return b"", 0x6A80
# Derive session keys
sk_device = self._derive_sk_device()
# Sign Table 8-13 with credential_priv
table_813 = build_table_813(
reader_id_32=reader_id_32,
credential_ephem_pub_x=cred_ephem_pub[1:33],
reader_ephem_pub_x=reader_ephem_pub[1:33],
transaction_id=txn_id,
)
der_sig = self.credential_priv.sign(table_813, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der_sig)
ud_raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big")
# Build Table 8-11 plaintext
plaintext = self._build_table_811_plaintext(cmd_params, ud_raw_sig)
# Encrypt with sk_device, IV = 7x00 || 01 || 0x00000001
iv = b"\x00" * 7 + b"\x01" + (1).to_bytes(4, "big")
ct_and_tag = AESGCM(sk_device).encrypt(iv, plaintext, associated_data=None)
return ct_and_tag, 0x9000
def _parse_auth1(self, data: bytes) -> dict:
if len(data) == 0:
raise _WrongData()
seen = 0
out = {}
i = 0
while i < len(data):
if i + 2 > len(data):
raise _WrongData()
tag = data[i]
length = data[i + 1]
value_off = i + 2
if value_off + length > len(data):
raise _WrongData()
value = data[value_off : value_off + length]
if tag == TAG_COMMAND_PARAMETERS:
if length != 1:
raise _WrongData()
out["command_parameters"] = value[0]
seen |= 0x01
elif tag == TAG_READER_SIGNATURE:
if length != 64:
raise _WrongData()
out["reader_signature"] = value
seen |= 0x02
elif tag == TAG_READER_CERT:
# v1: rejected explicitly
raise _WrongData()
# else: unknown tag, silently accept
i = value_off + length
if seen != 0x03:
raise _WrongData()
return out
def _verify_reader_sig(self, table_812: bytes, raw_sig_64: bytes) -> bool:
if len(raw_sig_64) != 64:
return False
r = int.from_bytes(raw_sig_64[:32], "big")
s = int.from_bytes(raw_sig_64[32:], "big")
der = encode_dss_signature(r, s)
try:
self.reader_pub.verify(der, table_812, ec.ECDSA(hashes.SHA256()))
return True
except InvalidSignature:
return False
def _derive_sk_device(self) -> bytes:
"""Mirrors AliroApplet.deriveSessionKeys."""
reader_ephem_pub = self.session.reader_ephem_pub_uncompressed
cred_ephem_pub = self.session.credential_ephem_pub_uncompressed
txn_id = self.session.transaction_id
# Kdh: ECDH(credential_ephem_priv, reader_ephem_pub) -> HKDF with salt=txn_id
kdh = derive_kdh(
self.credential_ephem_priv,
reader_ephem_pub,
txn_id,
)
# x-coords
reader_long_term_x = self._pub_x(self.reader_pub)
credential_long_term_x = self._pub_x(self.credential_pub)
salt = build_salt_volatile(
reader_long_term_pub_x=reader_long_term_x,
reader_group_id=self.session.reader_group_id,
reader_group_sub_id=self.session.reader_group_sub_id,
reader_ephem_pub_x=reader_ephem_pub[1:33],
transaction_id=txn_id,
command_parameters=self.session.command_parameters,
authentication_policy=self.session.authentication_policy,
credential_long_term_pub_x=credential_long_term_x,
)
info = cred_ephem_pub[1:33]
derived = derive_expedited_session_keys(kdh, salt, info)
return derived[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32]
def _build_table_811_plaintext(self, cmd_params: int, ud_raw_sig: bytes) -> bytes:
"""Table 8-11 plaintext per AliroApplet.buildTable811Plaintext."""
out = bytearray()
if (cmd_params & 0x01) == 0:
# 0x4E 0x08 [key_slot] = first 8B of SHA-1(uncompressed credential_PubK)
cred_pub_uncomp = self.credential_pub.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
sha1 = hashes.Hash(hashes.SHA1())
sha1.update(cred_pub_uncomp)
digest = sha1.finalize()
out += bytes([0x4E, 0x08]) + digest[:8]
else:
# 0x5A 0x41 [0x04 || x || y]
cred_pub_uncomp = self.credential_pub.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
out += bytes([0x5A, 0x41]) + cred_pub_uncomp
# 0x9E 0x40 [raw r||s UD signature]
out += bytes([0x9E, 0x40]) + ud_raw_sig
# 0x5E 0x02 [signaling_bitmap] (big-endian 16-bit)
bitmap = 0
if self.has_access_document:
bitmap |= 0x0001 # bit 0: AD retrievable
bitmap |= 0x0004 # bit 2: step-up AID required on NFC
out += bytes([0x5E, 0x02, (bitmap >> 8) & 0xFF, bitmap & 0xFF])
return bytes(out)
@staticmethod
def _pub_x(pub: ec.EllipticCurvePublicKey) -> bytes:
nums = pub.public_numbers()
return nums.x.to_bytes(32, "big")
class _WrongData(Exception):
"""Raised internally when TLV parsing fails; mapped to SW=6A80."""
pass

View File

@@ -0,0 +1,161 @@
"""Tests for building minimal Aliro Access Documents.
v1 scope: MSO contains only the fields needed for a Reader to verify the
Access Credential public key. No Access Data Elements, no schedules, no
access rules. Those arrive later.
Reference: Aliro spec \u00a77.2 and ISO 18013-5 \u00a79.1.2.
"""
import cbor2
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.access_document import (
ALIRO_DOCTYPE_ACCESS,
MSO_KEY_DEVICE_KEY_INFO,
MSO_KEY_DIGEST_ALG,
MSO_KEY_DOC_TYPE,
MSO_KEY_TIME_VERIFICATION_REQUIRED,
MSO_KEY_VALIDITY_INFO,
MSO_KEY_VALUE_DIGESTS,
MSO_KEY_VERSION,
build_access_document,
)
from aliro_harness.issuer.cose import verify_cose_sign1
from aliro_harness.issuer.kid import compute_issuer_kid
@pytest.fixture
def issuer_key():
return ec.generate_private_key(ec.SECP256R1())
@pytest.fixture
def access_credential_pub():
return ec.generate_private_key(ec.SECP256R1()).public_key()
def test_access_document_verifies_with_issuer_public_key(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
payload = verify_cose_sign1(doc, issuer_key.public_key())
assert isinstance(payload, bytes)
assert len(payload) > 0
def test_access_document_payload_is_canonical_cbor_map(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
payload = verify_cose_sign1(doc, issuer_key.public_key())
mso = cbor2.loads(payload)
assert isinstance(mso, dict)
def test_access_document_mso_has_required_aliro_keys(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
required = {
MSO_KEY_VERSION,
MSO_KEY_DIGEST_ALG,
MSO_KEY_VALUE_DIGESTS,
MSO_KEY_DEVICE_KEY_INFO,
MSO_KEY_DOC_TYPE,
MSO_KEY_VALIDITY_INFO,
MSO_KEY_TIME_VERIFICATION_REQUIRED,
}
assert required.issubset(mso.keys()), f"missing: {required - mso.keys()}"
def test_access_document_keys_are_text_strings_not_ints(issuer_key, access_credential_pub):
"""Aliro \u00a77.2.2: keys replaced by integers 'still encoded as text strings'."""
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
for key in mso:
assert isinstance(key, str), f"MSO key {key!r} must be tstr, got {type(key).__name__}"
def test_access_document_docType_is_aliro_a(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
assert mso[MSO_KEY_DOC_TYPE] == ALIRO_DOCTYPE_ACCESS == "aliro-a"
def test_access_document_digest_alg_is_sha256(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
assert mso[MSO_KEY_DIGEST_ALG] == "SHA-256"
def test_access_document_time_verification_required_is_false_for_v1(
issuer_key, access_credential_pub
):
"""v1 test CA emits permissive documents; Reader may ignore time checks."""
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
assert mso[MSO_KEY_TIME_VERIFICATION_REQUIRED] is False
def test_access_document_device_key_is_access_credential_pubkey(
issuer_key, access_credential_pub
):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
device_key_info = mso[MSO_KEY_DEVICE_KEY_INFO]
cose_key = device_key_info["1"]
assert cose_key[1] == 2 # kty = EC2
assert cose_key[-1] == 1 # crv = P-256
expected = access_credential_pub.public_numbers()
assert cose_key[-2] == expected.x.to_bytes(32, "big")
assert cose_key[-3] == expected.y.to_bytes(32, "big")
def test_access_document_fits_within_applet_storage_budget(issuer_key, access_credential_pub):
"""The applet's CredentialStore reserves ACCESS_DOC_MAX_LEN = 1024 bytes
for the Access Document. If this test ever fails, either the AD has
grown (add fields? new namespaces?) or the card-side budget needs a
bump — sync with CredentialStore.ACCESS_DOC_MAX_LEN on the applet side.
"""
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
assert len(doc) <= 1024, f"Access Document is {len(doc)} bytes, exceeds 1024-byte applet budget"
def test_access_document_kid_in_unprotected_header_matches_issuer(
issuer_key, access_credential_pub
):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
decoded = cbor2.loads(doc)
_protected, unprotected, _payload, _sig = decoded
assert unprotected[4] == compute_issuer_kid(issuer_key.public_key())

39
harness/tests/test_ca.py Normal file
View File

@@ -0,0 +1,39 @@
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.ca import create_self_signed_ca
def test_create_self_signed_ca_returns_p256_cert_and_key():
cert, key = create_self_signed_ca(common_name="Test Credential Issuer CA")
assert isinstance(cert, x509.Certificate)
assert isinstance(key, ec.EllipticCurvePrivateKey)
assert key.curve.name == "secp256r1"
def test_self_signed_ca_subject_and_issuer_match():
cert, _ = create_self_signed_ca(common_name="Test Credential Issuer CA")
assert cert.subject == cert.issuer
cn = cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value
assert cn == "Test Credential Issuer CA"
def test_self_signed_ca_has_ca_basic_constraints():
cert, _ = create_self_signed_ca(common_name="Test CA")
bc = cert.extensions.get_extension_for_class(x509.BasicConstraints)
assert bc.value.ca is True
assert bc.critical is True
def test_self_signed_ca_signature_verifies_with_own_key():
cert, key = create_self_signed_ca(common_name="Test CA")
public_key = key.public_key()
public_key.verify(
cert.signature,
cert.tbs_certificate_bytes,
ec.ECDSA(cert.signature_hash_algorithm),
)

View File

@@ -0,0 +1,74 @@
"""Tests for the hand-rolled COSE_Sign1 sign/verify primitive."""
import cbor2
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.cose import (
CoseVerificationError,
sign_cose_sign1,
verify_cose_sign1,
)
def test_sign_then_verify_returns_original_payload():
key = ec.generate_private_key(ec.SECP256R1())
payload = b"hello, aliro"
signed = sign_cose_sign1(
private_key=key,
payload=payload,
kid=b"\x01\x02\x03\x04\x05\x06\x07\x08",
)
recovered = verify_cose_sign1(signed, key.public_key())
assert recovered == payload
def test_verify_fails_with_wrong_key():
signing_key = ec.generate_private_key(ec.SECP256R1())
wrong_key = ec.generate_private_key(ec.SECP256R1())
signed = sign_cose_sign1(
private_key=signing_key,
payload=b"aliro-a",
kid=b"\x00" * 8,
)
with pytest.raises(CoseVerificationError):
verify_cose_sign1(signed, wrong_key.public_key())
def test_signed_structure_is_well_formed_cose_sign1():
"""COSE_Sign1 wire format: 4-element CBOR array, alg in protected, ES256 = -7."""
key = ec.generate_private_key(ec.SECP256R1())
kid = b"\xaa\xbb\xcc\xdd\xee\xff\x11\x22"
signed = sign_cose_sign1(private_key=key, payload=b"x", kid=kid)
decoded = cbor2.loads(signed)
assert isinstance(decoded, list)
assert len(decoded) == 4
protected_bstr, unprotected, payload_bstr, signature = decoded
assert isinstance(protected_bstr, bytes)
assert isinstance(unprotected, dict)
assert isinstance(payload_bstr, bytes)
assert isinstance(signature, bytes)
protected = cbor2.loads(protected_bstr)
assert protected == {1: -7}
assert unprotected.get(4) == kid
assert payload_bstr == b"x"
assert len(signature) == 64
def test_signature_uses_raw_r_s_not_der():
"""COSE ECDSA signatures are raw r||s (64 bytes for P-256), not DER-encoded."""
key = ec.generate_private_key(ec.SECP256R1())
signed = sign_cose_sign1(private_key=key, payload=b"x", kid=b"\x00" * 8)
signature = cbor2.loads(signed)[3]
assert len(signature) == 64
assert signature[0] != 0x30

View File

@@ -0,0 +1,97 @@
"""Tests for the DeviceResponse builder used in the step-up phase."""
import cbor2
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.issuer.cose import verify_cose_sign1
from aliro_harness.issuer.device_response import build_device_response
@pytest.fixture
def issuer_key():
return ec.generate_private_key(ec.SECP256R1())
@pytest.fixture
def access_doc(issuer_key):
return build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=ec.generate_private_key(ec.SECP256R1()).public_key(),
)
def test_device_response_has_aliro_remapped_top_level_keys(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert set(decoded.keys()) == {"1", "2", "3"}, (
"top-level keys must be Aliro Table 8-22 aliases (version, documents, status)"
)
def test_device_response_version_is_1_0(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert decoded["1"] == "1.0"
def test_device_response_status_is_ok(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert decoded["3"] == 0
def test_device_response_has_exactly_one_document(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert isinstance(decoded["2"], list)
assert len(decoded["2"]) == 1
def test_document_doctype_is_aliro_a(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert decoded["2"][0]["5"] == "aliro-a"
def test_issuer_signed_has_empty_namespaces(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
issuer_signed = decoded["2"][0]["1"]
assert issuer_signed["1"] == {}, "v1 returns no Access Data Elements"
def test_issuer_auth_is_the_original_cose_sign1(access_doc, issuer_key):
"""IssuerAuth embedded in DeviceResponse must still verify as a valid
COSE_Sign1 under the issuer's public key — i.e. the AD bytes round-trip."""
decoded = cbor2.loads(build_device_response(access_doc))
issuer_auth = decoded["2"][0]["1"]["2"]
assert isinstance(issuer_auth, list) and len(issuer_auth) == 4, (
"IssuerAuth is a 4-element COSE_Sign1 array"
)
# Re-serialize and verify.
reserialized = cbor2.dumps(issuer_auth, canonical=True)
payload = verify_cose_sign1(reserialized, issuer_key.public_key())
assert isinstance(payload, bytes) and len(payload) > 0
def test_forbidden_fields_absent(access_doc):
"""Per Aliro §8.4.2, documentErrors / errors / deviceSigned / readerAuth
SHALL NOT be present."""
decoded = cbor2.loads(build_device_response(access_doc))
doc = decoded["2"][0]
assert "documentErrors" not in decoded
assert "errors" not in decoded
assert "documentErrors" not in doc
assert "errors" not in doc
assert "deviceSigned" not in doc
def test_device_response_is_deterministic(access_doc):
"""Same inputs must produce byte-identical CBOR."""
a = build_device_response(access_doc)
b = build_device_response(access_doc)
assert a == b, "DeviceResponse must be deterministic CBOR for a given AD"
def test_device_response_fits_within_realistic_apdu_budget(access_doc):
"""Sanity-check that encrypted response size stays under the step-up
APDU chain budget (ENVELOPE/GET RESPONSE supports >2000 bytes with
extended-length support, but we want to stay comfortably small)."""
encoded = build_device_response(access_doc)
assert len(encoded) < 2000, f"DeviceResponse is {len(encoded)} bytes"

View File

@@ -0,0 +1,25 @@
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.keys import (
generate_p256_keypair,
load_private_key_pem,
save_private_key_pem,
)
def test_generate_p256_keypair_returns_p256_private_key():
priv = generate_p256_keypair()
assert isinstance(priv, ec.EllipticCurvePrivateKey)
assert priv.curve.name == "secp256r1"
def test_pem_roundtrip_preserves_private_key(tmp_path):
original = generate_p256_keypair()
path = tmp_path / "key.pem"
save_private_key_pem(path, original)
loaded = load_private_key_pem(path)
original_bytes = original.private_numbers().private_value.to_bytes(32, "big")
loaded_bytes = loaded.private_numbers().private_value.to_bytes(32, "big")
assert original_bytes == loaded_bytes

45
harness/tests/test_kid.py Normal file
View File

@@ -0,0 +1,45 @@
"""Tests for the Aliro `kid` header derivation per spec \u00a77.2.1.
The spec defines:
kid = SHA256("key-identifier" || 0x04 || IssuerKey_PubK.x || IssuerKey_PubK.y)[:8]
where "key-identifier" is the literal ASCII string.
"""
import hashlib
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.kid import compute_issuer_kid
def test_kid_is_8_bytes():
key = ec.generate_private_key(ec.SECP256R1())
kid = compute_issuer_kid(key.public_key())
assert isinstance(kid, bytes)
assert len(kid) == 8
def test_kid_matches_spec_formula():
"""Reference check: re-derive the kid from scratch and compare."""
key = ec.generate_private_key(ec.SECP256R1())
pub = key.public_key()
numbers = pub.public_numbers()
x_bytes = numbers.x.to_bytes(32, "big")
y_bytes = numbers.y.to_bytes(32, "big")
expected_input = b"key-identifier" + b"\x04" + x_bytes + y_bytes
expected_kid = hashlib.sha256(expected_input).digest()[:8]
assert compute_issuer_kid(pub) == expected_kid
def test_kid_is_deterministic():
key = ec.generate_private_key(ec.SECP256R1())
pub = key.public_key()
assert compute_issuer_kid(pub) == compute_issuer_kid(pub)
def test_kid_differs_for_different_keys():
key_a = ec.generate_private_key(ec.SECP256R1())
key_b = ec.generate_private_key(ec.SECP256R1())
assert compute_issuer_kid(key_a.public_key()) != compute_issuer_kid(key_b.public_key())

View File

@@ -0,0 +1,72 @@
"""Tests for the Access Document APDU sequence builder."""
import pytest
from aliro_harness.personalizer.access_document import (
ACCESS_DOC_MAX_LEN,
APDU_CHUNK_MAX,
CLA_PROPRIETARY,
INS_FINALIZE_ACCESS_DOC,
INS_WRITE_ACCESS_DOC,
access_document_apdus,
)
def test_small_ad_emits_one_write_then_finalize():
ad = bytes(range(50))
apdus = access_document_apdus(ad)
assert len(apdus) == 2, "small AD => 1 WRITE + 1 FINALIZE"
write = apdus[0]
assert write[0] == CLA_PROPRIETARY
assert write[1] == INS_WRITE_ACCESS_DOC
assert write[2] == 0 and write[3] == 0, "first chunk at offset 0"
assert write[4] == len(ad)
assert write[5 : 5 + len(ad)] == ad
finalize = apdus[1]
assert finalize == bytes([CLA_PROPRIETARY, INS_FINALIZE_ACCESS_DOC, 0x00, len(ad)])
def test_ad_over_chunk_size_splits_into_multiple_writes():
ad = bytes(i & 0xFF for i in range(600))
apdus = access_document_apdus(ad, chunk_size=255)
# 600 bytes @ 255/chunk = 3 writes (255 + 255 + 90), + 1 finalize
assert len(apdus) == 4
# Check offsets and chunk boundaries.
assert apdus[0][2:4] == bytes([0, 0])
assert apdus[0][4] == 255
assert apdus[1][2:4] == bytes([255 >> 8, 255 & 0xFF])
assert apdus[1][4] == 255
assert apdus[2][2:4] == bytes([510 >> 8, 510 & 0xFF])
assert apdus[2][4] == 90
assert apdus[3][0:2] == bytes([CLA_PROPRIETARY, INS_FINALIZE_ACCESS_DOC])
assert apdus[3][2:4] == bytes([600 >> 8, 600 & 0xFF])
def test_concatenated_write_payloads_reconstruct_the_original_ad():
ad = bytes(i & 0xFF for i in range(777))
apdus = access_document_apdus(ad)
reconstructed = b"".join(a[5:] for a in apdus if a[1] == INS_WRITE_ACCESS_DOC)
assert reconstructed == ad
def test_ad_exactly_at_budget_is_accepted():
ad = bytes(ACCESS_DOC_MAX_LEN)
apdus = access_document_apdus(ad)
assert apdus[-1][2] == (ACCESS_DOC_MAX_LEN >> 8)
assert apdus[-1][3] == (ACCESS_DOC_MAX_LEN & 0xFF)
def test_ad_over_budget_is_rejected():
ad = bytes(ACCESS_DOC_MAX_LEN + 1)
with pytest.raises(ValueError, match="exceeds"):
access_document_apdus(ad)
def test_chunk_size_out_of_range_is_rejected():
with pytest.raises(ValueError, match="chunk_size"):
access_document_apdus(b"x", chunk_size=0)
with pytest.raises(ValueError, match="chunk_size"):
access_document_apdus(b"x", chunk_size=APDU_CHUNK_MAX + 1)

View File

@@ -0,0 +1,61 @@
"""APDU constructor tests — exact byte-shape locks."""
import pytest
from aliro_harness.personalizer.apdus import (
CLA_PROPRIETARY,
INS_COMMIT,
INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK,
INS_SET_READER_PUBK,
PROVISIONING_AID,
commit_apdu,
select_provisioning_apdu,
set_credential_priv_apdu,
set_credential_pubk_apdu,
set_reader_pubk_apdu,
)
def test_provisioning_aid_matches_applet_constant():
"""If this changes, AliroAids.PROVISIONING in the applet has drifted."""
assert PROVISIONING_AID.hex().upper() == "A000000909ACCE559901"
def test_select_provisioning_apdu_shape():
apdu = select_provisioning_apdu()
assert apdu == bytes([0x00, 0xA4, 0x04, 0x00, 0x0A]) + PROVISIONING_AID
def test_set_credential_priv_apdu():
priv = bytes(range(32))
apdu = set_credential_priv_apdu(priv)
assert apdu == bytes([CLA_PROPRIETARY, INS_SET_CRED_PRIV, 0x00, 0x00, 0x20]) + priv
def test_set_credential_priv_rejects_wrong_length():
with pytest.raises(ValueError, match="32B"):
set_credential_priv_apdu(bytes(31))
def test_set_credential_pubk_apdu():
pub = bytes(range(64))
apdu = set_credential_pubk_apdu(pub)
assert apdu == bytes([CLA_PROPRIETARY, INS_SET_CRED_PUBK, 0x00, 0x00, 0x40]) + pub
def test_set_credential_pubk_rejects_wrong_length():
with pytest.raises(ValueError, match="64B"):
set_credential_pubk_apdu(bytes(65))
def test_set_reader_pubk_apdu():
pub = bytes(range(64))
apdu = set_reader_pubk_apdu(pub)
assert apdu == bytes([CLA_PROPRIETARY, INS_SET_READER_PUBK, 0x00, 0x00, 0x40]) + pub
def test_commit_apdu_has_no_data_field():
apdu = commit_apdu()
assert apdu == bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])
assert len(apdu) == 4

View File

@@ -0,0 +1,70 @@
"""CLI-level tests for ``aliro-personalize``.
The pyscard import is lazy inside ``main()`` so most of these tests don't
need pcscd at all. Tests that exercise --list-readers monkeypatch
``smartcard.System.readers`` to keep them hermetic.
"""
from click.testing import CliRunner
from aliro_harness.personalizer.cli import main
def test_help_lists_both_modes():
result = CliRunner().invoke(main, ["--help"])
assert result.exit_code == 0, result.output
assert "--trust-dir" in result.output
assert "--list-readers" in result.output
def test_bare_invocation_errors_with_clear_message_about_trust_dir():
result = CliRunner().invoke(main, [])
assert result.exit_code != 0
assert "--trust-dir" in result.output
# Click 8 prints "Error: ..." for UsageError; either wording is fine
# so long as trust-dir is mentioned.
def test_list_readers_with_no_readers_exits_nonzero(monkeypatch):
"""If pcscd reports no readers, --list-readers prints a message + exits 1."""
import smartcard.System
monkeypatch.setattr(smartcard.System, "readers", lambda: [])
result = CliRunner().invoke(main, ["--list-readers"])
assert result.exit_code == 1
# The message is printed to stderr; click.testing captures both into output.
assert "No PC/SC readers found" in result.output
def test_list_readers_enumerates_each_with_index(monkeypatch):
"""Two fake readers should be printed with [0] / [1] prefixes."""
import smartcard.System
class FakeReader:
def __init__(self, name: str) -> None:
self._name = name
def __str__(self) -> str:
return self._name
monkeypatch.setattr(
smartcard.System,
"readers",
lambda: [FakeReader("ACS Reader 0"), FakeReader("ACS Reader 1")],
)
result = CliRunner().invoke(main, ["--list-readers"])
assert result.exit_code == 0, result.output
assert "[0] ACS Reader 0" in result.output
assert "[1] ACS Reader 1" in result.output
def test_list_readers_does_not_require_trust_dir(monkeypatch):
"""The bug we're fixing: --list-readers should work standalone."""
import smartcard.System
monkeypatch.setattr(smartcard.System, "readers", lambda: [])
result = CliRunner().invoke(main, ["--list-readers"])
# Whatever the reader-discovery outcome, the failure mode must NOT be
# Click's "Missing option '--trust-dir'" usage error.
assert "Missing option" not in result.output
assert "--trust-dir is required" not in result.output

View File

@@ -0,0 +1,55 @@
"""Tests for PEM → fixed-width byte extraction."""
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.personalizer.credentials import (
P256_COORD_LEN,
extract_priv_scalar,
extract_pub_xy,
)
def test_extract_priv_scalar_is_32_bytes():
key = ec.generate_private_key(ec.SECP256R1())
s = extract_priv_scalar(key)
assert len(s) == 32
def test_extract_priv_scalar_matches_private_value_be():
key = ec.generate_private_key(ec.SECP256R1())
expected = key.private_numbers().private_value.to_bytes(32, "big")
assert extract_priv_scalar(key) == expected
def test_extract_priv_scalar_pads_short_value():
"""A scalar small enough to need leading zeros must be left-padded."""
# Private value of 1 is the smallest possible; should pad with 31 zeros.
pem = ec.derive_private_key(1, ec.SECP256R1()).private_numbers()
key = ec.derive_private_key(1, ec.SECP256R1())
s = extract_priv_scalar(key)
assert s == bytes(31) + b"\x01"
assert len(s) == P256_COORD_LEN
def test_extract_pub_xy_is_64_bytes_no_prefix():
key = ec.generate_private_key(ec.SECP256R1())
xy = extract_pub_xy(key.public_key())
assert len(xy) == 64
# The 0x04 uncompressed-point tag must NOT appear; the applet's
# set_reader_pubk_apdu prepends nothing.
nums = key.public_key().public_numbers()
assert xy[:32] == nums.x.to_bytes(32, "big")
assert xy[32:] == nums.y.to_bytes(32, "big")
def test_non_p256_priv_rejected():
key = ec.generate_private_key(ec.SECP384R1())
with pytest.raises(ValueError, match="P-256"):
extract_priv_scalar(key)
def test_non_p256_pub_rejected():
key = ec.generate_private_key(ec.SECP384R1())
with pytest.raises(ValueError, match="P-256"):
extract_pub_xy(key.public_key())

View File

@@ -0,0 +1,111 @@
"""Orchestrator tests — verifies the full APDU sequence is sent in the
right order, with the right shapes, and that a non-9000 response halts."""
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.personalizer.apdus import (
INS_COMMIT,
INS_FINALIZE_ACCESS_DOC,
INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK,
INS_SET_READER_PUBK,
INS_WRITE_ACCESS_DOC,
)
from aliro_harness.personalizer.credentials import (
extract_priv_scalar,
extract_pub_xy,
)
from aliro_harness.personalizer.orchestrator import (
PersonalizationError,
TrustArtifacts,
personalize_card,
)
class FakeTransport:
"""Records each APDU sent and returns 0x9000 unless preconfigured."""
def __init__(self, sw_overrides: dict[int, int] | None = None) -> None:
self.sent: list[bytes] = []
self._sw_overrides = sw_overrides or {}
def __call__(self, apdu: bytes) -> tuple[bytes, int]:
idx = len(self.sent)
self.sent.append(apdu)
return b"", self._sw_overrides.get(idx, 0x9000)
@pytest.fixture
def artifacts() -> TrustArtifacts:
cred = ec.generate_private_key(ec.SECP256R1())
reader = ec.generate_private_key(ec.SECP256R1())
issuer = ec.generate_private_key(ec.SECP256R1())
return TrustArtifacts(
credential_priv=extract_priv_scalar(cred),
credential_pubk_xy=extract_pub_xy(cred.public_key()),
reader_pubk_xy=extract_pub_xy(reader.public_key()),
access_document=build_access_document(
issuer_private_key=issuer,
access_credential_public_key=cred.public_key(),
),
)
def test_full_sequence_in_expected_order(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
insns = [apdu[1] for apdu in transport.sent]
# SELECT (0xA4) → SET_PRIV → SET_PUBK → SET_READER_PUBK → WRITE_AD+ → FINALIZE_AD → COMMIT
assert insns[0] == 0xA4
assert insns[1] == INS_SET_CRED_PRIV
assert insns[2] == INS_SET_CRED_PUBK
assert insns[3] == INS_SET_READER_PUBK
# AD writes (variable count) then a single finalize, then commit.
assert all(i == INS_WRITE_ACCESS_DOC for i in insns[4:-2])
assert insns[-2] == INS_FINALIZE_ACCESS_DOC
assert insns[-1] == INS_COMMIT
def test_select_apdu_carries_provisioning_aid(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
select = transport.sent[0]
assert select.hex().upper().endswith("A000000909ACCE559901")
def test_credential_priv_apdu_carries_exact_bytes(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
apdu = transport.sent[1] # SET_CRED_PRIV right after SELECT
assert apdu[5:] == artifacts.credential_priv
def test_failure_at_set_credential_priv_aborts_sequence(artifacts):
# SELECT (idx 0) returns 9000, SET_CRED_PRIV (idx 1) returns 6985.
transport = FakeTransport(sw_overrides={1: 0x6985})
with pytest.raises(PersonalizationError) as exc_info:
personalize_card(transport, artifacts)
assert exc_info.value.sw == 0x6985
assert "SET credential_PrivK" in str(exc_info.value)
# No further APDUs should have been sent past the failure.
assert len(transport.sent) == 2
def test_access_document_chunks_concatenate_to_original(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
write_apdus = [a for a in transport.sent if a[1] == INS_WRITE_ACCESS_DOC]
# Each WRITE APDU: CLA INS P1 P2 Lc <chunk>
reconstructed = b"".join(a[5:] for a in write_apdus)
assert reconstructed == artifacts.access_document
def test_finalize_carries_total_ad_length_in_p1p2(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
finalize = next(a for a in transport.sent if a[1] == INS_FINALIZE_ACCESS_DOC)
total = (finalize[2] << 8) | finalize[3]
assert total == len(artifacts.access_document)

View File

@@ -0,0 +1,72 @@
import pytest
from aliro_harness.reader.auth0 import build_auth0_apdu, build_auth0_data
def test_auth0_data_field_carries_six_mandatory_tlvs_in_spec_order():
reader_ephem_uncomp = bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32
data = build_auth0_data(
reader_ephem_pub_uncompressed=reader_ephem_uncomp,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
command_parameters=0x00,
authentication_policy=0x00,
)
assert data[0:3] == bytes([0x41, 0x01, 0x00]) # command_parameters
assert data[3:6] == bytes([0x42, 0x01, 0x00]) # authentication_policy
assert data[6:10] == bytes([0x5C, 0x02, 0x01, 0x00]) # protocol_version
assert data[10:12] == bytes([0x87, 0x41]) # reader_ePubK header (65B)
assert data[12:77] == reader_ephem_uncomp
assert data[77:79] == bytes([0x4C, 0x10]) # transaction_id header
assert data[79:95] == b"\xcc" * 16
assert data[95:97] == bytes([0x4D, 0x20]) # reader_identifier (32B = group||sub)
assert data[97:113] == b"\xaa" * 16
assert data[113:129] == b"\xbb" * 16
assert len(data) == 129
def test_build_auth0_apdu_wraps_data_in_short_form():
apdu = build_auth0_apdu(b"\x41\x01\x00")
assert apdu == bytes([0x80, 0x80, 0x00, 0x00, 0x03, 0x41, 0x01, 0x00])
def test_reader_ephem_must_be_uncompressed():
with pytest.raises(ValueError, match="65B uncompressed"):
build_auth0_data(
reader_ephem_pub_uncompressed=b"\x02" + b"\x11" * 32, # compressed
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
)
def test_wrong_group_id_length_rejected():
with pytest.raises(ValueError, match="must each be 16B"):
build_auth0_data(
reader_ephem_pub_uncompressed=bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32,
reader_group_id=b"\xaa" * 15,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
)
def test_wrong_transaction_id_length_rejected():
with pytest.raises(ValueError, match="must be 16B"):
build_auth0_data(
reader_ephem_pub_uncompressed=bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 17,
)
def test_command_parameters_bit_zero_set_emits_0x01_byte():
data = build_auth0_data(
reader_ephem_pub_uncompressed=bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
command_parameters=0x01,
)
assert data[0:3] == bytes([0x41, 0x01, 0x01])

View File

@@ -0,0 +1,100 @@
import pytest
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from aliro_harness.reader.auth1 import (
build_auth1_apdu,
build_auth1_data,
build_table_812,
build_table_813,
sign_table_812_raw,
)
def test_table_812_layout_per_spec_table_8_12():
t = build_table_812(
reader_id_32=b"\x4d" * 32,
credential_ephem_pub_x=b"\x86" * 32,
reader_ephem_pub_x=b"\x87" * 32,
transaction_id=b"\x4c" * 16,
)
assert t[0:2] == bytes([0x4D, 0x20])
assert t[2:34] == b"\x4d" * 32
assert t[34:36] == bytes([0x86, 0x20])
assert t[36:68] == b"\x86" * 32
assert t[68:70] == bytes([0x87, 0x20])
assert t[70:102] == b"\x87" * 32
assert t[102:104] == bytes([0x4C, 0x10])
assert t[104:120] == b"\x4c" * 16
assert t[120:122] == bytes([0x93, 0x04])
assert t[122:126] == bytes.fromhex("415D9569") # reader sig usage
# 5 TLVs: 34+34+34+18+6 = 126 bytes (plan's "128" was off-by-2; byte
# offsets above are self-consistent and match ReaderSide.buildTable812).
assert len(t) == 126
def test_table_813_uses_ud_usage_tag():
"""Same TLV positions as 8-12, but the 0x93 value = UD usage tag."""
kwargs = dict(
reader_id_32=b"\x4d" * 32,
credential_ephem_pub_x=b"\x86" * 32,
reader_ephem_pub_x=b"\x87" * 32,
transaction_id=b"\x4c" * 16,
)
t = build_table_813(**kwargs)
t12 = build_table_812(**kwargs)
# Everything up through the 0x93 0x04 header is identical...
assert t[0:122] == t12[0:122]
assert t[120:122] == bytes([0x93, 0x04])
# ...but the usage tag differs.
assert t[122:126] == bytes.fromhex("4E887B4C")
assert t12[122:126] == bytes.fromhex("415D9569")
assert len(t) == 126
def test_sign_then_verify_round_trip():
"""Sign Table 8-12 with a fresh P-256 key, verify with public half.
Raw 64B r||s must re-assemble into a DER signature that verifies."""
priv = ec.generate_private_key(ec.SECP256R1())
table_812 = build_table_812(
reader_id_32=b"\x01" * 32,
credential_ephem_pub_x=b"\x02" * 32,
reader_ephem_pub_x=b"\x03" * 32,
transaction_id=b"\x04" * 16,
)
raw_sig = sign_table_812_raw(table_812, priv)
assert len(raw_sig) == 64
r = int.from_bytes(raw_sig[:32], "big")
s = int.from_bytes(raw_sig[32:], "big")
der = encode_dss_signature(r, s)
# Should verify -- raises InvalidSignature if not.
priv.public_key().verify(der, table_812, ec.ECDSA(hashes.SHA256()))
def test_build_auth1_data_with_cmd_params_zero():
"""cmd_params=0 ('key_slot' path) still emits the 0x41/0x9E TLV shell."""
sig = b"\xaa" * 64
data = build_auth1_data(sig, command_parameters=0x00)
assert data[:5] == bytes([0x41, 0x01, 0x00, 0x9E, 0x40])
assert data[5:69] == sig
assert len(data) == 69
def test_build_auth1_data_rejects_wrong_sig_length():
with pytest.raises(ValueError, match="64B raw"):
build_auth1_data(b"\x00" * 63)
def test_build_auth1_apdu_short_form_wrap():
"""CLA=0x80, INS=0x81, P1=P2=0, Lc=len(data), then data."""
data = bytes([0x41, 0x01, 0x01]) + bytes([0x9E, 0x40]) + b"\xcc" * 64
apdu = build_auth1_apdu(data)
assert apdu[0] == 0x80 # CLA
assert apdu[1] == 0x81 # INS_AUTH1
assert apdu[2] == 0x00 # P1
assert apdu[3] == 0x00 # P2
assert apdu[4] == len(data) # Lc (short form)
assert apdu[5:] == data
assert len(apdu) == 5 + len(data)

View File

@@ -0,0 +1,92 @@
import pytest
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.auth1_response import (
decrypt_auth1_response,
verify_ud_signature,
)
def _gen_ud_keypair_and_pub_uncompressed():
priv = ec.generate_private_key(ec.SECP256R1())
pub_uncompressed = priv.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
return priv, pub_uncompressed
def _sign_raw(priv, message: bytes) -> bytes:
der = priv.sign(message, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der)
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
def test_decrypt_auth1_response_roundtrips_with_device_counter_1():
sk = b"\x42" * 32
iv = b"\x00" * 7 + b"\x01" + b"\x00\x00\x00\x01"
plaintext = b"\xAB" * 17
ct_and_tag = AESGCM(sk).encrypt(iv, plaintext, associated_data=None)
assert decrypt_auth1_response(sk, ct_and_tag) == plaintext
def test_decrypt_auth1_response_fails_on_tampered_ciphertext():
sk = b"\x42" * 32
iv = b"\x00" * 7 + b"\x01" + b"\x00\x00\x00\x01"
plaintext = b"\xAB" * 17
ct_and_tag = bytearray(AESGCM(sk).encrypt(iv, plaintext, associated_data=None))
ct_and_tag[0] ^= 0x01
with pytest.raises(InvalidTag):
decrypt_auth1_response(sk, bytes(ct_and_tag))
def test_verify_ud_signature_round_trip_returns_true():
priv, pub_uncompressed = _gen_ud_keypair_and_pub_uncompressed()
table_813 = b"\x00\x01\x02\x03" + b"table-8-13-test-bytes" * 3
raw_sig = _sign_raw(priv, table_813)
assert len(pub_uncompressed) == 65
assert verify_ud_signature(pub_uncompressed, table_813, raw_sig) is True
def test_verify_ud_signature_returns_false_on_tampered_table():
priv, pub_uncompressed = _gen_ud_keypair_and_pub_uncompressed()
table_813 = b"table-8-13-original-bytes-here!!"
raw_sig = _sign_raw(priv, table_813)
tampered = bytearray(table_813)
tampered[0] ^= 0x01
assert verify_ud_signature(pub_uncompressed, bytes(tampered), raw_sig) is False
def test_verify_ud_signature_returns_false_on_tampered_sig():
priv, pub_uncompressed = _gen_ud_keypair_and_pub_uncompressed()
table_813 = b"table-8-13-original-bytes-here!!"
raw_sig = bytearray(_sign_raw(priv, table_813))
raw_sig[0] ^= 0x01
assert verify_ud_signature(pub_uncompressed, table_813, bytes(raw_sig)) is False
def test_verify_ud_signature_rejects_wrong_sig_length():
_, pub_uncompressed = _gen_ud_keypair_and_pub_uncompressed()
with pytest.raises(ValueError):
verify_ud_signature(pub_uncompressed, b"data", b"\x00" * 63)
def test_decrypt_auth1_response_fails_with_wrong_counter():
"""Pins the hardcoded counter=1 behavior. If someone "fixes" the counter
to 0 or 2 without thinking, this test fails loudly."""
sk = b"\x42" * 32
wrong_iv = b"\x00" * 7 + b"\x01" + b"\x00\x00\x00\x02" # counter=2
ct = AESGCM(sk).encrypt(wrong_iv, b"hello", associated_data=None)
with pytest.raises(InvalidTag):
decrypt_auth1_response(sk, ct)
def test_verify_ud_signature_rejects_malformed_pubkey():
"""A short or off-curve pubkey blob must raise (not return False).
Guards against a silent ``cryptography`` behavior change."""
with pytest.raises(ValueError):
verify_ud_signature(b"\x04" + b"\x00" * 10, b"data", b"\x00" * 64)

View File

@@ -0,0 +1,71 @@
"""CLI-level tests for ``aliro-bench-test``.
Mirrors ``test_personalizer_cli.py``. The pyscard import is lazy inside
``main()`` so most of these tests don't need pcscd at all. Tests that
exercise --list-readers monkeypatch ``smartcard.System.readers`` to keep
them hermetic.
"""
from click.testing import CliRunner
from aliro_harness.reader.cli import main
def test_help_lists_both_modes():
result = CliRunner().invoke(main, ["--help"])
assert result.exit_code == 0, result.output
assert "--trust-dir" in result.output
assert "--list-readers" in result.output
def test_bare_invocation_errors_with_clear_message_about_trust_dir():
result = CliRunner().invoke(main, [])
assert result.exit_code != 0
assert "--trust-dir" in result.output
# Click 8 prints "Error: ..." for UsageError; either wording is fine
# so long as trust-dir is mentioned.
def test_list_readers_with_no_readers_exits_nonzero(monkeypatch):
"""If pcscd reports no readers, --list-readers prints a message + exits 1."""
import smartcard.System
monkeypatch.setattr(smartcard.System, "readers", lambda: [])
result = CliRunner().invoke(main, ["--list-readers"])
assert result.exit_code == 1
# The message is printed to stderr; click.testing captures both into output.
assert "No PC/SC readers found" in result.output
def test_list_readers_enumerates_each_with_index(monkeypatch):
"""Two fake readers should be printed with [0] / [1] prefixes."""
import smartcard.System
class FakeReader:
def __init__(self, name: str) -> None:
self._name = name
def __str__(self) -> str:
return self._name
monkeypatch.setattr(
smartcard.System,
"readers",
lambda: [FakeReader("ACS Reader 0"), FakeReader("ACS Reader 1")],
)
result = CliRunner().invoke(main, ["--list-readers"])
assert result.exit_code == 0, result.output
assert "[0] ACS Reader 0" in result.output
assert "[1] ACS Reader 1" in result.output
def test_list_readers_does_not_require_trust_dir(monkeypatch):
"""The bug we're fixing: --list-readers should work standalone."""
import smartcard.System
monkeypatch.setattr(smartcard.System, "readers", lambda: [])
result = CliRunner().invoke(main, ["--list-readers"])
# Whatever the reader-discovery outcome, the failure mode must NOT be
# Click's "Missing option '--trust-dir'" usage error.
assert "Missing option" not in result.output
assert "--trust-dir is required" not in result.output

View File

@@ -0,0 +1,39 @@
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.reader.crypto import (
derive_kdh,
ecdh_shared_x,
hkdf_sha256,
)
# RFC 5869 Test Case 1
RFC5869_T1_IKM = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
RFC5869_T1_SALT = bytes.fromhex("000102030405060708090a0b0c")
RFC5869_T1_INFO = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
RFC5869_T1_OKM_42 = bytes.fromhex(
"3cb25f25faacd57a90434f64d0362f2a"
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
"34007208d5b887185865"
)
def test_hkdf_sha256_matches_rfc5869_test_case_1():
assert hkdf_sha256(RFC5869_T1_IKM, RFC5869_T1_SALT, RFC5869_T1_INFO, 42) == RFC5869_T1_OKM_42
def test_ecdh_commutative():
a = ec.generate_private_key(ec.SECP256R1())
b = ec.generate_private_key(ec.SECP256R1())
a_pub_uncomp = bytes([0x04]) + a.public_key().public_numbers().x.to_bytes(32, "big") + a.public_key().public_numbers().y.to_bytes(32, "big")
b_pub_uncomp = bytes([0x04]) + b.public_key().public_numbers().x.to_bytes(32, "big") + b.public_key().public_numbers().y.to_bytes(32, "big")
assert ecdh_shared_x(a, b_pub_uncomp) == ecdh_shared_x(b, a_pub_uncomp)
def test_derive_kdh_agrees_on_both_sides():
a = ec.generate_private_key(ec.SECP256R1())
b = ec.generate_private_key(ec.SECP256R1())
a_pub = bytes([0x04]) + a.public_key().public_numbers().x.to_bytes(32, "big") + a.public_key().public_numbers().y.to_bytes(32, "big")
b_pub = bytes([0x04]) + b.public_key().public_numbers().x.to_bytes(32, "big") + b.public_key().public_numbers().y.to_bytes(32, "big")
txn = b"\x01" * 16
assert derive_kdh(a, b_pub, txn) == derive_kdh(b, a_pub, txn)

View File

@@ -0,0 +1,78 @@
from aliro_harness.reader.key_derivation import (
build_salt_volatile,
derive_expedited_session_keys,
)
def test_salt_volatile_layout_per_spec_8_3_1_13():
salt = build_salt_volatile(
reader_long_term_pub_x=b"\x01" * 32,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
reader_ephem_pub_x=b"\x33" * 32,
transaction_id=b"\xcc" * 16,
command_parameters=0x00,
authentication_policy=0x00,
credential_long_term_pub_x=b"\x55" * 32,
)
p = 0
assert salt[p : p + 32] == b"\x01" * 32 # x(reader_group_identifier_key)
p += 32
assert salt[p : p + 12] == b"Volatile****"
p += 12
assert salt[p : p + 16] == b"\xaa" * 16 # group_id
p += 16
assert salt[p : p + 16] == b"\xbb" * 16 # sub_id
p += 16
assert salt[p] == 0x5E # interface_byte = NFC
p += 1
assert salt[p : p + 2] == bytes([0x5C, 0x02]) # literal
p += 2
assert salt[p : p + 2] == bytes([0x01, 0x00]) # protocol_version
p += 2
assert salt[p : p + 32] == b"\x33" * 32 # x(reader_ephem)
p += 32
assert salt[p : p + 16] == b"\xcc" * 16 # txn_id
p += 16
assert salt[p : p + 2] == bytes([0x00, 0x00]) # flag = cmd_params || auth_policy
p += 2
assert salt[p : p + 10] == bytes.fromhex("A50880020000 5C020100".replace(" ", ""))
p += 10
assert salt[p : p + 32] == b"\x55" * 32 # x(credential_long_term)
p += 32
assert len(salt) == p == 173
def test_salt_volatile_threads_flag_bytes_in_correct_order():
"""Guards against cmd_params/auth_policy swap."""
salt = build_salt_volatile(
reader_long_term_pub_x=b"\x00" * 32,
reader_group_id=b"\x00" * 16,
reader_group_sub_id=b"\x00" * 16,
reader_ephem_pub_x=b"\x00" * 32,
transaction_id=b"\x00" * 16,
command_parameters=0xAB,
authentication_policy=0xCD,
credential_long_term_pub_x=b"\x00" * 32,
)
# flag offset: 32 + 12 + 16 + 16 + 1 + 2 + 2 + 32 + 16 = 129
assert salt[129] == 0xAB
assert salt[130] == 0xCD
def test_derive_expedited_session_keys_returns_160_bytes_deterministic():
kdh = bytes.fromhex("11" * 32)
salt = bytes.fromhex("22" * 173)
info = bytes.fromhex("33" * 65) # x(credential_ephem_pub) is 32B in practice; any bytes OK here
out1 = derive_expedited_session_keys(kdh, salt, info)
out2 = derive_expedited_session_keys(kdh, salt, info)
assert len(out1) == 160
assert out1 == out2
def test_derive_expedited_session_keys_changes_with_inputs():
kdh = bytes.fromhex("11" * 32)
salt_a = bytes.fromhex("22" * 173)
salt_b = bytes.fromhex("23" + "22" * 172)
info = bytes.fromhex("33" * 32)
assert derive_expedited_session_keys(kdh, salt_a, info) != derive_expedited_session_keys(kdh, salt_b, info)

View File

@@ -0,0 +1,8 @@
from aliro_harness.reader.session import ReaderSession
def test_session_starts_with_no_transaction_state():
s = ReaderSession.fresh()
assert s.reader_ephem_priv is None
assert s.transaction_id is None
assert s.credential_ephem_pubk is None

View File

@@ -0,0 +1,62 @@
from aliro_harness.reader.tlv import find_top_level
def test_find_top_level_returns_value_bytes():
# 86 41 [65B value] 9D 02 [2B value]
blob = bytes([0x86, 0x41]) + b"\xaa" * 65 + bytes([0x9D, 0x02, 0xff, 0xee])
assert find_top_level(blob, 0x86) == b"\xaa" * 65
assert find_top_level(blob, 0x9D) == bytes([0xff, 0xee])
assert find_top_level(blob, 0x42) is None
def test_short_form_length_under_128():
value = b"\xbb" * 100
blob = bytes([0x9E, 100]) + value
assert find_top_level(blob, 0x9E) == value
def test_long_form_0x81_length_128_to_255():
value = b"\xcc" * 200
blob = bytes([0x9E, 0x81, 200]) + value
assert find_top_level(blob, 0x9E) == value
def test_long_form_0x82_length_256_or_more():
value = b"\xdd" * 1000
blob = bytes([0x9E, 0x82, (1000 >> 8) & 0xFF, 1000 & 0xFF]) + value
assert find_top_level(blob, 0x9E) == value
def test_returns_none_when_tag_absent():
blob = bytes([0x86, 0x02, 0x01, 0x02, 0x9D, 0x01, 0x03])
assert find_top_level(blob, 0x42) is None
def test_skips_over_unmatched_tags_to_find_later_match():
# Three TLVs: 0x86, 0x9D, 0x9E. Ask for the third.
tlv1 = bytes([0x86, 0x03, 0x11, 0x22, 0x33])
tlv2 = bytes([0x9D, 0x02, 0x44, 0x55])
tlv3 = bytes([0x9E, 0x04, 0x66, 0x77, 0x88, 0x99])
blob = tlv1 + tlv2 + tlv3
assert find_top_level(blob, 0x9E) == bytes([0x66, 0x77, 0x88, 0x99])
def test_zero_length_value_returns_empty_bytes():
"""Pin the 0-length contract — easy to break with a `if length > 0` micro-opt."""
blob = bytes([0x86, 0x00, 0x9D, 0x02, 0x11, 0x22])
assert find_top_level(blob, 0x86) == b""
assert find_top_level(blob, 0x9D) == bytes([0x11, 0x22])
def test_empty_input_returns_none():
assert find_top_level(b"", 0x9E) is None
def test_skips_long_form_unmatched_to_find_later_match():
"""Make sure the 0x82-length skip path advances correctly when the
matched tag comes AFTER a long-form TLV (the current `skips_over_…`
test only exercises short-form skips)."""
skip_value = b"\x99" * 1000
skip_tlv = bytes([0x86, 0x82, (1000 >> 8) & 0xFF, 1000 & 0xFF]) + skip_value
target = bytes([0x9E, 0x02, 0xAB, 0xCD])
assert find_top_level(skip_tlv + target, 0x9E) == bytes([0xAB, 0xCD])

View File

@@ -0,0 +1,101 @@
"""Offline round-trip: reader transaction orchestrator vs. in-process fake card."""
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.reader.transaction import (
TransactionResult,
TrustBundle,
run_aliro_transaction,
)
from tests.fake_card import FakeAliroCard
def _make_keys():
reader = ec.generate_private_key(ec.SECP256R1())
cred = ec.generate_private_key(ec.SECP256R1())
return reader, cred
def test_transaction_against_in_process_fake_card_succeeds():
reader, cred = _make_keys()
bundle = TrustBundle.synthesize(
reader_priv=reader, credential_pub=cred.public_key()
)
fake = FakeAliroCard.provisioned(
reader_pub=reader.public_key(),
credential_priv=cred,
credential_pub=cred.public_key(),
)
result = run_aliro_transaction(transmit=fake.transmit, bundle=bundle)
assert result.ok, f"expected success, got error={result.error!r}"
assert result.signaling_bitmap == bytes([0x00, 0x00]) # no AD in fake
# cmd_params=0x01 path returns 0x5A credential_PubK
assert result.credential_pub is not None
assert len(result.credential_pub) == 65
assert result.credential_pub[0] == 0x04
assert result.ud_sig is not None
assert len(result.ud_sig) == 64
def test_transaction_signals_ad_present_when_fake_card_has_access_document():
"""Cross-check: bitmap tracks the has_access_document provisioning bit."""
reader, cred = _make_keys()
bundle = TrustBundle.synthesize(
reader_priv=reader, credential_pub=cred.public_key()
)
fake = FakeAliroCard.provisioned(
reader_pub=reader.public_key(),
credential_priv=cred,
credential_pub=cred.public_key(),
has_access_document=True,
)
result = run_aliro_transaction(transmit=fake.transmit, bundle=bundle)
assert result.ok
# Bit 0 (AD retrievable) + bit 2 (step-up-AID-required) set.
assert result.signaling_bitmap == bytes([0x00, 0x05])
def test_transaction_fails_when_reader_priv_mismatches_card_reader_pub():
"""Reader's long-term priv does not match what the card has provisioned
-> card's reader-sig verification fails -> AUTH1 returns 6A80."""
_, cred = _make_keys()
reader_in_bundle = ec.generate_private_key(ec.SECP256R1())
reader_provisioned = ec.generate_private_key(ec.SECP256R1())
bundle = TrustBundle.synthesize(
reader_priv=reader_in_bundle, credential_pub=cred.public_key()
)
fake = FakeAliroCard.provisioned(
reader_pub=reader_provisioned.public_key(), # different key
credential_priv=cred,
credential_pub=cred.public_key(),
)
result = run_aliro_transaction(transmit=fake.transmit, bundle=bundle)
assert not result.ok
assert result.error is not None
assert "AUTH1 failed" in result.error
assert "6A80" in result.error
def test_trust_bundle_synthesize_field_types():
"""Round-trip: TrustBundle.synthesize produces the expected field shapes."""
reader = ec.generate_private_key(ec.SECP256R1())
cred = ec.generate_private_key(ec.SECP256R1())
bundle = TrustBundle.synthesize(
reader_priv=reader, credential_pub=cred.public_key()
)
assert isinstance(bundle.reader_priv, ec.EllipticCurvePrivateKey)
assert len(bundle.reader_long_term_pub_x) == 32
assert len(bundle.reader_group_id) == 16
assert len(bundle.reader_group_sub_id) == 16
assert len(bundle.credential_long_term_pub_uncompressed) == 65
assert bundle.credential_long_term_pub_uncompressed[0] == 0x04
assert len(bundle.credential_long_term_pub_x) == 32
# The x-coord inside the uncompressed encoding matches the separate x-coord field.
assert (
bundle.credential_long_term_pub_uncompressed[1:33]
== bundle.credential_long_term_pub_x
)

View File

@@ -0,0 +1,87 @@
"""Tests for the C header emitted from a TrustBundle."""
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.trustgen.header import TrustBundle, render_trust_header
def _bundle():
return TrustBundle(
issuer_public_key=ec.generate_private_key(ec.SECP256R1()).public_key(),
reader_private_key=ec.generate_private_key(ec.SECP256R1()),
reader_group_id=bytes(range(16)),
reader_group_sub_id=bytes(range(16, 32)),
)
def test_header_has_include_guard():
text = render_trust_header(_bundle())
assert "#ifndef ALIRO_TRUST_H_" in text
assert "#define ALIRO_TRUST_H_" in text
assert "#endif" in text
def test_header_marks_file_as_autogenerated():
text = render_trust_header(_bundle())
assert "AUTO-GENERATED" in text
assert "DO NOT EDIT" in text
def test_header_defines_reader_private_key_as_32_bytes():
text = render_trust_header(_bundle())
assert "#define ALIRO_READER_PRIVATE_KEY" in text
assert text.count("0x", text.index("ALIRO_READER_PRIVATE_KEY")) >= 32
def test_header_defines_reader_public_key_as_x_y_concatenated_64_bytes():
text = render_trust_header(_bundle())
assert "#define ALIRO_READER_PUBLIC_KEY" in text
def test_header_defines_issuer_pub_x_y_and_concatenated():
text = render_trust_header(_bundle())
assert "#define ALIRO_ISSUER_PUB_X" in text
assert "#define ALIRO_ISSUER_PUB_Y" in text
assert "#define ALIRO_ISSUER_PUB" in text
def test_header_defines_issuer_kid():
text = render_trust_header(_bundle())
assert "#define ALIRO_ISSUER_KID" in text
def test_header_defines_reader_group_ids():
text = render_trust_header(_bundle())
assert "#define ALIRO_READER_GROUP_ID" in text
assert "#define ALIRO_READER_GROUP_SUB_ID" in text
def test_reader_private_key_bytes_match_input():
bundle = _bundle()
text = render_trust_header(bundle)
expected = bundle.reader_private_key.private_numbers().private_value.to_bytes(32, "big")
first_byte_hex = f"0x{expected[0]:02x}"
last_byte_hex = f"0x{expected[-1]:02x}"
priv_block_start = text.index("ALIRO_READER_PRIVATE_KEY")
priv_block_end = text.index("#define", priv_block_start + 1)
block = text[priv_block_start:priv_block_end]
assert first_byte_hex in block
assert last_byte_hex in block
def test_issuer_kid_matches_compute_issuer_kid():
from aliro_harness.issuer.kid import compute_issuer_kid
bundle = _bundle()
text = render_trust_header(bundle)
expected_kid = compute_issuer_kid(bundle.issuer_public_key)
kid_start = text.index("ALIRO_ISSUER_KID")
kid_end = text.index("\n", kid_start)
kid_line = text[kid_start:kid_end]
for b in expected_kid:
assert f"0x{b:02x}" in kid_line

View File

@@ -0,0 +1,79 @@
from click.testing import CliRunner
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.cose import verify_cose_sign1
from aliro_harness.issuer.keys import load_private_key_pem
from aliro_harness.trustgen.cli import main
def test_init_creates_all_artifacts(tmp_path):
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code == 0, result.output
assert (tmp_path / "issuer.pem").is_file()
assert (tmp_path / "reader.pem").is_file()
assert (tmp_path / "access_credential.pem").is_file()
assert (tmp_path / "reader_group_id.bin").is_file()
assert (tmp_path / "reader_group_sub_id.bin").is_file()
assert (tmp_path / "access_document.bin").is_file()
assert (tmp_path / "device_response.bin").is_file()
assert (tmp_path / "aliro_trust.h").is_file()
def test_init_writes_consistent_issuer_and_access_document(tmp_path):
"""The Access Document must verify against the issuer public key on disk."""
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code == 0, result.output
issuer = load_private_key_pem(tmp_path / "issuer.pem")
access_doc = (tmp_path / "access_document.bin").read_bytes()
payload = verify_cose_sign1(access_doc, issuer.public_key())
assert len(payload) > 0
def test_init_refuses_to_overwrite_without_force(tmp_path):
(tmp_path / "issuer.pem").write_bytes(b"dummy")
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code != 0
assert "force" in result.output.lower()
def test_init_force_overwrites(tmp_path):
(tmp_path / "issuer.pem").write_bytes(b"dummy")
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path), "--force"])
assert result.exit_code == 0, result.output
loaded = load_private_key_pem(tmp_path / "issuer.pem")
assert isinstance(loaded, ec.EllipticCurvePrivateKey)
def test_reader_group_id_is_16_bytes(tmp_path):
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code == 0, result.output
assert (tmp_path / "reader_group_id.bin").stat().st_size == 16
assert (tmp_path / "reader_group_sub_id.bin").stat().st_size == 16
def test_trust_header_references_issuer_on_disk(tmp_path):
"""Header should embed the same issuer key that signed the access_document."""
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code == 0, result.output
issuer = load_private_key_pem(tmp_path / "issuer.pem")
header = (tmp_path / "aliro_trust.h").read_text()
n = issuer.public_key().public_numbers()
first_byte_of_x = f"0x{n.x.to_bytes(32, 'big')[0]:02x}"
pub_x_idx = header.index("ALIRO_ISSUER_PUB_X")
pub_x_end = header.index("\n", pub_x_idx)
assert first_byte_of_x in header[pub_x_idx:pub_x_end]

View File

@@ -0,0 +1,27 @@
Copyright 2021 STMicroelectronics.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,6 @@
This software component is provided to you as part of a software package and
applicable license terms are in the Package_license file. If you received this
software component outside of a package or without applicable license terms,
the terms of the BSD-3-Clause license shall apply.
You may obtain a copy of the BSD-3-Clause at:
https://opensource.org/licenses/BSD-3-Clause

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<title>Release Notes for BSP Common Components Drivers</title>
<style type="text/css">
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
span.underline{text-decoration: underline;}
div.column{display: inline-block; vertical-align: top; width: 50%;}
</style>
<link rel="stylesheet" href="_htmresc/mini-st.css" />
<!--[if lt IE 9]>
<script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv-printshiv.min.js"></script>
<![endif]-->
<link rel="icon" type="image/x-icon" href="_htmresc/favicon.png" />
</head>
<body>
<div class="row">
<div class="col-sm-12 col-lg-4">
<center>
<h1 id="release-notes-for-bsp-common-components-drivers"><small>Release Notes for</small> <mark>BSP Common Components Drivers</mark></h1>
<p>Copyright © 2014 STMicroelectronics<br />
</p>
<a href="https://www.st.com" class="logo"><img src="_htmresc/st_logo.png" alt="ST logo" /></a>
</center>
<h1 id="purpose">Purpose</h1>
<p>This directory contains the BSP Common components drivers.</p>
</div>
<div class="col-sm-12 col-lg-8">
<h1 id="update-history">Update History</h1>
<div class="collapse">
<input type="checkbox" id="collapse-section19" checked aria-hidden="true"> <label for="collapse-section19" aria-hidden="true">V7.2.1 / 16-November-2021</label>
<div>
<h2 id="main-changes">Main Changes</h2>
<h3 id="component-release">Component release</h3>
<ul>
<li>Update licensing</li>
</ul>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section18" aria-hidden="true"> <label for="collapse-section18" aria-hidden="true">V7.2.0 / 11-October-2021</label>
<div>
<h2 id="main-changes-1">Main Changes</h2>
<h3 id="component-release-1">Component release</h3>
<ul>
<li>Add hybrid_sensor.h to support hybrid (env and motion) sensors</li>
</ul>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section17" aria-hidden="true"> <label for="collapse-section17" aria-hidden="true">V7.1.0 / 18-Juin-2021</label>
<div>
<h2 id="main-changes-2">Main Changes</h2>
<h3 id="component-release-2">Component release</h3>
<ul>
<li>Add ranging_sensor.h to support ranging sensors</li>
<li>Add light_sensor.h to support ambient light sensors</li>
<li>Rename UTILS_LCD_Drv_t structure into LCD_UTILS_Drv_t</li>
<li>Code spelling and formatting</li>
</ul>
<h2 id="backward-compatibility">Backward Compatibility</h2>
<ul>
<li>This release breaks compatibility with previous versions.</li>
</ul>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section16" aria-hidden="true"> <label for="collapse-section16" aria-hidden="true">V7.0.0 / 25-February-2020</label>
<div>
<h2 id="main-changes-3">Main Changes</h2>
<h3 id="component-release-3">Component release</h3>
<ul>
<li>Rename GUI_Drv_t structure into UTILS_LCD_Drv_t</li>
</ul>
<h2 id="backward-compatibility-1">Backward Compatibility</h2>
<ul>
<li>This release breaks compatibility with previous versions.</li>
</ul>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section15" aria-hidden="true"> <label for="collapse-section15" aria-hidden="true">V6.0.1 / 15-October-2019</label>
<div>
<h2 id="main-changes-4">Main Changes</h2>
<h3 id="component-release-4">Component release</h3>
<ul>
<li>Update st_logo.png inclusion path in Release notes.</li>
</ul>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section14" aria-hidden="true"> <label for="collapse-section14" aria-hidden="true">V6.0.0 / 12-April-2019</label>
<div>
<h2 id="main-changes-5">Main Changes</h2>
<h3 id="component-release-5">Component release</h3>
<p>Official release of BSP Common components drivers in line with STM32Cube BSP drivers development guidelines (UM2298).</p>
<h2 id="backward-compatibility-2">Backward Compatibility</h2>
<p>This release breaks compatibility with previous versions.</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section13" aria-hidden="true"> <label for="collapse-section13" aria-hidden="true">V5.1.1 / 31-August-2018</label>
<div>
<h2 id="main-changes-6">Main Changes</h2>
<p>Reformat the BSD 3-Clause license declaration in the files header (replace license terms by a web reference to OSI website where those terms lie)<br />
Correct sensor names in headers files hsensor.h and psensor.h</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section12" aria-hidden="true"> <label for="collapse-section12" aria-hidden="true">V5.1.0 / 21-November-2017</label>
<div>
<h2 id="main-changes-7">Main Changes</h2>
<p>Add dpredriver.h: support of DP redriver class<br />
Add pwrmon.h: support of power monitor class<br />
Add usbtypecswitch.h: support of USB type C switch class</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section11" aria-hidden="true"> <label for="collapse-section11" aria-hidden="true">V5.0.0 / 01-March-2017</label>
<div>
<h2 id="main-changes-8">Main Changes</h2>
<p>Add hsensor.h: support of humidity class<br />
Add psensor.h: support of pressure class<br />
Update tsensor.h: Temperature can be negative<br />
Update accelero.h: LowPower API can enable or disable the low power mode<br />
Update magneto.h: LowPower API can enable or disable the low power mode</p>
<h2 id="backward-compatibility-3">Backward Compatibility</h2>
<p>This release breaks compatibility with previous versions.</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section10" aria-hidden="true"> <label for="collapse-section10" aria-hidden="true">V4.0.1 / 21-July-2015</label>
<div>
<h2 id="main-changes-9">Main Changes</h2>
<p>tsensor.h: Fix compilation issue on <em>TSENSOR_InitTypeDef</em></p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section9" aria-hidden="true"> <label for="collapse-section9" aria-hidden="true">V4.0.0 / 22-June-2015</label>
<div>
<h2 id="main-changes-10">Main Changes</h2>
<p>accelero.h: add <em>DeInit</em> field in <em>ACCELERO_DrvTypeDef</em> structure<br />
audio.h: add <em>DeInit</em> field in <em>AUDIO_DrvTypeDef</em> structure<br />
idd.h:</p>
<ul>
<li>add <em>Shunt0StabDelay</em>, <em>Shunt1StabDelay</em>, <em>Shunt2StabDelay</em>, <em>Shunt3StabDelay</em>, <em>Shunt4StabDelay</em> and <em>ShuntNbOnBoard</em> fields in <em>IDD_ConfigTypeDef</em> structure<br />
</li>
<li>rename <em>ShuntNumber</em> field to <em>ShuntNbUsed</em> in <em>IDD_ConfigTypeDef</em> structure</li>
</ul>
<p>magneto.h: add <em>DeInit</em> field in <em>MAGNETO_DrvTypeDef</em> structure</p>
<h2 id="backward-compatibility-4">Backward Compatibility</h2>
<p>This release breaks compatibility with previous versions.</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section8" aria-hidden="true"> <label for="collapse-section8" aria-hidden="true">V3.0.0 / 28-April-2015</label>
<div>
<h2 id="main-changes-11">Main Changes</h2>
<p>accelero.h: add <em>LowPower</em> field in <em>ACCELERO_DrvTypeDef</em> structure<br />
magneto.h: add <em>LowPower</em> field in <em>MAGNETO_DrvTypeDef</em> structure<br />
gyro.h: add <em>DeInit</em> and <em>LowPower</em> fields in <em>GYRO_DrvTypeDef</em> structure<br />
camera.h: add CAMERA_COLOR_EFFECT_NONE define<br />
idd.h:</p>
<ul>
<li>add <em>MeasureNb</em>, <em>DeltaDelayUnit</em> and <em>DeltaDelayValue</em> fields in <em>IDD_ConfigTypeDef</em> structure<br />
</li>
<li>rename <em>PreDelay</em> field to <em>PreDelayUnit</em> in <em>IDD_ConfigTypeDef</em> structure</li>
</ul>
<h2 id="backward-compatibility-5">Backward Compatibility</h2>
<p>This release breaks compatibility with previous versions.</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section7" aria-hidden="true"> <label for="collapse-section7" aria-hidden="true">V2.2.0 / 09-February-2015</label>
<div>
<h2 id="main-changes-12">Main Changes</h2>
<p>Magnetometer driver function prototypes added (magneto.h file)<br />
Update “idd.h” file to provide DeInit() and WakeUp() services in IDD current measurement driver</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section6" aria-hidden="true"> <label for="collapse-section6" aria-hidden="true">V2.1.0 / 06-February-2015</label>
<div>
<h2 id="main-changes-13">Main Changes</h2>
<p>IDD current measurement driver function prototypes added (idd.h file)<br />
io.h: add new typedef enum IO_PinState with IO_PIN_RESET and IO_PIN_SET values</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section5" aria-hidden="true"> <label for="collapse-section5" aria-hidden="true">V2.0.0 / 15-December-2014</label>
<div>
<h2 id="main-changes-14">Main Changes</h2>
<p>Update “io.h” file to support MFX (Multi Function eXpander) device available on some STM32 boards</p>
<ul>
<li>add new entries for <em>IO_ModeTypedef</em> enumeration structure</li>
<li>update the <em>IO_DrvTypeDef</em> structure</li>
<li>Update all return values and function parameters to uint32_t</li>
<li>Add a return value for <em>Config</em> field</li>
</ul>
<h2 id="backward-compatibility-6">Backward Compatibility</h2>
<p>This release breaks compatibility with previous versions.</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section4" aria-hidden="true"> <label for="collapse-section4" aria-hidden="true">V1.2.1 / 02-December-2014</label>
<div>
<h2 id="main-changes-15">Main Changes</h2>
<p>gyro.h: change “__GIRO_H” by “__GYRO_H” to fix compilation issue under Mac OS</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section3" aria-hidden="true"> <label for="collapse-section3" aria-hidden="true">V1.2.0 / 18-June-2014</label>
<div>
<h2 id="main-changes-16">Main Changes</h2>
<p>EPD (E Paper Display) driver function prototype added (epd.h file)</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section2" aria-hidden="true"> <label for="collapse-section2" aria-hidden="true">V1.1.0 / 21-March-2014</label>
<div>
<h2 id="main-changes-17">Main Changes</h2>
<p>Temperature Sensor driver function prototype added</p>
</div>
</div>
<div class="collapse">
<input type="checkbox" id="collapse-section1" aria-hidden="true"> <label for="collapse-section1" aria-hidden="true">V1.0.0 / 18-February-2014</label>
<div>
<h2 id="main-changes-18">Main Changes</h2>
<p>First official release with Accelerometer, Audio, Camera, Gyroscope, IO, LCD and Touch Screen drivers function prototypes</p>
</div>
</div>
</div>
</div>
<footer class="sticky">
For complete documentation on <mark>STM32 Microcontrollers</mark> , visit: <a href="http://www.st.com/STM32">http://www.st.com/STM32</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,323 @@
---
pagetitle: Release Notes for BSP Common Components Drivers
lang: en
header-includes: <link rel="icon" type="image/x-icon" href="_htmresc/favicon.png" />
---
::: {.row}
::: {.col-sm-12 .col-lg-4}
<center>
# <small>Release Notes for</small> <mark>BSP Common Components Drivers</mark>
Copyright &copy; 2014 STMicroelectronics\
[![ST logo](_htmresc/st_logo.png)](https://www.st.com){.logo}
</center>
# Purpose
This directory contains the BSP Common components drivers.
:::
::: {.col-sm-12 .col-lg-8}
# Update History
::: {.collapse}
<input type="checkbox" id="collapse-section19" checked aria-hidden="true">
<label for="collapse-section19" aria-hidden="true">V7.2.1 / 16-November-2021</label>
<div>
## Main Changes
### Component release
- Update licensing
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section18" aria-hidden="true">
<label for="collapse-section18" aria-hidden="true">V7.2.0 / 11-October-2021</label>
<div>
## Main Changes
### Component release
- Add hybrid_sensor.h to support hybrid (env and motion) sensors
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section17" aria-hidden="true">
<label for="collapse-section17" aria-hidden="true">V7.1.0 / 18-Juin-2021</label>
<div>
## Main Changes
### Component release
- Add ranging_sensor.h to support ranging sensors
- Add light_sensor.h to support ambient light sensors
- Rename UTILS_LCD_Drv_t structure into LCD_UTILS_Drv_t
- Code spelling and formatting
## Backward Compatibility
- This release breaks compatibility with previous versions.
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section16" aria-hidden="true">
<label for="collapse-section16" aria-hidden="true">V7.0.0 / 25-February-2020</label>
<div>
## Main Changes
### Component release
- Rename GUI_Drv_t structure into UTILS_LCD_Drv_t
## Backward Compatibility
- This release breaks compatibility with previous versions.
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section15" aria-hidden="true">
<label for="collapse-section15" aria-hidden="true">V6.0.1 / 15-October-2019</label>
<div>
## Main Changes
### Component release
- Update st_logo.png inclusion path in Release notes.
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section14" aria-hidden="true">
<label for="collapse-section14" aria-hidden="true">V6.0.0 / 12-April-2019</label>
<div>
## Main Changes
### Component release
Official release of BSP Common components drivers in line with STM32Cube BSP drivers development guidelines (UM2298).
## Backward Compatibility
This release breaks compatibility with previous versions.
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section13" aria-hidden="true">
<label for="collapse-section13" aria-hidden="true">V5.1.1 / 31-August-2018</label>
<div>
## Main Changes
Reformat the BSD 3-Clause license declaration in the files header (replace license terms by a web reference to OSI website where those terms lie)
Correct sensor names in headers files hsensor.h and psensor.h
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section12" aria-hidden="true">
<label for="collapse-section12" aria-hidden="true">V5.1.0 / 21-November-2017</label>
<div>
## Main Changes
Add dpredriver.h: support of DP redriver class
Add pwrmon.h: support of power monitor class
Add usbtypecswitch.h: support of USB type C switch class
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section11" aria-hidden="true">
<label for="collapse-section11" aria-hidden="true">V5.0.0 / 01-March-2017</label>
<div>
## Main Changes
Add hsensor.h: support of humidity class
Add psensor.h: support of pressure class
Update tsensor.h: Temperature can be negative
Update accelero.h: LowPower API can enable or disable the low power mode
Update magneto.h: LowPower API can enable or disable the low power mode
## Backward Compatibility
This release breaks compatibility with previous versions.
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section10" aria-hidden="true">
<label for="collapse-section10" aria-hidden="true">V4.0.1 / 21-July-2015</label>
<div>
## Main Changes
tsensor.h: Fix compilation issue on *TSENSOR_InitTypeDef*
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section9" aria-hidden="true">
<label for="collapse-section9" aria-hidden="true">V4.0.0 / 22-June-2015</label>
<div>
## Main Changes
accelero.h: add *DeInit* field in *ACCELERO_DrvTypeDef* structure
audio.h: add *DeInit* field in *AUDIO_DrvTypeDef* structure
idd.h:
- add *Shunt0StabDelay*, *Shunt1StabDelay*, *Shunt2StabDelay*, *Shunt3StabDelay*, *Shunt4StabDelay* and *ShuntNbOnBoard* fields in *IDD_ConfigTypeDef* structure
- rename *ShuntNumber* field to *ShuntNbUsed* in *IDD_ConfigTypeDef* structure
magneto.h: add *DeInit* field in *MAGNETO_DrvTypeDef* structure
## Backward Compatibility
This release breaks compatibility with previous versions.
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section8" aria-hidden="true">
<label for="collapse-section8" aria-hidden="true">V3.0.0 / 28-April-2015</label>
<div>
## Main Changes
accelero.h: add *LowPower* field in *ACCELERO_DrvTypeDef* structure
magneto.h: add *LowPower* field in *MAGNETO_DrvTypeDef* structure
gyro.h: add *DeInit* and *LowPower* fields in *GYRO_DrvTypeDef* structure
camera.h: add CAMERA_COLOR_EFFECT_NONE define
idd.h:
- add *MeasureNb*, *DeltaDelayUnit* and *DeltaDelayValue* fields in *IDD_ConfigTypeDef* structure
- rename *PreDelay* field to *PreDelayUnit* in *IDD_ConfigTypeDef* structure
## Backward Compatibility
This release breaks compatibility with previous versions.
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section7" aria-hidden="true">
<label for="collapse-section7" aria-hidden="true">V2.2.0 / 09-February-2015</label>
<div>
## Main Changes
Magnetometer driver function prototypes added (magneto.h file)
Update "idd.h" file to provide DeInit() and WakeUp() services in IDD current measurement driver
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section6" aria-hidden="true">
<label for="collapse-section6" aria-hidden="true">V2.1.0 / 06-February-2015</label>
<div>
## Main Changes
IDD current measurement driver function prototypes added (idd.h file)
io.h: add new typedef enum IO_PinState with IO_PIN_RESET and IO_PIN_SET values
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section5" aria-hidden="true">
<label for="collapse-section5" aria-hidden="true">V2.0.0 / 15-December-2014</label>
<div>
## Main Changes
Update "io.h" file to support MFX (Multi Function eXpander) device available on some STM32 boards
- add new entries for *IO_ModeTypedef* enumeration structure
- update the *IO_DrvTypeDef* structure
- Update all return values and function parameters to uint32_t
- Add a return value for *Config* field
## Backward Compatibility
This release breaks compatibility with previous versions.
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section4" aria-hidden="true">
<label for="collapse-section4" aria-hidden="true">V1.2.1 / 02-December-2014</label>
<div>
## Main Changes
gyro.h: change “__GIRO_H” by “__GYRO_H” to fix compilation issue under Mac OS
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section3" aria-hidden="true">
<label for="collapse-section3" aria-hidden="true">V1.2.0 / 18-June-2014</label>
<div>
## Main Changes
EPD (E Paper Display) driver function prototype added (epd.h file)
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section2" aria-hidden="true">
<label for="collapse-section2" aria-hidden="true">V1.1.0 / 21-March-2014</label>
<div>
## Main Changes
Temperature Sensor driver function prototype added
</div>
:::
::: {.collapse}
<input type="checkbox" id="collapse-section1" aria-hidden="true">
<label for="collapse-section1" aria-hidden="true">V1.0.0 / 18-February-2014</label>
<div>
## Main Changes
First official release with Accelerometer, Audio, Camera, Gyroscope, IO, LCD and Touch Screen drivers function prototypes
</div>
:::
:::
:::
<footer class="sticky">
For complete documentation on <mark>STM32 Microcontrollers</mark> ,
visit: [http://www.st.com/STM32](http://www.st.com/STM32)
</footer>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><g fill="#13254a"><path d="M100 .212C44.868.212 0 44.889 0 99.788c0 55.132 44.868 100 100 100s100-44.868 100-100C200 44.889 155.132.212 100 .212zm0 181.164c-44.974 0-81.587-36.614-81.587-81.587 0-44.762 36.614-81.164 81.587-81.164 44.995 0 81.587 36.402 81.587 81.164 0 44.973-36.592 81.587-81.587 81.587z" style="fill: #e6007e;"/><path d="M141.1 88.127h-29.439V58.688c0-6.392-5.185-11.598-11.598-11.598-6.413 0-11.619 5.206-11.619 11.598v29.439H58.476c-6.392 0-11.598 5.185-11.598 11.598 0 6.413 5.206 11.619 11.598 11.619h29.968v29.968c0 6.392 5.206 11.598 11.619 11.598 6.413 0 11.598-5.206 11.598-11.598v-29.968H141.1c6.392 0 11.598-5.206 11.598-11.619 0-6.413-5.206-11.598-11.598-11.598z" style="fill: #e6007e;"/></g></svg>

After

Width:  |  Height:  |  Size: 832 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><g fill="#03234b"><path d="M170.934 64.569l-.04-.055-29.049 40.038-.057.079h14.867a59.366 59.366 0 0 1-1.488 15.615c-1.158 5.318-3.807 13.448-9.848 21.977-2.766 4.118-6.375 7.726-9.208 10.408-3.426 2.857-7.461 6.095-12 8.376-8.121 4.568-17.881 7.138-28.225 7.432-10.907.248-20.201-2.61-26.072-5.052-8.283-3.479-14.111-7.807-16.85-10.078-1.254-.956-2.25-1.907-3.057-2.676a28.662 28.662 0 0 0-1.157-1.069 158.874 158.874 0 0 0-1.479-1.362l-4.435-3.956 3.569 4.81.183.243c.2.263.548.722 1.048 1.389.276.334.579.747.93 1.226l.008.01c.689.939 1.546 2.107 2.664 3.322 3 3.536 8.178 8.801 15.808 13.801 5.969 3.866 15.672 8.901 28.001 10.935a79.05 79.05 0 0 0 12.485.998c7.97 0 15.651-1.228 22.828-3.649 6.366-1.877 12.192-4.981 17.053-7.771 6.16-3.936 10.817-7.586 14.654-11.488 8.084-7.899 14.884-17.913 19.15-28.199 3.259-7.594 5.456-15.727 6.529-24.175l.055-.425.083-.641H200l-29.066-40.063zM58.159 99.232l-12.655.563c-.097-.881-.159-1.986-.227-3.474a59.184 59.184 0 0 1 1.446-16.56c1.157-5.316 3.804-13.444 9.848-21.977 2.168-3.228 5.009-6.44 9.208-10.415 3.41-2.849 7.432-6.08 12.005-8.375 8.114-4.568 17.87-7.138 28.213-7.432 10.9-.25 20.196 2.607 26.072 5.045 8.258 3.473 14.105 7.812 16.857 10.091 1.257.951 2.253 1.904 3.057 2.673l.017.016c.43.411.801.766 1.136 1.051.475.438.841.777 1.091 1.01l.138.128.248.229 4.04 3.613-3.165-4.456c-.058-.083-.312-.417-.73-.971l-.507-.67a28.922 28.922 0 0 1-.901-1.192l-.02-.027c-.69-.945-1.548-2.121-2.677-3.346-3.002-3.537-8.182-8.803-15.813-13.801-5.964-3.865-15.662-8.9-27.997-10.935-8.484-1.363-21.496-2.009-35.313 2.651-6.355 1.88-12.186 4.983-17.054 7.772-6.163 3.944-10.82 7.595-14.654 11.488-8.079 7.894-14.882 17.909-19.155 28.2-3.268 7.624-5.463 15.757-6.523 24.173-.436 3.281-.642 5.421-.664 6.926L0 101.831l30.683 38.727.042.053 27.38-41.298.054-.081z" style="fill: #e6007e;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,105 @@
/**
******************************************************************************
* @file audio.h
* @author MCD Application Team
* @brief This header file contains the common defines and functions prototypes
* for the Audio driver.
******************************************************************************
* @attention
*
* Copyright (c) 2018 STMicroelectronics.
* All rights reserved.
*
* This software is licensed under terms that can be found in the LICENSE file
* in the root directory of this software component.
* If no LICENSE file comes with this software, it is provided AS-IS.
*
******************************************************************************
*/
/* Define to prevent recursive inclusion -------------------------------------*/
#ifndef AUDIO_H
#define AUDIO_H
#ifdef __cplusplus
extern "C" {
#endif
/* Includes ------------------------------------------------------------------*/
#include <stdint.h>
/** @addtogroup BSP
* @{
*/
/** @addtogroup Components
* @{
*/
/** @addtogroup AUDIO
* @{
*/
/** @defgroup AUDIO_Exported_Constants
* @{
*/
/**
* @}
*/
/** @defgroup AUDIO_Exported_Types
* @{
*/
/** @defgroup AUDIO_Driver_structure Audio Driver structure
* @{
*/
typedef struct
{
int32_t (*Init)(void *, void *);
int32_t (*DeInit)(void *);
int32_t (*ReadID)(void *, uint32_t *);
int32_t (*Play)(void *);
int32_t (*Pause)(void *);
int32_t (*Resume)(void *);
int32_t (*Stop)(void *, uint32_t);
int32_t (*SetFrequency)(void *, uint32_t);
int32_t (*GetFrequency)(void *);
int32_t (*SetVolume)(void *, uint32_t, uint8_t);
int32_t (*GetVolume)(void *, uint32_t, uint8_t *);
int32_t (*SetMute)(void *, uint32_t);
int32_t (*SetOutputMode)(void *, uint32_t);
int32_t (*SetResolution)(void *, uint32_t);
int32_t (*GetResolution)(void *, uint32_t *);
int32_t (*SetProtocol)(void *, uint32_t);
int32_t (*GetProtocol)(void *, uint32_t *);
int32_t (*Reset)(void *);
} AUDIO_Drv_t;
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
#ifdef __cplusplus
}
#endif
#endif /* AUDIO_H */

View File

@@ -0,0 +1,103 @@
/**
******************************************************************************
* @file camera.h
* @author MCD Application Team
* @brief This header file contains the common defines and functions prototypes
* for the camera driver.
******************************************************************************
* @attention
*
* Copyright (c) 2018 STMicroelectronics.
* All rights reserved.
*
* This software is licensed under terms that can be found in the LICENSE file
* in the root directory of this software component.
* If no LICENSE file comes with this software, it is provided AS-IS.
*
******************************************************************************
*/
/* Define to prevent recursive inclusion -------------------------------------*/
#ifndef CAMERA_H
#define CAMERA_H
#ifdef __cplusplus
extern "C" {
#endif
/* Includes ------------------------------------------------------------------*/
#include <stdint.h>
/** @addtogroup BSP
* @{
*/
/** @addtogroup Components
* @{
*/
/** @addtogroup CAMERA
* @{
*/
/** @defgroup CAMERA_Exported_Types
* @{
*/
/**
* @}
*/
/** @defgroup CAMERA_Driver_structure Camera Driver structure
* @{
*/
typedef struct
{
int32_t (*Init)(void *, uint32_t, uint32_t);
int32_t (*DeInit)(void *);
int32_t (*ReadID)(void *, uint32_t *);
int32_t (*GetCapabilities)(void *, void *);
int32_t (*SetLightMode)(void *, uint32_t);
int32_t (*SetColorEffect)(void *, uint32_t);
int32_t (*SetBrightness)(void *, int32_t);
int32_t (*SetSaturation)(void *, int32_t);
int32_t (*SetContrast)(void *, int32_t);
int32_t (*SetHueDegree)(void *, int32_t);
int32_t (*MirrorFlipConfig)(void *, uint32_t);
int32_t (*ZoomConfig)(void *, uint32_t);
int32_t (*SetResolution)(void *, uint32_t);
int32_t (*GetResolution)(void *, uint32_t *);
int32_t (*SetPixelFormat)(void *, uint32_t);
int32_t (*GetPixelFormat)(void *, uint32_t *);
int32_t (*NightModeConfig)(void *, uint32_t);
} CAMERA_Drv_t;
/**
* @}
*/
/** @defgroup CAMERA_Exported_Constants
* @{
*/
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
#ifdef __cplusplus
}
#endif
#endif /* CAMERA_H */

View File

@@ -0,0 +1,102 @@
/**
******************************************************************************
* @file dpredriver.h
* @author MCD Application Team
* @brief This header file contains the functions prototypes for the
* DisplayPort Linear Redriver.
******************************************************************************
* @attention
*
* Copyright (c) 2018 STMicroelectronics.
* All rights reserved.
*
* This software is licensed under terms that can be found in the LICENSE file
* in the root directory of this software component.
* If no LICENSE file comes with this software, it is provided AS-IS.
*
******************************************************************************
*/
/* Define to prevent recursive inclusion -------------------------------------*/
#ifndef __DPREDRIVER_H
#define __DPREDRIVER_H
#ifdef __cplusplus
extern "C" {
#endif
/* Includes ------------------------------------------------------------------*/
#include <stdint.h>
/** @addtogroup BSP
* @{
*/
/** @addtogroup Components
* @{
*/
/** @addtogroup DPREDRIVER
* @{
*/
/** @defgroup DPREDRIVER_Exported_Types
* @{
*/
/** @defgroup DPREDRIVER_Channel_Identifier Channel Identifier
* @{
*/
typedef enum
{
CHANNEL_DP0 = 0,
CHANNEL_DP1,
CHANNEL_DP2,
CHANNEL_DP3,
CHANNEL_RX1,
CHANNEL_RX2,
CHANNEL_SSTX
} DPREDRIVER_ChannelId_t;
/**
* @}
*/
/** @defgroup DPREDRIVER_Driver_structure DisplayPort Linear Redriver Driver structure
* @{
*/
typedef struct
{
uint32_t (*Init)(uint16_t);
void (*DeInit)(uint16_t);
uint32_t (*PowerOn)(uint16_t);
uint32_t (*PowerOff)(uint16_t);
uint32_t (*SetEQGain)(uint16_t, DPREDRIVER_ChannelId_t, uint8_t);
uint32_t (*EnableChannel)(uint16_t, DPREDRIVER_ChannelId_t);
uint32_t (*DisableChannel)(uint16_t, DPREDRIVER_ChannelId_t);
} DPREDRIVER_Drv_t;
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
#ifdef __cplusplus
}
#endif
#endif /* __DPREDRIVER_H */

View File

@@ -0,0 +1,95 @@
/**
******************************************************************************
* @file env_sensor.h
* @author MCD Application Team
* @brief This header file contains the functions prototypes for the
* environmental sensor driver
******************************************************************************
* @attention
*
* Copyright (c) 2017 STMicroelectronics.
* All rights reserved.
*
* This software is licensed under terms that can be found in the LICENSE file
* in the root directory of this software component.
* If no LICENSE file comes with this software, it is provided AS-IS.
*
******************************************************************************
*/
/* Define to prevent recursive inclusion -------------------------------------*/
#ifndef ENV_SENSOR_H
#define ENV_SENSOR_H
#ifdef __cplusplus
extern "C" {
#endif
/* Includes ------------------------------------------------------------------*/
#include <stdint.h>
/** @addtogroup BSP BSP
* @{
*/
/** @addtogroup COMPONENTS COMPONENTS
* @{
*/
/** @addtogroup COMMON COMMON
* @{
*/
/** @addtogroup ENV_SENSOR ENV SENSOR
* @{
*/
/** @addtogroup ENV_SENSOR_Public_Types ENV SENSOR Public types
* @{
*/
/**
* @brief ENV SENSOR driver structure definition
*/
typedef struct
{
int32_t (*Init)(void *);
int32_t (*DeInit)(void *);
int32_t (*ReadID)(void *, uint8_t *);
int32_t (*GetCapabilities)(void *, void *);
} ENV_SENSOR_CommonDrv_t;
typedef struct
{
int32_t (*Enable)(void *);
int32_t (*Disable)(void *);
int32_t (*GetOutputDataRate)(void *, float *);
int32_t (*SetOutputDataRate)(void *, float);
int32_t (*GetValue)(void *, float *);
} ENV_SENSOR_FuncDrv_t;
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
/**
* @}
*/
#ifdef __cplusplus
}
#endif
#endif /* ENV_SENSOR_H */

Some files were not shown because too many files have changed in this diff Show More