Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ tag: the USB `bcdDevice` build counter (bumped on every behavior change), and
a non-converging backend returns `CTAP2_ERR_OTHER` instead of wedging the worker.
**bcdDevice → 0x0876.**

### Added

- **Deterministic delayed presence for `rsk-emu`.** `--auto-touch-ms` exposes a
real pending-presence interval to CTAPHID clients, honours channel-scoped
cancellation, and then confirms automatically. The emulator workspace also
forwards the `rsk-fido/fido-conformance` feature for unattended conformance
runs against the socket applet stack.

## [0.4.9] - 2026-08-09

The emulator release: `tools/emu` runs the applet stack on the host, and with it
Expand Down
2 changes: 2 additions & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ run "test (tui)" cargo test --manifest-path tools/tui/Cargo.toml -
# protocol suites without a board, which is exactly when they have no board.
run "fmt (emu)" cargo fmt --manifest-path tools/emu/Cargo.toml --check
run "clippy (emu)" cargo clippy --manifest-path tools/emu/Cargo.toml --target "$HOST" --all-targets -- -D warnings
run "clippy (emu conformance)" cargo clippy --manifest-path tools/emu/Cargo.toml --target "$HOST" --all-targets --features fido-conformance -- -D warnings
# fuzz/ is also its own (nightly) workspace. rustfmt needs no toolchain, so the
# stable gate can format-check it here; building/clippy stay in the .#fuzz shell
# (deep-checks CI). Format fuzz/ with this same stable rustfmt — not the .#fuzz
Expand Down Expand Up @@ -234,6 +235,7 @@ run "cargo-audit (tui SCA)" cargo audit --file tools/tui/Cargo.lock
# the Linux kernel's and whose framing rule decides how many bytes come off the
# socket next; both fail silently on the wire rather than loudly.
run "test (emu)" cargo test --manifest-path tools/emu/Cargo.toml --target "$HOST"
run "test (emu conformance)" cargo test --manifest-path tools/emu/Cargo.toml --target "$HOST" --features fido-conformance
run "cargo-audit (emu SCA)" cargo audit --file tools/emu/Cargo.lock --ignore RUSTSEC-2023-0071
run "cargo-deny" cargo deny check
# Supply-chain provenance-of-review: every dependency must be covered by an
Expand Down
3 changes: 3 additions & 0 deletions tools/emu/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ description = "rs-key software emulator — the applet stack on a socket, no har
name = "rsk-emu"
path = "src/main.rs"

[features]
fido-conformance = ["rsk-fido/fido-conformance"]

# Only the in-tree crates: the emulator is the same applet code the firmware
# runs, wired to a socket instead of USB. No external dependency joins an
# authenticator's build for a dev tool — randomness comes from `/dev/urandom`
Expand Down
21 changes: 21 additions & 0 deletions tools/emu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ cargo run --manifest-path tools/emu/Cargo.toml --target "$HOST" -- --store ./my.
--ccid-port <n> APDU/card port, 0 disables (default 7800)
--store <path> the flash image to mount (default: a blank chip, memory only)
--touch ask for every user presence on the terminal
--auto-touch-ms <n> report presence pending, then approve after n milliseconds
--display open the trusted display in a window (SDL2); presence
becomes an on-screen hold, as on a screen board
--usbip [addr] serve USB/IP (default 127.0.0.1:3240) so a Linux host can
Expand All @@ -33,6 +34,26 @@ cargo run --manifest-path tools/emu/Cargo.toml --target "$HOST" -- --store ./my.
--power-cut <n> cut the flash's power after n bytes of writes
```

`--auto-touch-ms` is mutually exclusive with `--touch` and `--display`. During
the delay the CTAPHID endpoint reports `UPNEEDED`; a `CANCEL` on the active
channel ends the operation before it is approved. This mode is intended for
deterministic conformance runs that need to observe keepalive and cancellation.

Build the emulator with the conformance-specific FIDO feature and run it with
delayed presence like this:

```bash
HOST="$(rustc -vV | sed -n 's/^host: //p')"
cargo build --manifest-path tools/emu/Cargo.toml --target "$HOST" \
--features fido-conformance
tools/emu/target/"$HOST"/debug/rsk-emu \
--store ./conformance.store --fido-port 7799 --ccid-port 7800 \
--auto-touch-ms 250 --trace
```

The `fido-conformance` feature forwards to `rsk-fido/fido-conformance`, which
uses the authenticator profile intended for the upstream FIDO corpus.

## Running the on-device suites against it

```bash
Expand Down
6 changes: 3 additions & 3 deletions tools/emu/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use rsk_device::{AppletHandler, BootState, CcidApplets, Hooks};
use rsk_fs::Fs;

