diff --git a/docs/openssh-compatibility-status.md b/docs/openssh-compatibility-status.md index a16e1911..1c2fa3f5 100644 --- a/docs/openssh-compatibility-status.md +++ b/docs/openssh-compatibility-status.md @@ -1,6 +1,6 @@ # OpenSSH compatibility status and remaining contracts -Full OpenSSH command compatibility is the target, not a claim about the current release. A regression score, accepted option, or working bssh-to-bssh control socket does not establish a complete replacement. Residual work after [#314](https://github.com/lablup/bssh/issues/314) is tracked in [#327](https://github.com/lablup/bssh/issues/327) (destination-constrained agent keys); [#328](https://github.com/lablup/bssh/issues/328) added FIDO security-key support, and [#331](https://github.com/lablup/bssh/issues/331) tracks the built-in `internal` provider. [#281](https://github.com/lablup/bssh/issues/281) was closed in favor of #314. +Full OpenSSH command compatibility is the target, not a claim about the current release. A regression score, accepted option, or working bssh-to-bssh control socket does not establish a complete replacement. Residual work after [#314](https://github.com/lablup/bssh/issues/314) is tracked in [#327](https://github.com/lablup/bssh/issues/327) (destination-constrained agent keys); [#328](https://github.com/lablup/bssh/issues/328) added FIDO security-key support, [#331](https://github.com/lablup/bssh/issues/331) tracks the built-in `internal` provider, and [#345](https://github.com/lablup/bssh/issues/345) tracks WebAuthn security-key host signatures. [#281](https://github.com/lablup/bssh/issues/281) was closed in favor of #314. ## Measurement boundary @@ -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; `bssh-keygen` cannot enroll or list resident keys, and devices that need a built-in-sensor UV token are unsupported. | +| 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. | | 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 `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. +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). ## ssh_config integration diff --git a/src/ssh/security_key/internal/ctap2.rs b/src/ssh/security_key/internal/ctap2.rs index afacba23..a0069a07 100644 --- a/src/ssh/security_key/internal/ctap2.rs +++ b/src/ssh/security_key/internal/ctap2.rs @@ -299,6 +299,7 @@ impl CosePublicKey { const PIN_SUB_GET_KEY_AGREEMENT: i64 = 0x02; const PIN_SUB_GET_PIN_TOKEN: i64 = 0x05; +const PIN_SUB_GET_UV_TOKEN_WITH_PERMISSIONS: i64 = 0x06; const PIN_SUB_GET_PIN_TOKEN_WITH_PERMISSIONS: i64 = 0x09; /// `pinUvAuthToken` permission bit for `getAssertion`. pub const PERMISSION_GET_ASSERTION: i64 = 0x02; @@ -350,6 +351,30 @@ pub fn encode_client_pin_get_token( out } +/// `getPinUvAuthTokenUsingUvWithPermissions` (0x06). Unlike the PIN +/// variants, this request deliberately carries no `pinHashEnc`: the +/// authenticator performs user verification through its built-in method. +#[must_use] +pub fn encode_client_pin_get_uv_token( + protocol: u64, + platform_key: &CosePublicKey, + permissions: (i64, &str), +) -> Vec { + let (bits, rp_id) = permissions; + 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_UV_TOKEN_WITH_PERMISSIONS), + ), + (Value::Int(3), platform_key.to_value()), + (Value::Int(9), Value::Int(bits)), + (Value::Int(10), Value::from(rp_id)), + ]))); + out +} + /// The still-encrypted `pinUvAuthToken`. pub fn parse_pin_token(response: &[u8]) -> Result>, CtapError> { let map = cbor::decode(response_body(response)?)?; diff --git a/src/ssh/security_key/internal/ctap2_tests.rs b/src/ssh/security_key/internal/ctap2_tests.rs index 633e5786..e00880e2 100644 --- a/src/ssh/security_key/internal/ctap2_tests.rs +++ b/src/ssh/security_key/internal/ctap2_tests.rs @@ -213,6 +213,17 @@ fn client_pin_requests_select_the_subcommand_and_carry_permissions() { decoded.get_int(6).unwrap().as_bytes(), Some(&[0x9c; 32][..]) ); + + let uv = encode_client_pin_get_uv_token(2, &platform, (PERMISSION_GET_ASSERTION, "ssh:")); + assert_eq!(&uv[..6], &[0x06, 0xa5, 0x01, 0x02, 0x02, 0x06]); + let decoded = cbor::decode(&uv[1..]).unwrap(); + assert_eq!( + CosePublicKey::from_value(decoded.get_int(3).unwrap()).unwrap(), + platform + ); + assert_eq!(decoded.get_int(6), None, "UV token request has no PIN hash"); + assert_eq!(decoded.get_int(9).and_then(Value::as_int), Some(0x02)); + assert_eq!(decoded.get_int(10).and_then(Value::as_text), Some("ssh:")); } #[test] diff --git a/src/ssh/security_key/internal/device.rs b/src/ssh/security_key/internal/device.rs index 8f435076..4e45bd4a 100644 --- a/src/ssh/security_key/internal/device.rs +++ b/src/ssh/security_key/internal/device.rs @@ -24,7 +24,7 @@ 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::pin_protocol::{PinProtocol, PlatformKeyAgreement, SharedSecret, authenticate_with}; use super::u2f; use super::{CtapError, UserPresence}; @@ -182,6 +182,11 @@ impl Authenticator { let param = Zeroizing::new(authenticate_with(protocol, &token, client_data_hash)); Some((param, protocol)) } + None if request.uv == Some(true) && info.supports_permissions() => { + let (token, protocol) = self.uv_token(info, request.rp_id, 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 { @@ -189,7 +194,9 @@ impl Authenticator { client_data_hash, credential_id: request.credential_id, up: request.up, - uv: request.uv, + // A PIN/UV token proves verification; CTAP 2.1 requires the + // assertion's `uv` option to be omitted in this path. + uv: if pin_auth.is_some() { None } else { request.uv }, pin_auth: pin_auth .as_ref() .map(|(param, protocol)| (¶m[..], protocol.number())), @@ -215,15 +222,7 @@ impl Authenticator { 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 (platform, secret, protocol) = self.key_agreement(info, deadline, rng)?; let pin_hash_enc = secret.encrypt_pin_hash(pin, rng)?; let permissions = info .supports_permissions() @@ -242,6 +241,48 @@ impl Authenticator { Ok((token, protocol)) } + /// CTAP 2.1 `getPinUvAuthTokenUsingUvWithPermissions`: let the + /// authenticator perform built-in verification and issue a token without + /// a PIN or `pinHashEnc`. + fn uv_token( + &mut self, + info: &AuthenticatorInfo, + rp_id: &str, + deadline: Instant, + rng: &mut R, + ) -> Result<(Zeroizing>, PinProtocol), CtapError> { + let (platform, secret, protocol) = self.key_agreement(info, deadline, rng)?; + let reply = self.channel.transact( + CMD_CBOR, + &ctap2::encode_client_pin_get_uv_token( + protocol.number(), + platform.public_key(), + (PERMISSION_GET_ASSERTION, rp_id), + ), + Some(remaining(deadline)?), + )?; + let token = secret.decrypt(&ctap2::parse_pin_token(&reply)?)?; + Ok((token, protocol)) + } + + fn key_agreement( + &mut self, + info: &AuthenticatorInfo, + deadline: Instant, + rng: &mut R, + ) -> Result<(PlatformKeyAgreement, SharedSecret, 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)?; + Ok((platform, secret, 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. diff --git a/src/ssh/security_key/internal/mock.rs b/src/ssh/security_key/internal/mock.rs index b629ae5e..75056534 100644 --- a/src/ssh/security_key/internal/mock.rs +++ b/src/ssh/security_key/internal/mock.rs @@ -165,6 +165,8 @@ pub struct MockAuthenticator { pub u2f_presence_retries: usize, /// Answer every getAssertion with this status instead of signing. pub forced_status: Option, + /// Answer `getPinUvAuthTokenUsingUvWithPermissions` with this status. + pub uv_token_status: Option, pub requests: Vec, pub cancels: usize, cid: [u8; 4], @@ -189,6 +191,7 @@ impl MockAuthenticator { stall: None, u2f_presence_retries: 0, forced_status: None, + uv_token_status: None, requests: Vec::new(), cancels: 0, cid: [0x0b, 0x55, 0x47, 0x01], diff --git a/src/ssh/security_key/internal/mock_handlers.rs b/src/ssh/security_key/internal/mock_handlers.rs index b5fffca5..31c3b316 100644 --- a/src/ssh/security_key/internal/mock_handlers.rs +++ b/src/ssh/security_key/internal/mock_handlers.rs @@ -162,6 +162,7 @@ impl MockAuthenticator { .get_int(9) .and_then(Value::as_int) .and_then(|bits| Some((bits, map.get_int(10)?.as_text()?.to_string()))); + let has_permissions = permissions.is_some(); self.requests.push(Request::ClientPin { subcommand, protocol: protocol_number, @@ -185,17 +186,30 @@ impl MockAuthenticator { self.agreement = Some(agreement); Some(reply) } - 0x05 | 0x09 => { + 0x05 | 0x06 | 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]); + if subcommand == 0x06 { + if let Some(status) = self.uv_token_status { + return Some(vec![status]); + } + if config.options.get("uv") != Some(&true) + || config.options.get("pinUvAuthToken") != Some(&true) + || !has_permissions + || map.get_int(6).is_some() + { + return Some(vec![status::INVALID_OPTION]); + } + } else { + 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()) diff --git a/src/ssh/security_key/internal/mod.rs b/src/ssh/security_key/internal/mod.rs index a099d515..0b96d8a8 100644 --- a/src/ssh/security_key/internal/mod.rs +++ b/src/ssh/security_key/internal/mod.rs @@ -313,6 +313,10 @@ mod tests; #[path = "selection_tests.rs"] mod selection_tests; +#[cfg(test)] +#[path = "uv_token_tests.rs"] +mod uv_token_tests; + #[cfg(test)] #[path = "robustness_tests.rs"] mod robustness_tests; diff --git a/src/ssh/security_key/internal/selection_tests.rs b/src/ssh/security_key/internal/selection_tests.rs index 8c8c27d6..4647ccdc 100644 --- a/src/ssh/security_key/internal/selection_tests.rs +++ b/src/ssh/security_key/internal/selection_tests.rs @@ -139,6 +139,7 @@ fn verify_required_without_a_pin_needs_one_unless_the_device_verifies_itself() { "nothing is sent before the PIN is known" ); + // CTAP 2.0 keeps the direct `options.uv = true` path. let mut biometric = Fido2Config::plain(); biometric.options.insert("uv".to_string(), true); let (provider, devices) = provider_with(vec![( diff --git a/src/ssh/security_key/internal/uv_token_tests.rs b/src/ssh/security_key/internal/uv_token_tests.rs new file mode 100644 index 00000000..0c6e59ee --- /dev/null +++ b/src/ssh/security_key/internal/uv_token_tests.rs @@ -0,0 +1,116 @@ +// 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. + +//! CTAP 2.1 built-in user verification token flows. + +use super::mock::{CredentialKey, Fido2Config, Request}; +use super::tests::{APPLICATION, fido2, provider_with, public_key, request, verify}; +use super::*; +use crate::ssh::security_key::encode_signature; + +fn biometric(protocol: u64) -> Fido2Config { + let mut config = Fido2Config::with_pin("123456"); + config.options.insert("uv".to_string(), true); + config.pin_protocols = vec![protocol]; + config +} + +#[test] +fn a_built_in_sensor_obtains_a_uv_token_without_a_pin() { + for protocol in [1_u64, 2] { + let (provider, devices) = provider_with(vec![( + "/dev/hidraw1", + fido2( + biometric(protocol), + Some(CredentialKey::ed25519(protocol as u8)), + ), + )]); + let response = provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), None) + .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(); + assert!(device.requests.contains(&Request::ClientPin { + subcommand: 0x06, + protocol: protocol as i64, + permissions: Some((0x02, APPLICATION.to_string())), + })); + assert!(matches!( + device.assertion_requests()[0], + Request::GetAssertion { + uv: None, + pin_auth: Some((param, actual_protocol)), + .. + } if *actual_protocol == protocol as i64 + && param.len() == if protocol == 1 { 16 } else { 32 } + )); + } +} + +#[test] +fn a_refused_uv_token_prompts_for_a_pin_then_retries_with_subcommand_0x09() { + for protocol in [1_u64, 2] { + let mut authenticator = fido2( + biometric(protocol), + Some(CredentialKey::ed25519(protocol as u8)), + ); + authenticator.uv_token_status = Some(ctap2::status::PIN_REQUIRED); + let (provider, devices) = provider_with(vec![("/dev/hidraw1", authenticator)]); + + assert_eq!( + provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), None) + .unwrap_err(), + SecurityKeyError::PinRequired + ); + provider + .sign(&request(SkAlgorithm::Ed25519, 0x05), Some("123456")) + .unwrap(); + + let device = devices[0].lock().unwrap(); + let client_pin = device + .requests + .iter() + .filter_map(|request| match request { + Request::ClientPin { + subcommand, + protocol, + .. + } => Some((*subcommand, *protocol)), + _ => None, + }) + .collect::>(); + assert_eq!( + client_pin, + [ + (0x02, protocol as i64), + (0x06, protocol as i64), + (0x02, protocol as i64), + (0x09, protocol as i64), + ] + ); + assert!(matches!( + device.assertion_requests()[0], + Request::GetAssertion { + uv: None, + pin_auth: Some((_, actual_protocol)), + .. + } if *actual_protocol == protocol as i64 + )); + } +}