镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

feat(ssh): add the built-in internal SecurityKeyProvider over USB HID - #334

Merged
inureyes merged 3 commits into
mainfrom
feature/issue-331-internal-security-key-provider
Oct 22, 2026
Merged

inureyes merged 3 commits into
mainfrom
feature/issue-331-internal-security-key-provider

Conversation

@inureyes

@inureyes inureyes commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Summary

  • SecurityKeyProvider internal signs with attached FIDO2 and U2F authenticators over USB HID for sk-ssh-ed25519@openssh.com and sk-ecdsa-sha2-nistp256@openssh.com identities on Linux and macOS, and it is now the default (OpenSSH built with ENABLE_SK_INTERNAL, as distribution builds are: Ubuntu's OpenSSH 9.6p1 prints securitykeyprovider internal). $SSH_SK_PROVIDER and library paths keep working when configured; other platforms report that the built-in provider is unavailable.
  • The implementation follows sk-usbhid.c without libfido2 (src/ssh/security_key/internal/): CTAPHID framing and channels over a HidDevice/HidBackend trait, canonical CBOR, CTAP2 getInfo/getAssertion/clientPIN with PIN protocols 1 and 2 (permission-scoped tokens on CTAP 2.1 devices), U2F check-only and enforce-presence fallback (ECDSA only, no PIN), and sk_probe device selection: silent per-device assertion to find the credential holder, selection by touch when a PIN or verify-required is involved. Flags and statuses map as in sk_sign and fidoerr_to_skerr, and the packed response matches the external provider path byte for byte.
  • open_provider is the only integration point; the signer's process-wide FIFO lock, "Confirm user presence" notice, PIN prompt and decline-and-continue behavior are reused. HID devices are enumerated only after the server accepts an sk-* key. PINs, PIN hashes, shared secrets and tokens are Zeroizing and never logged.

Changes during review

  • Touch selection counts only the replies libfido2 counts (OK, PIN_AUTH_INVALID, PIN_INVALID, PIN_NOT_SET), so a device that answers immediately with another status cannot win selection and receive the PIN exchange.
  • Enumerated devices are deduplicated by path and capped at 8 (MAX_FIDO_DEVICES), since hidapi lists one entry per top-level usage.
  • One internal signature is bounded at 120 s overall, with CANCEL on expiry. This deliberately deviates from libfido2's unbounded wait: bssh shares one provider lock across all hosts, so a stuck or hostile device would otherwise block every later sk-* signature in the run. Documented in the compatibility status and man page.
  • Smaller parity fixes: 200 ms pause between touch polling rounds, U2F probe P1 = 0 and no CANCEL to U2F-only devices, a fresh channel after a getInfo timeout, one hidapi context per enumeration, hkdf crate for PIN protocol 2, pinUvAuthParam redacted from Debug, and non-exclusive device open on macOS (macos-shared-device).

Dependency choice

  • hidapi 2.6.7 with default features off: linux-native-basic-udev on Linux is pure Rust (no libudev) and cargo check -p hidapi --target aarch64-unknown-linux-musl passes; on macOS it compiles its vendored IOKit backend and links only system frameworks. No change to debian/control, the PPA rules or the Homebrew formula; the Linux release binary still links only libc, libm and libgcc_s. p256, aes, cbc and hkdf were already in the lock.
  • Rejected: ctap-hid-fido2 (pins hidapi's C linux-static-hidraw backend, which needs libudev, and adds ring), and libfido2 bindings.

Test plan

  • Mock-transport tests cover CTAPHID framing and channels, CBOR, getAssertion/clientPIN/getInfo, both PIN protocols, U2F APDUs, selection by credential and by touch, PIN and verify-required flows, the deadline, device dedupe and cap, and the end-to-end signature for both key types verified with ssh-key; each group fails when its behavior is reverted.
  • Full cargo fmt --check, cargo clippy -- -D warnings, cargo test --lib (1911 passed), cargo test --tests -- --skip integration_test, and cargo build --locked --release pass locally on 0b26a7e (the last code commit).
  • OpenSSH regress locally: hostkey-agent, keytype, limit-keytype, agent, connect, proxy-connect, sshcfgparse, hostkey-rotate, rekey pass; CI regress on Linux and macOS (the macOS job builds bssh with the IOKit backend) shows no new failures.
  • A manual check with a physical authenticator on Linux and macOS is pending user validation; no authenticator exists on this host or in CI.
  • musl: only the new crates were checked for the musl target here (aws-lc-sys needs a musl cross compiler); the next release workflow run is the first full musl build.

Follow-ups: key enrollment (ssh-keygen -t ed25519-sk) and resident keys (ssh-keygen -K, ssh-add -K), and UV tokens for sensor-only devices (getPinUvAuthTokenUsingUv).

Closes #331

`SecurityKeyProvider internal` now signs with attached FIDO authenticators instead of printing the #328 "not yet supported" diagnostic, and it becomes the default, as in OpenSSH 10.3p1 built with ENABLE_SK_INTERNAL (distribution builds, `ssh -G` prints `securitykeyprovider internal`). The `$SSH_SK_PROVIDER` indirection still works when configured explicitly.

The provider is `sk-usbhid.c` without libfido2: an in-tree CTAPHID layer over a `HidDevice`/`HidBackend` trait, canonical CBOR, CTAP2 getInfo/getAssertion/clientPIN (PIN protocols 1 and 2), a U2F fallback for devices without CBOR, and `sk_probe`-style device selection (silent per-device assertion to find the credential holder, selection by touch when a PIN or user verification is involved). Key flags, status codes and the packed `sig_r`/`sig_s` follow `sk_sign` and `fidoerr_to_skerr`, so the response is byte-identical to the external provider path, and the provider plugs into `open_provider` reusing the signer's lock, prompts and decline-and-continue path.

hidapi (default features off) is the only new crate: its pure-Rust hidraw backend on Linux keeps the static musl builds working without libudev, and its IOKit backend on macOS links only system frameworks. p256, aes and cbc were already in the graph.

Validation: mock-transport tests for every layer and the end-to-end signature (verified with ssh-key), each failing when its behavior is reverted; fmt, clippy, the -G and query contract tests, and the hostkey-agent, keytype, limit-keytype, agent, connect and sshcfgparse regress tests pass. A check with a physical authenticator is pending.

Closes #331
@inureyes inureyes added type:enhancement New feature or request status:review Under review priority:medium Medium priority issue labels Oct 22, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Make SecurityKeyProvider internal sign sk-* identities over USB HID (CTAPHID, CTAP2 getAssertion and clientPIN, U2F fallback, sk_probe device selection) without a new system library, and make it the default.

Findings Addressed

No CRITICAL or HIGH findings, so nothing was changed on the branch.

Remaining Items

  • device.rs:296-311 touch_status counts any CTAP2 reply to the dummy makeCredential as a touch. libfido2 fido_dev_get_touch_status counts only SUCCESS, PIN_AUTH_INVALID, PIN_INVALID and PIN_NOT_SET, and sk_touch_poll drops a device that answers anything else. A device that answers OPERATION_DENIED or an immediate error is selected without being touched, and the touched device loses (MEDIUM)
  • select.rs:166-181 has no pause between polling rounds (sk_touch_poll sleeps POLL_SLEEP_NS, 200 ms). U2F devices answer at once, so the REGISTER probe is re-sent in a tight loop for up to 15 s (LOW)
  • pin_protocol.rs:284-302 reimplements HKDF even though hkdf 0.13 is already in Cargo.lock. Its previous buffer holds the derived 32-byte key in a Vec that is not zeroized (LOW)
  • The sign path has no deadline (mod.rs:170-181 passes None), so endless KEEPALIVE or foreign-CID frames keep the provider lock held. This matches libfido2's default infinite timeout and the documented hung-provider cost (LOW, informational)
  • Minor: no cap on enumerated devices (MAX_FIDO_DEVICES is 8); the U2F REGISTER probe uses P1 0x03 where libfido2 uses 0; CANCEL is sent to U2F-only devices; a timed-out getInfo leaves a stale reply in front of the U2F fallback (LOW)

Verification

  • All stated requirements implemented
  • No placeholder/mock code remaining
  • Integrated into project code flow
  • Project conventions followed
  • Existing modules reused where applicable (HKDF, see above)
  • No unintended structural changes
  • Tests pass (cargo test --lib security_key 85 passed, ssh_config_dump_test, ssh_compat_output_test, --bin bssh -- query, cargo fmt --check, cargo clippy --lib --tests -D warnings, cargo check -p hidapi --target aarch64-unknown-linux-musl)

@inureyes

Copy link
Copy Markdown
Member Author

Security and Performance Review

No CRITICAL or HIGH findings, so nothing was changed on the branch. The threat model was a malicious HID device on the FIDO usage page, a malicious server, and a hostile local environment.

Checked and sound

  • Device-supplied bytes: CTAPHID reassembly is capped at 7609 bytes, CBOR lengths are bounded by the input, depth is limited to 16, authData is checked to be at least 37 bytes before flags and counter are read, and the U2F and DER parsers check bounds. I found no panic or allocation driven by device input. A crafted reply cannot get bssh to accept a signature over other data, because the server verifies against the public key in the key file.
  • PIN material goes only to the selected device, over ECDH, and lives in Zeroizing memory. It does not appear in errors, logs, or Debug output. Probing sends only the key handle and application (the same as sk_select_by_cred) or the dummy touch request.
  • Default internal: HID is enumerated only after the server answers PK_OK for an sk-* key, since russh sends the unsigned probe first. Opening the provider touches no device. With no device, no permission, or no sysfs, the signature fails fast and authentication moves on to the next identity. hidapi opens with O_CLOEXEC, and every fd is dropped on its error path.

Remaining Items

  • select.rs:43-49 fido_devices neither deduplicates nor caps entries. hidapi emits one entry per top-level usage, so one hostile report descriptor can list the same /dev/hidrawN hundreds of times, and each entry costs up to 2 s INIT plus 2 s getInfo plus 5 s probe under the provider lock. Deduplicating by path and capping at MAX_FIDO_DEVICES (8) bounds this. OpenSSH waits forever on a silent device, so bssh is already better here (MEDIUM)
  • mod.rs:170-181 and device.rs:254-275: the sign path has no deadline. Endless KEEPALIVE or foreign-CID frames, or an untouched U2F key, keep the process-wide lock held for the rest of the run. libfido2 behaves the same, but in bssh one stuck device stalls every host. A generous internal deadline (about 120 s, the server's default LoginGraceTime) would cost real users nothing (MEDIUM)
  • The touch_status MEDIUM in the earlier review has one more consequence: the device that wins the race receives the PIN exchange, and that costs it a PIN retry. A malicious device can still win under libfido2's rule by answering PIN_AUTH_INVALID at once, so this stays MEDIUM.

Verification

  • cargo test --lib security_key: 85 passed

Apply the remaining review findings on the built-in internal SecurityKeyProvider so a misbehaving or hostile HID device cannot stall or steer signing.

- Touch selection counts only the CTAP2 statuses libfido2's fido_dev_get_touch_status accepts (success, PIN_AUTH_INVALID, PIN_INVALID, PIN_NOT_SET); any other reply drops the device, so it can no longer win the race and receive the PIN exchange. Polling rounds are paced 200 ms apart (POLL_SLEEP_NS).
- Enumerated devices are deduplicated by path and capped at MAX_FIDO_DEVICES (8).
- A whole internal sign call is bounded at 120 s, covering KEEPALIVE and foreign-channel streams and the U2F presence loop. On expiry the pending CTAP2 request is cancelled and the signature fails with "timed out waiting for the security key", so authentication moves on to the next identity. This deviates from libfido2's unbounded wait because bssh serializes provider calls across all hosts; the module doc, compatibility status and man page say so.
- U2F devices, including CBOR devices forced to U2F, never get CTAPHID CANCEL, and the REGISTER touch probe uses P1 0. A timed-out getInfo re-INITs the channel so its late reply cannot answer the first U2F request.
- PIN protocol 2 uses the hkdf crate (same hmac 0.13 generation) into Zeroizing buffers; GetAssertionRequest's Debug redacts pinUvAuthParam; hidapi keeps one context for enumeration and opens, and opens devices non-exclusively on macOS (macos-shared-device).

Each behavioral fix has a mock-transport test that fails with the fix reverted; Timing makes the waits injectable.

Refs #331
Describe what a user hits with SecurityKeyProvider internal: hidraw access on Linux usually needs a udev rule and the failure reads "N FIDO device(s) found but none could be opened", macOS opens devices non-exclusively, other platforms get the "not available on this platform" error, U2F-only keys cannot sign Ed25519 or take a PIN, and enrollment, resident keys and sensor-only UV tokens are unsupported.

Updated ARCHITECTURE.md, README.md, docs/man/bssh.1, docs/architecture/ssh-client.md and docs/openssh-compatibility-status.md. The 120 s deadline, eight-device cap and selection by credential or touch were already documented and match the code. No source changes.

Refs #331
@inureyes inureyes added status:done Completed and removed status:review Under review labels Oct 22, 2026
@inureyes
inureyes merged commit a12e5d5 into main Oct 22, 2026
5 checks passed
@inureyes
inureyes deleted the feature/issue-331-internal-security-key-provider branch October 22, 2026 05:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:medium Medium priority issue status:done Completed type:enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ssh): implement the built-in internal SecurityKeyProvider for USB HID authenticators

1 participant