use crate::platform::EmuPlatform;
use crate::presence::EmuPresence;
use crate::presence::{EmuPresence, PresenceMode};
use crate::rng::EmuRng;
use crate::signals::{self, Signals};
use crate::store::EmuStore;
Expand Down Expand Up @@ -93,7 +93,7 @@ impl Hooks<EmuStore> for EmuHooks {

pub struct Config {
pub store: Option<PathBuf>,
pub touch: bool,
pub presence: PresenceMode,
/// Serve the trusted display in a window; presence becomes an on-screen hold.
pub display: bool,
/// Serve USB/IP on this address, so a Linux host sees a real USB device.
Expand Down Expand Up @@ -212,7 +212,7 @@ pub fn run(
serve(cfg, jobs, signals, fs, rng, &presence),
));
} else {
let presence = RefCell::new(EmuPresence::new(cfg.touch, lines, signals.clone()));
let presence = RefCell::new(EmuPresence::new(cfg.presence, lines, signals.clone()));
crate::park::block_on(serve(cfg, jobs, signals, fs, rng, &presence));
}
}
Expand Down
92 changes: 83 additions & 9 deletions tools/emu/src/hid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,27 +180,101 @@ fn run_job(
let _ = rx.recv();
return Ok(None);
};

let previous_timeout = stream.read_timeout()?;
let result = run_active_job(stream, shared, &rx, cid, is_cbor);
let restore = stream.set_read_timeout(previous_timeout);
match (result, restore) {
(Err(error), _) => Err(error),
(Ok(_), Err(error)) => Err(error),
(Ok(out), Ok(())) => Ok(out),
}
}

fn run_active_job(
stream: &mut TcpStream,
shared: &Arc<Shared>,
rx: &mpsc::Receiver<Option<Vec<u8>>>,
cid: u32,
is_cbor: bool,
) -> io::Result<Option<Vec<u8>>> {
let poll = Duration::from_millis(50);
let keepalive = Duration::from_millis(KEEPALIVE_MS);
let mut next_keepalive = Instant::now() + keepalive;
let mut watch = [0u8; HID_RPT_SIZE];
let mut watched = 0;
let mut reading = false;

loop {
match rx.recv_timeout(Duration::from_millis(KEEPALIVE_MS)) {
match rx.try_recv() {
Ok(out) => return Ok(out),
Err(RecvTimeoutError::Timeout) => {
if let Some(status) = keepalive_status(
is_cbor,
shared.signals.up_pending_for(crate::signals::SCOPE_FIDO),
) {
write_msg(stream, cid, CTAPHID_KEEPALIVE, &[status])?;
Err(mpsc::TryRecvError::Disconnected) => {
return Err(io::Error::other("the device thread dropped the job"));
}
Err(mpsc::TryRecvError::Empty) => {}
}

let now = Instant::now();
if now >= next_keepalive {
if let Some(status) = keepalive_status(
is_cbor,
shared.signals.up_pending_for(crate::signals::SCOPE_FIDO),
) {
write_msg(stream, cid, CTAPHID_KEEPALIVE, &[status])?;
}
next_keepalive = now + keepalive;
}

if !shared.signals.up_pending_for(crate::signals::SCOPE_FIDO) {
let wait = next_keepalive.saturating_duration_since(Instant::now());
match rx.recv_timeout(wait) {
Ok(out) => return Ok(out),
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => {
return Err(io::Error::other("the device thread dropped the job"));
}
}
Err(RecvTimeoutError::Disconnected) => {
return Err(io::Error::other("the device thread dropped the job"));
}

if !reading {
stream.set_read_timeout(Some(poll))?;
reading = true;
}
match stream.read(&mut watch[watched..]) {
Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)),
Ok(n) => {
watched += n;
if watched == HID_RPT_SIZE {
if is_cancel_report(&watch, cid) {
shared.signals.request_cancel(cid);
}
watched = 0;
}
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock
| io::ErrorKind::TimedOut
| io::ErrorKind::Interrupted
) => {}
Err(error) => return Err(error),
}
}
}

fn is_cancel_report(frame: &[u8; HID_RPT_SIZE], cid: u32) -> bool {
frame[4] == CTAPHID_CANCEL
&& u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]) == cid
}

fn write_msg(stream: &mut TcpStream, cid: u32, cmd: u8, data: &[u8]) -> io::Result<()> {
for f in TxFrames::new(cid, cmd, data) {
stream.write_all(&f)?;
}
stream.flush()
}

#[cfg(test)]
#[path = "hid_tests.rs"]
mod tests;
78 changes: 78 additions & 0 deletions tools/emu/src/hid_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 RS-Key contributors

use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};

use rsk_usb::ctaphid::{
CTAPHID_CANCEL, CTAPHID_KEEPALIVE, ChannelLock, CidAllocator, HID_RPT_SIZE,
};

use super::{Shared, run_active_job};
use crate::signals::{SCOPE_FIDO, Signals};

