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:
87
.gitignore
vendored
Normal file
87
.gitignore
vendored
Normal 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
|
||||
BIN
26-42802-001_Aliro_1.0_specification (2).pdf
Normal file
BIN
26-42802-001_Aliro_1.0_specification (2).pdf
Normal file
Binary file not shown.
4
applet/.gitignore
vendored
Normal file
4
applet/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.tools/
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
212
applet/INSTALL.md
Normal file
212
applet/INSTALL.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Installing the Aliro applet on a real Java Card
|
||||
|
||||
Tested target: NXP J3R180 (JC 3.0.5, GP 2.3) over a USB PC/SC reader. Other
|
||||
GP-2.2+ JC 3.0.5+ cards should work too.
|
||||
|
||||
## What you'll need
|
||||
|
||||
- A USB PC/SC reader (e.g. ACR1252U, ACR122U, OMNIKEY 5022).
|
||||
- A Java Card with default GlobalPlatform test keys still in place (any
|
||||
freshly-bought J3R180 from VivoKey-friendly suppliers ships this way).
|
||||
- Java 11+ on the host. `pcscd` running (`sudo systemctl start pcscd` on
|
||||
Linux).
|
||||
- [GlobalPlatformPro](https://github.com/martinpaljak/GlobalPlatformPro)
|
||||
(`gp.jar` — single fat jar, no install needed):
|
||||
|
||||
```
|
||||
curl -sLO https://github.com/martinpaljak/GlobalPlatformPro/releases/latest/download/gp.jar
|
||||
```
|
||||
|
||||
## Build the CAP
|
||||
|
||||
```
|
||||
cd applet
|
||||
./scripts/dt-mvn.sh clean package -DskipTests
|
||||
```
|
||||
|
||||
Output: `applet/target/aliro-applet.cap` (~42 KB). Built inside the
|
||||
`vivokey/smartcard-ci` container by the `ant-javacard` plugin against the
|
||||
JC 3.0.5 SDK.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
java -jar gp.jar --info # confirm reader sees the card
|
||||
|
||||
# Multi-applet CAPs: load the package once, then create each instance.
|
||||
# --create needs the instance AID, the applet class AID (same as instance
|
||||
# here), and the package AID (printed by --load).
|
||||
PKG=A0000009094454414C49524F
|
||||
java -jar gp.jar --load applet/target/aliro-applet.cap
|
||||
java -jar gp.jar --create A000000909ACCE5501 --applet A000000909ACCE5501 --package $PKG
|
||||
java -jar gp.jar --create A000000909ACCE5502 --applet A000000909ACCE5502 --package $PKG
|
||||
java -jar gp.jar --create A000000909ACCE559901 --applet A000000909ACCE559901 --package $PKG
|
||||
```
|
||||
|
||||
(`--install <cap>` alone fails with `"CAP contains more than one applet,
|
||||
specify the right one with --[applet]"`, and `--create AID` without
|
||||
`--applet`/`--package` fails with `"Need --[package, pkg] and --[applet]
|
||||
or --[cap]"`.)
|
||||
|
||||
Default GP test keys (`404142434445464748494A4B4C4D4E4F`) are tried
|
||||
automatically. For non-default keys, pass `--key <hex>` to every command.
|
||||
|
||||
Three applets get registered, one CAP:
|
||||
|
||||
| AID | Class | Role |
|
||||
| -------------------------------- | ---------------------- | -------------------------------------------- |
|
||||
| `A0000009 09 ACCE 55 01` | `AliroApplet` | Aliro EXPEDITED phase (spec) |
|
||||
| `A0000009 09 ACCE 55 02` | `StepUpApplet` | Aliro STEP_UP phase (spec; scaffold only ATM)|
|
||||
| `A0000009 09 ACCE 55 99 01` | `PersonalizationApplet`| DT-internal provisioning channel |
|
||||
|
||||
All three share the package AID `A0000009 09 4454414C49524F` (CSA RID +
|
||||
ASCII "DTALIRO"). JavaCard requires applets in one CAP to share the
|
||||
package's RID, hence the proprietary AID for personalization is also
|
||||
under the CSA RID with a non-spec PIX (`99 01`).
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
java -jar gp.jar --list
|
||||
```
|
||||
|
||||
You should see all three AIDs in the `Application` list.
|
||||
|
||||
## Personalize
|
||||
|
||||
After install the card holds three applets but no Access Credential keys
|
||||
or Access Document — every Aliro flow returns `SW_CONDITIONS_NOT_SATISFIED`.
|
||||
|
||||
Provision via the PersonalizationApplet (CLA `0x80`):
|
||||
|
||||
| INS | P1\|P2 | Data | Description |
|
||||
| ------ | --------------- | --------------------------------------- | ----------------------------------- |
|
||||
| `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey |
|
||||
| `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey |
|
||||
| `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey |
|
||||
| `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write |
|
||||
| `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document |
|
||||
| `0x2C` | `0000` | (none) | COMMIT — locks all writes |
|
||||
|
||||
Required order: SELECT provisioning AID → write all keys + AD chunks →
|
||||
finalize → COMMIT. After COMMIT, every write returns
|
||||
`SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1).
|
||||
|
||||
Source bytes come from `aliro-trustgen init --out-dir ./out`:
|
||||
- `out/access_credential.pem` → derive priv/pub bytes
|
||||
- `out/reader.pem` → derive pub bytes
|
||||
- `out/access_document.bin` → chunk into ≤255B writes
|
||||
|
||||
**One-shot personalization:** the harness ships an `aliro-personalize`
|
||||
CLI that walks the entire INS sequence above against a PC/SC reader:
|
||||
|
||||
```
|
||||
pip install -e harness/ # one-time, if not already installed
|
||||
aliro-trustgen init --out-dir ./trust-out
|
||||
aliro-personalize --trust-dir ./trust-out
|
||||
```
|
||||
|
||||
Use `aliro-personalize --list-readers` to enumerate readers if more than
|
||||
one is plugged in, then `--reader N` to pick by index.
|
||||
|
||||
The CLI fails fast on the first non-9000 status word and tells you which
|
||||
step. After `COMMIT`, the card refuses every further write — there is no
|
||||
factory-reset path in v1, so verify the trust artifacts before running.
|
||||
|
||||
## Validate after personalization
|
||||
|
||||
Once personalization has COMMITted, `aliro-bench-test` drives a full
|
||||
AUTH0+AUTH1 exchange against the card from a Python orchestrator over
|
||||
PC/SC. It's the real-hardware validation path that works without any
|
||||
reader firmware (X-CUBE-ALIRO on X-NUCLEO-NFC09A1 is still pending).
|
||||
|
||||
```
|
||||
pip install -e harness/ # one-time, same package that ships aliro-personalize
|
||||
aliro-bench-test --trust-dir ~/aliro-trust
|
||||
```
|
||||
|
||||
The `--trust-dir` must point at the same `aliro-trustgen init` output
|
||||
that personalization used (it reads `reader.pem` to sign the AUTH1
|
||||
challenge and `access_credential.pem` to verify the UD signature the
|
||||
applet returns).
|
||||
|
||||
Successful output looks like:
|
||||
|
||||
```
|
||||
Using reader: ACS ACR1252 1S CL Reader 0
|
||||
Connected. ATR: 3B8F8001804F0CA000000306030001000000006A
|
||||
Loaded trust artifacts from /home/you/aliro-trust
|
||||
RESULT: OK — applet round-trip on real hardware.
|
||||
0x5A credential_PubK: 65B
|
||||
0x9E UD signature: 64B
|
||||
0x5E signaling_bitmap: 0x0000
|
||||
```
|
||||
|
||||
`0x5E signaling_bitmap` is `0x0000` when no Access Document was
|
||||
provisioned and `0x0005` when one was (bit 0 = "Access Document
|
||||
retrievable", bit 2 = "retrieval requires step-up AID SELECT" — see
|
||||
Aliro Table 8-11).
|
||||
|
||||
Useful flags:
|
||||
- `--list-readers` — enumerate PC/SC readers, then `--reader N` to pick.
|
||||
|
||||
Failure modes:
|
||||
- **"trust dir not found" / missing `reader.pem`** — pass the right
|
||||
`--trust-dir`, or regenerate with `aliro-trustgen init`.
|
||||
- **"No reader with a card"** — card not powered on the field, or the
|
||||
wrong reader index. `--list-readers` helps.
|
||||
- **`SW=6985` on SELECT-EXPEDITED** — card wasn't personalized (no keys
|
||||
COMMITted). Run `aliro-personalize` first.
|
||||
- **`signature verification failed`** after AUTH1 — the `credential_PubK`
|
||||
returned by the card doesn't match `access_credential.pem` in the
|
||||
trust dir. Either the card was personalized from a different trust
|
||||
bundle, or the bundle on disk was regenerated post-personalization.
|
||||
There is no factory-reset in v1 — re-personalize on a fresh card.
|
||||
- **AES-GCM decrypt failure** — ExpeditedSKDevice mismatch; usually
|
||||
means the reader and applet derived different Kdh (check that
|
||||
`reader_PubK` in the trust dir matches the one that was provisioned).
|
||||
|
||||
This exercises the full EXPEDITED protocol end-to-end — every crypto
|
||||
step the real firmware will eventually run — so a green bench-test is
|
||||
strong evidence the applet is correct independently of any future
|
||||
reader implementation.
|
||||
|
||||
## Uninstall / re-install
|
||||
|
||||
`gp --delete <pkg_AID>` won't succeed while applet *instances* still
|
||||
reference the package, and `--deletedeps` isn't recognised by every
|
||||
gp.jar build. The portable sequence is to delete each instance first,
|
||||
then the package:
|
||||
|
||||
```
|
||||
java -jar gp.jar --delete A000000909ACCE5501
|
||||
java -jar gp.jar --delete A000000909ACCE5502
|
||||
java -jar gp.jar --delete A000000909ACCE559901
|
||||
java -jar gp.jar --delete A0000009094454414C49524F
|
||||
```
|
||||
|
||||
Then `--load` + the three `--create` commands above to reinstall the
|
||||
fresh CAP.
|
||||
|
||||
`--uninstall <cap>` is also supported (parses the CAP for the AIDs and
|
||||
deletes them) but only if the CAP file is reachable from the current
|
||||
directory, since `gp` resolves it as a path:
|
||||
|
||||
```
|
||||
java -jar gp.jar --uninstall ./aliro-applet.cap
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"No reader with a card found"**: confirm `pcsc_scan` (Linux/macOS)
|
||||
sees both reader and card. On Linux, `pcscd` must be running.
|
||||
- **`SW=6985 (Conditions not satisfied)`** on Aliro commands: provisioning
|
||||
hasn't been COMMITted yet, or no AUTH0 preceded an AUTH1.
|
||||
- **`SW=6A82 (File not found)`** on SELECT: applet didn't install — check
|
||||
`gp --list` and re-run `--install`.
|
||||
- **Auth failure on `gp --install`**: card uses non-default GP keys.
|
||||
Either find the issuer keys or factory-reset (vendor-specific).
|
||||
- **CAP build error "unsupported bytecode `new` in clinit"**: regression
|
||||
from someone adding a `static final ... = new ...()` initializer. JC
|
||||
3.0.5 forbids these. Move allocation to a constructor or to lazy init
|
||||
inside an instance method.
|
||||
145
applet/pom.xml
Normal file
145
applet/pom.xml
Normal file
@@ -0,0 +1,145 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.dangerousthings.aliro</groupId>
|
||||
<artifactId>aliro-applet</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<description>
|
||||
Java Card applet implementing the CSA Aliro v1.0 User Device role over NFC.
|
||||
Built and tested inside the vivokey/smartcard-ci Docker image; see scripts/dt-mvn.sh.
|
||||
</description>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- JC 3.0.5 converter supports up to 1.7 bytecode (v51.0). Source
|
||||
stays at 1.7 to match. Test classes can be compiled at 1.8 via
|
||||
the test compiler config below — they don't go through the JC
|
||||
converter and the image's javac handles either version. -->
|
||||
<maven.compiler.source>1.7</maven.compiler.source>
|
||||
<maven.compiler.target>1.7</maven.compiler.target>
|
||||
|
||||
<junit.jupiter.version>5.10.2</junit.jupiter.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!--
|
||||
jcardsim is pre-built into the smartcard-ci image's local m2 repo
|
||||
(see https://github.com/DangerousThings/smartcard-ci Dockerfile).
|
||||
No jitpack / central resolution needed.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.licel</groupId>
|
||||
<artifactId>jcardsim</artifactId>
|
||||
<version>3.0.5-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.jupiter.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<repositories>
|
||||
<!-- ant-javacard's recent releases are published via jitpack rather
|
||||
than Maven Central; the Central artifact is stuck on 2019. -->
|
||||
<repository>
|
||||
<id>jitpack.io</id>
|
||||
<url>https://jitpack.io</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>jitpack.io</id>
|
||||
<url>https://jitpack.io</url>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<!-- Main: 1.7 bytecode for the JC 3.0.5 converter. -->
|
||||
<source>1.7</source>
|
||||
<target>1.7</target>
|
||||
<!-- Tests: JUnit 5 + our helpers use 1.8 features. They
|
||||
don't go through the JC converter. -->
|
||||
<testSource>1.8</testSource>
|
||||
<testTarget>1.8</testTarget>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</plugin>
|
||||
|
||||
<!--
|
||||
Builds the Java Card CAP file via ant-javacard, which wraps
|
||||
Oracle's converter from the JC SDK pinned by JC_CLASSIC_HOME
|
||||
(the dt-mvn.sh wrapper sets this to /app/sdks/jc305u3_kit).
|
||||
|
||||
Output: target/aliro-applet.cap
|
||||
|
||||
Package AID: A0 00 00 09 09 44 54 41 4C 49 52 4F (CSA RID + "DTALIRO")
|
||||
Applet AIDs:
|
||||
AliroApplet A0 00 00 09 09 AC CE 55 01 (Aliro EXPEDITED, spec)
|
||||
StepUpApplet A0 00 00 09 09 AC CE 55 02 (Aliro STEP_UP, spec)
|
||||
PersonalizationApplet A0 00 00 09 09 AC CE 55 99 01 (DT-internal, CSA-RID-namespaced)
|
||||
|
||||
JavaCard requires all applet AIDs in a CAP to share the
|
||||
package's RID (first 5 bytes); using the CSA RID throughout
|
||||
lets the three applets ship in one CAP.
|
||||
-->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-antrun-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>build-cap</id>
|
||||
<phase>package</phase>
|
||||
<goals><goal>run</goal></goals>
|
||||
<configuration>
|
||||
<target>
|
||||
<taskdef name="javacard"
|
||||
classname="pro.javacard.ant.JavaCard"
|
||||
classpathref="maven.plugin.classpath"/>
|
||||
<javacard jckit="${env.JC_CLASSIC_HOME}">
|
||||
<cap output="${project.build.directory}/aliro-applet.cap"
|
||||
aid="A0000009094454414C49524F"
|
||||
package="com.dangerousthings.aliro"
|
||||
version="0.1"
|
||||
classes="${project.build.outputDirectory}">
|
||||
<applet class="com.dangerousthings.aliro.AliroApplet"
|
||||
aid="A000000909ACCE5501"/>
|
||||
<applet class="com.dangerousthings.aliro.StepUpApplet"
|
||||
aid="A000000909ACCE5502"/>
|
||||
<applet class="com.dangerousthings.aliro.PersonalizationApplet"
|
||||
aid="A000000909ACCE559901"/>
|
||||
</cap>
|
||||
</javacard>
|
||||
</target>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.martinpaljak</groupId>
|
||||
<artifactId>ant-javacard</artifactId>
|
||||
<version>v26.02.22</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
28
applet/scripts/dt-mvn.sh
Executable file
28
applet/scripts/dt-mvn.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run Maven inside the DangerousThings smartcard-ci Docker image.
|
||||
#
|
||||
# The image (vivokey/smartcard-ci) ships a pre-built jcardsim-3.0.5-SNAPSHOT,
|
||||
# Oracle JavaCard SDKs, Java 8, and Maven. On first run we seed a named
|
||||
# Docker volume with the image's baseline .m2 so subsequent runs keep
|
||||
# downloaded deps (JUnit etc.) warm between invocations.
|
||||
#
|
||||
# Usage: ./scripts/dt-mvn.sh <mvn args> e.g.: ./scripts/dt-mvn.sh -q test
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="vivokey/smartcard-ci:latest"
|
||||
VOLUME="aliro-smartcard-m2"
|
||||
|
||||
project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd)"
|
||||
|
||||
if ! docker volume inspect "$VOLUME" >/dev/null 2>&1; then
|
||||
echo "Seeding $VOLUME with baseline .m2 from $IMAGE (one-time)" >&2
|
||||
docker volume create "$VOLUME" >/dev/null
|
||||
docker run --rm -v "$VOLUME:/dst" --entrypoint sh "$IMAGE" \
|
||||
-c 'cp -a /root/.m2/. /dst/ && chmod -R a+rwX /dst'
|
||||
fi
|
||||
|
||||
exec docker run --rm -t \
|
||||
-v "$project_root:/work" -w /work \
|
||||
-v "$VOLUME:/root/.m2" \
|
||||
-e JC_CLASSIC_HOME=/app/sdks/jc305u3_kit \
|
||||
"$IMAGE" "mvn $*"
|
||||
67
applet/scripts/install-jcardsim-gcm.sh
Executable file
67
applet/scripts/install-jcardsim-gcm.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install licel/jcardsim master (which has AES-GCM support) into the persistent
|
||||
# aliro-smartcard-m2 Docker volume, shadowing the jcardsim-3.0.5-SNAPSHOT jar
|
||||
# baked into vivokey/smartcard-ci:latest.
|
||||
#
|
||||
# Why: the DT smartcard-ci image pins a jcardsim fork that is 13 commits behind
|
||||
# licel upstream and lacks the AES-GCM/CCM implementation merged in licel PR
|
||||
# #187 (2022-07). Aliro §8.3.1.6 requires AES-256-GCM; without the impl,
|
||||
# `Cipher.getInstance(AEADCipher.ALG_AES_GCM, false)` throws CryptoException.
|
||||
#
|
||||
# Rerun this when the base image or the m2 volume is recreated.
|
||||
#
|
||||
# Usage: ./scripts/install-jcardsim-gcm.sh
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="vivokey/smartcard-ci:latest"
|
||||
VOLUME="aliro-smartcard-m2"
|
||||
UPSTREAM_COMMIT="aa60a02f" # licel/jcardsim master HEAD as of 2024-04-03 (last verified)
|
||||
|
||||
project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd)"
|
||||
build_dir="$project_root/build-tools/jcardsim"
|
||||
|
||||
if [[ ! -d "$build_dir" ]]; then
|
||||
echo "Cloning licel/jcardsim master into $build_dir" >&2
|
||||
mkdir -p "$project_root/build-tools"
|
||||
git clone --depth 50 https://github.com/licel/jcardsim.git "$build_dir"
|
||||
fi
|
||||
|
||||
(cd "$build_dir" \
|
||||
&& git fetch --depth 50 origin master \
|
||||
&& git checkout master \
|
||||
&& git reset --hard origin/master)
|
||||
|
||||
patches_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/jcardsim-patches"
|
||||
if [[ -d "$patches_dir" ]]; then
|
||||
echo "Applying local patches from $patches_dir" >&2
|
||||
for patch in "$patches_dir"/*.patch; do
|
||||
[[ -e "$patch" ]] || continue
|
||||
(cd "$build_dir" && git apply "$patch")
|
||||
done
|
||||
fi
|
||||
|
||||
if ! docker volume inspect "$VOLUME" >/dev/null 2>&1; then
|
||||
echo "Volume $VOLUME missing. Run ./scripts/dt-mvn.sh once first to seed it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building jcardsim inside $IMAGE..." >&2
|
||||
docker run --rm -v "$build_dir:/src" -w /src \
|
||||
-v "$VOLUME:/root/.m2" \
|
||||
--entrypoint bash \
|
||||
"$IMAGE" -c \
|
||||
"export JC_CLASSIC_HOME=/app/sdks/jc305u3_kit && \
|
||||
mvn -q -DskipTests initialize && \
|
||||
mvn -q -DskipTests package && \
|
||||
mvn -q install:install-file \
|
||||
-Dfile=target/jcardsim-3.0.5-SNAPSHOT.jar \
|
||||
-DgroupId=com.licel -DartifactId=jcardsim \
|
||||
-Dversion=3.0.5-SNAPSHOT -Dpackaging=jar \
|
||||
-DpomFile=pom.xml"
|
||||
|
||||
echo "Verifying AEAD implementation is present in installed jar..." >&2
|
||||
docker run --rm -v "$VOLUME:/root/.m2" --entrypoint bash "$IMAGE" -c \
|
||||
"jar tf /root/.m2/repository/com/licel/jcardsim/3.0.5-SNAPSHOT/jcardsim-3.0.5-SNAPSHOT.jar \
|
||||
| grep -q 'AuthenticatedSymmetricCipherImpl.class'"
|
||||
|
||||
echo "jcardsim with AES-GCM installed. Run ./scripts/dt-mvn.sh test to verify." >&2
|
||||
@@ -0,0 +1,24 @@
|
||||
Fix ByteContainer.setBytes to reallocate data when the incoming length
|
||||
differs from the currently stored length.
|
||||
|
||||
Upstream licel/jcardsim master drops `this.length != length` from the
|
||||
reallocation guard; the DT fork kept it. Without the check, calling
|
||||
setKey(buf13, 0, 13) then setKey(buf64, 32, 32) on the same key reuses the
|
||||
already-sized-13 internal data[] and triggers ArrayIndexOutOfBoundsException
|
||||
in the subsequent Util.arrayCopy.
|
||||
|
||||
This regression hits HKDF-Extract-then-Expand on the same HMACKey, which is
|
||||
exactly the shape Aliro §8.3.1.4 / §8.3.1.13 require.
|
||||
|
||||
diff --git a/src/main/java/com/licel/jcardsim/crypto/ByteContainer.java b/src/main/java/com/licel/jcardsim/crypto/ByteContainer.java
|
||||
--- a/src/main/java/com/licel/jcardsim/crypto/ByteContainer.java
|
||||
+++ b/src/main/java/com/licel/jcardsim/crypto/ByteContainer.java
|
||||
@@ -106,7 +106,7 @@ public final class ByteContainer {
|
||||
* @param length length of data in byte array
|
||||
*/
|
||||
public void setBytes(byte[] buff, short offset, short length) {
|
||||
- if (data == null) {
|
||||
+ if (data == null || this.length != length) {
|
||||
switch (memoryType) {
|
||||
case JCSystem.MEMORY_TYPE_TRANSIENT_DESELECT:
|
||||
data = JCSystem.makeTransientByteArray(length, JCSystem.CLEAR_ON_DESELECT);
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
/**
|
||||
* CSA Aliro v1.0 AIDs, spec §10.2.1.
|
||||
*/
|
||||
public final class AliroAids {
|
||||
/** Expedited Phase AID: A0 00 00 09 09 AC CE 55 01 */
|
||||
public static final byte[] EXPEDITED = {
|
||||
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
|
||||
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x01
|
||||
};
|
||||
|
||||
/** Step-up Phase AID: A0 00 00 09 09 AC CE 55 02 */
|
||||
public static final byte[] STEP_UP = {
|
||||
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
|
||||
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x02
|
||||
};
|
||||
|
||||
/**
|
||||
* DT-internal provisioning AID. Lives under the CSA RID so this applet
|
||||
* can ship in the same CAP as the spec-defined Aliro applets (Java Card
|
||||
* requires all applets in a package share the package's RID). The PIX
|
||||
* tail (ACCE 55 99 01) is non-spec — Aliro v1 uses 0x01/0x02 as the
|
||||
* PIX-low byte; 0x99 stays clear of any future allocation.
|
||||
*
|
||||
* <p>Used by the PersonalizationApplet to receive Access Credential +
|
||||
* reader keys + Access Document before the main Aliro flow runs. The
|
||||
* applet locks itself after COMMIT.
|
||||
*/
|
||||
public static final byte[] PROVISIONING = {
|
||||
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
|
||||
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x99, (byte)0x01
|
||||
};
|
||||
|
||||
private AliroAids() {}
|
||||
}
|
||||
1048
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
Normal file
1048
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
Normal file
File diff suppressed because it is too large
Load Diff
265
applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java
Normal file
265
applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java
Normal file
@@ -0,0 +1,265 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.security.ECPrivateKey;
|
||||
import javacard.security.KeyAgreement;
|
||||
|
||||
/**
|
||||
* Low-level Aliro cryptographic primitives, factored out so they can be
|
||||
* unit-tested independently of the APDU dispatch in {@link AliroApplet}.
|
||||
*
|
||||
* <p>Intended to be instantiated once per applet (or test) and reused —
|
||||
* the bundled {@link KeyAgreement} and {@link AliroHmac} objects are
|
||||
* expensive to allocate.
|
||||
*
|
||||
* <p>HMAC-SHA-256 is supplied by {@link AliroHmac}, our userland
|
||||
* implementation. The target card (NXP J3R180) does not expose
|
||||
* {@code KeyBuilder.TYPE_HMAC} at any tested key size nor
|
||||
* {@code Signature.ALG_HMAC_SHA_256}, so we build HMAC on top of the card's
|
||||
* raw SHA-256 primitive per RFC 2104.
|
||||
*/
|
||||
final class AliroCrypto {
|
||||
|
||||
private static final short HASH_LEN = 32;
|
||||
|
||||
/** Maximum expanded-input buffer size for HKDF-Expand: one T(i-1) block
|
||||
* (32B) + info (up to ~200B, defensively) + one counter byte. Rounded up
|
||||
* to 256B. The largest info we currently pass is the 32-byte salt_volatile
|
||||
* info for expedited-standard derivation; step-up uses 8-byte ASCII info. */
|
||||
private static final short EXPAND_SCRATCH_LEN = 256;
|
||||
|
||||
private KeyAgreement ecdhPlain;
|
||||
private AliroHmac aliroHmac;
|
||||
|
||||
/** Reusable scratch for one HMAC output (T(i)) and one counter byte. */
|
||||
private byte[] hkdfPrevT;
|
||||
|
||||
/** Scratch for assembling the HMAC message for HKDF-Expand: T(i-1) || info || counter. */
|
||||
private byte[] expandScratch;
|
||||
|
||||
/** Scratch for {@link #deriveKdh}: 32B for Z_AB then 32B for PRK. */
|
||||
private byte[] kdfWorkbuf;
|
||||
private static final short KDF_WORKBUF_LEN = 64;
|
||||
|
||||
/** 32 zero bytes — RFC 5869 §2.2 fallback when {@link #hkdfExtract} is
|
||||
* called with an empty salt. Allocated in the constructor; Java Card
|
||||
* bans {@code new} in static initializers, so we can't make this static-final. */
|
||||
private byte[] emptySaltZeros;
|
||||
|
||||
AliroCrypto() {
|
||||
try {
|
||||
ecdhPlain = KeyAgreement.getInstance(KeyAgreement.ALG_EC_SVDP_DH_PLAIN, false);
|
||||
} catch (Throwable t) {
|
||||
javacard.framework.ISOException.throwIt((short) 0x6FC1);
|
||||
}
|
||||
// Userland HMAC-SHA-256 on top of MessageDigest.ALG_SHA_256. Diagnostic
|
||||
// status word 0x6FC7 gives us one regression cycle of visibility on the
|
||||
// real card before we strip it.
|
||||
try {
|
||||
aliroHmac = new AliroHmac();
|
||||
} catch (Throwable t) {
|
||||
javacard.framework.ISOException.throwIt((short) 0x6FC7);
|
||||
}
|
||||
try {
|
||||
hkdfPrevT = javacard.framework.JCSystem.makeTransientByteArray(
|
||||
HASH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
|
||||
expandScratch = javacard.framework.JCSystem.makeTransientByteArray(
|
||||
EXPAND_SCRATCH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
|
||||
kdfWorkbuf = javacard.framework.JCSystem.makeTransientByteArray(
|
||||
KDF_WORKBUF_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
|
||||
} catch (Throwable t) {
|
||||
javacard.framework.ISOException.throwIt((short) 0x6FC4);
|
||||
}
|
||||
try {
|
||||
emptySaltZeros = new byte[32]; // persistent, defaults to all zeros
|
||||
} catch (Throwable t) {
|
||||
javacard.framework.ISOException.throwIt((short) 0x6FC5);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the x-coordinate of the ECDH shared point, given a local
|
||||
* P-256 private key and the peer's uncompressed public point
|
||||
* (0x04 || x || y, 65 bytes). Returns 32 (the x-coordinate length).
|
||||
*/
|
||||
short computeEcdhSharedX(
|
||||
ECPrivateKey priv,
|
||||
byte[] peerPubUncomp, short peerPubOff,
|
||||
byte[] out, short outOff) {
|
||||
ecdhPlain.init(priv);
|
||||
return ecdhPlain.generateSecret(peerPubUncomp, peerPubOff, (short) 65, out, outOff);
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF-Extract per RFC 5869 §2.2 with HMAC-SHA-256:
|
||||
* {@code PRK = HMAC(salt, IKM)}.
|
||||
* Returns 32 (HashLen).
|
||||
*/
|
||||
short hkdfExtract(
|
||||
byte[] salt, short saltOff, short saltLen,
|
||||
byte[] ikm, short ikmOff, short ikmLen,
|
||||
byte[] prk, short prkOff) {
|
||||
// RFC 5869 §2.2: empty salt is treated as HashLen zero bytes.
|
||||
if (saltLen == 0) {
|
||||
return aliroHmac.compute(
|
||||
emptySaltZeros, (short) 0, HASH_LEN,
|
||||
ikm, ikmOff, ikmLen,
|
||||
prk, prkOff);
|
||||
}
|
||||
return aliroHmac.compute(
|
||||
salt, saltOff, saltLen,
|
||||
ikm, ikmOff, ikmLen,
|
||||
prk, prkOff);
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF-Expand per RFC 5869 §2.3 with HMAC-SHA-256. Generates {@code L}
|
||||
* bytes of output keying material, where {@code L} must be ≤ 255 *
|
||||
* HashLen (8160 bytes — far above any Aliro need). The info region must
|
||||
* be at most {@code EXPAND_SCRATCH_LEN - HASH_LEN - 1} bytes long
|
||||
* (= 223 bytes with the current buffer sizing).
|
||||
*/
|
||||
short hkdfExpand(
|
||||
byte[] prk, short prkOff, short prkLen,
|
||||
byte[] info, short infoOff, short infoLen,
|
||||
short L,
|
||||
byte[] out, short outOff) {
|
||||
|
||||
// T(0) = empty; T(i) = HMAC(PRK, T(i-1) || info || i). Concatenate
|
||||
// T(1)..T(N) and truncate to L bytes.
|
||||
short produced = 0;
|
||||
short prevTLen = 0;
|
||||
byte counter = (byte) 1;
|
||||
|
||||
while (produced < L) {
|
||||
// Assemble the HMAC message as T(i-1) || info || counter in expandScratch.
|
||||
short msgLen = 0;
|
||||
if (prevTLen != 0) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
hkdfPrevT, (short) 0, expandScratch, msgLen, prevTLen);
|
||||
msgLen += prevTLen;
|
||||
}
|
||||
if (infoLen != 0) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
info, infoOff, expandScratch, msgLen, infoLen);
|
||||
msgLen += infoLen;
|
||||
}
|
||||
expandScratch[msgLen] = counter;
|
||||
msgLen++;
|
||||
|
||||
aliroHmac.compute(
|
||||
prk, prkOff, prkLen,
|
||||
expandScratch, (short) 0, msgLen,
|
||||
hkdfPrevT, (short) 0);
|
||||
prevTLen = HASH_LEN;
|
||||
|
||||
short remaining = (short) (L - produced);
|
||||
short toCopy = remaining < HASH_LEN ? remaining : HASH_LEN;
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
hkdfPrevT, (short) 0, out, (short) (outOff + produced), toCopy);
|
||||
produced += toCopy;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Wipe the assembled message scratch — it held T(i-1) values (derived
|
||||
// from the HKDF PRK) and the info bytes. CLEAR_ON_DESELECT cleans up
|
||||
// at deselect, but multi-APDU sessions would otherwise see stale T(i).
|
||||
javacard.framework.Util.arrayFillNonAtomic(
|
||||
expandScratch, (short) 0, EXPAND_SCRATCH_LEN, (byte) 0);
|
||||
|
||||
return produced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives Kdh per Aliro §8.3.1.4 (with the §8.3.1.5 HKDF substitution
|
||||
* noted in the spec): {@code Kdh = HKDF(IKM=ECDH_x(priv, peerPub),
|
||||
* salt=txnId, info=∅, L=32)}. Writes 32 bytes to {@code out[outOff..]}
|
||||
* and returns 32.
|
||||
*/
|
||||
short deriveKdh(
|
||||
ECPrivateKey priv,
|
||||
byte[] peerPubUncomp, short peerPubOff,
|
||||
byte[] txnId, short txnIdOff, short txnIdLen,
|
||||
byte[] out, short outOff) {
|
||||
computeEcdhSharedX(priv, peerPubUncomp, peerPubOff, kdfWorkbuf, (short) 0);
|
||||
hkdfExtract(
|
||||
txnId, txnIdOff, txnIdLen,
|
||||
kdfWorkbuf, (short) 0, HASH_LEN,
|
||||
kdfWorkbuf, HASH_LEN);
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, HASH_LEN, HASH_LEN,
|
||||
kdfWorkbuf, (short) 0, (short) 0,
|
||||
HASH_LEN,
|
||||
out, outOff);
|
||||
return HASH_LEN;
|
||||
}
|
||||
|
||||
/** ASCII info strings for step-up session key derivation, per mdoc 9.1.1.5. */
|
||||
private static final byte[] STEP_UP_INFO_SK_DEVICE = {
|
||||
(byte) 'S', (byte) 'K', (byte) 'D', (byte) 'e',
|
||||
(byte) 'v', (byte) 'i', (byte) 'c', (byte) 'e'
|
||||
};
|
||||
private static final byte[] STEP_UP_INFO_SK_READER = {
|
||||
(byte) 'S', (byte) 'K', (byte) 'R', (byte) 'e',
|
||||
(byte) 'a', (byte) 'd', (byte) 'e', (byte) 'r'
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives {@code StepUpSKDevice} and {@code StepUpSKReader} per Aliro
|
||||
* §8.4.3, which points at mdoc [6] clause 9.1.1.5 with two Aliro-specific
|
||||
* changes: IKM is the {@code StepUpSK} computed in §8.3.1.13 and the salt
|
||||
* is empty. Info remains the ASCII bytes of {@code "SKDevice"} and
|
||||
* {@code "SKReader"}.
|
||||
*
|
||||
* <p>Writes 32 bytes each to {@code skDeviceOut[skDeviceOff..]} and
|
||||
* {@code skReaderOut[skReaderOff..]}.
|
||||
*/
|
||||
short deriveStepUpSessionKeys(
|
||||
byte[] stepUpSK, short stepUpSKOff,
|
||||
byte[] skDeviceOut, short skDeviceOff,
|
||||
byte[] skReaderOut, short skReaderOff) {
|
||||
// HKDF-Extract once (shared across the two Expand calls): empty salt.
|
||||
hkdfExtract(
|
||||
kdfWorkbuf, (short) 0, (short) 0, // empty salt — use first 0 bytes of anything
|
||||
stepUpSK, stepUpSKOff, HASH_LEN,
|
||||
kdfWorkbuf, HASH_LEN); // PRK at [32..64)
|
||||
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, HASH_LEN, HASH_LEN,
|
||||
STEP_UP_INFO_SK_DEVICE, (short) 0, (short) STEP_UP_INFO_SK_DEVICE.length,
|
||||
HASH_LEN,
|
||||
skDeviceOut, skDeviceOff);
|
||||
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, HASH_LEN, HASH_LEN,
|
||||
STEP_UP_INFO_SK_READER, (short) 0, (short) STEP_UP_INFO_SK_READER.length,
|
||||
HASH_LEN,
|
||||
skReaderOut, skReaderOff);
|
||||
|
||||
return HASH_LEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the 160-byte {@code derived_keys_volatile} for the
|
||||
* expedited-standard phase per Aliro §8.3.1.13. Output is laid out as
|
||||
* ExpeditedSKReader[0..32), ExpeditedSKDevice[32..64), StepUpSK[64..96),
|
||||
* BleSK[96..128), URSK[128..160). For NFC-only operation only the
|
||||
* first 96 bytes are load-bearing, but the spec mandates generating all
|
||||
* 160 so the caller can discard what it doesn't need.
|
||||
*/
|
||||
short deriveExpeditedStandardKeys(
|
||||
byte[] kdh, short kdhOff,
|
||||
byte[] saltVolatile, short saltOff, short saltLen,
|
||||
byte[] info, short infoOff, short infoLen,
|
||||
byte[] out, short outOff) {
|
||||
hkdfExtract(
|
||||
saltVolatile, saltOff, saltLen,
|
||||
kdh, kdhOff, HASH_LEN,
|
||||
kdfWorkbuf, (short) 0);
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, (short) 0, HASH_LEN,
|
||||
info, infoOff, infoLen,
|
||||
(short) 160,
|
||||
out, outOff);
|
||||
return (short) 160;
|
||||
}
|
||||
}
|
||||
286
applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
Normal file
286
applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
Normal file
@@ -0,0 +1,286 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.JCSystem;
|
||||
import javacard.framework.Util;
|
||||
import javacard.security.AESKey;
|
||||
import javacard.security.KeyBuilder;
|
||||
import javacardx.crypto.Cipher;
|
||||
|
||||
/**
|
||||
* Userland AES-256-GCM, built on top of the JC 3.0.5 single-block AES-ECB
|
||||
* primitive. Needed because the target card (NXP J3R180) does not expose
|
||||
* {@code AEADCipher.ALG_AES_GCM} despite advertising JC 3.0.5 support, while
|
||||
* {@code Cipher.ALG_AES_BLOCK_128_ECB_NOPAD} and AES-256 keys are both
|
||||
* present and functional.
|
||||
*
|
||||
* <p>Tailored to the Aliro AUTH1 response use case (spec §8.3.1.6):
|
||||
* AES-256 with a 12-byte IV, empty AAD, 16-byte tag, and plaintext on the
|
||||
* order of 137 bytes. The NIST SP 800-38D canonical 96-bit-IV shortcut is
|
||||
* used (IV is taken as {@code IV || 0x00000001} for J<sub>0</sub>, no IV
|
||||
* hashing). This is the only IV length we ever produce — see
|
||||
* {@link AliroApplet#encryptResponseGcm}.
|
||||
*
|
||||
* <p>GHASH follows NIST SP 800-38D §6.4's bit-reflected convention:
|
||||
* byte 0 bit 7 (MSB) is the highest-degree polynomial coefficient; the
|
||||
* reduction polynomial R = x^128 + x^7 + x^2 + x + 1 is represented as the
|
||||
* 16-byte array {0xE1, 0x00, ..., 0x00}; the GHASH shift-right shifts toward
|
||||
* higher byte indices (lower polynomial degrees).
|
||||
*
|
||||
* <p>Not constant-time — the GF(2^128) multiply branches on bits of the
|
||||
* authentication tag partial state. On a secure JC platform this is
|
||||
* acceptable (the key material is smartcard-protected), but callers should
|
||||
* not pass attacker-chosen GHASH state.
|
||||
*/
|
||||
final class AliroGcm {
|
||||
|
||||
private static final short BLOCK_LEN = 16;
|
||||
private static final short TAG_LEN = 16;
|
||||
private static final short IV_LEN = 12; // 96-bit IV shortcut (NIST SP 800-38D §7.1)
|
||||
private static final short AES_256_KEY_BYTES = 32;
|
||||
|
||||
// Scratch layout — all 16-byte slots, single transient allocation.
|
||||
private static final short OFF_H = 0; // hash subkey = AES_K(0^128)
|
||||
private static final short OFF_J0 = 16; // pre-counter block
|
||||
private static final short OFF_CB = 32; // running counter block
|
||||
private static final short OFF_GHASH = 48; // accumulated GHASH state
|
||||
private static final short OFF_ECB_OUT = 64; // AES keystream block (also used for J0 encryption)
|
||||
private static final short OFF_TMP = 80; // misc: GF multiply workspace (V)
|
||||
private static final short OFF_LEN_BLK = 96; // length block for GHASH finalization
|
||||
private static final short SCRATCH_LEN = 112;
|
||||
|
||||
/** Persistent ECB cipher (AES-128/192/256 single-block-NOPAD). AES-256
|
||||
* is driven by the size of the {@link AESKey} passed to {@link Cipher#init}. */
|
||||
private final Cipher aesEcb;
|
||||
|
||||
/** Reusable 256-bit AES key slot. {@link AESKey#setKey} is called per encrypt. */
|
||||
private final AESKey aesKey;
|
||||
|
||||
/** Transient scratch — see OFF_* constants above. */
|
||||
private final byte[] scratch;
|
||||
|
||||
AliroGcm() {
|
||||
aesEcb = Cipher.getInstance(Cipher.ALG_AES_BLOCK_128_ECB_NOPAD, false);
|
||||
aesKey = (AESKey) KeyBuilder.buildKey(
|
||||
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
|
||||
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM encrypt with the Aliro parameters: 32-byte key, 12-byte IV,
|
||||
* empty AAD, 16-byte tag appended. Writes {@code ptLen} ciphertext bytes
|
||||
* followed by 16 tag bytes to {@code out[outOff..]} and returns
|
||||
* {@code ptLen + 16}.
|
||||
*
|
||||
* <p>The {@code out} buffer may overlap {@code pt} (same buffer, same
|
||||
* offset is supported — we encrypt block-by-block left-to-right, the
|
||||
* GHASH pass reads from {@code out}, and the tag lands past the end of
|
||||
* the ciphertext).
|
||||
*
|
||||
* @param key 32-byte AES-256 key, at {@code key[keyOff..keyOff+32)}
|
||||
* @param iv 12-byte IV, at {@code iv[ivOff..ivOff+12)}
|
||||
* @param pt plaintext bytes
|
||||
* @param ptOff, ptLen plaintext region
|
||||
* @param out output buffer, must have at least {@code ptLen + 16} bytes
|
||||
* available at {@code outOff}
|
||||
* @param outOff output start offset
|
||||
* @return {@code ptLen + 16}
|
||||
*/
|
||||
short encrypt(
|
||||
byte[] key, short keyOff,
|
||||
byte[] iv, short ivOff,
|
||||
byte[] pt, short ptOff, short ptLen,
|
||||
byte[] out, short outOff) {
|
||||
|
||||
aesKey.setKey(key, keyOff);
|
||||
aesEcb.init(aesKey, Cipher.MODE_ENCRYPT);
|
||||
|
||||
// H = AES_K(0^128). Zero scratch[OFF_H..OFF_H+16) and encrypt in place.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0);
|
||||
aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H);
|
||||
|
||||
// J0 = IV || 0x00000001 (12B IV + 4B counter). 96-bit IV canonical case.
|
||||
Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN);
|
||||
scratch[(short) (OFF_J0 + 12)] = 0x00;
|
||||
scratch[(short) (OFF_J0 + 13)] = 0x00;
|
||||
scratch[(short) (OFF_J0 + 14)] = 0x00;
|
||||
scratch[(short) (OFF_J0 + 15)] = 0x01;
|
||||
|
||||
// cb = INC32(J0) — first counter block for the plaintext stream.
|
||||
Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN);
|
||||
inc32(scratch, OFF_CB);
|
||||
|
||||
// GCTR loop: stream plaintext through AES-CTR, writing ciphertext to out.
|
||||
short produced = 0;
|
||||
while (produced < ptLen) {
|
||||
short blockLen = (short) (ptLen - produced);
|
||||
if (blockLen > BLOCK_LEN) blockLen = BLOCK_LEN;
|
||||
|
||||
// keystream = AES_K(cb)
|
||||
aesEcb.doFinal(scratch, OFF_CB, BLOCK_LEN, scratch, OFF_ECB_OUT);
|
||||
|
||||
// ct[i] = pt[i] XOR keystream[i] — partial-block safe.
|
||||
for (short i = 0; i < blockLen; i++) {
|
||||
out[(short) (outOff + produced + i)] =
|
||||
(byte) (pt[(short) (ptOff + produced + i)]
|
||||
^ scratch[(short) (OFF_ECB_OUT + i)]);
|
||||
}
|
||||
|
||||
inc32(scratch, OFF_CB);
|
||||
produced += blockLen;
|
||||
}
|
||||
|
||||
// GHASH over (AAD=empty || ciphertext || len_block).
|
||||
// state = 0^128
|
||||
Util.arrayFillNonAtomic(scratch, OFF_GHASH, BLOCK_LEN, (byte) 0);
|
||||
|
||||
// Fold ciphertext blocks into GHASH, zero-padding the final partial block.
|
||||
short consumed = 0;
|
||||
while (consumed < ptLen) {
|
||||
short chunk = (short) (ptLen - consumed);
|
||||
if (chunk >= BLOCK_LEN) {
|
||||
// XOR full 16-byte block directly from the output buffer.
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_GHASH + i)] ^= out[(short) (outOff + consumed + i)];
|
||||
}
|
||||
consumed += BLOCK_LEN;
|
||||
} else {
|
||||
// Partial block: XOR the available bytes, implicit zero-pad for the rest.
|
||||
for (short i = 0; i < chunk; i++) {
|
||||
scratch[(short) (OFF_GHASH + i)] ^= out[(short) (outOff + consumed + i)];
|
||||
}
|
||||
consumed += chunk;
|
||||
}
|
||||
gfMul(scratch, OFF_GHASH, scratch, OFF_H);
|
||||
}
|
||||
|
||||
// len_block = (8*|AAD|)^64 || (8*|C|)^64, big-endian. AAD is always empty
|
||||
// for this use case; ptLen fits in 16 bits, so only the low 2 bytes of
|
||||
// the 8-byte plaintext-bit-length field can be non-zero.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_LEN_BLK, BLOCK_LEN, (byte) 0);
|
||||
// ptLen·8 in bits, big-endian, into the last 4 bytes of the 16-byte
|
||||
// length block (bits 0-95 are zero — AAD-bits=0 + high bytes of CT-bits).
|
||||
// Java Card 3.0.5 forbids `int` literals, so we compute as `short` and
|
||||
// split the byte-shift into two stages to dodge the implicit `int`
|
||||
// promotion that the converter rejects ("unsupported int type constant").
|
||||
// ptLen ≤ 8191 ⇒ ptLen·8 ≤ 65528, fits in unsigned 16 bits. The top two
|
||||
// bytes of the length field stay zero.
|
||||
short ctBitsLo = (short) (ptLen << 3); // low 16 bits of bit-count
|
||||
short ctBitsHi = (short) (((short)(ptLen >>> 13)) & 0x07); // bits 16-18 of bit-count
|
||||
scratch[(short) (OFF_LEN_BLK + 13)] = (byte) (ctBitsHi & 0xFF);
|
||||
scratch[(short) (OFF_LEN_BLK + 14)] = (byte) ((ctBitsLo >>> 8) & 0xFF);
|
||||
scratch[(short) (OFF_LEN_BLK + 15)] = (byte) (ctBitsLo & 0xFF);
|
||||
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_GHASH + i)] ^= scratch[(short) (OFF_LEN_BLK + i)];
|
||||
}
|
||||
gfMul(scratch, OFF_GHASH, scratch, OFF_H);
|
||||
|
||||
// T = AES_K(J0) XOR GHASH
|
||||
aesEcb.doFinal(scratch, OFF_J0, BLOCK_LEN, scratch, OFF_ECB_OUT);
|
||||
short tagOut = (short) (outOff + ptLen);
|
||||
for (short i = 0; i < TAG_LEN; i++) {
|
||||
out[(short) (tagOut + i)] =
|
||||
(byte) (scratch[(short) (OFF_ECB_OUT + i)]
|
||||
^ scratch[(short) (OFF_GHASH + i)]);
|
||||
}
|
||||
|
||||
// Wipe scratch: H, J0 (with IV bytes), GHASH state, and the keystream
|
||||
// remnant in OFF_ECB_OUT all touched the AES key directly. CLEAR_ON_DESELECT
|
||||
// would clean up at deselect, but multi-APDU sessions could otherwise
|
||||
// see stale H/keystream until then.
|
||||
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
|
||||
|
||||
return (short) (ptLen + TAG_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the
|
||||
* 16-byte block, big-endian, modulo 2^32.
|
||||
*/
|
||||
private static void inc32(byte[] buf, short off) {
|
||||
short i = (short) (off + 15);
|
||||
// Carry propagation across 4 bytes.
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
buf[i]++;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place GF(2^128) multiplication per NIST SP 800-38D §6.3, storing
|
||||
* X ·_GF Y into X. Uses the bit-reflected convention:
|
||||
* <ul>
|
||||
* <li>byte 0 MSB = x^127 (highest-degree polynomial coefficient)
|
||||
* <li>byte 15 LSB = x^0
|
||||
* <li>reduction polynomial R = x^128 + x^7 + x^2 + x + 1 represented
|
||||
* as {0xE1, 0x00, ..., 0x00}
|
||||
* <li>"shift right" means shift toward higher byte indices
|
||||
* </ul>
|
||||
*
|
||||
* <p>Algorithm 1 from NIST SP 800-38D:
|
||||
* <pre>
|
||||
* Z = 0; V = Y
|
||||
* for i = 0..127:
|
||||
* if bit i of X is 1: Z ^= V
|
||||
* if lowest bit of V (byte 15 bit 0) is 1: V = (V >> 1) ^ R
|
||||
* else: V = V >> 1
|
||||
* return Z
|
||||
* </pre>
|
||||
*
|
||||
* <p>This is the simple, portable NIST shift-XOR form. A 4-bit-table
|
||||
* variant would be ~4x faster but adds 32 bytes of per-H precomputation
|
||||
* and a more intricate shift; we encrypt ~137B per AUTH1, so the extra
|
||||
* cycles are acceptable versus the complexity cost.
|
||||
*/
|
||||
private void gfMul(byte[] xBuf, short xOff, byte[] yBuf, short yOff) {
|
||||
// V = Y (copied into OFF_TMP scratch region; gets shifted in place)
|
||||
Util.arrayCopyNonAtomic(yBuf, yOff, scratch, OFF_TMP, BLOCK_LEN);
|
||||
|
||||
// Z = 0 (we'll accumulate in a second 16-byte region — reuse OFF_LEN_BLK,
|
||||
// which is free until after the main GHASH loop finishes). gfMul is
|
||||
// called both during GHASH accumulation AND during final-length mixing;
|
||||
// at final-length mixing OFF_LEN_BLK has already been consumed, so it's
|
||||
// safe to overwrite. But to avoid an aliasing bug if OFF_LEN_BLK is
|
||||
// ever reused differently, we stage Z in OFF_ECB_OUT instead.
|
||||
//
|
||||
// Wait — OFF_ECB_OUT holds the AES keystream we need later for the
|
||||
// tag (T = AES_K(J0) XOR GHASH). However, gfMul is never called
|
||||
// between producing OFF_ECB_OUT (the AES_K(J0) output) and consuming
|
||||
// it for the tag XOR. The last gfMul is for (state ^ len_block) ·_GF H,
|
||||
// and only AFTER that do we compute AES_K(J0) into OFF_ECB_OUT. So
|
||||
// using OFF_ECB_OUT as the Z accumulator is safe.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_ECB_OUT, BLOCK_LEN, (byte) 0);
|
||||
|
||||
for (short byteIdx = 0; byteIdx < BLOCK_LEN; byteIdx++) {
|
||||
byte xByte = xBuf[(short) (xOff + byteIdx)];
|
||||
for (short bit = 7; bit >= 0; bit--) {
|
||||
// bit of X at polynomial position (byteIdx*8 + (7-bit)): byte-high bit first.
|
||||
if (((xByte >> bit) & 0x01) != 0) {
|
||||
for (short k = 0; k < BLOCK_LEN; k++) {
|
||||
scratch[(short) (OFF_ECB_OUT + k)] ^= scratch[(short) (OFF_TMP + k)];
|
||||
}
|
||||
}
|
||||
// V = V >> 1; if the bit that fell off (byte 15 low bit BEFORE shift) is 1,
|
||||
// also XOR the reduction polynomial {0xE1, 0x00, ..., 0x00}.
|
||||
boolean lsb = (scratch[(short) (OFF_TMP + 15)] & 0x01) != 0;
|
||||
// Shift V right by 1 bit (toward higher byte indices).
|
||||
short carry = 0;
|
||||
for (short k = 0; k < BLOCK_LEN; k++) {
|
||||
short cur = (short) (scratch[(short) (OFF_TMP + k)] & 0xFF);
|
||||
short newCur = (short) ((cur >>> 1) | carry);
|
||||
carry = (short) ((cur & 0x01) << 7);
|
||||
scratch[(short) (OFF_TMP + k)] = (byte) newCur;
|
||||
}
|
||||
if (lsb) {
|
||||
scratch[OFF_TMP] ^= (byte) 0xE1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy Z back into X in place.
|
||||
Util.arrayCopyNonAtomic(scratch, OFF_ECB_OUT, xBuf, xOff, BLOCK_LEN);
|
||||
}
|
||||
}
|
||||
110
applet/src/main/java/com/dangerousthings/aliro/AliroHmac.java
Normal file
110
applet/src/main/java/com/dangerousthings/aliro/AliroHmac.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.JCSystem;
|
||||
import javacard.framework.Util;
|
||||
import javacard.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* Userland HMAC-SHA-256 per RFC 2104, built on top of the JC
|
||||
* {@link MessageDigest#ALG_SHA_256} primitive. Needed because the target card
|
||||
* (NXP J3R180) does not expose {@code KeyBuilder.TYPE_HMAC} at any tested key
|
||||
* size (512/256/160/128 bits) nor {@code Signature.ALG_HMAC_SHA_256}, while
|
||||
* raw SHA-256 is present and functional.
|
||||
*
|
||||
* <p>Each {@link #compute} call reads a key and a message, zero-pads the key
|
||||
* to the SHA-256 block size (64 bytes) — first hashing the key down with
|
||||
* SHA-256 if it exceeds the block size — then computes:
|
||||
* <pre>
|
||||
* HMAC(K, M) = SHA-256((K_pad XOR opad) || SHA-256((K_pad XOR ipad) || M))
|
||||
* </pre>
|
||||
* with {@code ipad = 0x36 × 64} and {@code opad = 0x5C × 64}. Output is
|
||||
* always 32 bytes.
|
||||
*
|
||||
* <p>Intended to be instantiated once per applet (or test) and reused — the
|
||||
* underlying {@link MessageDigest} is expensive to allocate.
|
||||
*/
|
||||
final class AliroHmac {
|
||||
|
||||
private static final short BLOCK_LEN = 64; // SHA-256 block size
|
||||
private static final short HASH_LEN = 32; // SHA-256 output size
|
||||
|
||||
private static final byte IPAD_BYTE = (byte) 0x36;
|
||||
private static final byte OPAD_BYTE = (byte) 0x5C;
|
||||
|
||||
// Scratch layout: 64B pad block || 32B inner digest = 96B total.
|
||||
private static final short OFF_PAD = 0;
|
||||
private static final short OFF_INNER = 64;
|
||||
private static final short SCRATCH_LEN = 96;
|
||||
|
||||
/** Persistent SHA-256 digest. Always reset before reuse — JC
|
||||
* {@code MessageDigest} does not auto-reset after {@link MessageDigest#doFinal}. */
|
||||
private final MessageDigest sha256;
|
||||
|
||||
/** Transient scratch — see OFF_* constants above. */
|
||||
private final byte[] scratch;
|
||||
|
||||
AliroHmac() {
|
||||
sha256 = MessageDigest.getInstance(MessageDigest.ALG_SHA_256, false);
|
||||
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA-256(key, msg). Writes 32 bytes to {@code out[outOff..outOff+32)}
|
||||
* and returns 32. The key is copied into internal scratch before the first
|
||||
* hash, and the message is consumed during the inner SHA-256 pass before
|
||||
* anything is written to {@code out}, so the {@code key}, {@code msg}, and
|
||||
* {@code out} buffers may alias each other freely.
|
||||
*/
|
||||
short compute(
|
||||
byte[] key, short keyOff, short keyLen,
|
||||
byte[] msg, short msgOff, short msgLen,
|
||||
byte[] out, short outOff) {
|
||||
|
||||
// --- Build K_pad into scratch[OFF_PAD..OFF_PAD+64) ---
|
||||
// Zero the 64-byte pad region first so any unused key-tail bytes stay 0.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_PAD, BLOCK_LEN, (byte) 0);
|
||||
|
||||
if (keyLen > BLOCK_LEN) {
|
||||
// K' = SHA-256(K), 32 bytes; the remaining 32 bytes stay zero.
|
||||
sha256.reset();
|
||||
sha256.doFinal(key, keyOff, keyLen, scratch, OFF_PAD);
|
||||
} else if (keyLen > 0) {
|
||||
Util.arrayCopyNonAtomic(key, keyOff, scratch, OFF_PAD, keyLen);
|
||||
}
|
||||
// else keyLen == 0: scratch[OFF_PAD..OFF_PAD+64) stays all zeros.
|
||||
|
||||
// --- Inner: SHA-256((K_pad XOR ipad) || M) into scratch[OFF_INNER..) ---
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_PAD + i)] ^= IPAD_BYTE;
|
||||
}
|
||||
sha256.reset();
|
||||
sha256.update(scratch, OFF_PAD, BLOCK_LEN);
|
||||
if (msgLen > 0) {
|
||||
sha256.doFinal(msg, msgOff, msgLen, scratch, OFF_INNER);
|
||||
} else {
|
||||
// doFinal with length 0 — pass the same buffer as a harmless source.
|
||||
sha256.doFinal(scratch, OFF_PAD, (short) 0, scratch, OFF_INNER);
|
||||
}
|
||||
|
||||
// --- Recover K_pad from (K_pad XOR ipad), then XOR opad. ---
|
||||
// (K_pad XOR ipad) XOR (ipad XOR opad) = K_pad XOR opad, so one pass
|
||||
// over the block suffices. ipad XOR opad = 0x36 XOR 0x5C = 0x6A.
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_PAD + i)] ^= (byte) 0x6A;
|
||||
}
|
||||
|
||||
// --- Outer: SHA-256((K_pad XOR opad) || inner) into out[outOff..) ---
|
||||
sha256.reset();
|
||||
sha256.update(scratch, OFF_PAD, BLOCK_LEN);
|
||||
sha256.doFinal(scratch, OFF_INNER, HASH_LEN, out, outOff);
|
||||
|
||||
// Wipe scratch — it held K_pad (derived from the HMAC key) and the
|
||||
// inner digest, both of which would leak key-dependent state to a
|
||||
// later HMAC invocation on a different key otherwise. CLEAR_ON_DESELECT
|
||||
// cleans up at deselect, but multi-APDU sessions could see stale
|
||||
// state until then.
|
||||
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
|
||||
|
||||
return HASH_LEN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
/**
|
||||
* Shared, package-private provisioning store. Holds the Access Credential
|
||||
* private key and (eventually) reader public keys. The
|
||||
* {@link PersonalizationApplet} writes into it; {@link AliroApplet} reads
|
||||
* from it when it needs long-term keys for AUTH1 / step-up.
|
||||
*
|
||||
* <p>Two applets in the same CAP file share this class's static state
|
||||
* directly — no {@code Shareable} interface needed.
|
||||
*
|
||||
* <p>After {@link #commit()} is called, further writes via the
|
||||
* personalization interface are refused. The only way to re-unlock is
|
||||
* a factory reset, which is not yet implemented.
|
||||
*/
|
||||
final class CredentialStore {
|
||||
|
||||
static final short CRED_PRIV_KEY_LEN = 32;
|
||||
static final short CRED_PUBK_LEN = 64;
|
||||
static final short READER_PUBK_LEN = 64;
|
||||
|
||||
/** Max Access Document blob size (serialized COSE_Sign1 bytes). 1 KB
|
||||
* accommodates a typical Aliro Access Document with room to spare. */
|
||||
static final short ACCESS_DOC_MAX_LEN = 1024;
|
||||
|
||||
/** Lazily initialized in {@link #get()}. Java Card bans {@code new} in
|
||||
* static initializers, so we can't declare {@code = new CredentialStore()}. */
|
||||
private static CredentialStore INSTANCE;
|
||||
|
||||
private final byte[] credentialPrivKey;
|
||||
private boolean credentialPrivKeySet;
|
||||
|
||||
private final byte[] credentialPubKey;
|
||||
private boolean credentialPubKeySet;
|
||||
|
||||
private final byte[] readerPubKey;
|
||||
private boolean readerPubKeySet;
|
||||
|
||||
private final byte[] accessDocument;
|
||||
private short accessDocumentLen;
|
||||
private boolean accessDocumentFinalized;
|
||||
|
||||
private boolean committed;
|
||||
|
||||
private CredentialStore() {
|
||||
credentialPrivKey = new byte[CRED_PRIV_KEY_LEN];
|
||||
credentialPubKey = new byte[CRED_PUBK_LEN];
|
||||
readerPubKey = new byte[READER_PUBK_LEN];
|
||||
accessDocument = new byte[ACCESS_DOC_MAX_LEN];
|
||||
}
|
||||
|
||||
static CredentialStore get() {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = new CredentialStore();
|
||||
}
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
boolean isCommitted() {
|
||||
return committed;
|
||||
}
|
||||
|
||||
void commit() {
|
||||
committed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true iff a credential private key has been written. Callers
|
||||
* that need the actual bytes should use {@link #copyCredentialPrivKey}.
|
||||
*/
|
||||
boolean hasCredentialPrivKey() {
|
||||
return credentialPrivKeySet;
|
||||
}
|
||||
|
||||
void setCredentialPrivKey(byte[] src, short off) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
src, off, credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN);
|
||||
credentialPrivKeySet = true;
|
||||
}
|
||||
|
||||
short copyCredentialPrivKey(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
credentialPrivKey, (short) 0, dst, dstOff, CRED_PRIV_KEY_LEN);
|
||||
return CRED_PRIV_KEY_LEN;
|
||||
}
|
||||
|
||||
boolean hasCredentialPubKey() {
|
||||
return credentialPubKeySet;
|
||||
}
|
||||
|
||||
void setCredentialPubKey(byte[] src, short off) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
src, off, credentialPubKey, (short) 0, CRED_PUBK_LEN);
|
||||
credentialPubKeySet = true;
|
||||
}
|
||||
|
||||
short copyCredentialPubKey(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
credentialPubKey, (short) 0, dst, dstOff, CRED_PUBK_LEN);
|
||||
return CRED_PUBK_LEN;
|
||||
}
|
||||
|
||||
/** Copies only the 32-byte x-coordinate of credential_PubK (spec uses x-only in salt_volatile). */
|
||||
short copyCredentialPubKeyX(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
credentialPubKey, (short) 0, dst, dstOff, (short) 32);
|
||||
return (short) 32;
|
||||
}
|
||||
|
||||
boolean hasReaderPubKey() {
|
||||
return readerPubKeySet;
|
||||
}
|
||||
|
||||
void setReaderPubKey(byte[] src, short off) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
src, off, readerPubKey, (short) 0, READER_PUBK_LEN);
|
||||
readerPubKeySet = true;
|
||||
}
|
||||
|
||||
short copyReaderPubKey(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
readerPubKey, (short) 0, dst, dstOff, READER_PUBK_LEN);
|
||||
return READER_PUBK_LEN;
|
||||
}
|
||||
|
||||
/** Copies only the 32-byte x-coordinate of the reader_PubK. */
|
||||
short copyReaderPubKeyX(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
readerPubKey, (short) 0, dst, dstOff, (short) 32);
|
||||
return (short) 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an Access Document chunk at the given destination offset.
|
||||
* Returns true iff the write stayed within {@link #ACCESS_DOC_MAX_LEN};
|
||||
* otherwise the store is unchanged.
|
||||
*/
|
||||
boolean writeAccessDocumentChunk(byte[] src, short srcOff, short dstOff, short len) {
|
||||
if ((short) (dstOff + len) > ACCESS_DOC_MAX_LEN || dstOff < 0 || len < 0) {
|
||||
return false;
|
||||
}
|
||||
javacard.framework.Util.arrayCopyNonAtomic(src, srcOff, accessDocument, dstOff, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the Access Document as provisioned with {@code totalLen} bytes
|
||||
* of valid content starting at offset 0. Returns false (and does not
|
||||
* mutate state) if {@code totalLen} is outside {@code [0, ACCESS_DOC_MAX_LEN]}.
|
||||
*/
|
||||
boolean finalizeAccessDocument(short totalLen) {
|
||||
if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) {
|
||||
return false;
|
||||
}
|
||||
accessDocumentLen = totalLen;
|
||||
accessDocumentFinalized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean hasAccessDocument() {
|
||||
return accessDocumentFinalized;
|
||||
}
|
||||
|
||||
short getAccessDocumentLen() {
|
||||
return accessDocumentLen;
|
||||
}
|
||||
|
||||
short copyAccessDocument(byte[] dst, short dstOff, short srcOff, short len) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(accessDocument, srcOff, dst, dstOff, len);
|
||||
return len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only hook. The store is a static singleton, which makes unit
|
||||
* tests interfere with each other unless each test starts from a
|
||||
* known-blank slate. Package-private and named for clarity.
|
||||
*/
|
||||
void resetForTesting() {
|
||||
javacard.framework.Util.arrayFillNonAtomic(credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN, (byte) 0);
|
||||
javacard.framework.Util.arrayFillNonAtomic(credentialPubKey, (short) 0, CRED_PUBK_LEN, (byte) 0);
|
||||
javacard.framework.Util.arrayFillNonAtomic(readerPubKey, (short) 0, READER_PUBK_LEN, (byte) 0);
|
||||
javacard.framework.Util.arrayFillNonAtomic(accessDocument, (short) 0, ACCESS_DOC_MAX_LEN, (byte) 0);
|
||||
credentialPrivKeySet = false;
|
||||
credentialPubKeySet = false;
|
||||
readerPubKeySet = false;
|
||||
accessDocumentLen = 0;
|
||||
accessDocumentFinalized = false;
|
||||
committed = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.APDU;
|
||||
import javacard.framework.Applet;
|
||||
import javacard.framework.ISO7816;
|
||||
import javacard.framework.ISOException;
|
||||
|
||||
/**
|
||||
* Receives Access Credential long-term private key, reader public keys,
|
||||
* and (later) the pre-signed Access Document bytes. Accepts writes only
|
||||
* until {@link CredentialStore#commit()}; afterwards every write returns
|
||||
* SW_CONDITIONS_NOT_SATISFIED.
|
||||
*
|
||||
* <p>This applet lives under {@link AliroAids#PROVISIONING}, a
|
||||
* DangerousThings-proprietary AID — it is not part of the Aliro spec
|
||||
* surface.
|
||||
*/
|
||||
public class PersonalizationApplet extends Applet {
|
||||
|
||||
private static final byte CLA = (byte) 0x80;
|
||||
|
||||
private static final byte INS_SET_CREDENTIAL_PRIV_KEY = (byte) 0x20;
|
||||
private static final byte INS_SET_CREDENTIAL_PUBK = (byte) 0x21;
|
||||
private static final byte INS_SET_READER_PUBK = (byte) 0x22;
|
||||
private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23;
|
||||
private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24;
|
||||
private static final byte INS_COMMIT = (byte) 0x2C;
|
||||
|
||||
public static void install(byte[] bArray, short bOffset, byte bLength) {
|
||||
PersonalizationApplet applet = new PersonalizationApplet();
|
||||
if (bArray == null || bLength == 0) {
|
||||
applet.register();
|
||||
} else {
|
||||
applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void process(APDU apdu) {
|
||||
if (selectingApplet()) {
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] buf = apdu.getBuffer();
|
||||
if (buf[ISO7816.OFFSET_CLA] != CLA) {
|
||||
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
CredentialStore store = CredentialStore.get();
|
||||
byte ins = buf[ISO7816.OFFSET_INS];
|
||||
|
||||
switch (ins) {
|
||||
case INS_SET_CREDENTIAL_PRIV_KEY:
|
||||
requireUnlocked(store);
|
||||
setCredentialPrivKey(apdu, store);
|
||||
return;
|
||||
case INS_SET_CREDENTIAL_PUBK:
|
||||
requireUnlocked(store);
|
||||
setCredentialPubKey(apdu, store);
|
||||
return;
|
||||
case INS_SET_READER_PUBK:
|
||||
requireUnlocked(store);
|
||||
setReaderPubKey(apdu, store);
|
||||
return;
|
||||
case INS_WRITE_ACCESS_DOC:
|
||||
requireUnlocked(store);
|
||||
writeAccessDocChunk(apdu, store);
|
||||
return;
|
||||
case INS_FINALIZE_ACCESS_DOC:
|
||||
requireUnlocked(store);
|
||||
finalizeAccessDoc(apdu, store);
|
||||
return;
|
||||
case INS_COMMIT:
|
||||
requireUnlocked(store);
|
||||
store.commit();
|
||||
return;
|
||||
default:
|
||||
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireUnlocked(CredentialStore store) {
|
||||
if (store.isCommitted()) {
|
||||
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
|
||||
}
|
||||
}
|
||||
|
||||
private void setCredentialPrivKey(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
if (lc != CredentialStore.CRED_PRIV_KEY_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
byte[] buf = apdu.getBuffer();
|
||||
store.setCredentialPrivKey(buf, apdu.getOffsetCdata());
|
||||
}
|
||||
|
||||
private void setCredentialPubKey(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
if (lc != CredentialStore.CRED_PUBK_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
byte[] buf = apdu.getBuffer();
|
||||
store.setCredentialPubKey(buf, apdu.getOffsetCdata());
|
||||
}
|
||||
|
||||
private void setReaderPubKey(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
if (lc != CredentialStore.READER_PUBK_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
byte[] buf = apdu.getBuffer();
|
||||
store.setReaderPubKey(buf, apdu.getOffsetCdata());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an Access Document chunk. P1|P2 is the destination offset
|
||||
* within the stored Access Document (big-endian unsigned 16-bit); the
|
||||
* command data field is the chunk bytes.
|
||||
*/
|
||||
private void writeAccessDocChunk(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
byte[] buf = apdu.getBuffer();
|
||||
short dstOff = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
|
||||
| (buf[ISO7816.OFFSET_P2] & 0xFF));
|
||||
boolean ok = store.writeAccessDocumentChunk(
|
||||
buf, apdu.getOffsetCdata(), dstOff, lc);
|
||||
if (!ok) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the Access Document as provisioned. P1|P2 is the total length
|
||||
* (big-endian unsigned 16-bit). Lc must be 0.
|
||||
*/
|
||||
private void finalizeAccessDoc(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
if (lc != 0) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
byte[] buf = apdu.getBuffer();
|
||||
short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
|
||||
| (buf[ISO7816.OFFSET_P2] & 0xFF));
|
||||
if (!store.finalizeAccessDocument(totalLen)) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.JCSystem;
|
||||
import javacard.framework.Util;
|
||||
|
||||
/**
|
||||
* Cross-applet session state that survives an AID change (EXPEDITED →
|
||||
* STEP_UP) but not a card reset. Since Java Card installs distinct
|
||||
* applet instances per AID, {@link AliroApplet} and {@link StepUpApplet}
|
||||
* need a shared holder to hand off {@code StepUpSK}, the session-bound
|
||||
* 32-byte key derived during AUTH1 and consumed in the step-up phase.
|
||||
*
|
||||
* <p>Storage is a {@link JCSystem#CLEAR_ON_RESET} transient array,
|
||||
* lazily allocated on first access so that neither applet's constructor
|
||||
* assumes a particular install order. The reference is a static field;
|
||||
* both applets see the same underlying array.
|
||||
*
|
||||
* <p>State machine:
|
||||
* <ul>
|
||||
* <li>Initial / post-reset: disarmed, {@code StepUpSK} zeroed.
|
||||
* <li>{@link AliroApplet} SELECT or failure path: calls
|
||||
* {@link #disarmStepUp()} to clear any stale state.
|
||||
* <li>{@link AliroApplet} AUTH1 success: calls
|
||||
* {@link #armStepUp(byte[], short)} to load the freshly-derived
|
||||
* StepUpSK and flip the armed flag.
|
||||
* <li>{@link StepUpApplet}: reads via {@link #isStepUpArmed()} + (future)
|
||||
* an internal {@code copyStepUpSK} call to derive session keys.
|
||||
* </ul>
|
||||
*/
|
||||
final class SessionContext {
|
||||
|
||||
static final short STEP_UP_SK_LEN = 32;
|
||||
|
||||
private static byte[] stepUpSK;
|
||||
private static boolean[] armedFlag; // length 1 — boolean transient arrays are per-JC convention
|
||||
|
||||
private SessionContext() {}
|
||||
|
||||
/** Allocates the transient storage on first call. Subsequent calls are no-ops. */
|
||||
static void ensureInitialized() {
|
||||
if (stepUpSK == null) {
|
||||
stepUpSK = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_RESET);
|
||||
armedFlag = JCSystem.makeTransientBooleanArray((short) 1, JCSystem.CLEAR_ON_RESET);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isStepUpArmed() {
|
||||
return armedFlag != null && armedFlag[0];
|
||||
}
|
||||
|
||||
/** Loads {@code StepUpSK} from {@code src[srcOff..srcOff+32)} and arms the context. */
|
||||
static void armStepUp(byte[] src, short srcOff) {
|
||||
ensureInitialized();
|
||||
Util.arrayCopyNonAtomic(src, srcOff, stepUpSK, (short) 0, STEP_UP_SK_LEN);
|
||||
armedFlag[0] = true;
|
||||
}
|
||||
|
||||
/** Zeroes {@code StepUpSK} and flips the armed flag off. */
|
||||
static void disarmStepUp() {
|
||||
if (stepUpSK != null) {
|
||||
Util.arrayFillNonAtomic(stepUpSK, (short) 0, STEP_UP_SK_LEN, (byte) 0);
|
||||
}
|
||||
if (armedFlag != null) {
|
||||
armedFlag[0] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the armed StepUpSK into {@code dst[dstOff..]}. Only legal if {@link #isStepUpArmed()}. */
|
||||
static short copyStepUpSK(byte[] dst, short dstOff) {
|
||||
Util.arrayCopyNonAtomic(stepUpSK, (short) 0, dst, dstOff, STEP_UP_SK_LEN);
|
||||
return STEP_UP_SK_LEN;
|
||||
}
|
||||
|
||||
/** Test-only accessor that skips the armed-flag precondition. */
|
||||
static short copyStepUpSKForTesting(byte[] dst, short dstOff) {
|
||||
return copyStepUpSK(dst, dstOff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.APDU;
|
||||
import javacard.framework.Applet;
|
||||
import javacard.framework.ISO7816;
|
||||
import javacard.framework.ISOException;
|
||||
import javacard.framework.Util;
|
||||
|
||||
/**
|
||||
* Scaffold for the Aliro step-up phase (spec §8.4). Lives under
|
||||
* {@link AliroAids#STEP_UP}.
|
||||
*
|
||||
* <p>This first pass only answers SELECT with a minimal FCI and rejects
|
||||
* unknown commands cleanly. The actual step-up protocol — mdoc
|
||||
* SessionData framing (DeviceRequest/DeviceResponse CBOR with
|
||||
* Aliro-remapped integer keys per Tables 8-21/8-22), ENVELOPE/GET
|
||||
* RESPONSE command chaining, and a fresh AES-256-GCM session keyed by
|
||||
* StepUpSKDevice/StepUpSKReader derived per spec §8.4.3 — comes in
|
||||
* follow-up iterations.
|
||||
*
|
||||
* <p>When that work lands, this applet will need access to the
|
||||
* {@code StepUpSK} (a 32-byte session-bound value produced by
|
||||
* {@link AliroApplet}'s derivation in §8.3.1.13). Since Java Card
|
||||
* installs distinct instances per AID, that state will be exchanged via
|
||||
* a shared package-private holder (not yet implemented).
|
||||
*/
|
||||
public class StepUpApplet extends Applet {
|
||||
|
||||
private static final byte CLA_PROPRIETARY = (byte) 0x80;
|
||||
|
||||
public static void install(byte[] bArray, short bOffset, byte bLength) {
|
||||
StepUpApplet applet = new StepUpApplet();
|
||||
if (bArray == null || bLength == 0) {
|
||||
applet.register();
|
||||
} else {
|
||||
applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean select() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void process(APDU apdu) {
|
||||
if (selectingApplet()) {
|
||||
sendStepUpFci(apdu);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] buf = apdu.getBuffer();
|
||||
if (buf[ISO7816.OFFSET_CLA] != CLA_PROPRIETARY) {
|
||||
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
// Until the mdoc DeviceRequest pipeline lands, every proprietary INS
|
||||
// is unrecognised. ENVELOPE + GET RESPONSE handlers plug in here.
|
||||
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal FCI for step-up SELECT. The spec (§10.2.1.2) allows the UD to
|
||||
* advertise supported APDU command/response sizes here; those get added
|
||||
* when we implement ENVELOPE/GET RESPONSE.
|
||||
*
|
||||
* <pre>
|
||||
* 6F L
|
||||
* 84 09 [step-up AID]
|
||||
* </pre>
|
||||
*/
|
||||
private void sendStepUpFci(APDU apdu) {
|
||||
byte[] buf = apdu.getBuffer();
|
||||
short off = 0;
|
||||
buf[off++] = 0x6F;
|
||||
short outerLenPos = off++;
|
||||
|
||||
buf[off++] = (byte) 0x84;
|
||||
buf[off++] = (byte) AliroAids.STEP_UP.length;
|
||||
off = Util.arrayCopyNonAtomic(AliroAids.STEP_UP, (short) 0,
|
||||
buf, off, (short) AliroAids.STEP_UP.length);
|
||||
|
||||
buf[outerLenPos] = (byte) (off - 2);
|
||||
apdu.setOutgoingAndSend((short) 0, off);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import com.licel.jcardsim.smartcardio.CardSimulator;
|
||||
import java.security.KeyPair;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import javacard.framework.AID;
|
||||
import javax.smartcardio.CommandAPDU;
|
||||
import javax.smartcardio.ResponseAPDU;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* AUTH0 happy-path tests for the Aliro expedited-standard phase (spec §8.3.3.2).
|
||||
*/
|
||||
class AliroAppletAuth0Test {
|
||||
|
||||
private CardSimulator sim;
|
||||
private SecureRandom rng;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sim = new CardSimulator();
|
||||
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
|
||||
sim.installApplet(expeditedAid, AliroApplet.class);
|
||||
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
|
||||
ResponseAPDU selectResp = sim.transmitCommand(select);
|
||||
assertEquals(0x9000, selectResp.getSW(), "SELECT must succeed before AUTH0");
|
||||
|
||||
rng = new SecureRandom();
|
||||
}
|
||||
|
||||
private ResponseAPDU sendStandardAuth0() {
|
||||
KeyPair readerEphem = Auth0Command.generateEphemeralKeyPair();
|
||||
byte[] readerPub = Auth0Command.encodeUncompressed((ECPublicKey) readerEphem.getPublic());
|
||||
byte[] groupId = new byte[16];
|
||||
byte[] subId = new byte[16];
|
||||
byte[] txnId = new byte[16];
|
||||
rng.nextBytes(groupId);
|
||||
rng.nextBytes(subId);
|
||||
rng.nextBytes(txnId);
|
||||
|
||||
byte[] data = Auth0Command.buildStandardData(readerPub, groupId, subId, txnId);
|
||||
CommandAPDU auth0 = new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF,
|
||||
Auth0Command.INS & 0xFF,
|
||||
0x00, 0x00, data, 256);
|
||||
return sim.transmitCommand(auth0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth0StandardReturns9000() {
|
||||
assertEquals(0x9000, sendStandardAuth0().getSW(),
|
||||
"AUTH0 with a well-formed standard-phase command must succeed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth0ResponseContainsCredentialEphemeralPubKeyTag() {
|
||||
byte[] data = sendStandardAuth0().getData();
|
||||
byte[] credEphemPub = TlvUtil.findTopLevel(data, 0x86);
|
||||
assertNotNull(credEphemPub, "AUTH0 response must contain tag 0x86 (credential_ePubK)");
|
||||
assertEquals(65, credEphemPub.length, "credential_ePubK must be 65 bytes (0x04 || x || y)");
|
||||
assertEquals(0x04, credEphemPub[0] & 0xFF,
|
||||
"credential_ePubK must start with 0x04 (uncompressed point)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void credentialEphemeralKeyIsFreshPerTransaction() {
|
||||
byte[] first = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
|
||||
byte[] second = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
|
||||
|
||||
assertNotNull(first);
|
||||
assertNotNull(second);
|
||||
assertNotEquals(
|
||||
bytesToHex(first),
|
||||
bytesToHex(second),
|
||||
"each AUTH0 must produce a fresh ephemeral keypair");
|
||||
}
|
||||
|
||||
@Test
|
||||
void credentialEphemeralKeyIsNotAllZero() {
|
||||
byte[] credPub = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
|
||||
boolean allZero = true;
|
||||
for (int i = 1; i < credPub.length; i++) {
|
||||
if (credPub[i] != 0) { allZero = false; break; }
|
||||
}
|
||||
assertFalse(allZero, "credential_ePubK coordinates must not be all-zero");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth0WithEmptyDataFieldFails() {
|
||||
CommandAPDU auth0 = new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, new byte[0], 256);
|
||||
ResponseAPDU resp = sim.transmitCommand(auth0);
|
||||
assertNotEquals(0x9000, resp.getSW(),
|
||||
"AUTH0 with empty data must not succeed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth0MissingReaderEphemPubKeyTagFails() {
|
||||
byte[] data = validAuth0DataWithMutation(bytes -> {
|
||||
// Null out the 0x87 tag byte so the parser sees a bogus tag where reader_ePubK should be.
|
||||
int idx = findTagOffset(bytes, 0x87);
|
||||
bytes[idx] = (byte) 0x00;
|
||||
return bytes;
|
||||
});
|
||||
CommandAPDU auth0 = new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
|
||||
assertNotEquals(0x9000, sim.transmitCommand(auth0).getSW(),
|
||||
"AUTH0 without tag 0x87 (reader_ePubK) must fail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth0WithUnsupportedProtocolVersionFails() {
|
||||
byte[] data = validAuth0DataWithMutation(bytes -> {
|
||||
int idx = findTagOffset(bytes, 0x5C);
|
||||
bytes[idx + 2] = (byte) 0x00;
|
||||
bytes[idx + 3] = (byte) 0x99;
|
||||
return bytes;
|
||||
});
|
||||
CommandAPDU auth0 = new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
|
||||
assertNotEquals(0x9000, sim.transmitCommand(auth0).getSW(),
|
||||
"AUTH0 with protocol version != 0x0100 must fail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth0WithUnknownTrailingTagIsAccepted() {
|
||||
// Spec §1.4.3: unknown DER-TLVs SHALL be accepted by the receiver.
|
||||
byte[] base = validAuth0Data();
|
||||
byte[] withUnknown = new byte[base.length + 3];
|
||||
System.arraycopy(base, 0, withUnknown, 0, base.length);
|
||||
withUnknown[base.length] = (byte) 0x7F;
|
||||
withUnknown[base.length + 1] = (byte) 0x01;
|
||||
withUnknown[base.length + 2] = (byte) 0xFF;
|
||||
|
||||
CommandAPDU auth0 = new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, withUnknown, 256);
|
||||
assertEquals(0x9000, sim.transmitCommand(auth0).getSW(),
|
||||
"unknown trailing TLVs must be accepted (spec §1.4.3)");
|
||||
}
|
||||
|
||||
private byte[] validAuth0Data() {
|
||||
KeyPair readerEphem = Auth0Command.generateEphemeralKeyPair();
|
||||
byte[] readerPub = Auth0Command.encodeUncompressed((ECPublicKey) readerEphem.getPublic());
|
||||
byte[] groupId = new byte[16];
|
||||
byte[] subId = new byte[16];
|
||||
byte[] txnId = new byte[16];
|
||||
rng.nextBytes(groupId);
|
||||
rng.nextBytes(subId);
|
||||
rng.nextBytes(txnId);
|
||||
return Auth0Command.buildStandardData(readerPub, groupId, subId, txnId);
|
||||
}
|
||||
|
||||
private byte[] validAuth0DataWithMutation(java.util.function.Function<byte[], byte[]> mut) {
|
||||
return mut.apply(validAuth0Data());
|
||||
}
|
||||
|
||||
private static int findTagOffset(byte[] data, int tag) {
|
||||
int i = 0;
|
||||
while (i < data.length) {
|
||||
int t = data[i] & 0xFF;
|
||||
int len = data[i + 1] & 0xFF;
|
||||
if (t == tag) return i;
|
||||
i += 2 + len;
|
||||
}
|
||||
throw new AssertionError("tag 0x" + Integer.toHexString(tag) + " not found");
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] b) {
|
||||
StringBuilder sb = new StringBuilder(b.length * 2);
|
||||
for (byte x : b) sb.append(String.format("%02x", x));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import com.licel.jcardsim.smartcardio.CardSimulator;
|
||||
import java.security.KeyPair;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import javacard.framework.AID;
|
||||
import javax.smartcardio.CommandAPDU;
|
||||
import javax.smartcardio.ResponseAPDU;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
/**
|
||||
* AUTH1 tests. Covers state-machine sequencing, input TLV validation, and
|
||||
* provisioning preconditions. Real cryptographic verification of the
|
||||
* reader signature and construction of the encrypted response come in
|
||||
* later iterations.
|
||||
*/
|
||||
class AliroAppletAuth1Test {
|
||||
|
||||
private static final byte INS_AUTH1 = (byte) 0x81;
|
||||
|
||||
private CardSimulator sim;
|
||||
private KeyPair credentialKeyPair;
|
||||
private ReaderSide reader;
|
||||
private byte[] credentialEphemPubKey; // captured from the last AUTH0 response
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
CredentialStore.get().resetForTesting();
|
||||
sim = new CardSimulator();
|
||||
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
|
||||
sim.installApplet(expeditedAid, AliroApplet.class);
|
||||
|
||||
credentialKeyPair = Auth0Command.generateEphemeralKeyPair();
|
||||
reader = new ReaderSide();
|
||||
reader.provision(sim, credentialKeyPair);
|
||||
|
||||
selectExpedited();
|
||||
}
|
||||
|
||||
private void selectExpedited() {
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
|
||||
ResponseAPDU r = sim.transmitCommand(select);
|
||||
assertEquals(0x9000, r.getSW(), "SELECT must succeed");
|
||||
}
|
||||
|
||||
/** Runs a standard-phase AUTH0 and captures the returned credential_ePubK for later AUTH1 use. */
|
||||
private void sendStandardAuth0() {
|
||||
reader.startTransaction();
|
||||
byte[] data = reader.buildAuth0Data();
|
||||
CommandAPDU auth0 = new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
|
||||
ResponseAPDU r = sim.transmitCommand(auth0);
|
||||
assertEquals(0x9000, r.getSW(), "AUTH0 must succeed as a precondition for AUTH1 tests");
|
||||
credentialEphemPubKey = TlvUtil.findTopLevel(r.getData(), 0x86);
|
||||
}
|
||||
|
||||
/** AUTH1 command with a correctly-signed reader signature. */
|
||||
private ResponseAPDU sendValidAuth1() {
|
||||
return sendAuth1(reader.buildAuth1Data(credentialEphemPubKey));
|
||||
}
|
||||
|
||||
/** Syntactically-valid AUTH1 payload with zeroed signature (for TLV-only tests). */
|
||||
private ResponseAPDU sendStubAuth1() {
|
||||
byte[] data = new byte[3 + 2 + 64];
|
||||
int i = 0;
|
||||
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
|
||||
data[i++] = (byte) 0x9E; data[i++] = 0x40;
|
||||
return sendAuth1(data);
|
||||
}
|
||||
|
||||
private ResponseAPDU sendAuth1(byte[] data) {
|
||||
CommandAPDU auth1 = new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, INS_AUTH1 & 0xFF, 0x00, 0x00, data, 256);
|
||||
return sim.transmitCommand(auth1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1WithoutPriorAuth0IsRejected() {
|
||||
ResponseAPDU r = sendStubAuth1();
|
||||
assertNotEquals(0x9000, r.getSW(),
|
||||
"AUTH1 must fail unless immediately preceded by a successful AUTH0");
|
||||
assertEquals(0x6985, r.getSW(),
|
||||
"expected SW_CONDITIONS_NOT_SATISFIED (0x6985) when AUTH0 not done");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1WithValidReaderSignatureIsAccepted() {
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU r = sendValidAuth1();
|
||||
assertEquals(0x9000, r.getSW(),
|
||||
"AUTH1 with a correctly-signed reader signature must succeed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1ReplayWithoutFreshAuth0IsRejected() {
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU first = sendValidAuth1();
|
||||
assertEquals(0x9000, first.getSW());
|
||||
|
||||
// Second AUTH1 without a new AUTH0 must fail — otherwise the card would
|
||||
// re-encrypt with device_counter=1 under the same key, reusing the GCM
|
||||
// IV and catastrophically breaking confidentiality + integrity.
|
||||
ResponseAPDU replay = sendValidAuth1();
|
||||
assertEquals(0x6985, replay.getSW(),
|
||||
"AUTH1 after a prior successful AUTH1 must require a fresh AUTH0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1SuccessArmsStepUpContextWithExpectedStepUpSK() throws Exception {
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU r = sendValidAuth1();
|
||||
assertEquals(0x9000, r.getSW());
|
||||
|
||||
// Reader derives the full expedited-standard 160B key material and
|
||||
// extracts StepUpSK (offset 64..96, per spec §8.3.1.13).
|
||||
byte[] expected = java.util.Arrays.copyOfRange(
|
||||
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed(),
|
||||
"successful AUTH1 must arm the step-up context");
|
||||
byte[] got = new byte[32];
|
||||
SessionContext.copyStepUpSKForTesting(got, (short) 0);
|
||||
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, got,
|
||||
"armed StepUpSK must equal derived_keys_volatile[64..96]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshAuth0DisarmsPreviousStepUpArming() throws Exception {
|
||||
// First transaction arms the step-up context.
|
||||
sendStandardAuth0();
|
||||
assertEquals(0x9000, sendValidAuth1().getSW());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed());
|
||||
|
||||
// Starting a new transaction via AUTH0 (without re-SELECT) must
|
||||
// invalidate the prior arming — otherwise a mid-transaction failure
|
||||
// could leave stale StepUpSK reachable by a subsequent SELECT STEP_UP.
|
||||
reader.startTransaction();
|
||||
byte[] data = reader.buildAuth0Data();
|
||||
CommandAPDU auth0 = new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
|
||||
assertEquals(0x9000, sim.transmitCommand(auth0).getSW());
|
||||
org.junit.jupiter.api.Assertions.assertFalse(SessionContext.isStepUpArmed(),
|
||||
"starting a new AUTH0 must disarm any prior step-up state");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reSelectExpeditedDisarmsStepUpContext() throws Exception {
|
||||
sendStandardAuth0();
|
||||
assertEquals(0x9000, sendValidAuth1().getSW());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed());
|
||||
|
||||
selectExpedited();
|
||||
org.junit.jupiter.api.Assertions.assertFalse(SessionContext.isStepUpArmed(),
|
||||
"re-SELECT of expedited AID starts a new transaction and must disarm step-up");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1SignalingBitmapIsAllZeroWhenNoAccessDocProvisioned() throws Exception {
|
||||
// Default setUp provisions keys but no Access Document.
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU r = sendValidAuth1();
|
||||
assertEquals(0x9000, r.getSW());
|
||||
|
||||
byte[] pt = ReaderSide.decryptAuth1Response(
|
||||
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
|
||||
byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E);
|
||||
org.junit.jupiter.api.Assertions.assertArrayEquals(
|
||||
new byte[] { 0x00, 0x00 }, bitmap,
|
||||
"no Access Document → signaling_bitmap is 0x0000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1SignalingBitmapHasAccessDocBitsWhenAdProvisioned() throws Exception {
|
||||
// Provision a non-empty Access Document via CredentialStore (bypasses
|
||||
// the PersonalizationApplet INS layer — that flow is tested
|
||||
// separately). Content is opaque here; we only care that the
|
||||
// signaling_bitmap reflects presence.
|
||||
byte[] ad = new byte[120];
|
||||
for (int i = 0; i < ad.length; i++) ad[i] = (byte) (i ^ 0x5A);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
CredentialStore.get().writeAccessDocumentChunk(ad, (short) 0, (short) 0, (short) ad.length));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
CredentialStore.get().finalizeAccessDocument((short) ad.length));
|
||||
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU r = sendValidAuth1();
|
||||
assertEquals(0x9000, r.getSW());
|
||||
|
||||
byte[] pt = ReaderSide.decryptAuth1Response(
|
||||
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
|
||||
byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E);
|
||||
// bit 0 (access doc retrievable) + bit 2 (requires step-up AID on NFC)
|
||||
// = 2^0 + 2^2 = 0x0005 (big-endian)
|
||||
org.junit.jupiter.api.Assertions.assertArrayEquals(
|
||||
new byte[] { 0x00, 0x05 }, bitmap,
|
||||
"Access Document provisioned on NFC → bitmap bits 0 and 2 set (0x0005)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1WithKeySlotRequestReturnsSha1KeyIdentifier() throws Exception {
|
||||
sendStandardAuth0();
|
||||
// Build a valid AUTH1 payload, then flip command_parameters bit 0 to 0
|
||||
// (key_slot path). The reader sig is computed over Table 8-12, which
|
||||
// doesn't include command_parameters, so the sig stays valid.
|
||||
byte[] data = reader.buildAuth1Data(credentialEphemPubKey);
|
||||
data[2] = 0x00;
|
||||
ResponseAPDU r = sendAuth1(data);
|
||||
assertEquals(0x9000, r.getSW());
|
||||
|
||||
byte[] pt = ReaderSide.decryptAuth1Response(
|
||||
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
|
||||
|
||||
byte[] keySlot = TlvUtil.findTopLevel(pt, 0x4E);
|
||||
byte[] credPubTag = TlvUtil.findTopLevel(pt, 0x5A);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(keySlot, "0x4E key_slot must be present when cmd_params bit 0 = 0");
|
||||
org.junit.jupiter.api.Assertions.assertNull(credPubTag, "0x5A credential_PubK must NOT be present when cmd_params bit 0 = 0");
|
||||
assertEquals(8, keySlot.length, "key_slot is the first 8 bytes of keyIdentifier");
|
||||
|
||||
// Expected: first 8 bytes of SHA-1(uncompressed credential long-term pubkey)
|
||||
java.security.MessageDigest sha1 = java.security.MessageDigest.getInstance("SHA-1");
|
||||
byte[] credPubUncomp = new byte[65];
|
||||
credPubUncomp[0] = 0x04;
|
||||
byte[] x = toFixed32(credentialKeyPair.getPublic() instanceof java.security.interfaces.ECPublicKey
|
||||
? ((java.security.interfaces.ECPublicKey) credentialKeyPair.getPublic()).getW().getAffineX().toByteArray()
|
||||
: new byte[0]);
|
||||
byte[] y = toFixed32(((java.security.interfaces.ECPublicKey) credentialKeyPair.getPublic())
|
||||
.getW().getAffineY().toByteArray());
|
||||
System.arraycopy(x, 0, credPubUncomp, 1, 32);
|
||||
System.arraycopy(y, 0, credPubUncomp, 33, 32);
|
||||
byte[] expected = java.util.Arrays.copyOf(sha1.digest(credPubUncomp), 8);
|
||||
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, keySlot,
|
||||
"key_slot must be SHA-1(uncompressed credential_PubK)[0:8]");
|
||||
}
|
||||
|
||||
private static byte[] toFixed32(byte[] in) {
|
||||
if (in.length == 32) return in;
|
||||
byte[] out = new byte[32];
|
||||
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
|
||||
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctTransactionsProduceDistinctCiphertexts() throws Exception {
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU r1 = sendValidAuth1();
|
||||
assertEquals(0x9000, r1.getSW());
|
||||
byte[] ct1 = r1.getData();
|
||||
byte[] plaintext1 = ReaderSide.decryptAuth1Response(
|
||||
reader.deriveExpeditedSKDevice(credentialEphemPubKey), ct1);
|
||||
|
||||
// Second full transaction: fresh AUTH0 → fresh ephemerals + txn_id.
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU r2 = sendValidAuth1();
|
||||
assertEquals(0x9000, r2.getSW());
|
||||
byte[] ct2 = r2.getData();
|
||||
byte[] plaintext2 = ReaderSide.decryptAuth1Response(
|
||||
reader.deriveExpeditedSKDevice(credentialEphemPubKey), ct2);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
java.util.Arrays.equals(ct1, ct2),
|
||||
"two transactions must produce different ciphertexts — catches hardcoded salt/IV/key bugs");
|
||||
|
||||
// Plaintexts also differ (UD sig is over Table 8-13 which includes txn_id).
|
||||
byte[] sig1 = TlvUtil.findTopLevel(plaintext1, 0x9E);
|
||||
byte[] sig2 = TlvUtil.findTopLevel(plaintext2, 0x9E);
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
java.util.Arrays.equals(sig1, sig2),
|
||||
"UD signatures over Table 8-13 must differ across transactions");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1EncryptedResponseDecryptsAndVerifies() throws Exception {
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU r = sendValidAuth1();
|
||||
assertEquals(0x9000, r.getSW());
|
||||
|
||||
byte[] ctAndTag = r.getData();
|
||||
byte[] skDevice = reader.deriveExpeditedSKDevice(credentialEphemPubKey);
|
||||
byte[] plaintext = ReaderSide.decryptAuth1Response(skDevice, ctAndTag);
|
||||
|
||||
byte[] credPub = TlvUtil.findTopLevel(plaintext, 0x5A);
|
||||
byte[] udSig = TlvUtil.findTopLevel(plaintext, 0x9E);
|
||||
byte[] bitmap = TlvUtil.findTopLevel(plaintext, 0x5E);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(credPub, "Table 8-11 must include 0x5A (credential_PubK)");
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(udSig, "Table 8-11 must include 0x9E (UD signature)");
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(bitmap, "Table 8-11 must include 0x5E (signaling_bitmap)");
|
||||
assertEquals(65, credPub.length);
|
||||
assertEquals(64, udSig.length);
|
||||
assertEquals(2, bitmap.length);
|
||||
|
||||
byte[] table813 = reader.buildTable813(credentialEphemPubKey);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
ReaderSide.verifyUdSig(credPub, table813, udSig),
|
||||
"UD signature in the decrypted Table 8-11 must verify under credential_PubK over Table 8-13");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1WithInvalidReaderSignatureFails() {
|
||||
sendStandardAuth0();
|
||||
// Valid TLV structure but a random (definitely-not-matching) sig.
|
||||
byte[] data = new byte[3 + 2 + 64];
|
||||
int i = 0;
|
||||
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
|
||||
data[i++] = (byte) 0x9E; data[i++] = 0x40;
|
||||
new SecureRandom().nextBytes(java.util.Arrays.copyOfRange(data, i, i + 64));
|
||||
// Force at least one non-zero byte so the sig isn't accidentally valid.
|
||||
data[i] = 0x11;
|
||||
data[i + 1] = 0x22;
|
||||
ResponseAPDU r = sendAuth1(data);
|
||||
assertNotEquals(0x9000, r.getSW(),
|
||||
"AUTH1 with a bad reader signature must not return success");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reSelectClearsAuth0State() {
|
||||
sendStandardAuth0();
|
||||
selectExpedited();
|
||||
ResponseAPDU r = sendStubAuth1();
|
||||
assertEquals(0x6985, r.getSW(),
|
||||
"re-SELECT must invalidate previous AUTH0 state");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1MissingCommandParametersFails() {
|
||||
sendStandardAuth0();
|
||||
byte[] data = new byte[2 + 64];
|
||||
data[0] = (byte) 0x9E; data[1] = 0x40;
|
||||
assertEquals(0x6A80, sendAuth1(data).getSW(),
|
||||
"missing tag 0x41 must be rejected with SW_WRONG_DATA");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1MissingReaderSignatureFails() {
|
||||
sendStandardAuth0();
|
||||
byte[] data = new byte[] { 0x41, 0x01, 0x00 };
|
||||
assertEquals(0x6A80, sendAuth1(data).getSW(),
|
||||
"missing tag 0x9E (reader signature) must be rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1WithWrongReaderSignatureLengthFails() {
|
||||
sendStandardAuth0();
|
||||
byte[] data = new byte[3 + 2 + 63];
|
||||
int i = 0;
|
||||
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
|
||||
data[i++] = (byte) 0x9E; data[i++] = 0x3F;
|
||||
assertEquals(0x6A80, sendAuth1(data).getSW(),
|
||||
"reader signature length != 64 must be rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1WithReaderCertIsRejectedInV1() {
|
||||
sendStandardAuth0();
|
||||
byte[] data = new byte[3 + 2 + 64 + 2 + 1];
|
||||
int i = 0;
|
||||
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
|
||||
data[i++] = (byte) 0x9E; data[i++] = 0x40;
|
||||
i += 64;
|
||||
data[i++] = (byte) 0x90; data[i++] = 0x01; data[i++] = (byte) 0xFF;
|
||||
assertEquals(0x6A80, sendAuth1(data).getSW(),
|
||||
"reader_Cert (tag 0x90) is unsupported in v1 and must be rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1WithUnknownTrailingTagIsAccepted() {
|
||||
sendStandardAuth0();
|
||||
// Build a real-sig AUTH1 payload, then append an unknown TLV.
|
||||
byte[] valid = reader.buildAuth1Data(credentialEphemPubKey);
|
||||
byte[] data = new byte[valid.length + 3];
|
||||
System.arraycopy(valid, 0, data, 0, valid.length);
|
||||
int i = valid.length;
|
||||
data[i++] = (byte) 0x7F; data[i++] = 0x01; data[i++] = (byte) 0xAB;
|
||||
assertEquals(0x9000, sendAuth1(data).getSW(),
|
||||
"unknown trailing TLVs must be accepted (spec §1.4.3)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1WithoutProvisionedKeysFails() {
|
||||
CredentialStore.get().resetForTesting();
|
||||
selectExpedited();
|
||||
sendStandardAuth0();
|
||||
ResponseAPDU r = sendStubAuth1();
|
||||
assertEquals(0x6985, r.getSW(),
|
||||
"AUTH1 must fail if CredentialStore was not provisioned (6985)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import com.licel.jcardsim.smartcardio.CardSimulator;
|
||||
import javacard.framework.AID;
|
||||
import javax.smartcardio.CommandAPDU;
|
||||
import javax.smartcardio.ResponseAPDU;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests covering the Aliro Expedited Phase SELECT handler, per spec §10.2.1.
|
||||
*/
|
||||
class AliroAppletTest {
|
||||
|
||||
private CardSimulator sim;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sim = new CardSimulator();
|
||||
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
|
||||
sim.installApplet(expeditedAid, AliroApplet.class);
|
||||
}
|
||||
|
||||
private ResponseAPDU selectExpedited() {
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
|
||||
return sim.transmitCommand(select);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectExpeditedAidReturns9000() {
|
||||
assertEquals(0x9000, selectExpedited().getSW(),
|
||||
"SELECT expedited AID must return success SW");
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectExpeditedReturnsNonEmptyFci() {
|
||||
byte[] data = selectExpedited().getData();
|
||||
assertTrue(data.length > 0, "SELECT response data must be non-empty (FCI template)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectExpeditedResponseIsFciTemplate() {
|
||||
byte[] data = selectExpedited().getData();
|
||||
assertEquals(0x6F, data[0] & 0xFF, "top-level tag must be 0x6F (FCI template)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fciContainsAidTagMatchingExpeditedAid() {
|
||||
byte[] data = selectExpedited().getData();
|
||||
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
|
||||
assertNotNull(fci, "must contain FCI template");
|
||||
|
||||
byte[] aid = TlvUtil.findTopLevel(fci, 0x84);
|
||||
assertNotNull(aid, "FCI must contain AID tag 0x84");
|
||||
assertArrayEquals(AliroAids.EXPEDITED, aid, "AID tag value must equal expedited AID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fciContainsProprietaryInfoTagA5() {
|
||||
byte[] data = selectExpedited().getData();
|
||||
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
|
||||
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
|
||||
assertNotNull(propInfo, "FCI must contain Proprietary Information tag 0xA5");
|
||||
}
|
||||
|
||||
@Test
|
||||
void proprietaryInfoContainsCsaApplicationType() {
|
||||
byte[] data = selectExpedited().getData();
|
||||
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
|
||||
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
|
||||
|
||||
byte[] type = TlvUtil.findTopLevel(propInfo, 0x80);
|
||||
assertNotNull(type, "Proprietary Information must contain Type tag 0x80");
|
||||
assertArrayEquals(new byte[] { 0x00, 0x00 }, type, "Type must be 0x0000 (CSA application)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void proprietaryInfoContainsExpeditedProtocolVersion() {
|
||||
byte[] data = selectExpedited().getData();
|
||||
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
|
||||
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
|
||||
|
||||
byte[] versions = TlvUtil.findTopLevel(propInfo, 0x5C);
|
||||
assertNotNull(versions, "expedited SELECT must include protocol versions tag 0x5C");
|
||||
assertTrue(versions.length >= 2 && versions.length % 2 == 0,
|
||||
"protocol versions must be a non-empty list of 2-byte entries");
|
||||
assertEquals(0x01, versions[0] & 0xFF, "expected first protocol version high byte 0x01");
|
||||
assertEquals(0x00, versions[1] & 0xFF, "expected first protocol version low byte 0x00");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.security.AESKey;
|
||||
import javacard.security.ECPrivateKey;
|
||||
import javacard.security.ECPublicKey;
|
||||
import javacard.security.KeyBuilder;
|
||||
import javacard.security.KeyPair;
|
||||
import javacard.security.Signature;
|
||||
import javacardx.crypto.AEADCipher;
|
||||
import javacardx.crypto.Cipher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Direct tests for {@link AliroCrypto} primitives, exercising them outside
|
||||
* the applet's APDU dispatch.
|
||||
*/
|
||||
class AliroCryptoTest {
|
||||
|
||||
/** Pulled from RFC 5869 Test Case 1 (HKDF-SHA-256). */
|
||||
private static final byte[] RFC5869_T1_IKM = hex(
|
||||
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
|
||||
private static final byte[] RFC5869_T1_SALT = hex(
|
||||
"000102030405060708090a0b0c");
|
||||
private static final byte[] RFC5869_T1_INFO = hex(
|
||||
"f0f1f2f3f4f5f6f7f8f9");
|
||||
private static final byte[] RFC5869_T1_PRK = hex(
|
||||
"077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5");
|
||||
private static final byte[] RFC5869_T1_OKM_42 = hex(
|
||||
"3cb25f25faacd57a90434f64d0362f2a"
|
||||
+ "2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
|
||||
+ "34007208d5b887185865");
|
||||
|
||||
/** RFC 5869 Test Case 3 — empty salt, empty info. Guard for the
|
||||
* Aliro step-up path (§8.4.3) which specifies an empty salt. */
|
||||
private static final byte[] RFC5869_T3_IKM = hex(
|
||||
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
|
||||
private static final byte[] RFC5869_T3_PRK = hex(
|
||||
"19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04");
|
||||
private static final byte[] RFC5869_T3_OKM_42 = hex(
|
||||
"8da4e775a563c18f715f802a063c5a31"
|
||||
+ "b8a11f5c5ee1879ec3454e5f3c738d2d"
|
||||
+ "9d201395faa4b61a96c8");
|
||||
|
||||
@Test
|
||||
void ecdhIsCommutative() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
|
||||
KeyPair kp1 = freshP256();
|
||||
KeyPair kp2 = freshP256();
|
||||
|
||||
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
|
||||
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
|
||||
|
||||
byte[] x1 = new byte[32];
|
||||
crypto.computeEcdhSharedX(
|
||||
(ECPrivateKey) kp1.getPrivate(),
|
||||
pub2, (short) 0,
|
||||
x1, (short) 0);
|
||||
|
||||
byte[] x2 = new byte[32];
|
||||
crypto.computeEcdhSharedX(
|
||||
(ECPrivateKey) kp2.getPrivate(),
|
||||
pub1, (short) 0,
|
||||
x2, (short) 0);
|
||||
|
||||
assertArrayEquals(x1, x2,
|
||||
"ECDH(priv1, pub2) must equal ECDH(priv2, pub1)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hkdfExtractRfc5869TestCase1() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
byte[] prk = new byte[32];
|
||||
short prkLen = crypto.hkdfExtract(
|
||||
RFC5869_T1_SALT, (short) 0, (short) RFC5869_T1_SALT.length,
|
||||
RFC5869_T1_IKM, (short) 0, (short) RFC5869_T1_IKM.length,
|
||||
prk, (short) 0);
|
||||
assertEquals(32, prkLen, "HKDF-Extract output is HashLen (32) bytes");
|
||||
assertArrayEquals(RFC5869_T1_PRK, prk, "matches RFC 5869 Test Case 1 PRK");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hkdfWithEmptySaltAndInfoMatchesRfc5869TestCase3() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
byte[] prk = new byte[32];
|
||||
crypto.hkdfExtract(
|
||||
new byte[0], (short) 0, (short) 0,
|
||||
RFC5869_T3_IKM, (short) 0, (short) RFC5869_T3_IKM.length,
|
||||
prk, (short) 0);
|
||||
assertArrayEquals(RFC5869_T3_PRK, prk,
|
||||
"HKDF-Extract with empty salt (per RFC 5869 §2.2: key becomes HashLen zeros)");
|
||||
|
||||
byte[] okm = new byte[42];
|
||||
crypto.hkdfExpand(
|
||||
prk, (short) 0, (short) 32,
|
||||
new byte[0], (short) 0, (short) 0,
|
||||
(short) 42,
|
||||
okm, (short) 0);
|
||||
assertArrayEquals(RFC5869_T3_OKM_42, okm,
|
||||
"HKDF-Expand with empty info");
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-up session keys per Aliro §8.4.3 + mdoc 9.1.1.5: derive
|
||||
* StepUpSKDevice and StepUpSKReader from StepUpSK via HKDF-SHA-256 with
|
||||
* empty salt and info = "SKDevice" or "SKReader". This test drives
|
||||
* {@link AliroCrypto#deriveStepUpSessionKeys} against the equivalent
|
||||
* manual HKDF chain.
|
||||
*/
|
||||
@Test
|
||||
void deriveStepUpSessionKeysMatchesManualHkdf() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
byte[] stepUpSK = new byte[32];
|
||||
for (int i = 0; i < 32; i++) stepUpSK[i] = (byte) (0xC0 + i);
|
||||
|
||||
byte[] skDevice = new byte[32];
|
||||
byte[] skReader = new byte[32];
|
||||
crypto.deriveStepUpSessionKeys(stepUpSK, (short) 0,
|
||||
skDevice, (short) 0, skReader, (short) 0);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
java.util.Arrays.equals(skDevice, skReader),
|
||||
"SKDevice and SKReader must differ");
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
java.util.Arrays.equals(skDevice, stepUpSK),
|
||||
"SKDevice must not equal the IKM");
|
||||
|
||||
// Manual: HKDF(ikm=StepUpSK, salt=empty, info="SKDevice"/"SKReader", L=32)
|
||||
byte[] manualDevice = manualHkdf(stepUpSK, new byte[0], "SKDevice".getBytes(), 32);
|
||||
byte[] manualReader = manualHkdf(stepUpSK, new byte[0], "SKReader".getBytes(), 32);
|
||||
assertArrayEquals(manualDevice, skDevice,
|
||||
"StepUpSKDevice must equal HKDF(ikm=StepUpSK, salt=∅, info=\"SKDevice\", L=32)");
|
||||
assertArrayEquals(manualReader, skReader,
|
||||
"StepUpSKReader must equal HKDF(ikm=StepUpSK, salt=∅, info=\"SKReader\", L=32)");
|
||||
}
|
||||
|
||||
private static byte[] manualHkdf(byte[] ikm, byte[] salt, byte[] info, int L) {
|
||||
try {
|
||||
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
|
||||
byte[] useSalt = salt.length == 0 ? new byte[32] : salt;
|
||||
mac.init(new javax.crypto.spec.SecretKeySpec(useSalt, "HmacSHA256"));
|
||||
byte[] prk = mac.doFinal(ikm);
|
||||
|
||||
mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
|
||||
byte[] out = new byte[L];
|
||||
byte[] t = new byte[0];
|
||||
int produced = 0;
|
||||
byte counter = 1;
|
||||
while (produced < L) {
|
||||
mac.update(t);
|
||||
mac.update(info);
|
||||
mac.update(counter);
|
||||
t = mac.doFinal();
|
||||
int copy = Math.min(L - produced, t.length);
|
||||
System.arraycopy(t, 0, out, produced, copy);
|
||||
produced += copy;
|
||||
counter++;
|
||||
mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
|
||||
}
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void hkdfExpandRfc5869TestCase1() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
byte[] okm = new byte[42];
|
||||
short okmLen = crypto.hkdfExpand(
|
||||
RFC5869_T1_PRK, (short) 0, (short) RFC5869_T1_PRK.length,
|
||||
RFC5869_T1_INFO, (short) 0, (short) RFC5869_T1_INFO.length,
|
||||
(short) 42,
|
||||
okm, (short) 0);
|
||||
assertEquals(42, okmLen);
|
||||
assertArrayEquals(RFC5869_T1_OKM_42, okm, "matches RFC 5869 Test Case 1 OKM");
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for the upstream licel/jcardsim ByteContainer.setBytes
|
||||
* bug: without the ByteContainer patch in scripts/jcardsim-patches/,
|
||||
* reusing an HMACKey across setKey calls of different lengths corrupts
|
||||
* the internal data array and throws AIOOBE on the second arrayCopy.
|
||||
* This exact shape — setKey(13), sign, setKey(32) — is what HKDF-Extract
|
||||
* followed by HKDF-Expand does on the same key.
|
||||
*/
|
||||
@Test
|
||||
void hmacKeyReuseAcrossDifferentKeyLengths() {
|
||||
javacard.security.HMACKey k = (javacard.security.HMACKey)
|
||||
javacard.security.KeyBuilder.buildKey(
|
||||
javacard.security.KeyBuilder.TYPE_HMAC, (short) 512, false);
|
||||
javacard.security.Signature h = javacard.security.Signature.getInstance(
|
||||
javacard.security.Signature.ALG_HMAC_SHA_256, false);
|
||||
|
||||
k.setKey(RFC5869_T1_SALT, (short) 0, (short) RFC5869_T1_SALT.length);
|
||||
h.init(k, javacard.security.Signature.MODE_SIGN);
|
||||
byte[] prk = new byte[32];
|
||||
h.sign(RFC5869_T1_IKM, (short) 0, (short) RFC5869_T1_IKM.length, prk, (short) 0);
|
||||
|
||||
byte[] wide = new byte[64];
|
||||
System.arraycopy(RFC5869_T1_PRK, 0, wide, 32, 32);
|
||||
k.setKey(wide, (short) 32, (short) 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kdh is the session-key seed from Aliro §8.3.1.4. The spec note says
|
||||
* the procedure in §8.3.1.5 (HKDF-SHA-256) supersedes the X9.63 KDF in
|
||||
* §8.3.1.4 — concretely, Kdh = HKDF(IKM=ECDH_x(ePriv, peerEPub),
|
||||
* salt=transaction_identifier, info=∅, L=32). This test checks that
|
||||
* deriveKdh produces exactly what the manual HKDF chain produces.
|
||||
*/
|
||||
@Test
|
||||
void deriveKdhMatchesManualHkdfChain() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
|
||||
KeyPair kp1 = freshP256();
|
||||
KeyPair kp2 = freshP256();
|
||||
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
|
||||
|
||||
byte[] txnId = new byte[16];
|
||||
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x10 + i);
|
||||
|
||||
byte[] kdh = new byte[32];
|
||||
crypto.deriveKdh(
|
||||
(ECPrivateKey) kp1.getPrivate(),
|
||||
pub2, (short) 0,
|
||||
txnId, (short) 0, (short) txnId.length,
|
||||
kdh, (short) 0);
|
||||
|
||||
byte[] zab = new byte[32];
|
||||
crypto.computeEcdhSharedX(
|
||||
(ECPrivateKey) kp1.getPrivate(),
|
||||
pub2, (short) 0,
|
||||
zab, (short) 0);
|
||||
byte[] prk = new byte[32];
|
||||
crypto.hkdfExtract(
|
||||
txnId, (short) 0, (short) txnId.length,
|
||||
zab, (short) 0, (short) 32,
|
||||
prk, (short) 0);
|
||||
byte[] okm = new byte[32];
|
||||
crypto.hkdfExpand(
|
||||
prk, (short) 0, (short) 32,
|
||||
new byte[0], (short) 0, (short) 0,
|
||||
(short) 32,
|
||||
okm, (short) 0);
|
||||
|
||||
assertArrayEquals(okm, kdh,
|
||||
"deriveKdh must equal HKDF(IKM=ECDH_x, salt=txnId, info=empty, L=32)");
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.3.1.13 expedited-standard key material derivation is just
|
||||
* HKDF(IKM=Kdh, salt=salt_volatile, info=info, L=160). This test checks
|
||||
* the wrapper produces the same bytes as the explicit Extract→Expand
|
||||
* chain and that both transaction sides agree.
|
||||
*/
|
||||
@Test
|
||||
void deriveExpeditedStandardKeysMatchesManualHkdf() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
byte[] kdh = new byte[32];
|
||||
for (int i = 0; i < 32; i++) kdh[i] = (byte) (0xA0 + i);
|
||||
byte[] salt = new byte[40];
|
||||
for (int i = 0; i < 40; i++) salt[i] = (byte) i;
|
||||
byte[] info = new byte[16];
|
||||
for (int i = 0; i < 16; i++) info[i] = (byte) (i * 3);
|
||||
|
||||
byte[] derived = new byte[160];
|
||||
crypto.deriveExpeditedStandardKeys(
|
||||
kdh, (short) 0,
|
||||
salt, (short) 0, (short) salt.length,
|
||||
info, (short) 0, (short) info.length,
|
||||
derived, (short) 0);
|
||||
|
||||
byte[] prk = new byte[32];
|
||||
crypto.hkdfExtract(
|
||||
salt, (short) 0, (short) salt.length,
|
||||
kdh, (short) 0, (short) 32,
|
||||
prk, (short) 0);
|
||||
byte[] manual = new byte[160];
|
||||
crypto.hkdfExpand(
|
||||
prk, (short) 0, (short) 32,
|
||||
info, (short) 0, (short) info.length,
|
||||
(short) 160,
|
||||
manual, (short) 0);
|
||||
|
||||
assertArrayEquals(manual, derived,
|
||||
"deriveExpeditedStandardKeys must equal HKDF(Kdh, salt, info, 160)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deriveExpeditedStandardKeysAgreesOnBothSides() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
KeyPair kp1 = freshP256();
|
||||
KeyPair kp2 = freshP256();
|
||||
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
|
||||
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
|
||||
|
||||
byte[] txnId = new byte[16];
|
||||
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x11 * i);
|
||||
byte[] salt = new byte[80];
|
||||
for (int i = 0; i < 80; i++) salt[i] = (byte) (i ^ 0x55);
|
||||
byte[] info = new byte[32];
|
||||
for (int i = 0; i < 32; i++) info[i] = (byte) (i ^ 0xAA);
|
||||
|
||||
byte[] outA = new byte[160];
|
||||
byte[] outB = new byte[160];
|
||||
byte[] kdhA = new byte[32];
|
||||
byte[] kdhB = new byte[32];
|
||||
|
||||
crypto.deriveKdh((ECPrivateKey) kp1.getPrivate(), pub2, (short) 0,
|
||||
txnId, (short) 0, (short) txnId.length, kdhA, (short) 0);
|
||||
crypto.deriveExpeditedStandardKeys(kdhA, (short) 0,
|
||||
salt, (short) 0, (short) salt.length,
|
||||
info, (short) 0, (short) info.length,
|
||||
outA, (short) 0);
|
||||
|
||||
crypto.deriveKdh((ECPrivateKey) kp2.getPrivate(), pub1, (short) 0,
|
||||
txnId, (short) 0, (short) txnId.length, kdhB, (short) 0);
|
||||
crypto.deriveExpeditedStandardKeys(kdhB, (short) 0,
|
||||
salt, (short) 0, (short) salt.length,
|
||||
info, (short) 0, (short) info.length,
|
||||
outB, (short) 0);
|
||||
|
||||
assertArrayEquals(outA, outB,
|
||||
"reader and device must derive identical ExpeditedSK material");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deriveKdhAgreesOnBothSides() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
KeyPair kp1 = freshP256();
|
||||
KeyPair kp2 = freshP256();
|
||||
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
|
||||
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
|
||||
byte[] txnId = new byte[16];
|
||||
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x42 ^ i);
|
||||
|
||||
byte[] kdhA = new byte[32];
|
||||
byte[] kdhB = new byte[32];
|
||||
crypto.deriveKdh((ECPrivateKey) kp1.getPrivate(), pub2, (short) 0,
|
||||
txnId, (short) 0, (short) txnId.length, kdhA, (short) 0);
|
||||
crypto.deriveKdh((ECPrivateKey) kp2.getPrivate(), pub1, (short) 0,
|
||||
txnId, (short) 0, (short) txnId.length, kdhB, (short) 0);
|
||||
assertArrayEquals(kdhA, kdhB, "both sides must derive the same Kdh");
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves the load-a-known-scalar-into-a-JC-ECPrivateKey pattern we need
|
||||
* for credential_PrivK: create a JC KeyPair to inherit P-256 curve
|
||||
* parameters, overwrite S with the externally-supplied scalar, then
|
||||
* sign — the signature must verify under the matching (also externally
|
||||
* supplied, loaded via setW) public key.
|
||||
*/
|
||||
@Test
|
||||
void ecPrivateKeyLoadedViaKeyPairPatternSignsAndVerifies() throws Exception {
|
||||
java.security.KeyPairGenerator g = java.security.KeyPairGenerator.getInstance("EC");
|
||||
g.initialize(new java.security.spec.ECGenParameterSpec("secp256r1"));
|
||||
java.security.KeyPair jdk = g.generateKeyPair();
|
||||
byte[] s = toFixed32(
|
||||
((java.security.interfaces.ECPrivateKey) jdk.getPrivate()).getS().toByteArray());
|
||||
byte[] w = uncompressedFromJdk((java.security.interfaces.ECPublicKey) jdk.getPublic());
|
||||
|
||||
KeyPair privKp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
|
||||
privKp.genKeyPair();
|
||||
((ECPrivateKey) privKp.getPrivate()).setS(s, (short) 0, (short) s.length);
|
||||
|
||||
KeyPair pubKp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
|
||||
pubKp.genKeyPair();
|
||||
((ECPublicKey) pubKp.getPublic()).setW(w, (short) 0, (short) w.length);
|
||||
|
||||
byte[] msg = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55 };
|
||||
Signature signer = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
|
||||
signer.init(privKp.getPrivate(), Signature.MODE_SIGN);
|
||||
byte[] der = new byte[80];
|
||||
short sigLen = signer.sign(msg, (short) 0, (short) msg.length, der, (short) 0);
|
||||
|
||||
Signature verifier = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
|
||||
verifier.init(pubKp.getPublic(), Signature.MODE_VERIFY);
|
||||
assertTrue(verifier.verify(msg, (short) 0, (short) msg.length, der, (short) 0, sigLen),
|
||||
"sig from loaded privkey must verify under loaded pubkey");
|
||||
}
|
||||
|
||||
private static byte[] toFixed32(byte[] in) {
|
||||
if (in.length == 32) return in;
|
||||
byte[] out = new byte[32];
|
||||
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
|
||||
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] uncompressedFromJdk(java.security.interfaces.ECPublicKey pub) {
|
||||
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
|
||||
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
|
||||
byte[] out = new byte[65];
|
||||
out[0] = 0x04;
|
||||
System.arraycopy(x, 0, out, 1, 32);
|
||||
System.arraycopy(y, 0, out, 33, 32);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe: can jcardsim actually instantiate AES-256-GCM and round-trip a
|
||||
* block? Aliro §8.3.1.6 requires AES-GCM with a 32-byte session key;
|
||||
* if jcardsim doesn't support it, we need userland GCM instead.
|
||||
*/
|
||||
@Test
|
||||
void jcardsimSupportsAesGcm() {
|
||||
Cipher c = Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
|
||||
assertTrue(c instanceof AEADCipher, "expected an AEADCipher instance");
|
||||
|
||||
AEADCipher gcm = (AEADCipher) c;
|
||||
AESKey k = (AESKey) KeyBuilder.buildKey(
|
||||
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
|
||||
byte[] keyBytes = new byte[32];
|
||||
for (int i = 0; i < 32; i++) keyBytes[i] = (byte) i;
|
||||
k.setKey(keyBytes, (short) 0);
|
||||
|
||||
byte[] iv = new byte[12];
|
||||
iv[11] = 0x01;
|
||||
byte[] pt = new byte[] { 0x11, 0x22, 0x33, 0x44 };
|
||||
// jcardsim's AEADCipher impl writes ct||tag into the doFinal output
|
||||
// buffer; size it accordingly, then also call retrieveTag.
|
||||
byte[] ctAndTag = new byte[pt.length + 16];
|
||||
byte[] tag = new byte[16];
|
||||
|
||||
gcm.init(k, Cipher.MODE_ENCRYPT, iv, (short) 0, (short) iv.length);
|
||||
short outLen = gcm.doFinal(pt, (short) 0, (short) pt.length, ctAndTag, (short) 0);
|
||||
gcm.retrieveTag(tag, (short) 0, (short) tag.length);
|
||||
assertEquals(pt.length + 16, outLen);
|
||||
|
||||
byte[] tagFromOutput = new byte[16];
|
||||
System.arraycopy(ctAndTag, pt.length, tagFromOutput, 0, 16);
|
||||
assertArrayEquals(tag, tagFromOutput,
|
||||
"tag via retrieveTag must match tag appended by doFinal");
|
||||
|
||||
byte[] dec = new byte[pt.length + 16];
|
||||
gcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
|
||||
short decLen = gcm.doFinal(ctAndTag, (short) 0, (short) (pt.length + 16), dec, (short) 0);
|
||||
assertEquals(pt.length, decLen, "decrypt doFinal returns plaintext length");
|
||||
byte[] ptOut = new byte[pt.length];
|
||||
System.arraycopy(dec, 0, ptOut, 0, pt.length);
|
||||
assertArrayEquals(pt, ptOut);
|
||||
}
|
||||
|
||||
private static KeyPair freshP256() {
|
||||
KeyPair kp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
|
||||
kp.genKeyPair();
|
||||
return kp;
|
||||
}
|
||||
|
||||
private static byte[] uncompressed(ECPublicKey pub) {
|
||||
byte[] out = new byte[65];
|
||||
short len = pub.getW(out, (short) 0);
|
||||
if (len != 65) throw new IllegalStateException("expected 65B uncompressed point");
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] hex(String s) {
|
||||
s = s.replaceAll("\\s+", "");
|
||||
byte[] out = new byte[s.length() / 2];
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
229
applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java
Normal file
229
applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java
Normal file
@@ -0,0 +1,229 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.security.AESKey;
|
||||
import javacard.security.KeyBuilder;
|
||||
import javacardx.crypto.AEADCipher;
|
||||
import javacardx.crypto.Cipher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/**
|
||||
* Tests for {@link AliroGcm} — our userland AES-256-GCM, built on the JC
|
||||
* single-block AES-ECB primitive. Needed because the target card (NXP J3R180)
|
||||
* does not expose {@code AEADCipher.ALG_AES_GCM}. The NIST test vectors come
|
||||
* from {@code gcmEncryptExtIV256.rsp} (the CMVP AEAD AES-256 suite).
|
||||
*/
|
||||
class AliroGcmTest {
|
||||
|
||||
@Test
|
||||
void nistTestCase13_emptyMessage_emptyAad_returnsTagOnly() {
|
||||
// K=0^256, IV=0^96, P=A=empty, expected T = 530f8afbc74536b9a963b4f1c4cb738b
|
||||
byte[] key = new byte[32];
|
||||
byte[] iv = new byte[12];
|
||||
byte[] pt = new byte[0];
|
||||
byte[] expectedTag = hex("530f8afbc74536b9a963b4f1c4cb738b");
|
||||
|
||||
byte[] out = new byte[16];
|
||||
AliroGcm gcm = new AliroGcm();
|
||||
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) 0, out, (short) 0);
|
||||
|
||||
assertEquals(16, n, "empty plaintext → 16 bytes output (just the tag)");
|
||||
assertArrayEquals(expectedTag, out, "NIST GCM AES-256 Test Case 13 tag");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nistTestCase14_singleZeroBlock_returnsCiphertextAndTag() {
|
||||
// K=0^256, IV=0^96, P=0^128, A=empty
|
||||
// C = cea7403d4d606b6e074ec5d3baf39d18
|
||||
// T = d0d1c8a799996bf0265b98b5d48ab919
|
||||
byte[] key = new byte[32];
|
||||
byte[] iv = new byte[12];
|
||||
byte[] pt = new byte[16];
|
||||
byte[] expectedCt = hex("cea7403d4d606b6e074ec5d3baf39d18");
|
||||
byte[] expectedTag = hex("d0d1c8a799996bf0265b98b5d48ab919");
|
||||
|
||||
byte[] out = new byte[32];
|
||||
AliroGcm gcm = new AliroGcm();
|
||||
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out, (short) 0);
|
||||
|
||||
assertEquals(32, n, "16B plaintext + 16B tag");
|
||||
|
||||
byte[] ct = new byte[16];
|
||||
System.arraycopy(out, 0, ct, 0, 16);
|
||||
byte[] tag = new byte[16];
|
||||
System.arraycopy(out, 16, tag, 0, 16);
|
||||
|
||||
assertArrayEquals(expectedCt, ct, "NIST GCM AES-256 Test Case 14 ciphertext");
|
||||
assertArrayEquals(expectedTag, tag, "NIST GCM AES-256 Test Case 14 tag");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nistTestCase15_fourBlocks_realKey() {
|
||||
// K = feffe9928665731c6d6a8f9467308308 feffe9928665731c6d6a8f9467308308
|
||||
// IV = cafebabefacedbaddecaf888
|
||||
// P = 4 blocks, A = empty
|
||||
byte[] key = hex("feffe9928665731c6d6a8f9467308308"
|
||||
+ "feffe9928665731c6d6a8f9467308308");
|
||||
byte[] iv = hex("cafebabefacedbaddecaf888");
|
||||
byte[] pt = hex("d9313225f88406e5a55909c5aff5269a"
|
||||
+ "86a7a9531534f7da2e4c303d8a318a72"
|
||||
+ "1c3c0c95956809532fcf0e2449a6b525"
|
||||
+ "b16aedf5aa0de657ba637b391aafd255");
|
||||
byte[] expectedCt = hex("522dc1f099567d07f47f37a32a84427d"
|
||||
+ "643a8cdcbfe5c0c97598a2bd2555d1aa"
|
||||
+ "8cb08e48590dbb3da7b08b1056828838"
|
||||
+ "c5f61e6393ba7a0abcc9f662898015ad");
|
||||
byte[] expectedTag = hex("b094dac5d93471bdec1a502270e3cc6c");
|
||||
|
||||
byte[] out = new byte[pt.length + 16];
|
||||
AliroGcm gcm = new AliroGcm();
|
||||
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out, (short) 0);
|
||||
|
||||
assertEquals(pt.length + 16, n);
|
||||
|
||||
byte[] ct = new byte[pt.length];
|
||||
System.arraycopy(out, 0, ct, 0, pt.length);
|
||||
byte[] tag = new byte[16];
|
||||
System.arraycopy(out, pt.length, tag, 0, 16);
|
||||
|
||||
assertArrayEquals(expectedCt, ct, "NIST GCM AES-256 Test Case 15 ciphertext");
|
||||
assertArrayEquals(expectedTag, tag, "NIST GCM AES-256 Test Case 15 tag");
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end: encrypt with AliroGcm, decrypt with jcardsim's AEADCipher
|
||||
* (available in tests via the patched jcardsim master in m2 volume).
|
||||
* Confirms wire-compatibility with the standard implementation.
|
||||
*/
|
||||
@Test
|
||||
void roundTripAgainstJcardsimAEADCipher() {
|
||||
byte[] key = new byte[32];
|
||||
for (int i = 0; i < 32; i++) key[i] = (byte) (i * 7 + 3);
|
||||
byte[] iv = new byte[12];
|
||||
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xA0 ^ i);
|
||||
byte[] pt = new byte[137]; // realistic Aliro Table 8-11 size
|
||||
for (int i = 0; i < pt.length; i++) pt[i] = (byte) (i ^ 0x5C);
|
||||
|
||||
byte[] out = new byte[pt.length + 16];
|
||||
AliroGcm gcm = new AliroGcm();
|
||||
gcm.encrypt(key, (short) 0, iv, (short) 0,
|
||||
pt, (short) 0, (short) pt.length, out, (short) 0);
|
||||
|
||||
// Decrypt with jcardsim's AEADCipher.
|
||||
AESKey k = (AESKey) KeyBuilder.buildKey(
|
||||
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
|
||||
k.setKey(key, (short) 0);
|
||||
AEADCipher jcGcm = (AEADCipher) Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
|
||||
jcGcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
|
||||
|
||||
byte[] dec = new byte[pt.length + 16];
|
||||
short decLen = jcGcm.doFinal(out, (short) 0, (short) out.length, dec, (short) 0);
|
||||
assertEquals(pt.length, decLen, "jcardsim GCM decrypt returns pt length");
|
||||
|
||||
byte[] ptOut = new byte[pt.length];
|
||||
System.arraycopy(dec, 0, ptOut, 0, pt.length);
|
||||
assertArrayEquals(pt, ptOut,
|
||||
"AliroGcm ciphertext must decrypt correctly under jcardsim AEADCipher");
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial final block: plaintext whose length isn't a multiple of 16.
|
||||
* GCTR must handle the partial block correctly (XOR only |P| mod 16 bytes
|
||||
* of the last keystream block), and GHASH must zero-pad.
|
||||
*/
|
||||
@Test
|
||||
void partialFinalBlock() {
|
||||
byte[] key = new byte[32];
|
||||
for (int i = 0; i < 32; i++) key[i] = (byte) i;
|
||||
byte[] iv = new byte[12];
|
||||
iv[11] = 0x01;
|
||||
byte[] pt = new byte[17]; // 1 full block + 1 byte
|
||||
for (int i = 0; i < 17; i++) pt[i] = (byte) (0xAA + i);
|
||||
|
||||
byte[] out = new byte[pt.length + 16];
|
||||
AliroGcm gcm = new AliroGcm();
|
||||
short n = gcm.encrypt(key, (short) 0, iv, (short) 0,
|
||||
pt, (short) 0, (short) pt.length, out, (short) 0);
|
||||
|
||||
assertEquals(pt.length + 16, n,
|
||||
"ciphertext length must equal plaintext length (GCM is length-preserving)");
|
||||
|
||||
// Round-trip through jcardsim for correctness.
|
||||
AESKey k = (AESKey) KeyBuilder.buildKey(
|
||||
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
|
||||
k.setKey(key, (short) 0);
|
||||
AEADCipher jcGcm = (AEADCipher) Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
|
||||
jcGcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
|
||||
byte[] dec = new byte[pt.length + 16];
|
||||
short decLen = jcGcm.doFinal(out, (short) 0, (short) out.length, dec, (short) 0);
|
||||
assertEquals(pt.length, decLen);
|
||||
byte[] ptOut = new byte[pt.length];
|
||||
System.arraycopy(dec, 0, ptOut, 0, pt.length);
|
||||
assertArrayEquals(pt, ptOut, "17-byte plaintext must round-trip");
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentKeysProduceDifferentTags() {
|
||||
byte[] iv = new byte[12];
|
||||
iv[11] = 0x01;
|
||||
byte[] pt = new byte[] { 0x11, 0x22, 0x33, 0x44 };
|
||||
|
||||
byte[] key1 = new byte[32];
|
||||
byte[] key2 = new byte[32];
|
||||
key2[0] = 0x01; // differ in one bit
|
||||
|
||||
AliroGcm gcm = new AliroGcm();
|
||||
byte[] out1 = new byte[pt.length + 16];
|
||||
byte[] out2 = new byte[pt.length + 16];
|
||||
gcm.encrypt(key1, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out1, (short) 0);
|
||||
gcm.encrypt(key2, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out2, (short) 0);
|
||||
|
||||
// Extract tags (last 16 bytes).
|
||||
byte[] tag1 = new byte[16];
|
||||
byte[] tag2 = new byte[16];
|
||||
System.arraycopy(out1, pt.length, tag1, 0, 16);
|
||||
System.arraycopy(out2, pt.length, tag2, 0, 16);
|
||||
|
||||
assertFalse(java.util.Arrays.equals(tag1, tag2),
|
||||
"different keys must produce different tags");
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentIvsProduceDifferentCiphertexts() {
|
||||
byte[] key = new byte[32];
|
||||
for (int i = 0; i < 32; i++) key[i] = (byte) i;
|
||||
byte[] pt = new byte[32];
|
||||
for (int i = 0; i < 32; i++) pt[i] = (byte) (0x10 + i);
|
||||
|
||||
byte[] iv1 = new byte[12];
|
||||
iv1[11] = 0x01;
|
||||
byte[] iv2 = new byte[12];
|
||||
iv2[11] = 0x02;
|
||||
|
||||
AliroGcm gcm = new AliroGcm();
|
||||
byte[] out1 = new byte[pt.length + 16];
|
||||
byte[] out2 = new byte[pt.length + 16];
|
||||
gcm.encrypt(key, (short) 0, iv1, (short) 0, pt, (short) 0, (short) pt.length, out1, (short) 0);
|
||||
gcm.encrypt(key, (short) 0, iv2, (short) 0, pt, (short) 0, (short) pt.length, out2, (short) 0);
|
||||
|
||||
byte[] ct1 = new byte[pt.length];
|
||||
byte[] ct2 = new byte[pt.length];
|
||||
System.arraycopy(out1, 0, ct1, 0, pt.length);
|
||||
System.arraycopy(out2, 0, ct2, 0, pt.length);
|
||||
|
||||
assertFalse(java.util.Arrays.equals(ct1, ct2),
|
||||
"different IVs must produce different ciphertexts (CTR stream differs)");
|
||||
}
|
||||
|
||||
private static byte[] hex(String s) {
|
||||
s = s.replaceAll("\\s+", "");
|
||||
byte[] out = new byte[s.length() / 2];
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Tests for {@link AliroHmac} — userland HMAC-SHA-256, built on the JC
|
||||
* {@code MessageDigest.ALG_SHA_256} primitive. Needed because the target card
|
||||
* (NXP J3R180) does not support {@code KeyBuilder.TYPE_HMAC} or
|
||||
* {@code Signature.ALG_HMAC_SHA_256} despite advertising JC 3.0.5.
|
||||
*
|
||||
* <p>Known-answer vectors come from RFC 4231 (HMAC-SHA-256 test cases).
|
||||
*/
|
||||
class AliroHmacTest {
|
||||
|
||||
/** RFC 4231 TC1: K = 0x0b × 20, M = "Hi There". */
|
||||
@Test
|
||||
void rfc4231TestCase1() {
|
||||
byte[] key = new byte[20];
|
||||
for (int i = 0; i < 20; i++) key[i] = 0x0b;
|
||||
byte[] msg = "Hi There".getBytes();
|
||||
byte[] expected = hex(
|
||||
"b0344c61d8db38535ca8afceaf0bf12b"
|
||||
+ "881dc200c9833da726e9376c2e32cff7");
|
||||
|
||||
byte[] out = new byte[32];
|
||||
AliroHmac hmac = new AliroHmac();
|
||||
short n = hmac.compute(
|
||||
key, (short) 0, (short) key.length,
|
||||
msg, (short) 0, (short) msg.length,
|
||||
out, (short) 0);
|
||||
assertEquals(32, n);
|
||||
assertArrayEquals(expected, out, "RFC 4231 Test Case 1 HMAC-SHA-256");
|
||||
}
|
||||
|
||||
/** RFC 4231 TC2: K = "Jefe", M = "what do ya want for nothing?". */
|
||||
@Test
|
||||
void rfc4231TestCase2() {
|
||||
byte[] key = "Jefe".getBytes();
|
||||
byte[] msg = "what do ya want for nothing?".getBytes();
|
||||
byte[] expected = hex(
|
||||
"5bdcc146bf60754e6a042426089575c7"
|
||||
+ "5a003f089d2739839dec58b964ec3843");
|
||||
|
||||
byte[] out = new byte[32];
|
||||
AliroHmac hmac = new AliroHmac();
|
||||
hmac.compute(
|
||||
key, (short) 0, (short) key.length,
|
||||
msg, (short) 0, (short) msg.length,
|
||||
out, (short) 0);
|
||||
assertArrayEquals(expected, out, "RFC 4231 Test Case 2 HMAC-SHA-256");
|
||||
}
|
||||
|
||||
/** RFC 4231 TC4: K = 0x01..0x19 (25 bytes), M = 0xcd × 50. */
|
||||
@Test
|
||||
void rfc4231TestCase4() {
|
||||
byte[] key = hex("0102030405060708090a0b0c0d0e0f10111213141516171819");
|
||||
byte[] msg = new byte[50];
|
||||
for (int i = 0; i < 50; i++) msg[i] = (byte) 0xcd;
|
||||
byte[] expected = hex(
|
||||
"82558a389a443c0ea4cc819899f2083a"
|
||||
+ "85f0faa3e578f8077a2e3ff46729665b");
|
||||
|
||||
byte[] out = new byte[32];
|
||||
AliroHmac hmac = new AliroHmac();
|
||||
hmac.compute(
|
||||
key, (short) 0, (short) key.length,
|
||||
msg, (short) 0, (short) msg.length,
|
||||
out, (short) 0);
|
||||
assertArrayEquals(expected, out, "RFC 4231 Test Case 4 HMAC-SHA-256");
|
||||
}
|
||||
|
||||
/** RFC 4231 TC6: K = 0xaa × 131 (> 64B, triggers K' = SHA-256(K) branch). */
|
||||
@Test
|
||||
void rfc4231TestCase6_keyLongerThanBlock() {
|
||||
byte[] key = new byte[131];
|
||||
for (int i = 0; i < 131; i++) key[i] = (byte) 0xaa;
|
||||
byte[] msg = "Test Using Larger Than Block-Size Key - Hash Key First".getBytes();
|
||||
byte[] expected = hex(
|
||||
"60e431591ee0b67f0d8a26aacbf5b77f"
|
||||
+ "8e0bc6213728c5140546040f0ee37f54");
|
||||
|
||||
byte[] out = new byte[32];
|
||||
AliroHmac hmac = new AliroHmac();
|
||||
hmac.compute(
|
||||
key, (short) 0, (short) key.length,
|
||||
msg, (short) 0, (short) msg.length,
|
||||
out, (short) 0);
|
||||
assertArrayEquals(expected, out,
|
||||
"RFC 4231 Test Case 6 HMAC-SHA-256 — long key (K > B) must be hashed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Calling compute twice with the same inputs must produce the same output —
|
||||
* catches internal-state leaks (e.g. failing to reset the SHA-256 digest,
|
||||
* or forgetting to zero the pad block on reuse).
|
||||
*/
|
||||
@Test
|
||||
void repeatedComputesMatch() {
|
||||
byte[] key = new byte[20];
|
||||
for (int i = 0; i < 20; i++) key[i] = 0x0b;
|
||||
byte[] msg = "Hi There".getBytes();
|
||||
|
||||
AliroHmac hmac = new AliroHmac();
|
||||
byte[] out1 = new byte[32];
|
||||
byte[] out2 = new byte[32];
|
||||
hmac.compute(
|
||||
key, (short) 0, (short) key.length,
|
||||
msg, (short) 0, (short) msg.length,
|
||||
out1, (short) 0);
|
||||
hmac.compute(
|
||||
key, (short) 0, (short) key.length,
|
||||
msg, (short) 0, (short) msg.length,
|
||||
out2, (short) 0);
|
||||
assertArrayEquals(out1, out2,
|
||||
"same key+msg must produce same HMAC on repeat invocation");
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty message with a non-empty key — must still produce a valid,
|
||||
* deterministic HMAC (HMAC is defined for zero-length messages).
|
||||
* Reference value computed via JDK HmacSHA256 with the same key.
|
||||
*/
|
||||
@Test
|
||||
void emptyMessage() {
|
||||
byte[] key = new byte[20];
|
||||
for (int i = 0; i < 20; i++) key[i] = 0x0b;
|
||||
byte[] expected = jdkHmacSha256(key, new byte[0]);
|
||||
|
||||
byte[] out = new byte[32];
|
||||
AliroHmac hmac = new AliroHmac();
|
||||
hmac.compute(
|
||||
key, (short) 0, (short) key.length,
|
||||
new byte[0], (short) 0, (short) 0,
|
||||
out, (short) 0);
|
||||
assertArrayEquals(expected, out, "HMAC of empty message matches JDK reference");
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty key — RFC 2104 allows zero-length keys (K' is zero-padded to B);
|
||||
* some buggy implementations trip over this. Reference value from the JDK.
|
||||
*/
|
||||
@Test
|
||||
void emptyKey() {
|
||||
byte[] msg = "Hi There".getBytes();
|
||||
byte[] expected = jdkHmacSha256(new byte[0], msg);
|
||||
|
||||
byte[] out = new byte[32];
|
||||
AliroHmac hmac = new AliroHmac();
|
||||
hmac.compute(
|
||||
new byte[0], (short) 0, (short) 0,
|
||||
msg, (short) 0, (short) msg.length,
|
||||
out, (short) 0);
|
||||
assertArrayEquals(expected, out, "HMAC with empty key matches JDK reference");
|
||||
}
|
||||
|
||||
private static byte[] jdkHmacSha256(byte[] key, byte[] msg) {
|
||||
try {
|
||||
// HmacSHA256 rejects zero-length keys in some JDKs; normalize by
|
||||
// zero-padding to 1 byte when empty — equivalent under HMAC's
|
||||
// "zero-pad K to B bytes" rule (all-zero key → all-zero K').
|
||||
byte[] useKey = key.length == 0 ? new byte[1] : key;
|
||||
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
|
||||
mac.init(new javax.crypto.spec.SecretKeySpec(useKey, "HmacSHA256"));
|
||||
return mac.doFinal(msg);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] hex(String s) {
|
||||
s = s.replaceAll("\\s+", "");
|
||||
byte[] out = new byte[s.length() / 2];
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
106
applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
Normal file
106
applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
Normal file
@@ -0,0 +1,106 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
|
||||
/**
|
||||
* Test-only builder for Aliro AUTH0 command payloads, per spec Table 8-4.
|
||||
*
|
||||
* <pre>
|
||||
* 0x41 01 [command_parameters]
|
||||
* 0x42 01 [authentication_policy]
|
||||
* 0x5C 02 [expedited_phase_protocol_version]
|
||||
* 0x87 41 04 || x || y (reader_ePubK uncompressed, 65 bytes)
|
||||
* 0x4C 10 [transaction_identifier]
|
||||
* 0x4D 20 [reader_group_id 16 || reader_group_sub_id 16]
|
||||
* </pre>
|
||||
*/
|
||||
final class Auth0Command {
|
||||
|
||||
static final byte CLA = (byte) 0x80;
|
||||
static final byte INS = (byte) 0x80;
|
||||
|
||||
static final short PROTOCOL_V1_0 = (short) 0x0100;
|
||||
|
||||
static final byte CMD_PARAM_STANDARD = (byte) 0x00;
|
||||
static final byte CMD_PARAM_FAST = (byte) 0x01;
|
||||
|
||||
static final byte AUTH_POLICY_NONE = (byte) 0x00;
|
||||
|
||||
private Auth0Command() {}
|
||||
|
||||
/** Generates a fresh P-256 keypair for use by tests simulating the reader. */
|
||||
static KeyPair generateEphemeralKeyPair() {
|
||||
try {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
|
||||
kpg.initialize(new ECGenParameterSpec("secp256r1"), new SecureRandom());
|
||||
return kpg.generateKeyPair();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Encodes a P-256 public key to uncompressed 65-byte form (0x04 || x || y). */
|
||||
static byte[] encodeUncompressed(ECPublicKey pub) {
|
||||
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
|
||||
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
|
||||
byte[] out = new byte[65];
|
||||
out[0] = 0x04;
|
||||
System.arraycopy(x, 0, out, 1, 32);
|
||||
System.arraycopy(y, 0, out, 33, 32);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] toFixed32(byte[] b) {
|
||||
if (b.length == 32) return b;
|
||||
byte[] out = new byte[32];
|
||||
if (b.length > 32) {
|
||||
System.arraycopy(b, b.length - 32, out, 0, 32);
|
||||
} else {
|
||||
System.arraycopy(b, 0, out, 32 - b.length, b.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Builds a standard-phase AUTH0 command data field with the given parts. */
|
||||
static byte[] buildStandardData(
|
||||
byte[] readerEphemPubKeyUncompressed,
|
||||
byte[] readerGroupId,
|
||||
byte[] readerGroupSubId,
|
||||
byte[] transactionId) {
|
||||
if (readerEphemPubKeyUncompressed.length != 65) {
|
||||
throw new IllegalArgumentException("reader_ePubK must be 65 bytes (uncompressed)");
|
||||
}
|
||||
if (readerGroupId.length != 16 || readerGroupSubId.length != 16) {
|
||||
throw new IllegalArgumentException("reader_group_{id,sub_id} must be 16 bytes each");
|
||||
}
|
||||
if (transactionId.length != 16) {
|
||||
throw new IllegalArgumentException("transaction_identifier must be 16 bytes");
|
||||
}
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
writeTlv(out, 0x41, new byte[] { CMD_PARAM_STANDARD });
|
||||
writeTlv(out, 0x42, new byte[] { AUTH_POLICY_NONE });
|
||||
writeTlv(out, 0x5C, new byte[] {
|
||||
(byte) ((PROTOCOL_V1_0 >> 8) & 0xFF),
|
||||
(byte) (PROTOCOL_V1_0 & 0xFF)
|
||||
});
|
||||
writeTlv(out, 0x87, readerEphemPubKeyUncompressed);
|
||||
writeTlv(out, 0x4C, transactionId);
|
||||
byte[] readerIdentifier = new byte[32];
|
||||
System.arraycopy(readerGroupId, 0, readerIdentifier, 0, 16);
|
||||
System.arraycopy(readerGroupSubId, 0, readerIdentifier, 16, 16);
|
||||
writeTlv(out, 0x4D, readerIdentifier);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeTlv(ByteArrayOutputStream out, int tag, byte[] value) {
|
||||
out.write(tag);
|
||||
out.write(value.length);
|
||||
out.write(value, 0, value.length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import com.licel.jcardsim.smartcardio.CardSimulator;
|
||||
import javacard.framework.AID;
|
||||
import javax.smartcardio.CommandAPDU;
|
||||
import javax.smartcardio.ResponseAPDU;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Tests for the PersonalizationApplet — our proprietary provisioning
|
||||
* channel that loads the Access Credential long-term private key and
|
||||
* reader public keys before the Aliro flow runs.
|
||||
*/
|
||||
class PersonalizationAppletTest {
|
||||
|
||||
private static final byte CLA = (byte) 0x80;
|
||||
private static final byte INS_SET_CRED_PRIV = (byte) 0x20;
|
||||
private static final byte INS_SET_CRED_PUBK = (byte) 0x21;
|
||||
private static final byte INS_SET_READER_PUBK = (byte) 0x22;
|
||||
private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23;
|
||||
private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24;
|
||||
private static final byte INS_COMMIT = (byte) 0x2C;
|
||||
|
||||
private CardSimulator sim;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
CredentialStore.get().resetForTesting();
|
||||
|
||||
sim = new CardSimulator();
|
||||
AID aid = new AID(AliroAids.PROVISIONING, (short) 0, (byte) AliroAids.PROVISIONING.length);
|
||||
sim.installApplet(aid, PersonalizationApplet.class);
|
||||
selectProvisioning();
|
||||
}
|
||||
|
||||
private void selectProvisioning() {
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.PROVISIONING, 256);
|
||||
ResponseAPDU r = sim.transmitCommand(select);
|
||||
assertEquals(0x9000, r.getSW(), "SELECT provisioning AID must succeed");
|
||||
}
|
||||
|
||||
private ResponseAPDU send(byte ins, byte[] data) {
|
||||
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF, 0x00, 0x00,
|
||||
data == null ? new byte[0] : data, 256);
|
||||
return sim.transmitCommand(c);
|
||||
}
|
||||
|
||||
private ResponseAPDU sendWithP1P2(byte ins, int p1p2, byte[] data) {
|
||||
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF,
|
||||
(p1p2 >> 8) & 0xFF, p1p2 & 0xFF,
|
||||
data == null ? new byte[0] : data, 256);
|
||||
return sim.transmitCommand(c);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setCredentialPrivKeyWith32BytesSucceeds() {
|
||||
byte[] key = new byte[32];
|
||||
for (int i = 0; i < key.length; i++) key[i] = (byte) i;
|
||||
assertEquals(0x9000, send(INS_SET_CRED_PRIV, key).getSW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setCredentialPrivKeyWithWrongLengthFails() {
|
||||
byte[] key = new byte[31];
|
||||
assertEquals(0x6A80, send(INS_SET_CRED_PRIV, key).getSW(),
|
||||
"credential private key length != 32 must be rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setCredentialPubKeyWith64BytesSucceeds() {
|
||||
byte[] pub = new byte[64];
|
||||
for (int i = 0; i < pub.length; i++) pub[i] = (byte) (i * 3);
|
||||
assertEquals(0x9000, send(INS_SET_CRED_PUBK, pub).getSW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setCredentialPubKeyWithWrongLengthFails() {
|
||||
byte[] pub = new byte[63];
|
||||
assertEquals(0x6A80, send(INS_SET_CRED_PUBK, pub).getSW(),
|
||||
"credential public key length != 64 must be rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setCredentialPubKeyAfterCommitIsLocked() {
|
||||
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
|
||||
byte[] pub = new byte[64];
|
||||
assertEquals(0x6985, send(INS_SET_CRED_PUBK, pub).getSW(),
|
||||
"writes after COMMIT must return SW_CONDITIONS_NOT_SATISFIED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setReaderPubKeyWith64BytesSucceeds() {
|
||||
byte[] pub = new byte[64];
|
||||
for (int i = 0; i < pub.length; i++) pub[i] = (byte) (i * 7);
|
||||
assertEquals(0x9000, send(INS_SET_READER_PUBK, pub).getSW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessDocumentChunkedWriteAndFinalizeRoundTrip() {
|
||||
byte[] ad = new byte[420];
|
||||
for (int i = 0; i < ad.length; i++) ad[i] = (byte) ((i * 13) ^ 0xA5);
|
||||
|
||||
// Two chunks: [0..200), [200..420)
|
||||
byte[] chunk1 = java.util.Arrays.copyOfRange(ad, 0, 200);
|
||||
byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, 420);
|
||||
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW());
|
||||
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW());
|
||||
assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, ad.length, null).getSW());
|
||||
|
||||
// Verify via the test-only hook: bytes must match.
|
||||
byte[] stored = new byte[ad.length];
|
||||
CredentialStore.get().copyAccessDocument(stored, (short) 0, (short) 0, (short) ad.length);
|
||||
org.junit.jupiter.api.Assertions.assertArrayEquals(ad, stored);
|
||||
assertEquals(ad.length, CredentialStore.get().getAccessDocumentLen());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(CredentialStore.get().hasAccessDocument());
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessDocumentWriteBeyondMaxSizeFails() {
|
||||
byte[] payload = new byte[50];
|
||||
short maxOff = (short) (CredentialStore.ACCESS_DOC_MAX_LEN - 10); // 10 bytes short of max
|
||||
assertEquals(0x6A80, sendWithP1P2(INS_WRITE_ACCESS_DOC, maxOff, payload).getSW(),
|
||||
"a 50-byte chunk at offset MAX-10 overruns and must be rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessDocumentFinalizeBeyondMaxSizeFails() {
|
||||
byte[] chunk = new byte[10];
|
||||
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
|
||||
int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1;
|
||||
assertEquals(0x6A80, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, tooBig, null).getSW(),
|
||||
"finalize length > ACCESS_DOC_MAX_LEN must be rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessDocumentWriteAfterCommitIsLocked() {
|
||||
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
|
||||
byte[] chunk = new byte[8];
|
||||
assertEquals(0x6985, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
|
||||
assertEquals(0x6985, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, 8, null).getSW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void commitReturns9000() {
|
||||
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setAfterCommitIsLocked() {
|
||||
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
|
||||
byte[] key = new byte[32];
|
||||
assertEquals(0x6985, send(INS_SET_CRED_PRIV, key).getSW(),
|
||||
"writes after COMMIT must return SW_CONDITIONS_NOT_SATISFIED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondCommitIsLocked() {
|
||||
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
|
||||
assertEquals(0x6985, send(INS_COMMIT, null).getSW(),
|
||||
"a second COMMIT must also be refused");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import com.licel.jcardsim.smartcardio.CardSimulator;
|
||||
import java.security.KeyPair;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import javacard.framework.AID;
|
||||
import javax.smartcardio.CommandAPDU;
|
||||
import javax.smartcardio.ResponseAPDU;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Test-only helper that installs {@link PersonalizationApplet}, pushes the
|
||||
* Access Credential long-term key pair and reader public key into the
|
||||
* shared {@link CredentialStore}, and COMMITs so later Aliro flow tests
|
||||
* can assume a fully-provisioned card.
|
||||
*/
|
||||
final class ProvisioningHelper {
|
||||
|
||||
private static final byte CLA = (byte) 0x80;
|
||||
private static final byte INS_SET_CRED_PRIV = (byte) 0x20;
|
||||
private static final byte INS_SET_CRED_PUBK = (byte) 0x21;
|
||||
private static final byte INS_SET_READER_PUBK = (byte) 0x22;
|
||||
private static final byte INS_COMMIT = (byte) 0x2C;
|
||||
|
||||
private ProvisioningHelper() {}
|
||||
|
||||
/** Installs PersonalizationApplet, provisions both keys, commits, and deselects. */
|
||||
static void provision(CardSimulator sim, KeyPair credential, ECPublicKey readerPub) {
|
||||
AID aid = new AID(AliroAids.PROVISIONING, (short) 0, (byte) AliroAids.PROVISIONING.length);
|
||||
sim.installApplet(aid, PersonalizationApplet.class);
|
||||
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.PROVISIONING, 256);
|
||||
assertEquals(0x9000, sim.transmitCommand(select).getSW(), "SELECT provisioning AID");
|
||||
|
||||
byte[] credPriv = toFixed32(((ECPrivateKey) credential.getPrivate()).getS().toByteArray());
|
||||
send(sim, INS_SET_CRED_PRIV, credPriv);
|
||||
|
||||
byte[] credPubRaw = extractXY((ECPublicKey) credential.getPublic());
|
||||
send(sim, INS_SET_CRED_PUBK, credPubRaw);
|
||||
|
||||
byte[] readerPubRaw = extractXY(readerPub);
|
||||
send(sim, INS_SET_READER_PUBK, readerPubRaw);
|
||||
|
||||
send(sim, INS_COMMIT, new byte[0]);
|
||||
}
|
||||
|
||||
private static void send(CardSimulator sim, byte ins, byte[] data) {
|
||||
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF, 0x00, 0x00, data, 256);
|
||||
ResponseAPDU r = sim.transmitCommand(c);
|
||||
assertEquals(0x9000, r.getSW(), "provisioning INS 0x" + Integer.toHexString(ins & 0xFF) + " failed");
|
||||
}
|
||||
|
||||
/** Returns the 64-byte x||y representation (no 0x04 prefix). */
|
||||
static byte[] extractXY(ECPublicKey pub) {
|
||||
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
|
||||
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
|
||||
byte[] out = new byte[64];
|
||||
System.arraycopy(x, 0, out, 0, 32);
|
||||
System.arraycopy(y, 0, out, 32, 32);
|
||||
return out;
|
||||
}
|
||||
|
||||
static byte[] toFixed32(byte[] b) {
|
||||
if (b.length == 32) return b;
|
||||
byte[] out = new byte[32];
|
||||
if (b.length > 32) System.arraycopy(b, b.length - 32, out, 0, 32);
|
||||
else System.arraycopy(b, 0, out, 32 - b.length, b.length);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
410
applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java
Normal file
410
applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java
Normal file
@@ -0,0 +1,410 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import com.licel.jcardsim.smartcardio.CardSimulator;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Signature;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECPoint;
|
||||
import java.security.spec.ECPublicKeySpec;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Test-only model of the Reader side of an Aliro transaction.
|
||||
*
|
||||
* <p>Holds the reader's long-term keypair (bound to the card's
|
||||
* {@link CredentialStore}), a per-transaction ephemeral keypair, the
|
||||
* reader_identifier, and the transaction_identifier. Produces AUTH0 / AUTH1
|
||||
* command payloads with correctly-computed signatures over the fields
|
||||
* specified in spec Tables 8-12 (reader auth data).
|
||||
*/
|
||||
final class ReaderSide {
|
||||
|
||||
/** Fixed usage constant for reader's AUTH1 signature, spec Table 8-12. */
|
||||
static final byte[] USAGE_READER_AUTH1 = new byte[] {
|
||||
(byte) 0x41, (byte) 0x5D, (byte) 0x95, (byte) 0x69
|
||||
};
|
||||
|
||||
/** Fixed usage constant for UD's AUTH1 signature, spec Table 8-13. */
|
||||
static final byte[] USAGE_UD_AUTH1 = new byte[] {
|
||||
(byte) 0x4E, (byte) 0x88, (byte) 0x7B, (byte) 0x4C
|
||||
};
|
||||
|
||||
/** Proprietary 0xA5 TLV that our applet returns in its SELECT FCI. Must
|
||||
* match what AliroApplet.PROPRIETARY_A5_TLV emits byte-for-byte. */
|
||||
static final byte[] PROPRIETARY_A5_TLV = {
|
||||
(byte) 0xA5, (byte) 0x08,
|
||||
(byte) 0x80, (byte) 0x02, (byte) 0x00, (byte) 0x00,
|
||||
(byte) 0x5C, (byte) 0x02, (byte) 0x01, (byte) 0x00
|
||||
};
|
||||
|
||||
private final KeyPair longTerm;
|
||||
private final byte[] readerGroupId = new byte[16];
|
||||
private final byte[] readerGroupSubId = new byte[16];
|
||||
private KeyPair ephemeral;
|
||||
private byte[] transactionId;
|
||||
|
||||
ReaderSide() {
|
||||
longTerm = Auth0Command.generateEphemeralKeyPair();
|
||||
SecureRandom rng = new SecureRandom();
|
||||
rng.nextBytes(readerGroupId);
|
||||
rng.nextBytes(readerGroupSubId);
|
||||
}
|
||||
|
||||
ECPublicKey longTermPublic() {
|
||||
return (ECPublicKey) longTerm.getPublic();
|
||||
}
|
||||
|
||||
/** Provisions this reader's long-term pubkey and a caller-owned credential keypair onto the card. */
|
||||
void provision(CardSimulator sim, KeyPair credential) {
|
||||
ProvisioningHelper.provision(sim, credential, longTermPublic());
|
||||
setCredentialLongTermPublic((ECPublicKey) credential.getPublic());
|
||||
}
|
||||
|
||||
/** Fresh ephemeral keypair + transaction_id for a new transaction. */
|
||||
void startTransaction() {
|
||||
ephemeral = Auth0Command.generateEphemeralKeyPair();
|
||||
transactionId = new byte[16];
|
||||
new SecureRandom().nextBytes(transactionId);
|
||||
}
|
||||
|
||||
byte[] buildAuth0Data() {
|
||||
byte[] readerPubUncomp = Auth0Command.encodeUncompressed((ECPublicKey) ephemeral.getPublic());
|
||||
return Auth0Command.buildStandardData(
|
||||
readerPubUncomp, readerGroupId, readerGroupSubId, transactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the AUTH1 command data for the current transaction, computing
|
||||
* a proper ECDSA-SHA-256 signature over Table 8-12 using the reader's
|
||||
* long-term key. {@code credentialEphemPubKey65} is the 65-byte
|
||||
* uncompressed credential_ePubK returned by the card in the AUTH0
|
||||
* response (tag 0x86 value).
|
||||
*/
|
||||
byte[] buildAuth1Data(byte[] credentialEphemPubKey65) {
|
||||
byte[] toSign = buildTable812(credentialEphemPubKey65);
|
||||
byte[] rawSig = signEcdsaSha256Raw(toSign, (ECPrivateKey) longTerm.getPrivate());
|
||||
|
||||
byte[] out = new byte[3 + 2 + 64];
|
||||
int i = 0;
|
||||
// command_parameters bit 0 = 1 → AUTH1 response returns credential_PubK
|
||||
// (tag 0x5A, full 65B uncompressed). Simpler for the test harness than
|
||||
// the key_slot (0x4E) path, which requires the reader to maintain a
|
||||
// lookup table keyed by SHA-1(credential_PubK)[0:8].
|
||||
out[i++] = 0x41; out[i++] = 0x01; out[i++] = 0x01;
|
||||
out[i++] = (byte) 0x9E; out[i++] = 0x40;
|
||||
System.arraycopy(rawSig, 0, out, i, 64);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the Table 8-12 byte sequence that the reader signs:
|
||||
* <pre>
|
||||
* 0x4D 0x20 [reader_identifier 32B]
|
||||
* 0x86 0x20 [credential_ePubK.x 32B]
|
||||
* 0x87 0x20 [reader_ePubK.x 32B]
|
||||
* 0x4C 0x10 [transaction_identifier 16B]
|
||||
* 0x93 0x04 0x41 0x5D 0x95 0x69
|
||||
* </pre>
|
||||
*/
|
||||
private byte[] buildTable812(byte[] credEphemPubKey65) {
|
||||
byte[] readerEphemUncomp = Auth0Command.encodeUncompressed(
|
||||
(ECPublicKey) ephemeral.getPublic());
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
write(out, 0x4D, concat(readerGroupId, readerGroupSubId));
|
||||
write(out, 0x86, java.util.Arrays.copyOfRange(credEphemPubKey65, 1, 33));
|
||||
write(out, 0x87, java.util.Arrays.copyOfRange(readerEphemUncomp, 1, 33));
|
||||
write(out, 0x4C, transactionId);
|
||||
write(out, 0x93, USAGE_READER_AUTH1);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void write(ByteArrayOutputStream out, int tag, byte[] value) {
|
||||
out.write(tag);
|
||||
out.write(value.length);
|
||||
out.write(value, 0, value.length);
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[] a, byte[] b) {
|
||||
byte[] out = new byte[a.length + b.length];
|
||||
System.arraycopy(a, 0, out, 0, a.length);
|
||||
System.arraycopy(b, 0, out, a.length, b.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs with SHA256withECDSA and returns the 64-byte raw (r||s) form.
|
||||
* JDK returns DER-encoded signatures; we strip the ASN.1 envelope.
|
||||
*/
|
||||
static byte[] signEcdsaSha256Raw(byte[] data, ECPrivateKey priv) {
|
||||
try {
|
||||
Signature s = Signature.getInstance("SHA256withECDSA");
|
||||
s.initSign(priv);
|
||||
s.update(data);
|
||||
return derToRaw(s.sign(), 32);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** ASN.1 DER ECDSA sig → fixed-length {@code coordLen} bytes of r || s. */
|
||||
static byte[] derToRaw(byte[] der, int coordLen) {
|
||||
int i = 0;
|
||||
if (der[i++] != 0x30) throw new IllegalArgumentException("expected SEQUENCE");
|
||||
int seqLen = der[i++] & 0xFF;
|
||||
if (seqLen >= 0x80) throw new IllegalArgumentException("unexpected long-form length");
|
||||
|
||||
if (der[i++] != 0x02) throw new IllegalArgumentException("expected INTEGER for r");
|
||||
int rLen = der[i++] & 0xFF;
|
||||
byte[] r = java.util.Arrays.copyOfRange(der, i, i + rLen);
|
||||
i += rLen;
|
||||
|
||||
if (der[i++] != 0x02) throw new IllegalArgumentException("expected INTEGER for s");
|
||||
int sLen = der[i++] & 0xFF;
|
||||
byte[] s = java.util.Arrays.copyOfRange(der, i, i + sLen);
|
||||
|
||||
byte[] out = new byte[coordLen * 2];
|
||||
copyFixed(r, out, 0, coordLen);
|
||||
copyFixed(s, out, coordLen, coordLen);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void copyFixed(byte[] src, byte[] dst, int dstOff, int coordLen) {
|
||||
if (src.length == coordLen) {
|
||||
System.arraycopy(src, 0, dst, dstOff, coordLen);
|
||||
} else if (src.length == coordLen + 1 && src[0] == 0) {
|
||||
System.arraycopy(src, 1, dst, dstOff, coordLen);
|
||||
} else if (src.length < coordLen) {
|
||||
System.arraycopy(src, 0, dst, dstOff + (coordLen - src.length), src.length);
|
||||
} else {
|
||||
throw new IllegalArgumentException(
|
||||
"coord length " + src.length + " > " + coordLen);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the 32-byte ExpeditedSKDevice on the reader side (mirroring
|
||||
* AliroApplet.deriveSessionKeys). Used by integration tests to decrypt
|
||||
* the UD's AUTH1 response.
|
||||
*
|
||||
* @param credentialEphemPubKey65 the 0x86 TLV value from the AUTH0 response
|
||||
*/
|
||||
byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65) {
|
||||
return java.util.Arrays.copyOfRange(
|
||||
deriveExpeditedKeyMaterial(credentialEphemPubKey65), 32, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the full 160B {@code derived_keys_volatile} per spec §8.3.1.13
|
||||
* — same as the applet computes. Layout: ExpeditedSKReader[0..32),
|
||||
* ExpeditedSKDevice[32..64), StepUpSK[64..96), BleSK[96..128),
|
||||
* URSK[128..160).
|
||||
*/
|
||||
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65) {
|
||||
byte[] zab = ecdhSharedX(
|
||||
(ECPrivateKey) ephemeral.getPrivate(),
|
||||
credentialEphemPubKey65);
|
||||
byte[] kdh = hkdf(zab, transactionId, new byte[0], 32);
|
||||
byte[] salt = buildSaltVolatile(credentialEphemPubKey65);
|
||||
byte[] info = java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33);
|
||||
return hkdf(kdh, salt, info, 160);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an AUTH1 response (encrypted_payload || auth_tag, 12-byte IV
|
||||
* = 0x00..00 0x01 || device_counter=1 per §8.3.1.6) with
|
||||
* ExpeditedSKDevice. Throws on tag-mismatch.
|
||||
*/
|
||||
static byte[] decryptAuth1Response(byte[] skDevice, byte[] ctAndTag) {
|
||||
try {
|
||||
byte[] iv = new byte[12];
|
||||
iv[7] = 0x01;
|
||||
iv[11] = 0x01; // device_counter starts at 1 for the first response
|
||||
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
gcm.init(Cipher.DECRYPT_MODE, new SecretKeySpec(skDevice, "AES"),
|
||||
new GCMParameterSpec(128, iv));
|
||||
return gcm.doFinal(ctAndTag);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("GCM decrypt failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Table 8-13 from this reader's transaction state + the UD's
|
||||
* ephemeral public key. The UD's long-term public key is not needed here
|
||||
* — this is the bytes the UD signed.
|
||||
*/
|
||||
byte[] buildTable813(byte[] credentialEphemPubKey65) {
|
||||
byte[] readerEphemUncomp = Auth0Command.encodeUncompressed(
|
||||
(ECPublicKey) ephemeral.getPublic());
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
writeTlv(out, 0x4D, concat(readerGroupId, readerGroupSubId));
|
||||
writeTlv(out, 0x86, java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33));
|
||||
writeTlv(out, 0x87, java.util.Arrays.copyOfRange(readerEphemUncomp, 1, 33));
|
||||
writeTlv(out, 0x4C, transactionId);
|
||||
writeTlv(out, 0x93, USAGE_UD_AUTH1);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an ECDSA-SHA-256 signature in raw r||s form using a public
|
||||
* key in uncompressed 0x04||x||y form. Returns true iff valid.
|
||||
*/
|
||||
static boolean verifyUdSig(byte[] credPubUncompressed65, byte[] table813, byte[] rawSig64) {
|
||||
try {
|
||||
BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(credPubUncompressed65, 1, 33));
|
||||
BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(credPubUncompressed65, 33, 65));
|
||||
java.security.spec.ECParameterSpec params =
|
||||
((ECPublicKey) Auth0Command.generateEphemeralKeyPair().getPublic()).getParams();
|
||||
ECPublicKey pub = (ECPublicKey) KeyFactory.getInstance("EC")
|
||||
.generatePublic(new ECPublicKeySpec(new ECPoint(x, y), params));
|
||||
|
||||
Signature v = Signature.getInstance("SHA256withECDSA");
|
||||
v.initVerify(pub);
|
||||
v.update(table813);
|
||||
return v.verify(rawToDer(rawSig64));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try {
|
||||
// x(reader_group_identifier_key) = this reader's long-term pubkey.x
|
||||
ECPoint lt = ((ECPublicKey) longTerm.getPublic()).getW();
|
||||
out.write(toFixed32(lt.getAffineX().toByteArray()));
|
||||
out.write(new byte[] { 'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*' });
|
||||
out.write(readerGroupId);
|
||||
out.write(readerGroupSubId);
|
||||
out.write((byte) 0x5E); // interface_byte = NFC
|
||||
out.write((byte) 0x5C);
|
||||
out.write((byte) 0x02);
|
||||
out.write((byte) 0x01); out.write((byte) 0x00); // protocol_version 0x0100
|
||||
ECPoint re = ((ECPublicKey) ephemeral.getPublic()).getW();
|
||||
out.write(toFixed32(re.getAffineX().toByteArray()));
|
||||
out.write(transactionId);
|
||||
out.write((byte) 0x00); // command_parameters = standard
|
||||
out.write((byte) 0x00); // authentication_policy = none
|
||||
out.write(PROPRIETARY_A5_TLV);
|
||||
ECPoint cl = credentialLongTermPubX();
|
||||
out.write(toFixed32(cl.getAffineX().toByteArray()));
|
||||
} catch (java.io.IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/** Returns the credential's long-term public key as an ECPoint. Set via {@link #setCredentialLongTermPublic}. */
|
||||
private ECPoint credentialLongTermPubX() {
|
||||
if (credentialLongTermPub == null) {
|
||||
throw new IllegalStateException("credentialLongTermPub not set — call setCredentialLongTermPublic()");
|
||||
}
|
||||
return credentialLongTermPub.getW();
|
||||
}
|
||||
|
||||
private ECPublicKey credentialLongTermPub;
|
||||
|
||||
/** Test wiring: tells this reader what credential_PubK the card holds so salt_volatile can include x(). */
|
||||
void setCredentialLongTermPublic(ECPublicKey pub) {
|
||||
this.credentialLongTermPub = pub;
|
||||
}
|
||||
|
||||
private static byte[] ecdhSharedX(ECPrivateKey priv, byte[] peerPubUncomp65) {
|
||||
try {
|
||||
BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(peerPubUncomp65, 1, 33));
|
||||
BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(peerPubUncomp65, 33, 65));
|
||||
java.security.spec.ECParameterSpec params = priv.getParams();
|
||||
ECPublicKey peer = (ECPublicKey) KeyFactory.getInstance("EC")
|
||||
.generatePublic(new ECPublicKeySpec(new ECPoint(x, y), params));
|
||||
javax.crypto.KeyAgreement ka = javax.crypto.KeyAgreement.getInstance("ECDH");
|
||||
ka.init(priv);
|
||||
ka.doPhase(peer, true);
|
||||
return ka.generateSecret(); // raw 32B x-coord
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** HKDF-SHA-256 (RFC 5869). */
|
||||
private static byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int L) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(salt.length == 0 ? new byte[32] : salt, "HmacSHA256"));
|
||||
byte[] prk = mac.doFinal(ikm);
|
||||
|
||||
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
|
||||
byte[] out = new byte[L];
|
||||
byte[] t = new byte[0];
|
||||
int produced = 0;
|
||||
byte counter = 1;
|
||||
while (produced < L) {
|
||||
mac.update(t);
|
||||
mac.update(info);
|
||||
mac.update(counter);
|
||||
t = mac.doFinal();
|
||||
int copy = Math.min(L - produced, t.length);
|
||||
System.arraycopy(t, 0, out, produced, copy);
|
||||
produced += copy;
|
||||
counter++;
|
||||
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
|
||||
}
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] toFixed32(byte[] in) {
|
||||
if (in.length == 32) return in;
|
||||
byte[] out = new byte[32];
|
||||
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
|
||||
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void writeTlv(ByteArrayOutputStream out, int tag, byte[] value) {
|
||||
out.write(tag);
|
||||
out.write(value.length);
|
||||
out.write(value, 0, value.length);
|
||||
}
|
||||
|
||||
/** Raw r||s → DER SEQUENCE { INTEGER r, INTEGER s } for JDK Signature.verify. */
|
||||
static byte[] rawToDer(byte[] raw64) {
|
||||
byte[] r = java.util.Arrays.copyOfRange(raw64, 0, 32);
|
||||
byte[] s = java.util.Arrays.copyOfRange(raw64, 32, 64);
|
||||
byte[] rInt = toAsn1Integer(r);
|
||||
byte[] sInt = toAsn1Integer(s);
|
||||
int seqLen = 2 + rInt.length + 2 + sInt.length;
|
||||
byte[] out = new byte[2 + seqLen];
|
||||
int o = 0;
|
||||
out[o++] = 0x30;
|
||||
out[o++] = (byte) seqLen;
|
||||
out[o++] = 0x02; out[o++] = (byte) rInt.length;
|
||||
System.arraycopy(rInt, 0, out, o, rInt.length); o += rInt.length;
|
||||
out[o++] = 0x02; out[o++] = (byte) sInt.length;
|
||||
System.arraycopy(sInt, 0, out, o, sInt.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] toAsn1Integer(byte[] in) {
|
||||
int start = 0;
|
||||
while (start < in.length - 1 && in[start] == 0 && (in[start + 1] & 0x80) == 0) start++;
|
||||
boolean needPad = (in[start] & 0x80) != 0;
|
||||
byte[] out = new byte[(needPad ? 1 : 0) + (in.length - start)];
|
||||
int o = 0;
|
||||
if (needPad) out[o++] = 0x00;
|
||||
System.arraycopy(in, start, out, o, in.length - start);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import com.licel.jcardsim.smartcardio.CardSimulator;
|
||||
import javacard.framework.AID;
|
||||
import javax.smartcardio.CommandAPDU;
|
||||
import javax.smartcardio.ResponseAPDU;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Tests for {@link StepUpApplet} — scaffold for the Aliro step-up phase
|
||||
* (spec §8.4). This first pass only covers AID selection and
|
||||
* INS-not-supported handling; the mdoc DeviceRequest/DeviceResponse +
|
||||
* StepUpSK key derivation pipelines come in follow-up iterations.
|
||||
*/
|
||||
class StepUpAppletTest {
|
||||
|
||||
private CardSimulator sim;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
CredentialStore.get().resetForTesting();
|
||||
sim = new CardSimulator();
|
||||
AID aid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
|
||||
sim.installApplet(aid, StepUpApplet.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectStepUpAidSucceeds() {
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
|
||||
ResponseAPDU r = sim.transmitCommand(select);
|
||||
assertEquals(0x9000, r.getSW(), "SELECT of step-up AID must succeed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectStepUpAidReturnsFciWithAid() {
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
|
||||
ResponseAPDU r = sim.transmitCommand(select);
|
||||
byte[] aidFromFci = TlvUtil.findTopLevel(unwrap6F(r.getData()), 0x84);
|
||||
org.junit.jupiter.api.Assertions.assertArrayEquals(AliroAids.STEP_UP, aidFromFci,
|
||||
"FCI 84 tag must echo the step-up AID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedInsReturns6D00() {
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
|
||||
sim.transmitCommand(select);
|
||||
|
||||
// Some arbitrary unsupported INS under the proprietary class.
|
||||
CommandAPDU bogus = new CommandAPDU(0x80, 0xFE, 0x00, 0x00, new byte[0], 256);
|
||||
assertEquals(0x6D00, sim.transmitCommand(bogus).getSW(),
|
||||
"unrecognized INS on step-up applet must return SW_INS_NOT_SUPPORTED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongClaReturns6E00() {
|
||||
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
|
||||
sim.transmitCommand(select);
|
||||
|
||||
CommandAPDU wrongCla = new CommandAPDU(0x81, 0xC3, 0x00, 0x00, new byte[0], 256);
|
||||
assertEquals(0x6E00, sim.transmitCommand(wrongCla).getSW(),
|
||||
"wrong CLA must return SW_CLA_NOT_SUPPORTED");
|
||||
}
|
||||
|
||||
/** Unwraps the outer 6F FCI template to expose its nested TLVs. */
|
||||
private static byte[] unwrap6F(byte[] fci) {
|
||||
if (fci.length < 2 || fci[0] != 0x6F) {
|
||||
throw new IllegalArgumentException("expected a 6F-wrapped FCI template");
|
||||
}
|
||||
int len = fci[1] & 0xFF;
|
||||
byte[] inner = new byte[len];
|
||||
System.arraycopy(fci, 2, inner, 0, len);
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
48
applet/src/test/java/com/dangerousthings/aliro/TlvUtil.java
Normal file
48
applet/src/test/java/com/dangerousthings/aliro/TlvUtil.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
/**
|
||||
* Minimal TLV reader for tests. Supports single-byte tags, multi-byte tags
|
||||
* with BER continuation encoding, and short-form lengths (< 128) plus
|
||||
* 1- and 2-byte long-form lengths.
|
||||
*/
|
||||
final class TlvUtil {
|
||||
|
||||
private TlvUtil() {}
|
||||
|
||||
/**
|
||||
* Finds the value bytes of the first TLV with the given tag within the
|
||||
* given data, searching only at the top level (not recursing into nested
|
||||
* constructed TLVs).
|
||||
*
|
||||
* @return the value bytes of the first match, or {@code null} if not found.
|
||||
*/
|
||||
static byte[] findTopLevel(byte[] data, int tag) {
|
||||
int i = 0;
|
||||
while (i < data.length) {
|
||||
int tagStart = i;
|
||||
int currentTag = data[i++] & 0xFF;
|
||||
if ((currentTag & 0x1F) == 0x1F) {
|
||||
int next;
|
||||
do {
|
||||
next = data[i++] & 0xFF;
|
||||
currentTag = (currentTag << 8) | next;
|
||||
} while ((next & 0x80) != 0);
|
||||
}
|
||||
int length = data[i++] & 0xFF;
|
||||
if (length == 0x81) {
|
||||
length = data[i++] & 0xFF;
|
||||
} else if (length == 0x82) {
|
||||
length = ((data[i++] & 0xFF) << 8) | (data[i++] & 0xFF);
|
||||
}
|
||||
if (currentTag == tag) {
|
||||
byte[] value = new byte[length];
|
||||
System.arraycopy(data, i, value, 0, length);
|
||||
return value;
|
||||
}
|
||||
i += length;
|
||||
// Silence unused-variable warning
|
||||
if (tagStart < 0) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
242
docs/plans/2026-04-17-aliro-credential-design.md
Normal file
242
docs/plans/2026-04-17-aliro-credential-design.md
Normal 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.
|
||||
874
docs/plans/2026-04-19-pcsc-bench-reader.md
Normal file
874
docs/plans/2026-04-19-pcsc-bench-reader.md
Normal 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.
|
||||
513
docs/plans/2026-04-20-nucleo-nfc10a1-port.md
Normal file
513
docs/plans/2026-04-20-nucleo-nfc10a1-port.md
Normal 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
8
harness/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
out/
|
||||
29
harness/README.md
Normal file
29
harness/README.md
Normal 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
42
harness/pyproject.toml
Normal 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"]
|
||||
0
harness/src/aliro_harness/__init__.py
Normal file
0
harness/src/aliro_harness/__init__.py
Normal file
0
harness/src/aliro_harness/issuer/__init__.py
Normal file
0
harness/src/aliro_harness/issuer/__init__.py
Normal file
82
harness/src/aliro_harness/issuer/access_document.py
Normal file
82
harness/src/aliro_harness/issuer/access_document.py
Normal 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)
|
||||
45
harness/src/aliro_harness/issuer/ca.py
Normal file
45
harness/src/aliro_harness/issuer/ca.py
Normal 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
|
||||
90
harness/src/aliro_harness/issuer/cose.py
Normal file
90
harness/src/aliro_harness/issuer/cose.py
Normal 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
|
||||
61
harness/src/aliro_harness/issuer/device_response.py
Normal file
61
harness/src/aliro_harness/issuer/device_response.py
Normal 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,
|
||||
)
|
||||
24
harness/src/aliro_harness/issuer/keys.py
Normal file
24
harness/src/aliro_harness/issuer/keys.py
Normal 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
|
||||
16
harness/src/aliro_harness/issuer/kid.py
Normal file
16
harness/src/aliro_harness/issuer/kid.py
Normal 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]
|
||||
21
harness/src/aliro_harness/personalizer/__init__.py
Normal file
21
harness/src/aliro_harness/personalizer/__init__.py
Normal 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",
|
||||
]
|
||||
51
harness/src/aliro_harness/personalizer/access_document.py
Normal file
51
harness/src/aliro_harness/personalizer/access_document.py
Normal 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
|
||||
59
harness/src/aliro_harness/personalizer/apdus.py
Normal file
59
harness/src/aliro_harness/personalizer/apdus.py
Normal 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
|
||||
101
harness/src/aliro_harness/personalizer/cli.py
Normal file
101
harness/src/aliro_harness/personalizer/cli.py
Normal 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).")
|
||||
31
harness/src/aliro_harness/personalizer/credentials.py
Normal file
31
harness/src/aliro_harness/personalizer/credentials.py
Normal 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")
|
||||
87
harness/src/aliro_harness/personalizer/orchestrator.py
Normal file
87
harness/src/aliro_harness/personalizer/orchestrator.py
Normal 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
|
||||
0
harness/src/aliro_harness/reader/__init__.py
Normal file
0
harness/src/aliro_harness/reader/__init__.py
Normal file
48
harness/src/aliro_harness/reader/auth0.py
Normal file
48
harness/src/aliro_harness/reader/auth0.py
Normal 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
|
||||
86
harness/src/aliro_harness/reader/auth1.py
Normal file
86
harness/src/aliro_harness/reader/auth1.py
Normal 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
|
||||
54
harness/src/aliro_harness/reader/auth1_response.py
Normal file
54
harness/src/aliro_harness/reader/auth1_response.py
Normal 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
|
||||
118
harness/src/aliro_harness/reader/cli.py
Normal file
118
harness/src/aliro_harness/reader/cli.py
Normal 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}")
|
||||
42
harness/src/aliro_harness/reader/crypto.py
Normal file
42
harness/src/aliro_harness/reader/crypto.py
Normal 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)
|
||||
68
harness/src/aliro_harness/reader/key_derivation.py
Normal file
68
harness/src/aliro_harness/reader/key_derivation.py
Normal 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)
|
||||
20
harness/src/aliro_harness/reader/session.py
Normal file
20
harness/src/aliro_harness/reader/session.py
Normal 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()
|
||||
38
harness/src/aliro_harness/reader/tlv.py
Normal file
38
harness/src/aliro_harness/reader/tlv.py
Normal 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
|
||||
269
harness/src/aliro_harness/reader/transaction.py
Normal file
269
harness/src/aliro_harness/reader/transaction.py
Normal 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,
|
||||
)
|
||||
0
harness/src/aliro_harness/trustgen/__init__.py
Normal file
0
harness/src/aliro_harness/trustgen/__init__.py
Normal file
93
harness/src/aliro_harness/trustgen/cli.py
Normal file
93
harness/src/aliro_harness/trustgen/cli.py
Normal 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}")
|
||||
102
harness/src/aliro_harness/trustgen/header.py
Normal file
102
harness/src/aliro_harness/trustgen/header.py
Normal 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)
|
||||
0
harness/tests/__init__.py
Normal file
0
harness/tests/__init__.py
Normal file
425
harness/tests/fake_card.py
Normal file
425
harness/tests/fake_card.py
Normal 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
|
||||
161
harness/tests/test_access_document.py
Normal file
161
harness/tests/test_access_document.py
Normal 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
39
harness/tests/test_ca.py
Normal 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),
|
||||
)
|
||||
74
harness/tests/test_cose.py
Normal file
74
harness/tests/test_cose.py
Normal 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
|
||||
97
harness/tests/test_device_response.py
Normal file
97
harness/tests/test_device_response.py
Normal 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"
|
||||
25
harness/tests/test_keys.py
Normal file
25
harness/tests/test_keys.py
Normal 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
45
harness/tests/test_kid.py
Normal 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())
|
||||
72
harness/tests/test_personalizer_access_document.py
Normal file
72
harness/tests/test_personalizer_access_document.py
Normal 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)
|
||||
61
harness/tests/test_personalizer_apdus.py
Normal file
61
harness/tests/test_personalizer_apdus.py
Normal 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
|
||||
70
harness/tests/test_personalizer_cli.py
Normal file
70
harness/tests/test_personalizer_cli.py
Normal 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
|
||||
55
harness/tests/test_personalizer_credentials.py
Normal file
55
harness/tests/test_personalizer_credentials.py
Normal 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())
|
||||
111
harness/tests/test_personalizer_orchestrator.py
Normal file
111
harness/tests/test_personalizer_orchestrator.py
Normal 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)
|
||||
72
harness/tests/test_reader_auth0.py
Normal file
72
harness/tests/test_reader_auth0.py
Normal 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])
|
||||
100
harness/tests/test_reader_auth1.py
Normal file
100
harness/tests/test_reader_auth1.py
Normal 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)
|
||||
92
harness/tests/test_reader_auth1_response.py
Normal file
92
harness/tests/test_reader_auth1_response.py
Normal 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)
|
||||
71
harness/tests/test_reader_cli.py
Normal file
71
harness/tests/test_reader_cli.py
Normal 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
|
||||
39
harness/tests/test_reader_crypto.py
Normal file
39
harness/tests/test_reader_crypto.py
Normal 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)
|
||||
78
harness/tests/test_reader_key_derivation.py
Normal file
78
harness/tests/test_reader_key_derivation.py
Normal 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)
|
||||
8
harness/tests/test_reader_session.py
Normal file
8
harness/tests/test_reader_session.py
Normal 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
|
||||
62
harness/tests/test_reader_tlv.py
Normal file
62
harness/tests/test_reader_tlv.py
Normal 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])
|
||||
101
harness/tests/test_reader_transaction_offline.py
Normal file
101
harness/tests/test_reader_transaction_offline.py
Normal 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
|
||||
)
|
||||
87
harness/tests/test_trust_header.py
Normal file
87
harness/tests/test_trust_header.py
Normal 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
|
||||
79
harness/tests/test_trustgen_cli.py
Normal file
79
harness/tests/test_trustgen_cli.py
Normal 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]
|
||||
Binary file not shown.
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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 © 2014 STMicroelectronics\
|
||||
|
||||
[](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>
|
||||
@@ -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 |
@@ -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 |
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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
Reference in New Issue
Block a user