From bd464c7f1ddcbae2169f0f88555d43ac1aff4baf Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Fri, 23 Oct 2026 00:05:52 +0900 Subject: [PATCH] docs: update security-key compatibility ledger Replace stale enrollment and resident-key limitations after PR #347 with the implemented behavior, remaining device requirements, and validation boundary. Refs #337 --- docs/openssh-compatibility-status.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/openssh-compatibility-status.md b/docs/openssh-compatibility-status.md index 1c2fa3f5..96ac6964 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 | `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 (#345), and `bssh-keygen` cannot enroll or list resident keys. | +| Security keys | `webauthn-sk-*` signatures and physical-authenticator validation | `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 (#345). `bssh-keygen` enrolls both SK key types and downloads resident credentials with `-K` through internal and external providers (#337); mock-device, native ABI, and OpenSSH key-file parsing tests cover these paths. | | 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 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 the CTAP 2.0 `uv` option or a `pinUvAuthParam` from a token obtained through `clientPIN` (PIN protocol 2 when advertised, else 1; PIN-based permission tokens use subcommand `0x09`, while CTAP 2.1 built-in verification uses `0x06` without a PIN hash), 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. 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 remaining WebAuthn difference is tracked in [#345](https://github.com/lablup/bssh/issues/345). +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 the CTAP 2.0 `uv` option or a `pinUvAuthParam` from a token obtained through `clientPIN` (PIN protocol 2 when advertised, else 1; PIN-based permission tokens use subcommand `0x09`, while CTAP 2.1 built-in verification uses `0x06` without a PIN hash), 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. `bssh-keygen -t ed25519-sk` and `bssh-keygen -t ecdsa-sk` enroll credentials, and `bssh-keygen -K` downloads resident keys through either provider. Internal resident enrollment requires PIN-authenticated credential management to avoid replacing an existing application/user credential; protected enrollment requires `credProtect`. Signed packed/U2F registration attestations are verified without a vendor trust policy; unsigned `none` attestations remain permitted as in OpenSSH. Serialized handles are limited to 255 bytes by ssh-key, and `-O write-attestation=FILE` is explicitly unsupported. See [bssh-keygen options](man/bssh-keygen.1) for supported enrollment options and filename handling. 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 remaining WebAuthn difference is tracked in [#345](https://github.com/lablup/bssh/issues/345). ## ssh_config integration