diff --git a/.gitignore b/.gitignore index 8b2a668f5..d9ce10e4f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ secure_boot_*.pem *.pem /tools/tui/target/ /tools/emu/target/ +# The asm differential oracle builds beside its manifest (tools/emu pattern: +# detached workspace, own target dir). +/asm/oracle/target/ # `rsk-emu --store ` writes a flash image, and that image holds the device # seed in the clear — there is no OTP root under an emulator to seal it to. The # default path is the working directory, so this is the difference between a diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a71a747f..3b26c18bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,15 @@ tag: the USB `bcdDevice` build counter (bumped on every behavior change), and ## [Unreleased] +- **`rsk bench` can time ML-DSA-65 now.** `mldsa65_keygen`, `mldsa65_sign` and + `mldsa65_verify` join the never-shipped `bench` selectors, driving the real + `rsk-mldsa` keygen, signing and verification paths with fixed public inputs — + the first on-device ML-DSA timing numbers this tree has had, and the baseline + any future lattice-kernel work gets measured against. The default image is + unchanged (the selectors live behind the `bench` feature); the counter moves + because the row counts lines a build can reach. `rsk` 0.3.38. + **bcdDevice → 0x09DB.** + ## [0.4.11] - 2026-09-08 The catch-up release, and the one where the instruments were audited harder than diff --git a/asm/README.md b/asm/README.md new file mode 100644 index 000000000..2c287e128 --- /dev/null +++ b/asm/README.md @@ -0,0 +1,161 @@ + + + +# asm/ — the parallel assembly rewrite track + +Hand-written RP2350 assembly for the RS-Key authenticator, built and verified +**outside** the firmware tree. It is a parallel track: `asm/` must never touch +`firmware/`. The shipping Rust+embassy firmware remains the product; this +directory is a ground-up rewrite of selected pieces — boot image, CTAPHID +reassembly kernel, USB device driver — to prove the register facts and framing +contract independently of the Rust implementation. + +Everything is host-verified: the stateful logic is differentially tested against +the shipping Rust code, and the `firmware/` tree is not modified by anything in +here. No file here is linked into a firmware binary. + +## Layout + +| File | What | +|---|---| +| `boot.S` | M2 boot image: vector table, reset handler (.data/.bss copy), pad/FUNCSEL + GPIO blink. Publishes a `__image_def` block byte-identical to the shipping `image_def` at `0x10000114` — the pre-`pt.sh`, unpartitioned form. | +| `link.ld` | M3 memory map, region-for-region with the shipping `firmware/memory.x` default 4 MB layout: code 2560K, `KVMAIN` 1408K, `KVCNT` 128K, RAM 512K — plus the `__kvmain_start`/`__kvcnt_end` fence symbols (the exact spellings `scripts/pt.sh` reads) and ASSERTs that the map is one contiguous 4 MB with the image's own footprint out of the store. | +| `build.sh` | asm → ELF → `.bin`, then the store fence: the SAME `scripts/pt.sh` the shipping image runs over the SAME symbols, producing the flashable `boot-pt.elf`/`.uf2`. Verifies the emitted table against the symbols (the gate's `partition_table_fences_the_store` row, run against the asm image) and diffs the parsed table against the shipping firmware's — same script, same JSON, same bounds, so they must agree line for line. Deterministic; dumps `target/asm/ref.disasm` for the audit trail. Requires the `nix develop` devshell (`arm-none-eabi-gcc`, `picotool`). | +| `ctaphid.S` | CTAPHID reassembly kernel (CTAP 2.1 §11.2.9). Pure: no hardware, no allocator; parses host-controlled framing and emits events (busy/done/error/ignored). The differentially tested core. | +| `ctaphid_tx.S` | CTAPHID transmission framing kernel — the mirror of `ctaphid.S`: splits one outgoing message into 64-byte reports (an INIT then CONTs, always at least the INIT). The `cmd` byte is stored verbatim, matching the shipping `TxFrames` contract. | +| `ctaphid_init.S` | CTAPHID INIT allocation kernel (CTAP 2.1 §11.2.9.4): a persistent `next_cid` counter plus the 17-byte reply composition (nonce‖newcid LE‖iface‖version‖capabilities). The allocator wrap rule is spec-pinned (cited from the shipping tests); the values are differentially cross-checked against `CidAllocator` + `init_capabilities` + `rsk_sdk::FIRMWARE_VERSION`. | +| `ctaphid_ctrl.S` | CTAPHID transport-control predicates: the keepalive 2×2, cancel-frame detection (with the `n ∈ [5,63]` threshold the shipping tests leave unpinned), and the per-channel lock (u64 strict-expiry arithmetic, owner-only release, broadcast-INIT carve-out) with the caller supplying `now_ms` — clock-free, like the firmware feeds it. | +| `ctaphid_dispatch.S` | CTAPHID dispatcher verdicts (CTAP 2.1 §11.2.9): the pure leaves the transport consults per reassembled command — LOCK's length/`LOCK_MAX_SECONDS` clamp, WINK's capability gate, the empty-CBOR refusal, the unknown-command verdict. No framing of its own; the caller frames the error reply through `ctaphid_tx.S` and arms the lock through `ctaphid_ctrl.S`. | +| `ctaphid_wait.S` | CTAPHID worker-wait state machine (CTAP 2.1 §11.2.9): what the transport does while the compute worker runs a reassembled MSG/CBOR. The keepalive cadence (a `KEEPALIVE_MS` deadline chain, clock supplied by the caller like the lock's `now_ms`) and the mid-flight frame disposition — queued off the touch wait, dropped or cancel-signalled on it. The cancel check reuses the M8 `ctaphid_is_cancel` twin. | +| `ccid.S` | CCID message core (USB CCID 1.1): the smart-card transport's pure halves — `xfr_apdu`/`secure_apdu` payload ranging, the 10-byte response header, and `process_message`'s slot-state machine (power on/off with ATR presentation and cap clamp, slot status echo, the T=1 params block, the data-rate reply). XfrBlock/Secure earn no reply here either — they belong to the async worker in the shipping `Ccid::run`, exactly as MSG/CBOR do on the FIDO side. | +| `difftest.S` | Linux user-mode entry for the differential harness: raw EABI syscalls, no libc. | +| `difftest.c` | Differential harness driver: one 64-byte report per stdin line → `ctaphid_feed`, prints the event stream in the oracle's exact format; `T ` lines drive `ctaphid_tx.S`, `I ` lines drive `ctaphid_init.S`, `K`/`C`/`L` lines drive `ctaphid_ctrl.S`, `Q ` lines drive `ctaphid_dispatch.S` over the live lock state, `W start/up/tick/frame/done` lines drive `ctaphid_wait.S` over the live cadence, and `A`/`N`/`H`/`X`/`E`/`M` lines drive `ccid.S` (ATR select, slot-status seed, header composition, payload ranging, whole messages against a caller-sized out slice) — the frame/predicate outputs share one stream. Streams input line-by-line so no input size is truncated. | +| `difftest.sh` | Builds the ARM side + Rust oracle, then requires byte-identical event streams over the spec vectors and seeded random frames. | +| `gen_vectors.py` | Spec/reassembly vectors (CTAP 2.1 §11.2.9): single/multi-packet, gaps, cross-channel, broadcast, cap-overflow, maximum 7609-byte message; plus TX framing cases with boundary lengths. | +| `gen_random.py` | Seeded random frames for the fuzz differential: `noise` (uniform garbage around the framing), `mixed` (valid transactions with noise interleaved on live state), `tx` (random response-framing and INIT-allocation lines), `ctrl` (random keepalive/cancel/lock lines, including expiry-crossing `now_ms` sequences), `dispatch` (random command/cid/body verdict lines over a live lock, so the channel-busy guard and its carve-outs fuzz), `wait` (random cadence/touch-flag/frame sequences, so the deadline chain and the queue/drop/cancel disposition fuzz together) and `ccid` (random ATR/status drift, header composition, XfrBlock/Secure ranging with dwLength at and past the bytes present, and whole messages against caps around every reply size). | +| `oracle/` | Rust differential oracle over rsk-usb's `Reassembler`, `TxFrames` and `ccid` — the **shipping** implementations. A detached cargo workspace (the `tools/emu` pattern); links `rsk-usb` from `../../crates/rsk-usb` for host execution only. | +| `usb.S` | USB device-side driver for the RP2350 USBCTRL block: chapter-9 EP0 control transfers (device/config/string/report descriptors) plus the EP1 interrupt endpoints that carry CTAPHID. Plain MMIO, one event per `usb_task`; register facts cite pico-sdk 2.2.0 headers. | +| `usbtest.c` | Model-level USB harness: maps the USBCTRL register file + DPSRAM as plain memory under qemu-user and runs the driver against a datasheet-derived SIE model. Includes end-to-end exchanges: a CTAPHID echo through both EP1 endpoints, the INIT transaction (broadcast demand → allocation → reply field-checked: nonce echo, assigned cid, iface, versions, capabilities), and the full transport stack — dispatcher verdicts (LOCK arm/reply, WINK and empty-CBOR refusals, CHANNEL_BUSY on a locked channel) and the worker-wait cadence (PROCESSING/UPNEEDED keepalives out EP1, the touch-window CANCEL dispositions, the cancelled CBOR response) all framed through the TX kernel into EP1 IN. | +| `usbtest.sh` | Builds `usb.S` + the SIE-model harness into a static ARM ELF and runs it under `qemu-arm`. | + +## Running it + +Everything builds inside `nix develop` — `arm-none-eabi-gcc` and `picotool` +live only in the devshell. `qemu-arm` does **not**; the devshell does not ship +it (flakes are maintainer-only). Point `QEMU=` at any `qemu-arm`, e.g. the +store path. + +### Boot image + +``` +nix develop -c ./asm/build.sh +``` + +Outputs `target/asm/boot.{bin,elf}` (the bare pre-`pt.sh` form) plus +`boot-pt.{elf,uf2}` (the flashable, fenced image), `boot.disasm`/`ref.disasm`. +The build is deterministic: two runs produce byte-identical artifacts (buried +timestamps and build paths are none). The boot image's `__image_def` block +matches the shipping `image_def` byte-for-byte; `ref.disasm` backs the pad/SIO +register facts the source cites. The fence step verifies the emitted table +against the ELF's own symbols and — when the shipping firmware ELF is present +— diffs it against the shipping partition table, which it must match exactly: +same script, same JSON, same 0x280000..0x400000 bounds, NSBOOT denied over the +store. + +### CTAPHID differential + +``` +QEMU=/nix/store/m4qamr94ba84viib1l2wskzwr90hbmmn-qemu-10.2.4/bin/qemu-arm \ +nix develop -c ./asm/difftest.sh [frames_per_seed] +``` + +Default 700 frames/seed × 5 seeds × {noise, mixed, tx, ctrl, dispatch, wait, +ccid}; spec vectors always run +first. The oracle builds with an explicit host target triple (`HOST_TARGET`, +defaulting to the current `rustc` host). Green means the ARM kernels and the +shipping `rsk-usb` implementations emit **byte-identical** streams — a +divergence is an asm bug or a spec reading to adjudicate, never noise. The +RX and TX kernels also round-trip: frames emitted by `ctaphid_tx.S` fed back +into `ctaphid.S` reassemble to the original payload (verified at the +7609-byte maximum: 1 INIT + 128 CONTs). + +### USB model tests + +``` +QEMU=... nix develop -c ./asm/usbtest.sh +``` + +204 checks: chapter-9 EP0 enumeration (device/config/HID-report/string +descriptors, wLength clamp, SET_ADDRESS latch + §9.6.2 overflow stall, +GET_STATUS/interface, unknown-request stall, bus-reset address/pid clearing), +the EP1 CTAPHID data path (single/multi-packet, out-of-sequence abort, +short-packet tail zeroing, IN data-toggle tracking), an end-to-end echo +through both EP1 endpoints (request in → reassembly → TX framing → DPRAM +byte-compare, multi-frame with seq + toggle tracking, interleaved channels), +the INIT transaction end to end (broadcast demand, nonce echo, sequential +cid allocation, reply fields independently asserted), and the full transport +stack end to end (LOCK arms and answers empty, a second host earns +CHANNEL_BUSY, WINK and empty-CBOR refusals framed as CTAPHID_ERROR, the +worker-wait cadence streaming PROCESSING then UPNEEDED keepalives, the +touch-window CANCEL dispositions, the cancelled CBOR response, and the +IN data-toggle accounting across the whole section). +Fails if a wrong-register poll would hang the driver (guarded by `timeout 60`). + +## Verification status + +- **Store fence — differentially identical to the shipping table.** `build.sh` + runs the shipping `scripts/pt.sh` over the asm image's own + `__kvmain_start`/`__kvcnt_end` symbols and diffs the parsed table against + the shipping firmware's: identical, because the layout is region-for-region + the same and the script and JSON are literally the same files. Mutation- + verified through the build row: decoupling `__kvmain_start` from its region + links clean and passes the symbol self-check but is caught by the shipping + diff; breaking the map's contiguity is caught by the linker's own ASSERTs. +- **CTAPHID kernels — differentially tested.** All seven kernels byte-identical + to the shipping Rust implementations over the spec vectors and over four + million seeded random frames cumulatively (deepest single runs: 750 k and + 1 M frames; the transport-control expiry boundary, the dispatcher's LOCK + clamp / empty-CBOR refusal / WINK gate, and the worker-wait's cadence + boundary and queue/drop/cancel disposition are mutation-verified — flipping + each guard's branch is caught by the differential). The oracle links the + shipping `rsk-usb` `Reassembler`, `TxFrames`, `CidAllocator`, + `init_capabilities`, `keepalive_status`, `is_cancel_frame`, `ChannelLock` + and `rsk_sdk::FIRMWARE_VERSION`; the two output streams must `cmp` clean. + The dispatcher and worker-wait rows are pinned against Rust mirrors of the + shipping decision tables (both layers are private in `rsk-usb`): each mirror + imports the pub command/error consts and calls the pub predicates live, + which is what caught a spec-memory transcription slip — this firmware's MSG + is `0x83` (`TYPE_INIT|0x03`, ctaphid.rs:32), not the FIDO spec's `0x87`, + and the spec byte is refused as unknown. Four pieces are spec-pinned or + body-sourced rather than differential (each cited in-source): the INIT + allocator's wrap rule (the oracle's counter cannot be seeded near the wrap + boundary), the dispatcher's `len != 1` / `secs > 10` LOCK error rows (the + shipping tests leave them unpinned), and the worker-wait's + read-only-while-up_pending frame gating (the shipping + `run_with_keepalive` loop, ctaphid.rs:737-813). +- **CCID message core — differentially tested.** `ccid.S` byte-identical to + the shipping `rsk-usb` `ccid.rs` over the spec vectors and the `ccid` fuzz + mode; unlike the dispatcher and worker-wait rows this is a full + differential — `process_message`, `xfr_apdu`, `secure_apdu` and + `put_header` are all `pub`, so the oracle links and calls the live + functions, no mirror at all. The one owned datum, the private `T1_PARAMS` + block, is pinned by the differential. Mutation-verified through the + differential row: the power-on ATR clamp and the persistent + `*status = ACTIVE` write, the range clamp, the sub-header message guard, + and a T=1 params byte. The Rust `process_message` slices `out[10..17]`/ + `out[10..18]` unguarded on the params/rate replies — an out slice below + those floors panics the oracle rather than diverging, so the generators + keep sub-floor caps to the messages whose replies fit them (noted in + both generators). +- **USB driver — verified against a MODEL.** The SIE model is derived from the + datasheet, with write-to-clear behaviour and explicit host-driven EP1 OUT + delivery. Green here means datasheet-model agreement only — real-silicon + enumeration is **untested** and is the next gate. +- **Boot image — byte-identical `image_def`.** Matches the shipping block at + `0x10000114` (picotool parses it as `image def / RP2350 / ARM Secure`). The + `ref.disasm` audit trail is produced by `build.sh`. + +## Not here yet + +- **Hardware bring-up.** The USB driver is model-tested; nothing here has run + against real silicon. \ No newline at end of file diff --git a/asm/boot.S b/asm/boot.S new file mode 100644 index 000000000..3854e39b5 --- /dev/null +++ b/asm/boot.S @@ -0,0 +1,111 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* M2 skeleton: vector table + reset + GPIO wiggle. No USB, no crypto, no + * CTAP — the authenticator stays in firmware/; this boots standalone from UF2. */ + +/* Register facts, cross-checked against the shipping firmware image + * (target/asm/ref.disasm is dumped by build.sh for the audit trail): + * SIO 0xd0000000; OUT_SET +0x14, OUT_CLR +0x18, OE_SET +0x24 (SIO layout + * shared with RP2040). PADS_BANK0 0x40028000, pad n at +0x4+4*n (observed: + * 0x400280cc = pad 50, written 6 = PIO0). FUNCSEL 5 = SIO, whole-register + * write, as the shipping image does for pad setup. */ + +.equ SIO_BASE, 0xd0000000 +.equ SIO_GPIO_OUT_SET, SIO_BASE + 0x14 +.equ SIO_GPIO_OUT_CLR, SIO_BASE + 0x18 +.equ SIO_GPIO_OE_SET, SIO_BASE + 0x24 +.equ PADS_BANK0_BASE, 0x40028000 +.equ PADS_PAD_OFF, 0x4 +.equ FUNCSEL_SIO, 5 +.equ LED_GPIO, 16 /* firmware build.rs default; board-dependent */ +.equ DELAY_COUNT, 0x400000 /* ROSC-relative pacing, not a timer */ + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .vectors, "a" +.global __vectors +__vectors: + .word __stack_top + .word reset_handler + .rept 14 + .word default_halt + .endr + +/* picobin IMAGE_DEF, byte-identical to the shipping firmware's block at + * 0x10000114 (picotool parses it as "image def / RP2350 / ARM Secure"); + * w3 = 0 = no next block: the unpartitioned, pre-pt.sh form. Duplicated, + * as in the shipping image. */ +.section .image_def, "a" +__image_def: + .word 0xffffded3 /* PICOBIN_BLOCK_MARKER_START */ + .word 0x10210142 /* items {next_block, image_type}; EXE|SEC_S|ARM|RP2350 */ + .word 0x000001ff + .word 0x00000000 /* next block offset: 0 = self = chain end */ + .word 0xab123579 /* PICOBIN_BLOCK_MARKER_END */ + .word 0xffffded3 + .word 0x10210142 + .word 0x000001ff + .word 0x00000000 + .word 0xab123579 + +.section .text +.thumb_func +default_halt: + wfi + b default_halt + +.thumb_func +.global reset_handler +reset_handler: + ldr r0, =__stack_top + mov sp, r0 + ldr r0, =__vectors + ldr r1, =0xe000ed08 /* SCB_VTOR */ + str r0, [r1] + + ldr r0, =__data_load + ldr r1, =__data_start + ldr r2, =__data_end +1: cmp r1, r2 + bcs 2f + ldr r3, [r0], #4 + str r3, [r1], #4 + b 1b +2: ldr r1, =__bss_start + ldr r2, =__bss_end +3: cmp r1, r2 + bcs 4f + movs r3, #0 + str r3, [r1], #4 + b 3b +4: + ldr r1, =PADS_BANK0_BASE + movs r2, #LED_GPIO + lsls r2, r2, #2 + adds r1, r1, r2 + adds r1, #PADS_PAD_OFF + movs r2, #FUNCSEL_SIO + str r2, [r1] + + movs r4, #1 + lsls r4, r4, #LED_GPIO + ldr r5, =SIO_GPIO_OE_SET + str r4, [r5] +blink: + ldr r5, =SIO_GPIO_OUT_SET + str r4, [r5] + bl delay + ldr r5, =SIO_GPIO_OUT_CLR + str r4, [r5] + bl delay + b blink + +.thumb_func +delay: + ldr r3, =DELAY_COUNT +1: subs r3, #1 + bne 1b + bx lr diff --git a/asm/build.sh b/asm/build.sh new file mode 100755 index 000000000..ed7d374ca --- /dev/null +++ b/asm/build.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (C) 2026 RS-Key contributors +# +# M2/M3 build: asm -> ELF -> bin/UF2, deterministic, then the store fence: +# the SAME scripts/pt.sh the shipping image runs, reading the SAME +# __kvmain_start/__kvcnt_end symbols link.ld now defines — no second copy +# of the layout to keep in step. Run inside nix develop (arm-none-eabi-gcc +# and picotool live there; no new dependencies). + +set -euo pipefail +cd "$(dirname "$0")" + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ..)" +OUT="$ROOT/target/asm" +mkdir -p "$OUT" + +# Audit trail: the pad/SIO register facts in boot.S cite these sites. +arm-none-eabi-objdump -d "$ROOT/target/thumbv8m.main-none-eabihf/release/firmware" \ + > "$OUT/ref.disasm" 2>/dev/null || true + +arm-none-eabi-gcc -mcpu=cortex-m33 -mthumb -mfloat-abi=hard \ + -c boot.S -o "$OUT/boot.o" +arm-none-eabi-gcc -nostdlib -static -Wl,--build-id=none \ + -Wl,-T,link.ld -o "$OUT/boot.elf" "$OUT/boot.o" +arm-none-eabi-objcopy -O binary "$OUT/boot.elf" "$OUT/boot.bin" +arm-none-eabi-objdump -d "$OUT/boot.elf" > "$OUT/boot.disasm" +arm-none-eabi-size "$OUT/boot.elf" + +# the fence: boot.elf is the bare pre-pt form (the M2 artifact); boot-pt.elf +# is the flashable one, the same relation cargo-build firmware has to the +# pt.sh'd release image +"$ROOT/scripts/pt.sh" "$OUT/boot.elf" "$OUT/boot-pt.elf" +picotool uf2 convert "$OUT/boot-pt.elf" -t elf "$OUT/boot-pt.uf2" \ + --family rp2350-arm-s >/dev/null + +# the fence must bound the symbols it claims to: partition 1 spans +# __kvmain_start->__kvcnt_end and denies NSBOOT (the gate's +# partition_table_fences_the_store row, run here against the asm image) +want="$(arm-none-eabi-nm "$OUT/boot.elf" | awk '$3 == "__kvmain_start" { print $1 }')" +want="$want->$(arm-none-eabi-nm "$OUT/boot.elf" | awk '$3 == "__kvcnt_end" { print $1 }')" +for p in "0:NSBOOT(rw)" "1:NSBOOT(-)"; do + line=$(picotool info -a "$OUT/boot-pt.elf" | grep -E "^ +partition ${p%%:*} ") || { + echo "FAIL: no partition ${p%%:*} in the asm fence" >&2; exit 1; } + grep -q -- "${p#*:}" <<<"$line" || { + echo "FAIL: asm partition ${p%%:*} is not ${p#*:}: $line" >&2; exit 1; } +done +got=$(picotool info -a "$OUT/boot-pt.elf" | grep -E '^ +partition 1 ' | grep -oE '[0-9a-f]{8}->[0-9a-f]{8}') +[ "$got" = "$want" ] || { + echo "FAIL: asm store partition is $got but __kvmain_start..__kvcnt_end is $want" >&2 + exit 1; } +echo "asm store partition $got, NSBOOT denied; firmware partition writable" + +# differential: when the shipping firmware ELF is present, its fence must be +# the very same table — same script, same JSON, same bounds — so the parsed +# tables must agree line for line (picotool partition info needs a device; +# picotool info -a parses a file's table) +REF="$ROOT/target/thumbv8m.main-none-eabihf/release/firmware" +pt_lines() { picotool info -a "$1" 2>/dev/null | grep -E 'partition|un-partitioned'; } +if [ -f "$REF" ]; then + "$ROOT/scripts/pt.sh" "$REF" "$OUT/ref-pt.elf" >&2 + if ! diff <(pt_lines "$OUT/boot-pt.elf") <(pt_lines "$OUT/ref-pt.elf") >/dev/null; then + echo "FAIL: the asm fence differs from the shipping partition table" >&2 + diff <(pt_lines "$OUT/boot-pt.elf") <(pt_lines "$OUT/ref-pt.elf") >&2 + exit 1 + fi + echo "fence differential: identical to the shipping partition table" +fi + +sha256sum "$OUT/boot.bin" "$OUT/boot.elf" "$OUT/boot-pt.elf" +picotool info -a "$OUT/boot-pt.uf2" | head -20 diff --git a/asm/ccid.S b/asm/ccid.S new file mode 100644 index 000000000..c24ff9de7 --- /dev/null +++ b/asm/ccid.S @@ -0,0 +1,255 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* CCID message core (USB CCID 1.1 rev 1.1): the pure halves of rsk-usb's + * pub surface — xfr_apdu/secure_apdu payload ranging, put_header's 10-byte + * response header, and process_message's slot-state machine (power + * on/off, slot status, T=1 params, data rate). No I/O, no APDU handling: + * XfrBlock/Secure belong to the async worker in the shipping Ccid::run and + * earn no reply here either. Differentially tested against the LIVE rsk-usb + * functions by asm/difftest.sh — the oracle links ccid.rs itself, so every + * constant below is checked against the real ones, not restated in a + * mirror. + * + * The T1_PARAMS bytes are the one piece of data the kernel owns (ccid.rs + * keeps them private); they are pinned by the differential, not trusted. */ + +/* bulk-OUT message types (CCID 1.1 §6.1-1); the rest of the vocabulary + * (XfrBlock 0x6F, Secure 0x69) is range-checked, never dispatched */ +.equ CCID_POWER_ON, 0x62 +.equ CCID_POWER_OFF, 0x63 +.equ CCID_SET_PARAMS, 0x61 +.equ CCID_GET_PARAMS, 0x6c +.equ CCID_RESET_PARAMS, 0x6d +.equ CCID_SLOT_STATUS, 0x65 +.equ CCID_SET_RATE, 0x73 +/* bulk-IN response types (CCID 1.1 §6.2-1) */ +.equ CCID_DATA_BLOCK_RET, 0x80 +.equ CCID_SLOT_STATUS_RET, 0x81 +.equ CCID_PARAMS_RET, 0x82 +.equ CCID_SET_RATE_RET, 0x84 +/* bStatus (CCID 1.1 §6.2-6) */ +.equ STATUS_ACTIVE, 0 +.equ STATUS_INACTIVE, 1 + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .rodata +.align 2 +/* T=1 parameters, ccid.rs:109 (bmFindexDindex..bNadValue) */ +ccid_t1_params: + .byte 0x11, 0x10, 0xFE, 0x95, 0x03, 0xFE, 0x00 + +.section .text + +/* the 10-byte response header (CCID 1.1 §6.2): type, dwLength LE32, bSlot 0, + * bSeq echoed, bStatus, bError 0, bChainParameter 0. Clobbers \len (shifted + * to 0 by the LE32 stores); out is r0 throughout */ +.macro CCID_HDR typ, len, seq, stt + strb \typ, [r0, #0] + strb \len, [r0, #1] + lsrs \len, \len, #8 + strb \len, [r0, #2] + lsrs \len, \len, #8 + strb \len, [r0, #3] + lsrs \len, \len, #8 + strb \len, [r0, #4] + movs \len, #0 + strb \len, [r0, #5] + strb \seq, [r0, #6] + strb \stt, [r0, #7] + strb \len, [r0, #8] + strb \len, [r0, #9] +.endm + +/* r0 = out, r1 = msg_type, r2 = length, r3 = seq, [sp] = status + * + * The pub put_header, byte for byte. */ +.thumb_func +.global ccid_put_header +ccid_put_header: + push {r4, r5, lr} + mov r4, r2 /* HDR clobbers its length register */ + ldr r5, [sp, #12] /* the 5th argument: bStatus */ + CCID_HDR r1, r4, r3, r5 + pop {r4, r5, pc} + +/* r0 = msg, r1 = len, r2 = wanted type, r3 = range_out (2 words) + * -> r0 0 (not that type / shorter than a header) | 1 (range written: + * (HEADER, HEADER + min(dwLength, len - HEADER)) */ +.thumb_func +ccid_range: + cmp r1, #10 + blo cr_none + push {r4, r5, lr} + mov r4, r1 /* len, for the clamp below */ + ldrb r5, [r0] + cmp r5, r2 + bne cr_none_pop + ldrb r1, [r0, #1] /* dwLength, LE32 at msg[1..5] */ + ldrb r2, [r0, #2] + lsls r2, r2, #8 + orrs r1, r1, r2 + ldrb r2, [r0, #3] + lsls r2, r2, #16 + orrs r1, r1, r2 + ldrb r2, [r0, #4] + lsls r2, r2, #24 + orrs r1, r1, r2 + subs r4, r4, #10 /* available = len - HEADER */ + cmp r1, r4 + bls 1f /* dw <= available: the message holds it all */ + mov r1, r4 /* else the payload stops at the buffer end */ +1: movs r2, #10 + str r2, [r3] /* start */ + adds r2, r2, r1 + str r2, [r3, #4] /* end */ + movs r0, #1 + pop {r4, r5, pc} +cr_none_pop: + movs r0, #0 + pop {r4, r5, pc} +cr_none: + movs r0, #0 + bx lr + +/* r0 = msg, r1 = len, r2 = range_out -> r0 0/1 — the pub xfr_apdu */ +.thumb_func +.global ccid_xfr_apdu +ccid_xfr_apdu: + push {r4, lr} + mov r3, r2 + movs r2, #0x6f /* CCID_XFR_BLOCK */ + bl ccid_range + pop {r4, pc} + +/* r0 = msg, r1 = len, r2 = range_out -> r0 0/1 — the pub secure_apdu */ +.thumb_func +.global ccid_secure_apdu +ccid_secure_apdu: + push {r4, lr} + mov r3, r2 + movs r2, #0x69 /* CCID_SECURE */ + bl ccid_range + pop {r4, pc} + +/* r0 = msg, r1 = msg_len, r2 = atr, r3 = atr_len, + * [sp] = status ptr, [sp+4] = out, [sp+8] = out_cap -> r0 reply length + * + * The pub process_message: one complete bulk-OUT message in, one response + * written to out (bounded by out_cap, exactly the Rust slice length), the + * slot bStatus persisted through the status pointer. Unknown types — and + * the worker-owned XfrBlock/Secure — return 0: no response. */ +.thumb_func +.global ccid_process +ccid_process: + push {r4, r5, r6, r7, r8, lr} + cmp r1, #10 + blo cp_zero + ldr r6, [sp, #32] /* out_cap */ + cmp r6, #10 + blo cp_zero + mov r7, r2 /* atr */ + mov r8, r3 /* atr_len */ + ldrb r2, [r0, #6] /* bSeq, echoed in every reply */ + ldrb r3, [r0, #0] /* the message type */ + ldr r0, [sp, #28] /* out; the message is dead from here */ + ldr r1, [sp, #24] /* status ptr */ + /* live: r0 out, r1 status ptr, r2 seq, r3 type, r6 cap, + * r7 atr, r8 atr_len; r4/r5 scratch */ + cmp r3, #CCID_SLOT_STATUS + beq cp_slot + cmp r3, #CCID_POWER_ON + beq cp_on + cmp r3, #CCID_POWER_OFF + beq cp_off + cmp r3, #CCID_SET_PARAMS + beq cp_params + cmp r3, #CCID_GET_PARAMS + beq cp_params + cmp r3, #CCID_RESET_PARAMS + beq cp_params + cmp r3, #CCID_SET_RATE + beq cp_rate + b cp_zero + +cp_slot: + movs r4, #CCID_SLOT_STATUS_RET + ldrb r5, [r1] /* the slot's bStatus, verbatim */ + movs r6, #0 + CCID_HDR r4, r6, r2, r5 + movs r0, #10 + b cp_ret + +cp_on: + subs r6, r6, #10 /* payload room = out_cap - HEADER */ + cmp r8, r6 + blo 1f + mov r8, r6 /* n = min(atr_len, room) */ +1: movs r4, #CCID_DATA_BLOCK_RET + movs r5, #STATUS_ACTIVE + mov r6, r8 + CCID_HDR r4, r6, r2, r5 + movs r4, #0 + add r6, r0, #10 /* out[HEADER + i] */ +2: cmp r4, r8 + bhs 3f + ldrb r5, [r7, r4] + strb r5, [r6, r4] + adds r4, r4, #1 + b 2b +3: movs r5, #STATUS_ACTIVE + strb r5, [r1] + adds r0, r8, #10 + b cp_ret + +cp_off: + movs r4, #STATUS_INACTIVE + strb r4, [r1] /* power off persists before the reply cites it */ + movs r5, #CCID_SLOT_STATUS_RET + movs r6, #0 + CCID_HDR r5, r6, r2, r4 + movs r0, #10 + b cp_ret + +cp_params: + movs r4, #CCID_PARAMS_RET + ldrb r5, [r1] + movs r6, #7 /* T1_PARAMS.len */ + CCID_HDR r4, r6, r2, r5 + movs r4, #1 + strb r4, [r0, #9] /* bChainParameter slot carries bProtocolNum T=1 */ + ldr r7, =ccid_t1_params + add r6, r0, #10 + movs r4, #0 +1: cmp r4, #7 + bhs 2f + ldrb r5, [r7, r4] + strb r5, [r6, r4] + adds r4, r4, #1 + b 1b +2: movs r0, #17 + b cp_ret + +cp_rate: + movs r4, #CCID_SET_RATE_RET + ldrb r5, [r1] + movs r6, #8 /* dwLength: the 8-byte returned clock array */ + CCID_HDR r4, r6, r2, r5 + movs r4, #0 + add r5, r0, #10 + movs r6, #8 +1: strb r4, [r5], #1 + subs r6, r6, #1 + bne 1b + movs r0, #18 + b cp_ret + +cp_zero: + movs r0, #0 +cp_ret: + pop {r4, r5, r6, r7, r8, pc} + + .ltorg diff --git a/asm/ctaphid.S b/asm/ctaphid.S new file mode 100644 index 000000000..075520ef4 --- /dev/null +++ b/asm/ctaphid.S @@ -0,0 +1,225 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* CTAPHID reassembly kernel (CTAP2.1 §11.2.9). Pure: no hardware, no + * allocator; parses host-controlled framing only. Differentially tested + * against rsk-usb's Reassembler by asm/difftest.sh — the observable + * contract (event stream over identical vectors) must match byte-for-byte. */ + +/* caller-owned state, zeroed before the first feed: + * +0 cid, +4 cmd, +8 bcnt, +12 cur, +16 seq, +20 in_tx, + * +24 buf_max, +28 buf address, +32.. event out (tag/val/cid/cmd) */ +.equ ST_CID, 0 +.equ ST_CMD, 4 +.equ ST_BCNT, 8 +.equ ST_CUR, 12 +.equ ST_SEQ, 16 +.equ ST_IN_TX, 20 +.equ ST_BUF_MAX, 24 +.equ ST_BUF, 28 +.equ ST_EV_TAG, 32 +.equ ST_EV_VAL, 36 +.equ ST_EV_CID, 40 +.equ ST_EV_CMD, 44 + +/* CTAPHID framing (§11.2.9.2): INIT = cid[4 LE], cmd|0x80, bcnt[2 BE], + * data[57]; CONT = cid[4 LE], seq, data[59]. */ +.equ F_CID, 0 +.equ F_TYPE, 4 +.equ F_BCNT_HI, 5 +.equ F_BCNT_LO, 6 +.equ INIT_DATA, 7 +.equ INIT_LEN, 57 +.equ CONT_DATA, 5 +.equ CONT_LEN, 59 +.equ TYPE_INIT, 0x80 + +.equ CTAPHID_INIT_CMD, 0x86 +.equ CID_BROADCAST, 0xffffffff + +/* CTAPHID error codes (§11.2.9.1.3) */ +.equ ERR_INVALID_LEN, 0x03 +.equ ERR_INVALID_SEQ, 0x04 +.equ ERR_CHANNEL_BUSY, 0x06 +.equ ERR_INVALID_CHANNEL, 0x0b + +/* events: 0 busy, 1 done, 2 error, 3 ignored */ +.equ EV_BUSY, 0 +.equ EV_DONE, 1 +.equ EV_ERROR, 2 +.equ EV_IGNORED, 3 + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .text +.thumb_func +.global ctaphid_feed +/* r0 = state*, r1 = frame*; event lands at state+32 */ +ctaphid_feed: + push {r4, r5, r6, r7, lr} + mov r4, r0 + mov r5, r1 + + ldrb r6, [r5, #F_CID] /* cid: little-endian on the wire */ + ldrb r3, [r5, #F_CID+1] + orr r6, r6, r3, lsl #8 + ldrb r3, [r5, #F_CID+2] + orr r6, r6, r3, lsl #16 + ldrb r3, [r5, #F_CID+3] + orr r6, r6, r3, lsl #24 + + ldrb r2, [r5, #F_TYPE] + + /* cid 0 is never valid; broadcast only carries the INIT command */ + cmp r6, #0 + beq chan_error + mvn r7, #0 + cmp r6, r7 + bne chan_ok + cmp r2, #CTAPHID_INIT_CMD + beq chan_ok +chan_error: + movs r1, #ERR_INVALID_CHANNEL + b emit_error +chan_ok: + tst r2, #TYPE_INIT + beq cont_path + +init_path: + /* mid-transaction INIT that is not the allocation command */ + ldr r3, [r4, #ST_IN_TX] + cmp r3, #0 + beq init_fresh + cmp r2, #CTAPHID_INIT_CMD + beq init_fresh + ldr r3, [r4, #ST_CID] + cmp r6, r3 + bne busy_error /* other channel cannot interrupt */ + b seq_error /* same channel: abort, out of seq */ + +init_fresh: + ldrb r7, [r5, #F_BCNT_HI] /* bcnt is big-endian on the wire */ + ldrb r3, [r5, #F_BCNT_LO] + orr r7, r3, r7, lsl #8 + ldr r3, [r4, #ST_BUF_MAX] + cmp r7, r3 + bhi len_error /* tx state untouched */ + + str r6, [r4, #ST_CID] + str r2, [r4, #ST_CMD] + str r7, [r4, #ST_BCNT] + movs r3, #0 + str r3, [r4, #ST_SEQ] + cmp r7, #INIT_LEN /* n = min(bcnt, 57) */ + bls 1f + movs r7, #INIT_LEN +1: ldr r0, [r4, #ST_BUF] + adds r1, r5, #INIT_DATA + movs r2, #0 +2: cmp r2, r7 + bcs 3f + ldrb r3, [r1, r2] + strb r3, [r0, r2] + adds r2, #1 + b 2b +3: str r7, [r4, #ST_CUR] + ldr r3, [r4, #ST_BCNT] + movs r0, #0 + cmp r3, #INIT_LEN /* in_tx = bcnt > 57 */ + bls 4f + movs r0, #1 +4: str r0, [r4, #ST_IN_TX] + b finish_check + +cont_path: + ldr r3, [r4, #ST_IN_TX] + cmp r3, #0 + beq ignored_out + ldr r3, [r4, #ST_CID] + cmp r6, r3 + bne busy_error + bic r2, r2, #TYPE_INIT /* seq = type with the INIT bit off */ + ldr r3, [r4, #ST_SEQ] + cmp r2, r3 + bne seq_error + + ldr r7, [r4, #ST_BCNT] + ldr r0, [r4, #ST_CUR] + subs r1, r7, r0 + cmp r1, #CONT_LEN /* n = min(remaining, 59) */ + bls 5f + movs r1, #CONT_LEN +5: ldr r3, [r4, #ST_BUF] + adds r3, r3, r0 + adds r2, r5, #CONT_DATA + movs r0, #0 +6: cmp r0, r1 + bcs 7f + ldrb r7, [r2, r0] + strb r7, [r3, r0] + adds r0, #1 + b 6b +7: ldr r7, [r4, #ST_CUR] + adds r7, r7, r1 + str r7, [r4, #ST_CUR] + ldr r3, [r4, #ST_SEQ] + adds r3, #1 + str r3, [r4, #ST_SEQ] + +finish_check: + ldr r7, [r4, #ST_CUR] + ldr r3, [r4, #ST_BCNT] + cmp r7, r3 + bcs done_out + +busy_out: + movs r0, #EV_BUSY + str r0, [r4, #ST_EV_TAG] + movs r0, #0 + str r0, [r4, #ST_EV_VAL] + str r0, [r4, #ST_EV_CID] + str r0, [r4, #ST_EV_CMD] + pop {r4, r5, r6, r7, pc} + +ignored_out: + movs r0, #EV_IGNORED + str r0, [r4, #ST_EV_TAG] + movs r0, #0 + str r0, [r4, #ST_EV_VAL] + str r0, [r4, #ST_EV_CID] + str r0, [r4, #ST_EV_CMD] + pop {r4, r5, r6, r7, pc} + +done_out: + movs r3, #0 + str r3, [r4, #ST_IN_TX] + movs r0, #EV_DONE + str r0, [r4, #ST_EV_TAG] + str r7, [r4, #ST_EV_VAL] /* cur == bcnt */ + ldr r3, [r4, #ST_CID] + str r3, [r4, #ST_EV_CID] + ldr r3, [r4, #ST_CMD] + str r3, [r4, #ST_EV_CMD] + pop {r4, r5, r6, r7, pc} + +len_error: + movs r1, #ERR_INVALID_LEN + b emit_error +busy_error: + movs r1, #ERR_CHANNEL_BUSY + b emit_error +seq_error: + movs r3, #0 /* a sequence gap kills the tx */ + str r3, [r4, #ST_IN_TX] + movs r1, #ERR_INVALID_SEQ + b emit_error + +emit_error: + movs r0, #EV_ERROR + str r0, [r4, #ST_EV_TAG] + str r1, [r4, #ST_EV_VAL] + str r6, [r4, #ST_EV_CID] /* errors report the frame's cid */ + str r2, [r4, #ST_EV_CMD] + pop {r4, r5, r6, r7, pc} diff --git a/asm/ctaphid_ctrl.S b/asm/ctaphid_ctrl.S new file mode 100644 index 000000000..ab7228353 --- /dev/null +++ b/asm/ctaphid_ctrl.S @@ -0,0 +1,179 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* CTAPHID transport-control kernel (CTAP2.1 11.2.9): the three pure + * predicates the transport task needs to keep a host <-> device conversation + * sane — keepalive status choice, cancel detection on a frame read while a + * request is in flight, and the per-channel request lock (11.2.9.2.2). Pure, + * like asm/ctaphid.S / asm/ctaphid_tx.S / asm/ctaphid_init.S: no hardware, no + * allocator; the caller owns every byte of state. Differentially tested + * against rsk-usb's keepalive_status / is_cancel_frame / ChannelLock by + * asm/difftest.sh. + * + * The three entries are exactly the three pub fns in rsk-usb; the 2x2 + * keepalive table is pinned by keepalive_status_suppresses_processing_for_u2f_msg, + * the cancel rule by cancel_frame_detected_only_for_active_channel, and the + * lock rule (owner never blocked, strict-expiry boundary, release only by the + * owner, broadcast-INIT carve-out) by channel_lock_excludes_other_channels_until_it_expires + * and only_a_broadcast_init_survives_someone_elses_lock. */ + +/* caller-owned lock state, starts zeroed: + * +0 cid, +4 until_lo, +8 until_hi (until_ms: now + secs*1000) + * + * until_ms is stored as two 32-bit words (not a naturally aligned u64) so the + * 64-bit comparisons work unaligned-clean; 64-bit compare semantics are kept + * exactly by the strict-lo/hi software compare in ctaphid_lock_refuses. The + * owner is stored as a plain u32; until_hi is only ever touched by a full-64 + * add (carry-propagated) in arm, never read on its own. */ +.equ LOCK_CID, 0 +.equ LOCK_UNTIL_LO, 4 +.equ LOCK_UNTIL_HI, 8 + +/* keepalive status byte values (CTAP1/CTAP2 11.2.9.2.3); 0 is "no keepalive" */ +.equ STATUS_PROCESSING, 0x01 +.equ STATUS_UPNEEDED, 0x02 + +/* the wire byte a host uses to abort a running request (CTAPHID_CANCEL, + * TYPE_INIT | 0x11) — the frame's command slot and the fix IPC's 5th byte */ +.equ CANCEL_BYTE, 0x91 + +/* the one command that passes through someone else's lock: a broadcast + * allocation (CTAPHID_INIT / CID_BROADCAST) is a request for a channel id, + * not traffic on a channel (§11.2.9.1.3) */ +.equ INIT_CMD, 0x86 +.equ CID_BROADCAST, 0xffffffff + +/* a lock time in seconds * 1000 → expiry offset; the LOCK_MAX_SECONDS=10 + * guard lives in the shipping dispatcher, not here, so no clamp is applied */ +.equ MS_PER_SEC, 1000 + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .text + +/* r0 = is_cbor, r1 = up_pending -> r0 0 (none) | 1 (processing) | 2 (upneeded) + * + * The shipping truth table: a touch wait always wins (UPNEEDED), and only a + * genuinely slow CTAP2 (CBOR) op gets PROCESSING — a fast U2F MSG must stay + * silent or the host desyncs (FIDO conformance U2F-Authenticate P-3/F-2). */ +.thumb_func +.global ctaphid_keepalive_status +ctaphid_keepalive_status: + cbz r1, 1f + movs r0, #STATUS_UPNEEDED + bx lr +1: cbz r0, 2f + movs r0, #STATUS_PROCESSING + bx lr +2: movs r0, #0 + bx lr + +/* r0 = frame ptr, r1 = n (bytes read), r2 = cid -> r0 0/1 + * + * A frame read while the worker runs is a cancel only if the short read + * carried a command byte, that byte is CANCEL_BYTE, and the frame's cid + * matches the in-flight request's channel (FIDO conformance HID-1 P-10/P-15). */ +.thumb_func +.global ctaphid_is_cancel +ctaphid_is_cancel: + push {r4, r5, r6, lr} + mov r4, r0 + mov r5, r1 + mov r6, r2 + cmp r5, #5 + bcc cnl_false + ldrb r2, [r4, #4] + cmp r2, #CANCEL_BYTE + bne cnl_false + ldrb r0, [r4, #0] /* cid, little-endian on the wire */ + ldrb r1, [r4, #1] + orr r0, r0, r1, lsl #8 + ldrb r1, [r4, #2] + orr r0, r0, r1, lsl #16 + ldrb r1, [r4, #3] + orr r0, r0, r1, lsl #24 + cmp r0, r6 + bne cnl_false + movs r0, #1 + pop {r4, r5, r6, pc} +cnl_false: + movs r0, #0 + pop {r4, r5, r6, pc} + +/* r0 = state, r1 = cid, r2 = secs, r3 = now_lo, [sp] = now_hi + * + * Takes (secs > 0) or releases (secs == 0) the lock. A release only clears if + * the caller owns the lock — a non-owner cannot give someone else's lock back. + * now_ms is passed split into now_lo/now_hi (u32 hi on the stack, the + * ctaphid_init.S 5th-argument precedent) so a caller never assembles a u64 + * into an even:odd register pair just to call here. until_ms is now_ms plus a + * plain secs*1000: secs is u8-bounded (the shipping arm takes seconds: u8 and + * the harness parse enforces it), so the product always fits a word and the + * add carries into the high word — no clamp. */ +.thumb_func +.global ctaphid_lock_arm +ctaphid_lock_arm: + push {r4, r5, r6, r7, lr} + mov r4, r0 + mov r5, r1 + mov r6, r2 + ldr r7, [sp, #20] /* 5th argument: now_hi */ + cbz r6, lock_rel + str r5, [r4, #LOCK_CID] + movw r0, #MS_PER_SEC + mul r2, r6, r0 /* prod = secs * 1000 */ + adds r0, r3, r2 /* until_lo = now_lo + prod, carry set */ + adc r7, r7, #0 /* until_hi = now_hi + carry */ + str r0, [r4, #LOCK_UNTIL_LO] + str r7, [r4, #LOCK_UNTIL_HI] + pop {r4, r5, r6, r7, pc} +lock_rel: + ldr r0, [r4, #LOCK_CID] + cmp r0, r5 + bne 1f + movs r0, #0 + str r0, [r4, #LOCK_UNTIL_LO] + str r0, [r4, #LOCK_UNTIL_HI] +1: pop {r4, r5, r6, r7, pc} + +/* r0 = state, r1 = cid, r2 = cmd, r3 = now_lo, [sp] = now_hi -> r0 0/1 + * + * Whether a request from (cid, cmd) at now_ms is refused because the lock is + * held by someone else. A broadcast CTAPHID_INIT is never refused — it asks + * for a fresh channel, it is not a message on one. Otherwise blocked iff + * now_ms < until_ms strictly (now_ms == until_ms is the just-expired instant, + * pinned by channel_lock_excludes_other_channels_until_it_expires) AND the + * caller is not the owner. now_ms arrives split like arm's. */ +.thumb_func +.global ctaphid_lock_refuses +ctaphid_lock_refuses: + push {r4, r5, r6, r7, lr} + mov r4, r0 + mov r5, r1 + mov r6, r2 + ldr r7, [sp, #20] /* 5th argument: now_hi */ + mvn r0, #0 /* CID_BROADCAST */ + cmp r6, #INIT_CMD + bne rfx_check + cmp r5, r0 + beq rfx_false +rfx_check: + ldr r0, [r4, #LOCK_UNTIL_HI] + ldr r1, [r4, #LOCK_UNTIL_LO] + cmp r7, r0 + bhi rfx_false /* now_hi > until_hi: now >= until */ + bne rfx_owner /* now_hi < until_hi: strictly before */ + cmp r3, r1 /* equal high words: compare the low */ + bcc rfx_owner /* now_lo < until_lo: strictly before */ + b rfx_false /* now_lo >= until_lo: not strictly before */ +rfx_owner: + ldr r0, [r4, #LOCK_CID] + cmp r0, r5 + beq rfx_false /* the owner is never refused */ + movs r0, #1 + pop {r4, r5, r6, r7, pc} +rfx_false: + movs r0, #0 + pop {r4, r5, r6, r7, pc} \ No newline at end of file diff --git a/asm/ctaphid_dispatch.S b/asm/ctaphid_dispatch.S new file mode 100644 index 000000000..f88a72985 --- /dev/null +++ b/asm/ctaphid_dispatch.S @@ -0,0 +1,89 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* CTAPHID dispatcher decisions (CTAP2.1 11.2.9): the pure verdicts that map a + * reassembled command to the response it earns. Pure, like asm/ctaphid.S / + * asm/ctaphid_tx.S: no I/O, no framing of its own — the caller frames from the + * returned verdict and, for LOCK's success, runs ctaphid_lock_arm. Each entry + * is one concern so the transport can reorder/aggregate them freely. + * Differentially tested against the Rust mirror in asm/oracle/src/main.rs by + * asm/difftest.sh. + * + * Command/error bytes are read from rsk-usb's pub consts (ctaphid.rs:31-52). + * Provenance of the designed rows: + * wink: pinned by wink_is_refused_where_the_capability_bit_is_clear. + * lock: the <=10 arm and the empty-LOCK success reply are the code + * path the shipping dispatch arms; the len!=1 / >10 error rows + * are spec-claimed (reached in dispatch, no unit pins them). + * empty CBOR: spec-claimed (dispatch: ctaphid.rs:714-717). */ + +/* the command bytes this kernel compares against; the rest of the set + * (PING/MSG/INIT/CANCEL/ERROR) travels through the harness, not here */ +.equ CTAPHID_CBOR, 0x90 /* TYPE_INIT | 0x10 */ +.equ LOCK_MAX_SECONDS, 10 /* ctaphid.rs:162, host lock request length limit */ + +/* error codes, ctaphid.rs:46-52 — the three verdict leaves the entries return */ +.equ ERR_INVALID_CMD, 0x01 +.equ ERR_INVALID_PAR, 0x02 +.equ ERR_INVALID_LEN, 0x03 + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .text + +/* r0 = body, r1 = body_len -> r0 0 (arm) | 1 (INVALID_LEN) | 2 (INVALID_PAR) + * + * LOCK takes exactly one body byte (bcnt 1) holding the seconds 0..=10; + * anything else is a length error before the seconds are even read. */ +.thumb_func +.global ctaphid_lock_command +ctaphid_lock_command: + cmp r1, #1 + bne 1f + ldrb r0, [r0] /* body[0]: the lock seconds */ + cmp r0, #LOCK_MAX_SECONDS + bhi 2f /* secs > 10: parameter error */ + movs r0, #0 /* arm; caller runs ctaphid_lock_arm */ + bx lr +1: movs r0, #1 + bx lr +2: movs r0, #2 + bx lr + +/* r0 = can_wink -> r0 0 (wink reply) | 1 (INVALID_CMD) + * + * A build with nothing to flash refuses the command: answering an empty + * success frame would say "no indicator" and "yes, I blinked" at once. */ +.thumb_func +.global ctaphid_wink +ctaphid_wink: + cbz r0, 1f + movs r0, #0 + bx lr +1: movs r0, #1 + bx lr + +/* r0 = cmd, r1 = body_len -> r0 0 (route) | 1 (INVALID_LEN) + * + * MSG routes with any body; only an empty CBOR message (no command byte to + * run) is refused. PING/INIT also land here and always route. */ +.thumb_func +.global ctaphid_msg_guard +ctaphid_msg_guard: + cmp r0, #CTAPHID_CBOR + bne 1f + cbz r1, 2f /* empty CBOR body */ +1: movs r0, #0 + bx lr +2: movs r0, #1 + bx lr + +/* r0 = cmd -> r0 1 (INVALID_CMD). Trivial — kept so the caller's per-command + * table has a uniform leaf for everything it has not already handled. */ +.thumb_func +.global ctaphid_unknown +ctaphid_unknown: + movs r0, #1 + bx lr \ No newline at end of file diff --git a/asm/ctaphid_init.S b/asm/ctaphid_init.S new file mode 100644 index 000000000..0734e6ec2 --- /dev/null +++ b/asm/ctaphid_init.S @@ -0,0 +1,137 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* CTAPHID INIT (allocation) kernel (CTAP2.1 §11.2.9.1.3 / §11.2.9.4): maps a + * broadcast INIT demand into a fresh channel id and composes the 17-byte + * reply body. Pure, like asm/ctaphid.S / asm/ctaphid_tx.S: no hardware, no + * allocator of its own — the caller owns the state (persistent next_cid + * counter plus the small buffer the payload is composed straight into). + * Differentially tested against rsk-usb's CidAllocator + init_capabilities + * by asm/difftest.sh. + * + * The kernel does NOT frame the response: the caller passes the 17-byte + * buffer to ctaphid_tx_init(0xffffffff, 0x86, out17, 17) — the reply to a + * broadcast INIT is itself broadcast. */ + +/* caller-owned state: + * +0 next_cid */ +.equ IN_CID, 0 + +/* interface version reported in the INIT reply (CTAPHID_IF_VERSION) */ +.equ IFACE_VERSION, 2 + +/* build-time firmware version (matches rsk_sdk::FIRMWARE_VERSION default + * 5.8.0); the oracle cross-checks these against it, so they are the one + * value this kernel must not drift from */ +.equ CI_VERSION_MAJOR, 5 +.equ CI_VERSION_MINOR, 8 +.equ CI_VERSION_BUILD, 0 + +/* capability bits (§11.2.9.3): NMSG (0x08) is always clear */ +.equ CAP_WINK, 0x01 +.equ CAP_LOCK, 0x02 +.equ CAP_CBOR, 0x04 + +/* reply layout (§11.2.9.4, pinned by the shipping ctaphid.rs:646): + * nonce[8] || newcid[4 LE] || iface(1) || major(1) || minor(1) || + * build(1) || caps(1) */ +.equ INIT_NONCE_LEN, 8 +.equ INIT_CID_OFF, 8 +.equ INIT_IFACE_OFF, 12 +.equ INIT_VER_OFF, 13 +.equ INIT_CAPS_OFF, 16 +.equ INIT_RESP_LEN, 17 + +/* the first id handed out; a wrap past the two reserved values (0 and the + * broadcast CID) lands back here. Pinned by the shipping tests: allocating + * from 0xfffffffe yields 0xfffffffe then FIRST_CID; from 0xffffffff yields + * 0xffffffff then FIRST_CID. */ +.equ INIT_FIRST_CID, 0x01000000 + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .text + +/* r0 = state; sets next_cid = FIRST_CID */ +.thumb_func +.global ctaphid_init_init +ctaphid_init_init: + movw r1, #0x0000 + movt r1, #0x0100 /* INIT_FIRST_CID */ + str r1, [r0, #IN_CID] + bx lr + +/* r0 = state, r1 = nonce ptr, r2 = nonce len, r3 = can_wink, and out17 as + * the 5th argument on the stack; returns 1 on success. The payload is + * composed directly into the caller's out buffer, so the allocator counter + * is the only persistent state here. A degenerate nonce_len 0 still + * succeeds (clamped, zero-filled tail). */ +.thumb_func +.global ctaphid_init_run +ctaphid_init_run: + push {r4, r5, r6, r7, r8, lr} + mov r4, r0 /* state */ + mov r5, r1 /* nonce ptr */ + mov r6, r2 /* nonce len */ + mov r7, r3 /* can_wink */ + ldr r8, [sp, #24] /* 5th argument: out17 */ + + /* allocate: cid = next_cid, next = cid + 1, wrap the reserved Cs */ + ldr r0, [r4, #IN_CID] + adds r1, r0, #1 + beq 1f /* next == 0 (cid was 0xffffffff) */ + mvn r3, r1 + cbz r3, 1f /* next == 0xffffffff (cid was 0xfffffffe) */ + b 2f +1: movw r1, #0x0000 + movt r1, #0x0100 /* back to INIT_FIRST_CID */ +2: str r1, [r4, #IN_CID] + + /* nonce copy, clamped to 8 with a zero-filled tail */ + movs r3, #INIT_NONCE_LEN + cmp r6, r3 + bls 3f + movs r6, #INIT_NONCE_LEN +3: movs r3, #0 +4: cmp r3, r6 + bcs 5f + ldrb r2, [r5, r3] + strb r2, [r8, r3] + adds r3, #1 + b 4b +5: movs r2, #0 +6: cmp r3, #INIT_NONCE_LEN + bcs 7f + strb r2, [r8, r3] + adds r3, #1 + b 6b +7: + /* newcid[4 LE] */ + strb r0, [r8, #INIT_CID_OFF] + lsrs r0, r0, #8 + strb r0, [r8, #INIT_CID_OFF+1] + lsrs r0, r0, #8 + strb r0, [r8, #INIT_CID_OFF+2] + lsrs r0, r0, #8 + strb r0, [r8, #INIT_CID_OFF+3] + + movs r0, #IFACE_VERSION + strb r0, [r8, #INIT_IFACE_OFF] + movs r0, #CI_VERSION_MAJOR + strb r0, [r8, #INIT_VER_OFF] + movs r0, #CI_VERSION_MINOR + strb r0, [r8, #INIT_VER_OFF+1] + movs r0, #CI_VERSION_BUILD + strb r0, [r8, #INIT_VER_OFF+2] + + /* caps = LOCK|CBOR, plus WINK only when the build can wink */ + movs r0, #CAP_LOCK + orrs r0, r0, #CAP_CBOR + cbz r7, 9f + orrs r0, r0, #CAP_WINK +9: strb r0, [r8, #INIT_CAPS_OFF] + + movs r0, #1 + pop {r4, r5, r6, r7, r8, pc} \ No newline at end of file diff --git a/asm/ctaphid_tx.S b/asm/ctaphid_tx.S new file mode 100644 index 000000000..540365bd4 --- /dev/null +++ b/asm/ctaphid_tx.S @@ -0,0 +1,146 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* CTAPHID transmission framing kernel (CTAP2.1 11.2.9.2), the pure mirror + * of asm/ctaphid.S: splits one outgoing message into 64-byte HID reports + * (an INIT then CONT frames), always at least the INIT even for an empty + * payload. Differentially tested against rsk-usb's TxFrames by + * asm/difftest.sh — the frame stream over identical vectors must match + * byte-for-byte. */ + +/* caller-owned state: + * +0 cid, +4 cmd, +8 data address, +12 len, +16 off, +20 seq, + * +24 started */ +.equ TX_CID, 0 +.equ TX_CMD, 4 +.equ TX_DATA, 8 +.equ TX_LEN, 12 +.equ TX_OFF, 16 +.equ TX_SEQ, 20 +.equ TX_STARTED, 24 + +/* report layout mirrors asm/ctaphid.S (kept in sync by hand) */ +.equ F_CID, 0 +.equ F_TYPE, 4 +.equ F_BCNT_HI, 5 +.equ F_BCNT_LO, 6 +.equ INIT_DATA, 7 +.equ INIT_LEN, 57 +.equ CONT_DATA, 5 +.equ CONT_LEN, 59 + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .text + +/* r0 = state, r1 = cid, r2 = cmd (stored verbatim — callers pass the wire + * type byte, bit 7 already set, matching rsk-usb's TxFrames), r3 = data, + * len as the 5th argument on the stack */ +.thumb_func +.global ctaphid_tx_init +ctaphid_tx_init: + push {r4, lr} + ldr r4, [sp, #8] /* 5th argument */ + str r1, [r0, #TX_CID] + str r2, [r0, #TX_CMD] + str r3, [r0, #TX_DATA] + str r4, [r0, #TX_LEN] + movs r1, #0 + str r1, [r0, #TX_OFF] + str r1, [r0, #TX_SEQ] + str r1, [r0, #TX_STARTED] + pop {r4, pc} + +/* r0 = state, r1 = out[64]; returns 1 if a frame was written, 0 if done */ +.thumb_func +.global ctaphid_tx_next +ctaphid_tx_next: + push {r4, r5, r6, r7, lr} + mov r4, r0 + mov r5, r1 + /* the tail of every report is zero-padded */ + movs r2, #0 + movs r3, #0 +1: str r3, [r5, r2, lsl #2] + adds r2, #1 + cmp r2, #16 + bcc 1b + + ldr r6, [r4, #TX_STARTED] + cmp r6, #0 + bne cont_path + + /* INIT: cid LE, cmd verbatim, bcnt BE, up to 57 payload bytes */ + movs r6, #1 + str r6, [r4, #TX_STARTED] + ldr r0, [r4, #TX_CID] + strb r0, [r5, #F_CID] + lsrs r0, r0, #8 + strb r0, [r5, #F_CID+1] + lsrs r0, r0, #8 + strb r0, [r5, #F_CID+2] + lsrs r0, r0, #8 + strb r0, [r5, #F_CID+3] + ldr r0, [r4, #TX_CMD] + strb r0, [r5, #F_TYPE] /* verbatim — no bit forcing */ + ldr r0, [r4, #TX_LEN] + lsrs r6, r0, #8 + strb r6, [r5, #F_BCNT_HI] + strb r0, [r5, #F_BCNT_LO] + cmp r0, #INIT_LEN /* n = min(len, 57) */ + bls 2f + movs r0, #INIT_LEN +2: str r0, [r4, #TX_OFF] + adds r7, r5, #INIT_DATA + ldr r2, [r4, #TX_DATA] + movs r3, #0 +3: cmp r3, r0 + bcs 4f + ldrb r6, [r2, r3] + strb r6, [r7, r3] + adds r3, #1 + b 3b +4: movs r0, #1 + b tx_done + +cont_path: + ldr r6, [r4, #TX_OFF] + ldr r0, [r4, #TX_LEN] + cmp r6, r0 + bcs tx_end /* message fully sent */ + subs r6, r0, r6 /* n = len - off, before cid clobbers */ + cmp r6, #CONT_LEN + bls 5f + movs r6, #CONT_LEN +5: ldr r0, [r4, #TX_CID] + strb r0, [r5, #F_CID] + lsrs r0, r0, #8 + strb r0, [r5, #F_CID+1] + lsrs r0, r0, #8 + strb r0, [r5, #F_CID+2] + lsrs r0, r0, #8 + strb r0, [r5, #F_CID+3] + ldr r0, [r4, #TX_SEQ] + strb r0, [r5, #F_TYPE] + adds r7, r5, #CONT_DATA + ldr r2, [r4, #TX_DATA] + ldr r3, [r4, #TX_OFF] + movs r1, #0 +6: cmp r1, r6 + bcs 7f + ldrb r0, [r2, r3] + strb r0, [r7, r1] + adds r1, #1 + adds r3, #1 + b 6b +7: str r3, [r4, #TX_OFF] /* r3 advanced with the copy */ + ldr r0, [r4, #TX_SEQ] + adds r0, #1 + str r0, [r4, #TX_SEQ] + movs r0, #1 + b tx_done +tx_end: movs r0, #0 +tx_done: + pop {r4, r5, r6, r7, pc} diff --git a/asm/ctaphid_wait.S b/asm/ctaphid_wait.S new file mode 100644 index 000000000..62cab500f --- /dev/null +++ b/asm/ctaphid_wait.S @@ -0,0 +1,129 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* CTAPHID worker-wait state machine (CTAP 2.1 11.2.9): what the transport + * does while the compute worker runs a reassembled MSG/CBOR. Pure, like + * asm/ctaphid.S: no I/O, no timer — the caller advances the clock, exactly + * how the firmware's run_with_keepalive races the worker against + * Timer::after_millis(KEEPALIVE_MS). Differentially tested against the Rust + * mirror in asm/oracle/src/main.rs by asm/difftest.sh. + * + * Two concerns: + * cadence — a KEEPALIVE is due every KEEPALIVE_MS while a request is in + * flight; the timer restarts from each deadline it crosses + * (chained next += KEEPALIVE_MS), and a due tick asks + * ctaphid_keepalive_status (the M8 twin) what to send — for a + * U2F fast op that answer is "nothing". + * frames — mid-flight frames are read only while a touch is pending: + * a CANCEL on the requesting channel signals the worker (which + * answers KEEPALIVE_CANCEL itself), anything else read in that + * window is dropped, and off the touch wait nothing is read at + * all — the frame stays queued for the dispatcher after the + * response. + * + * Provenance: the cadence period is the pub KEEPALIVE_MS (ctaphid.rs:61); + * the read-only-while-up_pending gating and the drop/queue split are + * body-sourced (shipping run_with_keepalive, ctaphid.rs:737-813); the + * chained-restart arithmetic and the non-strict due boundary are + * mirror-defined (the shipping loop restarts its timer at the instant a + * deadline fires, which the model collapses to the deadline itself). */ + +/* the state: {active, next_lo, next_hi} — the lock_state pattern */ +.equ WAIT_ACTIVE, 0 +.equ WAIT_NEXT_LO, 4 +.equ WAIT_NEXT_HI, 8 + +.equ KEEPALIVE_MS, 100 /* ctaphid.rs:61, the pub streaming cadence */ + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .text + +/* r0 = state, r1 = now_lo, r2 = now_hi + * + * A MSG/CBOR request enters the worker; the first KEEPALIVE deadline is one + * cadence away. */ +.thumb_func +.global ctaphid_wait_start +ctaphid_wait_start: + movs r3, #1 + str r3, [r0, #WAIT_ACTIVE] + adds r1, r1, #KEEPALIVE_MS /* next_lo = now_lo + 100, carry */ + adc r2, r2, #0 /* next_hi += carry */ + str r1, [r0, #WAIT_NEXT_LO] + str r2, [r0, #WAIT_NEXT_HI] + bx lr + +/* r0 = state, r1 = now_lo, r2 = now_hi -> r0 0 (nothing due) | 1 (due) + * + * Due iff now >= next non-strictly — now == next is the deadline itself, the + * instant the shipping timer fires. Crossing the deadline advances next by + * one cadence, so a tick that jumps several deadlines past owes one KEEPALIVE + * per deadline crossed. An inactive wait is never due. */ +.thumb_func +.global ctaphid_wait_tick +ctaphid_wait_tick: + ldr r3, [r0, #WAIT_ACTIVE] + cbz r3, wtk_no + push {r4, r5} + ldr r4, [r0, #WAIT_NEXT_HI] + ldr r5, [r0, #WAIT_NEXT_LO] + cmp r2, r4 + bcc wtk_notdue /* now_hi < next_hi: strictly before */ + bhi wtk_due /* now_hi > next_hi */ + cmp r1, r5 + bcc wtk_notdue /* equal high words, now_lo < next_lo */ +wtk_due: + adds r5, r5, #KEEPALIVE_MS + adc r4, r4, #0 + str r5, [r0, #WAIT_NEXT_LO] + str r4, [r0, #WAIT_NEXT_HI] + movs r0, #1 + pop {r4, r5} + bx lr +wtk_notdue: + movs r0, #0 + pop {r4, r5} + bx lr +wtk_no: + movs r0, #0 + bx lr + +/* r0 = up_pending, r1 = frame, r2 = n, r3 = cid + * -> r0 0 (queued: off the touch wait, read later by the dispatcher) + * | 1 (dropped: read mid-wait, not the channel's CANCEL) + * | 2 (cancel: the requesting channel's CANCEL, signal the worker) + * + * The cancel check is the M8 ctaphid_is_cancel twin; this entry adds only + * the gating — whether the frame is observed at all. */ +.thumb_func +.global ctaphid_wait_frame +ctaphid_wait_frame: + cbz r0, wff_queue + push {r4, lr} + movs r4, #1 /* drop: the default mid-wait disposition */ + mov r0, r1 + mov r1, r2 + mov r2, r3 + bl ctaphid_is_cancel + cmp r0, #0 + beq wff_out + movs r4, #2 /* cancel: abort the worker's touch wait */ +wff_out: + movs r0, r4 + pop {r4, pc} +wff_queue: + movs r0, #0 + bx lr + +/* r0 = state + * + * The worker answered; the wait (and the cadence) end. */ +.thumb_func +.global ctaphid_wait_finish +ctaphid_wait_finish: + movs r1, #0 + str r1, [r0, #WAIT_ACTIVE] + bx lr diff --git a/asm/difftest.S b/asm/difftest.S new file mode 100644 index 000000000..ead25064e --- /dev/null +++ b/asm/difftest.S @@ -0,0 +1,54 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* Linux user-mode entry for the differential harness: the kernel supplies + * the stack; raw EABI syscalls keep the binary free of any libc. */ + +.equ SYS_EXIT, 1 +.equ SYS_READ, 3 +.equ SYS_WRITE, 4 + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .text +.thumb_func +.global _start +_start: + bl harness_main + mov r7, #SYS_EXIT + svc 0 + +.thumb_func +.global sys_mmap2 +/* r0 addr, r1 len, r2 prot, r3 flags, [sp] fd, [sp+4] pgoffset -> r0. + * The kernel syscall ABI passes args 5-6 in r4/r5, not on the stack. */ +sys_mmap2: + push {r4, r5, r7, lr} + ldr r4, [sp, #16] + ldr r5, [sp, #20] + mov r7, #192 + svc 0 + pop {r4, r5, r7, pc} + +.thumb_func +.global sys_read +/* r0 fd, r1 buf, r2 len -> r0. r7 is the EABI syscall register but + * callee-saved per AAPCS — save it or the caller's locals corrupt. */ +sys_read: + push {r7} + mov r7, #SYS_READ + svc 0 + pop {r7} + bx lr + +.thumb_func +.global sys_write +/* r0 fd, r1 buf, r2 len -> r0 */ +sys_write: + push {r7} + mov r7, #SYS_WRITE + svc 0 + pop {r7} + bx lr diff --git a/asm/difftest.c b/asm/difftest.c new file mode 100644 index 000000000..aadf25401 --- /dev/null +++ b/asm/difftest.c @@ -0,0 +1,814 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* Differential harness: one 64-byte CTAPHID report per stdin line (128 hex + * chars, '#' comments); feeds each to asm/ctaphid.S and prints the event + * stream. "T " lines instead drive asm/ctaphid_tx.S + * and print the resulting frame stream. The Rust oracle over rsk-usb's + * Reassembler / TxFrames prints the identical format; the two outputs must + * be byte-identical. Input is streamed line-by-line so no input size is + * silently truncated. */ + +struct state { + unsigned cid, cmd, bcnt, cur, seq, in_tx, buf_max; + unsigned char *buf; + unsigned ev_tag, ev_val, ev_cid, ev_cmd; +}; + +/* mirror of asm/ctaphid_tx.S's caller-owned state */ +struct tx_state { + unsigned cid, cmd; + const unsigned char *data; + unsigned len, off, seq, started; +}; + +/* mirror of asm/ctaphid_init.S's caller-owned state */ +struct init_state { + unsigned next_cid; +}; + +/* mirror of asm/ctaphid_ctrl.S's caller-owned lock state: cid plus the + * until_ms u64 split into two words (until_lo/until_hi), zero-init */ +struct lock_state { + unsigned cid; + unsigned until_lo; + unsigned until_hi; +}; + +/* mirror of asm/ctaphid_wait.S's caller-owned worker-wait state: whether a + * request is in flight plus the next KEEPALIVE deadline as a u64 split into + * two words, zero-init */ +struct wait_state { + unsigned active; + unsigned next_lo; + unsigned next_hi; +}; + +#define MSG_CAP 7609 /* CTAP_MAX_MESSAGE: 57 + 128*59 */ + +extern void ctaphid_feed(struct state *st, const unsigned char *rpt); +extern void ctaphid_tx_init(struct tx_state *st, unsigned cid, unsigned cmd, + const unsigned char *data, unsigned len); +extern unsigned ctaphid_tx_next(struct tx_state *st, unsigned char *out); +extern void ctaphid_init_init(struct init_state *st); +extern unsigned ctaphid_init_run(struct init_state *st, + const unsigned char *nonce, unsigned nonce_len, + unsigned can_wink, unsigned char *out17); +extern unsigned ctaphid_lock_command(const unsigned char *body, unsigned len); +extern unsigned ctaphid_wink(unsigned can_wink); +extern unsigned ctaphid_msg_guard(unsigned cmd, unsigned len); +extern unsigned ctaphid_unknown(unsigned cmd); +extern void ctaphid_wait_start(struct wait_state *st, unsigned now_lo, unsigned now_hi); +extern unsigned ctaphid_wait_tick(struct wait_state *st, unsigned now_lo, unsigned now_hi); +extern unsigned ctaphid_wait_frame(unsigned up_pending, const unsigned char *frame, + unsigned n, unsigned cid); +extern void ctaphid_wait_finish(struct wait_state *st); +extern unsigned ccid_process(const unsigned char *msg, unsigned msg_len, + const unsigned char *atr, unsigned atr_len, + unsigned char *status, unsigned char *out, + unsigned out_cap); +extern unsigned ccid_xfr_apdu(const unsigned char *msg, unsigned len, unsigned *range); +extern unsigned ccid_secure_apdu(const unsigned char *msg, unsigned len, unsigned *range); +extern void ccid_put_header(unsigned char *out, unsigned msg_type, unsigned length, + unsigned seq, unsigned status); +extern long sys_read(long fd, void *buf, long n); +extern long sys_write(long fd, const void *buf, long n); + +static struct state st; +static struct tx_state txs; +static struct init_state inis; +static struct lock_state lks; +static struct wait_state wst; +static unsigned char msgbuf[MSG_CAP]; +static unsigned char paybuf[MSG_CAP]; +static unsigned char tframe[64]; +static unsigned char qbody[MSG_CAP]; /* body slots for the Q dispatch line */ +static unsigned char codebuf[1]; +static unsigned long long cur_now; /* clock for Q lock guards (last L line) */ +static unsigned wait_up, wait_cbor; /* the worker's touch flag; the applet in flight */ + +/* CCID (M13): the slot's bStatus and the ATR the card presents on power-on. + * The defaults are an unpowered slot (STATUS_INACTIVE) and ATR_RSKEY + * (ccid.rs:100), byte-pinned by the differential, not restated in a mirror. */ +static unsigned char ccid_status = 1; +static unsigned char ccid_atr[256] = { + 0x3b, 0xfc, 0x13, 0x00, 0x00, 0x81, 0x31, 0xfe, 0x15, 0x80, 0x73, 0xc0, + 0x21, 0xc0, 0x56, 0x52, 0x53, 0x2d, 0x4b, 0x65, 0x79, 0x4b, +}; +static unsigned ccid_atr_len = 22; +static unsigned char ccid_out[2048]; /* MAX_CCID_MSG, the reply slice */ + +/* any real line fits with two orders of magnitude to spare: the longest is + * a maximum-size T payload at ~15.3 KB */ +static unsigned char inbuf[1 << 20]; + +/* CTAPHID framing offsets, mirroring asm/ctaphid.S. */ +#define RPT_CMD 0 +#define RPT_CID 1 +#define RPT_BCNT_HI 5 +#define RPT_BCNT_LO 6 + +/* CTAPHID command bytes the dispatcher keys on (ctaphid.rs:31-52); MSG is + * 0x83 here, not the FIDO spec's 0x87 — the shipping const is TYPE_INIT|0x03 + * and 0x87 falls to the unknown-command verdict */ +#define CMD_PING 0x81 +#define CMD_LOCK 0x84 +#define CMD_MSG 0x83 +#define CMD_WINK 0x88 +#define CMD_CBOR 0x90 +#define CMD_CANCEL 0x91 +#define CMD_ERROR 0xBF +/* dispatcher error verdicts, returned as the Q code byte (ctaphid.rs:46-52) */ +#define Q_INVALID_CMD 0x01 +#define Q_INVALID_PAR 0x02 +#define Q_INVALID_LEN 0x03 +#define Q_CHANNEL_BUSY 0x06 + +static int hexval(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static unsigned char *hex2(unsigned char *p, unsigned v) +{ + static const char d[] = "0123456789abcdef"; + *p++ = d[(v >> 4) & 0xf]; + *p++ = d[v & 0xf]; + return p; +} + +static unsigned char *hexp(unsigned char *p, unsigned v) +{ + /* plain hex, no leading zeros — matches the oracle's {:x} */ + static const char d[] = "0123456789abcdef"; + int started = 0; + for (int i = 7; i >= 0; i--) { + unsigned n = (v >> (4 * i)) & 0xf; + if (n || started || i == 0) { + *p++ = d[n]; + started = 1; + } + } + return p; +} + +static unsigned char *hex8(unsigned char *p, unsigned v) +{ + static const char d[] = "0123456789abcdef"; + for (int i = 7; i >= 0; i--) *p++ = d[(v >> (4 * i)) & 0xf]; + return p; +} + +static unsigned char *hexn(unsigned char *p, const unsigned char *b, unsigned n) +{ + static const char d[] = "0123456789abcdef"; + for (unsigned i = 0; i < n; i++) { + *p++ = d[b[i] >> 4]; + *p++ = d[b[i] & 0xf]; + } + return p; +} + +/* bare decimal, no padding — the oracle's "{}" formatting */ +static unsigned char *decn(unsigned char *p, unsigned v) +{ + char tmp[10]; + int n = 0; + do { + tmp[n++] = (char)('0' + v % 10); + v /= 10; + } while (v); + while (n) *p++ = (unsigned char)tmp[--n]; + return p; +} + +static void emit(const unsigned char *p, unsigned len) +{ + sys_write(1, p, len); +} + +/* the control lines are whitespace-separated; replicate Rust's split(' ') + * exactly so parsed field counts agree on malformed input (consecutive + * spaces → empty fields). The outer trim() leaves no leading/trailing + * separators, so `("a b c") -> 3` and `("a b") -> 3` with an empty middle. */ +#define MAX_FIELDS 6 +struct field { + const unsigned char *p; + unsigned len; +}; +static unsigned split_fields(const unsigned char *p, const unsigned char *eol, + struct field *out, unsigned max) +{ + const unsigned char *q = p; + unsigned n = 0; + while (q <= eol) { + const unsigned char *s = q; + while (q < eol && *q != ' ') q++; + if (n < max) { + out[n].p = s; + out[n].len = (unsigned)(q - s); + } + n++; + if (q >= eol) break; + q++; + } + return n; +} + +/* decimal u64, no libc: any non-digit or >u64::MAX overflows cleanly, which + * X-parses exactly where Rust's u64::from_str returns Err. The overflow guard + * uses compile-time constants for u64::MAX/10 = 1844674407370955161 and + * u64::MAX%10 = 5 — not runtime division, which would drag in __aeabi_uldivmod + * that this freestanding harness has no libc to satisfy. */ +#define U64_MAX_DIV10 1844674407370955161ULL +#define U64_MAX_MOD10 5 +static int parse_dec_u64(const unsigned char *p, unsigned len, unsigned long long *v) +{ + unsigned long long x = 0; + if (len == 0) return 0; + for (unsigned i = 0; i < len; i++) { + if (p[i] < '0' || p[i] > '9') return 0; + unsigned d = (unsigned)(p[i] - '0'); + if (x > U64_MAX_DIV10 || (x == U64_MAX_DIV10 && d > U64_MAX_MOD10)) + return 0; + x = x * 10 + d; + } + *v = x; + return 1; +} + +/* decimal u32 within [0, bound]; the cancel n and arm secs fields are bounded + * up front so an out-of-range value X-parses identically on both sides */ +static int parse_dec_u32_bounded(const unsigned char *p, unsigned len, + unsigned long long bound, unsigned *v) +{ + unsigned long long x; + if (!parse_dec_u64(p, len, &x) || x > bound) return 0; + *v = (unsigned)x; + return 1; +} + +static int parse_hex_u32(const unsigned char *p, unsigned len, unsigned *v) +{ + if (len != 8) return 0; + unsigned x = 0; + for (unsigned i = 0; i < 8; i++) { + int n = hexval(p[i]); + if (n < 0) return 0; + x = (x << 4) | (unsigned)n; + } + *v = x; + return 1; +} + +static int parse_hex_byte(const unsigned char *p, unsigned len, unsigned char *v) +{ + if (len != 2) return 0; + int hi = hexval(p[0]), lo = hexval(p[1]); + if (hi < 0 || lo < 0) return 0; + *v = (unsigned char)((hi << 4) | lo); + return 1; +} + +static int parse_hex_n(const unsigned char *p, unsigned len, unsigned char *out) +{ + if (len & 1) return 0; + for (unsigned i = 0; i < len; i += 2) { + int hi = hexval(p[i]), lo = hexval(p[i + 1]); + if (hi < 0 || lo < 0) return 0; + out[i / 2] = (unsigned char)((hi << 4) | lo); + } + return 1; +} + +extern unsigned ctaphid_keepalive_status(unsigned is_cbor, unsigned up_pending); +extern unsigned ctaphid_is_cancel(const unsigned char *frame, unsigned n, + unsigned cid); +extern void ctaphid_lock_arm(struct lock_state *st, unsigned cid, unsigned secs, + unsigned now_lo, unsigned now_hi); +extern unsigned ctaphid_lock_refuses(struct lock_state *st, unsigned cid, + unsigned cmd, unsigned now_lo, + unsigned now_hi); + +static void parse_error(void) +{ + emit((const unsigned char *)"X parse\n", 8); +} + +static void process_line(unsigned char *p, unsigned char *eol) +{ + unsigned char rpt[64]; + unsigned char out[2 * MSG_CAP + 64]; + + while (p < eol && (*p == ' ' || *p == '\t' || *p == '\r')) p++; + /* mirror the oracle's trim(): trailing whitespace too, so \r\n and + * padded lines parse identically on both sides */ + while (eol > p && (eol[-1] == ' ' || eol[-1] == '\t' || eol[-1] == '\r')) eol--; + if (p >= eol || *p == '#') return; + + if (*p == 'T' && p + 1 < eol && *(p + 1) == ' ') { + p += 2; + unsigned cid = 0, cmd = 0, plen = 0; + int ok = 1; + for (int i = 0; i < 8 && ok; i++) { + int v = (p < eol) ? hexval(*p++) : -1; + if (v < 0) ok = 0; else cid = (cid << 4) | v; + } + if (ok && p < eol && *p == ' ') p++; else ok = 0; + for (int i = 0; i < 2 && ok; i++) { + int v = (p < eol) ? hexval(*p++) : -1; + if (v < 0) ok = 0; else cmd = (cmd << 4) | v; + } + if (ok && p == eol) { + /* empty payload: the bare INIT */ + } else if (ok && p < eol && *p == ' ') { + p++; + while (p < eol) { + int hi = hexval(*p++); + int lo = (p < eol) ? hexval(*p++) : -1; + if (hi < 0 || lo < 0 || plen >= MSG_CAP) { ok = 0; break; } + paybuf[plen++] = (unsigned char)((hi << 4) | lo); + } + } else { + ok = 0; + } + if (!ok) { + emit((const unsigned char *)"X parse\n", 8); + return; + } + ctaphid_tx_init(&txs, cid, cmd, paybuf, plen); + unsigned n = 0; + while (n < 256 && ctaphid_tx_next(&txs, tframe)) { + unsigned char *o = out; + *o++ = 'F'; *o++ = ' '; + o = hexn(o, tframe, 64); + *o++ = '\n'; + emit(out, o - out); + n++; + } + if (n == 256) emit((const unsigned char *)"X runaway\n", 10); + return; + } + + if (*p == 'I' && p + 1 < eol && *(p + 1) == ' ') { + p += 2; + int can_wink, ok = 1; + if (p < eol && (*p == '0' || *p == '1')) { + can_wink = (*p == '1'); + p++; + } else { + ok = 0; + } + if (ok && p < eol && *p == ' ') p++; else ok = 0; + unsigned char nonce[8]; + for (int i = 0; i < 8 && ok; i++) { + int hi = (p < eol) ? hexval(*p++) : -1; + int lo = (p < eol) ? hexval(*p++) : -1; + if (hi < 0 || lo < 0) ok = 0; else nonce[i] = (unsigned char)((hi << 4) | lo); + } + if (ok && p != eol) ok = 0; /* nonce must be exactly 8 bytes */ + if (!ok) { + emit((const unsigned char *)"X parse\n", 8); + return; + } + unsigned char out17[17]; + ctaphid_init_run(&inis, nonce, 8, can_wink, out17); + ctaphid_tx_init(&txs, 0xffffffffu, 0x86, out17, 17); + unsigned n = 0; + while (n < 256 && ctaphid_tx_next(&txs, tframe)) { + unsigned char *o = out; + *o++ = 'F'; *o++ = ' '; + o = hexn(o, tframe, 64); + *o++ = '\n'; + emit(out, o - out); + n++; + } + return; + } + + /* keepalive status: "K " -> "S 00|01|02" */ + if (*p == 'K' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + if (nf != 2 || f[0].len != 1 || f[1].len != 1 || + !((f[0].p[0] == '0' || f[0].p[0] == '1') && + (f[1].p[0] == '0' || f[1].p[0] == '1'))) { + parse_error(); + return; + } + unsigned r = ctaphid_keepalive_status( + (unsigned)(f[0].p[0] - '0'), (unsigned)(f[1].p[0] - '0')); + unsigned char *o = out; + *o++ = 'S'; *o++ = ' '; + o = hex2(o, r); + *o++ = '\n'; + emit(out, o - out); + return; + } + + /* cancel detection: "C <128-hex frame> " -> "C 0|1" */ + if (*p == 'C' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + unsigned char frame[64], cmd; + unsigned n, cid; + int ok = nf == 3 && f[0].len == 128 && parse_hex_n(f[0].p, f[0].len, frame) && + parse_dec_u32_bounded(f[1].p, f[1].len, 64, &n) && + parse_hex_u32(f[2].p, f[2].len, &cid); + if (!ok) { + parse_error(); + return; + } + unsigned r = ctaphid_is_cancel(frame, n, cid); + unsigned char *o = out; + *o++ = 'C'; *o++ = ' '; + *o++ = (unsigned char)('0' + r); *o++ = '\n'; + emit(out, o - out); + return; + } + + /* channel lock: "L arm " persists the + * lock state; "L refuse " -> "R 0|1". */ + if (*p == 'L' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + unsigned cid, secs, now_lo, now_hi; + unsigned char cmd; + unsigned long long now; + if (nf != 4) { + parse_error(); + return; + } + if (f[0].len == 3 && f[0].p[0] == 'a' && f[0].p[0 + 1] == 'r' && f[0].p[2] == 'm') { + if (!(parse_hex_u32(f[1].p, f[1].len, &cid) && + parse_dec_u32_bounded(f[2].p, f[2].len, 255, &secs) && + parse_dec_u64(f[3].p, f[3].len, &now))) { + parse_error(); + return; + } + now_lo = (unsigned)now; + now_hi = (unsigned)(now >> 32); + cur_now = now; /* Q lock guards share the L branch's clock */ + ctaphid_lock_arm(&lks, cid, secs, now_lo, now_hi); + return; /* arm emits nothing; the lock persists for later lines */ + } + if (f[0].len == 6 && f[0].p[0] == 'r' && f[0].p[1] == 'e' && + f[0].p[2] == 'f' && f[0].p[3] == 'u' && f[0].p[4] == 's' && + f[0].p[5] == 'e') { + if (!(parse_hex_u32(f[1].p, f[1].len, &cid) && + parse_hex_byte(f[2].p, f[2].len, &cmd) && + parse_dec_u64(f[3].p, f[3].len, &now))) { + parse_error(); + return; + } + now_lo = (unsigned)now; + now_hi = (unsigned)(now >> 32); + cur_now = now; /* Q lock guards share the L branch's clock */ + unsigned r = ctaphid_lock_refuses(&lks, cid, (unsigned)cmd, now_lo, now_hi); + unsigned char *o = out; + *o++ = 'R'; *o++ = ' '; + *o++ = (unsigned char)('0' + r); *o++ = '\n'; + emit(out, o - out); + return; + } + parse_error(); + return; + } + + /* dispatcher verdicts: "Q [body-hex]" + * -> "Q " (00 = route/no-transport-response, else the error + * code); an immediate error verdict also frames CTAPHID_ERROR though tx. + * A missing body field is the empty body (the trailing-space trim would + * swallow a bare empty field), matching the T line's bare-INIT handling. + * The lock guard consults the persistent lock + the L branch's clock. */ + if (*p == 'Q' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + unsigned char cmd, can_wink; + unsigned cid, blen = 0; + int ok = (nf == 3 || nf == 4) && f[0].len == 1 && + (f[0].p[0] == '0' || f[0].p[0] == '1') && + parse_hex_byte(f[1].p, f[1].len, &cmd) && + parse_hex_u32(f[2].p, f[2].len, &cid); + if (ok && nf == 4) { + if ((f[3].len & 1) == 0) { + blen = f[3].len / 2; + if (blen) ok = (blen <= MSG_CAP) && parse_hex_n(f[3].p, f[3].len, qbody); + } else { + ok = 0; + } + } + if (!ok) { + parse_error(); + return; + } + can_wink = (unsigned char)(f[0].p[0] - '0'); + unsigned code = 0x00; + int frame = 0; + if (cmd == CMD_CANCEL) { + /* never acknowledged, never errored, in any state */ + } else if (cmd == CMD_PING || cmd == CMD_MSG || cmd == CMD_CBOR) { + /* the lock guard fires on a refused channel, before any routing */ + unsigned now_lo = (unsigned)cur_now, now_hi = (unsigned)(cur_now >> 32); + if (ctaphid_lock_refuses(&lks, cid, (unsigned)cmd, now_lo, now_hi)) { + code = Q_CHANNEL_BUSY; frame = 1; + } else if (ctaphid_msg_guard((unsigned)cmd, blen) == 1) { + code = Q_INVALID_LEN; frame = 1; /* only an empty CBOR refuses */ + } else { + code = 0x00; /* route */ + } + } else if (cmd == CMD_LOCK) { + unsigned r = ctaphid_lock_command(qbody, blen); + if (r == 2) { code = Q_INVALID_PAR; frame = 1; } + else if (r == 1) { code = Q_INVALID_LEN; frame = 1; } + else { + /* arm, the caller-side half of the dispatcher's LOCK verdict */ + unsigned now_lo = (unsigned)cur_now, now_hi = (unsigned)(cur_now >> 32); + ctaphid_lock_arm(&lks, cid, qbody[0], now_lo, now_hi); + code = 0x00; + } + } else if (cmd == CMD_WINK) { + if (ctaphid_wink((unsigned)can_wink) != 0) { code = Q_INVALID_CMD; frame = 1; } + else { code = 0x00; } /* empty wink reply */ + } else { + ctaphid_unknown((unsigned)cmd); + code = Q_INVALID_CMD; frame = 1; + } + unsigned char *o = out; + *o++ = 'Q'; *o++ = ' '; + o = hex2(o, code); + *o++ = '\n'; + emit(out, o - out); + if (frame) { + codebuf[0] = (unsigned char)code; + ctaphid_tx_init(&txs, cid, CMD_ERROR, codebuf, 1); + unsigned n = 0; + while (n < 256 && ctaphid_tx_next(&txs, tframe)) { + o = out; + *o++ = 'F'; *o++ = ' '; + o = hexn(o, tframe, 64); + *o++ = '\n'; + emit(out, o - out); + n++; + } + } + return; + } + + /* worker-wait orchestration (M10): "W start " + * arms the cadence, "W up <0|1>" sets the worker's touch flag, + * "W tick " -> one "W ka <00|01|02>" per 100 ms deadline + * crossed (00 = the U2F fast-op silence, the M8 S-line encoding), + * "W frame " -> "W r 0|1|2" + * (0 queued off the touch wait, 1 dropped mid-wait, 2 cancel signalled), + * "W done" ends the wait. The tick's catch-up loop is capped so a huge + * clock jump cannot hang either side; the cap matches the oracle's. */ + if (*p == 'W' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + unsigned long long now; + if (nf == 3 && f[0].len == 5 && f[0].p[0] == 's' && f[0].p[1] == 't' && + f[0].p[2] == 'a' && f[0].p[3] == 'r' && f[0].p[4] == 't') { + if (!(f[1].len == 1 && (f[1].p[0] == '0' || f[1].p[0] == '1') && + parse_dec_u64(f[2].p, f[2].len, &now))) { + parse_error(); + return; + } + wait_cbor = (unsigned)(f[1].p[0] - '0'); + ctaphid_wait_start(&wst, (unsigned)now, (unsigned)(now >> 32)); + return; /* start emits nothing; the cadence persists */ + } + if (nf == 2 && f[0].len == 2 && f[0].p[0] == 'u' && f[0].p[1] == 'p') { + if (!(f[1].len == 1 && (f[1].p[0] == '0' || f[1].p[0] == '1'))) { + parse_error(); + return; + } + wait_up = (unsigned)(f[1].p[0] - '0'); + return; /* the flag flip emits nothing; the next tick reads it */ + } + if (nf == 2 && f[0].len == 4 && f[0].p[0] == 't' && f[0].p[1] == 'i' && + f[0].p[2] == 'c' && f[0].p[3] == 'k') { + if (!parse_dec_u64(f[1].p, f[1].len, &now)) { + parse_error(); + return; + } + for (unsigned i = 0; i < 65536; i++) { + if (!ctaphid_wait_tick(&wst, (unsigned)now, (unsigned)(now >> 32))) + break; + unsigned s = ctaphid_keepalive_status(wait_cbor, wait_up); + unsigned char *o = out; + *o++ = 'W'; *o++ = ' '; *o++ = 'k'; *o++ = 'a'; *o++ = ' '; + o = hex2(o, s); + *o++ = '\n'; + emit(out, o - out); + } + return; + } + if (nf == 4 && f[0].len == 5 && f[0].p[0] == 'f' && f[0].p[1] == 'r' && + f[0].p[2] == 'a' && f[0].p[3] == 'm' && f[0].p[4] == 'e') { + unsigned char wframe[64]; + unsigned n, cid; + if (!(f[1].len == 128 && parse_hex_n(f[1].p, f[1].len, wframe) && + parse_dec_u32_bounded(f[2].p, f[2].len, 64, &n) && + parse_hex_u32(f[3].p, f[3].len, &cid))) { + parse_error(); + return; + } + unsigned r = ctaphid_wait_frame(wait_up, wframe, n, cid); + unsigned char *o = out; + *o++ = 'W'; *o++ = ' '; *o++ = 'r'; *o++ = ' '; + *o++ = (unsigned char)('0' + r); *o++ = '\n'; + emit(out, o - out); + return; + } + if (nf == 1 && f[0].len == 4 && f[0].p[0] == 'd' && f[0].p[1] == 'o' && + f[0].p[2] == 'n' && f[0].p[3] == 'e') { + ctaphid_wait_finish(&wst); + return; /* the response itself is a T line, the caller's */ + } + parse_error(); + return; + } + + /* CCID (M13): "A " selects the ATR the card presents (persists, + * emits nothing), "N <2-hex>" seeds the slot bStatus, "H + * " -> "H <20-hex>" pins the + * response header alone, "X|E " -> "X|E 0" or "X|E 1 + * " ranges an XfrBlock/Secure payload, and "M " + * runs one whole message -> "M " (n 0: no response). + * A cap in [10,17] is only fed to non-params/non-rate traffic: the Rust + * process_message slices out[10..17]/out[10..18] unguarded there and + * panics on its own bounds — a crash, not a divergence — so the + * generators keep that window to the messages that fit it. */ + if (*p == 'A' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + if (nf != 1 || (f[0].len & 1) != 0 || f[0].len / 2 > sizeof ccid_atr || + (f[0].len && !parse_hex_n(f[0].p, f[0].len, ccid_atr))) { + parse_error(); + return; + } + ccid_atr_len = f[0].len / 2; + return; /* persists; the next M line reads it */ + } + + if (*p == 'N' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + unsigned char v; + if (nf != 1 || !parse_hex_byte(f[0].p, f[0].len, &v)) { + parse_error(); + return; + } + ccid_status = v; + return; + } + + if (*p == 'H' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + unsigned char type, seq, stt; + unsigned long long len; + if (nf != 4 || !parse_hex_byte(f[0].p, f[0].len, &type) || + !parse_dec_u64(f[1].p, f[1].len, &len) || len > 0xffffffffull || + !parse_hex_byte(f[2].p, f[2].len, &seq) || + !parse_hex_byte(f[3].p, f[3].len, &stt)) { + parse_error(); + return; + } + ccid_put_header(ccid_out, type, (unsigned)len, seq, stt); + unsigned char *o = out; + *o++ = 'H'; *o++ = ' '; + o = hexn(o, ccid_out, 10); + *o++ = '\n'; + emit(out, o - out); + return; + } + + if ((*p == 'X' || *p == 'E') && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + if (nf != 1 || (f[0].len & 1) != 0 || f[0].len / 2 > MSG_CAP || + (f[0].len && !parse_hex_n(f[0].p, f[0].len, paybuf))) { + parse_error(); + return; + } + unsigned range[2]; + unsigned r = (*p == 'X') ? ccid_xfr_apdu(paybuf, f[0].len / 2, range) + : ccid_secure_apdu(paybuf, f[0].len / 2, range); + unsigned char *o = out; + *o++ = *p; *o++ = ' '; + if (r) { + *o++ = '1'; *o++ = ' '; + o = decn(o, range[0]); *o++ = ' '; + o = decn(o, range[1]); + } else { + *o++ = '0'; + } + *o++ = '\n'; + emit(out, o - out); + return; + } + + if (*p == 'M' && p + 1 < eol && *(p + 1) == ' ') { + struct field f[MAX_FIELDS]; + unsigned nf = split_fields(p + 2, eol, f, MAX_FIELDS); + unsigned cap; + if (nf != 2 || !parse_dec_u32_bounded(f[0].p, f[0].len, 2048, &cap) || + (f[1].len & 1) != 0 || f[1].len / 2 > MSG_CAP || + (f[1].len && !parse_hex_n(f[1].p, f[1].len, paybuf))) { + parse_error(); + return; + } + for (unsigned i = 0; i < cap; i++) ccid_out[i] = 0; + unsigned n = ccid_process(paybuf, f[1].len / 2, ccid_atr, ccid_atr_len, + &ccid_status, ccid_out, cap); + unsigned char *o = out; + *o++ = 'M'; *o++ = ' '; + o = decn(o, n); + if (n) { + *o++ = ' '; + o = hexn(o, ccid_out, n); + } + *o++ = '\n'; + emit(out, o - out); + return; + } + + for (int i = 0; i < 64; i++) rpt[i] = 0; + int ok = 1; + for (int i = 0; i < 64; i++) { + int hi = hexval(*p++); + int lo = (p < eol) ? hexval(*p++) : -1; + if (hi < 0 || lo < 0) { ok = 0; break; } + rpt[i] = (unsigned char)((hi << 4) | lo); + } + if (!ok) { + emit((const unsigned char *)"X parse\n", 8); + return; + } + + ctaphid_feed(&st, rpt); + unsigned tag = st.ev_tag, val = st.ev_val; + unsigned char *o = out; + switch (tag) { + case 0: + *o++ = 'B'; *o++ = '\n'; + break; + case 1: + *o++ = 'D'; *o++ = ' '; + o = hex8(o, st.ev_cid); *o++ = ' '; + o = hex2(o, st.ev_cmd); *o++ = ' '; + o = hexp(o, val); *o++ = ' '; + o = hexn(o, msgbuf, val); *o++ = '\n'; + break; + case 2: + *o++ = 'E'; *o++ = ' '; + o = hex8(o, st.ev_cid); *o++ = ' '; + o = hex2(o, val); *o++ = '\n'; + break; + default: + *o++ = 'I'; *o++ = '\n'; + break; + } + emit(out, o - out); +} + +int harness_main(void) +{ + st.buf_max = MSG_CAP; + st.buf = msgbuf; + ctaphid_init_init(&inis); + + unsigned have = 0; + for (;;) { + long n = sys_read(0, inbuf + have, sizeof inbuf - have); + if (n <= 0) break; + have += (unsigned)n; + + unsigned char *line = inbuf; + unsigned char *end = inbuf + have; + while (line < end) { + unsigned char *eol = line; + while (eol < end && *eol != '\n') eol++; + if (eol == end) break; + process_line(line, eol); + line = eol + 1; + } + if (line == inbuf) { + if (have == sizeof inbuf) { + /* a line with no room to exist: drop it, keep streaming */ + emit((const unsigned char *)"X parse\n", 8); + have = 0; + } + continue; + } + have = end - line; + for (unsigned i = 0; i < have; i++) inbuf[i] = line[i]; + } + if (have > 0) process_line(inbuf, inbuf + have); + return 0; +} diff --git a/asm/difftest.sh b/asm/difftest.sh new file mode 100755 index 000000000..6452cd95a --- /dev/null +++ b/asm/difftest.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (C) 2026 RS-Key contributors +# +# Differential harness for asm/ctaphid.S. Builds the ARM side (a static +# Linux ELF, no libc, raw EABI syscalls — assembled with the firmware's +# arm-none-eabi toolchain) and the Rust oracle over rsk-usb's Reassembler, +# then requires byte-identical event streams over the spec vectors and +# seeded random frames. +# +# usage: difftest.sh [fuzz_frames_per_seed] (default 700, 5 seeds) +# qemu: qemu-arm must be in PATH, or set QEMU=/path/to/qemu-arm. +# The devshell does not ship qemu (flakes are maintainer-only); +# QEMU=$(ls -d /nix/store/*-qemu-*/bin/qemu-arm | head -1) works. + +set -euo pipefail +cd "$(dirname "$0")" +OUT="${DIFFTEST_OUT:-$(git rev-parse --show-toplevel 2>/dev/null || echo ..)/target/asm}" +mkdir -p "$OUT" +FUZZ="${1:-700}" +HOST="${HOST_TARGET:-$(rustc -vV | sed -n 's/^host: //p')}" +QEMU_BIN="${QEMU:-qemu-arm}" +command -v "$QEMU_BIN" >/dev/null || { echo "qemu-arm not found; set QEMU=" >&2; exit 2; } + +CFLAGS="-mcpu=cortex-m33 -mthumb -mfloat-abi=soft -ffreestanding -fno-builtin -nostdlib" +arm-none-eabi-gcc $CFLAGS -c ctaphid.S -o "$OUT/ctaphid.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_tx.S -o "$OUT/ctaphid-tx.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_init.S -o "$OUT/ctaphid-init.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_ctrl.S -o "$OUT/ctaphid-ctrl.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_dispatch.S -o "$OUT/ctaphid-dispatch.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_wait.S -o "$OUT/ctaphid-wait.o" +arm-none-eabi-gcc $CFLAGS -c ccid.S -o "$OUT/ccid.o" +arm-none-eabi-gcc $CFLAGS -c difftest.S -o "$OUT/difftest-s.o" +arm-none-eabi-gcc $CFLAGS -Os -c difftest.c -o "$OUT/difftest-c.o" +arm-none-eabi-gcc -nostdlib -static -Wl,--build-id=none -e _start \ + -o "$OUT/difftest.elf" "$OUT/ctaphid.o" "$OUT/ctaphid-tx.o" "$OUT/ctaphid-init.o" "$OUT/ctaphid-ctrl.o" "$OUT/ctaphid-dispatch.o" "$OUT/ctaphid-wait.o" "$OUT/ccid.o" "$OUT/difftest-s.o" "$OUT/difftest-c.o" +arm-none-eabi-objdump -d "$OUT/difftest.elf" > "$OUT/difftest.disasm" +arm-none-eabi-size "$OUT/difftest.elf" + +echo "== building oracle ==" +cargo build --release --target "$HOST" --manifest-path oracle/Cargo.toml +ORACLE="oracle/target/$HOST/release/asm-oracle" + +echo "== spec vectors ==" +python3 gen_vectors.py > "$OUT/vectors.txt" +"$QEMU_BIN" "$OUT/difftest.elf" < "$OUT/vectors.txt" > "$OUT/spec-asm.txt" +"$ORACLE" < "$OUT/vectors.txt" > "$OUT/spec-rust.txt" +cmp "$OUT/spec-asm.txt" "$OUT/spec-rust.txt" \ + || { echo "DIFF FAILED on spec vectors:" >&2; diff "$OUT/spec-asm.txt" "$OUT/spec-rust.txt" | head -6 >&2; exit 1; } +echo " $(wc -l < "$OUT/spec-asm.txt") events identical" + +total=0 +for seed in 1 2 3 4 5; do + for mode in noise mixed tx ctrl dispatch wait ccid; do + python3 gen_random.py "$seed" "$FUZZ" "$mode" > "$OUT/fuzz.txt" + "$QEMU_BIN" "$OUT/difftest.elf" < "$OUT/fuzz.txt" > "$OUT/fuzz-asm.txt" + "$ORACLE" < "$OUT/fuzz.txt" > "$OUT/fuzz-rust.txt" + cmp "$OUT/fuzz-asm.txt" "$OUT/fuzz-rust.txt" \ + || { echo "DIFF FAILED on fuzz seed $seed ($mode):" >&2; diff "$OUT/fuzz-asm.txt" "$OUT/fuzz-rust.txt" | head -6 >&2; exit 1; } + done + total=$((total + 7 * FUZZ)) +done +echo "== differential clean: spec vectors + $total random frames ==" diff --git a/asm/gen_random.py b/asm/gen_random.py new file mode 100644 index 000000000..874715156 --- /dev/null +++ b/asm/gen_random.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (C) 2026 RS-Key contributors +"""Seeded random CTAPHID frames for the differential harness. + +usage: gen_random.py SEED N [noise|mixed] + +noise: every frame is uniform garbage around the framing (wrong cids, seqs, + bcnts past the cap, broadcast, cid zero). +mixed: valid transactions with random lengths (including empty, exact-fit and + full 7609) with noise frames interleaved mid-transaction — restarts, + cross-channel injections and gaps land on live state. +tx: "T cid cmd payload" lines — response framing with random lengths, + cmds with and without the INIT bit, and edge cids; roughly every + third line is instead an "I can_wink nonce-hex" INIT allocation + demand, so the differential also fuzzes the reply composition. +ctrl: transport-control lines — "K is_cbor up_pending" keepalive, "C frame + n cid" cancel detection, and "L arm/refuse ..." channel-lock lines + with random lock times (0..10 s and occasionally past the dispatcher's + LOCK_MAX_SECONDS clamp, which neither side may apply) and now_ms drift + that crosses lock expiry. +dispatch: dispatcher-verdict lines — "Q can_wink cmd cid body" over random + commands/cids/bodies, with the passive lock pre-armed so a share of the + routing commands land on a refused channel; the cannot-acknowledge + CANCEL and the unknown-command rows are exercised too. +wait: worker-wait lines — "W start/up/tick/frame/done" with the clock + drifting across 100ms keepalive deadlines, the touch flag flipping, and + frames arriving on the waiting channel's and foreign cids (cancel + shaped and not), so the cadence chain and the queue/drop/cancel + disposition fuzz together. +ccid: smart-card transport lines — "A/N" ATR and slot-status seeding, "H" + header composition, "X/E" XfrBlock/Secure payload ranging, and "M" + whole messages against caps around the reply-size boundaries. A cap + in [10,17] is only paired with replies that fit it: the Rust oracle + would panic slicing its own out buffer there (a crash, not a + divergence the differential should chase). +""" + +import random +import struct +import sys + +CIDS = [0x11223344, 0xAABBCCDD, 0x01020304, 0xFFFFFFFF, 0] +LOWNCIDS = [0x11223344, 0xAABBCCDD, 0x01020304] +CONTROL_CMDS = [0x86, 0x81, 0x83, 0x91, 0xBB] +LOCK_SECS = [0, 1, 2, 5, 9, 10, 11, 12, 15, 20, 30, 40] + + +def pat(rng, n): + return bytes(rng.randrange(256) for _ in range(n)) + + +def init_frame(cid, cmd, payload): + b = bytearray(64) + b[0:4] = struct.pack("H", len(payload)) + n = min(len(payload), 57) + b[7 : 7 + n] = payload[:n] + return bytes(b) + + +def cont_frame(cid, seq, payload): + b = bytearray(64) + b[0:4] = struct.pack("H", bcnt) + d = pat(rng, min(bcnt, 57)) + b[7 : 7 + len(d)] = d + return bytes(b) + b = bytearray(64) + b[0:4] = struct.pack("0), or None + + def arm_line(): + nonlocal active + cid = rng.choice(LOWNCIDS + [0xFFFFFFFF]) + secs = rng.choice(LOCK_SECS) + now = rng.randrange(0, 10_000_000) + if secs > 0: + active = (cid, now + secs * 1000) + elif active and active[0] == cid: + active = None # an owner release clears; a non-owner's is ignored + return "L arm {:08x} {} {}".format(cid, secs, now) + + def refuse_line(): + cid = rng.choice(CIDS) + cmd = rng.choice(CONTROL_CMDS) + if active and rng.random() < 0.8: + # probe across the active lock's expiry instant + until = active[1] + now = rng.choice( + [until - rng.randrange(0, 3000), until - 1, until, until + 1, + until + rng.randrange(0, 3000)] + ) + else: + now = rng.randrange(0, 10_000_000) + return "L refuse {:08x} {:02x} {}".format(cid, cmd, max(now, 0)) + + for _ in range(n): + r = rng.random() + if r < 0.25: + out.append("K {} {}".format( + rng.choice(["0", "1"]), rng.choice(["0", "1"]))) + elif r < 0.55: + out.append(refuse_line()) + elif r < 0.80: + out.append(arm_line()) + if active and rng.random() < 0.4: + # a short burst of refuses right at the new lock's boundary + for _ in range(rng.randrange(1, 4)): + out.append(refuse_line()) + else: + cid = rng.choice(CIDS) + b = cancel_frame(cid, rng.choice([0x91, 0x86, 0x81, rng.randrange(0x100)])) + nf = rng.randrange(0, 66) # 0..65; 65 X-parses identically both sides + out.append("C {} {} {:08x}".format(b.hex(), nf, cid)) + return out + + +# 0x83 is this firmware's MSG (TYPE_INIT|0x03, ctaphid.rs:32); 0x87, the +# FIDO spec's MSG, and the rest are unknown-command bytes +DISPATCH_CMDS = [0x81, 0x83, 0x87, 0x90, 0x84, 0x88, 0x91, 0x9F, 0x80, 0xC0, 0x3c] +DISPATCH_OWNER = 0x11223344 +DISPATCH_STRANGER = 0xAABBCCDD + +# the bulk-OUT vocabulary the message core answers (CCID 1.1 §6.1-1); +# XfrBlock/Secure are worker-owned and only ever range-checked +CCID_TYPES = [0x61, 0x62, 0x63, 0x65, 0x6C, 0x6D, 0x6F, 0x69, 0x73, 0x00] +# caps that answer any message (below HEADER, or with room to spare) vs the +# reply floors: params writes out[10..17], set-rate out[10..18], and the +# Rust oracle would panic slicing its own buffer below those +CCID_SMALL_CAPS = [0, 5, 9] +CCID_PARAMS_CAPS = [17, 18, 19, 20, 32, 64, 2048] +CCID_RATE_CAPS = [18, 19, 20, 32, 64, 2048] +CCID_TIGHT_CAPS = [10, 11, 15, 16, 17] # only with replies that fit them + + +def ccid_msg(rng, mtype, dw, payload_len): + b = bytearray(10) + b[0] = mtype + b[1:5] = struct.pack(" 3 else "noise" + rng = random.Random(seed) + + out = [] + if mode == "tx": + for _ in range(n): + if rng.randrange(3) == 0: + out.append("I {} {}".format(rng.choice(["0", "1"]), pat(rng, 8).hex())) + else: + out.append(tx_line(rng)) + print("\n".join(out)) + return + if mode == "ctrl": + print("\n".join(ctrl_lines(rng, n))) + return + if mode == "dispatch": + print("\n".join(dispatch_lines(rng, n))) + return + if mode == "wait": + print("\n".join(wait_lines(rng, n))) + return + if mode == "ccid": + print("\n".join(ccid_lines(rng, n))) + return + tx = None # live valid transaction: (cid, payload, off, seq) + for _ in range(n): + if mode == "mixed": + if tx is None: + if rng.random() < 0.55: + cid = rng.choice([0x11223344, 0xAABBCCDD, 0x01020304]) + bcnt = rng.choice( + [0, 5, 57, 58, 59, 116, rng.randrange(1, 2000), rng.randrange(2000, 7610)] + ) + payload = pat(rng, bcnt) + out.append(init_frame(cid, rng.choice([3, 2]), payload)) + if bcnt > 57: + tx = (cid, payload, 57, 0) + continue + else: + if rng.random() < 0.75: + cid, payload, off, seq = tx + out.append(cont_frame(cid, seq, payload[off:])) + off += 59 + seq += 1 + tx = (cid, payload, off, seq) if off < len(payload) else None + continue + # fall through: noise lands mid-transaction + out.append(noise_frame(rng)) + + print("\n".join(f.hex() for f in out)) + + +if __name__ == "__main__": + main() diff --git a/asm/gen_vectors.py b/asm/gen_vectors.py new file mode 100644 index 000000000..3a70b7921 --- /dev/null +++ b/asm/gen_vectors.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (C) 2026 RS-Key contributors +# +# CTAPHID reassembly vectors (CTAP2.1 11.2.9): one 64-byte report per +# line, 128 hex chars, '#' comments. Frame layout per the shipping +# contract: cid[4 LE], type|0x80 or seq, bcnt[2 BE] (INIT), data. + +import struct + +CID = 0x11223344 +CID2 = 0xAABBCCDD +CID3 = 0x01020304 +BROADCAST = 0xFFFFFFFF +CTAPHID_INIT = 0x86 +MSG_CAP = 7609 # 57 + 128*59 + + +def pat(n): + return bytes((i * 7 + 3) & 0xFF for i in range(n)) + + +def init(cid, cmd, payload): + r = bytearray(64) + r[0:4] = struct.pack("H", len(payload)) + n = min(len(payload), 57) + r[7 : 7 + n] = payload[:n] + return bytes(r) + + +def cont(cid, seq, payload): + r = bytearray(64) + r[0:4] = struct.pack(" " lines; both sides split the +# response and must emit identical F-frames. + + +def tcase(name, cid, cmd, payload): + frames.append("# " + name) + frames.append("T {:08x} {:02x} {}".format(cid, cmd & 0xFF, payload.hex())) + + +tcase("tx: empty message is a bare INIT", CID, 0x86, b"") +tcase("tx: single byte", CID, 0x83, pat(1)) +tcase("tx: exact INIT fill (57)", CID, 0x83, pat(57)) +tcase("tx: one byte into a CONT (58)", CID, 0x83, pat(58)) +tcase("tx: exact two-frame fill (116)", CID, 0x83, pat(116)) +tcase("tx: 117", CID, 0x83, pat(117)) +tcase("tx: keepalive is a one-byte response", CID, 0xBB, b"\x01") +tcase("tx: error frame shape", CID, 0xBF, b"\x2c") +tcase("tx: cmd passes through verbatim (no bit forcing)", CID, 0x03, pat(5)) +tcase("tx: maximum message (7609)", CID, 0x83, pat(MSG_CAP)) +tcase("tx: one short of the maximum (7608)", CID, 0x83, pat(MSG_CAP - 1)) +tcase("tx: broadcast cid passthrough", BROADCAST, 0x86, pat(100)) +tcase("tx: cid zero passthrough", 0, 0x83, pat(100)) +tcase("tx: cid one", 1, 0x83, pat(60)) + +# Harness parse-parity: trailing whitespace and CRLF must trim identically +# on both sides of the differential (the C harness mirrors the oracle's +# trim() in both directions). +frames.append("# tx: trailing spaces after the payload hex") +frames.append("T {:08x} {:02x} {} ".format(CID, 0x83, pat(5).hex())) +frames.append("# frame line with a trailing \\r (CRLF input)") +frames.append(init(CID, 3, pat(5)).hex() + "\r") +frames.append("# frame line with trailing spaces") +frames.append(init(CID2, 3, pat(57)).hex() + " ") + +# INIT allocation: "I " lines; both sides allocate a +# persistent next_cid from FIRST_CID and compose, then broadcast-frame the +# 17-byte reply (nonce||newcid LE||iface 2||major||minor||build||caps). + + +def icase(name, can_wink, nonce): + frames.append("# " + name) + frames.append("I {} {}".format(can_wink, nonce.hex())) + + +icase("init: broadcast reply, can_wink off", 0, b"\x01\x02\x03\x04\x05\x06\x07\x08") +icase("init: broadcast reply, can_wink on", 1, bytes(range(8))) +icase("init: cid sequence step 1", 0, b"\x00" * 8) +icase("init: cid sequence step 2", 0, b"\x00" * 8) +icase("init: cid sequence step 3", 0, b"\x00" * 8) +icase("init: nonce all-zero", 1, b"\x00" * 8) +icase("init: nonce all-ff", 0, b"\xff" * 8) +icase("init: nonce mixed", 1, bytes([0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22, 0x33])) +frames.append("# init: odd nonce length X-parses identically") +frames.append("I 0 deadbeef") + +# Transport control: keepalive / cancel / channel lock. "K " +# -> "S 00|01|02"; "C <128-hex frame> " -> "C 0|1"; +# "L arm " persists the lock (no output); +# "L refuse " -> "R 0|1". + +LOWN = 0x11223344 +LOTH = 0x01000001 + + +def kcase(name, is_cbor, up): + frames.append("# keepalive: " + name) + frames.append("K {} {}".format(is_cbor, up)) + + +kcase("u2f fast op stays silent", 0, 0) # None +kcase("u2f touch wait", 0, 1) # UPNEEDED +kcase("cbor slow op", 1, 0) # PROCESSING +kcase("cbor touch wait", 1, 1) # UPNEEDED (touch wins) + + +def cframe(cid, cmd): + b = bytearray(64) + b[0:4] = struct.pack(" C {1}".format(name, want)) + frames.append("C {} {} {:08x}".format(cframe(cid, cmd).hex(), n, cid)) + + +ccase("full 64-byte frame, matching cid", LOWN, 0x91, 64, 1) +ccase("n=63 matching", LOWN, 0x91, 63, 1) +ccase("n=6 matching", LOWN, 0x91, 6, 1) +ccase("n=5 boundary matching", LOWN, 0x91, 5, 1) +ccase("n=4 too short to carry a command byte", LOWN, 0x91, 4, 0) +ccase("n=64 wrong command byte is not a cancel", LOWN, 0x86, 64, 0) +ccase("n=64 mismatched cid", LOTH, 0x91, 64, 0) +ccase("broadcast frame matching broadcast cid", BROADCAST, 0x91, 5, 1) +ccase("broadcast frame, mismatched cid", LOWN, 0x91, 5, 0) + +# the 2x2 keepalive table lives in difftest output already; finish the cancel +# family with the tiny-n differential gold rows +ccase("n=63 mismatched", LOTH, 0x91, 63, 0) +ccase("n=6 non-cancel cmd", LOWN, 0x81, 6, 0) + +# channel lock: expiry boundary, strict < (until == now is unblocked). arm +# owner t=2s at now=1000 -> until=3000, then probe at 2999/3000/3001. + + +def larm(name, cid, secs, now): + frames.append("# lock: " + name) + frames.append("L arm {:08x} {} {}".format(cid, secs, now)) + + +def lrefuse(name, cid, cmd, now): + frames.append("# lock: " + name) + frames.append("L refuse {:08x} {:02x} {}".format(cid, cmd & 0xFF, now)) + + +larm("expiry boundary cluster: arm owner 2s", LOWN, 2, 1000) +lrefuse("owner at until-1 not blocked", LOWN, 0x81, 2999) +lrefuse("other at until-1 blocked", LOTH, 0x81, 2999) +lrefuse("owner at until not blocked", LOWN, 0x81, 3000) +lrefuse("other at until == unblocked (strict <)", LOTH, 0x81, 3000) +lrefuse("owner at until+1 not blocked", LOWN, 0x81, 3001) +lrefuse("other at until+1 unblocked", LOTH, 0x81, 3001) +lrefuse("broadcast INIT at until-1 carve-out", BROADCAST, 0x86, 2999) +lrefuse("broadcast INIT at until+1 carve-out", BROADCAST, 0x86, 3001) + +# carve-out matrix: cmd x cid at a now well before any expiry (arm holds for 5s) +larm("carve-out matrix: owner locks 5s", LOWN, 5, 1000) +for cmd in (0x86, 0x81, 0x83, 0x91, 0xBB): + for cid, tag in ((LOWN, "owner"), (LOTH, "other"), (BROADCAST, "broadcast")): + want = "unblocked" + if cid != LOWN and not (cmd == 0x86 and cid == BROADCAST): + want = "blocked" + lrefuse("carve-out cmd=0x{0:02x} cid={1} -> {2}".format(cmd, tag, want), + cid, cmd, 1500) + +# release ownership: only the owner may release +larm("release: owner locks 10s", LOWN, 10, 1000) +lrefuse("other blocked before release", LOTH, 0x81, 1500) +larm("non-owner release is ignored", LOTH, 0, 1500) +lrefuse("still blocked after non-owner release attempt", LOTH, 0x81, 1500) +larm("owner release clears", LOWN, 0, 1500) +lrefuse("other unblocked after owner release", LOTH, 0x81, 1500) +lrefuse("other unblocked after owner release, later now", LOTH, 0x83, 3000) + +# secs=0 on a fresh lock is a harmless no-op (no lock was ever taken) +lrefuse("fresh lock refuses nothing", LOTH, 0x81, 500) +larm("secs=0 on a fresh lock is a no-op", LOTH, 0, 500) +lrefuse("still nothing refused", LOTH, 0x81, 1500) + +# non-monotonic now_ms: the kernel must not assume a clock that advances +larm("non-monotonic now: arm owner 2s", LOWN, 2, 1000) +lrefuse("refuse at an earlier now still respects the lock", LOTH, 0x81, 500) + +# dispatcher verdicts (M9): "Q " +# -> "Q " (00 = route/no-transport-response, else the error code); +# an immediate error verdict also frames CTAPHID_ERROR through tx. The lock +# guard consults the persistent lock + the L branch's clock. Provenance: the +# wink row is pinned by wink_is_refused_where_the_capability_bit_is_clear; the +# lock len!=1 / >10 and empty-CBOR rows are spec-claimed (shipping dispatch +# ctaphid.rs:674-689, 714-717); the unknown and lock-guard rows are doc/run-time +# pinned (dispatch falls through to ERR_INVALID_CMD, on_frame guards Messages). + +CLI = 0x55556666 # a cid reserved for the LOCK sweep's arm side-effects + + +def qcase(name, can_wink, cmd, cid, body): + frames.append("# dispatch: " + name) + # an empty body omits the field entirely (as the T line's empty payload); + # a bare trailing space would be trimmed and read back as a missing field + hex = body.hex() + frames.append("Q {} {:02x} {:08x}{}".format(can_wink, cmd & 0xFF, cid, (" " + hex) if hex else "")) + + +# LOCK sweep: secs {0,1,9,10,11,255} x body-len {0,1,2}. len!=1 -> INVALID_LEN; +# len 1 with secs>10 -> INVALID_PAR; else arm (Q 00, the empty LOCK reply). +for secs in (0, 1, 9, 10, 11, 255): + qcase("lock len0 secs={}".format(secs), 0, 0x84, CLI, b"") + qcase("lock len1 secs={}".format(secs), 0, 0x84, CLI, bytes([secs])) + qcase("lock len2 secs={}".format(secs), 0, 0x84, CLI, bytes([secs, 0xAB])) + +# drop the sweep's leftover lock (owner CLI, expiry 0..10 s) so the routing +# vectors below see a fresh channel; clock returns to 0 with it +frames.append("# dispatch: release the sweep's lock before the routes") +frames.append("L arm {:08x} 0 0".format(CLI)) + +# WINK: can_wink gates the command (test-pinned). +qcase("wink without an indicator is refused", 0, 0x88, LOWN, b"") +qcase("wink with an indicator answers empty", 1, 0x88, LOWN, b"") + +# MSG/CBOR routing: MSG routes with any body; only an empty CBOR refuses. +# MSG is 0x83 (ctaphid.rs:32, TYPE_INIT|0x03), not the spec's 0x87 — which +# this firmware therefore answers as an unknown command. +qcase("msg empty routes", 0, 0x83, LOWN, b"") +qcase("msg non-empty routes", 0, 0x83, LOWN, b"\x05\x06") +qcase("the spec's 0x87 msg byte is unknown here", 0, 0x87, LOWN, b"\x01") +qcase("cbor non-empty routes", 0, 0x90, LOWN, b"\x00\xa1\x01\x02") +qcase("cbor empty refused as invalid len", 0, 0x90, LOWN, b"") + +# PING echoes its body (a route here; the echo is harness-level). +qcase("ping with a body routes", 0, 0x81, LOWN, b"\xde\xad\xbe\xef") + +# unknown command -> INVALID_CMD. +qcase("unknown command refused", 0, 0x9F, LOWN, b"") + +# CANCEL is never acknowledged, never errored, in any state; no F-lines either. +qcase("cancel with nothing in flight", 0, 0x91, LOWN, b"") +qcase("cancel carries a body too, still silent", 0, 0x91, LOWN, b"\x01\x02") + +# lock guard on a completed routing message (row 5). arm owner 2s at 1000 -> +# until=3000; Q uses the L branch's clock so a refuse line advances it. +# (INIT is not a Q case: the shipping dispatcher routes it before this table, +# and its broadcast carve-out is already pinned by the L lines above.) +larm("guard: owner locks 2s", LOWN, 2, 1000) +qcase("guard cbor on a stranger is channel-busy", 0, 0x90, LOTH, b"\x00\xa1") +qcase("guard cbor on the owner routes", 0, 0x90, LOWN, b"\x00\xa1") +qcase("guard ping on a stranger is channel-busy", 0, 0x81, LOTH, b"\x01") +qcase("guard msg on a stranger is channel-busy", 0, 0x83, LOTH, b"\x02") +lrefuse("advance the clock to just before expiry", LOWN, 0x81, 2999) +qcase("still busy at 2999", 0, 0x90, LOTH, b"\x00") +lrefuse("advance the clock past expiry", LOWN, 0x81, 3001) +qcase("unblocked at 3001", 0, 0x90, LOTH, b"\x00") + +# malformed Q-lines: both sides must X-parse identically +frames.append("# malformed: q can_wink not 0/1 X-parses") +frames.append("Q 2 90 {:08x} aa".format(LOWN)) +frames.append("# malformed: q cmd not 2 hex X-parses") +frames.append("Q 0 9 {:08x} aa".format(LOWN)) +frames.append("# malformed: q cid short of 8 hex X-parses") +frames.append("Q 0 90 1122 aa") +frames.append("# malformed: q odd-length body X-parses") +frames.append("Q 0 90 {:08x} a".format(LOWN)) +frames.append("# malformed: q non-hex body byte X-parses") +frames.append("Q 0 90 {:08x} zz".format(LOWN)) +frames.append("# malformed: q missing the cid+body fields X-parses") +frames.append("Q 0 90") +frames.append("# malformed: q extra trailing field X-parses") +frames.append("Q 0 90 {:08x} aa 02".format(LOWN)) + +# worker-wait orchestration (M10): "W start " arms the +# cadence, "W up <0|1>" sets the touch flag, "W tick " -> one +# "W ka " per 100ms deadline crossed (00 = the U2F fast-op silence), +# "W frame <128hex> " -> "W r 0|1|2" (0 queued off the touch wait, +# 1 dropped mid-wait, 2 cancel signalled), "W done" ends the wait. +# Provenance: the period is the pub KEEPALIVE_MS (ctaphid.rs:61); the +# read-only-while-up_pending gating and the drop/queue split are body-sourced +# (shipping run_with_keepalive, ctaphid.rs:737-813); the chained restart and +# the non-strict due boundary are mirror-defined. +WOWN = 0x0123ABCD # the channel whose MSG/CBOR request is in flight + + +def wcase(name): + frames.append("# wait: " + name) + + +wcase("a tick before any start is never due") +frames.append("W tick 100000") + +wcase("cbor wait: nothing before the first deadline, ka on the boundary") +frames.append("W start 1 0") +frames.append("W tick 99") +frames.append("W tick 100") # now == next: the deadline itself is due +frames.append("W tick 100") # next moved to 200: not due again +frames.append("W tick 250") # crosses 200 only + +wcase("a tick back in time is simply not due") +frames.append("W tick 150") + +wcase("the touch flag flips the status between deadlines") +frames.append("W up 1") +frames.append("W tick 300") # crosses 300 -> UPNEEDED +frames.append("W up 0") +frames.append("W tick 400") # crosses 400 -> PROCESSING + +wcase("a big jump owes one ka per deadline crossed") +frames.append("W done") +frames.append("W start 1 0") +frames.append("W tick 1000") # deadlines 100..1000: ten keepalives + +wcase("u2f fast op stays silent; a touch wait does not") +frames.append("W done") +frames.append("W start 0 0") +frames.append("W tick 100") # keepalive_status(false,false) = None -> 00 +frames.append("W up 1") +frames.append("W tick 200") # keepalive_status(false,true) = UPNEEDED +frames.append("W done") + +wcase("an inactive wait is never due") +frames.append("W tick 5000") + +wcase("frames off the touch wait are queued, not observed") +frames.append("W start 1 0") +frames.append("W up 0") +frames.append("W frame {} 64 {:08x}".format(cframe(WOWN, 0x91).hex(), WOWN)) +frames.append("W frame {} 64 {:08x}".format(cframe(0xDEAD, 0x81).hex(), 0xDEAD)) + +wcase("frames on the touch wait: the channel's cancel signals, the rest drop") +frames.append("W up 1") +frames.append("W frame {} 64 {:08x}".format(cframe(WOWN, 0x91).hex(), WOWN)) +frames.append("W frame {} 64 {:08x}".format(cframe(0xBEEF, 0x91).hex(), 0xBEEF)) +frames.append("W frame {} 64 {:08x}".format(cframe(WOWN, 0x81).hex(), WOWN)) +frames.append("W frame {} 5 {:08x}".format(cframe(WOWN, 0x91).hex(), WOWN)) +frames.append("W frame {} 4 {:08x}".format(cframe(WOWN, 0x91).hex(), WOWN)) +frames.append("W done") + +# malformed W-lines: both sides must X-parse identically +frames.append("# malformed: w unknown subop X-parses") +frames.append("W zzz 1") +frames.append("# malformed: w start is_cbor not 0/1 X-parses") +frames.append("W start 2 0") +frames.append("# malformed: w start missing the now field X-parses") +frames.append("W start 1") +frames.append("# malformed: w up not 0/1 X-parses") +frames.append("W up 2") +frames.append("# malformed: w tick non-decimal X-parses") +frames.append("W tick zz") +frames.append("# malformed: w frame short of 128 hex X-parses") +frames.append("W frame {} 64 {:08x}".format(cframe(WOWN, 0x91).hex()[:-2], WOWN)) +frames.append("# malformed: w frame n out of range X-parses") +frames.append("W frame {} 65 {:08x}".format(cframe(WOWN, 0x91).hex(), WOWN)) +frames.append("# malformed: w frame cid short of 8 hex X-parses") +frames.append("W frame {} 64 1122".format(cframe(WOWN, 0x91).hex())) +frames.append("# malformed: w done with a trailing field X-parses") +frames.append("W done now") + +# malformed control lines: both sides must X-parse identically +frames.append("# malformed: cancel n out of range (65) X-parses") +frames.append("C {} 65 {:08x}".format(cframe(LOWN, 0x91).hex(), LOWN)) +frames.append("# malformed: cancel frame short of 128 hex X-parses") +frames.append("C {} 64 {:08x}".format(cframe(LOWN, 0x91).hex()[:-2], LOWN)) +frames.append("# malformed: keepalive is_cbor not 0/1 X-parses") +frames.append("K 2 0") +frames.append("# malformed: keepalive missing the up_pending field X-parses") +frames.append("K 1") +frames.append("# malformed: arm secs not decimal X-parses") +frames.append("L arm {:08x} abc 1000".format(LOWN)) +frames.append("# malformed: refuse cmd not 2 hex digits X-parses") +frames.append("L refuse {:08x} 0 1000".format(LOWN)) +frames.append("# malformed: refuse unknown op X-parses") +frames.append("L bogus {:08x} 81 1000".format(LOWN)) +frames.append("# malformed: refuse cid short of 8 hex X-parses") +frames.append("L refuse 1122 81 1000") + +# CCID (M13): rsk-usb's smart-card transport, run against the live +# process_message/xfr_apdu/secure_apdu/put_header. The bulk-OUT header is +# type, dwLength LE32, bSlot, bSeq, bStatus, bError, bChainParameter +# (CCID 1.1 §6.1); every reply echoes bSeq and carries bSlot 0, bError 0. +CCID_ATR_RSKEY = bytes.fromhex("3bfc1300008131fe158073c021c05652532d4b65794b") + + +def cmsg(mtype, seq, dw=0, payload=b""): + b = bytearray(10) + b[0] = mtype + b[1:5] = struct.pack(" FLASH + .image_def : { KEEP(*(.image_def)) } > FLASH + .text : { *(.text*) } > FLASH + .rodata : { *(.rodata*) } > FLASH + + .data : { + __data_start = .; + *(.data*) + __data_end = .; + } > RAM AT > FLASH + __data_load = LOADADDR(.data); + + .bss (NOLOAD) : { + __bss_start = .; + *(.bss*) *(COMMON) + __bss_end = .; + } > RAM + + /* MPU-granularity guard between statics and the descending stack. */ + .stack_guard (NOLOAD) : { + . = ALIGN(32); + __stack_guard = .; + . += 32; + __stack_bottom = .; + } > RAM + + __stack_top = ORIGIN(RAM) + LENGTH(RAM); + + ASSERT(SIZEOF(.vectors) == 64, "vector table must be 16 words") + ASSERT(SIZEOF(.image_def) == 40, "image_def must be two blocks") + ASSERT(ADDR(.text) == ADDR(.image_def) + SIZEOF(.image_def), + "code must directly follow the image def") + ASSERT(__stack_top - __stack_bottom >= 0x800, "stack must be at least 2 KiB") + /* the map must be one contiguous 4 MB so the fence covers exactly the + * tail: code ends where the store begins, the store ends at the top */ + ASSERT(ORIGIN(KVMAIN) == ORIGIN(FLASH) + LENGTH(FLASH), + "KVMAIN must directly follow the code region") + ASSERT(ORIGIN(KVCNT) == ORIGIN(KVMAIN) + LENGTH(KVMAIN), + "KVCNT must directly follow KVMAIN") + ASSERT(__kvcnt_end == 0x400000, "the store must end at the 4 MB top") + /* the image's own flash footprint (last LMA: .data's load image) must + * stay out of the store a table that misses would fence off */ + ASSERT(__data_load + SIZEOF(.data) <= ORIGIN(KVMAIN), + "image spills into the KV store") +} diff --git a/asm/oracle/Cargo.lock b/asm/oracle/Cargo.lock new file mode 100644 index 000000000..15d9321e5 --- /dev/null +++ b/asm/oracle/Cargo.lock @@ -0,0 +1,439 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "asm-oracle" +version = "0.1.0" +dependencies = [ + "rsk-sdk", + "rsk-usb", +] + +[[package]] +name = "bitfield" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45721c9db4c7a20899d05efb7ad9235f50b256e980db30ffb229abf732934c3" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cb6f3d4773a2107b94cbeccaa5b5f0b35a88389b5d522d13d659f64317b22d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "git+https://github.com/embassy-rs/embassy?branch=main#ae258ddd1b2a45715aef5ac12a70434b94e96139" + +[[package]] +name = "embassy-net-driver-channel" +version = "0.4.0" +source = "git+https://github.com/embassy-rs/embassy?branch=main#ae258ddd1b2a45715aef5ac12a70434b94e96139" +dependencies = [ + "embassy-futures", + "embassy-sync", + "xarxa-driver", +] + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "git+https://github.com/embassy-rs/embassy?branch=main#ae258ddd1b2a45715aef5ac12a70434b94e96139" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.3", +] + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "git+https://github.com/embassy-rs/embassy?branch=main#ae258ddd1b2a45715aef5ac12a70434b94e96139" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "git+https://github.com/embassy-rs/embassy?branch=main#ae258ddd1b2a45715aef5ac12a70434b94e96139" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-usb" +version = "0.6.0" +source = "git+https://github.com/embassy-rs/embassy?branch=main#ae258ddd1b2a45715aef5ac12a70434b94e96139" +dependencies = [ + "bitflags", + "embassy-futures", + "embassy-net-driver-channel", + "embassy-sync", + "embassy-time", + "embassy-usb-driver", + "embedded-io-async 0.7.0", + "heapless 0.9.3", + "ssmarshal", + "usbd-hid", +] + +[[package]] +name = "embassy-usb-driver" +version = "0.2.2" +source = "git+https://github.com/embassy-rs/embassy?branch=main#ae258ddd1b2a45715aef5ac12a70434b94e96139" +dependencies = [ + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", +] + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rsk-sdk" +version = "0.1.0" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rsk-usb" +version = "0.1.0" +dependencies = [ + "embassy-futures", + "embassy-time", + "embassy-usb", + "rsk-sdk", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ssmarshal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e6ad23b128192ed337dfa4f1b8099ced0c2bf30d61e551b65fda5916dbb850" +dependencies = [ + "encode_unicode", + "serde", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "usb-device" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98816b1accafbb09085168b90f27e93d790b4bfa19d883466b5e53315b5f06a6" +dependencies = [ + "heapless 0.8.0", + "portable-atomic", +] + +[[package]] +name = "usbd-hid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c64029483e469c727a1808fa1b786be849d1ecaae923c11258025571b05df7c" +dependencies = [ + "usb-device", + "usbd-hid-macros", +] + +[[package]] +name = "usbd-hid-descriptors" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd466c5ed272b360a4866bac7d999bb68f061cad12abe6af381fc46e012a17c9" +dependencies = [ + "bitfield", +] + +[[package]] +name = "usbd-hid-macros" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d261df1b7cc908e93e3f66aeb65bd2fc7fcfb3751196478e5266d251984eea" +dependencies = [ + "byteorder", + "log", + "proc-macro2", + "quote", + "serde", + "syn", + "usbd-hid-descriptors", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "xarxa-driver" +version = "0.1.0" +source = "git+https://github.com/embassy-rs/xarxa?rev=af7342391a78692312cff94a826e9ea52dfea72f#af7342391a78692312cff94a826e9ea52dfea72f" +dependencies = [ + "critical-section", + "document-features", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/asm/oracle/Cargo.toml b/asm/oracle/Cargo.toml new file mode 100644 index 000000000..66fb9e844 --- /dev/null +++ b/asm/oracle/Cargo.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (C) 2026 RS-Key contributors + +# Differential oracle host binary: feeds the same vector stream as +# asm/difftest.c into rsk-usb's Reassembler — the shipping implementation — +# and prints the identical event format. The two outputs must match +# byte-for-byte; a divergence is either an asm bug or a spec reading to +# adjudicate, never noise. + +[package] +name = "asm-oracle" +version = "0.1.0" +edition = "2021" +publish = false + +# Detached workspace (the tools/emu pattern): not a firmware crate, it only +# links rsk-usb for host execution. + +[workspace] + +[dependencies] +rsk-usb = { path = "../../crates/rsk-usb" } +rsk-sdk = { path = "../../crates/rsk-sdk" } diff --git a/asm/oracle/src/main.rs b/asm/oracle/src/main.rs new file mode 100644 index 000000000..2ed4c366c --- /dev/null +++ b/asm/oracle/src/main.rs @@ -0,0 +1,528 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 RS-Key contributors + +use rsk_usb::ccid::{process_message, put_header, secure_apdu, xfr_apdu, ATR_RSKEY}; +use rsk_usb::ctaphid::{ + init_capabilities, is_cancel_frame, keepalive_status, ChannelLock, CidAllocator, Outcome, + Reassembler, TxFrames, CTAPHID_CANCEL, CTAPHID_CBOR, CTAPHID_ERROR, CTAPHID_LOCK, + CTAPHID_MSG, CTAPHID_PING, CTAPHID_WINK, CTAPHID_IF_VERSION, ERR_CHANNEL_BUSY, + ERR_INVALID_CMD, ERR_INVALID_LEN, ERR_INVALID_PAR, KEEPALIVE_MS, LOCK_MAX_SECONDS, + HID_RPT_SIZE, +}; +use std::io::Read; + +const TX_CAP: usize = 7609; // CTAP_MAX_MESSAGE: 57 + 128*59 +const CID_BROADCAST: u32 = 0xFFFF_FFFF; +const INIT_CMD: u8 = 0x86; + +fn parse_hex(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + let b = s.as_bytes(); + let mut out = Vec::with_capacity(s.len() / 2); + for i in 0..b.len() / 2 { + let pair = std::str::from_utf8(&b[2 * i..2 * i + 2]).ok()?; + out.push(u8::from_str_radix(pair, 16).ok()?); + } + Some(out) +} + +fn hex_str(b: &[u8]) -> String { + let mut s = String::with_capacity(2 * b.len()); + for byte in b { + s.push_str(&format!("{:02x}", byte)); + } + s +} + +fn main() { + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input).unwrap(); + let mut re = Reassembler::new(); + let mut allocator = CidAllocator::new(); + let mut lock = ChannelLock::default(); + let mut clock_now: u64 = 0; // clock for Q lock guards; advanced by L lines + let mut wait_active = false; // M10 worker-wait state, the C wst's mirror + let mut wait_next: u64 = 0; + let mut wait_up = false; + let mut wait_cbor = false; + // CCID (M13): the slot bStatus (an unpowered slot reports STATUS_INACTIVE) + // and the ATR the card presents, both pinned against the C harness's + let mut ccid_status: u8 = 1; + let mut ccid_atr: Vec = ATR_RSKEY.to_vec(); + let mut out = String::new(); + + 'lines: for line in input.lines() { + let l = line.trim(); + if l.is_empty() || l.starts_with('#') { + continue; + } + + if let Some(rest) = l.strip_prefix("T ") { + let parts: Vec<&str> = rest.split(' ').collect(); + if parts.len() < 2 || parts[0].len() != 8 || parts[1].len() != 2 { + out.push_str("X parse\n"); + continue; + } + let (cid, cmd, data) = ( + u32::from_str_radix(parts[0], 16), + u8::from_str_radix(parts[1], 16), + parse_hex(parts.get(2).copied().unwrap_or("")), + ); + let (cid, cmd, data) = match (cid, cmd, data) { + (Ok(c), Ok(m), Some(d)) if d.len() <= TX_CAP => (c, m, d), + _ => { + out.push_str("X parse\n"); + continue; + } + }; + for f in TxFrames::new(cid, cmd, &data) { + out.push_str("F "); + for b in f { + out.push_str(&format!("{:02x}", b)); + } + out.push('\n'); + } + continue; + } + + if let Some(rest) = l.strip_prefix("I ") { + let parts: Vec<&str> = rest.split(' ').collect(); + let (can_wink, nonce) = match parts.as_slice() { + [w, h] if (*w == "0" || *w == "1") && h.len() == 16 => match parse_hex(h) { + Some(b) if b.len() == 8 => (*w == "1", b), + _ => { + out.push_str("X parse\n"); + continue; + } + }, + _ => { + out.push_str("X parse\n"); + continue; + } + }; + let cid = allocator.allocate(); + let mut payload = nonce; + payload.extend_from_slice(&cid.to_le_bytes()); + payload.push(CTAPHID_IF_VERSION); + let (maj, min, bld) = rsk_sdk::FIRMWARE_VERSION; + payload.extend_from_slice(&[maj, min, bld]); + payload.push(init_capabilities(can_wink)); + assert_eq!(payload.len(), 17); + for f in TxFrames::new(CID_BROADCAST, INIT_CMD, &payload) { + out.push_str("F "); + for b in f { + out.push_str(&format!("{:02x}", b)); + } + out.push('\n'); + } + continue; + } + + // keepalive status: "K " -> "S 00|01|02" + if let Some(rest) = l.strip_prefix("K ") { + let parts: Vec<&str> = rest.split(' ').collect(); + match parts.as_slice() { + [a, b] if (*a == "0" || *a == "1") && (*b == "0" || *b == "1") => { + let is_cbor = *a == "1"; + let up_pending = *b == "1"; + let s = match keepalive_status(is_cbor, up_pending) { + None => 0u8, + Some(v) => v, + }; + out.push_str(&format!("S {:02x}\n", s)); + } + _ => { + out.push_str("X parse\n"); + } + } + continue; + } + + // cancel detection: "C <128-hex frame> " -> "C 0|1" + if let Some(rest) = l.strip_prefix("C ") { + let parts: Vec<&str> = rest.split(' ').collect(); + let parse = match parts.as_slice() { + [frame_h, n_s, cid_s] if frame_h.len() == 128 && cid_s.len() == 8 => { + let frame = parse_hex(frame_h); + let n = n_s.parse::().ok().filter(|n| *n <= 64); + let cid = u32::from_str_radix(cid_s, 16).ok(); + match (frame, n, cid) { + (Some(f), Some(n), Some(c)) if f.len() == 64 => { + let mut arr = [0u8; 64]; + arr.copy_from_slice(&f); + is_cancel_frame(&arr, n as usize, c) + } + _ => { + out.push_str("X parse\n"); + continue; + } + } + } + _ => { + out.push_str("X parse\n"); + continue; + } + }; + out.push_str(if parse { "C 1\n" } else { "C 0\n" }); + continue; + } + + // channel lock: "L arm|refuse ..." lines (see difftest.c for the + // grammar); an arm persists the lock, a refuse prints "R 0|1". + if let Some(rest) = l.strip_prefix("L ") { + let parts: Vec<&str> = rest.split(' ').collect(); + match parts.as_slice() { + ["arm", cid_s, secs_s, now_s] if cid_s.len() == 8 => { + let cid = u32::from_str_radix(cid_s, 16); + let secs = secs_s.parse::().ok().filter(|s| *s <= 255); + let now = now_s.parse::().ok(); + match (cid, secs, now) { + (Ok(c), Some(s), Some(n)) => { + clock_now = n; + lock.arm(c, s as u8, n) + } + _ => { + out.push_str("X parse\n"); + } + } + } + ["refuse", cid_s, cmd_s, now_s] if cid_s.len() == 8 && cmd_s.len() == 2 => { + let cid = u32::from_str_radix(cid_s, 16); + let cmd = u8::from_str_radix(cmd_s, 16); + let now = now_s.parse::().ok(); + let r = match (cid, cmd, now) { + (Ok(c), Ok(m), Some(n)) => { + clock_now = n; + lock.refuses(c, m, n) + } + _ => { + out.push_str("X parse\n"); + continue; + } + }; + out.push_str(if r { "R 1\n" } else { "R 0\n" }); + } + _ => { + out.push_str("X parse\n"); + } + } + continue; + } + + // dispatcher verdicts (M9): "Q + // [body-hex]" -> "Q " (00 = route/no-transport-response, + // else the error code); an immediate error verdict also frames + // CTAPHID_ERROR through TxFrames. A missing body field is the empty + // body (the line trim swallows a bare empty 4th field), mirroring how + // this harness models T's bare-INIT empty payload. This is the Rust + // mirror of the table in asm/ctaphid_dispatch.S; provenance per row: + // wink: pinned by wink_is_refused_where_the_capability_bit_is_clear. + // lock: <=10 arm + empty-LOCK reply are the shipping dispatch's + // code path; len!=1 / >10 are spec-claimed (no unit pins). + // empty CBOR: spec-claimed (dispatch ctaphid.rs:714-717). + // lock guard: the shipping on_frame guards every Message with + // lock.refuses -> ERR_CHANNEL_BUSY (doc-pinned, and the + // same refuses the twin'd ctaphid_lock_refuses). + if let Some(rest) = l.strip_prefix("Q ") { + let parts: Vec<&str> = rest.split(' ').collect(); + // empty body = the 3-field line; a body occupies the 4th field + let (w, cmd_s, cid_s, body_s) = match parts.as_slice() { + [w, cmd_s, cid_s] if w.len() == 1 && (*w == "0" || *w == "1") + && cmd_s.len() == 2 && cid_s.len() == 8 => (*w, *cmd_s, *cid_s, ""), + [w, cmd_s, cid_s, body_s] if w.len() == 1 && (*w == "0" || *w == "1") + && cmd_s.len() == 2 && cid_s.len() == 8 => { + (*w, *cmd_s, *cid_s, *body_s) + } + _ => { + out.push_str("X parse\n"); + continue; + } + }; + let can_wink = w == "1"; + let cmd = u8::from_str_radix(cmd_s, 16).ok(); + let cid = u32::from_str_radix(cid_s, 16).ok(); + let body = parse_hex(body_s); + let build = match (cmd, cid, body) { + (Some(c), Some(ci), Some(b)) if b.len() <= TX_CAP => { + let (code, err) = match c { + CTAPHID_CANCEL => (0x00, false), // never acknowledged + CTAPHID_PING | CTAPHID_MSG | CTAPHID_CBOR => { + if lock.refuses(ci, c, clock_now) { + (ERR_CHANNEL_BUSY, true) + } else if c == CTAPHID_CBOR && b.is_empty() { + (ERR_INVALID_LEN, true) + } else { + (0x00, false) // route + } + } + CTAPHID_LOCK => { + if b.len() != 1 { + (ERR_INVALID_LEN, true) + } else if b[0] > LOCK_MAX_SECONDS { + (ERR_INVALID_PAR, true) + } else { + lock.arm(ci, b[0], clock_now); + (0x00, false) // arm; no reply-from-here + } + } + CTAPHID_WINK => { + if can_wink { + (0x00, false) // empty wink reply + } else { + (ERR_INVALID_CMD, true) + } + } + _ => (ERR_INVALID_CMD, true), // unknown command + }; + let mut s = format!("Q {:02x}\n", code); + if err { + for fr in TxFrames::new(ci, CTAPHID_ERROR, &[code]) { + s.push_str("F "); + for byte in fr { + s.push_str(&format!("{:02x}", byte)); + } + s.push('\n'); + } + } + s + } + _ => "X parse\n".to_string(), + }; + out.push_str(&build); + continue; + } + + // worker-wait orchestration (M10): the Rust mirror of the table in + // asm/ctaphid_wait.S. "W start " arms the cadence, + // "W up <0|1>" sets the worker's touch flag, "W tick " emits + // one "W ka " per KEEPALIVE_MS deadline crossed (00 = the + // U2F fast-op silence — the keepalive_status() call is the shipping + // pub twin, live), "W frame <128hex> " -> "W r 0|1|2", + // "W done" ends the wait. Provenance: the read-only-while-up_pending + // gating and the drop/queue split are body-sourced (shipping + // run_with_keepalive, ctaphid.rs:737-813); the chained + // next += KEEPALIVE_MS restart and the non-strict (now >= next) due + // boundary are mirror-defined. The tick catch-up cap matches the C + // harness's. + if let Some(rest) = l.strip_prefix("W ") { + let parts: Vec<&str> = rest.split(' ').collect(); + match parts.as_slice() { + ["start", w, now_s] if w.len() == 1 && (*w == "0" || *w == "1") => { + match now_s.parse::() { + Ok(n) => { + wait_cbor = *w == "1"; + wait_active = true; + wait_next = n + KEEPALIVE_MS; + } + _ => out.push_str("X parse\n"), + } + continue; + } + ["up", w] if w.len() == 1 && (*w == "0" || *w == "1") => { + wait_up = *w == "1"; + continue; + } + ["tick", now_s] => match now_s.parse::() { + Ok(n) => { + for _ in 0..65536 { + if !wait_active || n < wait_next { + break; + } + let s = keepalive_status(wait_cbor, wait_up); + out.push_str(&format!( + "W ka {:02x}\n", + s.unwrap_or(0) + )); + wait_next += KEEPALIVE_MS; + } + } + _ => out.push_str("X parse\n"), + }, + ["frame", hex, n_s, cid_s] + if hex.len() == 128 && cid_s.len() == 8 => + { + let frame = parse_hex(hex); + let n = n_s.parse::().ok().filter(|v| *v <= 64); + let cid = u32::from_str_radix(cid_s, 16).ok(); + let r = match (frame, n, cid) { + (Some(f), Some(n), Some(c)) if f.len() == 64 => { + let mut arr = [0u8; 64]; + arr.copy_from_slice(&f); + if !wait_up { + 0 // queued: off the touch wait, unread + } else if is_cancel_frame(&arr, n as usize, c) { + 2 // cancel: signal the worker's touch wait + } else { + 1 // dropped: read mid-wait, not this cancel + } + } + _ => { + out.push_str("X parse\n"); + continue; + } + }; + out.push_str(&format!("W r {}\n", r)); + } + ["done"] => { + wait_active = false; + } + _ => out.push_str("X parse\n"), + } + continue; + } + + // CCID (M13): the live rsk-usb functions — process_message, the two + // payload rangers and put_header — run against the asm twin. The + // state mirrors the C harness's: a slot bStatus and the ATR bytes + // the card presents, both starting at ATR_RSKEY/unpowered. + if let Some(rest) = l.strip_prefix("A ") { + let parts: Vec<&str> = rest.split(' ').collect(); + let atr = match parts.as_slice() { + [h] if h.len() % 2 == 0 && h.len() / 2 <= 256 => parse_hex(h), + _ => None, + }; + match atr { + Some(b) => ccid_atr = b, + _ => out.push_str("X parse\n"), + } + continue; + } + if let Some(rest) = l.strip_prefix("N ") { + let parts: Vec<&str> = rest.split(' ').collect(); + let v = match parts.as_slice() { + [h] if h.len() == 2 => u8::from_str_radix(h, 16).ok(), + _ => None, + }; + match v { + Some(b) => ccid_status = b, + _ => out.push_str("X parse\n"), + } + continue; + } + if let Some(rest) = l.strip_prefix("H ") { + let parts: Vec<&str> = rest.split(' ').collect(); + let parsed = match parts.as_slice() { + // 2-hex type/seq/status, exactly — the C parser's widths + [t, len_s, sq, st] + if t.len() == 2 && sq.len() == 2 && st.len() == 2 => + { + let len = len_s.parse::().ok().filter(|v| *v <= 0xffff_ffff); + match ( + u8::from_str_radix(t, 16).ok(), + len, + u8::from_str_radix(sq, 16).ok(), + u8::from_str_radix(st, 16).ok(), + ) { + (Some(t), Some(len), Some(sq), Some(st)) => Some((t, len, sq, st)), + _ => None, + } + } + _ => None, + }; + let mut hbuf = [0u8; 10]; + match parsed { + Some((t, len, sq, st)) => { + put_header(&mut hbuf, t, len as u32, sq, st); + out.push_str(&format!("H {}\n", hex_str(&hbuf))); + } + _ => out.push_str("X parse\n"), + } + continue; + } + for tag in ["X", "E"] { + if let Some(rest) = l.strip_prefix(tag).filter(|r| r.starts_with(' ')) { + let rest = &rest[1..]; // past the separator the prefix kept + let parts: Vec<&str> = rest.split(' ').collect(); + let msg = match parts.as_slice() { + [h] if h.len() % 2 == 0 && h.len() / 2 <= TX_CAP => parse_hex(h), + _ => None, + }; + let msg = match msg { + Some(m) => m, + _ => { + out.push_str("X parse\n"); + continue 'lines; + } + }; + let r = if *tag == *"X" { xfr_apdu(&msg) } else { secure_apdu(&msg) }; + match r { + Some((s, e)) => out.push_str(&format!("{} 1 {} {}\n", tag, s, e)), + None => out.push_str(&format!("{} 0\n", tag)), + } + continue 'lines; + } + } + if let Some(rest) = l.strip_prefix("M ") { + let parts: Vec<&str> = rest.split(' ').collect(); + let parsed = match parts.as_slice() { + [cap_s, h] if h.len() % 2 == 0 && h.len() / 2 <= TX_CAP => { + match (cap_s.parse::().ok(), parse_hex(h)) { + (Some(cap), msg) if cap <= 2048 => Some((cap, msg)), + _ => None, + } + } + _ => None, + }; + let (cap, msg) = match parsed { + Some((cap, Some(msg))) => (cap, msg), + _ => { + out.push_str("X parse\n"); + continue; + } + }; + let mut obuf = vec![0u8; cap]; + let n = process_message(&msg, &ccid_atr, &mut ccid_status, &mut obuf); + if n > 0 { + out.push_str(&format!("M {} {}\n", n, hex_str(&obuf[..n]))); + } else { + out.push_str("M 0\n"); + } + continue; + } + + let mut rpt = [0u8; HID_RPT_SIZE]; + let b = l.as_bytes(); + let mut ok = true; + for i in 0..HID_RPT_SIZE { + if b.len() < 2 * i + 2 { + ok = false; + break; + } + rpt[i] = match u8::from_str_radix(&l[2 * i..2 * i + 2], 16) { + Ok(v) => v, + Err(_) => { + ok = false; + break; + } + }; + } + if !ok { + out.push_str("X parse\n"); + continue; + } + + match re.feed(&rpt) { + Outcome::None => { + if re.in_progress() { + out.push_str("B\n"); + } else { + out.push_str("I\n"); + } + } + Outcome::Error(cid, code) => { + out.push_str(&format!("E {:08x} {:02x}\n", cid, code)); + } + Outcome::Message(cid, cmd) => { + let m = re.message(); + out.push_str(&format!("D {:08x} {:02x} {:x} ", cid, cmd, m.len())); + for byte in m { + out.push_str(&format!("{:02x}", byte)); + } + out.push('\n'); + } + } + } + print!("{out}"); +} diff --git a/asm/oracle/tests/roundtrip.rs b/asm/oracle/tests/roundtrip.rs new file mode 100644 index 000000000..967f34a7f --- /dev/null +++ b/asm/oracle/tests/roundtrip.rs @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 RS-Key contributors + +// Host-side property tests over the CTAPHID TX→RX round trip: frames from +// TxFrames fed one-by-one into a fresh Reassembler must yield exactly one +// Outcome::Message with a byte-identical payload, nothing else terminal. +// +// Two rsk-usb behaviours observed while building this (2026-10-02, oracle + +// byte-level repro, see the #[ignore] test): +// 1. The Reassembler only reassembles host→device messages: cmd < 0x80 +// (host bit unset) is silently dropped — every frame yields +// Outcome::None with in_progress() == false and no Message ever +// surfaces. 0x03 in the original table hits exactly this. +// 2. Channels 0 and 0xFFFFFFFF are reserved: every frame they carry +// yields Error(cid, 0x0B) (INVALID_CHANNEL). +// Both are pinned as characterization tests; the round-trip property holds +// for the remaining space (cid ≥ 1, cid ≤ 0xFFFFFFFE, cmd ≥ 0x80). + +use rsk_usb::ctaphid::{Outcome, Reassembler, TxFrames, HID_RPT_SIZE}; + +const TX_CAP: usize = 7609; // CTAP_MAX_MESSAGE: 57 + 128*59 +const CID_BROADCAST: u32 = 0xFFFF_FFFF; // reserved; observed to be rejected +const CID_ZERO: u32 = 0; // reserved; observed to be rejected + +/// Deterministic pseudo-random payload keyed by `kind`; the per-position xor +/// keeps 0-based and 1-based index comparisons distinguishable. +fn payload_of(kind: u8, len: usize) -> Vec { + let mut s: u64 = ((kind as u64) << 56) ^ 0x9E3779B97F4A7C15; + (0..len) + .map(|i| { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s ^ i as u64) as u8 + }) + .collect() +} + +const LENS: [usize; 15] = [ + 0, 1, 56, 57, 58, 59, 60, 115, 116, 117, 118, 1000, 7551, 7608, 7609, +]; + +/// Exact task table: cmd 0x03 drops (host bit unset), so the property below +/// cannot be green as written. Kept as evidence per the task's own rule — +/// see the observation above and tx_nonhost_cmd_dropped. +#[ignore = "rsk-usb Reassembler drops cmd-0x03 frames (no host bit); see module docs"] +#[test] +fn tx_frames_round_trip() { + for len in LENS { + assert!(len <= TX_CAP); + for &cmd in &[0x83u8, 0x86, 0x03, 0xBB, 0xBF] { + for &cid in &[1u32, 0x11223344, 0xFFFF_FFFF] { + let data = payload_of(len as u8, len); + let (r_cid, r_cmd, r_data) = round_trip(cid, cmd, &data); + assert_eq!((r_cid, r_cmd, r_data), (cid, cmd, data)); + } + } + } +} + +/// The round-trip property, restricted to the space where it actually +/// holds: non-reserved cids and host-bit commands. +#[test] +fn tx_frames_round_trip_host_cmds() { + for len in LENS { + assert!(len <= TX_CAP); + for &cmd in &[0x83u8, 0x86, 0xBB, 0xBF] { + for &cid in &[1u32, 0x11223344] { + let data = payload_of(len as u8, len); + let (r_cid, r_cmd, r_data) = round_trip(cid, cmd, &data); + assert_eq!((r_cid, r_cmd, r_data), (cid, cmd, data)); + } + } + } + // Boundary sanity: 7609 = 57 + 128*59, i.e. exactly 128 full cont frames + // (seq 0..=127). + assert_eq!(57 + 128 * 59, TX_CAP); +} + +/// Every frame of a non-host (cmd < 0x80) message is dropped: None with +/// in_progress() == false, and no terminal outcome, no matter the cid +/// (as long as it is not a reserved one, which is rejected up front). +#[test] +fn tx_nonhost_cmd_dropped() { + for len in LENS { + for &cmd in &[0x01u8, 0x02, 0x03, 0x07, 0x41, 0x7F] { + for &cid in &[1u32, 0x11223344] { + let data = payload_of(len as u8, len); + let mut re = Reassembler::new(); + let mut saw_any = false; + for f in TxFrames::new(cid, cmd, &data) { + saw_any = true; + assert!( + matches!(re.feed(&f), Outcome::None), + "cmd={cmd:#04x} len={len}: expected None, something else came" + ); + assert!(!re.in_progress()); + } + assert!(saw_any, "TxFrames yielded no frames"); + } + } + } +} + +/// Reserved channels 0 and 0xFFFFFFFF reject every frame with +/// Error(cid, 0x0B) — init and each cont — and never deliver a Message. +#[test] +fn tx_reserved_cid_rejected() { + for &cid in &[CID_BROADCAST, CID_ZERO] { + for len in LENS { + let data = payload_of(len as u8, len); + let mut re = Reassembler::new(); + for (i, f) in TxFrames::new(cid, 0x83, &data).enumerate() { + assert!( + matches!(re.feed(&f), Outcome::Error(c, 0x0B) if c == cid), + "cid={cid:#x} frame {i}: expected Error(cid, 0x0b)" + ); + } + assert!(!re.in_progress()); + } + } +} + +/// Feeds a whole TxFrames stream through a fresh Reassembler and asserts: +/// - every non-terminal frame yields Outcome::None with in_progress() +/// true — a None without in_progress would mean the reassembler lost +/// (or never started) the transaction; +/// - exactly one Outcome::Message lands, with matching cid/cmd and a +/// byte-identical payload (single-frame messages deliver it directly, +/// with no None event at all); +/// - no Error; the reassembler is idle when the stream ends. +fn round_trip(cid: u32, cmd: u8, data: &[u8]) -> (u32, u8, Vec) { + let mut re = Reassembler::new(); + let mut finals = 0usize; + let mut frames = 0usize; + for f in TxFrames::new(cid, cmd, data) { + frames += 1; + match re.feed(&f) { + Outcome::None => { + assert!( + re.in_progress(), + "frame {frames}: None without in_progress (message dropped or never started)" + ); + } + Outcome::Message(r_cid, r_cmd) => { + finals += 1; + assert_eq!((r_cid, r_cmd), (cid, cmd)); + assert_eq!(re.message(), data, "payload byte mismatch"); + } + Outcome::Error(c, code) => { + panic!("unexpected Error({c:#010x}, {code:#04x}) mid-round-trip"); + } + } + } + assert_eq!(finals, 1, "expected exactly one Message, got {finals}"); + assert!(!re.in_progress()); + (cid, cmd, data.to_vec()) +} + +#[test] +fn tx_frame_count() { + // Formula under test: frames = 1 + (len.saturating_sub(57) + 58) / 59. + let mut divergences = Vec::new(); + for len in LENS { + let actual = TxFrames::new(0xBEEF, 0x83, &payload_of(1, len)).count(); + let formula = 1 + (len.saturating_sub(57) + 58) / 59; + if actual != formula { + divergences.push((len, actual, formula)); + } + } + // Reporting, per task: if the formula diverges, surface it exactly + // rather than forcing the test. A real divergence leaves this red. + assert!( + divergences.is_empty(), + "frame-count formula diverged at {divergences:?} (len, actual, formula)" + ); +} + +#[test] +fn tx_frame_layout() { + for len in [0usize, 1, 56, 57, 58, 59, 60, 115, 7551, 7609] { + for &(cmd, cid) in &[(0x83u8, 1u32), (0x03, 0x11223344), (0xBF, 0xFFFF_FFEF)] { + let data = payload_of(7, len); + let cidb = cid.to_le_bytes(); + let mut cont_seq: u8 = 0; + let mut init_seen = false; + for (i, f) in TxFrames::new(cid, cmd, &data).enumerate() { + assert_eq!(f.len(), HID_RPT_SIZE); + assert_eq!(&f[0..4], &cidb, "frame {i}: cid prefix"); + // INIT: cid(4) | cmd(1) | bcnt(2) | payload[57]; + // CONT: cid(4) | seq(1) | payload[59]. + let pstart = if i == 0 { 7 } else { 5 }; + // Source-payload bytes carried by this frame. + let n = if i == 0 { + 57.min(len) + } else { + 59.min(len - (57 + (i - 1) * 59)) + }; + let pend = pstart + n; + if i == 0 { + init_seen = true; + assert_eq!(f[4], cmd, "INIT: type byte must be cmd verbatim"); + let bcnt = u16::from_be_bytes([f[5], f[6]]); + assert_eq!(bcnt as usize, len, "INIT: bcnt != payload len"); + } else { + assert!(i <= 128, "more than 128 cont frames?"); + assert_eq!(f[4], cont_seq, "frame {i}: cont seq not sequential"); + cont_seq += 1; + } + // Payload bytes in this frame must match the source. + if i == 0 { + assert_eq!(&f[pstart..pend], &data[..n]); + } else { + let off = 57 + (i - 1) * 59; + assert_eq!(&f[pstart..pend], &data[off..off + n]); + } + // Padding past the logical payload in this frame must be 0. + for (j, b) in f[pend..].iter().enumerate() { + assert_eq!(*b, 0, "frame {i} pad byte {j} != 0"); + } + } + assert!(init_seen, "no INIT frame yielded"); + assert_eq!(cont_seq as usize, len.saturating_sub(57).div_ceil(59)); + } + } +} + +fn lcg(state: &mut u64) -> u32 { + *state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (*state >> 32) as u32 +} + +#[test] +fn tx_random_soak() { + let mut state: u64 = 0x5EED_1234_5678_9ABC; + for it in 0..500u32 { + let len = lcg(&mut state) as usize % (TX_CAP + 1); + // Cid in 1..=0x7FFFFFFF: avoids both reserved channels. + let cid = 1 + (lcg(&mut state) & 0x7FFF_FFFF); + // Host-bit command. + let cmd = ((lcg(&mut state) & 0x7F) | 0x80) as u8; + let data = payload_of(it as u8, len); + let (r_cid, r_cmd, r_data) = round_trip(cid, cmd, &data); + assert_eq!((r_cid, r_cmd, r_data), (cid, cmd, data), "iter {it}"); + } +} + +/// The INIT reply body is glued positionally (nonce||cid LE||iface||maj||min|| +/// build||caps) — there is no pub builder in shipping code, so this test pins +/// the layout the assembly kernel and the oracle must both reproduce. +#[test] +fn init_payload_layout() { + use rsk_usb::ctaphid::{init_capabilities, CidAllocator, CTAPHID_IF_VERSION}; + + let nonce = [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; + let mut alloc = CidAllocator::new(); + let cid = alloc.allocate(); + let can_wink = true; + + let mut payload = nonce.to_vec(); + payload.extend_from_slice(&cid.to_le_bytes()); + payload.push(CTAPHID_IF_VERSION); + let (maj, min, bld) = rsk_sdk::FIRMWARE_VERSION; + payload.extend_from_slice(&[maj, min, bld]); + payload.push(init_capabilities(can_wink)); + + assert_eq!(payload.len(), 17); + assert_eq!(&payload[0..8], &nonce); + assert_eq!(u32::from_le_bytes(payload[8..12].try_into().unwrap()), cid); + assert_eq!(payload[12], CTAPHID_IF_VERSION); + assert_eq!( + (payload[13], payload[14], payload[15]), + rsk_sdk::FIRMWARE_VERSION + ); + assert_eq!(payload[16], init_capabilities(can_wink)); +} + +/// The 2x2 keepalive truth table, pinned by the shipping +/// `keepalive_status_suppresses_processing_for_u2f_msg`: a touch wait always +/// signals UPNEEDED, only a slow CBOR op gets PROCESSING, and a fast U2F op +/// stays silent. The asm kernel's tables lies in that this is the exact table: +/// (0,0)->None, (0,1)->UPNEEDED, (1,0)->PROCESSING, (1,1)->UPNEEDED. +#[test] +fn keepalive_status_table() { + use rsk_usb::ctaphid::{keepalive_status, STATUS_PROCESSING, STATUS_UPNEEDED}; + + assert_eq!(keepalive_status(false, false), None); // U2F fast op — stay silent + assert_eq!(keepalive_status(false, true), Some(STATUS_UPNEEDED)); // U2F touch wait + assert_eq!(keepalive_status(true, false), Some(STATUS_PROCESSING)); // CBOR slow op + assert_eq!(keepalive_status(true, true), Some(STATUS_UPNEEDED)); // CBOR touch wait +} + +/// The cancel rule, pinned by the shipping +/// `cancel_frame_detected_only_for_active_channel` (n = 64, n = 4), plus the +/// n in [5, 63] band — closed by the differential only, so this pins the +/// high-value short-read rows against the pub API. +#[test] +fn cancel_frame_contract() { + use rsk_usb::ctaphid::{is_cancel_frame, CTAPHID_CANCEL, CTAPHID_PING}; + + let mut frame = [0u8; HID_RPT_SIZE]; + frame[0..4].copy_from_slice(&0x0100_0000u32.to_le_bytes()); + frame[4] = CTAPHID_CANCEL; + assert!(is_cancel_frame(&frame, HID_RPT_SIZE, 0x0100_0000)); // full frame + assert!(!is_cancel_frame(&frame, 4, 0x0100_0000)); // no command byte + assert!(!is_cancel_frame(&frame, HID_RPT_SIZE, 0x0200_0000)); // other channel + let mut ping = frame; + ping[4] = CTAPHID_PING; + assert!(!is_cancel_frame(&ping, HID_RPT_SIZE, 0x0100_0000)); // not a cancel + // The differential-only gold band no shipping test pins. + assert!(is_cancel_frame(&frame, 5, 0x0100_0000)); + assert!(!is_cancel_frame(&frame, 63, 0xFFFF_FFFF)); +} + +/// The strict-expiry boundary and the ownership rules of the channel lock, +/// pinned by the shipping `channel_lock_excludes_other_channels_until_it_expires` +/// and `only_a_broadcast_init_survives_someone_elses_lock`. The highest-value +/// row is the equality instant: arm(2s) at t=1000 goes unblocked at t=3000. +#[test] +fn channel_lock_strict_expiry_boundary() { + use rsk_usb::ctaphid::{ChannelLock, CID_BROADCAST, CTAPHID_INIT, CTAPHID_PING}; + + let mut lock = ChannelLock::default(); + let (mine, theirs) = (0x0100_0000u32, 0x0100_0001u32); + lock.arm(mine, 2, 1_000); + assert!( + lock.refuses(theirs, CTAPHID_PING, 2_999), + "other, before expiry" + ); + assert!( + !lock.refuses(mine, CTAPHID_PING, 2_999), + "owner is never blocked" + ); + assert!( + !lock.refuses(theirs, CTAPHID_PING, 3_000), + "until == now is NOT blocked (strict <)" + ); + assert!(!lock.refuses(theirs, CTAPHID_PING, 3_001), "expired"); + + // Only a broadcast INIT survives someone else's lock (an allocation, not + // traffic on a channel); an INIT aimed at another channel is refused. + lock.arm(mine, 5, 10_000); + assert!( + !lock.refuses(CID_BROADCAST, CTAPHID_INIT, 10_500), + "broadcast INIT allocation is never refused" + ); + assert!( + lock.refuses(theirs, CTAPHID_INIT, 10_500), + "channel resync refused" + ); + + // A release by a non-owner does not hand the lock back; the owner's does. + lock.arm(theirs, 0, 10_500); + assert!( + lock.refuses(theirs, CTAPHID_PING, 10_600), + "non-owner release is ignored" + ); + lock.arm(mine, 0, 10_600); + assert!( + !lock.refuses(theirs, CTAPHID_PING, 10_700), + "owner release clears the lock" + ); +} diff --git a/asm/usb.S b/asm/usb.S new file mode 100644 index 000000000..d50f9573b --- /dev/null +++ b/asm/usb.S @@ -0,0 +1,477 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* USB device-side driver for the RP2350 USBCTRL block: chapter-9 EP0 control + * transfers plus the EP1 interrupt endpoints that carry CTAPHID. Plain MMIO, + * one event per usb_task call; the model harness (asm/usbtest.c) runs it + * against a datasheet-derived SIE simulation. Register facts cite the + * pico-sdk 2.2.0 RP2350 headers; init order mirrors pico-examples + * dev_lowlevel (unreset, muxing, vbus override, then pullup last). */ + +.equ USB_REGS, 0x50110000 +.equ USB_DPRAM, 0x50100000 +.equ RESETS, 0x40020000 + +/* register offsets (pico-sdk 2.2.0, hardware/regs/usb.h) */ +.equ R_ADDR_ENDP, 0x00 +.equ R_MAIN_CTRL, 0x40 +.equ R_SIE_CTRL, 0x4c +.equ R_SIE_STATUS, 0x50 +.equ R_BUFF_STATUS, 0x58 +.equ R_MUXING, 0x74 +.equ R_PWR, 0x78 + +/* resets block (hardware/regs/resets.h) */ +.equ RST_RESET, 0x00 +.equ RST_DONE, 0x08 +.equ RST_USBCTRL, 0x10000000 + +.equ MAIN_CTRL_EN, 0x00000001 +.equ MUX_TO_PHY_SOFT, 0x00000009 /* TO_PHY | SOFTCON */ +.equ PWR_VBUS_FORCE, 0x0000000c /* VBUS_DETECT | OVERRIDE_EN */ +.equ SIE_PULLUP, 0x00010000 +.equ SIE_EP0_1BUF, 0x20000000 /* without it EP0 never reaches BUFF_STATUS */ +.equ ST_BUS_RESET, 0x00080000 +.equ ST_SETUP_REC, 0x00020000 +.equ BS_EP0_IN, 0x00000001 +.equ BS_EP0_OUT, 0x00000002 +.equ BS_EP1_IN, 0x00000004 +.equ BS_EP1_OUT, 0x00000008 + +/* DPSRAM layout (hardware/regs/usb_device_dpram.h) */ +.equ D_SETUP, 0x000 +.equ D_EP1_CTRL_IN, 0x008 +.equ D_EP1_CTRL_OUT, 0x00c +.equ D_BC_IN, 0x080 +.equ D_BC_OUT, 0x084 +.equ D_BC1_IN, 0x088 +.equ D_BC1_OUT, 0x08c +.equ D_EP0_BUF, 0x100 +.equ D_EP1_OUT_BUF, 0x180 +.equ D_EP1_IN_BUF, 0x1c0 + +/* ep_ctrl word (device mode) */ +.equ EP_ENABLE, 0x80000000 +.equ EP_INT_PER_BUF, 0x20000000 /* required for BUFF_STATUS on non-EP0 */ +.equ EP_TYPE_INT, 3 << 26 + +/* buffer control word */ +.equ BC_FULL, 0x8000 +.equ BC_LAST, 0x4000 +.equ BC_DATA1, 0x2000 +.equ BC_STALL, 0x0800 +.equ BC_AVAIL, 0x0400 + +/* chapter-9 requests */ +.equ REQ_GET_STATUS, 0x00 +.equ REQ_SET_ADDRESS, 0x05 +.equ REQ_GET_DESCRIPTOR, 0x06 +.equ REQ_GET_CONFIG, 0x08 +.equ REQ_SET_CONFIG, 0x09 +.equ REQ_GET_IFACE, 0x0a +.equ DESC_DEVICE, 0x01 +.equ DESC_CONFIG, 0x02 +.equ DESC_STRING, 0x03 +.equ DESC_REPORT, 0x22 +.equ STR_MANUF, 1 +.equ STR_PROD, 2 +.equ LANGID_US, 0x0409 + +/* offsets into asm/ctaphid.S's caller state (kept in sync by hand) */ +.equ ST_CID, 0 +.equ ST_IN_TX, 20 +.equ ST_BUF_MAX, 24 +.equ ST_BUF, 28 +.equ CTAPHID_MAX, 7609 + +.syntax unified +.cpu cortex-m33 +.thumb + +.section .bss +.align 2 +.global usb_state +/* +0 addr_pending (bit 31 marks pending), +4 configured, + * +8 ep1_out_pid, +12 ep1_in_pid, +16 tx_done */ +usb_state: + .space 20 + +.global ctaphid_state +ctaphid_state: + .space 48 + +.global ctaphid_msg +ctaphid_msg: + .space CTAPHID_MAX + +.section .rodata +.align 2 +.global usb_dev_desc +usb_dev_desc: + .byte 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40 + .byte 0x09, 0x12, 0x01, 0x00, 0x00, 0x01, STR_MANUF, STR_PROD, 0x00, 0x01 + +.global usb_cfg_desc +usb_cfg_desc: + .byte 0x09, 0x02, 0x29, 0x00, 0x01, 0x01, 0x00, 0x80, 0x19 + .byte 0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00 + .byte 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x21, 0x00 + .byte 0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x05 + .byte 0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x05 + +.global usb_report_desc +usb_report_desc: + .byte 0x05, 0xF1, 0x09, 0x00, 0xA1, 0x01, 0x09, 0x20, 0x15, 0x00 + .byte 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09 + .byte 0x21, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x40 + .byte 0x91, 0x02, 0xC0 + +/* string index 0: LANGUAGE ID (§9.6.7), 0x0409 little-endian */ +.global usb_str_langid +usb_str_langid: + .byte 4, DESC_STRING + .hword LANGID_US + +/* string index 1: manufacturer "RS-Key contributors" */ +.global usb_str_manuf +usb_str_manuf: + .byte 40, DESC_STRING /* 2 + 2*19 */ + .hword 0x52, 0x53, 0x2D, 0x4B, 0x65, 0x79, 0x20 /* "RS-Key " */ + .hword 0x63, 0x6F, 0x6E, 0x74, 0x72, 0x69, 0x62 /* contribut */ + .hword 0x75, 0x74, 0x6F, 0x72, 0x73 /* ors */ + +/* string index 2: product "RS-Key" */ +.global usb_str_prod +usb_str_prod: + .byte 14, DESC_STRING /* 2 + 2*6 */ + .hword 0x52, 0x53, 0x2D, 0x4B, 0x65, 0x79 /* "RS-Key" */ + +.section .text +.thumb_func +.global usb_init +usb_init: + push {r4, r5, lr} + /* the bootrom leaves USBCTRL in reset: release it, wait for done */ + ldr r0, =RESETS + ldr r1, [r0, #RST_RESET] + ldr r2, =RST_USBCTRL + bic r1, r1, r2 + str r1, [r0, #RST_RESET] +1: ldr r1, [r0, #RST_DONE] + tst r1, r2 + beq 1b + + ldr r0, =USB_REGS + movs r1, #MUX_TO_PHY_SOFT + str r1, [r0, #R_MUXING] + movs r1, #PWR_VBUS_FORCE + str r1, [r0, #R_PWR] + movs r1, #0 + str r1, [r0, #R_ADDR_ENDP] + movs r1, #MAIN_CTRL_EN + str r1, [r0, #R_MAIN_CTRL] + + ldr r5, =USB_DPRAM + ldr r1, =EP_ENABLE|EP_INT_PER_BUF|EP_TYPE_INT|D_EP1_OUT_BUF + str r1, [r5, #D_EP1_CTRL_OUT] + ldr r1, =EP_ENABLE|EP_INT_PER_BUF|EP_TYPE_INT|D_EP1_IN_BUF + str r1, [r5, #D_EP1_CTRL_IN] + + /* reassembler state: buf_max/buf are set here, rest stays zeroed */ + ldr r0, =ctaphid_state + movs r1, #0 + str r1, [r0, #ST_CID] + str r1, [r0, #ST_IN_TX] + ldr r1, =CTAPHID_MAX + str r1, [r0, #ST_BUF_MAX] + ldr r1, =ctaphid_msg + str r1, [r0, #ST_BUF] + + /* present the device only once everything is set up; plain MMIO + * means no set-alias, so pullup and the EP0 interrupt bit go in + * together or the second write erases the first */ + ldr r0, =USB_REGS + ldr r1, =SIE_PULLUP|SIE_EP0_1BUF + str r1, [r0, #R_SIE_CTRL] + pop {r4, r5, pc} + +/* one event per call: bus reset, setup, then one buffer completion */ +.thumb_func +.global usb_task +usb_task: + push {r4, r5, r6, lr} + ldr r4, =USB_REGS + ldr r5, =USB_DPRAM + + ldr r0, [r4, #R_SIE_STATUS] + ldr r1, =ST_BUS_RESET + tst r0, r1 + beq 1f + str r1, [r4, #R_SIE_STATUS] + movs r2, #0 + str r2, [r4, #R_ADDR_ENDP] + ldr r3, =usb_state + str r2, [r3, #0] /* pending address */ + str r2, [r3, #4] /* configured */ + str r2, [r3, #8] /* data toggles */ + str r2, [r3, #12] + str r2, [r3, #16] /* tx_done */ + ldr r3, =ctaphid_state + str r2, [r3, #ST_CID] /* reassembler back to idle */ + str r2, [r3, #ST_IN_TX] + b task_done + +1: ldr r1, =ST_SETUP_REC + tst r0, r1 + beq 2f + str r1, [r4, #R_SIE_STATUS] + movs r2, #0 + str r2, [r5, #D_BC_IN] /* the SIE resets EP0 on setup */ + str r2, [r5, #D_BC_OUT] + + ldrb r0, [r5, #D_SETUP+0] /* bmRequestType */ + ldrb r1, [r5, #D_SETUP+1] /* bRequest */ + ldrb r2, [r5, #D_SETUP+3] /* wValue hi */ + ldrb r6, [r5, #D_SETUP+7] /* wLength is little-endian */ + ldrb r3, [r5, #D_SETUP+6] + lsls r6, r6, #8 + orrs r6, r6, r3 /* r6 = wLength */ + + movs r3, #0xE0 + ands r3, r0 /* direction + type */ + cmp r3, #0x80 /* IN, standard, any recipient */ + beq in_std + cmp r0, #0x00 /* OUT + standard + device */ + bne stall_both + cmp r1, #REQ_SET_ADDRESS + beq set_address + cmp r1, #REQ_SET_CONFIG + beq set_config + b stall_both + +in_std: + cmp r1, #REQ_GET_DESCRIPTOR + beq get_descriptor + cmp r1, #REQ_GET_STATUS + beq get_status + cmp r1, #REQ_GET_CONFIG + beq get_config + cmp r1, #REQ_GET_IFACE + beq get_iface + b stall_both + +get_status: + movs r2, #0 + str r2, [r5, #D_EP0_BUF] + str r2, [r5, #D_EP0_BUF+1] + ldr r2, =BC_AVAIL|BC_FULL|BC_DATA1|BC_LAST|2 + str r2, [r5, #D_BC_IN] + b task_done + +get_config: + ldr r3, =usb_state + ldr r3, [r3, #4] + strb r3, [r5, #D_EP0_BUF] + ldr r2, =BC_AVAIL|BC_FULL|BC_DATA1|BC_LAST|1 + str r2, [r5, #D_BC_IN] + b task_done + +get_iface: + movs r2, #0 /* one interface, alternate 0 */ + strb r2, [r5, #D_EP0_BUF] + ldr r2, =BC_AVAIL|BC_FULL|BC_DATA1|BC_LAST|1 + str r2, [r5, #D_BC_IN] + b task_done + +get_descriptor: + cmp r2, #DESC_DEVICE + bne 3f + ldr r3, =usb_dev_desc + movs r0, #18 + b send_desc +3: cmp r2, #DESC_CONFIG + bne 4f + ldr r3, =usb_cfg_desc + movs r0, #41 + b send_desc +4: cmp r2, #DESC_REPORT + bne 8f + ldr r3, =usb_report_desc + movs r0, #33 + b send_desc +8: cmp r2, #DESC_STRING + bne stall_both + ldrb r2, [r5, #D_SETUP+2] /* wValue lo = string index */ + cmp r2, #STR_MANUF + bne 9f + ldr r3, =usb_str_manuf + movs r0, #40 + b send_desc +9: cmp r2, #STR_PROD + bne 10f + ldr r3, =usb_str_prod + movs r0, #14 + b send_desc +10: cmp r2, #0 + bne stall_both /* unknown index */ + ldr r3, =usb_str_langid + movs r0, #4 +send_desc: + cmp r0, r6 + bls 5f + movs r0, r6 /* min(desc_len, wLength) */ +5: adds r2, r5, #D_EP0_BUF + movs r1, #0 +6: cmp r1, r0 + bcs 7f + ldrb r6, [r3, r1] + strb r6, [r2, r1] + adds r1, #1 + b 6b +7: ldr r6, =BC_AVAIL|BC_FULL|BC_DATA1|BC_LAST + orrs r6, r6, r0 + str r6, [r5, #D_BC_IN] + b task_done + +set_address: + ldrb r3, [r5, #D_SETUP+3] /* wValue hi must be zero too */ + cmp r3, #0 + bne stall_both + ldrb r2, [r5, #D_SETUP+2] /* wValue lo = address */ + cmp r2, #0x7F /* §9.6.2: 7-bit, larger is an error */ + bhi stall_both + movs r3, #1 + lsls r3, r3, #31 + orrs r2, r2, r3 /* pending marker */ + ldr r3, =usb_state + str r2, [r3, #0] + b status_in /* latch after the host saw the ack */ + +set_config: + ldrb r2, [r5, #D_SETUP+2] + subs r2, #1 + bne 8f + ldr r3, =usb_state + movs r2, #1 + str r2, [r3, #4] + /* data toggles reset on SET_CONFIGURATION */ + movs r2, #0 + str r2, [r3, #8] + str r2, [r3, #12] + ldr r2, =BC_AVAIL|64 /* first EP1 OUT is DATA0 */ + str r2, [r5, #D_BC1_OUT] + b status_in +8: ldr r3, =usb_state + movs r2, #0 + str r2, [r3, #4] +status_in: + ldr r2, =BC_AVAIL|BC_FULL|BC_DATA1 /* zero-length IN status stage */ + str r2, [r5, #D_BC_IN] + b task_done + +stall_both: + ldr r2, =BC_STALL + str r2, [r5, #D_BC_IN] + str r2, [r5, #D_BC_OUT] + b task_done + +2: ldr r0, [r4, #R_BUFF_STATUS] + movs r1, #BS_EP0_IN + tst r0, r1 + beq 10f + str r1, [r4, #R_BUFF_STATUS] + ldr r2, =usb_state + ldr r3, [r2, #0] + cmp r3, #0 + bpl 20f + lsls r3, r3, #1 /* drop the pending flag, latch now */ + lsrs r3, r3, #1 + str r3, [r4, #R_ADDR_ENDP] + movs r3, #0 + str r3, [r2, #0] + b task_done +20: ldr r2, =BC_AVAIL|BC_DATA1 /* IN data stage done: host OUT status */ + str r2, [r5, #D_BC_OUT] + b task_done + +10: movs r1, #BS_EP0_OUT + tst r0, r1 + beq 11f + str r1, [r4, #R_BUFF_STATUS] /* status stage observed, done */ + b task_done + +11: movs r1, #BS_EP1_IN + tst r0, r1 + beq 12f + str r1, [r4, #R_BUFF_STATUS] + ldr r2, =usb_state + ldr r3, [r2, #12] /* flip the EP1 IN data toggle */ + eors r3, r3, #1 + str r3, [r2, #12] + movs r3, #1 + str r3, [r2, #16] /* tx_done event */ + b task_done + +12: movs r1, #BS_EP1_OUT + tst r0, r1 + beq task_done + str r1, [r4, #R_BUFF_STATUS] + /* a short host packet leaves stale bytes in the 57/59-byte window + * the reassembler reads; zero them so input stays deterministic */ + ldr r2, [r5, #D_BC1_OUT] + ldr r3, =0x3ff + ands r2, r2, r3 /* received length */ + cmp r2, #64 + bcs 15f + adds r3, r5, #D_EP1_OUT_BUF +14: movs r6, #0 + strb r6, [r3, r2] + adds r2, #1 + cmp r2, #64 + bcc 14b +15: ldr r0, =ctaphid_state + adds r1, r5, #D_EP1_OUT_BUF /* DPRAM is plain RAM: feed in place */ + bl ctaphid_feed + /* re-arm the receiver with the flipped data toggle */ + ldr r2, =usb_state + ldr r3, [r2, #8] + eors r3, r3, #1 + str r3, [r2, #8] + ldr r2, =BC_AVAIL|64 + cmp r3, #0 + beq 13f + ldr r3, =BC_DATA1 + orrs r2, r2, r3 +13: str r2, [r5, #D_BC1_OUT] + b task_done + +task_done: + pop {r4, r5, r6, pc} + +/* r0 = payload, r1 = len (<=64). Caller waits for tx_done between packets. */ +.thumb_func +.global usb_send_ep1 +usb_send_ep1: + push {r4, r5, r6, lr} + cmp r1, #64 + bls 1f + movs r1, #64 +1: ldr r4, =USB_DPRAM + adds r5, r4, #D_EP1_IN_BUF + movs r2, #0 +2: cmp r2, r1 + bcs 3f + ldrb r3, [r0, r2] + strb r3, [r5, r2] + adds r2, #1 + b 2b +3: ldr r5, =usb_state + ldr r6, [r5, #12] /* ep1_in_pid */ + ldr r2, =BC_AVAIL|BC_FULL + cmp r6, #0 + beq 4f + ldr r3, =BC_DATA1 + orrs r2, r2, r3 +4: orrs r2, r2, r1 + str r2, [r4, #D_BC1_IN] + pop {r4, r5, r6, pc} diff --git a/asm/usbtest.c b/asm/usbtest.c new file mode 100644 index 000000000..9c4695447 --- /dev/null +++ b/asm/usbtest.c @@ -0,0 +1,821 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Copyright (C) 2026 RS-Key contributors */ + +/* Model-level USB harness: maps the RP2350 USBCTRL register file and DPSRAM + * as plain memory (qemu-user MAP_FIXED), then runs the asm USB driver + * against a datasheet-derived model of the SIE. The model owns the + * write-to-clear status registers — it rewrites them from its shadow each + * cycle, so the driver's own WC writes (correct for hardware) are absorbed. + * EP1 OUT packets are delivered explicitly by the test (the host decides + * when to talk); EP1 IN completes on the next cycle (the host is polling). + * Hardware execution remains the unverified step. */ + +#define REGS_BASE 0x50110000u +#define DPRAM_BASE 0x50100000u +#define RESETS_BASE 0x40020000u + +#define REG(n) (*(volatile unsigned *)(REGS_BASE + (n))) +#define REGS2(n) (*(volatile unsigned *)(RESETS_BASE + (n))) +#define BC_IN (*(volatile unsigned *)(DPRAM_BASE + 0x80)) +#define BC_OUT (*(volatile unsigned *)(DPRAM_BASE + 0x84)) +#define BC1_IN (*(volatile unsigned *)(DPRAM_BASE + 0x88)) +#define BC1_OUT (*(volatile unsigned *)(DPRAM_BASE + 0x8c)) +#define EP1_CTRL_IN (*(volatile unsigned *)(DPRAM_BASE + 0x008)) +#define EP1_CTRL_OUT (*(volatile unsigned *)(DPRAM_BASE + 0x00c)) +#define EPBUF(i) (*(volatile unsigned char *)(DPRAM_BASE + 0x100 + (i))) +#define EP1_OUT_BUF(i) (*(volatile unsigned char *)(DPRAM_BASE + 0x180 + (i))) +#define EP1_IN_BUF(i) (*(volatile unsigned char *)(DPRAM_BASE + 0x1c0 + (i))) + +#define R_ADDR_ENDP 0x00 +#define R_MAIN_CTRL 0x40 +#define R_SIE_CTRL 0x4c +#define R_SIE_STATUS 0x50 +#define R_BUFF_STATUS 0x58 +#define R_MUXING 0x74 +#define R_PWR 0x78 + +#define ST_SETUP_REC 0x20000u +#define ST_BUS_RESET 0x80000u +#define BC_AVAIL 0x400u +#define BC_STALL 0x800u +#define BC_DATA1 0x2000u +#define BC_LAST 0x4000u +#define BC_FULL 0x8000u + +/* ctaphid reassembler state, as words (asm/ctaphid.S layout) */ +#define CS_CID 0 +#define CS_CUR 3 +#define CS_IN_TX 5 +#define CS_EV_TAG 8 +#define CS_EV_VAL 9 +#define CS_EV_CID 10 +#define CS_EV_CMD 11 +#define EV_DONE 1 +#define EV_ERROR 2 +#define ERR_INVALID_SEQ 4 + +/* tx state (asm/ctaphid_tx.S layout) */ +#define CTAPHID_TX_STATE 28 + +/* init exchange (asm/ctaphid_init.S + asm/ctaphid_tx.S layouts) */ +#define CTAPHID_INIT_STATE 4 +#define INIT_DATA 7 /* INIT report payload offset (asm/ctaphid_tx.S) */ +#define INIT_NONCE_LEN 8 /* nonce field (asm/ctaphid_init.S) */ +#define INIT_CID_OFF 8 /* newcid[4 LE] (asm/ctaphid_init.S) */ +#define INIT_IFACE_OFF 12 /* interface version (asm/ctaphid_init.S) */ +#define INIT_VER_OFF 13 /* major/minor/build (+0/+1/+2) */ +#define INIT_CAPS_OFF 16 /* capability bits (asm/ctaphid_init.S) */ +#define INIT_RESP_LEN 17 /* full reply length */ +#define INIT_IFACE 2 /* CTAPHID_IF_VERSION */ +#define INIT_MAJ 5 /* CI_VERSION_MAJOR */ +#define INIT_MIN 8 /* CI_VERSION_MINOR */ +#define INIT_BLD 0 /* CI_VERSION_BUILD */ +#define CAP_LOCK 0x02 /* capability bits (§11.2.9.3) */ +#define CAP_CBOR 0x04 +#define CAPS_NOWINK 0x06 /* CAP_LOCK|CAP_CBOR: no display in this model */ +#define INIT_BROADCAST_CID 0xffffffffu +#define INIT_CMD 0x86 /* allocation command (CTAP2.1 §11.2.9.4) */ + +/* dispatcher command bytes (ctaphid.rs:31-52); MSG is 0x83 — TYPE_INIT|0x03, + * not the FIDO spec's 0x87 */ +#define CMD_LOCK 0x84 +#define CMD_MSG 0x83 +#define CMD_CBOR 0x90 +#define CMD_CANCEL 0x91 +#define CMD_ERROR 0xBF +#define CMD_KEEPALIVE 0xBB +/* dispatcher error codes (ctaphid.rs:46-52) */ +#define ERRCODE_INVALID_CMD 0x01 +#define ERRCODE_INVALID_LEN 0x03 +#define ERRCODE_CHANNEL_BUSY 0x06 +/* keepalive status bytes (ctaphid.rs:55-58) */ +#define KA_PROCESSING 0x01 +#define KA_UPNEEDED 0x02 + +extern void usb_init(void); +extern void usb_task(void); +extern void usb_send_ep1(const unsigned char *buf, unsigned len); +extern void ctaphid_init_init(unsigned *state); +extern unsigned ctaphid_init_run(unsigned *state, const unsigned char *nonce, + unsigned nonce_len, unsigned can_wink, + unsigned char *out17); +extern void ctaphid_tx_init(unsigned *state, unsigned cid, unsigned char cmd, + const unsigned char *data, unsigned len); +extern unsigned ctaphid_tx_next(unsigned *state, unsigned char *out); +extern unsigned usb_state[5]; /* pending, configured, out_pid, in_pid, tx_done */ +extern unsigned ctaphid_state[12]; +extern unsigned char ctaphid_msg[7609]; + +/* dispatcher verdicts + transport control + worker wait (the asm/ctaphid_ + * {dispatch,ctrl,wait}.S layouts; the same caller-owned states difftest.c + * mirrors) */ +struct lock_state { unsigned cid, until_lo, until_hi; }; +struct wait_state { unsigned active, next_lo, next_hi; }; +extern unsigned ctaphid_lock_command(const unsigned char *body, unsigned len); +extern unsigned ctaphid_wink(unsigned can_wink); +extern unsigned ctaphid_msg_guard(unsigned cmd, unsigned len); +extern void ctaphid_lock_arm(struct lock_state *st, unsigned cid, unsigned secs, + unsigned now_lo, unsigned now_hi); +extern unsigned ctaphid_lock_refuses(struct lock_state *st, unsigned cid, + unsigned cmd, unsigned now_lo, unsigned now_hi); +extern unsigned ctaphid_keepalive_status(unsigned is_cbor, unsigned up_pending); +extern void ctaphid_wait_start(struct wait_state *st, unsigned now_lo, unsigned now_hi); +extern unsigned ctaphid_wait_tick(struct wait_state *st, unsigned now_lo, unsigned now_hi); +extern unsigned ctaphid_wait_frame(unsigned up_pending, const unsigned char *frame, + unsigned n, unsigned cid); +extern void ctaphid_wait_finish(struct wait_state *st); + +extern long sys_mmap2(long addr, long len, long prot, long flags, long fd, long off); +extern long sys_write(long fd, const void *buf, long n); + +static const unsigned char DEV[18] = { + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, + 0x09, 0x12, 0x01, 0x00, 0x00, 0x01, 0x01, 0x02, 0x00, 0x01, +}; +static const unsigned char CFG[41] = { + 0x09, 0x02, 0x29, 0x00, 0x01, 0x01, 0x00, 0x80, 0x19, + 0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00, + 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x21, 0x00, + 0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x05, + 0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x05, +}; +static const unsigned char RPT[33] = { + 0x05, 0xF1, 0x09, 0x00, 0xA1, 0x01, 0x09, 0x20, 0x15, 0x00, + 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, + 0x21, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x40, + 0x91, 0x02, 0xC0, +}; +static const unsigned char STR0[4] = { 0x04, 0x03, 0x09, 0x04 }; +static const unsigned char STR1[40] = { + 40, 3, + 0x52,0x00, 0x53,0x00, 0x2D,0x00, 0x4B,0x00, 0x65,0x00, 0x79,0x00, 0x20,0x00, + 0x63,0x00, 0x6F,0x00, 0x6E,0x00, 0x74,0x00, 0x72,0x00, 0x69,0x00, 0x62,0x00, + 0x75,0x00, 0x74,0x00, 0x6F,0x00, 0x72,0x00, 0x73,0x00, +}; +static const unsigned char STR2[14] = { + 14, 3, + 0x52,0x00, 0x53,0x00, 0x2D,0x00, 0x4B,0x00, 0x65,0x00, 0x79,0x00, +}; + +static unsigned sie_shadow, buff_shadow; +static int fails; + +static void say(const char *s) +{ + int n = 0; + while (s[n]) n++; + sys_write(1, s, n); +} + +#define CHECK(cond, name) \ + do { if (cond) { say("ok " name "\n"); } else { fails++; say("FAIL " name "\n"); } } while (0) + +static void cycle(void) +{ + REG(R_SIE_STATUS) = sie_shadow; + REG(R_BUFF_STATUS) = buff_shadow; + usb_task(); + /* one-shot: an event is visible to exactly one usb_task call, matching + * what the driver's write-to-clear achieves on hardware */ + sie_shadow = 0; + buff_shadow = 0; + if (BC_IN & BC_AVAIL) { + BC_IN &= ~BC_AVAIL; + buff_shadow |= 1u; + } + if (BC_OUT & BC_AVAIL) { + BC_OUT &= ~BC_AVAIL; + buff_shadow |= 2u; + } + if (BC1_IN & BC_AVAIL) { + BC1_IN &= ~BC_AVAIL; + buff_shadow |= 4u; + } +} + +static void pump(int n) +{ + while (n-- > 0) cycle(); +} + +static void inject(const unsigned char *s) +{ + volatile unsigned char *d = (volatile unsigned char *)DPRAM_BASE; + for (int i = 0; i < 8; i++) d[i] = s[i]; + sie_shadow |= ST_SETUP_REC; +} + +/* the host sends an EP1 OUT packet into the armed receive buffer */ +static void host_send_ep1(const unsigned char *data, int n) +{ + for (int i = 0; i < n; i++) EP1_OUT_BUF(i) = data[i]; + BC1_OUT = (BC1_OUT & ~(BC_AVAIL | 0x3ffu)) | BC_FULL | n; + buff_shadow |= 8u; +} + +static int buf_matches(const unsigned char *exp, int n) +{ + for (int i = 0; i < n; i++) + if (EPBUF(i) != exp[i]) return 0; + return 1; +} + +static int msg_matches(const unsigned char *exp, int n) +{ + for (int i = 0; i < n; i++) + if (ctaphid_msg[i] != exp[i]) return 0; + return 1; +} + +/* one CTAPHID frame padded to a 64-byte USB report */ +static void ctaphid_packet(unsigned char *p, unsigned cid, int is_init, + unsigned char type_or_seq, int bcnt, + const unsigned char *data, int dlen) +{ + for (int i = 0; i < 64; i++) p[i] = 0; + p[0] = cid & 0xff; p[1] = (cid >> 8) & 0xff; + p[2] = (cid >> 16) & 0xff; p[3] = (cid >> 24) & 0xff; + p[4] = type_or_seq; + int off, cap; + if (is_init) { + p[5] = (bcnt >> 8) & 0xff; p[6] = bcnt & 0xff; + off = 7; cap = 57; + } else { + off = 5; cap = 59; + } + int n = bcnt < cap ? bcnt : cap; + if (n > dlen) n = dlen; + for (int i = 0; i < n; i++) p[off + i] = data[i]; +} + +/* the responder's IN read after the driver sent one frame: the model + * auto-completes on the next cycle; compare the EP1 IN DPRAM against + * the frame the TX kernel produced */ +static int send_and_read_ep1_in(const unsigned char *frame) +{ + usb_state[4] = 0; /* consume the prior completion */ + usb_send_ep1(frame, 64); + pump(2); + if (usb_state[4] != 1) return 0; + int ok = 1; + for (int i = 0; i < 64; i++) + if (EP1_IN_BUF(i) != frame[i]) ok = 0; + return ok; +} + +int harness_main(void) +{ + long m = sys_mmap2(DPRAM_BASE, 0x20000, 3, 0x32, -1, 0); + if ((unsigned long)m > 0xfffff000u) { + say("FAIL mmap dpram\n"); + return 1; + } + m = sys_mmap2(RESETS_BASE, 0x1000, 3, 0x32, -1, 0); + if ((unsigned long)m > 0xfffff000u) { + say("FAIL mmap resets\n"); + return 1; + } + /* pretend the reset sequencer already finished, so usb_init's poll + * passes; the pre-set RESET word lets us check the release write */ + REGS2(0x8) = 0x10000000u; + REGS2(0x0) = 0xffffffffu; + + usb_init(); + CHECK(REGS2(0x0) == 0xefffffffu, "init releases usbctrl reset"); + CHECK(REG(R_MUXING) == 0x9, "init muxing to_phy|softcon"); + CHECK(REG(R_PWR) == 0xc, "init pwr vbus forced"); + CHECK(REG(R_MAIN_CTRL) & 1, "init main_ctrl en"); + CHECK(REG(R_SIE_CTRL) == 0x20010000u, "init pullup + ep0_int_1buf"); + CHECK(REG(R_ADDR_ENDP) == 0, "init addr 0"); + CHECK(EP1_CTRL_OUT == 0xAC000180u, "init ep1 out ctrl"); + CHECK(EP1_CTRL_IN == 0xAC0001C0u, "init ep1 in ctrl"); + CHECK(ctaphid_state[6] == 7609, "init ctaphid buf_max"); + CHECK((unsigned)ctaphid_state[7] == (unsigned)ctaphid_msg, "init ctaphid buf"); + + /* device descriptor, full length */ + inject((const unsigned char[]){0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x40, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 18, "devdesc len 18"); + CHECK((BC_IN & (BC_FULL | BC_DATA1 | BC_LAST)) == (BC_FULL | BC_DATA1 | BC_LAST), "devdesc full/pid/last"); + CHECK(buf_matches(DEV, 18), "devdesc bytes"); + CHECK(EPBUF(14) == 1 && EPBUF(15) == 2, "devdesc string indices 1/2"); + + /* wLength clamp */ + inject((const unsigned char[]){0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x08, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 8, "devdesc clamped to 8"); + CHECK(buf_matches(DEV, 8), "devdesc clamp bytes"); + + /* SET_ADDRESS latches after the zero-length IN status stage */ + inject((const unsigned char[]){0x00, 0x05, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK(REG(R_ADDR_ENDP) == 5, "set_address 5"); + + /* bus reset clears address, configuration and the data toggles */ + sie_shadow |= ST_BUS_RESET; + pump(1); + CHECK(REG(R_ADDR_ENDP) == 0, "bus reset clears addr"); + + inject((const unsigned char[]){0x00, 0x05, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK(REG(R_ADDR_ENDP) == 7, "set_address 7"); + + inject((const unsigned char[]){0x00, 0x09, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK(usb_state[1] == 1, "set_config 1"); + CHECK((BC1_OUT & (BC_AVAIL | 0x3ff)) == (BC_AVAIL | 64), "config arms ep1 out"); + CHECK(!(BC1_OUT & BC_DATA1), "first ep1 out is DATA0"); + + /* §9.6.2: an address above 0x7F is a request error */ + inject((const unsigned char[]){0x00, 0x05, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK((BC_IN & BC_STALL) && (BC_OUT & BC_STALL), "set_address 0xff stalls"); + CHECK(REG(R_ADDR_ENDP) == 7, "stalled address not latched"); + + /* wValue 0x0109 has the same low byte as address 9 but is not one */ + inject((const unsigned char[]){0x00, 0x05, 0x09, 0x01, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK((BC_IN & BC_STALL) && (BC_OUT & BC_STALL), "set_address 0x109 stalls"); + + /* GET_CONFIGURATION follows the configured state */ + inject((const unsigned char[]){0x80, 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 1, "get_configuration len 1"); + CHECK(EPBUF(0) == 1, "get_configuration value 1"); + + /* GET_INTERFACE: one interface, alternate setting 0 */ + inject((const unsigned char[]){0x81, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 1, "get_interface len 1"); + CHECK(EPBUF(0) == 0, "get_interface alt 0"); + + inject((const unsigned char[]){0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK(usb_state[1] == 0, "set_config 0"); + + inject((const unsigned char[]){0x80, 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 1, "get_configuration after unconfig"); + CHECK(EPBUF(0) == 0, "unconfigured reports 0"); + + /* config descriptor */ + inject((const unsigned char[]){0x80, 0x06, 0x00, 0x02, 0x00, 0x00, 0x29, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 41, "cfgdesc len 41"); + CHECK(buf_matches(CFG, 41), "cfgdesc bytes"); + + /* HID report descriptor (recipient = interface) */ + inject((const unsigned char[]){0x81, 0x06, 0x00, 0x22, 0x00, 0x00, 0x21, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 33, "reportdesc len 33"); + CHECK(buf_matches(RPT, 33), "reportdesc bytes"); + + /* string descriptor 0 (LANGID, US English) */ + inject((const unsigned char[]){0x80, 0x06, 0x00, 0x03, 0x00, 0x00, 0x04, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 4, "str0 langid len 4"); + CHECK(buf_matches(STR0, 4), "str0 langid bytes"); + + /* string 0 clamps when wLength < bLength */ + inject((const unsigned char[]){0x80, 0x06, 0x00, 0x03, 0x00, 0x00, 0x02, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 2, "str0 clamped to 2"); + CHECK(buf_matches(STR0, 2), "str0 clamp bytes"); + + /* string 1 (manufacturer) */ + inject((const unsigned char[]){0x80, 0x06, 0x01, 0x03, 0x00, 0x00, 0x00, 0x40}); + pump(5); + CHECK((BC_IN & 0x3ff) == 40, "str1 len 40"); + CHECK(buf_matches(STR1, 40), "str1 bytes"); + + /* string 2 (product) */ + inject((const unsigned char[]){0x80, 0x06, 0x02, 0x03, 0x00, 0x00, 0x00, 0x40}); + pump(5); + CHECK((BC_IN & 0x3ff) == 14, "str2 len 14"); + CHECK(buf_matches(STR2, 14), "str2 bytes"); + + /* unknown string index stalls both directions */ + inject((const unsigned char[]){0x80, 0x06, 0x07, 0x03, 0x00, 0x00, 0x04, 0x00}); + pump(5); + CHECK((BC_IN & BC_STALL) && (BC_OUT & BC_STALL), "unknown string index stalls"); + + /* GET_STATUS */ + inject((const unsigned char[]){0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00}); + pump(5); + CHECK((BC_IN & 0x3ff) == 2, "get_status len 2"); + CHECK(EPBUF(0) == 0 && EPBUF(1) == 0, "get_status zeros"); + + /* unknown request stalls both directions */ + inject((const unsigned char[]){0x80, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK((BC_IN & BC_STALL) && (BC_OUT & BC_STALL), "unknown stalls"); + + /* ---- EP1 data path: configure, then talk CTAPHID over interrupt ---- */ + inject((const unsigned char[]){0x00, 0x09, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK((BC1_OUT & (BC_AVAIL | 0x3ff)) == (BC_AVAIL | 64), "reconfig rearms ep1 out"); + + unsigned char pkt[64]; + unsigned char payload[64]; + for (int i = 0; i < 64; i++) payload[i] = (unsigned char)(i * 7 + 3); + + /* single-packet message completes on the INIT report */ + ctaphid_packet(pkt, 0xA1B2C3D4u, 1, 0x90, 16, payload, 16); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "ep1 single packet done"); + CHECK(ctaphid_state[CS_CUR] == 16, "ep1 single length"); + CHECK(msg_matches(payload, 16), "ep1 single bytes"); + CHECK((BC1_OUT & (BC_AVAIL | BC_DATA1)) == (BC_AVAIL | BC_DATA1), "ep1 out re-armed DATA1"); + + /* multi-packet: INIT carries 57, CONT 0 carries the rest */ + ctaphid_packet(pkt, 0xA1B2C3D4u, 1, 0x90, 100, payload, 100); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_IN_TX] == 1, "ep1 mid-transaction"); + CHECK(ctaphid_state[CS_EV_TAG] == 0, "ep1 no event mid-transaction"); + ctaphid_packet(pkt, 0xA1B2C3D4u, 0, 0x00, 100, payload + 57, 43); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "ep1 multi done"); + CHECK(ctaphid_state[CS_CUR] == 100, "ep1 multi length"); + CHECK(msg_matches(payload, 100), "ep1 multi bytes"); + + /* out-of-sequence CONT aborts with INVALID_SEQ */ + ctaphid_packet(pkt, 0xA1B2C3D4u, 1, 0x90, 200, payload, 57); + host_send_ep1(pkt, 64); + pump(2); + ctaphid_packet(pkt, 0xA1B2C3D4u, 0, 0x01, 200, payload + 57, 59); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_ERROR, "ep1 bad seq error"); + CHECK(ctaphid_state[CS_EV_VAL] == ERR_INVALID_SEQ, "ep1 bad seq code"); + + /* short host packet: the stale DPRAM tail must not reach the message */ + ctaphid_packet(pkt, 0x11223344u, 1, 0x90, 16, payload, 16); + for (int i = 10; i < 64; i++) pkt[i] = 0xBB; /* would-be stale bytes */ + host_send_ep1(pkt, 10); /* host stops at byte 10 */ + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "ep1 short packet done"); + CHECK(ctaphid_state[CS_CUR] == 16, "ep1 short length"); + { + int stale = 0; + for (int i = 10; i < 16; i++) + if (ctaphid_msg[i] != 0) stale = 1; + CHECK(!stale, "ep1 short tail zeroed"); + } + + /* ---- EP1 IN: transmit with data-toggle tracking ---- */ + usb_send_ep1(payload, 32); + CHECK((BC1_IN & (BC_AVAIL | BC_FULL | 0x3ff)) == (BC_AVAIL | BC_FULL | 32), "ep1 in armed"); + CHECK(!(BC1_IN & BC_DATA1), "first ep1 in is DATA0"); + pump(2); + CHECK(usb_state[4] == 1, "ep1 tx_done"); + CHECK(usb_state[3] == 1, "ep1 in pid toggled"); + + usb_send_ep1(payload, 32); + CHECK((BC1_IN & BC_DATA1) != 0, "second ep1 in is DATA1"); + pump(2); + CHECK(usb_state[4] == 1, "ep1 tx_done again"); + + /* tx payload lands in the EP1 IN buffer */ + usb_send_ep1(payload, 8); + int ok = 1; + for (int i = 0; i < 8; i++) + if (EP1_IN_BUF(i) != payload[i]) ok = 0; + pump(2); + CHECK(ok, "ep1 in bytes"); + + /* bus reset returns the data toggles to DATA0 */ + sie_shadow |= ST_BUS_RESET; + pump(1); + CHECK(usb_state[2] == 0 && usb_state[3] == 0, "bus reset clears pids"); + + inject((const unsigned char[]){0x00, 0x09, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK(!(BC1_OUT & BC_DATA1), "post-reset ep1 out DATA0"); + usb_send_ep1(payload, 16); + CHECK(!(BC1_IN & BC_DATA1), "post-reset ep1 in DATA0"); + pump(2); + + /* ---- E2E: host -> EP1 OUT -> ctaphid_feed -> done -> harness + * echoes via ctaphid_tx + usb_send_ep1 -> model host reads EP1 IN ---- */ + unsigned char txst[CTAPHID_TX_STATE]; + unsigned char frame[64]; + unsigned char data400[400]; + for (int i = 0; i < 400; i++) data400[i] = (unsigned char)(i * 3 + 11); + unsigned char req32[32]; + for (int i = 0; i < 32; i++) req32[i] = (unsigned char)(i + 100); + + /* (a) single-frame response: host request payload 32b, response 32b + * (fits in one INIT, seq path = INIT only). The wire INIT byte is + * cmd|0x80 (CTAP2 §11.2.9.2); the response cmd reuses it, bit 7 + * already set — 0x83 here */ + ctaphid_packet(pkt, 0x5EED5EEDu, 1, 0x83, 32, req32, 32); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "e2e single request done"); + CHECK((unsigned)ctaphid_state[CS_EV_CMD] == (unsigned)0x83u, "e2e single cmd echo"); + ctaphid_tx_init((unsigned*)txst, 0x5EED5EEDu, 0x83, req32, 32); + CHECK(ctaphid_tx_next((unsigned*)txst, frame) == 1, "e2e single tx frame 1"); + CHECK(!(BC1_IN & BC_DATA1), "e2e single in DATA0 (post-reset pid)"); + CHECK(send_and_read_ep1_in(frame), "e2e single dpram == tx frame"); + CHECK(ctaphid_tx_next((unsigned*)txst, frame) == 0, "e2e single tx exhausted"); + + /* (b) 100-byte response crosses 57/59: INIT + 1 CONT (seq 0) */ + ctaphid_packet(pkt, 0x5EED5EEDu, 1, 0x84, 40, data400, 40); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "e2e mid request done"); + ctaphid_tx_init((unsigned*)txst, 0x5EED5EEDu, 0x84, data400, 100); + CHECK(ctaphid_tx_next((unsigned*)txst, frame) == 1, "e2e multi tx init"); + CHECK(send_and_read_ep1_in(frame), "e2e multi dpram == tx init"); + CHECK(usb_state[3] == 1, "e2e multi pid toggled toward DATA1"); + CHECK(ctaphid_tx_next((unsigned*)txst, frame) == 1, "e2e multi tx cont"); + CHECK(send_and_read_ep1_in(frame), "e2e multi dpram == tx cont"); + CHECK(usb_state[3] == 0, "e2e multi pid back to DATA0 after 2 frames"); + CHECK(ctaphid_tx_next((unsigned*)txst, frame) == 0, "e2e multi tx exhausted"); + + /* (c) 400-byte response: INIT + CONT seq 0..5, 6 CONT frames + * (INIT 57 + 5*59 = 352, tail 48 in CONT5) */ + ctaphid_packet(pkt, 0x5EED5EEDu, 1, 0x85, 20, data400, 20); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "e2e large request done"); + /* INIT 57 + 5 contiguous runs of 59 (293..352) + 48 tail = 7 frames */ + ctaphid_tx_init((unsigned*)txst, 0x5EED5EEDu, 0x85, data400, 400); + int nframes = 0; + while (ctaphid_tx_next((unsigned*)txst, frame) == 1) { + CHECK(send_and_read_ep1_in(frame), "e2e large dpram == tx frame"); + nframes++; + } + CHECK(nframes == 7, "e2e large frame count 7"); + + /* (d) IN data toggle alternated through the whole exchange above: + * odd IN completion since the SET_CONFIGURATION just before the + * section, so the next frame must go out on DATA1 */ + CHECK(usb_state[3] == 1, "e2e toggle odd count leaves pid DATA1"); + usb_send_ep1(frame, 64); + CHECK(BC1_IN & BC_DATA1, "e2e toggle next in DATA1"); + CHECK(send_and_read_ep1_in(frame), "e2e toggle dpram == tx frame"); + CHECK(usb_state[3] == 0, "e2e toggle flipped back to DATA0"); + inject((const unsigned char[]){0x00, 0x09, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}); + pump(5); + CHECK(usb_state[3] == 0 && usb_state[2] == 0, "e2e set_config resets both pids"); + CHECK(!(BC1_OUT & BC_DATA1), "e2e set_config re-arms out DATA0"); + + /* (e) interleaving: a second host->device message after the response + * completes exercises reassembler state reset; the echo must still + * match byte-for-byte */ + unsigned char data200[200]; + for (int i = 0; i < 200; i++) data200[i] = (unsigned char)(i * 5 + 77); + ctaphid_packet(pkt, 0x0BAD5EEDu, 1, 0x86, 12, data200, 12); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "e2e interleave request done"); + CHECK((unsigned)ctaphid_state[CS_EV_CMD] == (unsigned)0x86u, "e2e interleave cmd echo"); + CHECK(ctaphid_state[CS_CUR] == 12, "e2e interleave length"); + CHECK(msg_matches(data200, 12), "e2e interleave bytes"); + ctaphid_tx_init((unsigned*)txst, 0x0BAD5EEDu, 0x86, data200, 150); + nframes = 0; + while (ctaphid_tx_next((unsigned*)txst, frame) == 1) { + CHECK(send_and_read_ep1_in(frame), "e2e interleave dpram == tx frame"); + nframes++; + } + CHECK(nframes == 3, "e2e interleave frame count 3"); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "e2e interleave rx still settled"); + CHECK(ctaphid_state[CS_CID] == 0x0BAD5EEDu, "e2e interleave rx cid holds new channel"); + + /* ---- E2E: INIT allocation (CTAP2.1 §11.2.9.1.3/§11.2.9.4) — the full + * exchange: broadcast INIT demand -> reassembler done -> allocation + * kernel -> TX kernel -> EP1 IN -> model host reads the reply. The + * reply to a broadcast INIT is itself broadcast. ---- */ + unsigned char initst[CTAPHID_INIT_STATE]; + /* the caller-owned next_cid the allocation kernel persists in + * (asm/ctaphid_init.S state layout) */ + ctaphid_init_init((unsigned *)initst); + + /* (i) broadcast INIT demand: cid 0xffffffff may only carry 0x86, and the + * reassembler must complete it on the nonce */ + unsigned char nonce1[INIT_NONCE_LEN] = { + 0xA0, 0x14, 0x62, 0x7B, 0xC5, 0x08, 0x3D, 0xE1 + }; + ctaphid_packet(pkt, INIT_BROADCAST_CID, 1, INIT_CMD, INIT_NONCE_LEN, + nonce1, INIT_NONCE_LEN); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "init e2e request done"); + CHECK((unsigned)ctaphid_state[CS_EV_CMD] == (unsigned)INIT_CMD, "init e2e cmd 0x86"); + CHECK(msg_matches(nonce1, INIT_NONCE_LEN), "init e2e nonce reassembled"); + CHECK(ctaphid_state[CS_CUR] == INIT_NONCE_LEN, "init e2e length"); + + /* (ii) the allocation kernel composes the 17-byte reply into the caller's + * buffer; the TX kernel frames it, the model host reads it out of DPRAM. + * The field asserts below make a layout corruption visible even if the + * whole-copy above is clean */ + unsigned char resp17[INIT_RESP_LEN]; + CHECK(ctaphid_init_run((unsigned *)initst, nonce1, INIT_NONCE_LEN, 0, resp17) == 1, + "init e2e run ok"); + ctaphid_tx_init((unsigned *)txst, INIT_BROADCAST_CID, INIT_CMD, resp17, INIT_RESP_LEN); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "init e2e tx frame 1"); + CHECK(send_and_read_ep1_in(frame), "init e2e dpram == tx frame"); + CHECK(frame[0] == 0xff && frame[1] == 0xff && frame[2] == 0xff && + frame[3] == 0xff, "init reply broadcast cid"); + CHECK(frame[4] == INIT_CMD, "init reply type 0x86"); + CHECK(frame[5] == 0 && frame[6] == INIT_RESP_LEN, "init reply bcnt 17"); + /* report payload = INIT_DATA + reply offset */ + for (int i = 0; i < INIT_NONCE_LEN; i++) + CHECK(frame[INIT_DATA + i] == nonce1[i], "init reply nonce echo"); + CHECK(frame[INIT_DATA + INIT_CID_OFF + 0] == 0x00 && + frame[INIT_DATA + INIT_CID_OFF + 1] == 0x00 && + frame[INIT_DATA + INIT_CID_OFF + 2] == 0x00 && + frame[INIT_DATA + INIT_CID_OFF + 3] == 0x01, + "init reply first cid 0x01000000"); + CHECK(frame[INIT_DATA + INIT_IFACE_OFF] == INIT_IFACE, "init reply iface 2"); + CHECK(frame[INIT_DATA + INIT_VER_OFF + 0] == INIT_MAJ && + frame[INIT_DATA + INIT_VER_OFF + 1] == INIT_MIN && + frame[INIT_DATA + INIT_VER_OFF + 2] == INIT_BLD, "init reply version 5.8.0"); + CHECK(frame[INIT_DATA + INIT_CAPS_OFF] == CAPS_NOWINK, "init reply caps LOCK|CBOR"); + + /* (iv, first half) the stored toggle is the pid of the NEXT IN frame: + * the interleave echo's 3 frames since SET_CONFIGURATION left it armed + * for DATA1, so this response went out on DATA1 and the toggle is now + * armed for DATA0 */ + CHECK(usb_state[3] == 0, "init e2e toggle armed DATA0 after first response"); + + /* (iii) different nonce, same broadcast channel: the counter is caller + * state, so it must persist across the whole section */ + unsigned char nonce2[INIT_NONCE_LEN] = { + 0x47, 0x39, 0xE6, 0x1C, 0x88, 0xB2, 0x05, 0xA9 + }; + ctaphid_packet(pkt, INIT_BROADCAST_CID, 1, INIT_CMD, INIT_NONCE_LEN, + nonce2, INIT_NONCE_LEN); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "init e2e second request done"); + CHECK(msg_matches(nonce2, INIT_NONCE_LEN), "init e2e second nonce"); + unsigned char resp17b[INIT_RESP_LEN]; + CHECK(ctaphid_init_run((unsigned *)initst, nonce2, INIT_NONCE_LEN, 0, resp17b) == 1, + "init e2e second run ok"); + ctaphid_tx_init((unsigned *)txst, INIT_BROADCAST_CID, INIT_CMD, resp17b, INIT_RESP_LEN); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "init second tx frame 1"); + CHECK(send_and_read_ep1_in(frame), "init second dpram == tx frame"); + for (int i = 0; i < INIT_NONCE_LEN; i++) + CHECK(frame[INIT_DATA + i] == nonce2[i], "init second nonce echo"); + CHECK(frame[INIT_DATA + INIT_CID_OFF + 0] == 0x01 && + frame[INIT_DATA + INIT_CID_OFF + 1] == 0x00 && + frame[INIT_DATA + INIT_CID_OFF + 2] == 0x00 && + frame[INIT_DATA + INIT_CID_OFF + 3] == 0x01, + "init second cid 0x01000001"); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 0, "init e2e tx exhausted"); + + /* (iv, second half) the IN data toggle kept alternating through the + * two INIT responses: the second went out on DATA0 and the stored + * toggle is armed back on DATA1 */ + CHECK(usb_state[3] == 1, "init e2e toggle armed DATA1 after 2 responses"); + + /* ---- E2E: the full transport stack — the dispatcher verdicts and the + * worker-wait cadence over EP1, the INIT e2e one layer up: host -> + * EP1 OUT -> reassembler done -> verdict / cadence -> CTAPHID_ERROR or + * CTAPHID_KEEPALIVE or the response, framed through the TX kernel -> + * EP1 IN. The lock and wait states are the kernels' caller-owned + * structs, exactly as difftest.c drives them. ---- */ + struct lock_state lks = {0, 0, 0}; + struct wait_state wst = {0, 0, 0}; + unsigned lcid = 0xCAFE0001u, ocid = 0xCAFE0002u; + unsigned char one[1] = {5}; + + /* (a) LOCK arms and answers empty: verdict, arm at now_ms 100, the + * zero-length reply is the TX kernel's always-at-least-INIT contract */ + ctaphid_packet(pkt, lcid, 1, CMD_LOCK, 1, one, 1); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "stack e2e lock request done"); + CHECK(ctaphid_state[CS_CUR] == 1 && ctaphid_msg[0] == 5, "stack e2e lock body 1 byte, secs 5"); + CHECK(ctaphid_lock_command(ctaphid_msg, ctaphid_state[CS_CUR]) == 0, "stack e2e lock verdict arm"); + ctaphid_lock_arm(&lks, lcid, 5, 100, 0); + ctaphid_tx_init((unsigned *)txst, lcid, CMD_LOCK, one, 0); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "stack e2e lock reply one INIT frame"); + CHECK(send_and_read_ep1_in(frame), "stack e2e lock dpram == tx frame"); + CHECK(frame[0] == 0x01 && frame[1] == 0x00 && frame[2] == 0xfe && frame[3] == 0xca, + "stack e2e lock reply cid"); + CHECK(frame[4] == CMD_LOCK && frame[5] == 0 && frame[6] == 0, "stack e2e lock reply bcnt 0"); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 0, "stack e2e lock tx exhausted"); + CHECK(usb_state[3] == 0, "stack e2e toggle armed DATA0 after lock reply"); + + /* (b) a second host's MSG while locked earns CHANNEL_BUSY; the owner + * routes, and at the just-expired instant (now == until, armed 5 s at + * now_ms 100) the lock is already gone */ + ctaphid_packet(pkt, ocid, 1, CMD_MSG, 4, one, 0); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "stack e2e msg request done"); + CHECK(ctaphid_lock_refuses(&lks, ocid, CMD_MSG, 1000, 0) == 1, "stack e2e lock refuses the second host"); + CHECK(ctaphid_lock_refuses(&lks, lcid, CMD_CBOR, 1000, 0) == 0, "stack e2e owner routes while locked"); + CHECK(ctaphid_lock_refuses(&lks, ocid, CMD_CBOR, 5099, 0) == 1, "stack e2e still locked at 5099"); + CHECK(ctaphid_lock_refuses(&lks, ocid, CMD_CBOR, 5100, 0) == 0, "stack e2e expired at the boundary"); + one[0] = ERRCODE_CHANNEL_BUSY; + ctaphid_tx_init((unsigned *)txst, ocid, CMD_ERROR, one, 1); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "stack e2e busy error one frame"); + CHECK(send_and_read_ep1_in(frame), "stack e2e busy dpram == tx frame"); + CHECK(frame[4] == CMD_ERROR && frame[5] == 0 && frame[6] == 1 && frame[7] == ERRCODE_CHANNEL_BUSY, + "stack e2e busy error frame 0xbf/06"); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 0, "stack e2e busy tx exhausted"); + + /* (c) WINK without an indicator is INVALID_CMD */ + CHECK(ctaphid_wink(0) == 1, "stack e2e wink refused without an indicator"); + one[0] = ERRCODE_INVALID_CMD; + ctaphid_tx_init((unsigned *)txst, lcid, CMD_ERROR, one, 1); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "stack e2e wink error one frame"); + CHECK(send_and_read_ep1_in(frame), "stack e2e wink dpram == tx frame"); + CHECK(frame[4] == CMD_ERROR && frame[7] == ERRCODE_INVALID_CMD, "stack e2e wink error frame 0xbf/01"); + + /* (d) an empty CBOR message is INVALID_LEN; the reassembler completes + * the zero-bcnt INIT frame, the guard refuses it */ + ctaphid_packet(pkt, lcid, 1, CMD_CBOR, 0, one, 0); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "stack e2e empty cbor done"); + CHECK(ctaphid_state[CS_CUR] == 0, "stack e2e empty cbor length 0"); + CHECK(ctaphid_msg_guard(CMD_CBOR, 0) == 1, "stack e2e empty cbor refused"); + one[0] = ERRCODE_INVALID_LEN; + ctaphid_tx_init((unsigned *)txst, lcid, CMD_ERROR, one, 1); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "stack e2e cbor error one frame"); + CHECK(send_and_read_ep1_in(frame), "stack e2e cbor dpram == tx frame"); + CHECK(frame[4] == CMD_ERROR && frame[7] == ERRCODE_INVALID_LEN, "stack e2e cbor error frame 0xbf/03"); + + /* (e) a CBOR request enters the worker wait: one keepalive per 100 ms + * deadline, PROCESSING off the touch wait and UPNEEDED on it, both out + * EP1 IN */ + unsigned char cborbody[4] = {0x01, 0xA2, 0x03, 0xB4}; + ctaphid_packet(pkt, lcid, 1, CMD_CBOR, 4, cborbody, 4); + host_send_ep1(pkt, 64); + pump(2); + CHECK(ctaphid_state[CS_EV_TAG] == EV_DONE, "stack e2e wait request done"); + CHECK(ctaphid_msg_guard(CMD_CBOR, ctaphid_state[CS_CUR]) == 0, "stack e2e wait cbor routes"); + ctaphid_wait_start(&wst, 0, 0); + CHECK(ctaphid_wait_tick(&wst, 50, 0) == 0, "stack e2e nothing before the deadline"); + CHECK(ctaphid_wait_tick(&wst, 100, 0) == 1, "stack e2e deadline due"); + CHECK(ctaphid_keepalive_status(1, 0) == KA_PROCESSING, "stack e2e processing status"); + one[0] = KA_PROCESSING; + ctaphid_tx_init((unsigned *)txst, lcid, CMD_KEEPALIVE, one, 1); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "stack e2e ka processing one frame"); + CHECK(send_and_read_ep1_in(frame), "stack e2e ka processing dpram == tx frame"); + CHECK(frame[4] == CMD_KEEPALIVE && frame[5] == 0 && frame[6] == 1 && frame[7] == KA_PROCESSING, + "stack e2e ka frame 0xbb/01"); + CHECK(ctaphid_wait_tick(&wst, 100, 0) == 0, "stack e2e next deadline not due yet"); + CHECK(ctaphid_wait_tick(&wst, 200, 0) == 1, "stack e2e second deadline due"); + CHECK(ctaphid_keepalive_status(1, 1) == KA_UPNEEDED, "stack e2e upneeded status"); + one[0] = KA_UPNEEDED; + ctaphid_tx_init((unsigned *)txst, lcid, CMD_KEEPALIVE, one, 1); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "stack e2e ka upneeded one frame"); + CHECK(send_and_read_ep1_in(frame), "stack e2e ka upneeded dpram == tx frame"); + CHECK(frame[4] == CMD_KEEPALIVE && frame[7] == KA_UPNEEDED, "stack e2e ka frame 0xbb/02"); + + /* (f) the touch goes pending: a CANCEL on the waiting channel signals + * the worker, one on a foreign channel is dropped; the raw frames go + * straight to the wait window — the driver never reassembles them, so + * no pump runs and the OUT buffer is consumed by the watch read, + * exactly the firmware's reader split */ + unsigned char watch[64]; + ctaphid_packet(pkt, ocid, 1, CMD_CANCEL, 0, one, 0); + host_send_ep1(pkt, 64); + for (int i = 0; i < 64; i++) watch[i] = EP1_OUT_BUF(i); + BC1_OUT &= ~(BC_FULL | 0x3ffu); + buff_shadow &= ~8u; + CHECK(ctaphid_wait_frame(1, watch, 64, lcid) == 1, "stack e2e foreign cancel drops"); + ctaphid_packet(pkt, lcid, 1, CMD_CANCEL, 0, one, 0); + host_send_ep1(pkt, 64); + for (int i = 0; i < 64; i++) watch[i] = EP1_OUT_BUF(i); + BC1_OUT &= ~(BC_FULL | 0x3ffu); + buff_shadow &= ~8u; + CHECK(ctaphid_wait_frame(1, watch, 64, lcid) == 2, "stack e2e own cancel signals"); + CHECK(ctaphid_wait_frame(0, watch, 64, lcid) == 0, "stack e2e off the touch wait it queues"); + + /* (g) the worker answers the cancelled touch with CTAP2_ERR_KEEPALIVE_ + * CANCEL in the CBOR response; the wait ends and the cadence with it */ + ctaphid_wait_finish(&wst); + CHECK(ctaphid_wait_tick(&wst, 9999, 0) == 0, "stack e2e finished wait never due"); + one[0] = 0x2d; /* CTAP2_ERR_KEEPALIVE_CANCEL (rsk-fido/src/error.rs:31) */ + ctaphid_tx_init((unsigned *)txst, lcid, CMD_CBOR, one, 1); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 1, "stack e2e response one frame"); + CHECK(send_and_read_ep1_in(frame), "stack e2e response dpram == tx frame"); + CHECK(frame[4] == CMD_CBOR && frame[7] == 0x2d, "stack e2e response 0x90/0x2d"); + CHECK(ctaphid_tx_next((unsigned *)txst, frame) == 0, "stack e2e response tx exhausted"); + + /* (h) a U2F fast op stays silent: the deadline is due but the status + * is None, so nothing goes out EP1 IN */ + ctaphid_wait_start(&wst, 0, 0); + CHECK(ctaphid_wait_tick(&wst, 100, 0) == 1, "stack e2e u2f deadline due"); + CHECK(ctaphid_keepalive_status(0, 0) == 0, "stack e2e u2f fast op stays silent"); + ctaphid_wait_finish(&wst); + + /* the whole section sent seven IN frames since the INIT pair left the + * toggle armed DATA1: lock reply, busy error, wink error, cbor error, + * two keepalives, the response — an odd count, so the toggle is armed + * back on DATA0 */ + CHECK(usb_state[3] == 0, "stack e2e toggle armed DATA0 after seven IN frames"); + + say(fails ? "USB MODEL TESTS: FAILED\n" : "USB MODEL TESTS: PASSED\n"); + return fails; +} diff --git a/asm/usbtest.sh b/asm/usbtest.sh new file mode 100755 index 000000000..bd80d7f1b --- /dev/null +++ b/asm/usbtest.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (C) 2026 RS-Key contributors +# +# Model-level USB test: builds the EP0 driver (asm/usb.S) plus its SIE-model +# harness into a static ARM Linux ELF and runs it under qemu-arm. Green here +# means datasheet-model agreement only — hardware execution is untested. + +set -euo pipefail +cd "$(dirname "$0")" +OUT="${USBTEST_OUT:-$(git rev-parse --show-toplevel 2>/dev/null || echo ..)/target/asm}" +mkdir -p "$OUT" +QEMU_BIN="${QEMU:-qemu-arm}" +command -v "$QEMU_BIN" >/dev/null || { echo "qemu-arm not found; set QEMU=" >&2; exit 2; } + +CFLAGS="-mcpu=cortex-m33 -mthumb -mfloat-abi=soft -ffreestanding -fno-builtin -nostdlib" +arm-none-eabi-gcc $CFLAGS -c difftest.S -o "$OUT/difftest-s.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid.S -o "$OUT/ctaphid.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_tx.S -o "$OUT/ctaphid-tx.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_init.S -o "$OUT/ctaphid-init.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_ctrl.S -o "$OUT/ctaphid-ctrl.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_dispatch.S -o "$OUT/ctaphid-dispatch.o" +arm-none-eabi-gcc $CFLAGS -c ctaphid_wait.S -o "$OUT/ctaphid-wait.o" +arm-none-eabi-gcc $CFLAGS -c usb.S -o "$OUT/usb.o" +arm-none-eabi-gcc $CFLAGS -Os -c usbtest.c -o "$OUT/usbtest-c.o" +arm-none-eabi-gcc -nostdlib -static -Wl,--build-id=none -e _start \ + -o "$OUT/usbtest.elf" "$OUT/usb.o" "$OUT/ctaphid.o" "$OUT/ctaphid-tx.o" "$OUT/ctaphid-init.o" "$OUT/ctaphid-ctrl.o" "$OUT/ctaphid-dispatch.o" "$OUT/ctaphid-wait.o" "$OUT/difftest-s.o" "$OUT/usbtest-c.o" +arm-none-eabi-objdump -d "$OUT/usbtest.elf" > "$OUT/usbtest.disasm" +arm-none-eabi-size "$OUT/usbtest.elf" + +# a poll on the wrong register would hang the driver; fail loudly instead +timeout 60 "$QEMU_BIN" "$OUT/usbtest.elf" diff --git a/crates/rsk-fido/src/bench.rs b/crates/rsk-fido/src/bench.rs index 69b11f092..563b76ff2 100644 --- a/crates/rsk-fido/src/bench.rs +++ b/crates/rsk-fido/src/bench.rs @@ -18,6 +18,10 @@ //! - `1` — P-256 fixed-base comb sign (`crate::ec::sign_p256_comb`): the //! getAssertion signing hot path. //! - `2` — the HKDF-SHA512 key-derivation ratchet (`crate::keyderiv::ratchet`). +//! - `4` — ML-DSA-65 keygen (`rsk_crypto::MlDsa65::from_seed` + `public_key`): +//! the PQC makeCredential path. +//! - `5` — ML-DSA-65 sign, key expansion included: the PQC getAssertion path. +//! - `6` — ML-DSA-65 sign + verify round trip (verify-only ≈ `6` − `5`). use core::hint::black_box; @@ -38,6 +42,13 @@ const FIXED_SCALAR: [u8; 32] = [ const FIXED_SEED: [u8; 32] = [0xa5; 32]; const FIXED_PATH: [u8; crate::keyderiv::KEY_PATH_LEN] = [0x5a; crate::keyderiv::KEY_PATH_LEN]; const FIXED_MSG: [u8; 32] = [0x3c; 32]; +/// The hedge randomness, fixed: all-zero is FIPS 204's deterministic variant, +/// so the signature is reproducible host-side. +const FIXED_RND: [u8; 32] = [0; 32]; + +/// The highest selector [`run`] answers; the firmware's P1 bound reads this so +/// the two cannot drift. +pub const SEL_LAST: u8 = 6; /// Run one primitive once and return a checksum of its output. The inputs are /// `black_box`ed so the compiler can't see they're compile-time constants: the @@ -71,10 +82,46 @@ pub fn run(sel: u8) -> u32 { let r = crate::keyderiv::ratchet(black_box(&FIXED_SEED), black_box(&FIXED_PATH)); checksum(&r) } + 4 => mldsa65_keygen(black_box(&FIXED_SEED)), + 5 => mldsa65_sign(black_box(&FIXED_SEED)), + 6 => mldsa65_sign_verify(black_box(&FIXED_SEED)), _ => 0, } } +// Each ML-DSA arm keeps its own `#[inline(never)]` frame: `from_seed`'s ~100 KiB +// expansion must not size `run`'s frame for the EC selectors too (the `ec.rs` +// CredKey rationale, one layer down). +#[inline(never)] +fn mldsa65_keygen(seed: &[u8; 32]) -> u32 { + let pk = rsk_crypto::MlDsa65::from_seed(seed).public_key(); + checksum(&pk) +} + +#[inline(never)] +fn mldsa65_sign(seed: &[u8; 32]) -> u32 { + let kp = rsk_crypto::MlDsa65::from_seed(seed); + let mut out = [0u8; rsk_crypto::MLDSA65_SIG_LEN]; + let n = kp + .sign(black_box(&FIXED_MSG), &FIXED_RND, &mut out) + .expect("bench out is MLDSA65_SIG_LEN"); + checksum(&out[..n]) +} + +#[inline(never)] +fn mldsa65_sign_verify(seed: &[u8; 32]) -> u32 { + let kp = rsk_crypto::MlDsa65::from_seed(seed); + let pk = kp.public_key(); + let mut sig = [0u8; rsk_crypto::MLDSA65_SIG_LEN]; + let n = kp + .sign(black_box(&FIXED_MSG), &FIXED_RND, &mut sig) + .expect("bench out is MLDSA65_SIG_LEN"); + // A `false` verify flips the checksum, so the test can tell a refused + // signature from a timed one. + let ok = rsk_crypto::mldsa65_verify(&pk, black_box(&FIXED_MSG), &sig); + checksum(&sig[..n]) ^ u32::from(ok) +} + /// XOR-fold `bytes` into a `u32`, wrapped in `black_box` so the whole [`run`] call /// can't be optimized away as unused. fn checksum(bytes: &[u8]) -> u32 { diff --git a/crates/rsk-fido/src/bench_tests.rs b/crates/rsk-fido/src/bench_tests.rs index 8a54e9a52..718e58d43 100644 --- a/crates/rsk-fido/src/bench_tests.rs +++ b/crates/rsk-fido/src/bench_tests.rs @@ -40,8 +40,37 @@ fn ratchet_selector_matches_the_kdf() { assert_eq!(run(2), checksum(&r)); } +#[test] +fn mldsa65_keygen_selector_expands_the_real_key() { + let pk = rsk_crypto::MlDsa65::from_seed(&FIXED_SEED).public_key(); + assert_eq!(pk.len(), rsk_crypto::MLDSA65_PK_LEN); + assert_eq!(run(4), checksum(&pk)); +} + +#[test] +fn mldsa65_sign_selector_produces_a_signature() { + let kp = rsk_crypto::MlDsa65::from_seed(&FIXED_SEED); + let mut out = [0u8; rsk_crypto::MLDSA65_SIG_LEN]; + let n = kp.sign(&FIXED_MSG, &FIXED_RND, &mut out).unwrap(); + assert_eq!(n, out.len()); + assert_eq!(run(5), checksum(&out[..n])); +} + +#[test] +fn mldsa65_verify_selector_round_trips() { + let kp = rsk_crypto::MlDsa65::from_seed(&FIXED_SEED); + let pk = kp.public_key(); + let mut sig = [0u8; rsk_crypto::MLDSA65_SIG_LEN]; + let n = kp.sign(&FIXED_MSG, &FIXED_RND, &mut sig).unwrap(); + assert!(rsk_crypto::mldsa65_verify(&pk, &FIXED_MSG, &sig)); + // run(6) folds the verify verdict in: a refusal flips the checksum. + assert_eq!(run(6), checksum(&sig[..n]) ^ 1); +} + #[test] fn unknown_selectors_are_zero() { + // 3 is the firmware-served OTP selector; `run` itself leaves it at 0. assert_eq!(run(3), 0); + assert_eq!(run(SEL_LAST + 1), 0); assert_eq!(run(255), 0); } diff --git a/docs/testing.md b/docs/testing.md index 82b216612..c48507a7f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -903,6 +903,9 @@ cargo build --release -p firmware --features bench,no-touch rsk bench ecdh # variable-base P-256 ECDH (the layout-sensitive one) rsk bench sign # P-256 comb sign (the getAssertion hot path) rsk bench ratchet # the HKDF-SHA512 key-derivation ratchet +rsk bench mldsa65_keygen # ML-DSA-65 keygen from seed +rsk bench mldsa65_sign # ML-DSA-65 sign (key expansion included) +rsk bench mldsa65_verify # ML-DSA-65 sign+verify (verify ≈ this − sign) ``` To A/B two builds without the cross-session trap that faked a "-33%" during the diff --git a/docs/verified-compilation.md b/docs/verified-compilation.md index f8a6e7fe2..ed022a02e 100644 --- a/docs/verified-compilation.md +++ b/docs/verified-compilation.md @@ -40,7 +40,7 @@ subset, one is already C, and three are not code a C compiler could accept. | linker-generated boundaries | five of the ten `[[boundary]]` rows in `assurance/toolchain.toml` | 5 symbols | **Not code.** The datum is a symbol's address; a compiler has nothing to say about it | The genericity is the load-bearing number. Across the production modules of -`crates/rsk-fido/src/`, 215 of 396 functions are generic and 169 of those carry +`crates/rsk-fido/src/`, 215 of 399 functions are generic and 169 of those carry ` Sw { use embassy_time::Instant; - if p1 > SEL_OTP_READ { + if p1 > rsk_fido::bench::SEL_LAST { return Sw::INCORRECT_P1P2; } let warmup = p2 as usize; diff --git a/scripts/spdx_gate.py b/scripts/spdx_gate.py index ab93b6d42..23bcfd85e 100644 --- a/scripts/spdx_gate.py +++ b/scripts/spdx_gate.py @@ -77,7 +77,7 @@ #: Growing this set means stamping the files that do not — a bulk edit, #: deliberately not this guard's to make on its own. CHECKED = frozenset({".rs", ".py", ".sh", ".nix", ".svg", ".x", ".tla", ".js", ".mjs", ".html", - ".c", ".h", ".S", ".yml"}) + ".c", ".h", ".S", ".s", ".ld", ".yml"}) #: Below this the guard is green over nothing. It has no roster to go empty, but #: it does walk a tree, and a walk that returns nothing exits 0 — the shape four diff --git a/tools/rsk/__init__.py b/tools/rsk/__init__.py index 19be49cd9..76a94adce 100644 --- a/tools/rsk/__init__.py +++ b/tools/rsk/__init__.py @@ -8,4 +8,4 @@ OpenPGP reset, and hands-free reboot. Run from `nix develop` (the flake provides `rsk` on PATH and all Python deps) or as `python -m rsk`. """ -__version__ = "0.3.37" +__version__ = "0.3.38" diff --git a/tools/rsk/bench.py b/tools/rsk/bench.py index 0e5ab6586..9e834bc1f 100644 --- a/tools/rsk/bench.py +++ b/tools/rsk/bench.py @@ -31,14 +31,17 @@ INS_BENCH = 0x14 SUMMARY_LEN = 20 -# Selector name -> (P1, human label). 0-2 mirror rsk_fido::bench::run; 3 is served -# in firmware/src/vendor.rs, since OTP is board hardware that host-testable crate -# cannot reach. +# Selector name -> (P1, human label). 0-2 and 4-6 mirror rsk_fido::bench::run; 3 +# is served in firmware/src/vendor.rs, since OTP is board hardware that +# host-testable crate cannot reach. PRIMITIVES = { "ecdh": (0, "variable-base P-256 ECDH (clientPIN key agreement)"), "sign": (1, "P-256 comb sign (getAssertion hot path)"), "ratchet": (2, "HKDF-SHA512 key-derivation ratchet"), "otp": (3, "OTP key-page read — 16 raw + 16 ECC rows"), + "mldsa65_keygen": (4, "ML-DSA-65 keygen from seed (makeCredential path)"), + "mldsa65_sign": (5, "ML-DSA-65 sign incl. key expansion (getAssertion path)"), + "mldsa65_verify": (6, "ML-DSA-65 sign+verify round trip (verify ≈ this − sign)"), }