diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 24172db0..66defa8a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -863,7 +863,7 @@ but not with themselves; omit the key (plain `#[serial]`) when in doubt. - Private key files with passphrase support - Password authentication (discouraged in production) - Public key authentication preferred -- FIDO security-key identities (`sk-ssh-ed25519@openssh.com`, `sk-ecdsa-sha2-nistp256@openssh.com`): the default identity list includes `id_ed25519_sk` and `id_ecdsa_sk`. Signing goes through a `SecurityKeyProvider` (`src/ssh/security_key/`, `src/ssh/tokio_client/security_key_signer.rs`): the default is `$SSH_SK_PROVIDER`, an external library implementing OpenSSH's `sk-api.h` is loaded with `dlopen` and called on a blocking thread, and `internal` reports a diagnostic because bssh has no built-in provider. A missing or failing provider declines that identity and authentication continues with the next one. Keys held by an agent sign through the agent and need no provider. Static musl builds cannot `dlopen` and report a diagnostic instead. +- FIDO security-key identities (`sk-ssh-ed25519@openssh.com`, `sk-ecdsa-sha2-nistp256@openssh.com`): the default identity list includes `id_ed25519_sk` and `id_ecdsa_sk`. Signing goes through a `SecurityKeyProvider` (`src/ssh/security_key/`, `src/ssh/tokio_client/security_key_signer.rs`): the default is `internal`, the built-in USB HID provider (`src/ssh/security_key/internal/`: CTAPHID framing over hidapi, CTAP2 `getAssertion` and `clientPIN`, a U2F fallback, and `sk-usbhid.c`'s device selection), and a path names an external library implementing OpenSSH's `sk-api.h`, loaded with `dlopen`; either is called on a blocking thread. A missing or failing provider declines that identity and authentication continues with the next one. Keys held by an agent sign through the agent and need no provider. Static musl builds cannot `dlopen` an external library and report that, while their built-in provider works. The built-in provider supports Linux (hidraw, usually needing a udev rule) and macOS (non-exclusive IOKit open) only and reports itself unavailable elsewhere; it considers at most eight devices, bounds a whole signature at 120 s (libfido2 waits without limit), selects the device by credential or by touch, and over U2F cannot sign Ed25519 or take a PIN. Enrollment, resident keys and sensor-only UV tokens are not implemented. #### macOS `UseKeychain` diff --git a/Cargo.lock b/Cargo.lock index 8800da23..a3f3c165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,6 +275,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-udev" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6" + [[package]] name = "bcrypt" version = "0.19.3" @@ -389,6 +395,7 @@ dependencies = [ name = "bssh" version = "3.0.1" dependencies = [ + "aes", "anyhow", "argon2 0.5.3", "async-compression", @@ -396,14 +403,18 @@ dependencies = [ "base64 0.22.1", "bcrypt", "bytes", + "cbc", "chrono", "clap", "criterion", "crossterm", "dirs", + "ed25519-dalek", "futures", "glob", "hickory-resolver", + "hidapi", + "hkdf", "hmac", "indicatif", "insta", @@ -418,6 +429,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "owo-colors", + "p256", "rand 0.10.2", "ratatui", "regex", @@ -1786,6 +1798,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hidapi" +version = "2.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6" +dependencies = [ + "basic-udev", + "cc", + "cfg-if", + "libc", + "nix 0.30.1", + "pkg-config", + "windows-sys 0.61.2", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -2592,6 +2619,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.31.3" diff --git a/Cargo.toml b/Cargo.toml index 54c2d118..789cdd03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,12 +72,20 @@ socket2 = { version = "0.6", features = ["all"] } shell-words = "1.1.1" base64 = "0.22.1" hmac = "0.13.0" +hkdf = "0.13.0" sha2 = "0.11.0" hickory-resolver = { version = "0.26.1", features = ["dnssec-ring"] } libc = "0.2" # sk-api (FIDO security key) middleware libraries are dlopened in process; # see src/ssh/security_key for why no ssh-sk-helper style subprocess exists. libloading = "0.8" +# The built-in `SecurityKeyProvider internal` (src/ssh/security_key/internal) +# implements CTAPHID, CTAP2 getAssertion/clientPIN and the U2F fallback in +# tree; these three only supply the PIN-protocol crypto and are already in the +# dependency graph through russh/ssh-key, so they add no crate. +p256 = { version = "0.14", features = ["ecdh", "ecdsa"] } +aes = "0.9" +cbc = { version = "0.2", features = ["alloc"] } ipnetwork = "0.21" bcrypt = "0.19" # `argon2` 0.5 uses `crypto-common` 0.1.7, which deliberately pins @@ -100,13 +108,27 @@ tokio-rustls = "0.26" rustls-native-certs = "0.8" surge-ping = "0.9.1" +# USB HID transport for the built-in security-key provider. Linux uses +# hidapi's pure-Rust hidraw backend (sysfs enumeration, no libudev), so the +# static musl release binaries keep building and no runtime library is added +# to the Debian, PPA or Homebrew packaging. macOS compiles hidapi's IOKit +# backend from the vendored C source and links only system frameworks. +[target.'cfg(target_os = "linux")'.dependencies] +hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] } + [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3.7.0" +# `macos-shared-device` opens devices non-exclusively, as libfido2 does, so a +# browser can still use the key while bssh has it open. +hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } [dev-dependencies] +# The mock FIDO authenticator signs Ed25519 assertions; already in the graph +# through ssh-key. +ed25519-dalek = "3.0" tempfile = "3.27.0" mockito = "1.7.2" tokio-test = "0.4" diff --git a/README.md b/README.md index 6dbb1bf1..cb8b6dcf 100644 --- a/README.md +++ b/README.md @@ -602,7 +602,7 @@ See [examples/mpi_exit_code.sh](examples/mpi_exit_code.sh) and [examples/health_ bssh supports multiple authentication methods: ### SSH Key Authentication -- **Default keys**: Automatically tries `~/.ssh/id_ed25519`, `~/.ssh/id_ed25519_sk`, `~/.ssh/id_rsa`, `~/.ssh/id_ecdsa`, `~/.ssh/id_ecdsa_sk`, `~/.ssh/id_dsa` (the `*_sk` FIDO keys need a `SecurityKeyProvider`) +- **Default keys**: Automatically tries `~/.ssh/id_ed25519`, `~/.ssh/id_ed25519_sk`, `~/.ssh/id_rsa`, `~/.ssh/id_ecdsa`, `~/.ssh/id_ecdsa_sk`, `~/.ssh/id_dsa` (the `*_sk` FIDO keys sign with an attached authenticator through the built-in `SecurityKeyProvider internal`, or an sk-api library if configured; on Linux the user needs access to the `/dev/hidraw*` device, usually through the FIDO udev rules of the `libfido2` packages; enrollment, resident keys and biometric-only UV tokens are not supported, and Windows needs an sk-api library) - **Custom key**: Use `-i` flag to specify a key file - **Encrypted keys**: Automatically detects and prompts for passphrase diff --git a/docs/architecture/ssh-client.md b/docs/architecture/ssh-client.md index df9189b5..b205e782 100644 --- a/docs/architecture/ssh-client.md +++ b/docs/architecture/ssh-client.md @@ -16,7 +16,7 @@ - `tokio_client/connection.rs` - Connection management (293 lines) - `tokio_client/authentication.rs` - Authentication methods (378 lines) - `tokio_client/security_key_signer.rs` - russh `Signer` for FIDO `sk-*` identities: resolves `SecurityKeyProvider`, checks the provider before decrypting an `sk-*` key file, calls the provider on a blocking thread (serialized process-wide), prints the touch notice, prompts once for a PIN, and declines a failed signature so authentication continues with the next identity (`authenticate_declinable`) -- `ssh/security_key/` - provider interface (`SecurityKeyProvider` trait, `$VAR` resolution, `internal`/`none` handling), the `sk-api.h` `dlopen` binding (`external.rs`), and the `sk-*` signature blob encoder (`signature.rs`) +- `ssh/security_key/` - provider interface (`SecurityKeyProvider` trait, `$VAR` resolution, `internal`/`none` handling), the `sk-api.h` `dlopen` binding (`external.rs`), the `sk-*` signature blob encoder (`signature.rs`), and the built-in provider (`internal/`: `ctaphid.rs` framing and channels over a `HidDevice` trait, `cbor.rs`, `ctap2.rs`, `u2f.rs`, `pin_protocol.rs` for PIN protocols 1 and 2, `device.rs` for one authenticator's `getAssertion`, `select.rs` for `sk_probe`-style device selection, `hidapi_backend.rs` as the only OS-facing file, and `mock.rs` as the scripted test authenticator). The built-in provider exists on Linux and macOS only; elsewhere `internal` fails with a "not available on this platform" error - `tokio_client/host_key_type.rs` - refuses a server host key or certificate whose type differs from the negotiated host-key algorithm (OpenSSH `kex_verify_host_key`), including `sk-*` types and ECDSA curves - `tokio_client/channel_manager.rs` - Channel operations (230 lines) - `tokio_client/file_transfer.rs` - SFTP file operations (285 lines) diff --git a/docs/architecture/ssh-config-parser.md b/docs/architecture/ssh-config-parser.md index e17beb02..31aa3651 100644 --- a/docs/architecture/ssh-config-parser.md +++ b/docs/architecture/ssh-config-parser.md @@ -207,7 +207,7 @@ bssh supports 40+ SSH configuration directives organized into categories: - `ConnectTimeout` - Connection timeout in seconds - `ServerAliveInterval` - Keepalive interval - `ServerAliveCountMax` - Keepalive retry count -- `SecurityKeyProvider` - Library implementing OpenSSH's `sk-api.h` for `sk-*` identities, `internal`, `none`, or a `$VAR` reference (default `$SSH_SK_PROVIDER`). Stored verbatim in `security_key_provider`; `-G` prints the raw value and expansion happens in `src/ssh/tokio_client/security_key_signer.rs` when an `sk-*` identity is used. +- `SecurityKeyProvider` - Library implementing OpenSSH's `sk-api.h` for `sk-*` identities, `internal` (the built-in USB HID provider and the default), `none`, or a `$VAR` reference. Stored verbatim in `security_key_provider`; `-G` prints the raw value and expansion happens in `src/ssh/tokio_client/security_key_signer.rs` when an `sk-*` identity is used. - `Compression` - Advertise transport compression to the server (yes/no, default: no). `yes` offers eager `zlib` ahead of `none`; `no`/unset offers only `none`. `zlib@openssh.com` is never advertised because russh's delayed-zlib codec desyncs the flate2 stream a few packets after activation; wiring is in `SshConnectionConfig::to_russh_config` (`src/ssh/tokio_client/connection.rs`). **Authentication Options:** diff --git a/docs/man/bssh.1 b/docs/man/bssh.1 index 76033ae2..88f33d4d 100644 --- a/docs/man/bssh.1 +++ b/docs/man/bssh.1 @@ -1098,7 +1098,7 @@ Example: .TP .B SecurityKeyProvider -Specifies the library used to access FIDO authenticator-hosted keys (sk-ssh-ed25519@openssh.com and sk-ecdsa-sha2-nistp256@openssh.com identity files, as enrolled by ssh-keygen -t ed25519-sk or ecdsa-sk). The value is a path to a shared library implementing OpenSSH's sk-api (for example libsk-libfido2.so), or a name starting with $ that selects an environment variable holding that path. The default is $SSH_SK_PROVIDER; when the variable is unset, or the value is none, sk-* identities are skipped. The value internal (OpenSSH's built-in USB HID provider) is not available in this build and is reported before the next identity is tried. The library is loaded with dlopen(3); statically linked musl builds cannot load it and say so. When a key requires user presence and standard error is a terminal, bssh prints a confirmation notice while waiting for the touch; when the provider asks for a PIN and BatchMode is off, bssh prompts for it once. If signing fails (no device, PIN required in BatchMode, provider error), bssh reports it and continues with the next identity. Keys held by an agent keep signing through the agent. +Specifies how FIDO authenticator-hosted keys (sk-ssh-ed25519@openssh.com and sk-ecdsa-sha2-nistp256@openssh.com identity files, as enrolled by ssh-keygen -t ed25519-sk or ecdsa-sk) are accessed. The default, internal, is bssh's built-in USB HID provider, which talks CTAP2 (with CTAP1/U2F fallback) to attached authenticators on Linux and macOS without any middleware library; when several authenticators are attached, the one holding the key is used, or, when a PIN is involved, the one that is touched. Otherwise the value is a path to a shared library implementing OpenSSH's sk-api (for example libsk-libfido2.so), or a name starting with $ that selects an environment variable holding that path; an unset variable, or the value none, skips sk-* identities. A library is loaded with dlopen(3); statically linked musl builds cannot load one and say so, but their built-in provider works. When a key requires user presence and standard error is a terminal, bssh prints a confirmation notice while waiting for the touch; when the provider asks for a PIN and BatchMode is off, bssh prompts for it once. At most eight attached authenticators are considered. U2F-only keys work for ecdsa-sk keys but not ed25519-sk keys, and cannot take a PIN. Enrolling keys, loading resident keys, and authenticators that verify the user only through a built-in sensor are not supported. On Linux the provider uses /dev/hidraw devices, which normally need a udev rule granting your user access (for example the FIDO rules from the libfido2 udev package on Debian and Ubuntu); without one, signing fails with a message that FIDO devices were found but none could be opened. On macOS devices are opened non-exclusively. On other platforms the built-in provider reports that it is not available, and an sk-api library must be used. Unlike OpenSSH, the built-in provider gives up on a signature after 120 seconds (for example when the key is never touched), because provider calls are serialized across all hosts. If signing fails (no device, PIN required in BatchMode, timeout, provider error), bssh reports it and continues with the next identity. Keys held by an agent keep signing through the agent. .br Example: .I SecurityKeyProvider /usr/lib/libsk-libfido2.so diff --git a/docs/openssh-compatibility-status.md b/docs/openssh-compatibility-status.md index e47a2277..5922eb71 100644 --- a/docs/openssh-compatibility-status.md +++ b/docs/openssh-compatibility-status.md @@ -20,7 +20,7 @@ This ledger records contracts still requiring implementation or verification, no | Percent/environment | Directive-specific tokens and environment expansion, trusted `Match exec`, explicitly permitted shell `LocalCommand` | Historical `percent` fails on both platforms. Shell semantics must retain `PermitLocalCommand` gating and untrusted-input boundaries. | | Rekey | Actual byte/time-triggered exchange and truthful KEX/NEWKEYS diagnostics | `rekey` passes on Linux (114.3 s candidate, 114.5 s reference) after the client stopped padding every connection by 0.6 s; the manifest minimum is 180 s. macOS remains environmental (the reference client also exceeds the limit there). | | Agent | Destination-constrained identities (`session-bind@openssh.com`) | Every `agent-restrict` phase that does not need a constrained key passes on Linux; the eight constrained phases fail until russh exposes the binding tuple. Tracked in #327. | -| Security keys | Built-in (`SecurityKeyProvider internal`) USB HID provider, `webauthn-sk-*` signatures, resident-key and enrollment tooling | `hostkey-agent`, `keytype`, `limit-keytype` and `agent` pass on Linux with the regress `sk-dummy` middleware. `internal` is refused with a diagnostic until #331; `webauthn-sk-ecdsa-sha2-nistp256@openssh.com` is not advertised because bssh cannot verify it; `bssh-keygen` cannot enroll or list resident keys. | +| Security keys | `webauthn-sk-*` signatures, resident-key and enrollment tooling | `hostkey-agent`, `keytype`, `limit-keytype` and `agent` pass on Linux with the regress `sk-dummy` middleware. The built-in `SecurityKeyProvider internal` (the default) signs over USB HID on Linux and macOS (#331) and is covered by mock-transport tests; a check against a physical authenticator is pending user validation. `webauthn-sk-ecdsa-sha2-nistp256@openssh.com` is not advertised because bssh cannot verify it; `bssh-keygen` cannot enroll or list resident keys, and devices that need a built-in-sensor UV token are unsupported. | | Multiplexing | OpenSSH mux-v4 local protocol, raw `-O proxy`, complete channel reporting, allocated remote-port replies | `multiplex` passes on Linux (72.1 s) including TCP and Unix-domain `-O forward`/`-O cancel`, when the regress directory path fits `sockaddr_un`. Control sockets still use framed JSON; passenger reuse is not OpenSSH-master interoperability. | | Jump hosts | CLI/config parsing, SSH URI hops, `none`, multihop transport/authentication | Historical `proxyjump` fails on both platforms. Parser tests alone do not establish transport behavior. | | Remote copy | Remote-to-remote copy invocation, transport selection, integrity and exit status | Historical Ubuntu `scp3` fails; macOS environmental. | @@ -39,7 +39,7 @@ Host certificates are accepted only after full validation: a `@cert-authority` l ## Security keys -Authenticator-hosted (`sk-ssh-ed25519@openssh.com`, `sk-ecdsa-sha2-nistp256@openssh.com`) keys follow OpenSSH 10.3p1 built without `ENABLE_SK_INTERNAL`. `SecurityKeyProvider` defaults to `$SSH_SK_PROVIDER`; a `$`-prefixed value names an environment variable, an unset variable or `none` disables `sk-*` identities (logged at debug level, as `ssh` does), and `internal` prints a diagnostic and moves on to the next identity until #331 supplies the built-in provider. An external provider is a shared library implementing `sk-api.h` version `0x000a0000` (`sk_api_version`, `sk_enroll`, `sk_sign`, `sk_load_resident_keys`); bssh loads it in process with `dlopen(RTLD_NOW | RTLD_LOCAL)`, as `sshsk_open` does with `RTLD_NOW`, so a library with an unresolved symbol is refused at load time; each library is opened once per canonical path and kept for the life of the process instead of being reopened and closed for every identity and connection, and a bare file name refers to a file relative to the working directory (OpenSSH's `lib_contains_symbol` opens the path directly) rather than one found through the loader search path. bssh calls `sk_sign` on a blocking thread, printing `Confirm user presence for key ...` when the key requires a touch and stderr is a terminal, and prompting once for a PIN when the provider reports `SSH_SK_ERR_PIN_REQUIRED` outside `BatchMode`. OpenSSH runs the same calls in `ssh-sk-helper` to keep the middleware out of its `pledge`d client; bssh has no such sandbox, so a helper would add a private protocol without changing what runs as the user. Running in process has accepted costs: an `sk_sign` call that has started cannot be cancelled, so a hung provider keeps its blocking thread and the process-wide provider lock until it returns and no other security-key signature in the process can proceed meanwhile (connections queued behind it give up at `ConnectTimeout` without reaching the provider); and the provider can read everything bssh holds in memory, including decrypted keys and passwords for other hosts. A key without an application string is refused before the provider is called, as `sshsk_sign` refuses it, and an Ed25519 response must carry exactly 64 bytes in `sig_r` and nothing in `sig_s`. A provider that cannot be opened (missing file, no `sk_api_version`, wrong major version) is reported before the key is offered and authentication continues; a failure inside `sk_sign` after the server has accepted the key (device absent, PIN required in `BatchMode`, provider error) prints `security key signing failed for : ...` and authentication continues with the next identity on the same connection, as after OpenSSH's `sign_and_send_pubkey` failure: the signer hands russh the request back unsigned, russh sends nothing for it, and bssh stops waiting for the reply. Provider calls are serialized process-wide, because sk-api makes no thread-safety promise and bssh may authenticate to many hosts at once. An `sk-*` identity is checked against the provider from its unencrypted public half before any passphrase prompt, and `~/.ssh/id_ed25519_sk` and `~/.ssh/id_ecdsa_sk` are among the default identities. Static musl release builds cannot `dlopen`; they build, and report that limitation when an external provider is configured. Host keys and host certificates of the `sk-*` types are verified by ssh-key's `sk` verifiers (`SHA256(application) || flags || counter || SHA256(message)`), with no flag policy on the client side, as in OpenSSH. Agent-held `sk-*` keys keep signing through the agent. `-Q key-plain`, `-Q key`, `-Q key-cert`, `-Q key-sig`, `HostKeyAlgorithms` and `PubkeyAcceptedAlgorithms` list the `sk-*` names in the reference order; the only difference from the reference `ssh -Q key-sig` and `ssh -G` is the absent `webauthn-sk-ecdsa-sha2-nistp256@openssh.com` pair. +Authenticator-hosted (`sk-ssh-ed25519@openssh.com`, `sk-ecdsa-sha2-nistp256@openssh.com`) keys follow OpenSSH 10.3p1 built with `ENABLE_SK_INTERNAL`, as distribution builds are. `SecurityKeyProvider` defaults to `internal`, the built-in USB HID provider (`-G` prints `securitykeyprovider internal`, as the reference does); a `$`-prefixed value names an environment variable, an unset variable or `none` disables `sk-*` identities (logged at debug level, as `ssh` does), and a path names an external provider. The built-in provider (`src/ssh/security_key/internal`) is `sk-usbhid.c` without libfido2: an in-tree CTAPHID, CTAP2 and U2F stack over hidapi (its pure-Rust hidraw backend on Linux, IOKit on macOS, no new runtime library). It follows `sk_sign` step by step: devices on the FIDO usage page are enumerated (each path once, at most eight, as `MAX_FIDO_DEVICES` caps them) and opened; without a PIN or `verify-required` the device holding the credential is found with a silent assertion per device (`sk_select_by_cred`, skipped for a lone CTAP2 device that reports its `uv` option), otherwise the device the user touches is chosen (`sk_select_by_touch`, 15 s, with the dummy `makeCredential`/`REGISTER` probe libfido2 uses, polling rounds 200 ms apart, and only success or a PIN error counted as a touch, so a device that refuses the probe drops out instead of winning); `getAssertion` carries `up` from the key flags and, for `verify-required` keys, either `uv` for devices that verify on their own or a `pinUvAuthToken` obtained through `clientPIN` (PIN protocol 2 when advertised, else 1; permission-scoped tokens on CTAP 2.1 devices), and a device without PIN support or built-in verification yields `SSH_SK_ERR_PIN_REQUIRED` so the PIN prompt follows; devices without CBOR support, or whose `getInfo` fails (after a timed-out `getInfo` the channel is allocated again so a late reply cannot answer the first U2F request), fall back to U2F check-only and enforce-presence `AUTHENTICATE` (ECDSA only), and are never sent `CTAPHID_CANCEL`, as libfido2 sends it to CTAP2 devices only. One deliberate deviation from libfido2, which waits without limit: a whole built-in signature is bounded at 120 s (sshd's default `LoginGraceTime`). bssh serializes provider calls process-wide, so a device that streams KEEPALIVE or other channels' frames forever, or a U2F key nobody touches, would otherwise stall authentication to every host; on expiry the pending CTAP2 request is cancelled, the signature fails with `timed out waiting for the security key`, and authentication continues with the next identity. The resulting `flags`, `counter`, and `sig_r`/`sig_s` (minimal big-endian, as `BN_bn2bin` produces) are the same bytes the external provider path hands to `sshsk_sign`. The CTAPHID framing, CBOR, PIN protocols, U2F encoding, device selection, and the end-to-end signature (verified with `ssh-key`) are tested against a mock HID transport; CI has no authenticator, so a manual check with a physical key on Linux and macOS is pending user validation. Enrollment (`ssh-keygen -t ed25519-sk`) and resident-key loading (`ssh-keygen -K`) are not implemented, and a `verify-required` key on a device that must verify through its own sensor to obtain a `pinUvAuthToken` (some biometric authenticators) is not supported: only the PIN path and the plain `uv` option are. A U2F-only device cannot take a PIN, so a `verify-required` key on it cannot sign, and Ed25519 keys cannot sign over U2F at all. On Linux the provider reads `/dev/hidraw*` nodes, which are usually not accessible to an ordinary user until a udev rule grants access (the FIDO udev rules distributed with the libfido2 packages on Debian and Ubuntu serve this purpose); when devices are found but none can be opened, the signature fails with `N FIDO device(s) found but none could be opened: : `. On macOS devices are opened non-exclusively (hidapi `macos-shared-device`, as libfido2 does), so a browser can use the key meanwhile. On other platforms (Windows) the built-in provider is not available: a signature fails with `built-in security key support is not available on this platform; set SecurityKeyProvider to an sk-api library`. An external provider is a shared library implementing `sk-api.h` version `0x000a0000` (`sk_api_version`, `sk_enroll`, `sk_sign`, `sk_load_resident_keys`); bssh loads it in process with `dlopen(RTLD_NOW | RTLD_LOCAL)`, as `sshsk_open` does with `RTLD_NOW`, so a library with an unresolved symbol is refused at load time; each library is opened once per canonical path and kept for the life of the process instead of being reopened and closed for every identity and connection, and a bare file name refers to a file relative to the working directory (OpenSSH's `lib_contains_symbol` opens the path directly) rather than one found through the loader search path. bssh calls `sk_sign` on a blocking thread, printing `Confirm user presence for key ...` when the key requires a touch and stderr is a terminal, and prompting once for a PIN when the provider reports `SSH_SK_ERR_PIN_REQUIRED` outside `BatchMode`. OpenSSH runs the same calls in `ssh-sk-helper` to keep the middleware out of its `pledge`d client; bssh has no such sandbox, so a helper would add a private protocol without changing what runs as the user. Running in process has accepted costs: an `sk_sign` call that has started cannot be cancelled, so a hung provider keeps its blocking thread and the process-wide provider lock until it returns and no other security-key signature in the process can proceed meanwhile (connections queued behind it give up at `ConnectTimeout` without reaching the provider); and the provider can read everything bssh holds in memory, including decrypted keys and passwords for other hosts. A key without an application string is refused before the provider is called, as `sshsk_sign` refuses it, and an Ed25519 response must carry exactly 64 bytes in `sig_r` and nothing in `sig_s`. A provider that cannot be opened (missing file, no `sk_api_version`, wrong major version) is reported before the key is offered and authentication continues; a failure inside `sk_sign` after the server has accepted the key (device absent, PIN required in `BatchMode`, provider error) prints `security key signing failed for : ...` and authentication continues with the next identity on the same connection, as after OpenSSH's `sign_and_send_pubkey` failure: the signer hands russh the request back unsigned, russh sends nothing for it, and bssh stops waiting for the reply. Provider calls are serialized process-wide, because sk-api makes no thread-safety promise and bssh may authenticate to many hosts at once. An `sk-*` identity is checked against the provider from its unencrypted public half before any passphrase prompt, and `~/.ssh/id_ed25519_sk` and `~/.ssh/id_ecdsa_sk` are among the default identities. Static musl release builds cannot `dlopen`; they build, and report that limitation when an external provider is configured. Host keys and host certificates of the `sk-*` types are verified by ssh-key's `sk` verifiers (`SHA256(application) || flags || counter || SHA256(message)`), with no flag policy on the client side, as in OpenSSH. Agent-held `sk-*` keys keep signing through the agent. `-Q key-plain`, `-Q key`, `-Q key-cert`, `-Q key-sig`, `HostKeyAlgorithms` and `PubkeyAcceptedAlgorithms` list the `sk-*` names in the reference order; the only difference from the reference `ssh -Q key-sig` and `ssh -G` is the absent `webauthn-sk-ecdsa-sha2-nistp256@openssh.com` pair. ## ssh_config integration @@ -49,7 +49,7 @@ Named-regress acceptance is separate: historical results include candidate failu ## Previously excluded scope -[#275](https://github.com/lablup/bssh/issues/275) excluded GSSAPI, PKCS#11 and FIDO middleware, X11, tun forwarding, KRL generation, CA signing, sshd configuration tests and PuTTY/Dropbear/Twisted conch/ssh.com interoperability. #328 reversed the FIDO half of that exclusion: `sk-*` keys are supported through an external `SecurityKeyProvider` library, matching OpenSSH 10.3p1 built without its internal provider. PKCS#11 stays excluded and its manifest rows preserve that reason. `cert-userkey`, named by #281, is permanently skipped for CA signing; it must not be marked passed. +[#275](https://github.com/lablup/bssh/issues/275) excluded GSSAPI, PKCS#11 and FIDO middleware, X11, tun forwarding, KRL generation, CA signing, sshd configuration tests and PuTTY/Dropbear/Twisted conch/ssh.com interoperability. #328 and #331 reversed the FIDO half of that exclusion: `sk-*` keys are supported through the built-in `internal` provider or an external `SecurityKeyProvider` library, matching OpenSSH 10.3p1 built with its internal provider. PKCS#11 stays excluded and its manifest rows preserve that reason. `cert-userkey`, named by #281, is permanently skipped for CA signing; it must not be marked passed. These exclusions bounded the original score-gated epic, not full compatibility. Revisit them explicitly for a broader replacement contract. Distinguish server-only tooling and third-party interoperability from the desired `ssh(1)` surface instead of silently declaring them implemented. Unsupported configuration requires explicit diagnostics until runtime support exists. diff --git a/src/ssh/security_key/internal/cbor.rs b/src/ssh/security_key/internal/cbor.rs new file mode 100644 index 00000000..760301cd --- /dev/null +++ b/src/ssh/security_key/internal/cbor.rs @@ -0,0 +1,325 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The subset of CBOR (RFC 8949) that CTAP2 messages use, encoded in the +//! CTAP2 canonical form: definite lengths only, minimal integer encodings, +//! and map keys sorted by major type, then encoded length, then bytes. +//! +//! Only the value kinds CTAP2 exchanges are modelled; tags and floats are +//! rejected on input, which is what libfido2's parser does as well. + +use super::CtapError; + +/// Nesting depth beyond which input is treated as hostile. +const MAX_DEPTH: usize = 16; + +/// A CBOR data item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + /// Major types 0 and 1 (the negative range is limited to `i64`). + Int(i64), + Bytes(Vec), + Text(String), + Array(Vec), + /// Pairs in wire order; [`encode`] sorts them canonically. + Map(Vec<(Value, Value)>), + Bool(bool), + Null, +} + +impl Value { + #[must_use] + pub fn as_int(&self) -> Option { + match self { + Self::Int(value) => Some(*value), + _ => None, + } + } + + #[must_use] + pub fn as_bytes(&self) -> Option<&[u8]> { + match self { + Self::Bytes(bytes) => Some(bytes), + _ => None, + } + } + + #[must_use] + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text(text) => Some(text), + _ => None, + } + } + + #[must_use] + pub fn as_bool(&self) -> Option { + match self { + Self::Bool(value) => Some(*value), + _ => None, + } + } + + #[must_use] + pub fn as_array(&self) -> Option<&[Value]> { + match self { + Self::Array(items) => Some(items), + _ => None, + } + } + + #[must_use] + pub fn as_map(&self) -> Option<&[(Value, Value)]> { + match self { + Self::Map(pairs) => Some(pairs), + _ => None, + } + } + + /// Look a key up in a map value (`None` for non-maps and missing keys). + #[must_use] + pub fn get(&self, key: &Value) -> Option<&Value> { + self.as_map()? + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, value)| value) + } + + /// Look an integer key up, the common CTAP2 case. + #[must_use] + pub fn get_int(&self, key: i64) -> Option<&Value> { + self.get(&Value::Int(key)) + } +} + +impl From<&str> for Value { + fn from(text: &str) -> Self { + Self::Text(text.to_string()) + } +} + +impl From<&[u8]> for Value { + fn from(bytes: &[u8]) -> Self { + Self::Bytes(bytes.to_vec()) + } +} + +impl From> for Value { + fn from(bytes: Vec) -> Self { + Self::Bytes(bytes) + } +} + +impl From for Value { + fn from(value: i64) -> Self { + Self::Int(value) + } +} + +impl From for Value { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +/// Encode `value` canonically. +#[must_use] +pub fn encode(value: &Value) -> Vec { + let mut out = Vec::new(); + encode_into(value, &mut out); + out +} + +fn encode_head(major: u8, argument: u64, out: &mut Vec) { + let major = major << 5; + if argument < 24 { + out.push(major | argument as u8); + } else if argument <= u64::from(u8::MAX) { + out.push(major | 24); + out.push(argument as u8); + } else if argument <= u64::from(u16::MAX) { + out.push(major | 25); + out.extend_from_slice(&(argument as u16).to_be_bytes()); + } else if argument <= u64::from(u32::MAX) { + out.push(major | 26); + out.extend_from_slice(&(argument as u32).to_be_bytes()); + } else { + out.push(major | 27); + out.extend_from_slice(&argument.to_be_bytes()); + } +} + +fn encode_into(value: &Value, out: &mut Vec) { + match value { + Value::Int(int) => { + if *int >= 0 { + encode_head(0, *int as u64, out); + } else { + encode_head(1, (-1 - *int) as u64, out); + } + } + Value::Bytes(bytes) => { + encode_head(2, bytes.len() as u64, out); + out.extend_from_slice(bytes); + } + Value::Text(text) => { + encode_head(3, text.len() as u64, out); + out.extend_from_slice(text.as_bytes()); + } + Value::Array(items) => { + encode_head(4, items.len() as u64, out); + for item in items { + encode_into(item, out); + } + } + Value::Map(pairs) => { + let mut encoded = pairs + .iter() + .map(|(key, value)| (encode(key), encode(value))) + .collect::>(); + // CTAP2 canonical order: major type, then length, then bytes. + // The major type is the top three bits of the first byte, so a + // (major, len, bytes) comparison of the encoded key is exactly + // the rule. + encoded.sort_by(|(a, _), (b, _)| { + (a[0] >> 5, a.len(), a.as_slice()).cmp(&(b[0] >> 5, b.len(), b.as_slice())) + }); + encode_head(5, encoded.len() as u64, out); + for (key, value) in encoded { + out.extend_from_slice(&key); + out.extend_from_slice(&value); + } + } + Value::Bool(flag) => out.push(if *flag { 0xf5 } else { 0xf4 }), + Value::Null => out.push(0xf6), + } +} + +/// Decode one item that must span the whole input. +pub fn decode(input: &[u8]) -> Result { + let mut cursor = Cursor { input, offset: 0 }; + let value = cursor.item(0)?; + if cursor.offset != input.len() { + return Err(CtapError::Cbor("trailing bytes after item")); + } + Ok(value) +} + +struct Cursor<'a> { + input: &'a [u8], + offset: usize, +} + +impl Cursor<'_> { + fn take(&mut self, len: usize) -> Result<&[u8], CtapError> { + let end = self + .offset + .checked_add(len) + .filter(|end| *end <= self.input.len()) + .ok_or(CtapError::Cbor("truncated item"))?; + let bytes = &self.input[self.offset..end]; + self.offset = end; + Ok(bytes) + } + + fn byte(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + /// The argument of a head, or `None` for additional information 31 + /// (indefinite length), which CTAP2 forbids. + fn argument(&mut self, additional: u8) -> Result { + Ok(match additional { + 0..=23 => u64::from(additional), + 24 => u64::from(self.byte()?), + 25 => u64::from(u16::from_be_bytes([self.byte()?, self.byte()?])), + 26 => { + let bytes: [u8; 4] = self.take(4)?.try_into().expect("four bytes"); + u64::from(u32::from_be_bytes(bytes)) + } + 27 => { + let bytes: [u8; 8] = self.take(8)?.try_into().expect("eight bytes"); + u64::from_be_bytes(bytes) + } + _ => return Err(CtapError::Cbor("indefinite or reserved length")), + }) + } + + fn length(&mut self, additional: u8) -> Result { + let len = self.argument(additional)?; + usize::try_from(len) + .ok() + .filter(|len| *len <= self.input.len()) + .ok_or(CtapError::Cbor("length exceeds input")) + } + + fn item(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(CtapError::Cbor("nesting too deep")); + } + let head = self.byte()?; + let (major, additional) = (head >> 5, head & 0x1f); + Ok(match major { + 0 => Value::Int( + i64::try_from(self.argument(additional)?) + .map_err(|_| CtapError::Cbor("integer out of range"))?, + ), + 1 => { + let magnitude = self.argument(additional)?; + let magnitude = i64::try_from(magnitude) + .map_err(|_| CtapError::Cbor("integer out of range"))?; + Value::Int(-1 - magnitude) + } + 2 => { + let len = self.length(additional)?; + Value::Bytes(self.take(len)?.to_vec()) + } + 3 => { + let len = self.length(additional)?; + let text = std::str::from_utf8(self.take(len)?) + .map_err(|_| CtapError::Cbor("text is not UTF-8"))?; + Value::Text(text.to_string()) + } + 4 => { + let len = self.length(additional)?; + let mut items = Vec::with_capacity(len.min(64)); + for _ in 0..len { + items.push(self.item(depth + 1)?); + } + Value::Array(items) + } + 5 => { + let len = self.length(additional)?; + let mut pairs = Vec::with_capacity(len.min(64)); + for _ in 0..len { + let key = self.item(depth + 1)?; + let value = self.item(depth + 1)?; + pairs.push((key, value)); + } + Value::Map(pairs) + } + 7 => match additional { + 20 => Value::Bool(false), + 21 => Value::Bool(true), + 22 => Value::Null, + _ => return Err(CtapError::Cbor("unsupported simple value or float")), + }, + _ => return Err(CtapError::Cbor("tags are not allowed")), + }) + } +} + +#[cfg(test)] +#[path = "cbor_tests.rs"] +mod tests; diff --git a/src/ssh/security_key/internal/cbor_tests.rs b/src/ssh/security_key/internal/cbor_tests.rs new file mode 100644 index 00000000..1812f0e1 --- /dev/null +++ b/src/ssh/security_key/internal/cbor_tests.rs @@ -0,0 +1,161 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +#[test] +fn integers_use_minimal_heads() { + let cases: [(i64, &[u8]); 10] = [ + (0, &[0x00]), + (23, &[0x17]), + (24, &[0x18, 0x18]), + (255, &[0x18, 0xff]), + (256, &[0x19, 0x01, 0x00]), + (65535, &[0x19, 0xff, 0xff]), + (65536, &[0x1a, 0x00, 0x01, 0x00, 0x00]), + (1 << 32, &[0x1b, 0, 0, 0, 1, 0, 0, 0, 0]), + (-1, &[0x20]), + (-25, &[0x38, 0x18]), + ]; + for (value, expected) in cases { + assert_eq!(encode(&Value::Int(value)), expected, "{value}"); + assert_eq!(decode(expected).unwrap(), Value::Int(value), "{value}"); + } + assert_eq!( + encode(&Value::Int(i64::MIN)), + [0x3b, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] + ); + // -2^64 does not fit an i64. + assert!(decode(&[0x3b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]).is_err()); +} + +#[test] +fn simple_values_strings_and_arrays_encode_per_rfc_8949() { + assert_eq!( + encode(&Value::Bytes(b"abc".to_vec())), + [0x43, b'a', b'b', b'c'] + ); + assert_eq!(encode(&Value::from("ab")), [0x62, b'a', b'b']); + assert_eq!( + encode(&Value::Array(vec![Value::Int(1), Value::Int(2)])), + [0x82, 0x01, 0x02] + ); + assert_eq!(encode(&Value::Bool(true)), [0xf5]); + assert_eq!(encode(&Value::Bool(false)), [0xf4]); + assert_eq!(encode(&Value::Null), [0xf6]); + let long = vec![0xab; 300]; + let encoded = encode(&Value::Bytes(long.clone())); + assert_eq!(&encoded[..3], &[0x59, 0x01, 0x2c]); + assert_eq!(&encoded[3..], &long[..]); +} + +#[test] +fn map_keys_are_sorted_in_ctap2_canonical_order() { + // A COSE key written in scrambled order must come out as 1, 3, -1, -2, -3: + // lower major type first (unsigned before negative), then by length, + // then by bytes. + let map = Value::Map(vec![ + (Value::Int(-3), Value::Int(0)), + (Value::Int(1), Value::Int(2)), + (Value::Int(-1), Value::Int(1)), + (Value::Int(3), Value::Int(-25)), + (Value::Int(-2), Value::Int(0)), + ]); + assert_eq!( + encode(&map), + [ + 0xa5, 0x01, 0x02, 0x03, 0x38, 0x18, 0x20, 0x01, 0x21, 0x00, 0x22, 0x00 + ] + ); + // Text keys: the shorter key sorts first regardless of alphabet, and + // integer keys sort before text keys. + let map = Value::Map(vec![ + (Value::from("type"), Value::Int(0)), + (Value::from("id"), Value::Int(1)), + (Value::from("zz"), Value::Int(2)), + (Value::Int(200), Value::Int(3)), + ]); + assert_eq!( + encode(&map), + [ + 0xa4, 0x18, 0xc8, 0x03, 0x62, b'i', b'd', 0x01, 0x62, b'z', b'z', 0x02, 0x64, b't', + b'y', b'p', b'e', 0x00 + ] + ); +} + +#[test] +fn nested_structures_round_trip() { + let value = Value::Map(vec![ + (Value::Int(1), Value::from("ssh:")), + ( + Value::Int(3), + Value::Array(vec![Value::Map(vec![ + (Value::from("id"), Value::Bytes(vec![1, 2, 3])), + (Value::from("type"), Value::from("public-key")), + ])]), + ), + ( + Value::Int(5), + Value::Map(vec![(Value::from("up"), Value::Bool(false))]), + ), + (Value::Int(-7), Value::Null), + ]); + let decoded = decode(&encode(&value)).unwrap(); + assert_eq!(decoded.get_int(1).unwrap().as_text(), Some("ssh:")); + assert_eq!( + decoded.get_int(3).unwrap().as_array().unwrap()[0] + .get(&Value::from("id")) + .unwrap() + .as_bytes(), + Some(&[1u8, 2, 3][..]) + ); + assert_eq!( + decoded + .get_int(5) + .unwrap() + .get(&Value::from("up")) + .unwrap() + .as_bool(), + Some(false) + ); + assert_eq!(decoded.get_int(-7), Some(&Value::Null)); + assert_eq!(decoded.get_int(99), None); +} + +#[test] +fn decoder_rejects_what_ctap2_forbids() { + let rejected: [(&[u8], &str); 8] = [ + (&[0x9f, 0x01, 0xff], "indefinite array"), + (&[0x5f, 0x41, 0x01, 0xff], "indefinite bytes"), + (&[0x01, 0x02], "trailing bytes"), + (&[0x43, 0x01], "truncated bytes"), + (&[0xc0, 0x00], "tag"), + (&[0xf9, 0x3c, 0x00], "float"), + (&[0x5a, 0xff, 0xff, 0xff, 0xff], "length beyond input"), + (&[0x61, 0xff], "invalid UTF-8"), + ]; + for (input, what) in rejected { + assert!(decode(input).is_err(), "{what} was accepted"); + } + let mut deep = vec![0x81; MAX_DEPTH + 2]; + deep.push(0x00); + assert!( + decode(&deep).is_err(), + "nesting beyond {MAX_DEPTH} was accepted" + ); + let mut shallow = vec![0x81; MAX_DEPTH]; + shallow.push(0x00); + assert!(decode(&shallow).is_ok()); +} diff --git a/src/ssh/security_key/internal/ctap2.rs b/src/ssh/security_key/internal/ctap2.rs new file mode 100644 index 00000000..afacba23 --- /dev/null +++ b/src/ssh/security_key/internal/ctap2.rs @@ -0,0 +1,402 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! CTAP2 messages: `authenticatorGetInfo`, `authenticatorGetAssertion`, the +//! `authenticatorClientPIN` subcommands the sign path needs, and the dummy +//! `authenticatorMakeCredential` libfido2 uses to detect a touch. +//! +//! A request is `command byte || CBOR map`; a response is +//! `status byte || CBOR map`. + +use std::collections::BTreeMap; + +use zeroize::Zeroizing; + +use super::CtapError; +use super::cbor::{self, Value}; + +pub const CMD_MAKE_CREDENTIAL: u8 = 0x01; +pub const CMD_GET_ASSERTION: u8 = 0x02; +pub const CMD_GET_INFO: u8 = 0x04; +pub const CMD_CLIENT_PIN: u8 = 0x06; + +/// CTAP2 status codes the sign path interprets. +pub mod status { + pub const OK: u8 = 0x00; + pub const UNSUPPORTED_ALGORITHM: u8 = 0x26; + pub const OPERATION_DENIED: u8 = 0x27; + pub const UNSUPPORTED_OPTION: u8 = 0x2b; + pub const INVALID_OPTION: u8 = 0x2c; + pub const KEEPALIVE_CANCEL: u8 = 0x2d; + pub const NO_CREDENTIALS: u8 = 0x2e; + pub const USER_ACTION_TIMEOUT: u8 = 0x2f; + pub const PIN_INVALID: u8 = 0x31; + pub const PIN_BLOCKED: u8 = 0x32; + pub const PIN_AUTH_INVALID: u8 = 0x33; + pub const PIN_AUTH_BLOCKED: u8 = 0x34; + pub const PIN_NOT_SET: u8 = 0x35; + pub const PIN_REQUIRED: u8 = 0x36; + pub const UV_BLOCKED: u8 = 0x3c; + pub const UV_INVALID: u8 = 0x3f; +} + +/// Human-readable name for a status byte, for diagnostics. +#[must_use] +pub fn status_name(code: u8) -> &'static str { + match code { + status::OK => "success", + 0x01 => "invalid command", + 0x02 => "invalid parameter", + 0x11 => "unexpected CBOR type", + 0x12 => "invalid CBOR", + 0x14 => "missing parameter", + 0x21 => "credential excluded", + status::UNSUPPORTED_ALGORITHM => "unsupported algorithm", + status::OPERATION_DENIED => "operation denied", + status::UNSUPPORTED_OPTION => "unsupported option", + status::INVALID_OPTION => "invalid option", + status::KEEPALIVE_CANCEL => "cancelled", + status::NO_CREDENTIALS => "no credentials", + status::USER_ACTION_TIMEOUT => "user action timeout", + status::PIN_INVALID => "PIN invalid", + status::PIN_BLOCKED => "PIN blocked", + status::PIN_AUTH_INVALID => "PIN authentication invalid", + status::PIN_AUTH_BLOCKED => "PIN authentication blocked", + status::PIN_NOT_SET => "PIN not set", + status::PIN_REQUIRED => "PIN required", + status::UV_BLOCKED => "user verification blocked", + status::UV_INVALID => "user verification invalid", + _ => "unknown status", + } +} + +/// Strip and check the status byte of a CTAP2 response. +pub fn response_body(response: &[u8]) -> Result<&[u8], CtapError> { + let (&code, body) = response + .split_first() + .ok_or(CtapError::Protocol("empty CTAP2 response"))?; + if code != status::OK { + return Err(CtapError::Status(code)); + } + Ok(body) +} + +/// What `authenticatorGetInfo` reports, limited to the fields consulted. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthenticatorInfo { + pub versions: Vec, + pub options: BTreeMap, + pub pin_protocols: Vec, +} + +impl AuthenticatorInfo { + #[must_use] + pub fn option(&self, name: &str) -> Option { + self.options.get(name).copied() + } + + /// CTAP 2.1 `pinUvAuthToken`: the device issues permission-scoped + /// tokens (`getPinUvAuthTokenUsingPinWithPermissions`). + #[must_use] + pub fn supports_permissions(&self) -> bool { + self.option("pinUvAuthToken") == Some(true) + } +} + +pub fn parse_get_info(response: &[u8]) -> Result { + let map = cbor::decode(response_body(response)?)?; + let versions = map + .get_int(1) + .and_then(Value::as_array) + .ok_or(CtapError::Protocol("getInfo without versions"))? + .iter() + .filter_map(|item| item.as_text().map(str::to_string)) + .collect(); + let options = map + .get_int(4) + .and_then(Value::as_map) + .map(|pairs| { + pairs + .iter() + .filter_map(|(key, value)| Some((key.as_text()?.to_string(), value.as_bool()?))) + .collect() + }) + .unwrap_or_default(); + let pin_protocols = map + .get_int(6) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_int().and_then(|v| u64::try_from(v).ok())) + .collect() + }) + .unwrap_or_default(); + Ok(AuthenticatorInfo { + versions, + options, + pin_protocols, + }) +} + +/// `authenticatorGetAssertion` parameters for one allowed credential. +/// `Debug` leaves `pinUvAuthParam` out: it is a MAC under the PIN token. +#[derive(Clone, PartialEq, Eq)] +pub struct GetAssertionRequest<'a> { + pub rp_id: &'a str, + pub client_data_hash: &'a [u8; 32], + pub credential_id: &'a [u8], + /// `options.up`; omitted when `None`. + pub up: Option, + /// `options.uv`; omitted when `None`. + pub uv: Option, + /// `pinUvAuthParam` and `pinUvAuthProtocol`. + pub pin_auth: Option<(&'a [u8], u64)>, +} + +impl std::fmt::Debug for GetAssertionRequest<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GetAssertionRequest") + .field("rp_id", &self.rp_id) + .field("client_data_hash", &self.client_data_hash) + .field("credential_id", &self.credential_id) + .field("up", &self.up) + .field("uv", &self.uv) + .field( + "pin_auth", + &self.pin_auth.map(|(_, protocol)| ("", protocol)), + ) + .finish() + } +} + +#[must_use] +pub fn encode_get_assertion(request: &GetAssertionRequest<'_>) -> Vec { + let mut pairs = vec![ + (Value::Int(1), Value::from(request.rp_id)), + (Value::Int(2), Value::from(&request.client_data_hash[..])), + ( + Value::Int(3), + Value::Array(vec![Value::Map(vec![ + (Value::from("id"), Value::from(request.credential_id)), + (Value::from("type"), Value::from("public-key")), + ])]), + ), + ]; + let mut options = Vec::new(); + if let Some(up) = request.up { + options.push((Value::from("up"), Value::Bool(up))); + } + if let Some(uv) = request.uv { + options.push((Value::from("uv"), Value::Bool(uv))); + } + if !options.is_empty() { + pairs.push((Value::Int(5), Value::Map(options))); + } + if let Some((param, protocol)) = request.pin_auth { + pairs.push((Value::Int(6), Value::from(param))); + pairs.push((Value::Int(7), Value::Int(protocol as i64))); + } + let mut out = vec![CMD_GET_ASSERTION]; + out.extend(cbor::encode(&Value::Map(pairs))); + out +} + +/// The parts of an assertion the SSH signature needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Assertion { + /// `authData`: `rpIdHash(32) || flags(1) || signCount(4) || ...`. + pub auth_data: Vec, + /// Raw signature: DER ECDSA for ES256, 64 bytes for EdDSA. + pub signature: Vec, +} + +impl Assertion { + #[must_use] + pub fn flags(&self) -> u8 { + self.auth_data[32] + } + + #[must_use] + pub fn counter(&self) -> u32 { + u32::from_be_bytes(self.auth_data[33..37].try_into().expect("four bytes")) + } +} + +pub fn parse_get_assertion(response: &[u8]) -> Result { + let map = cbor::decode(response_body(response)?)?; + let auth_data = map + .get_int(2) + .and_then(Value::as_bytes) + .ok_or(CtapError::Protocol("assertion without authData"))?; + if auth_data.len() < 37 { + return Err(CtapError::Protocol("authData shorter than 37 bytes")); + } + let signature = map + .get_int(3) + .and_then(Value::as_bytes) + .ok_or(CtapError::Protocol("assertion without signature"))?; + if signature.is_empty() { + return Err(CtapError::Protocol("empty assertion signature")); + } + Ok(Assertion { + auth_data: auth_data.to_vec(), + signature: signature.to_vec(), + }) +} + +/// An uncompressed P-256 point as a COSE_Key (`ECDH-ES+HKDF-256`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CosePublicKey { + pub x: [u8; 32], + pub y: [u8; 32], +} + +impl CosePublicKey { + #[must_use] + pub fn to_value(&self) -> Value { + Value::Map(vec![ + (Value::Int(1), Value::Int(2)), + (Value::Int(3), Value::Int(-25)), + (Value::Int(-1), Value::Int(1)), + (Value::Int(-2), Value::from(&self.x[..])), + (Value::Int(-3), Value::from(&self.y[..])), + ]) + } + + pub fn from_value(value: &Value) -> Result { + let coordinate = |key: i64| -> Result<[u8; 32], CtapError> { + value + .get_int(key) + .and_then(Value::as_bytes) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or(CtapError::Protocol( + "COSE key coordinate missing or not 32 bytes", + )) + }; + if value.get_int(1).and_then(Value::as_int) != Some(2) + || value.get_int(-1).and_then(Value::as_int) != Some(1) + { + return Err(CtapError::Protocol("COSE key is not an EC2 P-256 key")); + } + Ok(Self { + x: coordinate(-2)?, + y: coordinate(-3)?, + }) + } +} + +const PIN_SUB_GET_KEY_AGREEMENT: i64 = 0x02; +const PIN_SUB_GET_PIN_TOKEN: i64 = 0x05; +const PIN_SUB_GET_PIN_TOKEN_WITH_PERMISSIONS: i64 = 0x09; +/// `pinUvAuthToken` permission bit for `getAssertion`. +pub const PERMISSION_GET_ASSERTION: i64 = 0x02; + +#[must_use] +pub fn encode_client_pin_key_agreement(protocol: u64) -> Vec { + let mut out = vec![CMD_CLIENT_PIN]; + out.extend(cbor::encode(&Value::Map(vec![ + (Value::Int(1), Value::Int(protocol as i64)), + (Value::Int(2), Value::Int(PIN_SUB_GET_KEY_AGREEMENT)), + ]))); + out +} + +pub fn parse_key_agreement(response: &[u8]) -> Result { + let map = cbor::decode(response_body(response)?)?; + let key = map.get_int(1).ok_or(CtapError::Protocol( + "clientPIN response without keyAgreement", + ))?; + CosePublicKey::from_value(key) +} + +/// `getPinToken` (0x05), or `getPinUvAuthTokenUsingPinWithPermissions` +/// (0x09) when `permissions` names the operation and relying party. +#[must_use] +pub fn encode_client_pin_get_token( + protocol: u64, + platform_key: &CosePublicKey, + pin_hash_enc: &[u8], + permissions: Option<(i64, &str)>, +) -> Vec { + let subcommand = if permissions.is_some() { + PIN_SUB_GET_PIN_TOKEN_WITH_PERMISSIONS + } else { + PIN_SUB_GET_PIN_TOKEN + }; + let mut pairs = vec![ + (Value::Int(1), Value::Int(protocol as i64)), + (Value::Int(2), Value::Int(subcommand)), + (Value::Int(3), platform_key.to_value()), + (Value::Int(6), Value::from(pin_hash_enc)), + ]; + if let Some((bits, rp_id)) = permissions { + pairs.push((Value::Int(9), Value::Int(bits))); + pairs.push((Value::Int(10), Value::from(rp_id))); + } + let mut out = vec![CMD_CLIENT_PIN]; + out.extend(cbor::encode(&Value::Map(pairs))); + out +} + +/// The still-encrypted `pinUvAuthToken`. +pub fn parse_pin_token(response: &[u8]) -> Result>, CtapError> { + let map = cbor::decode(response_body(response)?)?; + map.get_int(2) + .and_then(Value::as_bytes) + .map(|bytes| Zeroizing::new(bytes.to_vec())) + .ok_or(CtapError::Protocol( + "clientPIN response without pinUvAuthToken", + )) +} + +/// The dummy `makeCredential` libfido2's `fido_dev_get_touch_begin` sends +/// to make a device wait for a touch without creating anything useful: with +/// an empty `pinUvAuthParam` the device asks for presence and then answers +/// with a PIN error. Devices without PIN support get a plain request. +#[must_use] +pub fn encode_touch_probe(with_pin_auth: Option) -> Vec { + let mut pairs = vec![ + (Value::Int(1), Value::Bytes(vec![0; 32])), + ( + Value::Int(2), + Value::Map(vec![(Value::from("id"), Value::from("localhost"))]), + ), + ( + Value::Int(3), + Value::Map(vec![ + (Value::from("id"), Value::Bytes(vec![0x01])), + (Value::from("name"), Value::from("dummy")), + ]), + ), + ( + Value::Int(4), + Value::Array(vec![Value::Map(vec![ + (Value::from("alg"), Value::Int(-7)), + (Value::from("type"), Value::from("public-key")), + ])]), + ), + ]; + if let Some(protocol) = with_pin_auth { + pairs.push((Value::Int(8), Value::Bytes(Vec::new()))); + pairs.push((Value::Int(9), Value::Int(protocol as i64))); + } + let mut out = vec![CMD_MAKE_CREDENTIAL]; + out.extend(cbor::encode(&Value::Map(pairs))); + out +} + +#[cfg(test)] +#[path = "ctap2_tests.rs"] +mod tests; diff --git a/src/ssh/security_key/internal/ctap2_tests.rs b/src/ssh/security_key/internal/ctap2_tests.rs new file mode 100644 index 00000000..633e5786 --- /dev/null +++ b/src/ssh/security_key/internal/ctap2_tests.rs @@ -0,0 +1,287 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +const CDH: [u8; 32] = [0x11; 32]; + +fn request<'a>( + up: Option, + uv: Option, + pin_auth: Option<(&'a [u8], u64)>, +) -> GetAssertionRequest<'a> { + GetAssertionRequest { + rp_id: "ssh:", + client_data_hash: &CDH, + credential_id: &[0xaa, 0xbb], + up, + uv, + pin_auth, + } +} + +#[test] +fn get_assertion_encodes_the_spec_layout_in_canonical_order() { + let mut expected = vec![ + 0x02, 0xa4, 0x01, 0x64, b's', b's', b'h', b':', 0x02, 0x58, 0x20, + ]; + expected.extend_from_slice(&CDH); + expected.extend_from_slice(&[ + 0x03, 0x81, 0xa2, 0x62, b'i', b'd', 0x42, 0xaa, 0xbb, 0x64, b't', b'y', b'p', b'e', 0x6a, + ]); + expected.extend_from_slice(b"public-key"); + expected.extend_from_slice(&[0x05, 0xa1, 0x62, b'u', b'p', 0xf5]); + assert_eq!( + encode_get_assertion(&request(Some(true), None, None)), + expected + ); + + // Both options, in canonical (byte) order, and the PIN parameters. + let param = [0x5a; 16]; + let encoded = encode_get_assertion(&request(Some(false), Some(true), Some((¶m, 1)))); + assert_eq!(encoded[1], 0xa6); + let options_at = encoded.windows(2).position(|w| w == [0x05, 0xa2]).unwrap(); + assert_eq!( + &encoded[options_at..options_at + 10], + &[0x05, 0xa2, 0x62, b'u', b'p', 0xf4, 0x62, b'u', b'v', 0xf5] + ); + let mut tail = vec![0x06, 0x50]; + tail.extend_from_slice(¶m); + tail.extend_from_slice(&[0x07, 0x01]); + assert!(encoded.ends_with(&tail), "{encoded:02x?}"); + + // No options at all when neither is set. + let encoded = encode_get_assertion(&request(None, None, None)); + assert_eq!(encoded[1], 0xa3); + assert!(!encoded.windows(2).any(|w| w == [0x05, 0xa1])); +} + +fn assertion_response(auth_data: &[u8], signature: &[u8]) -> Vec { + let mut response = vec![status::OK]; + response.extend(cbor::encode(&Value::Map(vec![ + ( + Value::Int(1), + Value::Map(vec![(Value::from("id"), Value::Bytes(vec![1]))]), + ), + (Value::Int(2), Value::Bytes(auth_data.to_vec())), + (Value::Int(3), Value::Bytes(signature.to_vec())), + ]))); + response +} + +#[test] +fn get_assertion_response_yields_flags_counter_and_signature() { + let mut auth_data = vec![0x33; 32]; + auth_data.push(0x05); + auth_data.extend_from_slice(&0x0102_0304u32.to_be_bytes()); + let assertion = parse_get_assertion(&assertion_response(&auth_data, &[0x77; 64])).unwrap(); + assert_eq!(assertion.flags(), 0x05); + assert_eq!(assertion.counter(), 0x0102_0304); + assert_eq!(assertion.signature, vec![0x77; 64]); + + assert_eq!( + parse_get_assertion(&assertion_response(&auth_data[..36], &[1])).unwrap_err(), + CtapError::Protocol("authData shorter than 37 bytes") + ); + assert_eq!( + parse_get_assertion(&assertion_response(&auth_data, &[])).unwrap_err(), + CtapError::Protocol("empty assertion signature") + ); + assert_eq!( + parse_get_assertion(&[status::NO_CREDENTIALS]).unwrap_err(), + CtapError::Status(status::NO_CREDENTIALS) + ); + assert_eq!( + parse_get_assertion(&[]).unwrap_err(), + CtapError::Protocol("empty CTAP2 response") + ); +} + +#[test] +fn get_info_reports_versions_options_and_pin_protocols() { + let mut response = vec![status::OK]; + response.extend(cbor::encode(&Value::Map(vec![ + ( + Value::Int(1), + Value::Array(vec![Value::from("FIDO_2_1"), Value::from("U2F_V2")]), + ), + ( + Value::Int(4), + Value::Map(vec![ + (Value::from("clientPin"), Value::Bool(true)), + (Value::from("uv"), Value::Bool(false)), + (Value::from("pinUvAuthToken"), Value::Bool(true)), + ]), + ), + ( + Value::Int(6), + Value::Array(vec![Value::Int(2), Value::Int(1)]), + ), + ]))); + let info = parse_get_info(&response).unwrap(); + assert_eq!(info.versions, ["FIDO_2_1", "U2F_V2"]); + assert_eq!(info.option("clientPin"), Some(true)); + assert_eq!(info.option("uv"), Some(false)); + assert_eq!(info.option("rk"), None); + assert!(info.supports_permissions()); + assert_eq!(info.pin_protocols, [2, 1]); + + let mut minimal = vec![status::OK]; + minimal.extend(cbor::encode(&Value::Map(vec![( + Value::Int(1), + Value::Array(vec![Value::from("FIDO_2_0")]), + )]))); + let info = parse_get_info(&minimal).unwrap(); + assert!(info.options.is_empty()); + assert!(info.pin_protocols.is_empty()); + assert!(!info.supports_permissions()); + + let mut no_versions = vec![status::OK]; + no_versions.extend(cbor::encode(&Value::Map(vec![]))); + assert_eq!( + parse_get_info(&no_versions).unwrap_err(), + CtapError::Protocol("getInfo without versions") + ); +} + +#[test] +fn cose_keys_round_trip_and_reject_other_curves() { + let key = CosePublicKey { + x: [1; 32], + y: [2; 32], + }; + let value = key.to_value(); + assert_eq!( + &cbor::encode(&value)[..8], + &[0xa5, 0x01, 0x02, 0x03, 0x38, 0x18, 0x20, 0x01] + ); + assert_eq!(CosePublicKey::from_value(&value).unwrap(), key); + + let mut wrong_curve = value.as_map().unwrap().to_vec(); + wrong_curve[2] = (Value::Int(-1), Value::Int(2)); + assert_eq!( + CosePublicKey::from_value(&Value::Map(wrong_curve)).unwrap_err(), + CtapError::Protocol("COSE key is not an EC2 P-256 key") + ); + let mut short = value.as_map().unwrap().to_vec(); + short[3] = (Value::Int(-2), Value::Bytes(vec![1; 31])); + assert!(CosePublicKey::from_value(&Value::Map(short)).is_err()); +} + +#[test] +fn client_pin_requests_select_the_subcommand_and_carry_permissions() { + assert_eq!( + encode_client_pin_key_agreement(2), + [0x06, 0xa2, 0x01, 0x02, 0x02, 0x02] + ); + let platform = CosePublicKey { + x: [3; 32], + y: [4; 32], + }; + let plain = encode_client_pin_get_token(1, &platform, &[0x9c; 16], None); + assert_eq!(&plain[..6], &[0x06, 0xa4, 0x01, 0x01, 0x02, 0x05]); + assert!(!plain.windows(2).any(|w| w == [0x09, 0x02])); + + let scoped = encode_client_pin_get_token( + 2, + &platform, + &[0x9c; 32], + Some((PERMISSION_GET_ASSERTION, "ssh:")), + ); + assert_eq!(&scoped[..6], &[0x06, 0xa6, 0x01, 0x02, 0x02, 0x09]); + let mut tail = vec![0x09, 0x02, 0x0a, 0x64]; + tail.extend_from_slice(b"ssh:"); + assert!(scoped.ends_with(&tail), "{scoped:02x?}"); + + let decoded = cbor::decode(&scoped[1..]).unwrap(); + assert_eq!( + CosePublicKey::from_value(decoded.get_int(3).unwrap()).unwrap(), + platform + ); + assert_eq!( + decoded.get_int(6).unwrap().as_bytes(), + Some(&[0x9c; 32][..]) + ); +} + +#[test] +fn client_pin_responses_are_parsed() { + let key = CosePublicKey { + x: [5; 32], + y: [6; 32], + }; + let mut response = vec![status::OK]; + response.extend(cbor::encode(&Value::Map(vec![( + Value::Int(1), + key.to_value(), + )]))); + assert_eq!(parse_key_agreement(&response).unwrap(), key); + + let mut response = vec![status::OK]; + response.extend(cbor::encode(&Value::Map(vec![( + Value::Int(2), + Value::Bytes(vec![0xab; 32]), + )]))); + assert_eq!(&parse_pin_token(&response).unwrap()[..], &[0xab; 32]); + assert_eq!( + parse_pin_token(&[status::PIN_INVALID]).unwrap_err(), + CtapError::Status(status::PIN_INVALID) + ); + let mut without = vec![status::OK]; + without.extend(cbor::encode(&Value::Map(vec![]))); + assert!(parse_pin_token(&without).is_err()); + assert!(parse_key_agreement(&without).is_err()); +} + +#[test] +fn touch_probe_carries_an_empty_pin_auth_only_for_pin_capable_devices() { + let with = encode_touch_probe(Some(1)); + assert_eq!(with[0], CMD_MAKE_CREDENTIAL); + assert_eq!(with[1], 0xa6); + assert!(with.ends_with(&[0x08, 0x40, 0x09, 0x01]), "{with:02x?}"); + let without = encode_touch_probe(None); + assert_eq!(without[1], 0xa4); + assert!(!without.windows(2).any(|w| w == [0x08, 0x40])); + let decoded = cbor::decode(&without[1..]).unwrap(); + assert_eq!( + decoded + .get_int(2) + .unwrap() + .get(&Value::from("id")) + .unwrap() + .as_text(), + Some("localhost") + ); +} + +#[test] +fn status_names_cover_the_codes_the_sign_path_maps() { + assert_eq!(status_name(status::NO_CREDENTIALS), "no credentials"); + assert_eq!(status_name(status::PIN_INVALID), "PIN invalid"); + assert_eq!(status_name(status::OPERATION_DENIED), "operation denied"); + assert_eq!(status_name(0xfe), "unknown status"); + assert_eq!( + CtapError::Status(status::PIN_REQUIRED).to_string(), + "authenticator returned CTAP2 status 0x36 (PIN required)" + ); +} + +#[test] +fn get_assertion_debug_output_redacts_the_pin_auth_param() { + let param = [0xab; 32]; + let shown = format!("{:?}", request(Some(true), None, Some((¶m, 2)))); + assert!(shown.contains(""), "{shown}"); + assert!(!shown.contains("171"), "{shown}"); + assert!(shown.contains("ssh:"), "{shown}"); +} diff --git a/src/ssh/security_key/internal/ctaphid.rs b/src/ssh/security_key/internal/ctaphid.rs new file mode 100644 index 00000000..5d2155d8 --- /dev/null +++ b/src/ssh/security_key/internal/ctaphid.rs @@ -0,0 +1,440 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! CTAPHID: the USB HID framing under CTAP2 and U2F (CTAP 2.1 section 11.2). +//! +//! A message is carried in 64-byte reports: an initialization packet +//! `CID(4) | CMD(1, bit 7 set) | BCNT(2, big-endian) | data(57)` followed by +//! continuation packets `CID(4) | SEQ(1) | data(59)`. The HID device itself is +//! behind [`HidDevice`], so the framing, channel allocation, KEEPALIVE and +//! ERROR handling are exercised against a mock in tests. + +use std::time::{Duration, Instant}; + +use super::CtapError; + +/// Report size every CTAPHID authenticator uses. +pub const REPORT_LEN: usize = 64; +/// One HID report. +pub type Report = [u8; REPORT_LEN]; + +const INIT_HEADER_LEN: usize = 7; +const CONT_HEADER_LEN: usize = 5; +const INIT_DATA_LEN: usize = REPORT_LEN - INIT_HEADER_LEN; +const CONT_DATA_LEN: usize = REPORT_LEN - CONT_HEADER_LEN; +/// Sequence numbers run 0..=127, so a message fits in 1 + 128 packets. +pub const MAX_MESSAGE_LEN: usize = INIT_DATA_LEN + 128 * CONT_DATA_LEN; + +/// `CTAPHID_MSG`: a U2F APDU. +pub const CMD_MSG: u8 = 0x03; +/// `CTAPHID_INIT`: channel allocation. +pub const CMD_INIT: u8 = 0x06; +/// `CTAPHID_CBOR`: a CTAP2 command. +pub const CMD_CBOR: u8 = 0x10; +/// `CTAPHID_CANCEL`. +pub const CMD_CANCEL: u8 = 0x11; +/// `CTAPHID_KEEPALIVE`: the authenticator is still busy (status byte). +pub const CMD_KEEPALIVE: u8 = 0x3b; +/// `CTAPHID_ERROR`: a transport-level error code. +pub const CMD_ERROR: u8 = 0x3f; + +/// The channel every device listens on before one is allocated. +pub const BROADCAST_CID: [u8; 4] = [0xff; 4]; +/// `CAPFLAG_CBOR`: the device speaks CTAP2. +pub const CAPABILITY_CBOR: u8 = 0x04; + +/// Bound on channel allocation; a device that does not answer `INIT` is +/// not a working authenticator, and waiting forever on it would block +/// every other device behind the provider lock. +const INIT_TIMEOUT: Duration = Duration::from_secs(2); + +/// One open HID report pipe. +pub trait HidDevice: Send { + fn write_report(&mut self, report: &Report) -> Result<(), CtapError>; + + /// `Ok(None)` when `timeout` elapsed without a report; `None` waits + /// without limit. + fn read_report(&mut self, timeout: Option) -> Result, CtapError>; +} + +/// A HID device as the host enumerates it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HidDeviceInfo { + /// Backend-specific path handed back to [`HidBackend::open`]. + pub path: String, + pub usage_page: u16, + pub usage: u16, + pub vendor_id: u16, + pub product_id: u16, + pub product: Option, +} + +/// FIDO alliance HID usage page and the U2F/CTAPHID usage on it. +pub const FIDO_USAGE_PAGE: u16 = 0xf1d0; +pub const FIDO_USAGE_CTAPHID: u16 = 0x01; + +impl HidDeviceInfo { + #[must_use] + pub fn is_fido(&self) -> bool { + self.usage_page == FIDO_USAGE_PAGE && self.usage == FIDO_USAGE_CTAPHID + } +} + +/// Enumerates and opens HID devices; the real one wraps hidapi. +pub trait HidBackend: Send + Sync { + fn enumerate(&self) -> Result, CtapError>; + fn open(&self, path: &str) -> Result, CtapError>; +} + +/// Split `payload` into the reports that carry command `cmd` on `cid`. +pub fn frame_message(cid: [u8; 4], cmd: u8, payload: &[u8]) -> Result, CtapError> { + if payload.len() > MAX_MESSAGE_LEN { + return Err(CtapError::Protocol("message too long for CTAPHID")); + } + let mut reports = Vec::with_capacity(1 + payload.len() / CONT_DATA_LEN); + let mut init = [0u8; REPORT_LEN]; + init[..4].copy_from_slice(&cid); + init[4] = cmd | 0x80; + init[5..7].copy_from_slice(&(payload.len() as u16).to_be_bytes()); + let first = payload.len().min(INIT_DATA_LEN); + init[INIT_HEADER_LEN..INIT_HEADER_LEN + first].copy_from_slice(&payload[..first]); + reports.push(init); + for (seq, chunk) in payload[first..].chunks(CONT_DATA_LEN).enumerate() { + let mut cont = [0u8; REPORT_LEN]; + cont[..4].copy_from_slice(&cid); + cont[4] = seq as u8; + cont[CONT_HEADER_LEN..CONT_HEADER_LEN + chunk.len()].copy_from_slice(chunk); + reports.push(cont); + } + Ok(reports) +} + +/// A parsed report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Frame<'a> { + Init { + cid: [u8; 4], + cmd: u8, + len: usize, + data: &'a [u8], + }, + Continuation { + cid: [u8; 4], + seq: u8, + data: &'a [u8], + }, +} + +impl<'a> Frame<'a> { + #[must_use] + pub fn parse(report: &'a Report) -> Self { + let cid: [u8; 4] = report[..4].try_into().expect("four bytes"); + if report[4] & 0x80 != 0 { + Self::Init { + cid, + cmd: report[4] & 0x7f, + len: usize::from(u16::from_be_bytes([report[5], report[6]])), + data: &report[INIT_HEADER_LEN..], + } + } else { + Self::Continuation { + cid, + seq: report[4], + data: &report[CONT_HEADER_LEN..], + } + } + } + + #[must_use] + pub fn cid(&self) -> [u8; 4] { + match self { + Self::Init { cid, .. } | Self::Continuation { cid, .. } => *cid, + } + } +} + +/// Reassembles one message from its initialization and continuation packets. +#[derive(Debug, Default)] +pub struct Assembler { + current: Option, +} + +#[derive(Debug)] +struct Partial { + cmd: u8, + expected: usize, + next_seq: u8, + buffer: Vec, +} + +impl Assembler { + /// Feed one frame already known to belong to this channel. Returns the + /// completed `(cmd, payload)` when the frame finished a message. + pub fn push(&mut self, frame: &Frame<'_>) -> Result)>, CtapError> { + match frame { + Frame::Init { cmd, len, data, .. } => { + if *len > MAX_MESSAGE_LEN { + return Err(CtapError::Protocol("announced length too long")); + } + let take = (*len).min(data.len()); + let partial = Partial { + cmd: *cmd, + expected: *len, + next_seq: 0, + buffer: data[..take].to_vec(), + }; + self.current = Some(partial); + } + Frame::Continuation { seq, data, .. } => { + let Some(partial) = self.current.as_mut() else { + // A stray continuation (for example the tail of a + // message an earlier, abandoned read left behind). + return Ok(None); + }; + if *seq != partial.next_seq { + self.current = None; + return Err(CtapError::Protocol("continuation out of sequence")); + } + partial.next_seq = partial.next_seq.wrapping_add(1) & 0x7f; + let remaining = partial.expected - partial.buffer.len(); + partial + .buffer + .extend_from_slice(&data[..remaining.min(data.len())]); + } + } + let complete = self + .current + .as_ref() + .is_some_and(|partial| partial.buffer.len() >= partial.expected); + if complete { + let partial = self.current.take().expect("checked above"); + return Ok(Some((partial.cmd, partial.buffer))); + } + Ok(None) + } +} + +/// An allocated CTAPHID channel to one device. +pub struct CtapHidChannel { + device: Box, + cid: [u8; 4], + capabilities: u8, + /// A request was sent and its reply not yet consumed; `CANCEL` is sent + /// on drop so a device left blinking for a touch stops. + pending: bool, +} + +impl std::fmt::Debug for CtapHidChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CtapHidChannel") + .field("cid", &self.cid) + .field("capabilities", &self.capabilities) + .finish_non_exhaustive() + } +} + +impl CtapHidChannel { + /// Allocate a channel with `CTAPHID_INIT`; `nonce` identifies our reply + /// among those of other hosts sharing the broadcast channel. + pub fn open(device: Box, nonce: [u8; 8]) -> Result { + Self::open_within(device, nonce, INIT_TIMEOUT) + } + + /// [`open`](Self::open), waiting at most `timeout` (capped at the usual + /// `INIT` bound) for the reply. + pub fn open_within( + mut device: Box, + nonce: [u8; 8], + timeout: Duration, + ) -> Result { + let (cid, capabilities) = allocate(&mut *device, nonce, timeout.min(INIT_TIMEOUT))?; + Ok(Self { + device, + cid, + capabilities, + pending: false, + }) + } + + /// Allocate a fresh channel on the same device. The device abandons the + /// pending transaction, and a reply to it that is still in flight + /// carries the old channel ID, so [`receive`](Self::receive) drops it. + pub fn reinit(&mut self, nonce: [u8; 8], timeout: Duration) -> Result<(), CtapError> { + let (cid, capabilities) = allocate(&mut *self.device, nonce, timeout.min(INIT_TIMEOUT))?; + self.cid = cid; + self.capabilities = capabilities; + self.pending = false; + Ok(()) + } + + /// Treat the device as U2F-only from now on, as libfido2's + /// `fido_dev_force_u2f` does when `getInfo` fails: it clears the CBOR + /// capability, and with it the `CANCEL` libfido2 only sends to FIDO2 + /// devices. + pub fn force_u2f(&mut self) { + self.capabilities &= !CAPABILITY_CBOR; + } + + /// `CAPFLAG_CBOR`: the device accepts CTAP2 commands. + #[must_use] + pub fn supports_cbor(&self) -> bool { + self.capabilities & CAPABILITY_CBOR != 0 + } + + pub fn send(&mut self, cmd: u8, payload: &[u8]) -> Result<(), CtapError> { + for report in frame_message(self.cid, cmd, payload)? { + self.device.write_report(&report)?; + } + self.pending = true; + Ok(()) + } + + /// Wait for the reply to the last [`send`](Self::send). `Ok(None)` when + /// `timeout` elapses first (the request stays pending); KEEPALIVE frames + /// are skipped without extending the deadline, as libfido2 does, and an + /// ERROR frame becomes [`CtapError::HidError`]. + pub fn receive( + &mut self, + timeout: Option, + ) -> Result)>, CtapError> { + let deadline = timeout.map(|timeout| Instant::now() + timeout); + let mut assembler = Assembler::default(); + loop { + let remaining = match deadline { + Some(deadline) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + Some(remaining) + } + None => None, + }; + let Some(report) = self.device.read_report(remaining)? else { + return Ok(None); + }; + let frame = Frame::parse(&report); + if frame.cid() != self.cid { + continue; + } + if let Frame::Init { cmd, .. } = frame { + if cmd == CMD_KEEPALIVE { + continue; + } + if cmd == CMD_ERROR { + self.pending = false; + let code = report[INIT_HEADER_LEN]; + return Err(CtapError::HidError(code)); + } + } + if let Some(message) = assembler.push(&frame)? { + self.pending = false; + return Ok(Some(message)); + } + } + } + + /// Send `cmd` and wait for its reply; a reply carrying another command + /// is a protocol violation. + pub fn transact( + &mut self, + cmd: u8, + payload: &[u8], + timeout: Option, + ) -> Result, CtapError> { + self.send(cmd, payload)?; + let (reply_cmd, reply) = self.receive(timeout)?.ok_or(CtapError::Timeout)?; + if reply_cmd != cmd { + return Err(CtapError::Protocol("reply for a different command")); + } + Ok(reply) + } + + /// `CTAPHID_CANCEL` for the pending request, if any. U2F-only devices + /// are left alone: `fido_dev_cancel` refuses them, since `CANCEL` is a + /// CTAP2 command a U2F device may not implement. + pub fn cancel(&mut self) -> Result<(), CtapError> { + let pending = std::mem::replace(&mut self.pending, false); + if !pending || !self.supports_cbor() { + return Ok(()); + } + for report in frame_message(self.cid, CMD_CANCEL, &[])? { + self.device.write_report(&report)?; + } + Ok(()) + } +} + +/// `CTAPHID_INIT` on the broadcast channel: the allocated channel ID and +/// the capability flags. +fn allocate( + device: &mut dyn HidDevice, + nonce: [u8; 8], + timeout: Duration, +) -> Result<([u8; 4], u8), CtapError> { + for report in frame_message(BROADCAST_CID, CMD_INIT, &nonce)? { + device.write_report(&report)?; + } + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(CtapError::Timeout); + } + let Some(report) = device.read_report(Some(remaining))? else { + return Err(CtapError::Timeout); + }; + let Frame::Init { + cid, + cmd, + len, + data, + .. + } = Frame::parse(&report) + else { + continue; + }; + if cid != BROADCAST_CID || cmd != CMD_INIT { + continue; + } + if len < 17 || data[..8] != nonce { + continue; + } + let cid: [u8; 4] = data[8..12].try_into().expect("four bytes"); + if cid == BROADCAST_CID || cid == [0; 4] { + return Err(CtapError::Protocol("device allocated an invalid channel")); + } + return Ok((cid, data[16])); + } +} + +/// Time left before `deadline`; [`CtapError::Timeout`] once it has passed. +pub fn remaining(deadline: Instant) -> Result { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(CtapError::Timeout); + } + Ok(left) +} + +impl Drop for CtapHidChannel { + fn drop(&mut self) { + // Best effort: the device may already be gone. + let _ = self.cancel(); + } +} + +#[cfg(test)] +#[path = "ctaphid_tests.rs"] +mod tests; diff --git a/src/ssh/security_key/internal/ctaphid_tests.rs b/src/ssh/security_key/internal/ctaphid_tests.rs new file mode 100644 index 00000000..01e82002 --- /dev/null +++ b/src/ssh/security_key/internal/ctaphid_tests.rs @@ -0,0 +1,300 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use super::*; + +const CID: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + +#[test] +fn frame_message_splits_at_the_57_and_59_byte_boundaries() { + let one = frame_message(CID, CMD_CBOR, &[0xaa; 57]).unwrap(); + assert_eq!(one.len(), 1); + assert_eq!(&one[0][..7], &[0x11, 0x22, 0x33, 0x44, 0x90, 0x00, 0x39]); + assert_eq!(&one[0][7..], &[0xaa; 57]); + + let two = frame_message(CID, CMD_MSG, &[0xbb; 58]).unwrap(); + assert_eq!(two.len(), 2); + assert_eq!(two[0][4], 0x83); + assert_eq!(&two[1][..5], &[0x11, 0x22, 0x33, 0x44, 0x00]); + assert_eq!(two[1][5], 0xbb); + assert_eq!(&two[1][6..], &[0u8; 58]); + + let four = frame_message(CID, CMD_CBOR, &[0xcc; 57 + 59 * 2 + 1]).unwrap(); + assert_eq!(four.len(), 4); + assert_eq!( + four.iter().skip(1).map(|r| r[4]).collect::>(), + [0, 1, 2] + ); + assert_eq!(four[3][5], 0xcc); + assert_eq!(four[3][6], 0); + + let max = frame_message(CID, CMD_CBOR, &[0; MAX_MESSAGE_LEN]).unwrap(); + assert_eq!(max.len(), 129); + assert_eq!(max[128][4], 127); + assert_eq!( + frame_message(CID, CMD_CBOR, &[0; MAX_MESSAGE_LEN + 1]).unwrap_err(), + CtapError::Protocol("message too long for CTAPHID") + ); + let empty = frame_message(CID, CMD_CANCEL, &[]).unwrap(); + assert_eq!(empty.len(), 1); + assert_eq!(&empty[0][4..7], &[0x91, 0, 0]); +} + +#[test] +fn frames_parse_back_into_init_and_continuation_packets() { + let reports = frame_message(CID, CMD_CBOR, &[0xdd; 60]).unwrap(); + match Frame::parse(&reports[0]) { + Frame::Init { + cid, + cmd, + len, + data, + } => { + assert_eq!(cid, CID); + assert_eq!(cmd, CMD_CBOR); + assert_eq!(len, 60); + assert_eq!(data, &[0xdd; 57]); + } + other => panic!("{other:?}"), + } + match Frame::parse(&reports[1]) { + Frame::Continuation { cid, seq, data } => { + assert_eq!(cid, CID); + assert_eq!(seq, 0); + assert_eq!(&data[..3], &[0xdd; 3]); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn assembler_reassembles_in_order_and_rejects_out_of_sequence_packets() { + let payload = (0..200u16).map(|i| i as u8).collect::>(); + let reports = frame_message(CID, CMD_CBOR, &payload).unwrap(); + assert_eq!(reports.len(), 4); + let mut assembler = Assembler::default(); + for report in &reports[..3] { + assert_eq!(assembler.push(&Frame::parse(report)).unwrap(), None); + } + assert_eq!( + assembler.push(&Frame::parse(&reports[3])).unwrap(), + Some((CMD_CBOR, payload.clone())) + ); + + let mut assembler = Assembler::default(); + assembler.push(&Frame::parse(&reports[0])).unwrap(); + assert_eq!( + assembler.push(&Frame::parse(&reports[2])).unwrap_err(), + CtapError::Protocol("continuation out of sequence") + ); + // A continuation with nothing in progress is dropped, not an error. + let mut assembler = Assembler::default(); + assert_eq!(assembler.push(&Frame::parse(&reports[1])).unwrap(), None); + // A single-packet message completes on the init packet. + let single = frame_message(CID, CMD_CBOR, &[1, 2, 3]).unwrap(); + assert_eq!( + Assembler::default() + .push(&Frame::parse(&single[0])) + .unwrap(), + Some((CMD_CBOR, vec![1, 2, 3])) + ); +} + +/// A device that answers from a script, recording what was written. +#[derive(Default)] +struct ScriptedDevice { + written: Arc>>, + to_read: VecDeque, +} + +impl HidDevice for ScriptedDevice { + fn write_report(&mut self, report: &Report) -> Result<(), CtapError> { + self.written.lock().unwrap().push(*report); + Ok(()) + } + + fn read_report(&mut self, _: Option) -> Result, CtapError> { + Ok(self.to_read.pop_front()) + } +} + +fn init_reply(nonce: [u8; 8], cid: [u8; 4], capabilities: u8) -> Report { + let mut payload = Vec::new(); + payload.extend_from_slice(&nonce); + payload.extend_from_slice(&cid); + payload.extend_from_slice(&[2, 1, 0, 0, capabilities]); + frame_message(BROADCAST_CID, CMD_INIT, &payload).unwrap()[0] +} + +const NONCE: [u8; 8] = [9, 8, 7, 6, 5, 4, 3, 2]; + +fn open_scripted(to_read: Vec) -> (CtapHidChannel, Arc>>) { + let written = Arc::new(Mutex::new(Vec::new())); + let device = ScriptedDevice { + written: Arc::clone(&written), + to_read: to_read.into(), + }; + let channel = CtapHidChannel::open(Box::new(device), NONCE).unwrap(); + (channel, written) +} + +#[test] +fn open_allocates_a_channel_and_ignores_replies_to_other_nonces() { + let (channel, written) = open_scripted(vec![ + init_reply([0; 8], [0xde, 0xad, 0xbe, 0xef], CAPABILITY_CBOR), + init_reply(NONCE, CID, CAPABILITY_CBOR), + ]); + assert_eq!(channel.cid, CID); + assert!(channel.supports_cbor()); + let written = written.lock().unwrap(); + assert_eq!(written.len(), 1); + assert_eq!(&written[0][..4], &BROADCAST_CID); + assert_eq!(written[0][4], CMD_INIT | 0x80); + assert_eq!(&written[0][7..15], &NONCE); +} + +#[test] +fn open_fails_on_silence_or_an_invalid_channel() { + let silent = ScriptedDevice::default(); + assert_eq!( + CtapHidChannel::open(Box::new(silent), NONCE).unwrap_err(), + CtapError::Timeout + ); + let device = ScriptedDevice { + to_read: vec![init_reply(NONCE, BROADCAST_CID, 0)].into(), + ..ScriptedDevice::default() + }; + assert_eq!( + CtapHidChannel::open(Box::new(device), NONCE).unwrap_err(), + CtapError::Protocol("device allocated an invalid channel") + ); + let device = ScriptedDevice { + to_read: vec![init_reply(NONCE, CID, 0)].into(), + ..ScriptedDevice::default() + }; + assert!( + !CtapHidChannel::open(Box::new(device), NONCE) + .unwrap() + .supports_cbor() + ); +} + +#[test] +fn receive_skips_keepalives_and_other_channels_then_reassembles() { + let reply = (0..100u8).collect::>(); + let mut script = vec![init_reply(NONCE, CID, CAPABILITY_CBOR)]; + script.extend(frame_message(CID, CMD_KEEPALIVE, &[0x02]).unwrap()); + script.extend(frame_message([1, 1, 1, 1], CMD_CBOR, &[0xee; 10]).unwrap()); + script.extend(frame_message(CID, CMD_CBOR, &reply).unwrap()); + let (mut channel, written) = open_scripted(script); + assert_eq!( + channel + .transact(CMD_CBOR, &[0x04], Some(Duration::from_millis(10))) + .unwrap(), + reply + ); + // The request itself was framed on the allocated channel. + let written = written.lock().unwrap(); + assert_eq!(&written[1][..5], &[0x11, 0x22, 0x33, 0x44, 0x90]); + assert_eq!(&written[1][5..8], &[0x00, 0x01, 0x04]); +} + +#[test] +fn receive_reports_error_frames_timeouts_and_mismatched_commands() { + let mut script = vec![init_reply(NONCE, CID, CAPABILITY_CBOR)]; + script.extend(frame_message(CID, CMD_ERROR, &[0x06]).unwrap()); + let (mut channel, _) = open_scripted(script); + assert_eq!( + channel + .transact(CMD_CBOR, &[0x04], Some(Duration::from_millis(10))) + .unwrap_err(), + CtapError::HidError(0x06) + ); + assert!(!channel.pending, "an error frame ends the request"); + + let (mut channel, written) = open_scripted(vec![init_reply(NONCE, CID, CAPABILITY_CBOR)]); + channel.send(CMD_CBOR, &[0x04]).unwrap(); + assert_eq!( + channel.receive(Some(Duration::from_millis(1))).unwrap(), + None + ); + assert!(channel.pending); + channel.cancel().unwrap(); + assert!(!channel.pending); + assert_eq!(written.lock().unwrap().len(), 3); + assert_eq!(written.lock().unwrap()[2][4], CMD_CANCEL | 0x80); + // Nothing is pending any more, so dropping the channel sends no second CANCEL. + drop(channel); + assert_eq!(written.lock().unwrap().len(), 3); + + let mut script = vec![init_reply(NONCE, CID, CAPABILITY_CBOR)]; + script.extend(frame_message(CID, CMD_MSG, &[0x90, 0x00]).unwrap()); + let (mut channel, _) = open_scripted(script); + assert_eq!( + channel + .transact(CMD_CBOR, &[0x04], Some(Duration::from_millis(10))) + .unwrap_err(), + CtapError::Protocol("reply for a different command") + ); +} + +#[test] +fn dropping_a_channel_with_a_pending_request_cancels_it() { + let (mut channel, written) = open_scripted(vec![init_reply(NONCE, CID, CAPABILITY_CBOR)]); + channel.send(CMD_CBOR, &[0x02]).unwrap(); + drop(channel); + let written = written.lock().unwrap(); + assert_eq!(written.len(), 3); + assert_eq!(written[2][4], CMD_CANCEL | 0x80); +} + +#[test] +fn u2f_only_channels_are_never_sent_a_cancel() { + let (mut channel, written) = open_scripted(vec![init_reply(NONCE, CID, 0)]); + channel.send(CMD_MSG, &[0x00, 0x01]).unwrap(); + channel.cancel().unwrap(); + assert!(!channel.pending); + drop(channel); + assert_eq!( + written.lock().unwrap().len(), + 2, + "INIT and the request only" + ); + + // A CBOR device forced to U2F after a failed getInfo is treated the same. + let (mut channel, written) = open_scripted(vec![init_reply(NONCE, CID, CAPABILITY_CBOR)]); + channel.force_u2f(); + assert!(!channel.supports_cbor()); + channel.send(CMD_MSG, &[0x00, 0x01]).unwrap(); + drop(channel); + assert_eq!(written.lock().unwrap().len(), 2); +} + +#[test] +fn reinit_moves_to_a_new_channel_and_drops_the_old_channels_replies() { + const NEW_CID: [u8; 4] = [0x55, 0x66, 0x77, 0x88]; + let next_nonce = [1; 8]; + let mut script = vec![init_reply(NONCE, CID, CAPABILITY_CBOR)]; + // The late reply to the abandoned request, then the new INIT reply. + script.extend(frame_message(CID, CMD_CBOR, &[0x00, 0xa0]).unwrap()); + script.push(init_reply(next_nonce, NEW_CID, CAPABILITY_CBOR)); + let (mut channel, _) = open_scripted(script); + channel.send(CMD_CBOR, &[0x04]).unwrap(); + channel.reinit(next_nonce, Duration::from_secs(1)).unwrap(); + assert_eq!(channel.cid, NEW_CID); + assert!(!channel.pending, "the device abandoned the old request"); +} diff --git a/src/ssh/security_key/internal/device.rs b/src/ssh/security_key/internal/device.rs new file mode 100644 index 00000000..8f435076 --- /dev/null +++ b/src/ssh/security_key/internal/device.rs @@ -0,0 +1,365 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! One opened authenticator: CTAP2 when the device advertises CBOR support +//! and answers `getInfo`, U2F otherwise (libfido2's `fido_dev_open` makes +//! the same decision). Mirrors the parts of `fido_dev_get_assert` and +//! `fido_dev_get_touch_begin/status` that `sk-usbhid.c` relies on. + +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use super::ctap2::{self, AuthenticatorInfo, GetAssertionRequest, PERMISSION_GET_ASSERTION}; +use super::ctaphid::{CMD_CBOR, CMD_MSG, CtapHidChannel, HidBackend, HidDeviceInfo, remaining}; +use super::pin_protocol::{PinProtocol, PlatformKeyAgreement, authenticate_with}; +use super::u2f; +use super::{CtapError, UserPresence}; + +/// Pause between U2F presence retries (`U2F_PACE_MS` in libfido2). +const U2F_PACE: Duration = Duration::from_millis(100); +/// Bound on `getInfo` while opening; a device that does not answer it is +/// treated as U2F. +const GET_INFO_TIMEOUT: Duration = Duration::from_secs(2); + +/// CTAP2 replies to the dummy `makeCredential` that mean the user touched +/// the device (`fido_dev_get_touch_status`): success, or the PIN errors a +/// device reports only after the touch. Anything else is a device that +/// refused the probe, which `sk_touch_poll` drops from the selection. +#[must_use] +pub fn counts_as_touch(status: u8) -> bool { + use ctap2::status; + matches!( + status, + status::OK | status::PIN_AUTH_INVALID | status::PIN_INVALID | status::PIN_NOT_SET + ) +} + +/// What the provider asks the device to sign. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssertionRequest<'a> { + pub rp_id: &'a str, + /// Raw data; the client data hash is `SHA-256(data)`, as + /// `fido_assert_set_clientdata` computes it. + pub data: &'a [u8], + pub credential_id: &'a [u8], + pub up: Option, + pub uv: Option, +} + +/// The parts of the device's answer the SSH signature needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssertionResult { + pub flags: u8, + pub counter: u32, + /// DER ECDSA for P-256 credentials, 64 raw bytes for Ed25519. + pub signature: Vec, +} + +pub struct Authenticator { + channel: CtapHidChannel, + info: Option, + path: String, +} + +impl std::fmt::Debug for Authenticator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Authenticator") + .field("path", &self.path) + .field("fido2", &self.info.is_some()) + .finish_non_exhaustive() + } +} + +impl Authenticator { + /// `fido_dev_open`: allocate a channel and, on a CBOR-capable device, + /// read `getInfo`; every wait ends by `deadline`. + pub fn open( + backend: &dyn HidBackend, + device: &HidDeviceInfo, + deadline: Instant, + rng: &mut R, + ) -> Result { + let hid = backend.open(&device.path)?; + let mut nonce = [0u8; 8]; + rng.fill_bytes(&mut nonce); + let mut channel = CtapHidChannel::open_within(hid, nonce, remaining(deadline)?)?; + let info = if channel.supports_cbor() { + let reply = remaining(deadline).and_then(|left| { + channel.transact( + CMD_CBOR, + &[ctap2::CMD_GET_INFO], + Some(left.min(GET_INFO_TIMEOUT)), + ) + }); + match reply.and_then(|reply| ctap2::parse_get_info(&reply)) { + Ok(info) => Some(info), + Err(error) => { + tracing::debug!( + "{}: getInfo failed ({error}); falling back to U2F", + device.path + ); + if error == CtapError::Timeout { + // The reply may still arrive and would then answer + // the first U2F request; a new channel leaves it + // behind on the old channel ID. + rng.fill_bytes(&mut nonce); + channel.reinit(nonce, remaining(deadline)?)?; + } + channel.force_u2f(); + None + } + } + } else { + None + }; + Ok(Self { + channel, + info, + path: device.path.clone(), + }) + } + + #[must_use] + pub fn path(&self) -> &str { + &self.path + } + + #[must_use] + pub fn is_fido2(&self) -> bool { + self.info.is_some() + } + + /// A `getInfo` option, or `None` for U2F devices and unknown options + /// (`check_sk_options` reports both as `-1`). + #[must_use] + pub fn option(&self, name: &str) -> Option { + self.info.as_ref()?.option(name) + } + + /// `fido_dev_get_assert`. + pub fn get_assertion( + &mut self, + request: &AssertionRequest<'_>, + pin: Option<&str>, + deadline: Instant, + rng: &mut R, + ) -> Result { + let client_data_hash: [u8; 32] = Sha256::digest(request.data).into(); + match &self.info { + Some(info) => { + let info = info.clone(); + self.get_assertion_fido2(&info, request, &client_data_hash, pin, deadline, rng) + } + None => self.get_assertion_u2f(request, &client_data_hash, pin, deadline), + } + } + + fn get_assertion_fido2( + &mut self, + info: &AuthenticatorInfo, + request: &AssertionRequest<'_>, + client_data_hash: &[u8; 32], + pin: Option<&str>, + deadline: Instant, + rng: &mut R, + ) -> Result { + let pin_auth: Option<(Zeroizing>, PinProtocol)> = match pin { + Some(pin) => { + let (token, protocol) = self.pin_token(info, request.rp_id, pin, deadline, rng)?; + let param = Zeroizing::new(authenticate_with(protocol, &token, client_data_hash)); + Some((param, protocol)) + } + None => None, + }; + let encoded = ctap2::encode_get_assertion(&GetAssertionRequest { + rp_id: request.rp_id, + client_data_hash, + credential_id: request.credential_id, + up: request.up, + uv: request.uv, + pin_auth: pin_auth + .as_ref() + .map(|(param, protocol)| (¶m[..], protocol.number())), + }); + let reply = self + .channel + .transact(CMD_CBOR, &encoded, Some(remaining(deadline)?))?; + let assertion = ctap2::parse_get_assertion(&reply)?; + Ok(AssertionResult { + flags: assertion.flags(), + counter: assertion.counter(), + signature: assertion.signature, + }) + } + + /// `fido_dev_get_uv_token`: agree on a shared secret, prove the PIN, + /// and decrypt the token the device hands back. + fn pin_token( + &mut self, + info: &AuthenticatorInfo, + rp_id: &str, + pin: &str, + deadline: Instant, + rng: &mut R, + ) -> Result<(Zeroizing>, PinProtocol), CtapError> { + let protocol = PinProtocol::select(&info.pin_protocols); + let platform = PlatformKeyAgreement::generate(rng); + let reply = self.channel.transact( + CMD_CBOR, + &ctap2::encode_client_pin_key_agreement(protocol.number()), + Some(remaining(deadline)?), + )?; + let peer = ctap2::parse_key_agreement(&reply)?; + let secret = platform.shared_secret(protocol, &peer)?; + let pin_hash_enc = secret.encrypt_pin_hash(pin, rng)?; + let permissions = info + .supports_permissions() + .then_some((PERMISSION_GET_ASSERTION, rp_id)); + let reply = self.channel.transact( + CMD_CBOR, + &ctap2::encode_client_pin_get_token( + protocol.number(), + platform.public_key(), + &pin_hash_enc, + permissions, + ), + Some(remaining(deadline)?), + )?; + let token = secret.decrypt(&ctap2::parse_pin_token(&reply)?)?; + Ok((token, protocol)) + } + + /// `u2f_authenticate`: look the key handle up with a check-only request, + /// then sign with user presence enforced, retrying while the device + /// reports that the user has not touched it yet. + fn get_assertion_u2f( + &mut self, + request: &AssertionRequest<'_>, + client_data_hash: &[u8; 32], + pin: Option<&str>, + deadline: Instant, + ) -> Result { + if pin.is_some() || request.uv == Some(true) { + return Err(CtapError::Unsupported( + "U2F authenticators support neither PINs nor user verification", + )); + } + let application: [u8; 32] = Sha256::digest(request.rp_id.as_bytes()).into(); + let lookup = u2f::encode_authenticate( + u2f::P1_CHECK_ONLY, + &[0u8; 32], + &application, + request.credential_id, + )?; + let reply = self + .channel + .transact(CMD_MSG, &lookup, Some(remaining(deadline)?))?; + match u2f::split_status(&reply)?.1 { + u2f::SW_CONDITIONS_NOT_SATISFIED => {} + u2f::SW_WRONG_DATA => return Err(CtapError::Status(ctap2::status::NO_CREDENTIALS)), + other => return Err(CtapError::Apdu(other)), + } + if request.up == Some(false) { + // The credential exists; a signature needs a touch U2F cannot skip. + return Err(CtapError::UserPresenceRequired); + } + let sign = u2f::encode_authenticate( + u2f::P1_ENFORCE_USER_PRESENCE, + client_data_hash, + &application, + request.credential_id, + )?; + loop { + let reply = self + .channel + .transact(CMD_MSG, &sign, Some(remaining(deadline)?))?; + let (data, sw) = u2f::split_status(&reply)?; + match sw { + u2f::SW_NO_ERROR => { + let auth = u2f::parse_authenticate(data)?; + return Ok(AssertionResult { + flags: auth.presence, + counter: auth.counter, + signature: auth.signature_der, + }); + } + u2f::SW_CONDITIONS_NOT_SATISFIED => { + std::thread::sleep(U2F_PACE.min(remaining(deadline)?)); + } + other => return Err(CtapError::Apdu(other)), + } + } + } + + /// `fido_dev_get_touch_begin`: make the device wait for a touch. + pub fn touch_begin(&mut self) -> Result<(), CtapError> { + match &self.info { + Some(info) => { + let pin_auth = info + .option("clientPin") + .is_some() + .then(|| PinProtocol::select(&info.pin_protocols).number()); + self.channel + .send(CMD_CBOR, &ctap2::encode_touch_probe(pin_auth)) + } + None => self.channel.send(CMD_MSG, &u2f::encode_touch_probe()), + } + } + + /// `fido_dev_get_touch_status`: did the device answer the probe within + /// `poll`? A CTAP2 answer means a touch only when its status is one + /// [`counts_as_touch`] accepts; any other status is an error, which + /// drops the device from the selection. A U2F device answering + /// "conditions not satisfied" is asked again. + pub fn touch_status(&mut self, poll: Duration) -> Result { + let Some((cmd, reply)) = self.channel.receive(Some(poll))? else { + return Ok(UserPresence::Waiting); + }; + let expected = if self.info.is_some() { + CMD_CBOR + } else { + CMD_MSG + }; + if cmd != expected { + return Err(CtapError::Protocol("reply for a different command")); + } + if self.info.is_some() { + let status = *reply + .first() + .ok_or(CtapError::Protocol("empty CTAP2 reply"))?; + return if counts_as_touch(status) { + Ok(UserPresence::Touched) + } else { + Err(CtapError::Status(status)) + }; + } + match u2f::split_status(&reply)?.1 { + u2f::SW_NO_ERROR => Ok(UserPresence::Touched), + u2f::SW_CONDITIONS_NOT_SATISFIED => { + self.channel.send(CMD_MSG, &u2f::encode_touch_probe())?; + Ok(UserPresence::Waiting) + } + other => Err(CtapError::Apdu(other)), + } + } + + /// Stop waiting on this device. + pub fn cancel(&mut self) { + if let Err(error) = self.channel.cancel() { + tracing::debug!("{}: cancel failed: {error}", self.path); + } + } +} diff --git a/src/ssh/security_key/internal/hidapi_backend.rs b/src/ssh/security_key/internal/hidapi_backend.rs new file mode 100644 index 00000000..56703f98 --- /dev/null +++ b/src/ssh/security_key/internal/hidapi_backend.rs @@ -0,0 +1,152 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! [`HidBackend`] over the `hidapi` crate: hidraw through its pure-Rust +//! backend on Linux (sysfs enumeration, no libudev) and IOKit on macOS. +//! +//! hidapi's write takes a leading report ID byte (0 for CTAPHID devices, +//! which use unnumbered reports) and its reads return the report without +//! one, on both platforms. + +use std::ffi::CString; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use super::CtapError; +use super::ctaphid::{HidBackend, HidDevice, HidDeviceInfo, REPORT_LEN, Report}; + +/// One hidapi context, created by the first enumeration and refreshed by +/// later ones, which device opens reuse instead of building (and so +/// re-enumerating) a context of their own. +#[derive(Default)] +pub struct HidapiBackend { + api: Mutex>, + /// Contexts created, so tests can check the reuse. + #[cfg(test)] + contexts: AtomicUsize, +} + +impl std::fmt::Debug for HidapiBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HidapiBackend").finish_non_exhaustive() + } +} + +fn transport(error: hidapi::HidError) -> CtapError { + CtapError::Transport(error.to_string()) +} + +impl HidapiBackend { + /// Run `f` with the context, creating it first if there is none. + fn with_api( + &self, + refresh: bool, + f: impl FnOnce(&hidapi::HidApi) -> Result, + ) -> Result { + let mut guard = self.api.lock().unwrap_or_else(PoisonError::into_inner); + match guard.as_mut() { + Some(api) if refresh => api.refresh_devices().map_err(transport)?, + Some(_) => {} + None => { + *guard = Some(hidapi::HidApi::new().map_err(transport)?); + #[cfg(test)] + self.contexts.fetch_add(1, Ordering::Relaxed); + } + } + f(guard.as_ref().expect("context created above")) + } +} + +impl HidBackend for HidapiBackend { + fn enumerate(&self) -> Result, CtapError> { + self.with_api(true, |api| { + Ok(api + .device_list() + .map(|device| HidDeviceInfo { + path: device.path().to_string_lossy().into_owned(), + usage_page: device.usage_page(), + usage: device.usage(), + vendor_id: device.vendor_id(), + product_id: device.product_id(), + product: device.product_string().map(str::to_string), + }) + .collect()) + }) + } + + fn open(&self, path: &str) -> Result, CtapError> { + let path = CString::new(path) + .map_err(|_| CtapError::Transport("device path contains NUL".to_string()))?; + let device = self.with_api(false, |api| api.open_path(&path).map_err(transport))?; + Ok(Box::new(HidapiDevice(device))) + } +} + +struct HidapiDevice(hidapi::HidDevice); + +impl HidDevice for HidapiDevice { + fn write_report(&mut self, report: &Report) -> Result<(), CtapError> { + let mut buffer = [0u8; REPORT_LEN + 1]; + buffer[1..].copy_from_slice(report); + let written = self.0.write(&buffer).map_err(transport)?; + if written != buffer.len() { + return Err(CtapError::Transport(format!( + "short HID write: {written} of {} bytes", + buffer.len() + ))); + } + Ok(()) + } + + fn read_report(&mut self, timeout: Option) -> Result, CtapError> { + let millis = timeout.map_or(-1, |timeout| { + i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX) + }); + let mut report = [0u8; REPORT_LEN]; + let read = self + .0 + .read_timeout(&mut report, millis) + .map_err(transport)?; + if read == 0 { + return Ok(None); + } + if read != REPORT_LEN { + return Err(CtapError::Transport(format!( + "short HID report: {read} of {REPORT_LEN} bytes" + ))); + } + Ok(Some(report)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opening_a_device_reuses_the_enumeration_context() { + let backend = HidapiBackend::default(); + if backend.enumerate().is_err() { + // No HID subsystem here (a container without sysfs); nothing + // to check. + return; + } + assert!(backend.open("/nonexistent/bssh-hidraw-test").is_err()); + assert!(backend.open("/nonexistent/bssh-hidraw-test").is_err()); + backend.enumerate().unwrap(); + assert_eq!(backend.contexts.load(Ordering::Relaxed), 1); + } +} diff --git a/src/ssh/security_key/internal/mock.rs b/src/ssh/security_key/internal/mock.rs new file mode 100644 index 00000000..b629ae5e --- /dev/null +++ b/src/ssh/security_key/internal/mock.rs @@ -0,0 +1,407 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! A scripted authenticator behind the [`HidDevice`] trait: it speaks +//! CTAPHID on the wire (channel allocation, reassembly, KEEPALIVE), answers +//! CTAP2 `getInfo`/`getAssertion`/`clientPIN` or U2F APDUs, and signs +//! assertions with a real Ed25519 or P-256 key so the resulting SSH +//! signature can be verified with `ssh-key`. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use p256::ecdsa::signature::Signer as _; + +use super::CtapError; +use super::cbor::{self, Value}; +use super::ctap2::{self, status}; +use super::ctaphid::{ + Assembler, BROADCAST_CID, CAPABILITY_CBOR, CMD_CANCEL, CMD_CBOR, CMD_ERROR, CMD_INIT, + CMD_KEEPALIVE, CMD_MSG, FIDO_USAGE_CTAPHID, FIDO_USAGE_PAGE, Frame, HidBackend, HidDevice, + HidDeviceInfo, Report, frame_message, +}; +use super::pin_protocol::PlatformKeyAgreement; + +#[path = "mock_handlers.rs"] +mod handlers; + +pub enum CredentialKey { + Ed25519(ed25519_dalek::SigningKey), + P256(p256::ecdsa::SigningKey), +} + +impl CredentialKey { + pub fn ed25519(seed: u8) -> Self { + Self::Ed25519(ed25519_dalek::SigningKey::from_bytes(&[seed; 32])) + } + + pub fn p256(seed: u8) -> Self { + Self::P256(p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("valid scalar")) + } + + fn sign(&self, message: &[u8]) -> Vec { + match self { + Self::Ed25519(key) => key.sign(message).to_bytes().to_vec(), + Self::P256(key) => { + let signature: p256::ecdsa::Signature = key.sign(message); + signature.to_der().to_bytes().to_vec() + } + } + } +} + +pub struct Credential { + pub rp_id: String, + pub id: Vec, + pub key: CredentialKey, +} + +/// What the device records about each request, for assertions in tests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Request { + GetInfo, + GetAssertion { + rp_id: String, + credential_ids: Vec>, + up: Option, + uv: Option, + pin_auth: Option<(Vec, i64)>, + }, + ClientPin { + subcommand: i64, + protocol: i64, + permissions: Option<(i64, String)>, + }, + TouchProbe, + U2fCheck, + U2fSign, + U2fRegister { + p1: u8, + }, + Cancel, +} + +/// What a stalled device sends while it holds a `getAssertion` reply back. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Flood { + /// KEEPALIVE frames on our channel. + Keepalive, + /// Frames addressed to another host's channel. + ForeignChannel, +} + +struct Stalled { + since: Instant, + reports: Vec, +} + +pub struct Fido2Config { + pub options: BTreeMap, + pub pin_protocols: Vec, + pub pin: Option, +} + +impl Fido2Config { + /// A CTAP 2.1 device with a PIN set, protocol 2, and permission tokens. + pub fn with_pin(pin: &str) -> Self { + Self { + options: BTreeMap::from([ + ("clientPin".to_string(), true), + ("pinUvAuthToken".to_string(), true), + ("up".to_string(), true), + ]), + pin_protocols: vec![1, 2], + pin: Some(pin.to_string()), + } + } + + /// A CTAP 2.0 device without a PIN and without a `uv` option. + pub fn plain() -> Self { + Self { + options: BTreeMap::from([("up".to_string(), true)]), + pin_protocols: vec![1], + pin: None, + } + } +} + +pub enum Protocol { + Fido2(Fido2Config), + U2f, +} + +pub struct MockAuthenticator { + pub protocol: Protocol, + pub credential: Option, + pub counter: u32, + /// KEEPALIVE frames sent before every CBOR reply. + pub keepalives_before_reply: usize, + /// Whether a touch probe (dummy makeCredential or REGISTER) is answered, + /// standing in for the user touching this device. + pub touch_on_probe: bool, + /// The CTAP2 status a touched device answers the dummy makeCredential + /// with. + pub touch_status: u8, + /// Hold the first `getInfo` reply back until the next report arrives, + /// as a device does that answers after the host stopped waiting. + pub late_get_info: bool, + /// Hold every `getAssertion` reply back for this long, sending the + /// given frames meanwhile. + pub stall: Option<(Flood, Duration)>, + /// U2F: ENFORCE requests answered with "conditions not satisfied" + /// before the device signs. + pub u2f_presence_retries: usize, + /// Answer every getAssertion with this status instead of signing. + pub forced_status: Option, + pub requests: Vec, + pub cancels: usize, + cid: [u8; 4], + assembler: Assembler, + outbound: VecDeque, + late: Vec, + stalled: Option, + agreement: Option, + pin_token: [u8; 32], +} + +impl MockAuthenticator { + pub fn new(protocol: Protocol, credential: Option) -> Self { + Self { + protocol, + credential, + counter: 41, + keepalives_before_reply: 0, + touch_on_probe: true, + touch_status: status::PIN_NOT_SET, + late_get_info: false, + stall: None, + u2f_presence_retries: 0, + forced_status: None, + requests: Vec::new(), + cancels: 0, + cid: [0x0b, 0x55, 0x47, 0x01], + assembler: Assembler::default(), + outbound: VecDeque::new(), + late: Vec::new(), + stalled: None, + agreement: None, + pin_token: [0x7a; 32], + } + } + + pub fn assertion_requests(&self) -> Vec<&Request> { + self.requests + .iter() + .filter(|request| matches!(request, Request::GetAssertion { .. })) + .collect() + } + + fn is_fido2(&self) -> bool { + matches!(self.protocol, Protocol::Fido2(_)) + } + + fn handle_report(&mut self, report: &Report) { + let late = std::mem::take(&mut self.late); + self.outbound.extend(late); + let frame = Frame::parse(report); + if let Frame::Init { + cid: BROADCAST_CID, + cmd: CMD_INIT, + data, + .. + } = frame + { + // Every INIT allocates a new channel, as real devices do. + self.cid[3] = self.cid[3].wrapping_add(1); + let mut reply = Vec::with_capacity(17); + reply.extend_from_slice(&data[..8]); + reply.extend_from_slice(&self.cid); + reply.extend_from_slice(&[2, 1, 0, 0]); + reply.push(if self.is_fido2() { CAPABILITY_CBOR } else { 0 }); + self.queue(CMD_INIT, &reply, BROADCAST_CID, 0); + return; + } + if frame.cid() != self.cid { + return; + } + let Some((cmd, payload)) = self.assembler.push(&frame).expect("well-formed frames") else { + return; + }; + match cmd { + CMD_CANCEL => { + self.cancels += 1; + self.requests.push(Request::Cancel); + } + CMD_CBOR => { + let command = payload.first().copied(); + if let Some(reply) = self.handle_cbor(&payload) { + if command == Some(ctap2::CMD_GET_INFO) && self.late_get_info { + self.late_get_info = false; + self.late = frame_message(self.cid, CMD_CBOR, &reply).expect("fits"); + } else if command == Some(ctap2::CMD_GET_ASSERTION) && self.stall.is_some() { + self.stalled = Some(Stalled { + since: Instant::now(), + reports: frame_message(self.cid, CMD_CBOR, &reply).expect("fits"), + }); + } else { + let keepalives = self.keepalives_before_reply; + self.queue(CMD_CBOR, &reply, self.cid, keepalives); + } + } + } + CMD_MSG => { + if let Some(reply) = self.handle_apdu(&payload) { + self.queue(CMD_MSG, &reply, self.cid, 0); + } + } + _ => self.queue(CMD_ERROR, &[0x01], self.cid, 0), + } + } + + /// The next report the device sends, if any. + fn next_report(&mut self) -> Option { + if let Some(report) = self.outbound.pop_front() { + return Some(report); + } + let (flood, duration) = self.stall?; + let stalled = self.stalled.as_ref()?; + if stalled.since.elapsed() < duration { + let cid = match flood { + Flood::Keepalive => self.cid, + Flood::ForeignChannel => [0x99; 4], + }; + let cmd = match flood { + Flood::Keepalive => CMD_KEEPALIVE, + Flood::ForeignChannel => CMD_CBOR, + }; + return Some(frame_message(cid, cmd, &[0x02]).expect("fits")[0]); + } + let stalled = self.stalled.take()?; + self.outbound.extend(stalled.reports); + self.outbound.pop_front() + } + + fn queue(&mut self, cmd: u8, payload: &[u8], cid: [u8; 4], keepalives: usize) { + for _ in 0..keepalives { + self.outbound + .extend(frame_message(cid, CMD_KEEPALIVE, &[0x02]).expect("fits")); + } + self.outbound + .extend(frame_message(cid, cmd, payload).expect("fits")); + } +} + +pub(super) fn ok(value: &Value) -> Vec { + let mut reply = vec![status::OK]; + reply.extend(cbor::encode(value)); + reply +} + +/// The [`HidDevice`] handle tests hand to the provider. +pub struct MockDevice { + inner: Arc>, +} + +impl HidDevice for MockDevice { + fn write_report(&mut self, report: &Report) -> Result<(), CtapError> { + self.inner.lock().unwrap().handle_report(report); + Ok(()) + } + + fn read_report(&mut self, _timeout: Option) -> Result, CtapError> { + Ok(self.inner.lock().unwrap().next_report()) + } +} + +/// A set of attached HID devices. +#[derive(Default)] +pub struct MockBackend { + devices: Vec<(HidDeviceInfo, Option>>)>, + pub unopenable: Vec, +} + +impl MockBackend { + pub fn add( + &mut self, + path: &str, + authenticator: MockAuthenticator, + ) -> Arc> { + let shared = Arc::new(Mutex::new(authenticator)); + self.devices.push(( + HidDeviceInfo { + path: path.to_string(), + usage_page: FIDO_USAGE_PAGE, + usage: FIDO_USAGE_CTAPHID, + vendor_id: 0x1050, + product_id: 0x0407, + product: Some("Mock Key".to_string()), + }, + Some(Arc::clone(&shared)), + )); + shared + } + + /// Another enumeration entry for a device already added, as hidapi lists + /// one per top-level collection of the report descriptor. + pub fn add_alias(&mut self, path: &str) { + let entry = self + .devices + .iter() + .find(|(info, _)| info.path == path) + .map(|(info, device)| (info.clone(), device.clone())) + .expect("device added before its alias"); + self.devices.push(entry); + } + + /// A HID device on another usage page (a keyboard), which must be ignored. + pub fn add_non_fido(&mut self, path: &str) { + self.devices.push(( + HidDeviceInfo { + path: path.to_string(), + usage_page: 0x01, + usage: 0x06, + vendor_id: 0x046d, + product_id: 0xc31c, + product: Some("Keyboard".to_string()), + }, + None, + )); + } +} + +impl HidBackend for MockBackend { + fn enumerate(&self) -> Result, CtapError> { + Ok(self.devices.iter().map(|(info, _)| info.clone()).collect()) + } + + fn open(&self, path: &str) -> Result, CtapError> { + if self.unopenable.iter().any(|blocked| blocked == path) { + return Err(CtapError::Transport(format!( + "failed to open device with path {path}: Permission denied" + ))); + } + let (_, authenticator) = self + .devices + .iter() + .find(|(info, _)| info.path == path) + .ok_or_else(|| CtapError::Transport("no such device".to_string()))?; + let inner = authenticator + .as_ref() + .ok_or_else(|| CtapError::Transport("not a FIDO device".to_string()))?; + Ok(Box::new(MockDevice { + inner: Arc::clone(inner), + })) + } +} diff --git a/src/ssh/security_key/internal/mock_handlers.rs b/src/ssh/security_key/internal/mock_handlers.rs new file mode 100644 index 00000000..b5fffca5 --- /dev/null +++ b/src/ssh/security_key/internal/mock_handlers.rs @@ -0,0 +1,269 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! CTAP2 and U2F message handling of the scripted authenticator. + +use sha2::{Digest, Sha256}; + +use super::super::cbor::{self, Value}; +use super::super::ctap2::{self, CosePublicKey, status}; +use super::super::pin_protocol::{ + PinProtocol, PlatformKeyAgreement, SharedSecret, authenticate_with, +}; +use super::super::u2f; +use super::{MockAuthenticator, Protocol, Request, ok}; + +impl MockAuthenticator { + pub(super) fn handle_cbor(&mut self, payload: &[u8]) -> Option> { + let Protocol::Fido2(config) = &self.protocol else { + return Some(vec![0x01]); + }; + let (&command, body) = payload.split_first()?; + match command { + ctap2::CMD_GET_INFO => { + self.requests.push(Request::GetInfo); + let mut pairs = vec![ + ( + Value::Int(1), + Value::Array(vec![Value::from("FIDO_2_0"), Value::from("FIDO_2_1")]), + ), + (Value::Int(3), Value::Bytes(vec![0xa0; 16])), + ( + Value::Int(4), + Value::Map( + config + .options + .iter() + .map(|(name, value)| { + (Value::from(name.as_str()), Value::Bool(*value)) + }) + .collect(), + ), + ), + ]; + if !config.pin_protocols.is_empty() { + pairs.push(( + Value::Int(6), + Value::Array( + config + .pin_protocols + .iter() + .map(|protocol| Value::Int(*protocol as i64)) + .collect(), + ), + )); + } + Some(ok(&Value::Map(pairs))) + } + ctap2::CMD_GET_ASSERTION => self.handle_get_assertion(body), + ctap2::CMD_CLIENT_PIN => self.handle_client_pin(body), + ctap2::CMD_MAKE_CREDENTIAL => { + self.requests.push(Request::TouchProbe); + self.touch_on_probe.then(|| vec![self.touch_status]) + } + _ => Some(vec![0x01]), + } + } + + fn handle_get_assertion(&mut self, body: &[u8]) -> Option> { + let map = cbor::decode(body).expect("valid getAssertion CBOR"); + let rp_id = map.get_int(1)?.as_text()?.to_string(); + let client_data_hash = map.get_int(2)?.as_bytes()?.to_vec(); + let credential_ids = map + .get_int(3)? + .as_array()? + .iter() + .filter_map(|item| item.get(&Value::from("id"))?.as_bytes().map(<[u8]>::to_vec)) + .collect::>(); + let option = |name: &str| map.get_int(5)?.get(&Value::from(name))?.as_bool(); + let (up, uv) = (option("up"), option("uv")); + let pin_auth = match ( + map.get_int(6).and_then(Value::as_bytes), + map.get_int(7).and_then(Value::as_int), + ) { + (Some(param), Some(protocol)) => Some((param.to_vec(), protocol)), + _ => None, + }; + self.requests.push(Request::GetAssertion { + rp_id: rp_id.clone(), + credential_ids: credential_ids.clone(), + up, + uv, + pin_auth: pin_auth.clone(), + }); + if let Some(code) = self.forced_status { + return Some(vec![code]); + } + let Protocol::Fido2(config) = &self.protocol else { + unreachable!() + }; + let Some(credential) = self.credential.as_ref().filter(|credential| { + credential.rp_id == rp_id && credential_ids.contains(&credential.id) + }) else { + return Some(vec![status::NO_CREDENTIALS]); + }; + let mut flags = 0u8; + if up != Some(false) { + flags |= 0x01; + } + if let Some((param, protocol)) = pin_auth { + let protocol = if protocol == 2 { + PinProtocol::V2 + } else { + PinProtocol::V1 + }; + if param != authenticate_with(protocol, &self.pin_token, &client_data_hash) { + return Some(vec![status::PIN_AUTH_INVALID]); + } + flags |= 0x04; + } else if uv == Some(true) { + if config.options.get("uv") != Some(&true) { + return Some(vec![status::UNSUPPORTED_OPTION]); + } + flags |= 0x04; + } + self.counter += 1; + let mut auth_data = Vec::with_capacity(37); + auth_data.extend_from_slice(&Sha256::digest(rp_id.as_bytes())); + auth_data.push(flags); + auth_data.extend_from_slice(&self.counter.to_be_bytes()); + let mut signed = auth_data.clone(); + signed.extend_from_slice(&client_data_hash); + let signature = credential.key.sign(&signed); + Some(ok(&Value::Map(vec![ + ( + Value::Int(1), + Value::Map(vec![ + (Value::from("id"), Value::from(credential.id.as_slice())), + (Value::from("type"), Value::from("public-key")), + ]), + ), + (Value::Int(2), Value::Bytes(auth_data)), + (Value::Int(3), Value::Bytes(signature)), + ]))) + } + + fn handle_client_pin(&mut self, body: &[u8]) -> Option> { + let map = cbor::decode(body).expect("valid clientPIN CBOR"); + let protocol_number = map.get_int(1)?.as_int()?; + let subcommand = map.get_int(2)?.as_int()?; + let permissions = map + .get_int(9) + .and_then(Value::as_int) + .and_then(|bits| Some((bits, map.get_int(10)?.as_text()?.to_string()))); + self.requests.push(Request::ClientPin { + subcommand, + protocol: protocol_number, + permissions, + }); + let Protocol::Fido2(config) = &self.protocol else { + unreachable!() + }; + let protocol = if protocol_number == 2 { + PinProtocol::V2 + } else { + PinProtocol::V1 + }; + match subcommand { + 0x02 => { + let agreement = PlatformKeyAgreement::generate(&mut rand::rng()); + let reply = ok(&Value::Map(vec![( + Value::Int(1), + agreement.public_key().to_value(), + )])); + self.agreement = Some(agreement); + Some(reply) + } + 0x05 | 0x09 => { + let platform = CosePublicKey::from_value(map.get_int(3)?).ok()?; + let secret: SharedSecret = self + .agreement + .as_ref()? + .shared_secret(protocol, &platform) + .ok()?; + let pin_hash = secret.decrypt(map.get_int(6)?.as_bytes()?).ok()?; + let expected = Sha256::digest(config.pin.as_deref()?.as_bytes()); + if pin_hash[..] != expected[..16] { + return Some(vec![status::PIN_INVALID]); + } + let token = secret + .encrypt(&self.pin_token, &mut rand::rng()) + .expect("whole blocks"); + Some(ok(&Value::Map(vec![(Value::Int(2), Value::Bytes(token))]))) + } + _ => Some(vec![0x01]), + } + } + + pub(super) fn handle_apdu(&mut self, apdu: &[u8]) -> Option> { + let ins = apdu[1]; + let p1 = apdu[2]; + let len = usize::from(u16::from_be_bytes([apdu[5], apdu[6]])); + let data = &apdu[7..7 + len]; + match ins { + u2f::INS_REGISTER => { + self.requests.push(Request::U2fRegister { p1 }); + Some(if self.touch_on_probe { + let mut reply = vec![0x05; 70]; + reply.extend_from_slice(&u2f::SW_NO_ERROR.to_be_bytes()); + reply + } else { + u2f::SW_CONDITIONS_NOT_SATISFIED.to_be_bytes().to_vec() + }) + } + u2f::INS_AUTHENTICATE => { + let challenge = &data[..32]; + let application = &data[32..64]; + let key_handle = &data[65..65 + usize::from(data[64])]; + let holds = self.credential.as_ref().is_some_and(|credential| { + Sha256::digest(credential.rp_id.as_bytes())[..] == *application + && credential.id == key_handle + }); + if p1 == u2f::P1_CHECK_ONLY { + self.requests.push(Request::U2fCheck); + return Some( + if holds { + u2f::SW_CONDITIONS_NOT_SATISFIED + } else { + u2f::SW_WRONG_DATA + } + .to_be_bytes() + .to_vec(), + ); + } + self.requests.push(Request::U2fSign); + if !holds { + return Some(u2f::SW_WRONG_DATA.to_be_bytes().to_vec()); + } + if self.u2f_presence_retries > 0 { + self.u2f_presence_retries -= 1; + return Some(u2f::SW_CONDITIONS_NOT_SATISFIED.to_be_bytes().to_vec()); + } + self.counter += 1; + let mut signed = Vec::with_capacity(69); + signed.extend_from_slice(application); + signed.push(0x01); + signed.extend_from_slice(&self.counter.to_be_bytes()); + signed.extend_from_slice(challenge); + let signature = self.credential.as_ref()?.key.sign(&signed); + let mut reply = vec![0x01]; + reply.extend_from_slice(&self.counter.to_be_bytes()); + reply.extend_from_slice(&signature); + reply.extend_from_slice(&u2f::SW_NO_ERROR.to_be_bytes()); + Some(reply) + } + _ => Some(0x6d00u16.to_be_bytes().to_vec()), + } + } +} diff --git a/src/ssh/security_key/internal/mod.rs b/src/ssh/security_key/internal/mod.rs new file mode 100644 index 00000000..a099d515 --- /dev/null +++ b/src/ssh/security_key/internal/mod.rs @@ -0,0 +1,318 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The built-in `SecurityKeyProvider internal`: USB HID authenticators +//! driven over CTAPHID, after OpenSSH's `sk-usbhid.c` (which uses libfido2). +//! +//! Layers, bottom up: [`ctaphid`] (report framing and channels over a +//! [`HidDevice`](ctaphid::HidDevice) trait), [`cbor`], [`ctap2`] and +//! [`u2f`] (messages), [`pin_protocol`] (PIN/UV auth protocols 1 and 2), +//! [`device`] (one authenticator's `getAssertion` with PIN and U2F +//! fallback), [`select`] (which attached device to use), and +//! [`InternalProvider`], which is `sk_sign` behind the +//! [`SecurityKeyProvider`] trait. Only [`hidapi_backend`] touches the OS. +//! +//! Only signing exists. Enrollment (`ssh-keygen -t ed25519-sk`) and resident +//! key loading (`ssh-keygen -K`) stay with OpenSSH's tools. +//! +//! One deliberate deviation from libfido2: a whole `sk_sign` call is bounded +//! by [`SIGN_TIMEOUT`] (120 s, sshd's default `LoginGraceTime`). libfido2 +//! waits without limit for a reply, so a device that streams KEEPALIVE or +//! frames for other channels forever, or a U2F key nobody touches, holds +//! `ssh` until the user interrupts it. bssh serializes every provider call +//! in the process behind one lock, so such a device would stall +//! authentication to every host. On expiry the pending request is cancelled +//! (on CTAP2 devices), the signature fails with "timed out waiting for the +//! security key", and authentication moves on to the next identity. + +pub mod cbor; +pub mod ctap2; +pub mod ctaphid; +pub mod device; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod hidapi_backend; +#[cfg(test)] +pub(crate) mod mock; +pub mod pin_protocol; +pub mod select; +pub mod u2f; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use p256::ecdsa::Signature as EcdsaSignature; +use zeroize::Zeroizing; + +use super::{ + SK_FLAG_USER_PRESENCE_REQUIRED, SK_FLAG_USER_VERIFICATION_REQUIRED, SecurityKeyError, + SecurityKeyProvider, SkAlgorithm, SkSignRequest, SkSignResponse, +}; +use ctaphid::HidBackend; +use device::{AssertionRequest, AssertionResult}; +use select::Probe; + +/// Failures below the sk-api surface. [`SecurityKeyError::from`] folds them +/// into the codes `fidoerr_to_skerr` would produce. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CtapError { + #[error("HID transport: {0}")] + Transport(String), + #[error("CTAPHID error {0:#04x}")] + HidError(u8), + #[error("CTAPHID protocol: {0}")] + Protocol(&'static str), + #[error("timed out waiting for the authenticator")] + Timeout, + /// The overall [`SIGN_TIMEOUT`] of one signature ran out. + #[error("timed out waiting for the security key")] + DeadlineExceeded, + #[error("authenticator returned CTAP2 status {code:#04x} ({name})", code = .0, name = ctap2::status_name(*.0))] + Status(u8), + #[error("authenticator returned U2F status {0:#06x}")] + Apdu(u16), + #[error("CBOR: {0}")] + Cbor(&'static str), + #[error("unsupported: {0}")] + Unsupported(&'static str), + /// A U2F device recognised the key handle but needs a touch to sign. + #[error("user presence required")] + UserPresenceRequired, +} + +/// Outcome of one touch poll. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserPresence { + Touched, + Waiting, +} + +impl From for SecurityKeyError { + /// `fidoerr_to_skerr`: unsupported options and algorithms map to + /// `SSH_SK_ERR_UNSUPPORTED`; PIN required/invalid and operation denied + /// map to `SSH_SK_ERR_PIN_REQUIRED`; everything else is a general + /// failure, which keeps its description here. + fn from(error: CtapError) -> Self { + use ctap2::status; + match error { + CtapError::Status(status::UNSUPPORTED_OPTION | status::UNSUPPORTED_ALGORITHM) + | CtapError::Unsupported(_) => Self::Unsupported, + CtapError::Status( + status::PIN_INVALID | status::PIN_REQUIRED | status::OPERATION_DENIED, + ) => Self::PinRequired, + other => Self::Failed(other.to_string()), + } + } +} + +/// Bound on one whole `sk_sign` call; see the module documentation. +pub const SIGN_TIMEOUT: Duration = Duration::from_secs(120); +/// `SELECT_MS`: how long a touch is awaited when several devices are attached. +pub const SELECT_TIMEOUT: Duration = Duration::from_secs(15); +/// `POLL_SLEEP_NS`: pause before each touch-polling round. +pub const POLL_SLEEP: Duration = Duration::from_millis(200); + +/// The provider's waits. Only tests change them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Timing { + pub sign_timeout: Duration, + pub select_timeout: Duration, + pub poll_sleep: Duration, +} + +impl Default for Timing { + fn default() -> Self { + Self { + sign_timeout: SIGN_TIMEOUT, + select_timeout: SELECT_TIMEOUT, + poll_sleep: POLL_SLEEP, + } + } +} + +/// `sk_sign` over USB HID. +pub struct InternalProvider { + backend: Arc, + timing: Timing, +} + +impl std::fmt::Debug for InternalProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InternalProvider").finish_non_exhaustive() + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Default for InternalProvider { + fn default() -> Self { + Self::new() + } +} + +impl InternalProvider { + /// The provider over the host's HID devices. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[must_use] + pub fn new() -> Self { + Self::with_backend(Arc::new(hidapi_backend::HidapiBackend::default())) + } + + #[must_use] + pub fn with_backend(backend: Arc) -> Self { + Self { + backend, + timing: Timing::default(), + } + } + + /// Replace the waits (tests use short ones). + #[must_use] + pub fn with_timing(mut self, timing: Timing) -> Self { + self.timing = timing; + self + } +} + +impl SecurityKeyProvider for InternalProvider { + fn sign( + &self, + request: &SkSignRequest<'_>, + pin: Option<&str>, + ) -> Result { + request.validate()?; + let deadline = Instant::now() + self.timing.sign_timeout; + let mut rng = rand::rng(); + let wants_uv = request.flags & SK_FLAG_USER_VERIFICATION_REQUIRED != 0; + // With a PIN in hand or verification required, the probe assertion + // would itself need the PIN, so the device is chosen by touch. + let probe = if pin.is_some() || wants_uv { + Probe::ByTouch + } else { + Probe::ByCredential { + application: request.application, + key_handle: request.key_handle, + } + }; + let selected = select::probe(&*self.backend, probe, &self.timing, deadline, &mut rng) + .map_err(|error| past_deadline(error, deadline))?; + let Some(mut device) = selected else { + return Err(if Instant::now() >= deadline { + CtapError::DeadlineExceeded.into() + } else { + SecurityKeyError::DeviceNotFound + }); + }; + if !device.is_fido2() && request.algorithm == SkAlgorithm::Ed25519 { + return Err(SecurityKeyError::Unsupported); + } + let up = Some(request.flags & SK_FLAG_USER_PRESENCE_REQUIRED != 0); + let mut uv = None; + if pin.is_none() && wants_uv { + // Without a PIN, verification must come from the device itself + // (biometrics); a device that cannot verify needs the PIN. + if device.option("uv") != Some(true) { + return Err(SecurityKeyError::PinRequired); + } + uv = Some(true); + } + let assertion = device.get_assertion( + &AssertionRequest { + rp_id: request.application, + data: request.data, + credential_id: request.key_handle, + up, + uv, + }, + pin, + deadline, + &mut rng, + ); + let assertion = match assertion { + Ok(assertion) => assertion, + Err(error) => { + let error = past_deadline(error, deadline); + if error == CtapError::DeadlineExceeded { + // Stop the device from waiting for a touch nobody gives. + device.cancel(); + } + return Err(error.into()); + } + }; + pack_signature(request.algorithm, &assertion) + } + + fn describe(&self) -> String { + "internal".to_string() + } +} + +/// A wait that ended because the overall deadline passed. +fn past_deadline(error: CtapError, deadline: Instant) -> CtapError { + if error == CtapError::Timeout && Instant::now() >= deadline { + CtapError::DeadlineExceeded + } else { + error + } +} + +/// `pack_sig`: the device signature as sk-api `sig_r`/`sig_s`. ECDSA +/// components are the minimal big-endian integers `BN_bn2bin` produces; +/// an Ed25519 signature travels whole in `sig_r`. +pub fn pack_signature( + algorithm: SkAlgorithm, + assertion: &AssertionResult, +) -> Result { + let (sig_r, sig_s) = match algorithm { + SkAlgorithm::Ed25519 => { + if assertion.signature.len() != 64 { + return Err(SecurityKeyError::InvalidResponse( + "Ed25519 assertion signature is not 64 bytes", + )); + } + (assertion.signature.clone(), Vec::new()) + } + SkAlgorithm::EcdsaSha2NistP256 => { + let signature = EcdsaSignature::from_der(&assertion.signature) + .map_err(|_| SecurityKeyError::InvalidResponse("malformed DER ECDSA signature"))?; + let (r, s) = signature.split_bytes(); + (minimal_big_endian(&r), minimal_big_endian(&s)) + } + }; + Ok(SkSignResponse { + flags: assertion.flags, + counter: assertion.counter, + sig_r: Zeroizing::new(sig_r), + sig_s: Zeroizing::new(sig_s), + }) +} + +/// Strip leading zero bytes the way `BN_bn2bin` omits them. +fn minimal_big_endian(bytes: &[u8]) -> Vec { + let start = bytes + .iter() + .position(|byte| *byte != 0) + .unwrap_or(bytes.len()); + bytes[start..].to_vec() +} + +#[cfg(test)] +#[path = "provider_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "selection_tests.rs"] +mod selection_tests; + +#[cfg(test)] +#[path = "robustness_tests.rs"] +mod robustness_tests; diff --git a/src/ssh/security_key/internal/pin_protocol.rs b/src/ssh/security_key/internal/pin_protocol.rs new file mode 100644 index 00000000..76a2fff0 --- /dev/null +++ b/src/ssh/security_key/internal/pin_protocol.rs @@ -0,0 +1,293 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! CTAP2 PIN/UV auth protocols 1 and 2 (CTAP 2.1 section 6.5.6 and 6.5.7). +//! +//! Both start from an ECDH P-256 agreement with the authenticator. Protocol +//! 1 uses `SHA-256(x)` as the single key, AES-256-CBC with a zero IV, and the +//! first 16 bytes of HMAC-SHA-256; protocol 2 derives separate HMAC and AES +//! keys with HKDF, prepends a random IV, and uses the full HMAC. libfido2 +//! picks 2 when the authenticator lists it, and so does this module. +//! +//! Every key, hash and token lives in `Zeroizing` memory; the PIN itself is +//! only ever hashed here and never logged. + +use aes::Aes256; +use cbc::cipher::block_padding::NoPadding; +use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit}; +use hkdf::Hkdf; +use hmac::{Hmac, KeyInit, Mac}; +use p256::ecdh::EphemeralSecret; +use p256::elliptic_curve::Generate; +use p256::elliptic_curve::sec1::ToSec1Point; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use super::CtapError; +use super::ctap2::CosePublicKey; + +type HmacSha256 = Hmac; +type Aes256CbcEncryptor = cbc::Encryptor; +type Aes256CbcDecryptor = cbc::Decryptor; + +const AES_BLOCK_LEN: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PinProtocol { + V1, + V2, +} + +impl PinProtocol { + /// The `pinUvAuthProtocol` number on the wire. + #[must_use] + pub const fn number(self) -> u64 { + match self { + Self::V1 => 1, + Self::V2 => 2, + } + } + + /// Prefer protocol 2 when the device lists it; a device that lists + /// nothing is a CTAP 2.0 device, which implements protocol 1. + #[must_use] + pub fn select(advertised: &[u64]) -> Self { + if advertised.contains(&2) { + Self::V2 + } else { + Self::V1 + } + } + + /// Length of a `pinUvAuthParam` under this protocol. + #[must_use] + pub const fn auth_len(self) -> usize { + match self { + Self::V1 => 16, + Self::V2 => 32, + } + } +} + +/// The platform's ephemeral ECDH key for one PIN exchange. +pub struct PlatformKeyAgreement { + secret: EphemeralSecret, + public: CosePublicKey, +} + +impl std::fmt::Debug for PlatformKeyAgreement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformKeyAgreement") + .field("public", &self.public) + .finish_non_exhaustive() + } +} + +impl PlatformKeyAgreement { + pub fn generate(rng: &mut R) -> Self { + let secret = EphemeralSecret::generate_from_rng(rng); + let point = secret.public_key().to_sec1_point(false); + let coordinate = |bytes: Option<&p256::FieldBytes>| -> [u8; 32] { + bytes + .map(|bytes| { + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + out + }) + .expect("uncompressed point has both coordinates") + }; + let public = CosePublicKey { + x: coordinate(point.x()), + y: coordinate(point.y()), + }; + Self { secret, public } + } + + #[must_use] + pub fn public_key(&self) -> &CosePublicKey { + &self.public + } + + /// Complete the agreement with the authenticator's key. + pub fn shared_secret( + &self, + protocol: PinProtocol, + peer: &CosePublicKey, + ) -> Result { + let mut encoded = Vec::with_capacity(65); + encoded.push(0x04); + encoded.extend_from_slice(&peer.x); + encoded.extend_from_slice(&peer.y); + let peer = p256::PublicKey::from_sec1_bytes(&encoded) + .map_err(|_| CtapError::Protocol("authenticator key agreement point is invalid"))?; + let shared = self.secret.diffie_hellman(&peer); + let mut x = Zeroizing::new([0u8; 32]); + x.copy_from_slice(shared.raw_secret_bytes()); + Ok(SharedSecret::derive(protocol, &x)) + } +} + +/// Keys derived from the ECDH shared point. +pub struct SharedSecret { + protocol: PinProtocol, + hmac_key: Zeroizing<[u8; 32]>, + aes_key: Zeroizing<[u8; 32]>, +} + +impl std::fmt::Debug for SharedSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SharedSecret") + .field("protocol", &self.protocol) + .finish_non_exhaustive() + } +} + +impl SharedSecret { + /// `kdf(Z)` of the protocol, with `Z` the x coordinate of the shared point. + #[must_use] + pub fn derive(protocol: PinProtocol, point_x: &[u8; 32]) -> Self { + match protocol { + PinProtocol::V1 => { + let mut key = Zeroizing::new([0u8; 32]); + key.copy_from_slice(&Sha256::digest(point_x)); + Self { + protocol, + hmac_key: key.clone(), + aes_key: key, + } + } + PinProtocol::V2 => { + let salt = [0u8; 32]; + let hmac_key = hkdf_sha256::<32>(&salt, point_x, b"CTAP2 HMAC key"); + let aes_key = hkdf_sha256::<32>(&salt, point_x, b"CTAP2 AES key"); + Self { + protocol, + hmac_key, + aes_key, + } + } + } + } + + /// Build from known keys, for test vectors. + #[cfg(test)] + pub(crate) fn from_keys(protocol: PinProtocol, hmac_key: [u8; 32], aes_key: [u8; 32]) -> Self { + Self { + protocol, + hmac_key: Zeroizing::new(hmac_key), + aes_key: Zeroizing::new(aes_key), + } + } + + #[must_use] + pub fn protocol(&self) -> PinProtocol { + self.protocol + } + + /// `encrypt(key, demPlaintext)`; the plaintext must be whole AES blocks. + pub fn encrypt( + &self, + plaintext: &[u8], + rng: &mut R, + ) -> Result, CtapError> { + if plaintext.is_empty() || !plaintext.len().is_multiple_of(AES_BLOCK_LEN) { + return Err(CtapError::Protocol( + "PIN protocol plaintext is not whole blocks", + )); + } + let mut iv = [0u8; AES_BLOCK_LEN]; + if self.protocol == PinProtocol::V2 { + rng.fill_bytes(&mut iv); + } + let cipher = Aes256CbcEncryptor::new_from_slices(&self.aes_key[..], &iv) + .map_err(|_| CtapError::Protocol("AES key or IV length"))?; + let ciphertext = cipher.encrypt_padded_vec::(plaintext); + Ok(match self.protocol { + PinProtocol::V1 => ciphertext, + PinProtocol::V2 => { + let mut out = Vec::with_capacity(AES_BLOCK_LEN + ciphertext.len()); + out.extend_from_slice(&iv); + out.extend(ciphertext); + out + } + }) + } + + /// `decrypt(key, demCiphertext)`. + pub fn decrypt(&self, ciphertext: &[u8]) -> Result>, CtapError> { + let (iv, body): ([u8; AES_BLOCK_LEN], &[u8]) = match self.protocol { + PinProtocol::V1 => ([0u8; AES_BLOCK_LEN], ciphertext), + PinProtocol::V2 => { + if ciphertext.len() < AES_BLOCK_LEN { + return Err(CtapError::Protocol("PIN protocol 2 ciphertext without IV")); + } + let (iv, body) = ciphertext.split_at(AES_BLOCK_LEN); + (iv.try_into().expect("16 bytes"), body) + } + }; + if body.is_empty() || !body.len().is_multiple_of(AES_BLOCK_LEN) { + return Err(CtapError::Protocol( + "PIN protocol ciphertext is not whole blocks", + )); + } + let cipher = Aes256CbcDecryptor::new_from_slices(&self.aes_key[..], &iv) + .map_err(|_| CtapError::Protocol("AES key or IV length"))?; + cipher + .decrypt_padded_vec::(body) + .map(Zeroizing::new) + .map_err(|_| CtapError::Protocol("PIN protocol decryption failed")) + } + + /// `authenticate(key, message)` with the shared HMAC key. + #[must_use] + pub fn authenticate(&self, message: &[u8]) -> Vec { + authenticate_with(self.protocol, &self.hmac_key[..], message) + } + + /// `pinHashEnc`: the first 16 bytes of `SHA-256(pin)`, encrypted. + pub fn encrypt_pin_hash( + &self, + pin: &str, + rng: &mut R, + ) -> Result, CtapError> { + let hash = Zeroizing::new(Sha256::digest(pin.as_bytes())); + self.encrypt(&hash[..16], rng) + } +} + +/// `authenticate(key, message)` with an arbitrary key, as used with the +/// `pinUvAuthToken` to compute `pinUvAuthParam`. +#[must_use] +pub fn authenticate_with(protocol: PinProtocol, key: &[u8], message: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(message); + let tag = mac.finalize().into_bytes(); + tag[..protocol.auth_len()].to_vec() +} + +/// HKDF-SHA-256 (RFC 5869) extract-and-expand into `N` bytes of +/// `Zeroizing` memory, through the `hkdf` crate. +#[must_use] +pub fn hkdf_sha256(salt: &[u8], ikm: &[u8], info: &[u8]) -> Zeroizing<[u8; N]> { + const { assert!(N <= 255 * 32, "HKDF-SHA-256 output is at most 8160 bytes") }; + let mut okm = Zeroizing::new([0u8; N]); + Hkdf::::new(Some(salt), ikm) + .expand(info, &mut okm[..]) + .expect("length checked at compile time"); + okm +} + +#[cfg(test)] +#[path = "pin_protocol_tests.rs"] +mod tests; diff --git a/src/ssh/security_key/internal/pin_protocol_tests.rs b/src/ssh/security_key/internal/pin_protocol_tests.rs new file mode 100644 index 00000000..871ba757 --- /dev/null +++ b/src/ssh/security_key/internal/pin_protocol_tests.rs @@ -0,0 +1,147 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +fn hex(text: &str) -> Vec { + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()) + .collect() +} + +#[test] +fn hkdf_matches_rfc_5869_test_case_1() { + let okm = hkdf_sha256::<42>( + &hex("000102030405060708090a0b0c"), + &[0x0b; 22], + &hex("f0f1f2f3f4f5f6f7f8f9"), + ); + assert_eq!( + okm[..], + hex("3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865") + [..] + ); +} + +#[test] +fn protocol_1_derives_sha256_of_the_shared_x_and_uses_a_zero_iv() { + let x = [0x42; 32]; + let secret = SharedSecret::derive(PinProtocol::V1, &x); + let expected = SharedSecret::from_keys( + PinProtocol::V1, + Sha256::digest(x).into(), + Sha256::digest(x).into(), + ); + let block = [0x10; 16]; + let mut rng = rand::rng(); + assert_eq!( + secret.encrypt(&block, &mut rng).unwrap(), + expected.encrypt(&block, &mut rng).unwrap() + ); + + // FIPS-197 C.3 AES-256 vector: with a zero IV the first CBC block is + // plain AES. + let key: [u8; 32] = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + .try_into() + .unwrap(); + let secret = SharedSecret::from_keys(PinProtocol::V1, key, key); + let plaintext = hex("00112233445566778899aabbccddeeff"); + let ciphertext = secret.encrypt(&plaintext, &mut rng).unwrap(); + assert_eq!(ciphertext, hex("8ea2b7ca516745bfeafc49904b496089")); + assert_eq!(secret.decrypt(&ciphertext).unwrap()[..], plaintext[..]); + assert!(secret.encrypt(&[1; 15], &mut rng).is_err()); + assert!(secret.encrypt(&[], &mut rng).is_err()); + assert!(secret.decrypt(&[1; 17]).is_err()); +} + +#[test] +fn authenticate_is_hmac_sha256_truncated_only_in_protocol_1() { + // RFC 4231 test case 2. + let full = hex("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"); + let message = b"what do ya want for nothing?"; + assert_eq!( + authenticate_with(PinProtocol::V1, b"Jefe", message), + full[..16] + ); + assert_eq!(authenticate_with(PinProtocol::V2, b"Jefe", message), full); + let mut key = [0u8; 32]; + key[..4].copy_from_slice(b"Jefe"); + // A full-length key is a different key, so only the API shape is checked. + let secret = SharedSecret::from_keys(PinProtocol::V2, key, key); + assert_eq!(secret.authenticate(message).len(), 32); + assert_eq!(PinProtocol::V1.auth_len(), 16); + assert_eq!(PinProtocol::V2.auth_len(), 32); +} + +#[test] +fn protocol_2_derives_separate_keys_and_prefixes_a_random_iv() { + let x = [0x24; 32]; + let secret = SharedSecret::derive(PinProtocol::V2, &x); + let expected_hmac = hkdf_sha256::<32>(&[0; 32], &x, b"CTAP2 HMAC key"); + let expected_aes = hkdf_sha256::<32>(&[0; 32], &x, b"CTAP2 AES key"); + assert_ne!(expected_hmac[..], expected_aes[..]); + let expected = SharedSecret::from_keys(PinProtocol::V2, *expected_hmac, *expected_aes); + assert_eq!(secret.authenticate(b"m"), expected.authenticate(b"m")); + + let mut rng = rand::rng(); + let plaintext = [0x77; 32]; + let first = secret.encrypt(&plaintext, &mut rng).unwrap(); + let second = secret.encrypt(&plaintext, &mut rng).unwrap(); + assert_eq!(first.len(), 16 + 32); + assert_ne!(first, second, "protocol 2 must use a fresh IV"); + assert_eq!(expected.decrypt(&first).unwrap()[..], plaintext); + assert_eq!(expected.decrypt(&second).unwrap()[..], plaintext); + assert!(secret.decrypt(&first[..15]).is_err()); + assert!(secret.decrypt(&first[..16]).is_err()); + assert!(secret.decrypt(&first[..40]).is_err()); +} + +#[test] +fn both_sides_of_the_key_agreement_derive_the_same_secret() { + let mut rng = rand::rng(); + for protocol in [PinProtocol::V1, PinProtocol::V2] { + let platform = PlatformKeyAgreement::generate(&mut rng); + let authenticator = PlatformKeyAgreement::generate(&mut rng); + let ours = platform + .shared_secret(protocol, authenticator.public_key()) + .unwrap(); + let theirs = authenticator + .shared_secret(protocol, platform.public_key()) + .unwrap(); + let pin_hash_enc = ours.encrypt_pin_hash("123456", &mut rng).unwrap(); + let pin_hash = theirs.decrypt(&pin_hash_enc).unwrap(); + assert_eq!(pin_hash[..], Sha256::digest(b"123456")[..16]); + assert_eq!(ours.authenticate(b"x"), theirs.authenticate(b"x")); + } + let bogus = CosePublicKey { + x: [0xff; 32], + y: [0xff; 32], + }; + assert!( + PlatformKeyAgreement::generate(&mut rng) + .shared_secret(PinProtocol::V1, &bogus) + .is_err() + ); +} + +#[test] +fn protocol_2_is_preferred_when_advertised() { + assert_eq!(PinProtocol::select(&[1, 2]), PinProtocol::V2); + assert_eq!(PinProtocol::select(&[2]), PinProtocol::V2); + assert_eq!(PinProtocol::select(&[1]), PinProtocol::V1); + assert_eq!(PinProtocol::select(&[]), PinProtocol::V1); + assert_eq!(PinProtocol::V1.number(), 1); + assert_eq!(PinProtocol::V2.number(), 2); +} diff --git a/src/ssh/security_key/internal/provider_tests.rs b/src/ssh/security_key/internal/provider_tests.rs new file mode 100644 index 00000000..cee9cb60 --- /dev/null +++ b/src/ssh/security_key/internal/provider_tests.rs @@ -0,0 +1,236 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! `sk_sign` end to end over the mock transport, with the resulting SSH +//! signatures verified by `ssh-key`. + +use std::sync::{Arc, Mutex}; + +use p256::ecdsa::signature::Verifier as _; +use russh::keys::ssh_encoding::Decode as _; +use russh::keys::ssh_key::Signature; +use russh::keys::ssh_key::public::{Ed25519PublicKey, KeyData, SkEcdsaSha2NistP256, SkEd25519}; + +use super::mock::{ + Credential, CredentialKey, Fido2Config, MockAuthenticator, MockBackend, Protocol, +}; +use super::*; +use crate::ssh::security_key::encode_signature; + +pub(super) const APPLICATION: &str = "ssh:example"; +pub(super) const KEY_HANDLE: &[u8] = &[0xc0, 0xff, 0xee, 0x01]; +pub(super) const DATA: &[u8] = b"session identifier and request"; + +pub(super) fn credential(key: CredentialKey) -> Credential { + Credential { + rp_id: APPLICATION.to_string(), + id: KEY_HANDLE.to_vec(), + key, + } +} + +pub(super) fn fido2(config: Fido2Config, key: Option) -> MockAuthenticator { + MockAuthenticator::new(Protocol::Fido2(config), key.map(credential)) +} + +pub(super) type Handles = Vec>>; + +pub(super) fn provider_with( + devices: Vec<(&str, MockAuthenticator)>, +) -> (InternalProvider, Handles) { + let mut backend = MockBackend::default(); + backend.add_non_fido("/dev/hidraw0"); + let handles = devices + .into_iter() + .map(|(path, device)| backend.add(path, device)) + .collect(); + (InternalProvider::with_backend(Arc::new(backend)), handles) +} + +pub(super) fn request(algorithm: SkAlgorithm, flags: u8) -> SkSignRequest<'static> { + SkSignRequest { + algorithm, + data: DATA, + application: APPLICATION, + key_handle: KEY_HANDLE, + flags, + } +} + +pub(super) fn public_key(device: &MockAuthenticator) -> KeyData { + match &device.credential.as_ref().unwrap().key { + CredentialKey::Ed25519(key) => KeyData::SkEd25519(SkEd25519::new( + Ed25519PublicKey(key.verifying_key().to_bytes()), + APPLICATION, + )), + CredentialKey::P256(key) => { + let point = key.verifying_key().to_sec1_point(false); + KeyData::SkEcdsaSha2NistP256(SkEcdsaSha2NistP256::new(point, APPLICATION)) + } + } +} + +pub(super) fn verify(public: &KeyData, blob: &[u8]) { + let signature = Signature::decode(&mut &blob[..]).unwrap(); + public.verify(DATA, &signature).unwrap(); +} + +#[test] +fn ed25519_signature_through_the_provider_verifies_with_ssh_key() { + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2(Fido2Config::plain(), Some(CredentialKey::ed25519(1))), + )]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap(); + assert_eq!(response.flags, 0x01); + assert_eq!( + response.counter, 43, + "probe and signature each advance the counter" + ); + assert_eq!(response.sig_r.len(), 64); + assert!(response.sig_s.is_empty()); + let blob = encode_signature(SkAlgorithm::Ed25519, &response).unwrap(); + verify(&public_key(&devices[0].lock().unwrap()), &blob); + assert_eq!(provider.describe(), "internal"); +} + +#[test] +fn ecdsa_signature_through_the_provider_verifies_with_minimal_components() { + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2(Fido2Config::plain(), Some(CredentialKey::p256(2))), + )]); + let response = provider + .sign(&request(SkAlgorithm::EcdsaSha2NistP256, 0x01), None) + .unwrap(); + assert!(!response.sig_r.is_empty() && response.sig_r[0] != 0); + assert!(!response.sig_s.is_empty() && response.sig_s[0] != 0); + assert!(response.sig_r.len() <= 32 && response.sig_s.len() <= 32); + let blob = encode_signature(SkAlgorithm::EcdsaSha2NistP256, &response).unwrap(); + verify(&public_key(&devices[0].lock().unwrap()), &blob); +} + +#[test] +fn a_mutated_signature_does_not_verify() { + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2(Fido2Config::plain(), Some(CredentialKey::ed25519(1))), + )]); + let mut response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap(); + response.counter += 1; + let blob = encode_signature(SkAlgorithm::Ed25519, &response).unwrap(); + let signature = Signature::decode(&mut &blob[..]).unwrap(); + assert!( + public_key(&devices[0].lock().unwrap()) + .verify(DATA, &signature) + .is_err() + ); +} + +#[test] +fn authenticator_statuses_map_like_fidoerr_to_skerr() { + use ctap2::status; + for code in [ + status::PIN_INVALID, + status::PIN_REQUIRED, + status::OPERATION_DENIED, + ] { + assert_eq!( + SecurityKeyError::from(CtapError::Status(code)), + SecurityKeyError::PinRequired + ); + } + for code in [status::UNSUPPORTED_OPTION, status::UNSUPPORTED_ALGORITHM] { + assert_eq!( + SecurityKeyError::from(CtapError::Status(code)), + SecurityKeyError::Unsupported + ); + } + assert_eq!( + SecurityKeyError::from(CtapError::Unsupported("x")), + SecurityKeyError::Unsupported + ); + assert_eq!( + SecurityKeyError::from(CtapError::Status(status::NO_CREDENTIALS)).to_string(), + "sk_sign failed: authenticator returned CTAP2 status 0x2e (no credentials)" + ); + assert_eq!( + SecurityKeyError::from(CtapError::Timeout).to_string(), + "sk_sign failed: timed out waiting for the authenticator" + ); +} + +#[test] +fn pack_signature_strips_leading_zeros_and_checks_ed25519_length() { + let assertion = AssertionResult { + flags: 0x01, + counter: 9, + signature: vec![0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02], + }; + let packed = pack_signature(SkAlgorithm::EcdsaSha2NistP256, &assertion).unwrap(); + assert_eq!(&packed.sig_r[..], &[0x01]); + assert_eq!(&packed.sig_s[..], &[0x02]); + assert_eq!((packed.flags, packed.counter), (0x01, 9)); + assert_eq!( + pack_signature( + SkAlgorithm::EcdsaSha2NistP256, + &AssertionResult { + signature: vec![0x30, 0x00], + ..assertion.clone() + } + ) + .unwrap_err(), + SecurityKeyError::InvalidResponse("malformed DER ECDSA signature") + ); + assert_eq!( + pack_signature( + SkAlgorithm::Ed25519, + &AssertionResult { + signature: vec![1; 63], + ..assertion.clone() + } + ) + .unwrap_err(), + SecurityKeyError::InvalidResponse("Ed25519 assertion signature is not 64 bytes") + ); + let packed = pack_signature( + SkAlgorithm::Ed25519, + &AssertionResult { + signature: vec![1; 64], + ..assertion + }, + ) + .unwrap(); + assert_eq!(packed.sig_r.len(), 64); + assert!(packed.sig_s.is_empty()); +} + +#[test] +fn non_fido_hid_devices_are_never_opened() { + let mut backend = MockBackend::default(); + backend.add_non_fido("/dev/hidraw0"); + let backend: Arc = Arc::new(backend); + assert!(select::fido_devices(&*backend).unwrap().is_empty()); + let provider = InternalProvider::with_backend(backend); + assert_eq!( + provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap_err(), + SecurityKeyError::DeviceNotFound + ); +} diff --git a/src/ssh/security_key/internal/robustness_tests.rs b/src/ssh/security_key/internal/robustness_tests.rs new file mode 100644 index 00000000..f6d4dca0 --- /dev/null +++ b/src/ssh/security_key/internal/robustness_tests.rs @@ -0,0 +1,235 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Misbehaving and unusual devices over the mock transport: refused touch +//! probes, duplicate and excess enumeration entries, devices that never +//! finish, unpaced polling, and late `getInfo` replies. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use super::ctap2::status; +use super::device::counts_as_touch; +use super::mock::{ + CredentialKey, Fido2Config, Flood, MockAuthenticator, MockBackend, Protocol, Request, +}; +use super::tests::{credential, fido2, provider_with, public_key, request, verify}; +use super::*; +use crate::ssh::security_key::encode_signature; + +const TIMED_OUT: &str = "timed out waiting for the security key"; + +fn short_deadline(deadline: Duration) -> Timing { + Timing { + sign_timeout: deadline, + ..Timing::default() + } +} + +#[test] +fn only_success_and_pin_errors_count_as_a_touch() { + for touched in [ + status::OK, + status::PIN_AUTH_INVALID, + status::PIN_INVALID, + status::PIN_NOT_SET, + ] { + assert!(counts_as_touch(touched), "{touched:#04x}"); + } + for refused in [ + status::OPERATION_DENIED, + status::PIN_REQUIRED, + status::KEEPALIVE_CANCEL, + status::USER_ACTION_TIMEOUT, + 0x01, + ] { + assert!(!counts_as_touch(refused), "{refused:#04x}"); + } +} + +#[test] +fn a_device_refusing_the_touch_probe_is_dropped_and_the_touched_one_wins() { + let mut refusing = fido2( + Fido2Config::with_pin("123456"), + Some(CredentialKey::ed25519(7)), + ); + refusing.touch_status = status::OPERATION_DENIED; + let (provider, devices) = provider_with(vec![ + ("/dev/hidraw1", refusing), + ( + "/dev/hidraw2", + fido2( + Fido2Config::with_pin("123456"), + Some(CredentialKey::ed25519(1)), + ), + ), + ]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), Some("123456")) + .unwrap(); + verify( + &public_key(&devices[1].lock().unwrap()), + &encode_signature(SkAlgorithm::Ed25519, &response).unwrap(), + ); + // The refusing device answered at once but was never treated as + // touched, so it never saw the PIN exchange. + assert_eq!( + devices[0].lock().unwrap().requests, + [Request::GetInfo, Request::TouchProbe] + ); +} + +#[test] +fn duplicate_entries_are_merged_and_at_most_eight_devices_are_considered() { + let mut backend = MockBackend::default(); + let mut handles = vec![backend.add("/dev/hidraw1", fido2(Fido2Config::plain(), None))]; + for _ in 0..5 { + backend.add_alias("/dev/hidraw1"); + } + for index in 2..=10 { + handles.push(backend.add( + &format!("/dev/hidraw{index}"), + fido2(Fido2Config::plain(), None), + )); + } + let paths = select::fido_devices(&backend) + .unwrap() + .into_iter() + .map(|device| device.path) + .collect::>(); + assert_eq!( + paths, + (1..=8) + .map(|index| format!("/dev/hidraw{index}")) + .collect::>() + ); + + let provider = InternalProvider::with_backend(Arc::new(backend)); + assert_eq!( + provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap_err(), + SecurityKeyError::DeviceNotFound + ); + let get_infos = |index: usize| { + handles[index] + .lock() + .unwrap() + .requests + .iter() + .filter(|request| **request == Request::GetInfo) + .count() + }; + assert_eq!(get_infos(0), 1, "the repeated device is opened once"); + assert_eq!(get_infos(7), 1); + assert_eq!(get_infos(8), 0, "the ninth device is never opened"); + assert_eq!(get_infos(9), 0); +} + +/// A lone CTAP2 device that reports `uv`, so no probe precedes the +/// signature request, holding its reply back behind `flood`. +fn stalled_device(flood: Flood) -> MockAuthenticator { + let mut config = Fido2Config::plain(); + config.options.insert("uv".to_string(), false); + let mut device = fido2(config, Some(CredentialKey::ed25519(1))); + device.stall = Some((flood, Duration::from_secs(3))); + device +} + +fn assert_times_out(provider: &InternalProvider, algorithm: SkAlgorithm) { + let started = Instant::now(); + let error = provider.sign(&request(algorithm, 0x01), None).unwrap_err(); + assert_eq!(error, SecurityKeyError::Failed(TIMED_OUT.to_string())); + assert_eq!(error.to_string(), format!("sk_sign failed: {TIMED_OUT}")); + assert!( + started.elapsed() < Duration::from_secs(2), + "{:?}", + started.elapsed() + ); +} + +#[test] +fn an_endless_keepalive_stream_ends_at_the_deadline_with_a_cancel() { + for flood in [Flood::Keepalive, Flood::ForeignChannel] { + let (provider, devices) = provider_with(vec![("/dev/hidraw1", stalled_device(flood))]); + let provider = provider.with_timing(short_deadline(Duration::from_millis(300))); + assert_times_out(&provider, SkAlgorithm::Ed25519); + let device = devices[0].lock().unwrap(); + assert_eq!(device.assertion_requests().len(), 1, "{flood:?}"); + assert_eq!(device.requests.last(), Some(&Request::Cancel), "{flood:?}"); + } +} + +#[test] +fn an_untouched_u2f_key_ends_at_the_deadline_without_a_cancel() { + let mut u2f = MockAuthenticator::new(Protocol::U2f, Some(credential(CredentialKey::p256(3)))); + // 40 presence retries paced 100 ms apart: about 4 s without a deadline. + u2f.u2f_presence_retries = 40; + let (provider, devices) = provider_with(vec![("/dev/hidraw1", u2f)]); + let provider = provider.with_timing(short_deadline(Duration::from_millis(300))); + assert_times_out(&provider, SkAlgorithm::EcdsaSha2NistP256); + let device = devices[0].lock().unwrap(); + assert!(device.requests.contains(&Request::U2fSign)); + assert_eq!(device.cancels, 0, "U2F devices get no CTAPHID CANCEL"); +} + +#[test] +fn touch_polling_rounds_are_paced() { + let mut u2f = MockAuthenticator::new(Protocol::U2f, None); + u2f.touch_on_probe = false; + let mut ctap2 = fido2(Fido2Config::with_pin("123456"), None); + ctap2.touch_on_probe = false; + let (provider, devices) = provider_with(vec![("/dev/hidraw1", u2f), ("/dev/hidraw2", ctap2)]); + let provider = provider.with_timing(Timing { + select_timeout: Duration::from_secs(1), + ..Timing::default() + }); + let started = Instant::now(); + assert_eq!( + provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), Some("123456")) + .unwrap_err(), + SecurityKeyError::DeviceNotFound + ); + assert!(started.elapsed() >= Duration::from_millis(900)); + // The U2F device answers every REGISTER at once, so the probe is re-sent + // once per round: about five rounds of 200 ms fit in the second. + let registers = devices[0].lock().unwrap().requests.len(); + assert!((2..=8).contains(®isters), "{registers} REGISTER probes"); +} + +#[test] +fn a_late_get_info_reply_does_not_break_the_u2f_fallback() { + let mut late = fido2(Fido2Config::plain(), Some(CredentialKey::p256(4))); + late.late_get_info = true; + let (provider, devices) = provider_with(vec![("/dev/hidraw1", late)]); + let response = provider + .sign(&request(SkAlgorithm::EcdsaSha2NistP256, 0x01), None) + .unwrap(); + verify( + &public_key(&devices[0].lock().unwrap()), + &encode_signature(SkAlgorithm::EcdsaSha2NistP256, &response).unwrap(), + ); + let device = devices[0].lock().unwrap(); + assert_eq!( + device.requests, + [ + Request::GetInfo, + Request::U2fCheck, + Request::U2fCheck, + Request::U2fSign, + ] + ); + assert_eq!(device.cancels, 0); +} diff --git a/src/ssh/security_key/internal/select.rs b/src/ssh/security_key/internal/select.rs new file mode 100644 index 00000000..9c732929 --- /dev/null +++ b/src/ssh/security_key/internal/select.rs @@ -0,0 +1,222 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Device discovery and selection, after `sk_probe`, `sk_select_by_cred` +//! and `sk_select_by_touch` in `sk-usbhid.c`. + +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use super::ctaphid::{HidBackend, HidDeviceInfo}; +use super::device::{AssertionRequest, Authenticator}; +use super::{CtapError, Timing, UserPresence}; + +/// `FIDO_POLL_MS`: per-device wait in each polling round. +const POLL_INTERVAL: Duration = Duration::from_millis(50); +/// `MAX_FIDO_DEVICES`: how many attached devices are considered at most. +pub const MAX_FIDO_DEVICES: usize = 8; +/// Bound on the silent assertion that asks one device for the credential. +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +/// How `sk_probe` chooses among attached devices. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Probe<'a> { + /// Pick the device holding the credential (silent per-device assertion). + ByCredential { + application: &'a str, + key_handle: &'a [u8], + }, + /// Pick the device the user touches (a PIN or user verification is + /// involved, so a silent assertion would not be possible anyway). + ByTouch, +} + +/// Attached devices on the FIDO usage page, each path once and at most +/// [`MAX_FIDO_DEVICES`] of them, in enumeration order. hidapi lists a device +/// once per top-level collection, so a crafted report descriptor could +/// otherwise repeat one node many times, and every entry costs channel +/// allocation, `getInfo` and a probe under the provider lock. +pub fn fido_devices(backend: &dyn HidBackend) -> Result, CtapError> { + let mut seen = HashSet::new(); + Ok(backend + .enumerate()? + .into_iter() + .filter(HidDeviceInfo::is_fido) + .filter(|device| seen.insert(device.path.clone())) + .take(MAX_FIDO_DEVICES) + .collect()) +} + +/// `sk_probe`: `Ok(None)` when no attached device fits; nothing waits +/// past `deadline`. +pub fn probe( + backend: &dyn HidBackend, + probe: Probe<'_>, + timing: &Timing, + deadline: Instant, + rng: &mut R, +) -> Result, CtapError> { + let devices = fido_devices(backend)?; + tracing::debug!("{} FIDO device(s) detected", devices.len()); + if devices.is_empty() { + return Ok(None); + } + let (opened, failures) = open_all(backend, &devices, deadline, rng); + if opened.is_empty() { + let detail = failures.first().cloned().unwrap_or_default(); + return Err(CtapError::Transport(format!( + "{} FIDO device(s) found but none could be opened: {detail}", + devices.len() + ))); + } + match probe { + Probe::ByCredential { + application, + key_handle, + } => Ok(select_by_credential( + opened, + application, + key_handle, + deadline, + rng, + )), + Probe::ByTouch => select_by_touch(opened, timing, deadline), + } +} + +/// `sk_openv`: open every candidate, keeping the reasons for failures so a +/// permission problem can be reported when nothing opens at all. +fn open_all( + backend: &dyn HidBackend, + devices: &[HidDeviceInfo], + deadline: Instant, + rng: &mut R, +) -> (Vec, Vec) { + let mut opened = Vec::with_capacity(devices.len()); + let mut failures = Vec::new(); + for device in devices { + match Authenticator::open(backend, device, deadline, rng) { + Ok(authenticator) => opened.push(authenticator), + Err(error) => { + tracing::debug!("{}: open failed: {error}", device.path); + failures.push(format!("{}: {error}", device.path)); + } + } + } + (opened, failures) +} + +/// `sk_try`: does this device hold the credential? A silent assertion with +/// dummy data answers it; a U2F device reports "user presence required" +/// for a key handle it recognises. +fn holds_credential( + device: &mut Authenticator, + application: &str, + key_handle: &[u8], + deadline: Instant, + rng: &mut R, +) -> bool { + let request = AssertionRequest { + rp_id: application, + data: &[0u8; 32], + credential_id: key_handle, + up: Some(false), + uv: None, + }; + let deadline = deadline.min(Instant::now() + PROBE_TIMEOUT); + match device.get_assertion(&request, None, deadline, rng) { + Ok(_) | Err(CtapError::UserPresenceRequired) => true, + Err(error) => { + tracing::debug!("{}: credential probe failed: {error}", device.path()); + false + } + } +} + +/// `sk_select_by_cred`. +fn select_by_credential( + mut devices: Vec, + application: &str, + key_handle: &[u8], + deadline: Instant, + rng: &mut R, +) -> Option { + if devices.len() == 1 && devices[0].option("uv").is_some() { + // A lone CTAP2 device that reports its `uv` option is used as is; + // `sk_select_by_cred` skips the probe for it. + return devices.pop(); + } + let position = devices + .iter_mut() + .position(|device| holds_credential(device, application, key_handle, deadline, rng))?; + let selected = devices.swap_remove(position); + tracing::debug!("found key in {}", selected.path()); + Some(selected) +} + +/// `sk_select_by_touch`: with one device there is nothing to choose; with +/// several, every device is asked to wait for a touch and the first one +/// that reports it wins. The others are cancelled. Each polling round +/// starts after a [`Timing::poll_sleep`] pause, as `sk_touch_poll` does. +fn select_by_touch( + mut devices: Vec, + timing: &Timing, + deadline: Instant, +) -> Result, CtapError> { + if devices.len() < 2 { + return Ok(devices.pop()); + } + let mut polling = Vec::with_capacity(devices.len()); + for mut device in devices { + match device.touch_begin() { + Ok(()) => polling.push(device), + Err(error) => tracing::debug!("{}: touch begin failed: {error}", device.path()), + } + } + if polling.is_empty() { + return Err(CtapError::Transport( + "no attached device accepted a touch request".to_string(), + )); + } + let deadline = deadline.min(Instant::now() + timing.select_timeout); + let mut selected = None; + 'select: while Instant::now() + POLL_INTERVAL <= deadline && !polling.is_empty() { + std::thread::sleep( + timing + .poll_sleep + .min(deadline.saturating_duration_since(Instant::now())), + ); + let mut index = 0; + while index < polling.len() { + match polling[index].touch_status(POLL_INTERVAL) { + Ok(UserPresence::Touched) => { + selected = Some(polling.swap_remove(index)); + break 'select; + } + Ok(UserPresence::Waiting) => index += 1, + Err(error) => { + tracing::debug!("{}: touch poll failed: {error}", polling[index].path()); + polling.swap_remove(index); + } + } + } + } + for mut device in polling { + device.cancel(); + } + if selected.is_none() { + tracing::debug!("timeout waiting for a touch to select a device"); + } + Ok(selected) +} diff --git a/src/ssh/security_key/internal/selection_tests.rs b/src/ssh/security_key/internal/selection_tests.rs new file mode 100644 index 00000000..8c8c27d6 --- /dev/null +++ b/src/ssh/security_key/internal/selection_tests.rs @@ -0,0 +1,429 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Device selection, PIN, U2F fallback and error mapping of the built-in +//! provider over the mock transport. + +use std::sync::Arc; + +use super::mock::{ + Credential, CredentialKey, Fido2Config, MockAuthenticator, MockBackend, Protocol, Request, +}; +use super::tests::{ + APPLICATION, KEY_HANDLE, credential, fido2, provider_with, public_key, request, verify, +}; +use super::*; +use crate::ssh::security_key::encode_signature; + +#[test] +fn the_device_holding_the_credential_is_selected_among_several() { + let mut other = Credential { + rp_id: APPLICATION.to_string(), + id: vec![0xde, 0xad], + key: CredentialKey::ed25519(5), + }; + other.id.push(0x01); + let stranger = MockAuthenticator::new(Protocol::Fido2(Fido2Config::plain()), Some(other)); + let (provider, devices) = provider_with(vec![ + ("/dev/hidraw1", stranger), + ( + "/dev/hidraw2", + fido2(Fido2Config::plain(), Some(CredentialKey::ed25519(1))), + ), + ]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap(); + verify( + &public_key(&devices[1].lock().unwrap()), + &encode_signature(SkAlgorithm::Ed25519, &response).unwrap(), + ); + // The stranger only saw the silent probe (up: false), never the real + // request; the holder saw the probe and then the signature request. + let stranger = devices[0].lock().unwrap(); + assert_eq!( + stranger.assertion_requests(), + [&Request::GetAssertion { + rp_id: APPLICATION.to_string(), + credential_ids: vec![KEY_HANDLE.to_vec()], + up: Some(false), + uv: None, + pin_auth: None, + }] + ); + let holder = devices[1].lock().unwrap(); + let requests = holder.assertion_requests(); + assert_eq!(requests.len(), 2); + assert!(matches!( + requests[0], + Request::GetAssertion { + up: Some(false), + .. + } + )); + assert!(matches!( + requests[1], + Request::GetAssertion { up: Some(true), .. } + )); +} + +#[test] +fn a_lone_fido2_device_reporting_uv_is_used_without_a_probe() { + let mut config = Fido2Config::plain(); + config.options.insert("uv".to_string(), false); + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2(config, Some(CredentialKey::ed25519(1))), + )]); + provider + .sign(&request(SkAlgorithm::Ed25519, 0x00), None) + .unwrap(); + let device = devices[0].lock().unwrap(); + let requests = device.assertion_requests(); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert!(matches!( + requests[0], + Request::GetAssertion { + up: Some(false), + uv: None, + .. + } + )); +} + +#[test] +fn no_touch_required_keys_ask_for_no_presence() { + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2(Fido2Config::plain(), Some(CredentialKey::ed25519(1))), + )]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x00), None) + .unwrap(); + assert_eq!(response.flags, 0x00); + let device = devices[0].lock().unwrap(); + assert!(matches!( + device.assertion_requests()[1], + Request::GetAssertion { + up: Some(false), + .. + } + )); +} + +#[test] +fn verify_required_without_a_pin_needs_one_unless_the_device_verifies_itself() { + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2(Fido2Config::plain(), Some(CredentialKey::ed25519(1))), + )]); + assert_eq!( + provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), None) + .unwrap_err(), + SecurityKeyError::PinRequired + ); + assert!( + devices[0].lock().unwrap().assertion_requests().is_empty(), + "nothing is sent before the PIN is known" + ); + + let mut biometric = Fido2Config::plain(); + biometric.options.insert("uv".to_string(), true); + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2(biometric, Some(CredentialKey::ed25519(1))), + )]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), None) + .unwrap(); + assert_eq!(response.flags, 0x05); + let device = devices[0].lock().unwrap(); + assert!(matches!( + device.assertion_requests()[0], + Request::GetAssertion { + up: Some(true), + uv: Some(true), + pin_auth: None, + .. + } + )); +} + +#[test] +fn a_pin_obtains_a_scoped_token_and_the_device_reports_verification() { + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2( + Fido2Config::with_pin("123456"), + Some(CredentialKey::ed25519(1)), + ), + )]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), Some("123456")) + .unwrap(); + assert_eq!(response.flags, 0x05); + verify( + &public_key(&devices[0].lock().unwrap()), + &encode_signature(SkAlgorithm::Ed25519, &response).unwrap(), + ); + let device = devices[0].lock().unwrap(); + let pin_requests = device + .requests + .iter() + .filter(|request| matches!(request, Request::ClientPin { .. })) + .collect::>(); + assert_eq!( + pin_requests, + [ + &Request::ClientPin { + subcommand: 0x02, + protocol: 2, + permissions: None, + }, + &Request::ClientPin { + subcommand: 0x09, + protocol: 2, + permissions: Some((0x02, APPLICATION.to_string())), + }, + ] + ); + assert!(matches!( + device.assertion_requests()[0], + Request::GetAssertion { + uv: None, + pin_auth: Some((_, 2)), + .. + } + )); +} + +#[test] +fn a_ctap_2_0_device_gets_an_unscoped_protocol_1_token() { + let mut config = Fido2Config::with_pin("123456"); + config.options.remove("pinUvAuthToken"); + config.pin_protocols = vec![1]; + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2(config, Some(CredentialKey::p256(2))), + )]); + let response = provider + .sign( + &request(SkAlgorithm::EcdsaSha2NistP256, 0x05), + Some("123456"), + ) + .unwrap(); + assert_eq!(response.flags, 0x05); + let device = devices[0].lock().unwrap(); + assert!(device.requests.contains(&Request::ClientPin { + subcommand: 0x05, + protocol: 1, + permissions: None, + })); + assert!(matches!( + device.assertion_requests()[0], + Request::GetAssertion { + pin_auth: Some((param, 1)), + .. + } if param.len() == 16 + )); +} + +#[test] +fn a_wrong_pin_is_reported_as_pin_required() { + let (provider, _) = provider_with(vec![( + "/dev/hidraw1", + fido2( + Fido2Config::with_pin("123456"), + Some(CredentialKey::ed25519(1)), + ), + )]); + assert_eq!( + provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), Some("654321")) + .unwrap_err(), + SecurityKeyError::PinRequired + ); +} + +#[test] +fn u2f_devices_sign_ecdsa_after_presence_retries_and_cannot_hold_ed25519() { + let mut u2f = MockAuthenticator::new(Protocol::U2f, Some(credential(CredentialKey::p256(3)))); + u2f.u2f_presence_retries = 2; + let (provider, devices) = provider_with(vec![("/dev/hidraw1", u2f)]); + let response = provider + .sign(&request(SkAlgorithm::EcdsaSha2NistP256, 0x01), None) + .unwrap(); + assert_eq!(response.flags, 0x01); + verify( + &public_key(&devices[0].lock().unwrap()), + &encode_signature(SkAlgorithm::EcdsaSha2NistP256, &response).unwrap(), + ); + let device = devices[0].lock().unwrap(); + assert_eq!( + device.requests, + [ + Request::U2fCheck, + Request::U2fCheck, + Request::U2fSign, + Request::U2fSign, + Request::U2fSign, + ], + "probe lookup, then lookup and sign with two presence retries" + ); + drop(device); + + assert_eq!( + provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap_err(), + SecurityKeyError::Unsupported + ); + assert_eq!( + provider + .sign(&request(SkAlgorithm::EcdsaSha2NistP256, 0x05), Some("1234")) + .unwrap_err(), + SecurityKeyError::Unsupported + ); +} + +#[test] +fn missing_devices_and_credentials_are_device_not_found() { + let (provider, _) = provider_with(vec![]); + assert_eq!( + provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap_err(), + SecurityKeyError::DeviceNotFound + ); + let (provider, devices) = provider_with(vec![ + ("/dev/hidraw1", fido2(Fido2Config::plain(), None)), + ( + "/dev/hidraw2", + MockAuthenticator::new(Protocol::U2f, Some(credential(CredentialKey::p256(9)))), + ), + ]); + let mut strange = request(SkAlgorithm::EcdsaSha2NistP256, 0x01); + strange.key_handle = &[0x99]; + assert_eq!( + provider.sign(&strange, None).unwrap_err(), + SecurityKeyError::DeviceNotFound + ); + assert_eq!(devices[1].lock().unwrap().requests, [Request::U2fCheck]); +} + +#[test] +fn devices_that_cannot_be_opened_are_reported_with_the_reason() { + let mut backend = MockBackend::default(); + backend.add( + "/dev/hidraw1", + fido2(Fido2Config::plain(), Some(CredentialKey::ed25519(1))), + ); + backend.unopenable.push("/dev/hidraw1".to_string()); + let provider = InternalProvider::with_backend(Arc::new(backend)); + let error = provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap_err(); + match error { + SecurityKeyError::Failed(reason) => { + assert!( + reason.contains("1 FIDO device(s) found but none could be opened"), + "{reason}" + ); + assert!(reason.contains("Permission denied"), "{reason}"); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn keepalives_during_the_assertion_are_tolerated() { + let mut slow = fido2(Fido2Config::plain(), Some(CredentialKey::ed25519(1))); + slow.keepalives_before_reply = 3; + let (provider, devices) = provider_with(vec![("/dev/hidraw1", slow)]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x01), None) + .unwrap(); + verify( + &public_key(&devices[0].lock().unwrap()), + &encode_signature(SkAlgorithm::Ed25519, &response).unwrap(), + ); +} + +#[test] +fn with_a_pin_and_several_devices_the_touched_one_is_chosen_and_the_rest_cancelled() { + let mut untouched = fido2( + Fido2Config::with_pin("123456"), + Some(CredentialKey::ed25519(7)), + ); + untouched.touch_on_probe = false; + let (provider, devices) = provider_with(vec![ + ("/dev/hidraw1", untouched), + ( + "/dev/hidraw2", + fido2( + Fido2Config::with_pin("123456"), + Some(CredentialKey::ed25519(1)), + ), + ), + ]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), Some("123456")) + .unwrap(); + verify( + &public_key(&devices[1].lock().unwrap()), + &encode_signature(SkAlgorithm::Ed25519, &response).unwrap(), + ); + let untouched = devices[0].lock().unwrap(); + assert_eq!( + untouched.requests, + [Request::GetInfo, Request::TouchProbe, Request::Cancel] + ); + let touched = devices[1].lock().unwrap(); + assert_eq!(touched.cancels, 0); + assert!(touched.requests.contains(&Request::TouchProbe)); +} + +#[test] +fn a_u2f_device_among_several_is_selected_by_touch_too() { + let mut untouched = MockAuthenticator::new(Protocol::U2f, None); + untouched.touch_on_probe = false; + let (provider, devices) = provider_with(vec![ + ("/dev/hidraw1", untouched), + ( + "/dev/hidraw2", + fido2( + Fido2Config::with_pin("123456"), + Some(CredentialKey::ed25519(1)), + ), + ), + ]); + provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), Some("123456")) + .unwrap(); + let untouched = devices[0].lock().unwrap(); + // Re-sent REGISTER probes (P1 0, as libfido2 sends them) while it kept + // answering "not touched", and no CANCEL once the other device was + // chosen: libfido2 sends CANCEL to CTAP2 devices only. + assert!( + !untouched.requests.is_empty() + && untouched + .requests + .iter() + .all(|request| *request == Request::U2fRegister { p1: 0 }), + "{:?}", + untouched.requests + ); + assert_eq!(untouched.cancels, 0); +} diff --git a/src/ssh/security_key/internal/u2f.rs b/src/ssh/security_key/internal/u2f.rs new file mode 100644 index 00000000..27a7eb3b --- /dev/null +++ b/src/ssh/security_key/internal/u2f.rs @@ -0,0 +1,115 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! CTAP1/U2F raw messages (FIDO U2F Raw Message Formats), carried in +//! `CTAPHID_MSG` as extended-length ISO 7816-4 APDUs, for authenticators +//! that predate CTAP2. Only ECDSA P-256 keys exist in U2F. + +use super::CtapError; + +pub const INS_REGISTER: u8 = 0x01; +pub const INS_AUTHENTICATE: u8 = 0x02; + +/// `P1` of AUTHENTICATE: check whether the key handle belongs to the +/// application without signing. +pub const P1_CHECK_ONLY: u8 = 0x07; +/// `P1` of AUTHENTICATE: sign, requiring user presence. +pub const P1_ENFORCE_USER_PRESENCE: u8 = 0x03; + +pub const SW_NO_ERROR: u16 = 0x9000; +/// The key handle is valid, but the user has not touched the device yet. +pub const SW_CONDITIONS_NOT_SATISFIED: u16 = 0x6985; +/// The key handle does not belong to this device or application. +pub const SW_WRONG_DATA: u16 = 0x6a80; + +/// Largest key handle a one-byte length field can carry. +const MAX_KEY_HANDLE_LEN: usize = 255; + +/// Build an extended-length APDU: `CLA INS P1 P2 00 Lc(2) data Le(2)`. +#[must_use] +pub fn encode_apdu(ins: u8, p1: u8, data: &[u8]) -> Vec { + let mut apdu = Vec::with_capacity(9 + data.len()); + apdu.extend_from_slice(&[0x00, ins, p1, 0x00, 0x00]); + apdu.extend_from_slice(&(data.len() as u16).to_be_bytes()); + apdu.extend_from_slice(data); + apdu.extend_from_slice(&[0x00, 0x00]); + apdu +} + +/// U2F AUTHENTICATE request data: +/// `challenge(32) || application(32) || len(1) || key handle`. +pub fn encode_authenticate( + p1: u8, + challenge: &[u8; 32], + application: &[u8; 32], + key_handle: &[u8], +) -> Result, CtapError> { + if key_handle.is_empty() || key_handle.len() > MAX_KEY_HANDLE_LEN { + return Err(CtapError::Unsupported( + "key handle length is not valid for U2F", + )); + } + let mut data = Vec::with_capacity(65 + key_handle.len()); + data.extend_from_slice(challenge); + data.extend_from_slice(application); + data.push(key_handle.len() as u8); + data.extend_from_slice(key_handle); + Ok(encode_apdu(INS_AUTHENTICATE, p1, &data)) +} + +/// U2F REGISTER with dummy parameters, used only to make a device wait for a +/// touch during multi-device selection. `P1` is 0, as in libfido2's +/// `u2f_get_touch_begin`. +#[must_use] +pub fn encode_touch_probe() -> Vec { + encode_apdu(INS_REGISTER, 0x00, &[0u8; 64]) +} + +/// Split a response into its data and status word. +pub fn split_status(response: &[u8]) -> Result<(&[u8], u16), CtapError> { + if response.len() < 2 { + return Err(CtapError::Protocol( + "U2F response shorter than a status word", + )); + } + let (data, sw) = response.split_at(response.len() - 2); + Ok((data, u16::from_be_bytes([sw[0], sw[1]]))) +} + +/// A successful AUTHENTICATE response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Authentication { + /// The user-presence byte (bit 0 set when the user touched the device). + pub presence: u8, + pub counter: u32, + /// DER-encoded ECDSA signature. + pub signature_der: Vec, +} + +/// Parse the data part of an AUTHENTICATE response: +/// `presence(1) || counter(4) || DER signature`. +pub fn parse_authenticate(data: &[u8]) -> Result { + if data.len() < 6 { + return Err(CtapError::Protocol("U2F authentication response too short")); + } + Ok(Authentication { + presence: data[0], + counter: u32::from_be_bytes(data[1..5].try_into().expect("four bytes")), + signature_der: data[5..].to_vec(), + }) +} + +#[cfg(test)] +#[path = "u2f_tests.rs"] +mod tests; diff --git a/src/ssh/security_key/internal/u2f_tests.rs b/src/ssh/security_key/internal/u2f_tests.rs new file mode 100644 index 00000000..f2c42f8c --- /dev/null +++ b/src/ssh/security_key/internal/u2f_tests.rs @@ -0,0 +1,78 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +#[test] +fn apdus_use_extended_length_encoding() { + let apdu = encode_apdu(INS_AUTHENTICATE, P1_CHECK_ONLY, &[0xaa, 0xbb, 0xcc]); + assert_eq!( + apdu, + [ + 0x00, 0x02, 0x07, 0x00, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc, 0x00, 0x00 + ] + ); + let big = encode_apdu(INS_REGISTER, P1_ENFORCE_USER_PRESENCE, &[0; 300]); + assert_eq!(&big[..7], &[0x00, 0x01, 0x03, 0x00, 0x00, 0x01, 0x2c]); + assert_eq!(big.len(), 7 + 300 + 2); +} + +#[test] +fn authenticate_requests_carry_challenge_application_and_key_handle() { + let challenge = [0x11; 32]; + let application = [0x22; 32]; + let apdu = encode_authenticate( + P1_ENFORCE_USER_PRESENCE, + &challenge, + &application, + &[9, 8, 7], + ) + .unwrap(); + assert_eq!(&apdu[..7], &[0x00, 0x02, 0x03, 0x00, 0x00, 0x00, 68]); + assert_eq!(&apdu[7..39], &challenge); + assert_eq!(&apdu[39..71], &application); + assert_eq!(&apdu[71..75], &[3, 9, 8, 7]); + assert_eq!(&apdu[75..], &[0, 0]); + assert!(encode_authenticate(P1_CHECK_ONLY, &challenge, &application, &[]).is_err()); + assert!(encode_authenticate(P1_CHECK_ONLY, &challenge, &application, &[1; 256]).is_err()); + assert!(encode_authenticate(P1_CHECK_ONLY, &challenge, &application, &[1; 255]).is_ok()); + let probe = encode_touch_probe(); + assert_eq!(probe[1], INS_REGISTER); + assert_eq!(probe[2], 0, "P1 0, as libfido2's u2f_get_touch_begin sends"); + assert_eq!(probe.len(), 7 + 64 + 2); +} + +#[test] +fn responses_split_into_data_and_status_word() { + assert_eq!( + split_status(&[0x69, 0x85]).unwrap(), + (&[][..], SW_CONDITIONS_NOT_SATISFIED) + ); + assert_eq!( + split_status(&[1, 2, 0x6a, 0x80]).unwrap(), + (&[1u8, 2][..], SW_WRONG_DATA) + ); + assert!(split_status(&[0x90]).is_err()); +} + +#[test] +fn authentication_responses_parse_presence_counter_and_der_signature() { + let mut data = vec![0x01, 0x00, 0x00, 0x01, 0x02]; + data.extend_from_slice(&[0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); + let auth = parse_authenticate(&data).unwrap(); + assert_eq!(auth.presence, 0x01); + assert_eq!(auth.counter, 0x0102); + assert_eq!(auth.signature_der, data[5..]); + assert!(parse_authenticate(&data[..5]).is_err()); +} diff --git a/src/ssh/security_key/mod.rs b/src/ssh/security_key/mod.rs index c75fd35f..bb732794 100644 --- a/src/ssh/security_key/mod.rs +++ b/src/ssh/security_key/mod.rs @@ -17,13 +17,13 @@ //! OpenSSH signs with authenticator-hosted keys through a middleware library //! that implements `sk-api.h` (`sk_api_version`, `sk_sign`, ...). bssh loads //! the same libraries so an identity enrolled with `ssh-keygen -t ed25519-sk` -//! works unchanged. The narrow [`SecurityKeyProvider`] trait is the seam a -//! built-in USB HID provider plugs into later: `SecurityKeyProvider internal` -//! resolves to [`ProviderSource::Internal`] and [`open_provider`] is the only -//! place that needs to learn about it. +//! works unchanged, and ships the built-in USB HID provider (`internal`, the +//! default, as in OpenSSH built with `ENABLE_SK_INTERNAL`) behind the same +//! narrow [`SecurityKeyProvider`] trait: [`open_provider`] is the only place +//! that distinguishes the two. //! -//! The provider runs in-process (`dlopen`) rather than through an -//! `ssh-sk-helper`-style subprocess. OpenSSH's helper keeps the middleware +//! Both providers run in-process (`dlopen`, or the in-tree CTAPHID stack in +//! [`internal`]) rather than through an `ssh-sk-helper`-style subprocess. OpenSSH's helper keeps the middleware //! out of its `pledge`d client and shares one implementation between `ssh`, //! `ssh-agent`, and `ssh-keygen`; bssh has no such sandbox, and a re-exec //! helper would add a private wire protocol without changing what code runs @@ -42,6 +42,7 @@ //! hosts. Only configure a provider library you would trust with them. mod external; +pub mod internal; mod signature; use std::fmt; @@ -59,9 +60,10 @@ pub const SK_FLAG_USER_PRESENCE_REQUIRED: u8 = 0x01; /// `SSH_SK_USER_VERIFICATION_REQD`: the authenticator wants a PIN or biometric. pub const SK_FLAG_USER_VERIFICATION_REQUIRED: u8 = 0x04; -/// The ssh_config default for `SecurityKeyProvider` when no built-in provider -/// exists (`readconf.c`): a `$`-prefixed environment variable name. -pub const DEFAULT_PROVIDER_SETTING: &str = "$SSH_SK_PROVIDER"; +/// The ssh_config default for `SecurityKeyProvider`: OpenSSH built with +/// `ENABLE_SK_INTERNAL` defaults to its built-in provider (`readconf.c`), and +/// so does bssh. A `$`-prefixed value names an environment variable instead. +pub const DEFAULT_PROVIDER_SETTING: &str = "internal"; /// Key algorithms the sk-api distinguishes (`SSH_SK_ECDSA`, `SSH_SK_ED25519`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -116,7 +118,7 @@ impl SkSignRequest<'_> { pub enum ProviderSource { /// No provider: unset environment variable or `none`. Disabled, - /// `internal`: the built-in provider, which this build does not include. + /// `internal`: the built-in USB HID provider ([`internal`]). Internal, /// A shared library implementing `sk-api.h`. Library(PathBuf), @@ -128,7 +130,7 @@ pub enum SecurityKeyError { #[error("no SecurityKeyProvider has been specified")] NoProvider, #[error( - "built-in security key support is not available in this build; set SecurityKeyProvider to an sk-api library" + "built-in security key support is not available on this platform; set SecurityKeyProvider to an sk-api library" )] InternalUnavailable, #[error("Provider \"{path}\" dlopen failed: {reason}")] @@ -151,6 +153,9 @@ pub enum SecurityKeyError { Unsupported, #[error("sk_sign failed with code {0}")] SignFailed(i32), + /// The built-in provider failed with a described reason. + #[error("sk_sign failed: {0}")] + Failed(String), #[error("sk_sign response invalid: {0}")] InvalidResponse(&'static str), #[error("sk_sign request invalid: {0}")] @@ -235,11 +240,22 @@ pub fn open_provider( ) -> Result, SecurityKeyError> { match source { ProviderSource::Disabled => Err(SecurityKeyError::NoProvider), - ProviderSource::Internal => Err(SecurityKeyError::InternalUnavailable), + ProviderSource::Internal => open_internal(), ProviderSource::Library(path) => open_library(path), } } +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn open_internal() -> Result, SecurityKeyError> { + Ok(Box::new(internal::InternalProvider::new())) +} + +/// The HID transport is only wired up for Linux (hidraw) and macOS (IOKit). +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn open_internal() -> Result, SecurityKeyError> { + Err(SecurityKeyError::InternalUnavailable) +} + fn open_library(path: &Path) -> Result, SecurityKeyError> { external::ExternalProvider::load(path) .map(|provider| Box::new(provider) as Box) @@ -272,12 +288,26 @@ mod tests { } #[test] - fn default_setting_follows_the_ssh_sk_provider_variable() { + fn default_setting_is_the_internal_provider() { + assert_eq!(DEFAULT_PROVIDER_SETTING, "internal"); + assert_eq!(resolve_provider(None, no_env), ProviderSource::Internal); + // The environment variable OpenSSH-without-internal consults is not + // read unless the setting names it. assert_eq!( resolve_provider(None, env_with("SSH_SK_PROVIDER", "/opt/sk.so")), + ProviderSource::Internal + ); + assert_eq!( + resolve_provider( + Some("$SSH_SK_PROVIDER"), + env_with("SSH_SK_PROVIDER", "/opt/sk.so") + ), ProviderSource::Library(PathBuf::from("/opt/sk.so")) ); - assert_eq!(resolve_provider(None, no_env), ProviderSource::Disabled); + assert_eq!( + resolve_provider(Some("$SSH_SK_PROVIDER"), no_env), + ProviderSource::Disabled + ); } #[test] @@ -325,6 +355,18 @@ mod tests { ); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn internal_provider_opens_without_touching_any_device() { + let provider = open_provider(&ProviderSource::Internal).unwrap(); + assert_eq!(provider.describe(), "internal"); + assert_eq!( + open_provider(&ProviderSource::Disabled).unwrap_err(), + SecurityKeyError::NoProvider + ); + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] #[test] fn internal_provider_reports_an_explicit_unavailable_diagnostic() { let error = open_provider(&ProviderSource::Internal).unwrap_err(); @@ -334,10 +376,6 @@ mod tests { .to_string() .contains("built-in security key support is not available") ); - assert_eq!( - open_provider(&ProviderSource::Disabled).unwrap_err(), - SecurityKeyError::NoProvider - ); } #[test] diff --git a/src/ssh/ssh_config/dump_tests.rs b/src/ssh/ssh_config/dump_tests.rs index 29ff3754..d82dc1f8 100644 --- a/src/ssh/ssh_config/dump_tests.rs +++ b/src/ssh/ssh_config/dump_tests.rs @@ -239,7 +239,7 @@ fn default_dump_has_the_audited_full_keyword_shape() { "logverbose none", "obscurekeystroketiming yes", "revokedhostkeys none", - "securitykeyprovider $SSH_SK_PROVIDER", + "securitykeyprovider internal", "streamlocalbindmask 0177", "streamlocalbindunlink no", "tunnel false", diff --git a/src/ssh/tokio_client/auth_policy.rs b/src/ssh/tokio_client/auth_policy.rs index e89d502c..36787816 100644 --- a/src/ssh/tokio_client/auth_policy.rs +++ b/src/ssh/tokio_client/auth_policy.rs @@ -35,7 +35,7 @@ pub struct SshAuthenticationPolicy { pub batch_mode: bool, pub pubkey_accepted_algorithms: Option>, /// Raw `SecurityKeyProvider` value (`$VAR` unexpanded); `None` is the - /// OpenSSH default `$SSH_SK_PROVIDER`. + /// OpenSSH default (`internal`). pub security_key_provider: Option, } diff --git a/src/ssh/tokio_client/authentication.rs b/src/ssh/tokio_client/authentication.rs index 1379bcad..de533fb0 100644 --- a/src/ssh/tokio_client/authentication.rs +++ b/src/ssh/tokio_client/authentication.rs @@ -52,7 +52,7 @@ pub enum AuthMethod { allow_passphrase_prompt: bool, use_keychain: bool, /// `SecurityKeyProvider` for `sk-*` identities; `None` is the - /// OpenSSH default (`$SSH_SK_PROVIDER`). + /// OpenSSH default (`internal`). security_key_provider: Option, }, OpenSshCertificateFile { @@ -63,7 +63,7 @@ pub enum AuthMethod { key_pass: Option>, accepted_algorithms: Vec, /// `SecurityKeyProvider` for `sk-*` identities; `None` is the - /// OpenSSH default (`$SSH_SK_PROVIDER`). + /// OpenSSH default (`internal`). security_key_provider: Option, }, PasswordPrompt { diff --git a/src/ssh/tokio_client/security_key_signer.rs b/src/ssh/tokio_client/security_key_signer.rs index c621473d..bd5f06a8 100644 --- a/src/ssh/tokio_client/security_key_signer.rs +++ b/src/ssh/tokio_client/security_key_signer.rs @@ -124,12 +124,7 @@ pub(super) async fn open_security_key_provider( ); return Err(unavailable(SecurityKeyError::NoProvider.to_string())); } - ProviderSource::Internal => { - let error = SecurityKeyError::InternalUnavailable; - crate::warningln!("SecurityKeyProvider internal: {error}"); - return Err(unavailable(error.to_string())); - } - ProviderSource::Library(_) => {} + ProviderSource::Internal | ProviderSource::Library(_) => {} } let opened = tokio::task::spawn_blocking(move || open_provider(&source)) .await @@ -594,6 +589,42 @@ mod tests { assert!(error.contains("device not found"), "{error}"); } + #[tokio::test] + async fn an_internal_provider_past_its_deadline_fails_the_signature() { + use crate::ssh::security_key::internal::mock::{ + Credential, CredentialKey, Fido2Config, Flood, MockAuthenticator, MockBackend, Protocol, + }; + use crate::ssh::security_key::internal::{InternalProvider, Timing}; + + let mut config = Fido2Config::plain(); + config.options.insert("uv".to_string(), false); + let credential = Credential { + rp_id: "ssh:example".to_string(), + id: vec![1, 2, 3, 4], + key: CredentialKey::ed25519(1), + }; + let mut stalled = MockAuthenticator::new(Protocol::Fido2(config), Some(credential)); + stalled.stall = Some((Flood::Keepalive, std::time::Duration::from_secs(3))); + let mut backend = MockBackend::default(); + backend.add("/dev/hidraw1", stalled); + let provider = InternalProvider::with_backend(Arc::new(backend)).with_timing(Timing { + sign_timeout: std::time::Duration::from_millis(200), + ..Timing::default() + }); + let key = sk_ed25519_key(0x01); + let identity = SecurityKeyIdentity::from_private_key(&key, Path::new("/id_sk")).unwrap(); + let signer = SecurityKeySigner::new(Arc::new(provider), identity, true); + // Twice: the second call gets the provider lock back from the first. + for _ in 0..2 { + let error = signer.sign_blob(vec![1]).await.unwrap_err().to_string(); + assert!(error.contains("/id_sk"), "{error}"); + assert!( + error.contains("timed out waiting for the security key"), + "{error}" + ); + } + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn provider_calls_never_overlap_across_signers() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -787,8 +818,9 @@ mod tests { drop(runtime); } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[tokio::test] - async fn disabled_and_internal_providers_are_skipped_before_offering_the_key() { + async fn disabled_providers_are_skipped_and_internal_opens_before_offering_the_key() { let path = Path::new("/home/u/.ssh/id_ed25519_sk"); let error = open_security_key_provider(Some("none"), path) .await @@ -799,15 +831,14 @@ mod tests { )); assert!(super::super::authentication::is_retryable_rejection(&error)); - let error = open_security_key_provider(Some("internal"), path) + // `internal` (and the unset default) opens the built-in provider + // without touching any device; devices are looked up at sign time. + let provider = open_security_key_provider(Some("internal"), path) .await - .unwrap_err(); - let message = error.to_string(); - assert!( - message.contains("built-in security key support is not available"), - "{message}" - ); - assert!(message.contains("id_ed25519_sk"), "{message}"); + .unwrap(); + assert_eq!(provider.describe(), "internal"); + let provider = open_security_key_provider(None, path).await.unwrap(); + assert_eq!(provider.describe(), "internal"); let error = open_security_key_provider(Some("/nonexistent/provider.so"), path) .await