From 350966b331bb873650668549b3fa4a42e81e6a81 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 28 Oct 2026 10:27:44 +0800 Subject: [PATCH] Add trusted-device V2 with legacy upgrade compatibility (#16548) * fix: secure trusted devices with host-issued V2 tokens Signed-off-by: fufesou * fix: preserve trusted-device management and ordinary authentication Restore selected-device deletion using nonsecret record identifiers and acknowledged V2 revocation errors. Preserve password and TOTP authentication when the V2 authority or cached controller credentials are unavailable. Disable V2 for the affected attempt while retaining authentication, configuration, and revocation rejection. Validation: 20 selected Rust tests, Flutter-feature Rust compilation, focused Dart analysis, formatting, and whitespace checks passed. Signed-off-by: fufesou * fix: simplify trusted-device caching and stabilize tests Signed-off-by: fufesou * fix: persist trusted-device grants in host configuration Store host grants in RustDesk2.toml and preserve revocation across configuration writes and service transitions. Use local authority on macOS when no background service is installed. Preserve both configuration imports by checking Config2 eligibility before revocation writes. Cover the password and 2FA import regression alongside the existing trusted-device tests. Signed-off-by: fufesou * fix: store trusted-device credentials in peer configuration Signed-off-by: fufesou * fix: minimize trusted-device persistence changes Signed-off-by: fufesou * refactor: minimize trusted-device integration changes Keep ordinary policy writes on the generic configuration path and document authentication updates. Restore the macOS installer to master, rename the V2 version gate, and relocate the existing client regression test. Signed-off-by: fufesou * fix: harden trusted-device revocation and persistence Recover corrupt grant data without skipping authentication-write validation, and avoid revoking trust when IPC synchronizes an unchanged salt. Make desktop management IPC async and keep runtime creation at the Flutter worker boundary. Update hbb_common to preserve V2 credentials when configuration reads fail, and extend existing regressions for clear, salt synchronization, and persistence failures. Signed-off-by: fufesou * fix: preserve trusted-device validation and no-op settings Validate trust before connection side effects and retain tickets for the final check when the authority is unavailable after TOTP. Disable enrollment on that path. Skip revocation for password operations that the existing setter treats as no-ops, extend the existing IPC regression, and clean up client state on initialization or credential errors. Validation: 36 targeted native tests, Flutter-feature Rust check, formatting, and git diff --check passed. Signed-off-by: fufesou * feat: add trusted-device expiry and per-device renewal Keep legacy records for password-and-TOTP migration, encrypt host grant storage, and add configurable trust periods that revoke grants when changed. Allow renewing individual unexpired devices from the manager, hide renewal for never-expiring grants, and preserve the mobile table height constraints. Signed-off-by: fufesou * fix(macos): report trusted-device cleanup failure before uninstall Revoke local trusted-device grants before dispatching the uninstall closure so cleanup failures return false without starting the uninstall script. Keep the existing synchronous and asynchronous script execution paths. Signed-off-by: fufesou * fix: preserve trusted-device settings and revoke macOS service grants Default unsupported expiration values on read while keeping explicit edits strict. Allow password/TOTP when stored grants are out of sync, after validating the authentication scope. Revoke root-owned grants during macOS service removal. Bump hbb_common for malformed-file recovery and legacy-record preservation, and retain the reviewed regression coverage. Signed-off-by: fufesou * fix: avoid duplicate trusted-device revocation during config sync Signed-off-by: fufesou * fix: preserve trusted-device revocation during config mismatches Allow grant clearing when authentication snapshots are unavailable and validate existing snapshots before applying authentication updates. Restore optimistic caching for non-auth bulk options and restrict the trusted-device IPC helper to desktop targets. Signed-off-by: fufesou * feat: preserve trusted devices when trust duration changes Adjust existing grant deadlines for duration changes and startup policy overrides, support 90 days, and use an adaptive inline duration selector without the obsolete revocation warning. Consolidate management IPC and trim redundant tests while retaining authentication and revocation coverage. Validation: four existing focused Rust tests and the startup override reproduction passed; Windows default/service and Flutter-feature cargo checks passed. Signed-off-by: fufesou * fix: finish trusted-device notice cleanup and duration sync Remove the recurring legacy notice, unused response metadata and translation key. Allow duration-only saves with a readable inactive grant scope while preserving storage and authentication errors. Update hbb_common to 040bf3f662ae7422f2f9570701cc8451f7bb1d59, which removes the unused legacy detector. Validation: four existing focused Rust tests passed; Windows default/service and Flutter-feature cargo checks passed. No new tests were added. Signed-off-by: fufesou * fix: refine trusted-device duration layout Indent the duration row beneath the trusted-device option, add vertical spacing, and compact the desktop selector. Mobile sizing and other dropdowns remain unchanged. Validation: targeted Dart analysis passed with existing informational diagnostics. Actual window rendering was not exercised. Signed-off-by: fufesou * refact: adjust indentation Signed-off-by: fufesou * fix: preserve trusted devices across version upgrades Honor existing legacy trust without creating new legacy registrations. Import live records into encrypted grants, apply the current expiration policy, and migrate consenting controllers to V2 tokens while retaining grouped revocation. Advertise V2 capability explicitly and hide enrollment for mixed-version sessions. Update existing coverage and use the paired hbb_common migration API. Signed-off-by: fufesou * fix: translations Signed-off-by: fufesou * Align the compact trusted-device duration selector on mobile Signed-off-by: fufesou * Default trusted devices to 90 days and drop unreleased V2 migrations Signed-off-by: fufesou * Avoid masking macOS daemon stop failures during uninstall Signed-off-by: fufesou * restore uninstall.scpt, do not clear trusted devices on uninstall Signed-off-by: fufesou * Preserve trusted devices when uninstalling services Signed-off-by: fufesou * Preserve trusted devices when importing host configuration Signed-off-by: fufesou * Recover from unreadable trusted-device grants Log and persistently revoke invalid or undecryptable V2 payloads so password and TOTP can enroll a new device. Keep configuration read and write errors explicit and extend existing tests to cover recovery and rejection of old tokens. Signed-off-by: fufesou * Preserve trusted devices across service transitions Run grant operations in the accepting server and synchronize current grants and revocations through the existing service configuration channel. Keep ordinary settings saves from restoring stale credentials and retain root peer verification. Recognize explicit configuration errors as live service responses during IPC probing, so duplicate startup does not remove the active socket. Validated with five trusted-device tests, seven Linux IPC filesystem tests, an isolated IPC regression probe, and Windows Flutter and Linux compilation checks. Installed-service transitions and macOS runtime behavior remain untested. Signed-off-by: fufesou * Keep trusted-device grants under the service authority Preserve root-owned grants when copying service configuration and route installed-host trust operations through the root service. Keep local authentication edits available when the Linux service is explicitly stopped, then synchronize pending revocation before using service grants. Serialize pending revocation synchronization and cover service copies, stale user snapshots, and stopped-service authentication updates with the existing focused tests. Signed-off-by: fufesou * Replace same-ID grants when enrolling trusted devices Signed-off-by: fufesou * Allow macOS security edits after service removal Reuse pending trusted-device revocation for offline password and 2FA changes. Honor pending revocation during installation copies and before root-authority requests and configuration synchronization. Validation: five focused macOS tests, macOS and Windows cargo checks, and targeted formatting checks. Signed-off-by: fufesou * Allow macOS security edits before daemon reinstallation Detect an absent daemon using retained service ownership instead of the incoming-connections stop flag. General Start resumes connections without reinstalling the daemon, so local authentication edits must continue using pending revocation until installation. Adjust the existing recovery test to check that toggling incoming connections does not make a standalone profile service-owned. Signed-off-by: fufesou * Allow re-enrollment after invalid controller credentials Ignore malformed or undecryptable stored token payloads with a throttled warning while preserving file access errors and host identity checks. Fresh authorized enrollment can replace the unusable credential. Restore logged, non-propagating IPC errors for non-authentication option changes and extend the existing credential recovery test. Signed-off-by: fufesou * Update hbb_common to serialize peer deletion Signed-off-by: fufesou * fix(macos): preserve trusted devices across service removal Hand off the current root grant store to the removing user and restore that user's latest grants on reinstallation. Keep other retained profiles from becoming authoritative, and preserve user settings when the service has no optional configuration. Validate the macOS build, five trusted-device tests, isolated headless transfer cases, and AppleScript syntax. Real privileged service transitions remain untested. Signed-off-by: fufesou * refactor(trusted-devices): use local grants and existing config sync Keep grant decisions in the accepting host process and transfer grants and removals through the existing background configuration sync. Remove root-only grant RPC, ownership handoff, pending revocation, and dedicated copy commands; restore the master service lifecycle on Linux and macOS. Preserve V2 credentials, authentication revocation, and ordinary-save protections. Accept the existing asynchronous sync and multiple-user limitations. Validated Windows and macOS Flutter checks, the Linux library/tests/service check, four macOS trusted-device tests, and AppleScript syntax. Real service transitions remain untested. Signed-off-by: fufesou * fix(trusted-devices): preserve migrated tokens at capacity Raise the shared V2 token limit to 128 and order newly migrated tokens by issuance time without extending their legacy expiration. Extend the existing migration regression coverage to verify token reuse and expiry preservation at capacity. Signed-off-by: fufesou * fix(windows): preserve trust across service stop and start Export current on-disk grants and explicitly synchronize them into the GUI configuration at startup and before stopping a running service. Abort the stop if synchronization fails so the next service import cannot use a stale local copy. Extend the existing synchronization regression coverage to check revoked, retained and newly enrolled devices and the actual import-source file. Remove unrelated import logging changes. Validation: trusted-device tests passed on Windows and macOS (4 each); changed-line formatting and git diff --check passed. Real Windows GUI/UAC service transitions remain untested. Signed-off-by: fufesou * refactor(trusted-devices): simplify local authorization Replace local authorization envelopes with typed operations while retaining locked authentication checks and persistence. Remove redundant configuration and Dart wrappers without changing credential formats or management requests. Signed-off-by: fufesou * fix(windows): allow service removal when host is unavailable Signed-off-by: fufesou * fix(windows): preserve trust revocations before full uninstall Signed-off-by: fufesou * fix(windows): sync trust changes to the retained user config Signed-off-by: fufesou * fix(windows): sync trust after security setting changes Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/common/widgets/dialog.dart | 148 ++++++--- flutter/lib/consts.dart | 1 + .../desktop/pages/desktop_setting_page.dart | 23 +- flutter/lib/mobile/pages/settings_page.dart | 24 +- flutter/lib/web/bridge.dart | 4 + libs/base/protos/message.proto | 5 + libs/base/src/config/keys.rs | 2 + libs/hbb_common | 2 +- src/auth_2fa.rs | 8 +- src/client.rs | 47 ++- src/client/io_loop.rs | 46 ++- src/client/tests/trusted_devices_tests.rs | 144 +++++++++ src/flutter.rs | 13 + src/flutter_ffi.rs | 71 ++++- src/hbbs_http/sync.rs | 41 ++- src/ipc.rs | 196 +++++++++--- src/ipc/fs.rs | 5 +- src/lang/ar.rs | 2 + src/lang/az.rs | 2 + src/lang/be.rs | 2 + src/lang/bg.rs | 2 + src/lang/ca.rs | 2 + src/lang/cn.rs | 2 + src/lang/cs.rs | 2 + src/lang/da.rs | 2 + src/lang/de.rs | 2 + src/lang/el.rs | 2 + src/lang/eo.rs | 2 + src/lang/es.rs | 2 + src/lang/et.rs | 2 + src/lang/eu.rs | 2 + src/lang/fa.rs | 2 + src/lang/fi.rs | 2 + src/lang/fr.rs | 2 + src/lang/ge.rs | 2 + src/lang/gl.rs | 2 + src/lang/gu.rs | 2 + src/lang/he.rs | 2 + src/lang/hi.rs | 2 + src/lang/hr.rs | 2 + src/lang/hu.rs | 2 + src/lang/id.rs | 2 + src/lang/it.rs | 2 + src/lang/ja.rs | 2 + src/lang/ko.rs | 2 + src/lang/kz.rs | 2 + src/lang/lt.rs | 2 + src/lang/lv.rs | 2 + src/lang/ml.rs | 2 + src/lang/nb.rs | 2 + src/lang/nl.rs | 2 + src/lang/pl.rs | 2 + src/lang/pt_BR.rs | 2 + src/lang/pt_PT.rs | 2 + src/lang/ro.rs | 2 + src/lang/ru.rs | 2 + src/lang/sc.rs | 2 + src/lang/sk.rs | 2 + src/lang/sl.rs | 2 + src/lang/sq.rs | 2 + src/lang/sr.rs | 2 + src/lang/sv.rs | 2 + src/lang/ta.rs | 2 + src/lang/template.rs | 2 + src/lang/th.rs | 2 + src/lang/tr.rs | 2 + src/lang/tw.rs | 2 + src/lang/uk.rs | 2 + src/lang/ur.rs | 2 + src/lang/vi.rs | 2 + src/lib.rs | 1 + src/platform/windows.rs | 21 ++ src/port_forward.rs | 52 ++-- src/port_forward_mux.rs | 3 +- src/server.rs | 68 ++-- src/server/connection.rs | 68 ++-- src/server/connection/trusted_devices.rs | 101 ++++++ src/trusted_devices/authority.rs | 104 +++++++ src/trusted_devices/client.rs | 210 +++++++++++++ src/trusted_devices/config.rs | 291 +++++++++++++++++ src/trusted_devices/host.rs | 146 +++++++++ src/trusted_devices/legacy.rs | 145 +++++++++ src/trusted_devices/management.rs | 77 +++++ src/trusted_devices/mod.rs | 244 +++++++++++++++ src/trusted_devices/policy.rs | 103 ++++++ src/trusted_devices/storage.rs | 73 +++++ src/trusted_devices/tests.rs | 290 +++++++++++++++++ src/trusted_devices/tests/configuration.rs | 281 +++++++++++++++++ src/trusted_devices/tests/recovery.rs | 293 ++++++++++++++++++ src/ui_interface.rs | 74 +++-- src/ui_session_interface.rs | 24 +- 91 files changed, 3321 insertions(+), 234 deletions(-) create mode 100644 src/client/tests/trusted_devices_tests.rs create mode 100644 src/server/connection/trusted_devices.rs create mode 100644 src/trusted_devices/authority.rs create mode 100644 src/trusted_devices/client.rs create mode 100644 src/trusted_devices/config.rs create mode 100644 src/trusted_devices/host.rs create mode 100644 src/trusted_devices/legacy.rs create mode 100644 src/trusted_devices/management.rs create mode 100644 src/trusted_devices/mod.rs create mode 100644 src/trusted_devices/policy.rs create mode 100644 src/trusted_devices/storage.rs create mode 100644 src/trusted_devices/tests.rs create mode 100644 src/trusted_devices/tests/configuration.rs create mode 100644 src/trusted_devices/tests/recovery.rs 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) {