#[test]
fn active_job_streams_upneeded_and_reads_fragmented_scoped_cancel() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let signals = Arc::new(Signals::default());
signals.set_wait_scope(SCOPE_FIDO);
let cid = 0x0102_0304;
signals.begin(cid);
signals.set_up_pending(true);
let (jobs, _requests) = mpsc::channel();
let shared = Arc::new(Shared {
jobs,
signals: signals.clone(),
cids: Mutex::new(CidAllocator::new()),
lock: Mutex::new(ChannelLock::default()),
boot: Instant::now(),
});
let (reply, replies) = mpsc::channel();
let worker_signals = signals.clone();
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(2);
while !worker_signals.cancelled() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
reply.send(Some(vec![0x2d])).unwrap();
});

let (server_result, result) = mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
server_result
.send(run_active_job(&mut stream, &shared, &replies, cid, true))
.unwrap();
});

let mut client = TcpStream::connect(address).unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut keepalive = [0u8; HID_RPT_SIZE];
client.read_exact(&mut keepalive).unwrap();
assert_eq!(keepalive[4], CTAPHID_KEEPALIVE);
assert_eq!(keepalive[7], 0x02, "presence must report UPNEEDED");

let mut cancel = [0u8; HID_RPT_SIZE];
cancel[..4].copy_from_slice(&0x0506_0708u32.to_le_bytes());
cancel[4] = CTAPHID_CANCEL;
client.write_all(&cancel).unwrap();
client.read_exact(&mut keepalive).unwrap();

cancel[..4].copy_from_slice(&cid.to_le_bytes());
client.write_all(&cancel[..13]).unwrap();
client.write_all(&cancel[13..]).unwrap();

assert_eq!(
result
.recv_timeout(Duration::from_secs(2))
.unwrap()
.unwrap(),
Some(vec![0x2d])
);
assert!(signals.cancelled());
}
31 changes: 28 additions & 3 deletions tools/emu/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use std::sync::{Arc, Mutex};
use std::time::Instant;

use device::Config;
use presence::PresenceMode;
use signals::Signals;

const DEFAULT_FIDO_PORT: u16 = 7799;
Expand All @@ -61,6 +62,7 @@ usage: rsk-emu [options]
--ccid-port <n> APDU/card port, 0 disables (default 7800)
--store <path> persist the file system here (default: memory only)
--touch ask for every user presence on the terminal
--auto-touch-ms <n> mark presence pending, then approve it after n milliseconds
--display open the trusted display in a window; presence is an
on-screen hold, exactly as on a screen board
--screenshots <dir> write the docs' display screens as PNGs and exit
Expand All @@ -84,7 +86,7 @@ fn main() {
let mut ccid_port = DEFAULT_CCID_PORT;
let mut cfg = Config {
store: None,
touch: false,
presence: PresenceMode::Instant,
display: false,
usbip: None,
seed: None,
Expand All @@ -95,6 +97,8 @@ fn main() {
yubico: false,
power_cut: None,
};
let mut touch = false;
let mut auto_touch = None;

let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
Expand All @@ -111,7 +115,8 @@ fn main() {
"--fido-port" => fido_port = parse_port(&value("--fido-port")),
"--ccid-port" => ccid_port = parse_port(&value("--ccid-port")),
"--store" => cfg.store = Some(value("--store").into()),
"--touch" => cfg.touch = true,
"--touch" => touch = true,
"--auto-touch-ms" => auto_touch = Some(parse_millis(&value("--auto-touch-ms"))),
"--display" => cfg.display = true,
// Renders and exits: no store, no sockets, nothing to serve.
"--screenshots" => shots::run(&value("--screenshots")),
Expand All @@ -136,6 +141,16 @@ fn main() {
if fido_port == 0 && ccid_port == 0 {
die("both transports are disabled — nothing to serve");
}
if auto_touch.is_some() && (touch || cfg.display) {
die("--auto-touch-ms is mutually exclusive with --touch and --display");
}
cfg.presence = if touch {
PresenceMode::Terminal
} else if let Some(delay) = auto_touch {
PresenceMode::Delayed(delay)
} else {
PresenceMode::Instant
};
if cfg.seed.is_some() {
eprintln!("emu: DETERMINISTIC SEED — every key this run mints is predictable");
}
Expand All @@ -146,7 +161,7 @@ fn main() {
// The terminal is the only input the prompt has, so it is read once, here,
// and handed to the device thread — two readers of stdin would race for the
// same line.
let lines = cfg.touch.then(|| {
let lines = (cfg.presence == PresenceMode::Terminal).then(|| {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for line in std::io::stdin().lock().lines().map_while(Result::ok) {
Expand Down Expand Up @@ -233,6 +248,16 @@ fn parse_port(s: &str) -> u16 {
.unwrap_or_else(|_| die(&format!("not a port: {s:?}")))
}

fn parse_millis(s: &str) -> std::time::Duration {
let millis: u64 = s
.parse()
.unwrap_or_else(|_| die(&format!("not a positive millisecond delay: {s:?}")));
if millis == 0 {
die("auto-touch delay must be positive");
}
std::time::Duration::from_millis(millis)
}

/// Decode hex, optionally demanding an exact byte length.
fn parse_hex(s: &str, want: Option<usize>) -> Vec<u8> {
let s = s.strip_prefix("0x").unwrap_or(s);
Expand Down
Loading