diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index 98d7f6b4b..c756818b0 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -2278,9 +2278,12 @@ void changeBot({Function()? callback}) async { void change2fa({Function()? callback}) async { if (bind.mainHasValid2FaSync()) { - await bind.mainSetOption(key: "2fa", value: ""); - await bind.mainClearTrustedDevices(); - callback?.call(); + try { + await bind.mainSetOption(key: "2fa", value: ""); + callback?.call(); + } catch (error) { + showToast(error.toString()); + } return; } var new2fa = (await bind.mainGenerate2Fa()); @@ -2795,23 +2798,48 @@ void confrimDeleteTrustedDevicesDialog( CommonConfirmDialog(gFFI.dialogManager, '${translate('Confirm Delete')}?', () async { if (selectedDevices.isEmpty) return; - if (selectedDevices.length == trustedDevices.length) { - await bind.mainClearTrustedDevices(); - trustedDevices.clear(); - selectedDevices.clear(); - } else { - final json = jsonEncode(selectedDevices.map((e) => e.toList()).toList()); - await bind.mainRemoveTrustedDevices(json: json); - trustedDevices.removeWhere((element) { - return selectedDevices.contains(element.hwid); - }); - selectedDevices.clear(); + try { + if (selectedDevices.length == trustedDevices.length) { + await bind.mainClearTrustedDevices(); + trustedDevices.clear(); + selectedDevices.clear(); + } else { + final json = jsonEncode(selectedDevices.map((e) => e.toList()).toList()); + await bind.mainRemoveTrustedDevices(json: json); + trustedDevices.removeWhere((element) { + return selectedDevices.contains(element.recordId); + }); + selectedDevices.clear(); + } + } catch (error) { + showToast(error.toString()); } }); } +Future renewTrustedDevice( + TrustedDevice device, + RxList trustedDevices, + RxList selectedDevices) async { + try { + final json = jsonEncode([device.recordId.toList()]); + await bind.mainRenewTrustedDevices(json: json); + final result = await TrustedDevice.get(); + selectedDevices.clear(); + trustedDevices.assignAll(result); + } catch (error) { + showToast(error.toString()); + } +} + void manageTrustedDeviceDialog() async { - RxList trustedDevices = (await TrustedDevice.get()).obs; + late final RxList trustedDevices; + try { + trustedDevices = (await TrustedDevice.get()).obs; + } catch (error) { + showToast(error.toString()); + return; + } RxList selectedDevices = RxList.empty(); gFFI.dialogManager.show((setState, close, context) { return CustomAlertDialog( @@ -2837,25 +2865,67 @@ void manageTrustedDeviceDialog() async { }); } +Widget trustedDeviceExpiration({ + required bool enabled, + required VoidCallback onChanged, + bool showLabel = true, +}) { + final period = bind.mainGetOptionSync(key: kOptionTrustedDeviceExpiration); + const periods = ['1', '7', '30', '90', '0']; + const dropdownHeight = 36.0; + return Align( + alignment: AlignmentDirectional.centerStart, + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (showLabel) Text(translate('Trust duration (days)')), + SizedBox( + width: isDesktop || isWebDesktop ? 140 : 100, + height: dropdownHeight, + child: ComboBox( + keys: periods, + values: ['1', '7', '30', '90', translate('Never')], + initialKey: periods.contains(period) ? period : '90', + enabled: enabled && !isOptionFixed(kOptionTrustedDeviceExpiration), + onChanged: (value) async { + try { + await bind.mainSetOption( + key: kOptionTrustedDeviceExpiration, value: value); + } catch (error) { + showToast(error.toString()); + } + onChanged(); + }, + ), + ), + ], + ), + ); +} + class TrustedDevice { - late final Uint8List hwid; + late final Uint8List recordId; late final int time; + late final int expiresAt; late final String id; late final String name; late final String platform; TrustedDevice.fromJson(Map json) { - final hwidList = json['hwid'] as List; - hwid = Uint8List.fromList(hwidList.cast()); + final recordIdList = json['record_id'] as List; + recordId = Uint8List.fromList(recordIdList.cast()); time = json['time']; + expiresAt = json['expires_at']; id = json['id']; name = json['name']; platform = json['platform']; } String daysRemaining() { - final expiry = time + 90 * 24 * 60 * 60 * 1000; - final remaining = expiry - DateTime.now().millisecondsSinceEpoch; + if (expiresAt == 0) return translate('Never'); + final remaining = expiresAt - DateTime.now().millisecondsSinceEpoch; if (remaining < 0) { return '0'; } @@ -2864,18 +2934,11 @@ class TrustedDevice { static Future> get() async { final List devices = List.empty(growable: true); - try { - final devicesJson = await bind.mainGetTrustedDevices(); - if (devicesJson.isNotEmpty) { - final devicesList = json.decode(devicesJson); - if (devicesList is List) { - for (var device in devicesList) { - devices.add(TrustedDevice.fromJson(device)); - } - } - } - } catch (e) { - print(e.toString()); + final devicesJson = await bind.mainGetTrustedDevices(); + final result = json.decode(devicesJson) as Map; + final devicesList = result['devices'] as List; + for (final device in devicesList) { + devices.add(TrustedDevice.fromJson(device)); } devices.sort((a, b) => b.time.compareTo(a.time)); return devices; @@ -2909,7 +2972,7 @@ Widget trustedDevicesTable( onChanged: (value) { if (value == true) { selectedDevices.clear(); - selectedDevices.addAll(devices.map((e) => e.hwid)); + selectedDevices.addAll(devices.map((e) => e.recordId)); } else { selectedDevices.clear(); } @@ -2923,21 +2986,30 @@ Widget trustedDevicesTable( rows: devices.map((device) { return DataRow(cells: [ DataCell(Checkbox( - value: selectedDevices.contains(device.hwid), + value: selectedDevices.contains(device.recordId), onChanged: (value) { if (value == null) return; if (value) { - selectedDevices.remove(device.hwid); - selectedDevices.add(device.hwid); + selectedDevices.remove(device.recordId); + selectedDevices.add(device.recordId); } else { - selectedDevices.remove(device.hwid); + selectedDevices.remove(device.recordId); } }, )), DataCell(Text(device.platform)), DataCell(Text(device.id)), DataCell(Text(device.name)), - DataCell(Text(device.daysRemaining())), + DataCell(Row(mainAxisSize: MainAxisSize.min, children: [ + Text(device.daysRemaining()), + if (device.expiresAt != 0) + IconButton( + icon: Icon(Icons.refresh), + tooltip: translate('Renew trust'), + onPressed: () => + renewTrustedDevice(device, devices, selectedDevices), + ), + ])), ]); }).toList(), )), diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 9dff432db..90f5e3eb1 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -184,6 +184,7 @@ const String kOptionAllowSyncClipboardBetweenSessions = const String kOptionEnableWebrtc = "enable-webrtc"; const String kOptionRelayFallbackDelay = "relay-fallback-delay"; const String kOptionEnableTrustedDevices = "enable-trusted-devices"; +const String kOptionTrustedDeviceExpiration = "trusted-device-expiration-days"; const String kOptionShowVirtualMouse = "show-virtual-mouse"; const String kOptionVirtualMouseScale = "virtual-mouse-scale"; const String kOptionShowVirtualJoystick = "show-virtual-joystick"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 4560b3d69..730d75827 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -1144,6 +1144,13 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { message: translate("enable-trusted-devices-tip"), child: _OptionCheckBox(context, "Enable trusted devices", kOptionEnableTrustedDevices, + optSetter: (key, value) async { + try { + await mainSetBoolOption(key, value); + } catch (error) { + showToast(error.toString()); + } + }, enabled: !locked, update: (v) { setState(() {}); }), @@ -1161,7 +1168,21 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { ).marginOnly(left: 30); return Column( - children: [tfa, bot, trust], + children: [ + tfa, + bot, + trust, + if (mainGetBoolOptionSync(kOptionEnableTrustedDevices)) + trustedDeviceExpiration( + enabled: !locked, + onChanged: () { + if (mounted) setState(() {}); + }, + ).marginOnly( + left: _kCheckBoxLeftMargin + 20 + _kContentHSubMargin, + top: _kContentHMargin, + ), + ], ); } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index de5e55812..3ea64b37a 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -359,12 +359,28 @@ class _SettingsState extends State with WidgetsBindingObserver { onToggle: isOptionFixed(kOptionEnableTrustedDevices) ? null : (v) async { - mainSetBoolOption(kOptionEnableTrustedDevices, v); - setState(() { - _enableTrustedDevices = v; - }); + try { + await mainSetBoolOption(kOptionEnableTrustedDevices, v); + if (!mounted) return; + setState(() { + _enableTrustedDevices = v; + }); + } catch (error) { + showToast(error.toString()); + } }, ), + if (enable2fa && _enableTrustedDevices) + SettingsTile( + title: Text(translate('Trust duration (days)')), + trailing: trustedDeviceExpiration( + enabled: true, + showLabel: false, + onChanged: () { + if (mounted) setState(() {}); + }, + ), + ), if (enable2fa && _enableTrustedDevices) SettingsTile( title: Text(translate('Manage trusted devices')), diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index f82672d10..bfde2985b 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -1834,6 +1834,10 @@ class RustdeskImpl { throw UnimplementedError("mainRemoveTrustedDevices"); } + Future mainRenewTrustedDevices({required String json, dynamic hint}) { + throw UnimplementedError("mainRenewTrustedDevices"); + } + Future mainClearTrustedDevices({dynamic hint}) { throw UnimplementedError("mainClearTrustedDevices"); } diff --git a/libs/base/protos/message.proto b/libs/base/protos/message.proto index 146e44036..5f920bc9a 100644 --- a/libs/base/protos/message.proto +++ b/libs/base/protos/message.proto @@ -84,6 +84,8 @@ message LoginRequest { string my_platform = 13; bytes hwid = 14; string avatar = 17; + bytes trusted_device_v2_token = 18; + bool trusted_device_v2_supported = 19; } message Terminal { @@ -93,6 +95,7 @@ message Terminal { message Auth2FA { string code = 1; bytes hwid = 2; + bool trust_this_device = 3; } message ChatMessage { string text = 1; } @@ -147,6 +150,8 @@ message LoginResponse { PeerInfo peer_info = 2; } bool enable_trusted_devices = 3; + bool trusted_device_v2_supported = 4; + bytes trusted_device_v2_token = 5; } message TouchScaleUpdate { diff --git a/libs/base/src/config/keys.rs b/libs/base/src/config/keys.rs index 000b77f1d..5f5bf8f4f 100644 --- a/libs/base/src/config/keys.rs +++ b/libs/base/src/config/keys.rs @@ -88,6 +88,7 @@ pub const OPTION_ENABLE_DIRECTX_CAPTURE: &str = "enable-directx-capture"; pub const OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE: &str = "enable-android-software-encoding-half-scale"; pub const OPTION_ENABLE_TRUSTED_DEVICES: &str = "enable-trusted-devices"; +pub const OPTION_TRUSTED_DEVICE_EXPIRATION: &str = "trusted-device-expiration-days"; pub const OPTION_AV1_TEST: &str = "av1-test"; /// Maximum number of files allowed during a single file transfer request. /// @@ -323,6 +324,7 @@ pub const KEYS_SETTINGS: &[&str] = &[ OPTION_ENABLE_DIRECTX_CAPTURE, OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE, OPTION_ENABLE_TRUSTED_DEVICES, + OPTION_TRUSTED_DEVICE_EXPIRATION, OPTION_RELAY_SERVER, OPTION_ICE_SERVERS, OPTION_DISABLE_UDP, diff --git a/libs/hbb_common b/libs/hbb_common index edefa08b7..3ee3afd43 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit edefa08b798b7fbf70b274a4cdbf701c9e06f7a3 +Subproject commit 3ee3afd4389cd8885cc2a3e2def5272c24c80598 diff --git a/src/auth_2fa.rs b/src/auth_2fa.rs index 1c243bc77..cc1d96d88 100644 --- a/src/auth_2fa.rs +++ b/src/auth_2fa.rs @@ -93,10 +93,10 @@ pub fn verify2fa(code: String) -> bool { if let Ok(res) = totp.check_current(&code) { if res { if let Ok(v) = info.into_string() { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - crate::ipc::set_option("2fa", &v); - #[cfg(any(target_os = "android", target_os = "ios"))] - Config::set_option("2fa".to_owned(), v); + if let Err(error) = crate::ui_interface::set_auth_option("2fa".to_owned(), v) { + hbb_common::log::error!("2FA configuration update rejected: {error}"); + return false; + } return res; } } diff --git a/src/client.rs b/src/client.rs index 75c2b0422..597dc863a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4637,6 +4637,7 @@ pub async fn handle_hash( password_preset: &str, hash: Hash, interface: &impl Interface, + trust: &mut crate::trusted_devices::client::Session, peer: &mut Stream, ) -> bool { lc.write().unwrap().hash = hash.clone(); @@ -4755,7 +4756,19 @@ pub async fn handle_hash( hasher.finalize()[..].into() }; - send_login(lc.clone(), String::new(), String::new(), password, peer).await; + if let Err(error) = send_login( + lc.clone(), + String::new(), + String::new(), + password, + trust, + peer, + ) + .await + { + interface.on_error(&error.to_string()); + return false; + } lc.write().unwrap().hash = hash; true } @@ -4799,13 +4812,15 @@ async fn send_login( os_username: String, os_password: String, password: Vec, + trust: &mut crate::trusted_devices::client::Session, peer: &mut Stream, -) { +) -> ResultType<()> { let msg_out = lc .read() .unwrap() .create_login_msg(os_username, os_password, password); - allow_err!(peer.send(&msg_out).await); + let msg_out = trust.login(msg_out).await?; + peer.send(&msg_out).await } /// Handle login request made from ui. @@ -4824,8 +4839,9 @@ pub async fn handle_login_from_ui( os_password: String, password: String, remember: bool, + trust: &mut crate::trusted_devices::client::Session, peer: &mut Stream, -) { +) -> ResultType<()> { let mut hash_password = if password.is_empty() { let mut password2 = lc.read().unwrap().password.clone(); if password2.is_empty() { @@ -4850,7 +4866,15 @@ pub async fn handle_login_from_ui( hasher2.update(&lc.read().unwrap().hash.challenge); hash_password = hasher2.finalize()[..].to_vec(); - send_login(lc.clone(), os_username, os_password, hash_password, peer).await; + send_login( + lc.clone(), + os_username, + os_password, + hash_password, + trust, + peer, + ) + .await } async fn send_switch_login_request( @@ -4885,13 +4909,20 @@ pub trait Interface: Send + Clone + 'static + Sized { fn on_error(&self, err: &str) { self.msgbox("error", "Error", err, ""); } - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool; + async fn handle_hash( + &self, + pass: &str, + hash: Hash, + trust: &mut crate::trusted_devices::client::Session, + peer: &mut Stream, + ) -> bool; async fn handle_login_from_ui( &self, os_username: String, os_password: String, password: String, remember: bool, + trust: &mut crate::trusted_devices::client::Session, peer: &mut Stream, ); async fn handle_test_delay(&self, t: TestDelay, peer: &mut Stream); @@ -5736,6 +5767,10 @@ mod webrtc_race_tests { } } +#[cfg(test)] +#[path = "client/tests/trusted_devices_tests.rs"] +mod trusted_devices_tests; + #[cfg(test)] mod kx_tests { use super::*; diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index cb6867124..1b197a838 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -103,6 +103,7 @@ pub struct Remote { last_record_state: bool, sent_close_reason: bool, cursor_dedupe: CursorDedupe, + trusted_device: crate::trusted_devices::client::Session, } #[derive(Default)] @@ -156,6 +157,7 @@ impl Remote { last_record_state: false, sent_close_reason: false, cursor_dedupe: Default::default(), + trusted_device: Default::default(), } } @@ -209,6 +211,22 @@ impl Remote { .unwrap() .set_connected(); let is_secured = peer.is_secured(); + let trust = crate::trusted_devices::client::Session::new( + &self.handler.lc.read().unwrap(), + is_secured, + pk.as_deref(), + ); + match trust { + Ok(trust) => { + self.trusted_device = trust; + self.handler.lc.write().unwrap().enable_trusted_devices = false; + } + Err(error) => { + self.handler.on_error(&error.to_string()); + self.handle_disconnected(round); + return; + } + } // Only WebRTC needs refining: its label names the transport that won the race, // not the family ICE ended up nominating, and it is the one path where the two // can disagree with the address the rendezvous observed. @@ -744,7 +762,14 @@ impl Remote { } Data::Login((os_username, os_password, password, remember)) => { self.handler - .handle_login_from_ui(os_username, os_password, password, remember, peer) + .handle_login_from_ui( + os_username, + os_password, + password, + remember, + &mut self.trusted_device, + peer, + ) .await; } #[cfg(all(target_os = "windows", not(feature = "flutter")))] @@ -773,6 +798,7 @@ impl Remote { } } Data::Message(msg) => { + let msg = self.trusted_device.outgoing(msg); // The Flutter clipboard broadcast is process-wide, so a clipboard can reach this // round's queue before the round has logged in; it is dropped here, on the round // itself. @@ -1705,18 +1731,24 @@ impl Remote { Some(message::Union::Hash(hash)) => { if !self .handler - .handle_hash(&self.handler.password.clone(), hash, peer) + .handle_hash( + &self.handler.password.clone(), + hash, + &mut self.trusted_device, + peer, + ) .await { return false; } } - Some(message::Union::LoginResponse(lr)) => match lr.union { + Some(message::Union::LoginResponse(lr)) => match self + .trusted_device + .respond(lr, &self.handler) + .await + .union + { Some(login_response::Union::Error(err)) => { - if err == client::REQUIRE_2FA { - self.handler.lc.write().unwrap().enable_trusted_devices = - lr.enable_trusted_devices; - } if !self.handler.handle_login_error(&err) { return false; } diff --git a/src/client/tests/trusted_devices_tests.rs b/src/client/tests/trusted_devices_tests.rs new file mode 100644 index 000000000..6b6634e3f --- /dev/null +++ b/src/client/tests/trusted_devices_tests.rs @@ -0,0 +1,144 @@ +use super::*; +use hbb_common::tcp::FramedStream; + +#[tokio::test] +async fn existing_legacy_consent_is_preserved_across_upgrades() { + crate::trusted_devices::tests::initialize_config(); + let mut login = LoginConfigHandler::default(); + login.id = uuid::Uuid::new_v4().to_string(); + login + .config + .options + .insert("trust-this-device".into(), "Y".into()); + let request = login.create_login_msg(String::new(), String::new(), vec![1; 32]); + let Some(message::Union::LoginRequest(request)) = request.union else { + panic!("expected a login request"); + }; + assert!( + request.hwid == crate::get_hwid(), + "an existing legacy consent must remain usable with old hosts" + ); + verified_channel_controls_tokens(&login).await; + corrupt_credentials_do_not_block_password_login(&login).await; +} + +async fn corrupt_credentials_do_not_block_password_login(login: &LoginConfigHandler) { + use crate::trusted_devices::client::Session; + let path = Config::path(format!("peers/{}.toml", login.id)); + let saved = std::fs::read(&path).unwrap(); + std::fs::write(&path, b"invalid cached credential").unwrap(); + let mut trust = Session::new(login, true, Some(&[1; 32])).unwrap(); + let request = login.create_login_msg(String::new(), String::new(), vec![1; 32]); + let outgoing = trust.login(request.clone()).await; + let stored = std::fs::read(&path).unwrap(); + std::fs::write(&path, saved).unwrap(); + assert_eq!(outgoing.unwrap(), request); + assert_eq!(stored, b"invalid cached credential"); + let mut response = LoginResponse::new(); + response.set_error(REQUIRE_2FA.into()); + response.trusted_device_v2_supported = true; + assert!(!trust.response(&response).await.unwrap()); + let mut auth = Message::new(); + auth.set_auth_2fa(Auth2FA { + trust_this_device: true, + ..Default::default() + }); + assert!(!trust.outgoing(auth).auth_2fa().trust_this_device); +} + +async fn verified_channel_controls_tokens(login: &LoginConfigHandler) { + use crate::trusted_devices::client::Session; + let token = vec![9; crate::trusted_devices::TOKEN_BYTES]; + let mut trust = Session::new(login, true, Some(&[1; 32])).unwrap(); + let mut response = LoginResponse::new(); + response.set_error(REQUIRE_2FA.into()); + response.trusted_device_v2_supported = true; + assert!(trust.response(&response).await.unwrap()); + let mut auth = Message::new(); + auth.set_auth_2fa(Auth2FA { + trust_this_device: true, + ..Default::default() + }); + assert!(trust.outgoing(auth).auth_2fa().trust_this_device); + let mut success = LoginResponse::new(); + success.set_peer_info(PeerInfo { + platform: "Windows".into(), + ..Default::default() + }); + success.trusted_device_v2_token = token.clone().into(); + trust.response(&success).await.unwrap(); + assert_eq!(PeerConfig::peers(Some(vec![login.id.clone()])).len(), 1); + another_connection_cannot_supply_this_streams_token(&mut trust).await; + let request = login.create_login_msg(String::new(), String::new(), vec![1; 32]); + assert_eq!(sent_token(&mut trust, &request).await, token); + for mut unverified in [ + Session::new(login, false, Some(&[1; 32])).unwrap(), + Session::new(login, true, None).unwrap(), + ] { + assert!(sent_token(&mut unverified, &request).await.is_empty()); + } + response.trusted_device_v2_supported = false; + response.enable_trusted_devices = true; + assert!(!trust.response(&response).await.unwrap()); + assert_eq!(sent_token(&mut trust, &request).await, token); + response.trusted_device_v2_supported = true; + trust.response(&response).await.unwrap(); + assert_eq!(sent_token(&mut trust, &request).await, token); + let mut migration = Session::new(login, true, Some(&[1; 32])).unwrap(); + let outgoing = migration.login(request).await.unwrap(); + assert!(outgoing.login_request().trusted_device_v2_supported); + success.trusted_device_v2_supported = true; + migration.response(&success).await.unwrap(); + let mut no_consent = Session::new(login, true, Some(&[1; 32])).unwrap(); + assert!(no_consent.response(&success).await.is_err()); +} + +async fn another_connection_cannot_supply_this_streams_token( + trust: &mut crate::trusted_devices::client::Session, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let socket = tokio::net::TcpStream::connect(addr).await.unwrap(); + let (remote, _) = listener.accept().await.unwrap(); + let mut peer = Stream::Tcp(FramedStream::from(socket, addr)); + let mut remote = Stream::Tcp(FramedStream::from(remote, addr)); + let login = Arc::new(RwLock::new(LoginConfigHandler::default())); + let mut old_connection = crate::trusted_devices::client::Session::new( + &login.read().unwrap(), + peer.is_secured(), + None, + ) + .unwrap(); + let request = login + .read() + .unwrap() + .create_login_msg(String::new(), String::new(), vec![1; 32]); + assert!(!sent_token(trust, &request).await.is_empty()); + assert!(!peer.is_secured()); + send_login( + login, + String::new(), + String::new(), + vec![1; 32], + &mut old_connection, + &mut peer, + ) + .await + .unwrap(); + let bytes = remote.next().await.unwrap().unwrap(); + let message = Message::parse_from_bytes(&bytes).unwrap(); + assert!( + message.login_request().trusted_device_v2_token.is_empty(), + "another connection's credential was sent on this unsecured stream" + ); +} + +async fn sent_token(trust: &mut crate::trusted_devices::client::Session, request: &Message) -> Vec { + trust + .login(request.clone()) + .await + .unwrap() + .login_request() + .trusted_device_v2_token + .to_vec() +} diff --git a/src/flutter.rs b/src/flutter.rs index 4bde2ee37..8c393d4dd 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -993,6 +993,19 @@ impl InvokeUiSession for FlutterHandler { fn on_connected(&self, _conn_type: ConnType) {} fn msgbox(&self, msgtype: &str, title: &str, text: &str, link: &str, retry: bool) { + if msgtype == "custom-error-trusted-device" { + const ERROR_DURATION_MS: u32 = 10_000; + self.push_event( + "toast", + &[ + ("type", json!("error")), + ("text", json!(text)), + ("dur_msec", json!(ERROR_DURATION_MS)), + ], + &[], + ); + return; + } let has_retry = if retry { "true" } else { "" }; self.push_event( "msgbox", diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index bd9bd9657..8473e38fa 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1,5 +1,8 @@ #[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::keyboard::input_source::{change_input_source, get_cur_session_input_source}; +use crate::{ + keyboard::input_source::{change_input_source, get_cur_session_input_source}, + trusted_devices::authority, +}; #[cfg(target_os = "linux")] use crate::platform::linux::is_x11; use crate::{ @@ -1016,7 +1019,13 @@ pub fn main_get_error() -> String { get_error() } -pub fn main_set_option(key: String, value: String) { +pub fn main_set_option(key: String, value: String) -> hbb_common::anyhow::Result<()> { + if key == "2fa" + || key == keys::OPTION_ENABLE_TRUSTED_DEVICES + || key == keys::OPTION_TRUSTED_DEVICE_EXPIRATION + { + return set_auth_option(key, value); + } #[cfg(target_os = "android")] { let is_permission_option = key.eq(keys::OPTION_ENABLE_CLIPBOARD) @@ -1035,7 +1044,7 @@ pub fn main_set_option(key: String, value: String) { key, value ); - return; + return Ok(()); } } #[cfg(target_os = "android")] @@ -1074,6 +1083,7 @@ pub fn main_set_option(key: String, value: String) { } else { set_option(key, value.clone()); } + Ok(()) } pub fn main_get_options() -> String { @@ -1084,7 +1094,7 @@ pub fn main_get_options_sync() -> SyncReturn { SyncReturn(get_options()) } -pub fn main_set_options(json: String) { +pub fn main_set_options(json: String) -> hbb_common::anyhow::Result<()> { let mut map: HashMap = serde_json::from_str(&json).unwrap_or(HashMap::new()); #[cfg(target_os = "android")] { @@ -1109,8 +1119,9 @@ pub fn main_set_options(json: String) { } } if !map.is_empty() { - set_options(map) + try_set_options(map)?; } + Ok(()) } pub fn main_test_if_valid_server(server: String, test_with_proxy: bool) -> String { @@ -2669,16 +2680,54 @@ pub fn main_check_hwcodec() { check_hwcodec() } -pub fn main_get_trusted_devices() -> String { - get_trusted_devices() +// The bridge uses ordinary worker threads; desktop IPC needs a runtime at this boundary. +pub fn main_get_trusted_devices() -> hbb_common::anyhow::Result { + #[cfg(any(target_os = "android", target_os = "ios"))] + return crate::trusted_devices::devices(); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + hbb_common::tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(crate::ipc::trusted_devices(authority::Request::List)) } -pub fn main_remove_trusted_devices(json: String) { - remove_trusted_devices(&json) +pub fn main_remove_trusted_devices(json: String) -> hbb_common::anyhow::Result<()> { + let record_ids = serde_json::from_str::>(&json)?; + #[cfg(any(target_os = "android", target_os = "ios"))] + return crate::trusted_devices::remove(&record_ids); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + hbb_common::tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(crate::ipc::trusted_devices(authority::Request::Remove( + record_ids, + ))) + .map(|_| ()) } -pub fn main_clear_trusted_devices() { - clear_trusted_devices() +pub fn main_renew_trusted_devices(json: String) -> hbb_common::anyhow::Result<()> { + let record_ids = serde_json::from_str::>(&json)?; + #[cfg(any(target_os = "android", target_os = "ios"))] + return crate::trusted_devices::renew(&record_ids); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + hbb_common::tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(crate::ipc::trusted_devices(authority::Request::Renew( + record_ids, + ))) + .map(|_| ()) +} + +pub fn main_clear_trusted_devices() -> hbb_common::anyhow::Result<()> { + #[cfg(any(target_os = "android", target_os = "ios"))] + return crate::trusted_devices::clear(); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + hbb_common::tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(crate::ipc::trusted_devices(authority::Request::Clear)) + .map(|_| ()) } pub fn main_max_encrypt_len() -> SyncReturn { diff --git a/src/hbbs_http/sync.rs b/src/hbbs_http/sync.rs index 728e1e8be..aac220b9e 100644 --- a/src/hbbs_http/sync.rs +++ b/src/hbbs_http/sync.rs @@ -258,6 +258,15 @@ async fn start_hbbs_sync_async() { SENDER.lock().unwrap().send(conns).ok(); } } + if let Some(strategy) = rsp.remove("strategy") { + if let Ok(strategy) = serde_json::from_value::(strategy) { + if let Err(error) = handle_config_options(strategy.config_options).await { + hbb_common::throttled_log!(API_LOG_INTERVAL, error, "Strategy update rejected: {error}"); + continue; + } + log::info!("strategy updated"); + } + } if let Some(rsp_modified_at) = rsp.remove("modified_at") { if let Ok(rsp_modified_at) = serde_json::from_value::(rsp_modified_at) { if rsp_modified_at != modified_at { @@ -265,12 +274,6 @@ async fn start_hbbs_sync_async() { } } } - if let Some(strategy) = rsp.remove("strategy") { - if let Ok(strategy) = serde_json::from_value::(strategy) { - log::info!("strategy updated"); - handle_config_options(strategy.config_options); - } - } } } } @@ -289,7 +292,9 @@ fn heartbeat_url() -> String { format!("{}/api/heartbeat", url) } -fn handle_config_options(config_options: HashMap) { +async fn handle_config_options( + config_options: HashMap, +) -> hbb_common::ResultType<()> { let mut options = Config::get_options(); let default_settings = config::DEFAULT_SETTINGS.read().unwrap().clone(); config_options @@ -305,7 +310,29 @@ fn handle_config_options(config_options: HashMap) { } }) .count(); + if crate::trusted_devices::config::changes_authentication(&options) { + let current = Config::get_options(); + let mut non_auth_options = options.clone(); + for key in [ + "2fa", + keys::OPTION_ENABLE_TRUSTED_DEVICES, + keys::OPTION_TRUSTED_DEVICE_EXPIRATION, + ] { + match current.get(key) { + Some(value) => { + non_auth_options.insert(key.to_owned(), value.clone()); + } + None => { + non_auth_options.remove(key); + } + } + } + // Authentication update failures must not block unrelated strategy options. + Config::set_options(non_auth_options); + return crate::trusted_devices::config::update_options(options).await; + } Config::set_options(options); + Ok(()) } #[allow(unused)] diff --git a/src/ipc.rs b/src/ipc.rs index c51297720..44a1c8d51 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -24,6 +24,7 @@ use crate::{ privacy_mode, privacy_mode::PrivacyModeState, rendezvous_mediator::RendezvousMediator, + trusted_devices::authority, ui_interface::{get_local_option, set_local_option}, }; use bytes::Bytes; @@ -137,6 +138,19 @@ pub async fn connect_service(ms_timeout: u64) -> ResultType ResultType> { + let connection = connect_service(ms_timeout) + .await + .map_err(crate::trusted_devices::authority::Unavailable)?; + if connection.peer_uid() != Some(0) { + bail!("Trusted-device system service is not an authenticated root peer"); + } + Ok(connection) +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum FS { @@ -482,8 +496,9 @@ pub enum Data { // Although the key is not necessary, it is used to avoid hardcoding the key. WaylandScreencastRestoreToken((String, String)), HwCodecConfig(Option), - RemoveTrustedDevices(Vec), - ClearTrustedDevices, + TrustedDeviceManagement(Box), + TrustedDeviceResponse(Result), + SecurityConfigError(String), #[cfg(all(target_os = "windows", feature = "flutter"))] PrinterData(Vec), InstallOption(Option<(String, String)>), @@ -986,8 +1001,6 @@ async fn handle(data: Data, stream: &mut Connection) { value = crate::audio_service::get_voice_call_input_device(); } else if name == "unlock-pin" { value = Some(Config::get_unlock_pin()); - } else if name == "trusted-devices" { - value = Some(Config::get_trusted_devices_json()); } else { value = None; } @@ -1011,7 +1024,19 @@ async fn handle(data: Data, stream: &mut Connection) { log::warn!("Changing permanent password is disabled"); updated = false; } else { - updated = Config::set_permanent_password(&value); + let affected = crate::trusted_devices::config::changes_password(&value); + updated = + match crate::trusted_devices::config::update_auth(affected, move || { + crate::trusted_devices::config::set_password(&value) + }) + .await + { + Ok(updated) => updated, + Err(error) => { + log::error!("Password change rejected: {error}"); + false + } + }; } // Explicitly ACK/NACK permanent-password writes. This allows UIs/FFI to // distinguish "accepted by daemon" vs "IPC send succeeded" without @@ -1019,7 +1044,21 @@ async fn handle(data: Data, stream: &mut Connection) { let ack = if updated { "Y" } else { "N" }.to_owned(); allow_err!(stream.send(&Data::Config((name.clone(), Some(ack)))).await); } else if name == "salt" { - Config::set_salt(&value); + let affected = + Config::get_local_permanent_password_storage_and_salt().1 != value; + if let Err(error) = + crate::trusted_devices::config::update_auth(affected, move || { + crate::trusted_devices::config::set_salt(&value) + }) + .await + { + allow_err!( + stream + .send(&Data::SecurityConfigError(error.to_string())) + .await + ); + return; + } } else if name == "voice-call-input" { crate::audio_service::set_voice_call_input_device(Some(value), true); } else if name == "unlock-pin" { @@ -1043,7 +1082,14 @@ async fn handle(data: Data, stream: &mut Connection) { if let Some(v) = value.get("privacy-mode-impl-key") { crate::privacy_mode::switch(v); } - Config::set_options(value); + if let Err(error) = crate::trusted_devices::config::update_options(value).await { + allow_err!( + stream + .send(&Data::SecurityConfigError(error.to_string())) + .await + ); + return; + } allow_err!(stream.send(&Data::Options(None)).await); } }, @@ -1054,16 +1100,47 @@ async fn handle(data: Data, stream: &mut Connection) { Data::SyncConfig(Some(configs)) => { let (config, config2) = *configs; let _chk = CheckIfRestart::new(); - Config::set(config); - Config2::set(config2); + if let Err(error) = crate::trusted_devices::config::update(move || { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + crate::trusted_devices::config::sync_config(config, config2) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + crate::trusted_devices::config::set_config(config) + .and_then(|_| crate::trusted_devices::config::set_config2(config2)) + } + }) + .await + { + allow_err!( + stream + .send(&Data::SecurityConfigError(error.to_string())) + .await + ); + return; + } allow_err!(stream.send(&Data::SyncConfig(None)).await); } Data::SyncConfig(None) => { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + let config2 = match crate::trusted_devices::config::update(Config2::get_for_sync).await + { + Ok(config2) => config2, + Err(error) => { + allow_err!( + stream + .send(&Data::SecurityConfigError(error.to_string())) + .await + ); + return; + } + }; + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + let config2 = Config2::get(); allow_err!( stream - .send(&Data::SyncConfig(Some( - (Config::get(), Config2::get()).into() - ))) + .send(&Data::SyncConfig(Some((Config::get(), config2).into()))) .await ); } @@ -1214,11 +1291,11 @@ async fn handle(data: Data, stream: &mut Connection) { ); } } - Data::RemoveTrustedDevices(v) => { - Config::remove_trusted_devices(&v); - } - Data::ClearTrustedDevices => { - Config::clear_trusted_devices(); + Data::TrustedDeviceManagement(request) => { + let result = authority::request(*request) + .await + .map_err(|error| error.to_string()); + allow_err!(stream.send(&Data::TrustedDeviceResponse(result)).await); } Data::InstallOption(opt) => match opt { Some((_k, _v)) => { @@ -1708,6 +1785,7 @@ pub fn sync_permanent_password_storage_from_daemon() -> ResultType<()> { apply_permanent_password_storage_and_salt_payload(v.as_deref()) } +#[cfg(not(target_os = "windows"))] async fn sync_permanent_password_storage_from_daemon_async() -> ResultType<()> { let ms_timeout = 1_000; let v = get_config_async("permanent-password-storage-and-salt", ms_timeout).await?; @@ -1771,8 +1849,11 @@ async fn set_permanent_password_with_ack_async(v: String) -> ResultType { let v = v.trim(); let ok = v == "Y"; if ok { + #[cfg(target_os = "windows")] + sync_security_config_from_server(&mut c).await?; // Ensure the hashed permanent password storage is written to the user config file. // This sync must not affect the daemon ACK outcome. + #[cfg(not(target_os = "windows"))] if let Err(err) = sync_permanent_password_storage_from_daemon_async().await { log::warn!("Failed to sync permanent password storage from daemon: {err}"); } @@ -1823,26 +1904,54 @@ pub fn get_unlock_pin() -> String { #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn get_trusted_devices() -> String { - if let Ok(Some(v)) = get_config("trusted-devices") { - v - } else { - Config::get_trusted_devices_json() +pub async fn trusted_devices(request: authority::Request) -> ResultType { + const TIMEOUT_MS: u64 = 1000; + let list = matches!(request, authority::Request::List); + let mut connection = connect(TIMEOUT_MS, "").await?; + connection + .send(&Data::TrustedDeviceManagement(Box::new(request))) + .await?; + let response = match connection.next_timeout(TIMEOUT_MS).await? { + Some(Data::TrustedDeviceResponse(result)) => { + match result.map_err(|error| hbb_common::anyhow::anyhow!(error))? { + authority::Response::Devices(devices) if list => devices, + authority::Response::Done if !list => String::new(), + _ => bail!("Invalid trusted-device management response"), + } + } + _ => bail!("The host did not confirm the trusted-device operation"), + }; + #[cfg(target_os = "windows")] + if !list { + sync_security_config_from_server(&mut connection).await?; } + Ok(response) } -#[cfg(feature = "flutter")] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn remove_trusted_devices(hwids: Vec) { - Config::remove_trusted_devices(&hwids); - allow_err!(set_data(&Data::RemoveTrustedDevices(hwids))); -} +#[cfg(target_os = "windows")] +async fn sync_security_config_from_server( + connection: &mut ConnectionTmpl, +) -> ResultType<()> { + use hbb_common::anyhow::Context; -#[cfg(feature = "flutter")] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn clear_trusted_devices() { - Config::clear_trusted_devices(); - allow_err!(set_data(&Data::ClearTrustedDevices)); + const TIMEOUT_MS: u64 = 1000; + // MSI removal can reactivate this user copy without running our uninstall hooks. + async { + connection.send(&Data::SyncConfig(None)).await?; + match connection.next_timeout(TIMEOUT_MS).await? { + Some(Data::SyncConfig(Some(configs))) => { + let (config, config2) = *configs; + crate::trusted_devices::config::update(move || { + crate::trusted_devices::config::sync_config(config, config2) + }) + .await + } + Some(Data::SecurityConfigError(error)) => bail!(error), + _ => bail!("The host did not provide its current configuration"), + } + } + .await + .context("Host security update succeeded, but updating the local configuration failed") } pub fn get_id() -> String { @@ -1915,16 +2024,31 @@ pub fn set_option(key: &str, value: &str) { } else { options.insert(key.to_owned(), value.to_owned()); } - set_options(options).ok(); + allow_err!(set_options(options)); } #[tokio::main(flavor = "current_thread")] pub async fn set_options(value: HashMap) -> ResultType<()> { let _nat = CheckTestNatType::new(); - if let Ok(mut c) = connect(1000, "").await { + let affected = crate::trusted_devices::config::changes_authentication(&value); + let connection = connect(1000, "").await; + if affected && connection.is_err() { + bail!("The host is unavailable; authentication settings were not changed"); + } + if let Ok(mut c) = connection { c.send(&Data::Options(Some(value.clone()))).await?; // do not put below before connect, because we need to check should_exit - c.next_timeout(1000).await.ok(); + match c.next_timeout(1000).await { + #[cfg(target_os = "windows")] + Ok(Some(Data::Options(None))) if affected => { + sync_security_config_from_server(&mut c).await?; + } + Ok(Some(Data::Options(None))) => {} + Ok(Some(Data::SecurityConfigError(error))) => bail!(error), + Err(error) if affected => return Err(error), + _ if affected => bail!("The host did not confirm the configuration update"), + _ => {} + } } Config::set_options(value); Ok(()) diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs index 039a8eb4e..752f3354d 100644 --- a/src/ipc/fs.rs +++ b/src/ipc/fs.rs @@ -659,9 +659,12 @@ async fn probe_existing_listener(postfix: &str) -> bool { if stream.send(&Data::SyncConfig(None)).await.is_err() { return false; } + // A configuration error still confirms that the service is alive. matches!( stream.next_timeout(1000).await, - Ok(Some(Data::SyncConfig(Some(_)))) + Ok(Some( + Data::SyncConfig(Some(_)) | Data::SecurityConfigError(_) + )) ) } diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 3c79f5e33..9c1fc2a04 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/az.rs b/src/lang/az.rs index e63f978bf..00c7ec46e 100644 --- a/src/lang/az.rs +++ b/src/lang/az.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index d2d19e19d..cb1f66a33 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 68bc7430c..e7cd1f592 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 061ab7b78..5897a961b 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index b470a7fda..98e97631f 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", "请选择 Android → media,用于保存日志。"), ("Choose folder", "选择文件夹"), ("Please select Android → media.", "请选择 Android → media。"), + ("Trust duration (days)", "信任有效期(天)"), + ("Renew trust", "延长信任有效期"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index b520a7ea0..d4a7034ec 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index d779efff6..dfb86b864 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index d4a83465b..c6a489692 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 6dccbda99..85ed4d190 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 5434d4ac5..b66a24393 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index cdfb0fcee..3df5919d3 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 88de96b16..8e9c2f150 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 3ab92a123..6e6e56496 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index a037c2a95..f7560dc58 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -819,5 +819,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 0cea33773..df8aefc32 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 6c7318361..309686fbb 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 582f34b6e..c17fd84e0 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gl.rs b/src/lang/gl.rs index dbf1134de..8f1d32e26 100644 --- a/src/lang/gl.rs +++ b/src/lang/gl.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index f792358a3..8246f3c0b 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 747b3dbed..a7ffaec3f 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 562732bd2..e66f69070 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index e5d2994ac..3509e5dfd 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 3e8598ae5..c4fa8f73e 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 2ff8b5b40..18bd43930 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index a29da3d95..7575806bd 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index a6d02a023..4d35510d3 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 27d989640..b77fd42d4 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index b5cb79eb6..9d6db73dc 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index b1bc19224..aba2e098a 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index a6e7e0647..5cad1667c 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index b3ad4d0ff..0b0f0d9be 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 5f9f096de..f069f560b 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index e7ae1132b..51171df80 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index f50007d0f..2fc386460 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_BR.rs b/src/lang/pt_BR.rs index c19d15cbf..1471bf297 100644 --- a/src/lang/pt_BR.rs +++ b/src/lang/pt_BR.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index a276f4ebb..7977f989d 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 29c4a048c..f6d7a0caa 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 07471a135..f32d34f1a 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 92054a7a8..8f14ca5f7 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index a396f7402..e78a8089c 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index a925caca6..d72a40c6f 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 7537534c5..1a5b28348 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index 4a7836ded..3433ff850 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 27570ac7c..6723a7b29 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index f5223752e..13cd15422 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 04249f308..0ca3c660b 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index bc71765c6..5d56c692e 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index f6e3a0051..89355d470 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index e6e998478..59702154c 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", "請選擇 Android → media,用於儲存日誌。"), ("Choose folder", "選擇資料夾"), ("Please select Android → media.", "請選擇 Android → media。"), + ("Trust duration (days)", "信任有效期(天)"), + ("Renew trust", "延長信任期限"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 283212806..6663ce532 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ur.rs b/src/lang/ur.rs index a1603b525..0f97521ac 100644 --- a/src/lang/ur.rs +++ b/src/lang/ur.rs @@ -821,6 +821,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 1bab155fe..644fc24d6 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -821,5 +821,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android-logs-folder-tip", ""), ("Choose folder", ""), ("Please select Android → media.", ""), + ("Trust duration (days)", ""), + ("Renew trust", ""), ].iter().cloned().collect(); } diff --git a/src/lib.rs b/src/lib.rs index 69ca7faf3..f6e48a1be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod flutter; pub mod flutter_ffi; use common::*; mod auth_2fa; +mod trusted_devices; #[cfg(not(target_os = "ios"))] mod clipboard; #[cfg(not(any(target_os = "android", target_os = "ios")))] diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 00457c811..206a88d02 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1912,6 +1912,13 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> ResultType } pub fn uninstall_me(kill_self: bool) -> ResultType<()> { + if is_self_service_running() { + // This CLI entry is synchronous. Portable builds reuse the retained user configuration. + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(crate::trusted_devices::config::refresh_from_server())?; + } run_cmds(get_uninstall(kill_self, true)?, true, "uninstall") } @@ -3338,6 +3345,20 @@ pub fn try_lock_tray_single_instance() -> bool { /// Successful removal exits this process instead of returning. pub fn uninstall_service(show_new_window: bool, _: bool) -> bool { log::info!("Uninstalling service..."); + if is_self_service_running() { + // UI/tray/CLI callers are synchronous. Refresh the file before killing its source. + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(hbb_common::anyhow::Error::from) + .and_then(|runtime| { + runtime.block_on(crate::trusted_devices::config::refresh_from_server()) + }); + if let Err(error) = result { + log::error!("Cannot stop service before synchronizing its configuration: {error}"); + return true; + } + } let filter = format!(" /FI \"PID ne {}\"", get_current_pid()); let (_, _, _, exe) = get_install_info(); let remove_service = match service_control_command("remove", &exe, false) { diff --git a/src/port_forward.rs b/src/port_forward.rs index 05d64ed5a..26f8cbc07 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -174,8 +174,10 @@ async fn connect_and_login( } else { ConnType::PORT_FORWARD }; - let ((mut stream, direct, _pk, _kcp, _stream_type), (feedback, rendezvous_server)) = + let ((mut stream, direct, pk, _kcp, _stream_type), (feedback, rendezvous_server)) = Client::start(id, key, token, conn_type, interface.clone()).await?; + let mut trust = crate::trusted_devices::client::Session::new( + &interface.get_lch().read().unwrap(), stream.is_secured(), pk.as_deref())?; interface.update_direct(Some(direct)); if !stream.is_secured() && !crate::common::is_direct_ip_access(id) { if !confirm_insecure_connection(&interface, ui_receiver).await { @@ -205,11 +207,11 @@ async fn connect_and_login( match msg_in.union { Some(message::Union::Hash(hash)) => { challenge = Some(hash.clone()); - if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, false, &mut stream).await { + if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, false, &mut trust, &mut stream).await { return Ok(None); } } - Some(message::Union::LoginResponse(lr)) => match lr.union { + Some(message::Union::LoginResponse(lr)) => match trust.respond(lr, &interface).await.union { Some(login_response::Union::Error(err)) => { if !interface.handle_login_error(&err) { return Ok(None); @@ -237,10 +239,11 @@ async fn connect_and_login( d = ui_receiver.recv() => { match d { Some(Data::Login(login)) => match &challenge { - Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, false, &mut stream).await, + Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, false, &mut trust, &mut stream).await, None => pending_login = Some(login), }, Some(Data::Message(msg)) => { + let msg = trust.outgoing(msg); allow_err!(stream.send(&msg).await); } _ => {} @@ -275,6 +278,7 @@ async fn login_with_hash( remote_host: &str, remote_port: i32, mux: bool, + trust: &mut crate::trusted_devices::client::Session, stream: &mut Stream, ) -> bool { let lc = interface.get_lch(); @@ -282,7 +286,7 @@ async fn login_with_hash( let _turn = turn.lock().await; lc.write().unwrap().port_forward = (remote_host.to_owned(), remote_port); lc.write().unwrap().port_forward_multiplex = mux; - interface.handle_hash(password, hash, stream).await + interface.handle_hash(password, hash, trust, stream).await } type UiLogin = (String, String, String, bool); @@ -300,14 +304,15 @@ async fn hash_arrived( remote_host: &str, remote_port: i32, mux: bool, + trust: &mut crate::trusted_devices::client::Session, stream: &mut Stream, ) -> bool { match pending_login { Some(login) => { - login_from_ui(interface, &hash, login, remote_host, remote_port, mux, stream).await; + login_from_ui(interface, &hash, login, remote_host, remote_port, mux, trust, stream).await; true } - None => login_with_hash(interface, password, hash, remote_host, remote_port, mux, stream).await, + None => login_with_hash(interface, password, hash, remote_host, remote_port, mux, trust, stream).await, } } @@ -320,6 +325,7 @@ async fn login_from_ui( remote_host: &str, remote_port: i32, mux: bool, + trust: &mut crate::trusted_devices::client::Session, stream: &mut Stream, ) { let lc = interface.get_lch(); @@ -333,7 +339,7 @@ async fn login_from_ui( } let (os_username, os_password, password, remember) = login; interface - .handle_login_from_ui(os_username, os_password, password, remember, stream) + .handle_login_from_ui(os_username, os_password, password, remember, trust, stream) .await; } @@ -415,8 +421,10 @@ async fn connect_and_login_mux( } else { ConnType::PORT_FORWARD }; - let ((mut stream, direct, _pk, _kcp, _stream_type), (feedback, rendezvous_server)) = + let ((mut stream, direct, pk, _kcp, _stream_type), (feedback, rendezvous_server)) = Client::start(id, key, token, conn_type, interface.clone()).await?; + let mut trust = crate::trusted_devices::client::Session::new( + &interface.get_lch().read().unwrap(), stream.is_secured(), pk.as_deref())?; interface.update_direct(Some(direct)); if !stream.is_secured() && !crate::common::is_direct_ip_access(id) { if !confirm_insecure_connection(&interface, ui_receiver).await { @@ -448,11 +456,11 @@ async fn connect_and_login_mux( match msg_in.union { Some(message::Union::Hash(hash)) => { challenge = Some(hash.clone()); - if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, true, &mut stream).await { + if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, true, &mut trust, &mut stream).await { return Ok(None); } } - Some(message::Union::LoginResponse(lr)) => match lr.union { + Some(message::Union::LoginResponse(lr)) => match trust.respond(lr, &interface).await.union { Some(login_response::Union::Error(err)) => { if !interface.handle_login_error(&err) { return Ok(None); @@ -481,10 +489,11 @@ async fn connect_and_login_mux( d = ui_receiver.recv() => { match d { Some(Data::Login(login)) => match &challenge { - Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, true, &mut stream).await, + Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, true, &mut trust, &mut stream).await, None => pending_login = Some(login), }, Some(Data::Message(msg)) => { + let msg = trust.outgoing(msg); allow_err!(stream.send(&msg).await); } _ => {} @@ -611,9 +620,9 @@ mod login_tests { } fn handle_peer_info(&self, _pi: PeerInfo) {} fn set_multiple_windows_session(&self, _sessions: Vec) {} - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool { + async fn handle_hash(&self, pass: &str, hash: Hash, trust: &mut crate::trusted_devices::client::Session, peer: &mut Stream) -> bool { sleep(self.pause).await; - crate::client::handle_hash(self.lc.clone(), pass, hash, self, peer).await + crate::client::handle_hash(self.lc.clone(), pass, hash, self, trust, peer).await } async fn handle_login_from_ui( &self, @@ -621,6 +630,7 @@ mod login_tests { os_password: String, password: String, remember: bool, + trust: &mut crate::trusted_devices::client::Session, peer: &mut Stream, ) { crate::client::handle_login_from_ui( @@ -629,9 +639,10 @@ mod login_tests { os_password, password, remember, + trust, peer, ) - .await + .await.unwrap(); } async fn handle_test_delay(&self, _t: TestDelay, _peer: &mut Stream) {} fn get_lch(&self) -> Arc> { @@ -703,9 +714,10 @@ mod login_tests { ui.pause = Duration::from_millis(50); let (mut a, mut a_peer) = loopback().await; let (mut b, mut b_peer) = loopback().await; + let (mut a_trust, mut b_trust) = Default::default(); tokio::join!( - login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut a), - login_with_hash(&ui, "pw", hash("b"), "b", 2, false, &mut b), + login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut a_trust, &mut a), + login_with_hash(&ui, "pw", hash("b"), "b", 2, false, &mut b_trust, &mut b), ); assert_eq!(target(&login_at(&mut a_peer).await), ("a".to_owned(), 1)); assert_eq!(target(&login_at(&mut b_peer).await), ("b".to_owned(), 2)); @@ -723,10 +735,10 @@ mod login_tests { let (mut a, mut a_peer) = loopback().await; let (mut b, mut b_peer) = loopback().await; // A's hash arrived last, so it is the one the handler holds. - assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut a).await); + assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut Default::default(), &mut a).await); login_at(&mut a_peer).await; let typed = (String::new(), String::new(), "pw".to_owned(), false); - login_from_ui(&ui, &hash("b"), typed, "b", 2, false, &mut b).await; + login_from_ui(&ui, &hash("b"), typed, "b", 2, false, &mut Default::default(), &mut b).await; let lr = login_at(&mut b_peer).await; assert_eq!(lr.password, digest("b")); assert_eq!(target(&lr), ("b".to_owned(), 2)); @@ -742,7 +754,7 @@ mod login_tests { rt.block_on(async { let ui = window(); let (mut a, mut a_peer) = loopback().await; - assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, true, &mut a).await); + assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, true, &mut Default::default(), &mut a).await); assert!(login_at(&mut a_peer).await.port_forward().multiplex); }); } diff --git a/src/port_forward_mux.rs b/src/port_forward_mux.rs index 32287952a..1c9989499 100644 --- a/src/port_forward_mux.rs +++ b/src/port_forward_mux.rs @@ -1167,7 +1167,7 @@ mod tests { } fn handle_peer_info(&self, _pi: PeerInfo) {} fn set_multiple_windows_session(&self, _sessions: Vec) {} - async fn handle_hash(&self, _pass: &str, _hash: Hash, _peer: &mut Stream) -> bool { + async fn handle_hash(&self, _pass: &str, _hash: Hash, _trust: &mut crate::trusted_devices::client::Session, _peer: &mut Stream) -> bool { false } async fn handle_login_from_ui( @@ -1176,6 +1176,7 @@ mod tests { _os_password: String, _password: String, _remember: bool, + _trust: &mut crate::trusted_devices::client::Session, _peer: &mut Stream, ) { } diff --git a/src/server.rs b/src/server.rs index 472ab1a8e..8e4c56cba 100644 --- a/src/server.rs +++ b/src/server.rs @@ -648,6 +648,7 @@ pub fn check_zombie() { #[cfg(any(target_os = "android", target_os = "ios"))] #[tokio::main] pub async fn start_server(_is_server: bool) { + allow_err!(crate::trusted_devices::initialize()); crate::RendezvousMediator::start_all().await; } @@ -712,6 +713,7 @@ pub async fn start_server(is_server: bool, no_server: bool) { } #[cfg(any(target_os = "macos", target_os = "linux"))] wait_initial_config_sync().await; + allow_err!(crate::trusted_devices::initialize()); #[cfg(target_os = "windows")] crate::platform::try_kill_broker(); #[cfg(feature = "hwcodec")] @@ -725,11 +727,21 @@ pub async fn start_server(is_server: bool, no_server: bool) { match data { Data::SyncConfig(Some(configs)) => { let (config, config2) = *configs; - if Config::set(config) { - log::info!("config synced"); - } - if Config2::set(config2) { - log::info!("config2 synced"); + #[cfg(target_os = "windows")] + allow_err!( + crate::trusted_devices::config::update(move || { + crate::trusted_devices::config::sync_config(config, config2) + }) + .await + ); + #[cfg(not(target_os = "windows"))] + { + if Config::set(config) { + log::info!("config synced"); + } + if Config2::set(config2) { + log::info!("config2 synced"); + } } } _ => {} @@ -838,7 +850,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { if !synced { if conn.send(&Data::SyncConfig(None)).await.is_ok() { @@ -850,16 +862,25 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option cfg0 = configs, + Err(error) => { + crate::trusted_devices::config::log_sync_error( + error, + ); + continue; + } } + log::info!("sync config from root"); } else { // only on macos, because this issue was only reproduced on macos #[cfg(target_os = "macos")] @@ -900,7 +921,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { log::error!("sync config to root failed: {}", e); - match crate::ipc::connect_service(1000).await { + match crate::ipc::connect_trusted_device_service(1000).await { Ok(mut _conn) => { conn = _conn; log::info!("reconnected to ipc_service"); @@ -909,9 +930,18 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { - cfg0 = cfg; - conn.next_timeout(1000).await.ok(); - is_root_config_empty = false; + match conn.next_timeout(1000).await { + Ok(Some(Data::SyncConfig(None))) => { + cfg0 = cfg; + is_root_config_empty = false; + } + Ok(Some(Data::SecurityConfigError(error))) => { + crate::trusted_devices::config::log_sync_error(error) + } + _ => crate::trusted_devices::config::log_sync_error( + "Host did not confirm configuration sync", + ), + } } } } diff --git a/src/server/connection.rs b/src/server/connection.rs index b0cbcb6dd..e9ea313dd 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -31,7 +31,7 @@ use hbb_common::{ config::{ self, decode_permanent_password_h1_from_storage, decode_preset_password_h1_from_storage, local_permanent_password_storage_is_usable_for_auth, - preset_permanent_password_storage_is_usable_for_auth, Config, TrustedDevice, + preset_permanent_password_storage_is_usable_for_auth, Config, }, futures::{SinkExt, StreamExt}, get_time, get_version_number, @@ -75,6 +75,8 @@ use windows::Win32::Foundation::{CloseHandle, HANDLE}; use crate::virtual_display_manager; pub type Sender = mpsc::UnboundedSender<(Instant, Arc)>; +mod trusted_devices; + const FAILURE_IDX_ID_WHITELIST: usize = 2; // How long a rejection counts, so also how long a blocked address stays blocked. Longer // throttles enumeration harder; shorter limits collateral on whitelisted neighbours. @@ -374,6 +376,7 @@ pub struct Connection { unauthorized_id: Option, require_2fa: Option, awaiting_2fa: bool, + trusted_device: Option, keyboard: bool, clipboard: bool, audio: bool, @@ -569,6 +572,7 @@ impl Connection { }, require_2fa: crate::auth_2fa::get_2fa(None), awaiting_2fa: false, + trusted_device: None, // Defer display enumeration until login succeeds. Monitor login replaces this // with the primary index returned with the refreshed display snapshot. display_idx: 0, @@ -750,6 +754,7 @@ impl Connection { match data { ipc::Data::Authorize => { conn.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::Click); + conn.trusted_device = None; conn.require_2fa.take(); if !conn.send_logon_response_and_keep_alive().await { break; @@ -1886,6 +1891,10 @@ impl Connection { return true; } self.awaiting_2fa = false; + // The later check also covers revocation while connection setup awaits. + if !self.check_trusted_device().await { + return false; + } if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await { return keep_alive; } @@ -1896,6 +1905,10 @@ impl Connection { if !self.connect_port_forward_if_needed().await { return false; } + if !self.check_trusted_device().await { + return false; + } + self.lr.trusted_device_v2_token.clear(); self.authorized = true; self.unauthorized_id = None; // One-time means gone once it has let a peer in, not once that peer @@ -2028,6 +2041,9 @@ impl Connection { .into(); let mut msg_out = Message::new(); res.set_peer_info(pi); + if !self.enroll_trusted_device(&mut res).await { + return false; + } msg_out.set_login_response(res); self.send(msg_out).await; return true; @@ -2175,6 +2191,9 @@ impl Connection { } self.on_remote_authorized(); } + if !self.enroll_trusted_device(&mut res).await { + return false; + } let mut msg_out = Message::new(); msg_out.set_login_response(res); self.send(msg_out).await; @@ -2405,9 +2424,8 @@ impl Connection { let mut msg_out = Message::new(); let mut res = LoginResponse::new(); res.set_error(err.to_string()); - if err.to_string() == crate::client::REQUIRE_2FA { - res.enable_trusted_devices = Self::enable_trusted_devices(); - } + res.trusted_device_v2_supported = + self.lr.trusted_device_v2_supported && self.trusted_device.is_some(); msg_out.set_login_response(res); self.send(msg_out).await; } @@ -2741,14 +2759,6 @@ impl Connection { } } - #[inline] - fn enable_trusted_devices() -> bool { - config::option2bool( - keys::OPTION_ENABLE_TRUSTED_DEVICES, - &Config::get_option(keys::OPTION_ENABLE_TRUSTED_DEVICES), - ) - } - fn reset_session_scope_for_login(&mut self) { self.file_transfer = None; self.view_camera = false; @@ -2849,20 +2859,6 @@ impl Connection { if let Some(o) = lr.option.as_ref() { self.options_in_login = Some(o.clone()); } - if self.require_2fa.is_some() && !lr.hwid.is_empty() && Self::enable_trusted_devices() { - let devices = Config::get_trusted_devices(); - if let Some(device) = devices.iter().find(|d| d.hwid == lr.hwid) { - if !device.outdate() - && device.id == lr.my_id - && device.name == lr.my_name - && device.platform == lr.my_platform - { - log::info!("2FA bypassed by trusted devices"); - self.set_conn_audit_two_factor(ConnAuditTwoFactor::TrustedDevice); - self.require_2fa = None; - } - } - } self.video_ack_required = lr.video_ack_required; } @@ -2917,6 +2913,10 @@ impl Connection { return false; } self.awaiting_2fa = false; + self.trusted_device = None; + if !self.authorized { + self.require_2fa = crate::auth_2fa::get_2fa(None); + } self.handle_login_request_without_validation(&lr).await; if self.authorized { return true; @@ -3089,6 +3089,9 @@ impl Connection { self.try_start_cm(lr.my_id, lr.my_name, false); } else { self.update_failure_with_scope(failure, true, 0, FailureScope::Default); + if !self.begin_trusted_device().await { + return false; + } if !self.send_logon_response_and_keep_alive().await { return false; } @@ -3111,6 +3114,9 @@ impl Connection { self.update_failure(failure, true, 1); self.require_2fa.take(); self.set_conn_audit_two_factor(ConnAuditTwoFactor::Totp); + if let Some(attempt) = self.trusted_device.as_mut() { + attempt.enroll = tfa.trust_this_device; + } raii::AuthedConnID::set_session_2fa(self.session_key()); if !self.send_logon_response_and_keep_alive().await { return false; @@ -3120,15 +3126,6 @@ impl Connection { self.lr.my_name.to_owned(), self.authorized, ); - if !tfa.hwid.is_empty() && Self::enable_trusted_devices() { - Config::add_trusted_device(TrustedDevice { - hwid: tfa.hwid, - time: hbb_common::get_time(), - id: self.lr.my_id.clone(), - name: self.lr.my_name.clone(), - platform: self.lr.my_platform.clone(), - }); - } } else { self.update_failure(failure, false, 1); self.send_login_error(crate::client::LOGIN_MSG_2FA_WRONG) @@ -3179,6 +3176,7 @@ impl Connection { return false; } self.from_switch = true; + self.trusted_device = None; self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides); if !self.send_logon_response_and_keep_alive().await { return false; diff --git a/src/server/connection/trusted_devices.rs b/src/server/connection/trusted_devices.rs new file mode 100644 index 000000000..d058a20ff --- /dev/null +++ b/src/server/connection/trusted_devices.rs @@ -0,0 +1,101 @@ +use super::{ConnAuditPrimaryAuth, ConnAuditTwoFactor, Connection}; +use crate::trusted_devices::{ + authority::Unavailable, + host::{self, LoginProof}, + DeviceInfo, +}; +use base::message_proto::LoginResponse; + +impl Connection { + pub(super) async fn begin_trusted_device(&mut self) -> bool { + if self.conn_audit_primary_auth != ConnAuditPrimaryAuth::PermanentPassword + || !self.stream.is_secured() + || self.require_2fa.is_none() + || self.from_switch + || !hbb_common::config::Config::get_bool_option( + base::config::keys::OPTION_ENABLE_TRUSTED_DEVICES, + ) + || (!self.lr.trusted_device_v2_supported && self.lr.hwid.is_empty()) + { + self.lr.trusted_device_v2_token.clear(); + return true; + } + let result = match LoginProof::from_login(&mut self.lr, &self.hash) { + Ok(proof) => host::begin(proof).await, + Err(error) => Err(error), + }; + match result { + Ok(Some((attempt, valid, totp))) => { + self.trusted_device = attempt; + self.require_2fa = Some(totp); + if valid { + self.require_2fa = None; + self.set_conn_audit_two_factor(ConnAuditTwoFactor::TrustedDevice); + } + true + } + Ok(None) => true, + Err(error) => { + self.send_login_error(error).await; + false + } + } + } + + pub(super) async fn check_trusted_device(&mut self) -> bool { + if let Some(attempt) = self.trusted_device.as_mut() { + if let Err(error) = attempt.check().await { + if self.conn_audit_two_factor == ConnAuditTwoFactor::Totp + && error.is::() + { + // Retain the ticket for the final authentication check. + attempt.enroll = false; + host::log_unavailable(&error); + return true; + } + self.trusted_device = None; + self.send_login_error(error).await; + self.on_close("Trusted-device authentication failed", false) + .await; + return false; + } + } + true + } + + pub(super) async fn enroll_trusted_device(&mut self, response: &mut LoginResponse) -> bool { + let Some(attempt) = self.trusted_device.take() else { + return true; + }; + if !response.has_peer_info() + || self.conn_audit_primary_auth != ConnAuditPrimaryAuth::PermanentPassword + { + return true; + } + let info = DeviceInfo { + id: self.lr.my_id.clone(), + name: self.lr.my_name.clone(), + platform: self.lr.my_platform.clone(), + ..Default::default() + }; + match attempt.finish(info).await { + Ok(token) => { + response.trusted_device_v2_supported = self.lr.trusted_device_v2_supported; + response.trusted_device_v2_token = token.into(); + true + } + Err(error) => { + if self.conn_audit_two_factor == ConnAuditTwoFactor::Totp + && error.is::() + { + host::log_unavailable(&error); + return true; + } + self.send_login_error(error).await; + self.on_close("Trusted-device enrollment failed", false) + .await; + false + } + } + } +} diff --git a/src/trusted_devices/authority.rs b/src/trusted_devices/authority.rs new file mode 100644 index 000000000..aef9763f4 --- /dev/null +++ b/src/trusted_devices/authority.rs @@ -0,0 +1,104 @@ +use super::{Ticket, TOKEN_BYTES}; +use hbb_common::{bail, get_time, sodiumoxide::utils, ResultType}; +use serde_derive::{Deserialize, Serialize}; + +#[derive(Debug)] +pub struct Unavailable(pub hbb_common::anyhow::Error); + +impl std::fmt::Display for Unavailable { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.0) + } +} + +impl std::error::Error for Unavailable {} + +#[derive(Clone, Serialize, Deserialize)] +pub enum Request { + List, + Clear, + Remove(Vec<[u8; TOKEN_BYTES]>), + Renew(Vec<[u8; TOKEN_BYTES]>), +} + +#[derive(Clone, Serialize, Deserialize)] +pub enum Response { + Done, + Devices(String), +} + +#[derive(Clone)] +pub enum Credential { + Token([u8; TOKEN_BYTES]), + Legacy([u8; TOKEN_BYTES]), +} + +impl std::fmt::Debug for Request { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("TrustedDeviceRequest") + } +} + +impl std::fmt::Debug for Response { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("TrustedDeviceResponse") + } +} + +pub async fn request(request: Request) -> ResultType { + super::config::update(move || handle(request)).await +} + +pub fn handle(request: Request) -> ResultType { + match request { + Request::Clear => super::clear()?, + Request::List => return super::devices().map(Response::Devices), + Request::Remove(record_ids) => super::remove(&record_ids)?, + Request::Renew(record_ids) => super::renew(&record_ids)?, + } + Ok(Response::Done) +} + +pub(super) fn authorize( + scope: &[u8; TOKEN_BYTES], + apply: impl FnOnce(&mut super::Grants, u32) -> ResultType<(T, bool)>, +) -> ResultType { + // Configuration errors take precedence even if the grant lock is unavailable. + let storage = super::storage(); + let settings = super::config::snapshot()?; + if !settings.enabled || settings.totp.is_empty() { + bail!("Trusted devices require two-factor authentication to be enabled"); + } + if !utils::memcmp(scope, &settings.scope) { + bail!("Trusted-device authentication settings changed; reconnect and authenticate again"); + } + let storage = storage?; + let mut grants = super::load_grants(&storage, &settings.scope)?; + let (result, changed) = apply(&mut grants, settings.validity_days)?; + if changed { + storage.write(&grants).map_err(Unavailable)?; + } + Ok(result) +} + +pub(super) fn begin( + scope: [u8; TOKEN_BYTES], + verifier: Option<[u8; TOKEN_BYTES]>, + legacy: Option<[u8; TOKEN_BYTES]>, +) -> ResultType<(Ticket, Option)> { + authorize(&scope, |grants, _| { + let ticket = grants.begin(&scope)?; + let mut changed = false; + let credential = if grants.verify_digest(&ticket, verifier.as_ref(), get_time())? { + changed = verifier + .as_ref() + .is_some_and(|value| grants.confirm_migration(value)); + verifier.map(Credential::Token) + } else if grants.verify_legacy(&ticket, legacy.as_ref())? { + legacy.map(Credential::Legacy) + } else { + None + }; + Ok(((ticket, credential), changed)) + }) +} diff --git a/src/trusted_devices/client.rs b/src/trusted_devices/client.rs new file mode 100644 index 000000000..3a52e175a --- /dev/null +++ b/src/trusted_devices/client.rs @@ -0,0 +1,210 @@ +use super::TOKEN_BYTES; +use base::message_proto::{login_response, message, LoginResponse, Message}; +use hbb_common::{ + anyhow::anyhow, + bail, + config::{Config, PeerConfig, PeerInfoSerde}, + password_security::symmetric_crypt, + tokio, ResultType, +}; +use serde_derive::{Deserialize, Serialize}; +use std::convert::TryInto; + +#[derive(Default)] +pub struct Session { + credentials: Option, + supported: bool, + requested: bool, + legacy: bool, +} + +impl Session { + pub async fn respond( + &mut self, + response: LoginResponse, + interface: &impl crate::client::Interface, + ) -> LoginResponse { + match self.response(&response).await { + Ok(supported) => { + interface.get_lch().write().unwrap().enable_trusted_devices = supported + } + Err(error) => { + interface.get_lch().write().unwrap().enable_trusted_devices = false; + const ERROR_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + hbb_common::throttled_log!( + ERROR_LOG_INTERVAL, + error, + "Trusted-device storage: {error}" + ); + interface.msgbox( + "custom-error-trusted-device", + "Error", + &format!("Trusted-device storage: {error}"), + "", + ); + } + } + response + } + + pub fn new( + login: &crate::client::LoginConfigHandler, + secured: bool, + key: Option<&[u8]>, + ) -> ResultType { + let Some(key) = key.filter(|_| secured) else { + return Ok(Self::default()); + }; + let mut servers = login + .other_server + .as_ref() + .map(|(_, server, _)| vec![server.clone()]) + .unwrap_or_else(Config::get_rendezvous_servers); + servers.sort(); + let namespace = serde_json::to_string(&servers)?; + let credentials = Credentials::new(login.get_id().to_owned(), namespace, key)?; + Ok(Self { + credentials: Some(credentials), + ..Default::default() + }) + } + + pub async fn login(&mut self, mut message: Message) -> ResultType { + self.supported = false; + self.requested = false; + self.legacy = false; + if let (Some(credentials), Some(message::Union::LoginRequest(request))) = + (self.credentials.clone(), message.union.as_mut()) + { + request.trusted_device_v2_supported = true; + self.legacy = !request.hwid.is_empty(); + if !request.password.is_empty() { + request.trusted_device_v2_token.clear(); + let token = match tokio::task::spawn_blocking(move || credentials.load()).await? { + Ok(token) => token.unwrap_or_default(), + Err(error) => { + self.credentials = None; + self.legacy = false; + request.trusted_device_v2_supported = false; + const LOG_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(30); + hbb_common::throttled_log!(LOG_INTERVAL, warn, + "Trusted-device credential unavailable; using normal authentication for this connection: {error}"); + return Ok(message); + } + }; + request.trusted_device_v2_token = token.into(); + } + } + Ok(message) + } + + pub fn outgoing(&mut self, mut message: Message) -> Message { + if let Some(message::Union::Auth2fa(auth)) = message.union.as_mut() { + self.legacy = false; + auth.trust_this_device &= self.supported && self.credentials.is_some(); + self.requested = auth.trust_this_device; + } + message + } + + pub async fn response(&mut self, response: &LoginResponse) -> ResultType { + self.supported = response.trusted_device_v2_supported && self.credentials.is_some(); + let Some(credentials) = self.credentials.clone() else { + return Ok(false); + }; + if let Some(login_response::Union::PeerInfo(info)) = response.union.as_ref() { + let token = response.trusted_device_v2_token.to_vec(); + if self.requested && token.is_empty() { + bail!("Login succeeded, but the host did not remember this device"); + } + if !token.is_empty() { + if !self.requested && !(self.legacy && response.trusted_device_v2_supported) { + bail!("Host returned an unsolicited trusted-device credential"); + } + let info = PeerInfoSerde { + username: info.username.clone(), + hostname: info.hostname.clone(), + platform: info.platform.clone(), + }; + tokio::task::spawn_blocking(move || credentials.save(&token, info)).await??; + } + } + Ok(self.supported) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Issuer { + namespace: String, + public_key: [u8; TOKEN_BYTES], +} + +#[derive(Serialize, Deserialize)] +struct SavedToken { + issuer: Issuer, + ciphertext: Vec, +} + +#[derive(Clone)] +pub(super) struct Credentials { + peer_id: String, + issuer: Issuer, +} + +impl Credentials { + pub fn new(peer_id: String, namespace: String, public_key: &[u8]) -> ResultType { + Ok(Self { + peer_id, + issuer: Issuer { + namespace, + public_key: public_key + .try_into() + .map_err(|_| anyhow!("Invalid trusted host public key"))?, + }, + }) + } + + pub fn load(&self) -> ResultType>> { + let value = PeerConfig::get_trusted_device_v2(&self.peer_id)?; + match self.decode(&value) { + Ok(token) => Ok(token), + Err(error) => { + const LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + hbb_common::throttled_log!(LOG_INTERVAL, warn, + "Ignoring invalid trusted-device credential; re-enrollment is available: {error}"); + Ok(None) + } + } + } + + fn decode(&self, value: &str) -> ResultType>> { + if value.is_empty() { + return Ok(None); + } + let saved: SavedToken = serde_json::from_str(value) + .map_err(|_| anyhow!("Invalid stored trusted-device credential"))?; + if saved.issuer != self.issuer { + return Ok(None); + } + let token = symmetric_crypt(&saved.ciphertext, false) + .map_err(|_| anyhow!("Could not decrypt trusted-device credential"))?; + if token.len() != TOKEN_BYTES { + bail!("Invalid stored trusted-device credential"); + } + Ok(Some(token)) + } + + pub fn save(&self, token: &[u8], info: PeerInfoSerde) -> ResultType<()> { + if token.len() != TOKEN_BYTES { + bail!("Invalid issued trusted-device credential"); + } + let ciphertext = symmetric_crypt(token, true) + .map_err(|_| anyhow!("Could not encrypt trusted-device credential"))?; + let saved = SavedToken { + issuer: self.issuer.clone(), + ciphertext, + }; + PeerConfig::set_trusted_device_v2(&self.peer_id, serde_json::to_string(&saved)?, info) + } +} diff --git a/src/trusted_devices/config.rs b/src/trusted_devices/config.rs new file mode 100644 index 000000000..d23fbc9a8 --- /dev/null +++ b/src/trusted_devices/config.rs @@ -0,0 +1,291 @@ +use super::policy; +pub use super::policy::{changes_authentication, update_options}; +use base::config::keys; +use hbb_common::{ + anyhow::{anyhow, Context}, + bail, + config::{self, Config, Config2}, + password_security::decrypt_str_or_original, + sodiumoxide::{crypto::hash::sha256, utils}, + ResultType, +}; +use serde_derive::{Deserialize, Serialize}; +use std::{collections::HashMap, io::ErrorKind, path::Path}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +const SYNC_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn log_sync_error(error: impl std::fmt::Display) { + hbb_common::throttled_log!( + SYNC_LOG_INTERVAL, + error, + "Configuration sync rejected: {error}" + ); +} + +pub async fn update( + apply: impl FnOnce() -> ResultType + Send + 'static, +) -> ResultType { + hbb_common::tokio::task::spawn_blocking(apply).await? +} + +pub async fn update_auth( + affected: bool, + apply: impl FnOnce() -> ResultType + Send + 'static, +) -> ResultType { + update(move || { + if affected && super::storage()?.exists()? { + snapshot()?; + } + apply() + }) + .await +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos", test))] +pub fn sync_config(value: Config, options: Config2) -> ResultType<()> { + let _storage = super::storage()?; + Config::set(value); + Config2::set(options.clone()); + snapshot()?; + // Explicit configuration sync carries the source's grants and removals. + options.sync_trusted_devices() +} + +#[cfg(target_os = "windows")] +pub async fn refresh_from_server() -> ResultType<()> { + use crate::ipc::{connect, Data}; + + const TIMEOUT_MS: u64 = 1000; + let response = async { + let mut connection = connect(TIMEOUT_MS, "").await?; + connection.send(&Data::SyncConfig(None)).await?; + connection.next_timeout(TIMEOUT_MS).await + } + .await; + match response { + Ok(Some(Data::SyncConfig(Some(configs)))) => { + let (config, config2) = *configs; + update(move || sync_config(config, config2)).await + } + Ok(Some(Data::SecurityConfigError(error))) => bail!(error), + Ok(_) => bail!("The host did not provide its current configuration"), + Err(error) => { + hbb_common::log::warn!( + "Host unavailable during service removal; revoking local trusted devices (re-enrollment required): {error:#}" + ); + // The next service import must not restore an inactive, stale grant copy. + update(super::clear).await + } + } +} + +#[derive(Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +struct Password { + password: String, + salt: String, +} + +#[derive(Default, Deserialize)] +#[serde(default)] +struct Options { + options: HashMap, +} + +pub struct Snapshot { + pub scope: [u8; 32], + pub h1: Option<[u8; 32]>, + pub salt: String, + pub totp: String, + pub enabled: bool, + pub validity_days: u32, +} + +fn read(path: &Path) -> ResultType { + match std::fs::read_to_string(path) { + Ok(data) => { + hbb_common::toml::from_str(&data).context("Read trusted-device authentication settings") + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()), + Err(error) => Err(error).context("Open trusted-device authentication settings"), + } +} + +fn password(config: &Config) -> ResultType { + Ok(serde_json::from_value(serde_json::to_value(config)?)?) +} + +pub(super) fn effective_options(options: HashMap) -> HashMap { + let mut effective = config::DEFAULT_SETTINGS.read().unwrap().clone(); + effective.extend(options); + effective.extend(config::OVERWRITE_SETTINGS.read().unwrap().clone()); + effective +} + +fn normalize(mut password: Password, on_disk: bool) -> (Option<[u8; 32]>, String) { + if on_disk && password.password.starts_with("00") { + let (plain, decrypted, _) = decrypt_str_or_original(&password.password, "00"); + if decrypted { + password.password = plain; + } + } + if !password.password.is_empty() { + if !config::local_permanent_password_storage_is_usable_for_auth( + &password.password, + &password.salt, + ) { + return (None, password.salt); + } + let h1 = config::decode_permanent_password_h1_from_storage(&password.password) + .unwrap_or_else(|| { + config::compute_permanent_password_h1(&password.password, &password.salt) + }); + return (Some(h1), password.salt); + } + let (preset, salt) = Config::get_preset_password_storage_and_salt(); + if !config::preset_permanent_password_storage_is_usable_for_auth(&preset, &salt) { + return (None, password.salt); + } + if salt.is_empty() { + ( + Some(config::compute_permanent_password_h1( + &preset, + &password.salt, + )), + password.salt, + ) + } else { + ( + config::decode_preset_password_h1_from_storage(&preset), + salt, + ) + } +} + +fn build( + password: Password, + options: HashMap, + on_disk: bool, +) -> ResultType { + let (h1, salt) = normalize(password, on_disk); + let totp = options.get("2fa").cloned().unwrap_or_default(); + let enabled = config::option2bool( + keys::OPTION_ENABLE_TRUSTED_DEVICES, + options + .get(keys::OPTION_ENABLE_TRUSTED_DEVICES) + .map(String::as_str) + .unwrap_or_default(), + ); + let validity_days = policy::days(&options); + let scope = sha256::hash(&serde_json::to_vec(&(&h1, &salt, &totp, enabled))?).0; + Ok(Snapshot { + scope, + h1, + salt, + totp, + enabled, + validity_days, + }) +} + +pub fn snapshot() -> ResultType { + let current = build(password(&Config::get())?, Config::get_options(), false)?; + let stored_options: Options = read(&Config2::file())?; + let stored = build( + read(&Config::file())?, + effective_options(stored_options.options), + true, + )?; + if !utils::memcmp(¤t.scope, &stored.scope) + || current.validity_days != stored.validity_days + { + bail!("Trusted-device authentication settings are out of sync; reconnect after configuration sync"); + } + Ok(current) +} + +pub(super) fn change(affected: bool, apply: impl FnOnce() -> ResultType) -> ResultType { + if !affected { + return apply(); + } + let storage = super::storage()?; + if !storage.exists()? { + return apply(); + } + let mut grants = super::Grants::default(); + grants.revoke(None)?; + storage.write(&grants)?; + // Keep the credential lock until the new authentication settings are visible. + let result = apply()?; + grants.revoke(Some(snapshot()?.scope))?; + storage.write(&grants)?; + Ok(result) +} + +pub fn set_options(options: HashMap) -> ResultType<()> { + policy::change_options(effective_options(options.clone()), || { + Config::set_options(options); + Ok(()) + }) +} + +pub fn set_password(value: &str) -> ResultType { + change(changes_password(value), || { + Ok(Config::set_permanent_password(value)) + }) +} + +pub fn changes_password(value: &str) -> bool { + if Config::is_disable_change_permanent_password() { + return false; + } + let (password, salt) = Config::get_local_permanent_password_storage_and_salt(); + // Match the setter's no-op cases; existing local passwords are re-encrypted. + if !password.is_empty() { + return true; + } + if value.is_empty() { + return false; + } + let (h1, salt) = normalize(Password { password, salt }, false); + h1.map(|h1| !utils::memcmp(&h1, &config::compute_permanent_password_h1(value, &salt))) + .unwrap_or(true) +} + +pub fn set_salt(salt: &str) -> ResultType<()> { + let affected = Config::get_local_permanent_password_storage_and_salt().1 != salt; + change(affected, || { + Config::set_salt(salt); + Ok(()) + }) +} + +pub fn set_config(value: Config) -> ResultType { + let affected = + normalize(password(&Config::get())?, false) != normalize(password(&value)?, false); + change(affected, || Ok(Config::set(value))) +} + +pub fn set_config2(value: Config2) -> ResultType { + policy::change_options(effective_options(value.options.clone()), || { + Ok(Config2::set(value)) + }) +} + +impl Snapshot { + pub fn verify_password(&self, proof: &super::host::LoginProof) -> ResultType<()> { + let h1 = self + .h1 + .ok_or_else(|| anyhow!("Permanent password is unavailable for trusted devices"))?; + let mut material = h1.to_vec(); + material.extend_from_slice(proof.challenge.as_bytes()); + if self.salt != proof.salt || !utils::memcmp(&sha256::hash(&material).0, &proof.password) { + bail!( + "Trusted-device password configuration changed; reconnect and authenticate again" + ); + } + Ok(()) + } +} diff --git a/src/trusted_devices/host.rs b/src/trusted_devices/host.rs new file mode 100644 index 000000000..a8dde851a --- /dev/null +++ b/src/trusted_devices/host.rs @@ -0,0 +1,146 @@ +use super::{ + authority::{self, Credential}, + random_token, token_verifier, DeviceInfo, Ticket, TOKEN_BYTES, +}; +use base::message_proto::{Hash, LoginRequest}; +use hbb_common::{bail, get_time, sodiumoxide::crypto::hash::sha256, ResultType}; + +#[derive(Default)] +pub struct LoginProof { + pub password: Vec, + pub challenge: String, + pub salt: String, + pub token: Vec, + pub legacy: Option<[u8; TOKEN_BYTES]>, + pub supports_v2: bool, +} + +impl LoginProof { + pub fn from_login(request: &mut LoginRequest, hash: &Hash) -> ResultType { + let token = std::mem::take(&mut request.trusted_device_v2_token).to_vec(); + Ok(Self { + password: request.password.to_vec(), + challenge: hash.challenge.clone(), + salt: hash.salt.clone(), + token, + legacy: super::legacy::verifier( + &request.hwid, + &DeviceInfo { + id: request.my_id.clone(), + name: request.my_name.clone(), + platform: request.my_platform.clone(), + ..Default::default() + }, + )?, + supports_v2: request.trusted_device_v2_supported, + }) + } +} + +pub struct Attempt { + ticket: Ticket, + credential: Option, + pub enroll: bool, +} + +pub async fn begin( + proof: LoginProof, +) -> ResultType, bool, totp_rs::TOTP)>> { + let verifier = token_verifier(&proof.token); + let legacy = proof.legacy; + let supports_v2 = proof.supports_v2; + let settings = super::config::update(move || { + let settings = super::config::snapshot()?; + if !settings.enabled || settings.totp.is_empty() { + return Ok(None); + } + settings.verify_password(&proof)?; + let totp = crate::auth_2fa::TOTPInfo::from_str(&settings.totp)?; + Ok(Some((settings.scope, totp))) + }) + .await?; + let Some((scope, totp)) = settings else { + return Ok(None); + }; + let (ticket, credential) = + match super::config::update(move || authority::begin(scope, verifier, legacy)).await { + Ok(result) => result, + Err(error) if error.is::() => { + log_unavailable(&error); + return Ok(Some((None, false, totp))); + } + Err(error) => return Err(error), + }; + let valid = credential.is_some(); + if !valid && !supports_v2 { + return Ok(Some((None, false, totp))); + } + let enroll = supports_v2 && matches!(&credential, Some(Credential::Legacy(_))); + Ok(Some(( + Some(Attempt { + ticket, + credential, + enroll, + }), + valid, + totp, + ))) +} + +pub fn log_unavailable(error: &hbb_common::anyhow::Error) { + const LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + hbb_common::throttled_log!(LOG_INTERVAL, warn, + "Trusted-device authority unavailable; continuing password/TOTP without trusted devices: {error:#}"); +} + +impl Attempt { + pub async fn check(&self) -> ResultType<()> { + let ticket = self.ticket.clone(); + let credential = self.credential.clone(); + super::config::update(move || { + authority::authorize(&ticket.scope, |grants, _| { + let valid = match credential.as_ref() { + Some(Credential::Legacy(verifier)) => { + grants.migration_source(&ticket, verifier)?.is_some() + } + Some(Credential::Token(verifier)) => { + grants.verify_digest(&ticket, Some(verifier), get_time())? + } + None => grants.verify_digest(&ticket, None, get_time())?, + }; + if credential.is_some() && !valid { + bail!("Trusted-device credential expired or was revoked; reconnect and authenticate again"); + } + Ok(((), false)) + }) + }).await + } + + pub async fn finish(self, info: DeviceInfo) -> ResultType> { + if !self.enroll { + return Ok(Vec::new()); + } + super::config::update(move || { + authority::authorize(&self.ticket.scope, |grants, days| { + let token = random_token()?; + let verifier = sha256::hash(&token).0; + if let Some(Credential::Legacy(legacy)) = self.credential { + grants.migrate(&self.ticket, (legacy, verifier))?; + } else { + let time = get_time(); + grants.enroll_digest( + &self.ticket, + verifier, + DeviceInfo { + time, + expires_at: Some(super::policy::expires_at(time, days)), + ..info + }, + )?; + } + Ok((token.to_vec(), true)) + }) + }) + .await + } +} diff --git a/src/trusted_devices/legacy.rs b/src/trusted_devices/legacy.rs new file mode 100644 index 000000000..da46de984 --- /dev/null +++ b/src/trusted_devices/legacy.rs @@ -0,0 +1,145 @@ +use super::{config, policy, DeviceInfo, Grants, Record, Ticket, TOKEN_BYTES}; +use hbb_common::{ + anyhow::anyhow, + bail, get_time, + password_security::decrypt_str_or_original, + sodiumoxide::{crypto::hash::sha256, utils}, + ResultType, +}; +use serde_derive::Deserialize; + +const LEGACY_DAYS: u32 = 90; + +#[derive(Deserialize)] +struct LegacyDevice { + hwid: Vec, + #[serde(flatten)] + info: DeviceInfo, +} + +pub(super) fn verifier(hwid: &[u8], info: &DeviceInfo) -> ResultType> { + if hwid.is_empty() { + return Ok(None); + } + Ok(Some( + sha256::hash(&serde_json::to_vec(&( + hwid, + &info.id, + &info.name, + &info.platform, + ))?) + .0, + )) +} + +pub(super) fn import(grants: &mut Grants, value: &str, first_store: bool) -> ResultType<()> { + let settings = config::snapshot()?; + if first_store { + grants.revoke(Some(settings.scope))?; + } + // A previous revocation must take precedence over a remaining legacy copy. + if !grants.matches_scope(&settings.scope) { + return Ok(()); + } + let (plaintext, decrypted, _) = decrypt_str_or_original(value, "00"); + if !decrypted { + bail!("Could not decrypt legacy trusted devices"); + } + let devices: Vec = + serde_json::from_str(&plaintext).map_err(|_| anyhow!("Invalid legacy trusted devices"))?; + let now = get_time(); + for device in devices { + let Some(verifier) = verifier(&device.hwid, &device.info)? else { + continue; + }; + let mut info = device.info; + info.expires_at = Some( + info.expires_at + .unwrap_or_else(|| policy::expires_at(info.time, LEGACY_DAYS)), + ); + let record = Record { + verifier, + info, + legacy: true, + origin: None, + }; + if record.current(now) && !grants.records.iter().any(|old| old.id() == record.id()) { + grants.records.push(record); + } + } + policy::update_expiration(grants, settings.validity_days, now); + Ok(()) +} + +impl Grants { + pub(super) fn verify_legacy( + &self, + ticket: &Ticket, + verifier: Option<&[u8; TOKEN_BYTES]>, + ) -> ResultType { + self.check_ticket(ticket)?; + let Some(verifier) = verifier else { + return Ok(false); + }; + Ok(self.records.iter().any(|record| { + record.legacy && record.current(get_time()) && utils::memcmp(&record.verifier, verifier) + })) + } + + pub(super) fn migrate( + &mut self, + ticket: &Ticket, + credentials: ([u8; TOKEN_BYTES], [u8; TOKEN_BYTES]), + ) -> ResultType<()> { + let (legacy, verifier) = credentials; + let mut info = self + .migration_source(ticket, &legacy)? + .ok_or_else(|| anyhow!("Legacy trusted device expired or was revoked"))? + .info + .clone(); + let now = get_time(); + // Eviction follows V2 issuance time without extending the legacy expiry. + info.time = now; + self.records.push(Record { + verifier, + info, + legacy: false, + origin: Some(legacy), + }); + self.cleanup(now); + Ok(()) + } + + pub(super) fn migration_source( + &self, + ticket: &Ticket, + verifier: &[u8; TOKEN_BYTES], + ) -> ResultType> { + self.check_ticket(ticket)?; + // An already authorized attempt may finish after another connection confirms migration. + // New legacy logins still require a legacy record in verify_legacy. + Ok(self.records.iter().find(|record| { + record.current(get_time()) + && ((record.legacy && utils::memcmp(&record.verifier, verifier)) + || record + .origin + .as_ref() + .is_some_and(|origin| utils::memcmp(origin, verifier))) + })) + } + + pub(super) fn confirm_migration(&mut self, verifier: &[u8; TOKEN_BYTES]) -> bool { + let origin = self + .records + .iter() + .find(|record| !record.legacy && record.verifier == *verifier) + .and_then(|record| record.origin); + let Some(origin) = origin else { + return false; + }; + let previous = self.records.len(); + self.records + .retain(|record| !record.legacy || record.verifier != origin); + previous != self.records.len() + } +} diff --git a/src/trusted_devices/management.rs b/src/trusted_devices/management.rs new file mode 100644 index 000000000..622cc929e --- /dev/null +++ b/src/trusted_devices/management.rs @@ -0,0 +1,77 @@ +use super::{config, load_grants, policy, storage, DeviceInfo, TOKEN_BYTES}; +use hbb_common::{bail, get_time, ResultType}; +use serde_derive::Serialize; +use std::collections::HashSet; + +#[derive(Serialize)] +struct ListedDevice<'a> { + record_id: [u8; TOKEN_BYTES], + #[serde(flatten)] + info: &'a DeviceInfo, +} + +pub fn devices() -> ResultType { + let storage = storage()?; + if !storage.exists()? { + return Ok(serde_json::json!({ "devices": [] }).to_string()); + } + let current = config::snapshot()?; + let grants = load_grants(&storage, ¤t.scope)?; + let now = get_time(); + let mut seen = HashSet::new(); + let devices: Vec<_> = grants + .records + .iter() + .filter(|record| record.current(now) && seen.insert(record.id())) + .map(|record| ListedDevice { + record_id: record.id(), + info: &record.info, + }) + .collect(); + Ok(serde_json::json!({ "devices": devices }).to_string()) +} + +pub fn remove(record_ids: &[[u8; TOKEN_BYTES]]) -> ResultType<()> { + let storage = storage()?; + let mut grants = storage.read()?; + let previous = grants.records.len(); + grants + .records + .retain(|record| !record_ids.contains(&record.id())); + if grants.records.len() != previous { + storage.write(&grants)?; + } + Ok(()) +} + +pub fn renew(record_ids: &[[u8; TOKEN_BYTES]]) -> ResultType<()> { + if record_ids.is_empty() { + bail!("Select at least one trusted device"); + } + let storage = storage()?; + let settings = config::snapshot()?; + if !settings.enabled || settings.totp.is_empty() { + bail!("Trusted devices require two-factor authentication to be enabled"); + } + if settings.validity_days == policy::NEVER_EXPIRE { + bail!("Trusted devices are configured to never expire"); + } + let mut grants = load_grants(&storage, &settings.scope)?; + let now = get_time(); + for record_id in record_ids { + if !grants + .records + .iter() + .any(|record| record.id() == *record_id && record.current(now)) + { + bail!("Trusted device expired or was revoked; refresh the list"); + } + } + let expires_at = policy::expires_at(now, settings.validity_days); + for record in &mut grants.records { + if record_ids.contains(&record.id()) { + record.info.expires_at = Some(expires_at); + } + } + storage.write(&grants) +} diff --git a/src/trusted_devices/mod.rs b/src/trusted_devices/mod.rs new file mode 100644 index 000000000..69c0ab2f4 --- /dev/null +++ b/src/trusted_devices/mod.rs @@ -0,0 +1,244 @@ +pub mod authority; +pub mod client; +pub mod config; +pub mod host; +mod legacy; +mod management; +mod policy; +mod storage; + +pub use management::{devices, remove, renew}; + +#[cfg(test)] +pub(crate) mod tests; + +use hbb_common::{ + anyhow::anyhow, + bail, + config::Config2, + sodiumoxide::{crypto::hash::sha256, randombytes, utils}, + ResultType, +}; +use serde_derive::{Deserialize, Serialize}; +use storage::GrantsStorage; + +pub const TOKEN_BYTES: usize = 32; +pub const MAX_RECORDS: usize = 128; + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct DeviceInfo { + pub time: i64, + // Missing on V1 records until import; zero means no expiration. + #[serde(default)] + pub expires_at: Option, + pub id: String, + pub name: String, + pub platform: String, +} + +#[derive(Serialize, Deserialize)] +struct Record { + verifier: [u8; TOKEN_BYTES], + info: DeviceInfo, + #[serde(default)] + legacy: bool, + // Keep migrated credentials and their legacy entry in one revocation group. + #[serde(default, skip_serializing_if = "Option::is_none")] + origin: Option<[u8; TOKEN_BYTES]>, +} + +impl Record { + fn id(&self) -> [u8; TOKEN_BYTES] { + // Management identifiers must not expose authentication verifiers. + sha256::hash(&self.origin.unwrap_or(self.verifier)).0 + } + + fn current(&self, now: i64) -> bool { + now >= self.info.time + && self + .info + .expires_at + .is_some_and(|expiry| expiry == 0 || now < expiry) + } +} + +#[derive(Default, Serialize, Deserialize)] +pub struct Grants { + scope: Option<[u8; TOKEN_BYTES]>, + generation: [u8; TOKEN_BYTES], + records: Vec, +} + +#[derive(Clone)] +pub struct Ticket { + scope: [u8; TOKEN_BYTES], + generation: [u8; TOKEN_BYTES], +} + +impl Grants { + pub fn matches_scope(&self, scope: &[u8; TOKEN_BYTES]) -> bool { + self.scope + .as_ref() + .is_some_and(|stored| utils::memcmp(stored, scope)) + } + + pub fn revoke(&mut self, scope: Option<[u8; TOKEN_BYTES]>) -> ResultType<()> { + let generation = random_token()?; + self.records.clear(); + self.generation = generation; + self.scope = scope; + Ok(()) + } + + pub fn begin(&self, scope: &[u8; TOKEN_BYTES]) -> ResultType { + if !self.matches_scope(scope) { + bail!( + "Trusted-device authentication settings changed; reconnect and authenticate again" + ); + } + Ok(Ticket { + scope: *scope, + generation: self.generation, + }) + } + + fn check_ticket(&self, ticket: &Ticket) -> ResultType<()> { + if !self.matches_scope(&ticket.scope) + || !utils::memcmp(&self.generation, &ticket.generation) + { + bail!("Trusted-device authorization was revoked; reconnect and authenticate again"); + } + Ok(()) + } + + pub fn verify_digest( + &self, + ticket: &Ticket, + verifier: Option<&[u8; TOKEN_BYTES]>, + now: i64, + ) -> ResultType { + self.check_ticket(ticket)?; + let Some(verifier) = verifier else { + return Ok(false); + }; + Ok(self.records.iter().any(|record| { + !record.legacy && record.current(now) && utils::memcmp(&record.verifier, verifier) + })) + } + + pub fn enroll_digest( + &mut self, + ticket: &Ticket, + verifier: [u8; TOKEN_BYTES], + info: DeviceInfo, + ) -> ResultType<()> { + self.check_ticket(ticket)?; + if !info.id.is_empty() { + self.records.retain(|record| record.info.id != info.id); + } + let now = info.time; + self.records.push(Record { + verifier, + info, + legacy: false, + origin: None, + }); + self.cleanup(now); + Ok(()) + } + + pub fn cleanup(&mut self, now: i64) -> bool { + let previous = self.records.len(); + self.records.retain(|record| record.current(now)); + self.records.sort_by_key(|record| record.info.time); + // Upgrading must not evict existing legacy registrations to satisfy the V2 cap. + let mut excess = self + .records + .iter() + .filter(|record| !record.legacy) + .count() + .saturating_sub(MAX_RECORDS); + self.records.retain(|record| { + if !record.legacy && excess > 0 { + excess -= 1; + return false; + } + true + }); + previous != self.records.len() + } +} + +pub fn token_verifier(token: &[u8]) -> Option<[u8; TOKEN_BYTES]> { + (token.len() == TOKEN_BYTES).then(|| sha256::hash(token).0) +} + +pub fn random_token() -> ResultType<[u8; TOKEN_BYTES]> { + hbb_common::sodiumoxide::init() + .map_err(|_| anyhow!("Could not initialize secure random generation"))?; + let mut token = [0; TOKEN_BYTES]; + randombytes::randombytes_into(&mut token); + Ok(token) +} + +fn storage() -> ResultType { + Ok(GrantsStorage::lock().map_err(authority::Unavailable)?) +} + +fn load_grants(storage: &GrantsStorage, scope: &[u8; TOKEN_BYTES]) -> ResultType { + let mut grants: Grants = storage.read().map_err(authority::Unavailable)?; + if !storage.exists().map_err(authority::Unavailable)? { + grants.revoke(Some(*scope))?; + storage.write(&grants).map_err(authority::Unavailable)?; + } else if !grants.matches_scope(scope) { + return Err(authority::Unavailable(anyhow!( + "Trusted-device grants are out of sync; restart the host to synchronize" + )) + .into()); + } + if grants.cleanup(hbb_common::get_time()) { + storage.write(&grants).map_err(authority::Unavailable)?; + } + Ok(grants) +} + +pub fn clear() -> ResultType<()> { + let storage = storage()?; + if !storage.exists()? { + return Ok(()); + } + let scope = match config::snapshot() { + Ok(settings) => Some(settings.scope), + Err(error) => { + const LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + hbb_common::throttled_log!(LOG_INTERVAL, warn, + "Cannot read trusted-device authentication settings; revoking grants without an authentication scope: {error:#}"); + None + } + }; + let mut grants = Grants::default(); + grants.revoke(scope)?; + storage.write(&grants) +} + +pub fn initialize() -> ResultType<()> { + if !Config2::file().try_exists()? { + return Ok(()); + } + let storage = storage()?; + if !storage.exists()? { + return Ok(()); + } + let mut grants: Grants = storage.read()?; + let current = config::snapshot()?; + let scope_changed = !grants.matches_scope(¤t.scope); + if scope_changed { + grants.revoke(Some(current.scope))?; + } + if policy::update_expiration(&mut grants, current.validity_days, hbb_common::get_time()) + || scope_changed + { + storage.write(&grants)?; + } + Ok(()) +} diff --git a/src/trusted_devices/policy.rs b/src/trusted_devices/policy.rs new file mode 100644 index 000000000..26852c671 --- /dev/null +++ b/src/trusted_devices/policy.rs @@ -0,0 +1,103 @@ +use super::config; +use base::config::keys; +use hbb_common::{config::Config, get_time, ResultType}; +use std::collections::HashMap; + +pub const DEFAULT_DAYS: u32 = 90; +pub const NEVER_EXPIRE: u32 = 0; +pub const DAY_MILLIS: i64 = 24 * 60 * 60 * 1000; +const AUTH_OPTIONS: [&str; 2] = ["2fa", keys::OPTION_ENABLE_TRUSTED_DEVICES]; + +pub fn days(options: &HashMap) -> u32 { + match options + .get(keys::OPTION_TRUSTED_DEVICE_EXPIRATION) + .map(String::as_str) + { + None | Some("") | Some("90") => DEFAULT_DAYS, + Some("1") => 1, + Some("7") => 7, + Some("30") => 30, + Some("0") => NEVER_EXPIRE, + _ => { + const LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + hbb_common::throttled_log!(LOG_INTERVAL, warn, + "Invalid trusted-device expiration period; using the default of {DEFAULT_DAYS} days"); + DEFAULT_DAYS + } + } +} + +fn authentication_changed(old: &HashMap, new: &HashMap) -> bool { + AUTH_OPTIONS + .iter() + .any(|key| old.get(*key) != new.get(*key)) +} + +pub fn changes_authentication(options: &HashMap) -> bool { + let old = Config::get_options(); + let new = config::effective_options(options.clone()); + authentication_changed(&old, &new) || days(&old) != days(&new) +} + +pub async fn update_options(options: HashMap) -> ResultType<()> { + let old = Config::get_options(); + let new = config::effective_options(options.clone()); + let revoke = authentication_changed(&old, &new); + config::update_auth(revoke, move || config::set_options(options)).await +} + +pub(super) fn change_options( + options: HashMap, + apply: impl FnOnce() -> ResultType, +) -> ResultType { + let old = Config::get_options(); + if authentication_changed(&old, &options) { + return config::change(true, apply); + } + if days(&old) == days(&options) { + return apply(); + } + let storage = super::storage()?; + if !storage.exists()? { + return apply(); + } + let now = get_time(); + let scope = config::snapshot()?.scope; + let mut grants = storage.read()?; + if !grants.matches_scope(&scope) { + const LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + hbb_common::throttled_log!(LOG_INTERVAL, warn, + "Trusted-device grants remain out of sync; applying options without changing those grants"); + return apply(); + } + update_expiration(&mut grants, days(&options), now); + storage.write(&grants)?; + apply() +} + +pub(super) fn update_expiration(grants: &mut super::Grants, days: u32, now: i64) -> bool { + let mut changed = grants.cleanup(now); + let expiry = expires_at(now, days); + for record in &mut grants.records { + let Some(previous) = record.info.expires_at else { + continue; + }; + let next = if expiry == 0 || previous == 0 { + expiry + } else { + previous.min(expiry) + }; + if next != previous { + record.info.expires_at = Some(next); + changed = true; + } + } + changed +} + +pub fn expires_at(time: i64, days: u32) -> i64 { + if days == NEVER_EXPIRE { + return 0; + } + time.saturating_add(i64::from(days) * DAY_MILLIS) +} diff --git a/src/trusted_devices/storage.rs b/src/trusted_devices/storage.rs new file mode 100644 index 000000000..370e689b3 --- /dev/null +++ b/src/trusted_devices/storage.rs @@ -0,0 +1,73 @@ +use hbb_common::{ + anyhow::anyhow, config::Config2, password_security::symmetric_crypt, sodiumoxide::base64, + ResultType, +}; +use std::sync::{Mutex, MutexGuard}; + +const ENCRYPTION_VERSION: &str = "00"; +static GRANTS_LOCK: Mutex<()> = Mutex::new(()); + +pub(super) struct GrantsStorage { + _lock: MutexGuard<'static, ()>, +} + +impl GrantsStorage { + pub fn lock() -> ResultType { + Ok(Self { + _lock: GRANTS_LOCK + .lock() + .map_err(|_| anyhow!("Trusted-device storage lock is poisoned"))?, + }) + } + + pub fn read(&self) -> ResultType { + let (value, legacy) = Config2::get_trusted_device_stores()?; + let mut grants = match decode(&value) { + Ok(grants) => grants, + Err(error) => { + const LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + hbb_common::throttled_log!( + LOG_INTERVAL, + warn, + "Revoking unreadable trusted-device grants: {error}" + ); + // An unreadable V2 scope cannot authorize a remaining legacy copy. + Config2::set_trusted_devices_v2(String::new())?; + return Ok(super::Grants::default()); + } + }; + if !legacy.is_empty() { + super::legacy::import(&mut grants, &legacy, value.is_empty())?; + self.write(&grants)?; + } + Ok(grants) + } + + pub fn exists(&self) -> ResultType { + Ok(Config2::has_trusted_devices_v2()? + || !Config2::get_trusted_device_stores()?.1.is_empty()) + } + + pub fn write(&self, grants: &super::Grants) -> ResultType<()> { + let ciphertext = symmetric_crypt(&serde_json::to_vec(grants)?, true) + .map_err(|_| anyhow!("Could not encrypt trusted-device grants"))?; + Config2::set_trusted_devices_v2(format!( + "{ENCRYPTION_VERSION}{}", + base64::encode(&ciphertext, base64::Variant::Original) + )) + } +} + +fn decode(value: &str) -> ResultType { + if value.is_empty() { + return Ok(super::Grants::default()); + } + let ciphertext = value + .strip_prefix(ENCRYPTION_VERSION) + .ok_or_else(|| anyhow!("Invalid encrypted trusted-device grants"))?; + let ciphertext = base64::decode(ciphertext, base64::Variant::Original) + .map_err(|_| anyhow!("Invalid encrypted trusted-device grants"))?; + let plaintext = symmetric_crypt(&ciphertext, false) + .map_err(|_| anyhow!("Could not decrypt trusted-device grants"))?; + serde_json::from_slice(&plaintext).map_err(|_| anyhow!("Invalid trusted-device grants")) +} diff --git a/src/trusted_devices/tests.rs b/src/trusted_devices/tests.rs new file mode 100644 index 000000000..6f1715c8f --- /dev/null +++ b/src/trusted_devices/tests.rs @@ -0,0 +1,290 @@ +use super::{ + authority::{self, Request, Response}, + client::Credentials, + config, host, random_token, token_verifier, DeviceInfo, Grants, TOKEN_BYTES, +}; +use base::config::keys; +use hbb_common::{ + config::{Config, Config2, PeerConfig}, + sodiumoxide::crypto::hash::sha256, +}; +use std::fs; + +mod configuration; +mod recovery; + +const PASSWORD: &str = "permanent-test-password"; + +pub(crate) fn initialize_config() { + static INITIALIZED: std::sync::Once = std::sync::Once::new(); + INITIALIZED.call_once(|| { + *hbb_common::config::APP_NAME.write().unwrap() = + format!("RustDeskV2Test{}", uuid::Uuid::new_v4()); + }); +} + +fn proof(password: &str, token: &[u8]) -> host::LoginProof { + let salt = Config::get_effective_permanent_password_salt(); + let challenge = "trusted-device-test".to_owned(); + let mut material = hbb_common::config::compute_permanent_password_h1(password, &salt).to_vec(); + material.extend_from_slice(challenge.as_bytes()); + host::LoginProof { + password: sha256::hash(&material).0.to_vec(), + salt, + challenge, + token: token.to_vec(), + supports_v2: true, + ..Default::default() + } +} + +fn configure() -> std::collections::HashMap { + initialize_config(); + let totp = crate::auth_2fa::TOTPInfo { + name: "test".into(), + secret: vec![1; 20], + digits: 6, + created_at: 1, + }; + assert!(config::set_password(PASSWORD).unwrap()); + let mut options = Config::get_options(); + options.insert("2fa".into(), totp.into_string().unwrap()); + options.insert(keys::OPTION_ENABLE_TRUSTED_DEVICES.into(), "Y".into()); + config::set_options(options.clone()).unwrap(); + options +} + +async fn begin_enrollment() -> host::Attempt { + let (attempt, valid, _) = host::begin(proof(PASSWORD, &[])).await.unwrap().unwrap(); + assert!(!valid); + let mut attempt = attempt.unwrap(); + attempt.enroll = true; + attempt +} + +async fn is_trusted(token: &[u8]) -> bool { + let (_, valid, _) = host::begin(proof(PASSWORD, token)).await.unwrap().unwrap(); + valid +} + +fn legacy_proof(hwid: &[u8], info: &DeviceInfo, supports_v2: bool) -> host::LoginProof { + host::LoginProof { + legacy: super::legacy::verifier(hwid, info).unwrap(), + supports_v2, + ..proof(PASSWORD, &[]) + } +} + +#[hbb_common::tokio::test] +async fn password_and_configuration_revocation_gate_real_persisted_grants() { + let mut options = configure(); + configuration::malformed_host_field_preserves_authentication(); + configuration::legacy_settings_survive_saving().await; + configuration::expiration_changes_preserve_grants().await; + recovery::unknown_expiration_preserves_login_and_settings().await; + recovery::unusable_grant_scope_keeps_current_password_totp_available().await; + assert!(configuration::devices().await.is_empty()); + assert!(host::begin(proof("wrong-password", &[])).await.is_err()); + let enrollment = begin_enrollment().await; + let token = enrollment.finish(DeviceInfo::default()).await.unwrap(); + let (trusted, valid, _) = host::begin(proof(PASSWORD, &token)).await.unwrap().unwrap(); + let trusted = trusted.unwrap(); + assert!(valid); + trusted.check().await.unwrap(); + configuration::encrypted_store_preserves_grants(&token).await; + configuration::renewal_preserves_authorization(&token).await; + let pending = begin_enrollment().await; + options.insert(keys::OPTION_ENABLE_TRUSTED_DEVICES.into(), "N".into()); + let disabled = options.clone(); + config::update_auth(true, move || config::set_options(disabled)) + .await + .unwrap(); + assert!(trusted.check().await.is_err()); + assert!(pending.finish(DeviceInfo::default()).await.is_err()); + options.insert(keys::OPTION_ENABLE_TRUSTED_DEVICES.into(), "Y".into()); + config::update_auth(true, move || config::set_options(options)) + .await + .unwrap(); + assert!(!is_trusted(&token).await); + service_operations_share_and_revoke_persisted_grants().await; + recovery::service_sync_preserves_grants_and_revocations().await; + unavailable_grants_keep_password_totp_available().await; + password_write_failure_is_reported().await; +} + +async fn password_write_failure_is_reported() { + let result = config::update_auth(true, || { + let path = Config::file(); + fs::rename(&path, path.with_extension("backup"))?; + fs::create_dir(&path)?; + config::set_password("changed-test-password") + }) + .await; + let path = Config::file(); + fs::remove_dir(&path).unwrap(); + fs::rename(path.with_extension("backup"), &path).unwrap(); + config::set_password(PASSWORD).unwrap(); + assert!( + result.is_err(), + "Password persistence failure must be reported" + ); +} + +async fn unavailable_grants_keep_password_totp_available() { + Config2::set_trusted_devices_v2("invalid grant store".into()).unwrap(); + assert!(host::begin(proof("wrong-password", &[])).await.is_err()); + let login = host::begin(proof(PASSWORD, &[])).await; + let (attempt, valid, _) = login.unwrap().unwrap(); + assert!(attempt.is_some()); + assert!(!valid); + assert!(configuration::devices().await.is_empty()); + let enrollment = begin_enrollment().await; + let token = enrollment.finish(DeviceInfo::default()).await.unwrap(); + let path = Config2::file(); + fs::write(&path, b"\xff").unwrap(); + Config::set_option("trusted-device-test-option".into(), "N".into()); + let after_save: Config2 = + hbb_common::toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(after_save.options["trusted-device-test-option"], "N"); + assert!(Config2::get_trusted_devices_v2().unwrap().is_empty()); + assert!(!is_trusted(&token).await); +} + +async fn service_operations_share_and_revoke_persisted_grants() { + let enrollment = begin_enrollment().await; + let token = enrollment.finish(DeviceInfo::default()).await.unwrap(); + let synced = serde_json::to_value(Config2::get()).unwrap(); + assert!( + synced["trusted_devices_v2"].as_str() + == Some(Config2::get_trusted_devices_v2().unwrap().as_str()), + "The service configuration snapshot must include newly enrolled grants" + ); + let (pending, valid, _) = host::begin(proof(PASSWORD, &token)).await.unwrap().unwrap(); + let mut pending = pending.unwrap(); + assert!(valid); + pending.enroll = true; + let devices = configuration::devices().await; + let record_id: [u8; TOKEN_BYTES] = + serde_json::from_value(devices[0]["record_id"].clone()).unwrap(); + assert!(!is_trusted(&record_id).await); + let enrollment = begin_enrollment().await; + let other_token = enrollment.finish(DeviceInfo::default()).await.unwrap(); + assert!(matches!( + authority::request(Request::Remove(vec![record_id])) + .await + .unwrap(), + Response::Done + )); + assert!(pending.check().await.is_err()); + assert!(!is_trusted(&token).await); + let (remaining, valid, _) = host::begin(proof(PASSWORD, &other_token)) + .await + .unwrap() + .unwrap(); + assert!(valid); + let remaining = remaining.unwrap(); + remaining.check().await.unwrap(); + let stale_config: Config2 = hbb_common::config::load_path(Config2::file()); + assert_eq!(configuration::devices().await.len(), 1); + authority::request(Request::Clear).await.unwrap(); + // Ordinary settings saves must not restore grants from a stale snapshot. + config::set_config2(stale_config).unwrap(); + Config::set_option("trusted-device-test-option".into(), "Y".into()); + assert!(remaining.check().await.is_err()); + assert!(pending.finish(DeviceInfo::default()).await.is_err()); + assert!(!is_trusted(&other_token).await); + assert!(configuration::devices().await.is_empty()); +} + +const NOW: i64 = 1_700_000_000_000; +const SCOPE: [u8; TOKEN_BYTES] = [7; TOKEN_BYTES]; + +#[test] +fn only_original_tokens_satisfy_trust_and_use_does_not_refresh_expiry() { + let mut grants = Grants::default(); + grants.revoke(Some(SCOPE)).unwrap(); + let ticket = grants.begin(&SCOPE).unwrap(); + let token = random_token().unwrap(); + let expires_at = super::policy::expires_at(NOW, 90); + grants + .enroll_digest( + &ticket, + token_verifier(&token).unwrap(), + DeviceInfo { + time: NOW, + expires_at: Some(expires_at), + ..Default::default() + }, + ) + .unwrap(); + for candidate in [ + &token[..], + &sha256::hash(&token).0[..], + &token[..TOKEN_BYTES - 1], + &[], + ] { + let accepted = grants + .verify_digest(&ticket, token_verifier(candidate).as_ref(), NOW) + .unwrap(); + assert_eq!(accepted, candidate == token); + } + let verifier = token_verifier(&token); + assert!(grants + .verify_digest(&ticket, verifier.as_ref(), expires_at - 1) + .unwrap()); + assert!(!grants + .verify_digest(&ticket, verifier.as_ref(), expires_at) + .unwrap()); + assert!(grants.begin(&[8; TOKEN_BYTES]).is_err()); +} + +#[test] +fn controller_credentials_are_bound_and_storage_errors_surface() { + initialize_config(); + let peer_id = uuid::Uuid::new_v4().to_string(); + let path = Config::path(format!("peers/{peer_id}.toml")); + let mut settings = PeerConfig::default(); + settings.password = vec![5; TOKEN_BYTES]; + settings.info.platform = "Windows".into(); + settings + .options + .insert("alias".into(), "trusted-peer".into()); + settings.store(&peer_id); + let credentials = + Credentials::new(peer_id.clone(), "server-a".into(), &[1; TOKEN_BYTES]).unwrap(); + let token = random_token().unwrap(); + credentials.save(&token, settings.info.clone()).unwrap(); + assert_eq!(credentials.load().unwrap(), Some(token.to_vec())); + let loaded = PeerConfig::load(&peer_id); + assert_eq!(loaded.password, settings.password); + assert_eq!(loaded.options, settings.options); + assert!(serde_json::to_value(&loaded) + .unwrap() + .get("trusted_device_v2") + .is_none()); + settings.store(&peer_id); + assert_eq!(credentials.load().unwrap(), Some(token.to_vec())); + for (namespace, key) in [ + ("server-b", [1; TOKEN_BYTES]), + ("server-a", [2; TOKEN_BYTES]), + ] { + assert!(Credentials::new(peer_id.clone(), namespace.into(), &key) + .unwrap() + .load() + .unwrap() + .is_none()); + } + recovery::corrupt_controller_credentials_allow_reenrollment(&credentials, &peer_id); + let corrupt = format!("{}\ninvalid = [\n", fs::read_to_string(&path).unwrap()); + for corrupt in [corrupt.as_bytes(), b"\xff"] { + fs::write(&path, corrupt).unwrap(); + assert!(credentials.load().is_err()); + settings.store(&peer_id); + assert_eq!(PeerConfig::load(&peer_id).options, settings.options); + assert!(credentials.load().unwrap().is_none()); + } + let blocked_peer = uuid::Uuid::new_v4().to_string(); + fs::create_dir_all(Config::path(format!("peers/{blocked_peer}.toml"))).unwrap(); + let invalid = Credentials::new(blocked_peer, "server-a".into(), &[1; TOKEN_BYTES]).unwrap(); + assert!(invalid.save(&token, settings.info).is_err()); +} diff --git a/src/trusted_devices/tests/configuration.rs b/src/trusted_devices/tests/configuration.rs new file mode 100644 index 000000000..592842155 --- /dev/null +++ b/src/trusted_devices/tests/configuration.rs @@ -0,0 +1,281 @@ +use super::*; +use hbb_common::{ + password_security::{decrypt_str_or_original, encrypt_str_or_original}, + sodiumoxide::base64, +}; + +pub(super) async fn legacy_settings_survive_saving() { + let existing = begin_enrollment() + .await + .finish(DeviceInfo::default()) + .await + .unwrap(); + let now = hbb_common::get_time(); + let day = super::super::policy::DAY_MILLIS; + let info = DeviceInfo { + time: now - 7 * day, + id: "legacy-controller".into(), + ..Default::default() + }; + let hwid = b"legacy-hwid"; + let encrypted = legacy_fixture(hwid, &info); + let path = Config2::file(); + let saved = format!( + "trusted_devices = '{encrypted}'\n{}", + fs::read_to_string(&path).unwrap() + ); + fs::write(&path, saved).unwrap(); + Config::set_option("trusted-device-field-test".into(), "N".into()); + assert!(fs::read_to_string(&path).unwrap().contains(&encrypted)); + let listed = devices().await; + assert_eq!(listed.len(), 2); + let device = listed + .iter() + .find(|device| device["id"] == info.id) + .unwrap(); + let expiry = device["expires_at"].as_i64().unwrap(); + assert_eq!(expiry, info.time + 90 * day); + assert!(Config2::get_trusted_device_stores().unwrap().1.is_empty()); + let original = Config::get_options(); + let mut options = original.clone(); + options.insert(keys::OPTION_TRUSTED_DEVICE_EXPIRATION.into(), "0".into()); + config::set_options(options).unwrap(); + super::super::initialize().unwrap(); + assert!(devices() + .await + .iter() + .all(|device| device["expires_at"] == 0)); + recovery::legacy_migration_preserves_revocation(hwid, &info).await; + assert!(is_trusted(&existing).await); + authority::request(Request::Clear).await.unwrap(); + config::set_options(original).unwrap(); +} + +fn legacy_fixture(hwid: &[u8], info: &DeviceInfo) -> String { + let mut legacy = serde_json::to_value(info).unwrap(); + legacy["hwid"] = serde_json::json!(hwid); + let mut expired = legacy.clone(); + expired["time"] = serde_json::json!(info.time - 90 * super::super::policy::DAY_MILLIS); + expired["id"] = serde_json::json!("expired-controller"); + let plaintext = serde_json::to_string(&vec![legacy, expired]).unwrap(); + encrypt_str_or_original(&plaintext, "00", plaintext.len()) +} + +pub(super) async fn devices() -> Vec { + let Response::Devices(devices) = authority::request(Request::List).await.unwrap() else { + panic!("Expected a trusted-device list"); + }; + let result: serde_json::Value = serde_json::from_str(&devices).unwrap(); + serde_json::from_value(result["devices"].clone()).unwrap() +} + +pub(super) async fn encrypted_store_preserves_grants(token: &[u8]) { + let stored = Config2::get_trusted_devices_v2().unwrap(); + let (plaintext, encrypted, _) = decrypt_str_or_original(&stored, "00"); + assert!(encrypted, "Host grants must be encrypted at rest"); + Config2::set_trusted_devices_v2(plaintext).unwrap(); + super::super::initialize().unwrap(); + assert!(!is_trusted(token).await); + Config2::set_trusted_devices_v2(stored.clone()).unwrap(); + assert!(is_trusted(token).await); + + let mut ciphertext = base64::decode( + stored.strip_prefix("00").unwrap(), + base64::Variant::Original, + ) + .unwrap(); + *ciphertext.last_mut().unwrap() ^= 1; + let corrupted = format!( + "00{}", + base64::encode(&ciphertext, base64::Variant::Original) + ); + Config2::set_trusted_devices_v2(corrupted).unwrap(); + let (attempt, valid, _) = host::begin(proof(PASSWORD, token)).await.unwrap().unwrap(); + assert!(!valid, "Damaged ciphertext must not authorize a token"); + let mut attempt = attempt.expect("A damaged grant store must allow fresh enrollment"); + attempt.enroll = true; + let replacement = attempt.finish(DeviceInfo::default()).await.unwrap(); + assert!(is_trusted(&replacement).await); + assert!(!is_trusted(token).await); + Config2::set_trusted_devices_v2(stored).unwrap(); +} + +pub(super) async fn expiration_changes_preserve_grants() { + use super::super::policy::expires_at; + let original = Config::get_options(); + for (days, remaining) in [ + ("7", 21), + ("30", 3), + ("0", 3), + ("30", 0), + ("90", 3), + ("1", 21), + ] { + let attempt = begin_enrollment().await; + let token = attempt.finish(DeviceInfo::default()).await.unwrap(); + let pending = begin_enrollment().await; + let now = hbb_common::get_time(); + let previous = expires_at(now, remaining); + { + let storage = super::super::storage().unwrap(); + let mut grants = storage.read().unwrap(); + grants.records[0].info.expires_at = Some(previous); + storage.write(&grants).unwrap(); + } + let mut options = original.clone(); + options.insert(keys::OPTION_TRUSTED_DEVICE_EXPIRATION.into(), days.into()); + if days == "90" { + let mut value = Config2::get(); + value.options = options; + config::set_config2(value).unwrap(); + } else { + config::update_options(options).await.unwrap(); + } + assert!(is_trusted(&token).await); + let expiry = devices().await[0]["expires_at"].as_i64().unwrap(); + if days == "0" { + assert_eq!(expiry, 0); + } else if remaining != 0 && remaining <= days.parse::().unwrap() { + assert_eq!(expiry, previous); + } else { + assert!(expiry >= expires_at(now, days.parse().unwrap())); + assert!(expiry <= expires_at(hbb_common::get_time(), days.parse().unwrap())); + } + pending.finish(DeviceInfo::default()).await.unwrap(); + authority::request(Request::Clear).await.unwrap(); + assert_issued_expiry(days).await; + authority::request(Request::Clear).await.unwrap(); + } + config::set_options(original).unwrap(); +} + +pub(super) async fn renewal_preserves_authorization(token: &[u8]) { + let attempt = begin_enrollment().await; + let other_token = attempt.finish(DeviceInfo::default()).await.unwrap(); + let now = hbb_common::get_time(); + { + let storage = super::super::storage().unwrap(); + let mut grants = storage.read().unwrap(); + for record in &mut grants.records { + record.info.expires_at = Some(now + super::super::policy::DAY_MILLIS); + } + storage.write(&grants).unwrap(); + } + let before = devices().await; + let ids: Vec<[u8; TOKEN_BYTES]> = before + .iter() + .map(|device| serde_json::from_value(device["record_id"].clone()).unwrap()) + .collect(); + assert!(matches!( + authority::request(Request::Renew(vec![ids[0]])) + .await + .unwrap(), + Response::Done + )); + let after = devices().await; + let expires_at = after[0]["expires_at"].as_i64().unwrap(); + assert!(expires_at >= super::super::policy::expires_at(now, 90)); + assert!(expires_at <= super::super::policy::expires_at(hbb_common::get_time(), 90)); + assert_eq!(after[0]["time"], before[0]["time"]); + assert_eq!(after[1], before[1]); + for token in [token, other_token.as_slice()] { + assert!(is_trusted(token).await); + } + authority::request(Request::Clear).await.unwrap(); + assert!(authority::request(Request::Renew(vec![ids[1]])) + .await + .is_err()); + assert!(devices().await.is_empty()); +} + +async fn assert_issued_expiry(days: &str) { + let attempt = begin_enrollment().await; + attempt + .finish(DeviceInfo { + time: 1, + expires_at: Some(i64::MAX), + ..Default::default() + }) + .await + .unwrap(); + let listed = devices().await; + let time = listed[0]["time"].as_i64().unwrap(); + let days: i64 = days.parse().unwrap(); + let expected = if days == 0 { + 0 + } else { + time + days * super::super::policy::DAY_MILLIS + }; + assert_ne!(time, 1); + assert_eq!(listed[0]["expires_at"], expected); + if days == 0 { + let id = serde_json::from_value(listed[0]["record_id"].clone()).unwrap(); + assert!(authority::request(Request::Renew(vec![id])).await.is_err()); + assert_eq!(devices().await, listed); + } +} + +#[test] +fn explicit_expiry_is_enforced_and_never_expiring_grants_can_be_revoked() { + for days in [0, 1, 7, 30, 90] { + let mut grants = Grants::default(); + grants.revoke(Some(SCOPE)).unwrap(); + let ticket = grants.begin(&SCOPE).unwrap(); + let verifier = [9; TOKEN_BYTES]; + let expires_at = super::super::policy::expires_at(NOW, days); + grants + .enroll_digest( + &ticket, + verifier, + DeviceInfo { + time: NOW, + expires_at: Some(expires_at), + ..Default::default() + }, + ) + .unwrap(); + if days == 0 { + assert!(grants + .verify_digest(&ticket, Some(&verifier), i64::MAX) + .unwrap()); + } else { + assert!(grants + .verify_digest(&ticket, Some(&verifier), expires_at - 1) + .unwrap()); + assert!(!grants + .verify_digest(&ticket, Some(&verifier), expires_at) + .unwrap()); + } + grants.revoke(Some(SCOPE)).unwrap(); + assert!(grants.verify_digest(&ticket, Some(&verifier), NOW).is_err()); + } +} + +pub(super) fn malformed_host_field_preserves_authentication() { + let path = Config2::file(); + let saved = fs::read_to_string(&path).unwrap(); + let mut value: hbb_common::toml::Value = hbb_common::toml::from_str(&saved).unwrap(); + value.as_table_mut().unwrap().insert( + "trusted_devices_v2".into(), + hbb_common::toml::Value::Integer(123), + ); + let malformed = hbb_common::toml::to_string(&value).unwrap(); + fs::write(&path, &malformed).unwrap(); + + let credential = Config2::get_trusted_devices_v2(); + Config::set_option("trusted-device-field-test".into(), "Y".into()); + let after_save = fs::read_to_string(&path).unwrap(); + fs::write(&path, saved).unwrap(); + + assert!(credential.is_err()); + let after_save: Config2 = hbb_common::toml::from_str(&after_save).unwrap(); + assert_eq!(after_save.options["trusted-device-field-test"], "Y"); + assert_eq!( + after_save.options.get("2fa"), + Config::get_options().get("2fa") + ); + assert!(serde_json::to_value(&after_save) + .unwrap() + .get("trusted_devices_v2") + .is_none()); +} diff --git a/src/trusted_devices/tests/recovery.rs b/src/trusted_devices/tests/recovery.rs new file mode 100644 index 000000000..f6fb69789 --- /dev/null +++ b/src/trusted_devices/tests/recovery.rs @@ -0,0 +1,293 @@ +use super::*; + +pub(super) fn corrupt_controller_credentials_allow_reenrollment( + credentials: &Credentials, + peer_id: &str, +) { + let token = credentials.load().unwrap().unwrap(); + let info = PeerConfig::load(peer_id).info; + let mut saved: serde_json::Value = + serde_json::from_str(&PeerConfig::get_trusted_device_v2(peer_id).unwrap()).unwrap(); + saved["ciphertext"] = serde_json::json!([]); + let undecryptable = saved.to_string(); + saved["ciphertext"] = serde_json::json!(hbb_common::password_security::symmetric_crypt( + &token[..TOKEN_BYTES - 1], + true + ) + .unwrap()); + for corrupt in ["{".to_owned(), undecryptable, saved.to_string()] { + PeerConfig::set_trusted_device_v2(peer_id, corrupt, info.clone()).unwrap(); + assert_eq!(credentials.load().unwrap(), None); + credentials.save(&token, info.clone()).unwrap(); + assert_eq!(credentials.load().unwrap(), Some(token.clone())); + } +} + +pub(super) async fn service_sync_preserves_grants_and_revocations() { + let removed = begin_enrollment() + .await + .finish(DeviceInfo::default()) + .await + .unwrap(); + let retained = begin_enrollment() + .await + .finish(DeviceInfo::default()) + .await + .unwrap(); + let settings = Config::get(); + let previous = Config2::get_for_sync().unwrap(); + let id = + serde_json::from_value(configuration::devices().await[0]["record_id"].clone()).unwrap(); + authority::request(Request::Remove(vec![id])).await.unwrap(); + let added = begin_enrollment() + .await + .finish(DeviceInfo::default()) + .await + .unwrap(); + let current = Config2::get_for_sync().unwrap(); + + // Explicit service sync replaces the older copy, including removals and enrollment. + previous.sync_trusted_devices().unwrap(); + config::sync_config(settings.clone(), current.clone()).unwrap(); + assert!(!is_trusted(&removed).await); + assert!(is_trusted(&retained).await); + assert!(is_trusted(&added).await); + // Installation imports this file, including the original grant deadlines. + let imported: Config2 = hbb_common::config::load_path(Config2::file()); + assert_eq!( + imported, current, + "The service importer must receive current grants" + ); + + Config2::set_trusted_devices_v2(String::new()).unwrap(); + config::sync_config(settings.clone(), current).unwrap(); + super::super::initialize().unwrap(); + assert!(is_trusted(&retained).await); + assert!(is_trusted(&added).await); + + authority::request(Request::Clear).await.unwrap(); + let cleared = Config2::get_for_sync().unwrap(); + previous.sync_trusted_devices().unwrap(); + config::sync_config(settings, cleared).unwrap(); + assert!(configuration::devices().await.is_empty()); + stopped_service_password_changes().await; + #[cfg(windows)] + unavailable_host_clears_local_trust_before_removal().await; +} + +#[cfg(windows)] +async fn unavailable_host_clears_local_trust_before_removal() { + let token = begin_enrollment() + .await + .finish(DeviceInfo::default()) + .await + .unwrap(); + let previous = Config2::get_for_sync().unwrap(); + let settings = Config::get(); + let options = Config::get_options(); + config::refresh_from_server().await.unwrap(); + let imported: Config2 = hbb_common::config::load_path(Config2::file()); + assert_eq!(Config::get(), settings); + assert_eq!(imported.options, options); + assert!(configuration::devices().await.is_empty()); + previous.sync_trusted_devices().unwrap(); + config::sync_config(settings, imported).unwrap(); + assert!(!is_trusted(&token).await); +} + +async fn stopped_service_password_changes() { + let token = begin_enrollment() + .await + .finish(DeviceInfo::default()) + .await + .unwrap(); + let previous = Config2::get_for_sync().unwrap(); + let options = Config::get_options(); + Config::set_option("stop-service".into(), "Y".into()); + assert!( + config::update_auth(true, || config::set_password("stopped-service-password")) + .await + .unwrap() + ); + config::snapshot() + .unwrap() + .verify_password(&proof("stopped-service-password", &[])) + .unwrap(); + assert!(config::update_auth(true, || config::set_password(PASSWORD)) + .await + .unwrap()); + let mut without_2fa = options.clone(); + without_2fa.remove("2fa"); + config::update_options(without_2fa).await.unwrap(); + config::update_options(options).await.unwrap(); + let current = Config2::get_for_sync().unwrap(); + previous.sync_trusted_devices().unwrap(); + config::sync_config(Config::get(), current).unwrap(); + assert!( + !is_trusted(&token).await, + "Synced offline edits must revoke trust even when the original password is restored" + ); +} + +pub(super) async fn unknown_expiration_preserves_login_and_settings() { + let original = Config::get_options(); + let key = keys::OPTION_TRUSTED_DEVICE_EXPIRATION; + for layer in [ + None, + Some(&*hbb_common::config::DEFAULT_SETTINGS), + Some(&*hbb_common::config::OVERWRITE_SETTINGS), + ] { + if let Some(layer) = layer { + layer.write().unwrap().insert(key.into(), "14".into()); + } else { + Config::set_option(key.into(), "14".into()); + } + let mut options = Config::get_options(); + options.insert("trusted-device-expiration-test".into(), "Y".into()); + let saved = config::set_options(options); + let synced = config::set_config2(Config2::get()); + let snapshot = config::snapshot(); + let login = host::begin(proof(PASSWORD, &[])).await; + if let Some(layer) = layer { + layer.write().unwrap().remove(key); + } + saved.unwrap(); + synced.unwrap(); + assert_eq!( + snapshot.unwrap().validity_days, + super::super::policy::DEFAULT_DAYS + ); + let stored: Config2 = + hbb_common::toml::from_str(&fs::read_to_string(Config2::file()).unwrap()).unwrap(); + assert_eq!(stored.options["trusted-device-expiration-test"], "Y"); + let (_, valid, _) = login.unwrap().unwrap(); + assert!(!valid); + config::set_options(original.clone()).unwrap(); + } +} + +pub(super) async fn unusable_grant_scope_keeps_current_password_totp_available() { + let attempt = begin_enrollment().await; + let token = attempt.finish(DeviceInfo::default()).await.unwrap(); + let pending = begin_enrollment().await; + { + let storage = super::super::storage().unwrap(); + let mut grants = Grants::default(); + grants.revoke(None).unwrap(); + storage.write(&grants).unwrap(); + } + assert!(host::begin(proof("wrong-password", &token)).await.is_err()); + let (attempt, valid, totp) = host::begin(proof(PASSWORD, &token)).await.unwrap().unwrap(); + assert!(attempt.is_none()); + assert!(!valid); + assert_eq!(totp.secret, vec![1; 20]); + assert!(pending.finish(DeviceInfo::default()).await.is_err()); + + let mut wrong_scope = config::snapshot().unwrap().scope; + wrong_scope[0] ^= 1; + let error = authority::begin(wrong_scope, None, None).err().unwrap(); + assert!(!error.is::()); + + let path = Config2::file(); + let saved = fs::read_to_string(&path).unwrap(); + let mut changed = Config2::get(); + changed + .options + .insert("2fa".into(), "changed-secret".into()); + hbb_common::config::store_path(path.clone(), changed).unwrap(); + let login = host::begin(proof(PASSWORD, &token)).await; + fs::write(&path, saved).unwrap(); + assert!(login.is_err()); + authority::request(Request::Clear).await.unwrap(); +} + +pub(super) async fn legacy_migration_preserves_revocation(hwid: &[u8], info: &DeviceInfo) { + legacy_migration_at_capacity_preserves_expiry(hwid, info).await; + let (old, valid, _) = host::begin(legacy_proof(hwid, info, false)) + .await + .unwrap() + .unwrap(); + assert!(valid); + assert!(old.unwrap().finish(info.clone()).await.unwrap().is_empty()); + let in_flight = host::begin(legacy_proof(hwid, info, true)) + .await + .unwrap() + .unwrap() + .0 + .unwrap(); + let mut tokens = Vec::new(); + for _ in 0..2 { + let (attempt, valid, _) = host::begin(legacy_proof(hwid, info, true)) + .await + .unwrap() + .unwrap(); + assert!(valid); + tokens.push(attempt.unwrap().finish(info.clone()).await.unwrap()); + } + for token in &tokens { + assert!(is_trusted(token).await); + } + in_flight.check().await.unwrap(); + tokens.push(in_flight.finish(info.clone()).await.unwrap()); + let (attempt, valid, _) = host::begin(legacy_proof(hwid, info, false)) + .await + .unwrap() + .unwrap(); + assert!(!valid && attempt.is_none()); + let listed = configuration::devices().await; + assert_eq!( + listed.len(), + 2, + "Migration must keep a single management entry" + ); + let device = listed + .iter() + .find(|device| device["id"] == info.id) + .unwrap(); + assert_eq!(device["expires_at"], 0); + let id = serde_json::from_value(device["record_id"].clone()).unwrap(); + super::super::remove(&[id]).unwrap(); + for token in tokens { + assert!(!is_trusted(&token).await); + } +} + +async fn legacy_migration_at_capacity_preserves_expiry(hwid: &[u8], info: &DeviceInfo) { + let original = Config2::get_for_sync().unwrap(); + let mut options = Config::get_options(); + options.insert(keys::OPTION_TRUSTED_DEVICE_EXPIRATION.into(), "1".into()); + config::set_options(options).unwrap(); + let device = configuration::devices() + .await + .into_iter() + .find(|device| device["id"] == info.id) + .unwrap(); + let expiry = device["expires_at"].clone(); + for _ in 1..super::super::MAX_RECORDS { + begin_enrollment() + .await + .finish(DeviceInfo::default()) + .await + .unwrap(); + } + let (attempt, valid, _) = host::begin(legacy_proof(hwid, info, true)) + .await + .unwrap() + .unwrap(); + assert!(valid); + let token = attempt.unwrap().finish(info.clone()).await.unwrap(); + assert!( + is_trusted(&token).await, + "The issued migration token must survive eviction" + ); + let devices = configuration::devices().await; + assert_eq!(devices.len(), super::super::MAX_RECORDS); + assert_eq!( + devices + .iter() + .find(|device| device["id"] == info.id) + .unwrap()["expires_at"], + expiry + ); + config::sync_config(Config::get(), original).unwrap(); +} diff --git a/src/ui_interface.rs b/src/ui_interface.rs index bf0295a5f..995d6d0cf 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -419,17 +419,52 @@ pub fn get_sound_inputs() -> Vec { #[inline] pub fn set_options(m: HashMap) { + hbb_common::allow_err!(try_set_options(m)); +} + +pub fn try_set_options(m: HashMap) -> hbb_common::ResultType<()> { #[cfg(not(any(target_os = "android", target_os = "ios")))] { - *OPTIONS.lock().unwrap() = m.clone(); - ipc::set_options(m).ok(); + if !crate::trusted_devices::config::changes_authentication(&m) { + *OPTIONS.lock().unwrap() = m.clone(); + hbb_common::allow_err!(ipc::set_options(m)); + return Ok(()); + } + ipc::set_options(m.clone())?; + *OPTIONS.lock().unwrap() = m; } #[cfg(any(target_os = "android", target_os = "ios"))] - Config::set_options(m); + crate::trusted_devices::config::set_options(m)?; + Ok(()) +} + +pub fn set_auth_option(key: String, value: String) -> hbb_common::ResultType<()> { + if key == keys::OPTION_TRUSTED_DEVICE_EXPIRATION + && !matches!(value.as_str(), "" | "1" | "7" | "30" | "90" | "0") + { + hbb_common::bail!("Invalid trusted-device expiration period"); + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + let mut options = ipc::get_options(); + #[cfg(any(target_os = "android", target_os = "ios"))] + let mut options = Config::get_options(); + if value.is_empty() { + options.remove(&key); + } else { + options.insert(key, value); + } + try_set_options(options) } #[inline] pub fn set_option(key: String, value: String) { + if key == "2fa" + || key == keys::OPTION_ENABLE_TRUSTED_DEVICES + || key == keys::OPTION_TRUSTED_DEVICE_EXPIRATION + { + hbb_common::allow_err!(set_auth_option(key, value)); + return; + } if &key == "stop-service" { #[cfg(target_os = "macos")] { @@ -657,7 +692,13 @@ pub fn set_permanent_password_with_result(password: String) -> bool { } #[cfg(any(target_os = "android", target_os = "ios"))] { - return config::Config::set_permanent_password(&password); + return match crate::trusted_devices::config::set_password(&password) { + Ok(updated) => updated, + Err(error) => { + log::error!("Password change rejected: {error}"); + false + } + }; } #[cfg(not(any(target_os = "android", target_os = "ios")))] { @@ -1713,31 +1754,6 @@ pub fn set_unlock_pin(pin: String) -> String { } } -#[cfg(feature = "flutter")] -pub fn get_trusted_devices() -> String { - #[cfg(any(target_os = "android", target_os = "ios"))] - return Config::get_trusted_devices_json(); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - return ipc::get_trusted_devices(); -} - -#[cfg(feature = "flutter")] -pub fn remove_trusted_devices(json: &str) { - let hwids = serde_json::from_str::>(json).unwrap_or_default(); - #[cfg(any(target_os = "android", target_os = "ios"))] - Config::remove_trusted_devices(&hwids); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - ipc::remove_trusted_devices(hwids); -} - -#[cfg(feature = "flutter")] -pub fn clear_trusted_devices() { - #[cfg(any(target_os = "android", target_os = "ios"))] - Config::clear_trusted_devices(); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - ipc::clear_trusted_devices(); -} - #[cfg(feature = "flutter")] pub fn max_encrypt_len() -> usize { hbb_common::config::ENCRYPT_MAX_LEN diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 0c451f104..f317a2134 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -12,6 +12,7 @@ use base::config::keys; #[cfg(not(feature = "flutter"))] use base::fs; use base::message_proto::*; +#[cfg(feature = "flutter")] use bytes::Bytes; use hbb_common::{ allow_err, @@ -1408,18 +1409,9 @@ impl Session { pub fn send2fa(&self, code: String, trust_this_device: bool) { let mut msg_out = Message::new(); - let hwid = if trust_this_device { - crate::get_hwid() - } else { - Bytes::new() - }; - self.lc.write().unwrap().set_option( - "trust-this-device".to_string(), - if trust_this_device { "Y" } else { "" }.to_string(), - ); msg_out.set_auth_2fa(Auth2FA { code, - hwid, + trust_this_device, ..Default::default() }); self.send(Data::Message(msg_out)); @@ -1907,8 +1899,8 @@ impl Interface for Session { } } - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool { - handle_hash(self.lc.clone(), pass, hash, self, peer).await + async fn handle_hash(&self, pass: &str, hash: Hash, trust: &mut crate::trusted_devices::client::Session, peer: &mut Stream) -> bool { + handle_hash(self.lc.clone(), pass, hash, self, trust, peer).await } async fn handle_login_from_ui( @@ -1917,17 +1909,21 @@ impl Interface for Session { os_password: String, password: String, remember: bool, + trust: &mut crate::trusted_devices::client::Session, peer: &mut Stream, ) { - handle_login_from_ui( + if let Err(error) = handle_login_from_ui( self.lc.clone(), os_username, os_password, password, remember, + trust, peer, ) - .await; + .await { + self.on_error(&error.to_string()); + } } async fn handle_test_delay(&self, t: TestDelay, peer: &mut Stream) {