mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-11-03 16:32:22 +00:00
Add trusted-device V2 with legacy upgrade compatibility (#16548)
* fix: secure trusted devices with host-issued V2 tokens Signed-off-by: fufesou <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * fix: simplify trusted-device caching and stabilize tests Signed-off-by: fufesou <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * fix: store trusted-device credentials in peer configuration Signed-off-by: fufesou <linlong1266@gmail.com> * fix: minimize trusted-device persistence changes Signed-off-by: fufesou <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * fix: avoid duplicate trusted-device revocation during config sync Signed-off-by: fufesou <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * refact: adjust indentation Signed-off-by: fufesou <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * fix: translations Signed-off-by: fufesou <linlong1266@gmail.com> * Align the compact trusted-device duration selector on mobile Signed-off-by: fufesou <linlong1266@gmail.com> * Default trusted devices to 90 days and drop unreleased V2 migrations Signed-off-by: fufesou <linlong1266@gmail.com> * Avoid masking macOS daemon stop failures during uninstall Signed-off-by: fufesou <linlong1266@gmail.com> * restore uninstall.scpt, do not clear trusted devices on uninstall Signed-off-by: fufesou <linlong1266@gmail.com> * Preserve trusted devices when uninstalling services Signed-off-by: fufesou <linlong1266@gmail.com> * Preserve trusted devices when importing host configuration Signed-off-by: fufesou <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * Replace same-ID grants when enrolling trusted devices Signed-off-by: fufesou <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * Update hbb_common to serialize peer deletion Signed-off-by: fufesou <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * 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 <linlong1266@gmail.com> * fix(windows): allow service removal when host is unavailable Signed-off-by: fufesou <linlong1266@gmail.com> * fix(windows): preserve trust revocations before full uninstall Signed-off-by: fufesou <linlong1266@gmail.com> * fix(windows): sync trust changes to the retained user config Signed-off-by: fufesou <linlong1266@gmail.com> * fix(windows): sync trust after security setting changes Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
1 parent
d0a1af8c51
commit
350966b331
91 files changed
+3321
-234
No files matched your search
@@ -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<void> renewTrustedDevice(
|
||||
TrustedDevice device,
|
||||
RxList<TrustedDevice> trustedDevices,
|
||||
RxList<Uint8List> 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<TrustedDevice> trustedDevices = (await TrustedDevice.get()).obs;
|
||||
late final RxList<TrustedDevice> trustedDevices;
|
||||
try {
|
||||
trustedDevices = (await TrustedDevice.get()).obs;
|
||||
} catch (error) {
|
||||
showToast(error.toString());
|
||||
return;
|
||||
}
|
||||
RxList<Uint8List> 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<String, dynamic> json) {
|
||||
final hwidList = json['hwid'] as List<dynamic>;
|
||||
hwid = Uint8List.fromList(hwidList.cast<int>());
|
||||
final recordIdList = json['record_id'] as List<dynamic>;
|
||||
recordId = Uint8List.fromList(recordIdList.cast<int>());
|
||||
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<List<TrustedDevice>> get() async {
|
||||
final List<TrustedDevice> 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<String, dynamic>;
|
||||
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(),
|
||||
)),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -359,12 +359,28 @@ class _SettingsState extends State<SettingsPage> 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')),
|
||||
|
||||
@@ -1834,6 +1834,10 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("mainRemoveTrustedDevices");
|
||||
}
|
||||
|
||||
Future<void> mainRenewTrustedDevices({required String json, dynamic hint}) {
|
||||
throw UnimplementedError("mainRenewTrustedDevices");
|
||||
}
|
||||
|
||||
Future<void> mainClearTrustedDevices({dynamic hint}) {
|
||||
throw UnimplementedError("mainClearTrustedDevices");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
Submodule libs/hbb_common updated: edefa08b79...3ee3afd438.
+4
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+41
-6
@@ -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<u8>,
|
||||
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::*;
|
||||
|
||||
+39
-7
@@ -103,6 +103,7 @@ pub struct Remote<T: InvokeUiSession> {
|
||||
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<T: InvokeUiSession> Remote<T> {
|
||||
last_record_state: false,
|
||||
sent_close_reason: false,
|
||||
cursor_dedupe: Default::default(),
|
||||
trusted_device: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +211,22 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
.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<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
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<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
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<T: InvokeUiSession> Remote<T> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
trust
|
||||
.login(request.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.login_request()
|
||||
.trusted_device_v2_token
|
||||
.to_vec()
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+60
-11
@@ -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<String> {
|
||||
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<String, String> = 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<String> {
|
||||
#[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::<Vec<[u8; crate::trusted_devices::TOKEN_BYTES]>>(&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::<Vec<[u8; crate::trusted_devices::TOKEN_BYTES]>>(&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<usize> {
|
||||
|
||||
+34
-7
@@ -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::<StrategyOptions>(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::<i64>(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::<StrategyOptions>(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<String, String>) {
|
||||
async fn handle_config_options(
|
||||
config_options: HashMap<String, String>,
|
||||
) -> 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<String, String>) {
|
||||
}
|
||||
})
|
||||
.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)]
|
||||
|
||||
+160
-36
@@ -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<ConnectionTmpl<ConnC
|
||||
connect(ms_timeout, crate::POSTFIX_SERVICE).await
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub(crate) async fn connect_trusted_device_service(
|
||||
ms_timeout: u64,
|
||||
) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
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<String>),
|
||||
RemoveTrustedDevices(Vec<Bytes>),
|
||||
ClearTrustedDevices,
|
||||
TrustedDeviceManagement(Box<authority::Request>),
|
||||
TrustedDeviceResponse(Result<crate::trusted_devices::authority::Response, String>),
|
||||
SecurityConfigError(String),
|
||||
#[cfg(all(target_os = "windows", feature = "flutter"))]
|
||||
PrinterData(Vec<u8>),
|
||||
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<bool> {
|
||||
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<String> {
|
||||
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<Bytes>) {
|
||||
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<ConnClient>,
|
||||
) -> 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<String, String>) -> 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(())
|
||||
|
||||
+4
-1
@@ -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(_)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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")))]
|
||||
|
||||
@@ -1912,6 +1912,13 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> ResultType<String>
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+32
-20
@@ -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<WindowsSession>) {}
|
||||
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<RwLock<LoginConfigHandler>> {
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1167,7 +1167,7 @@ mod tests {
|
||||
}
|
||||
fn handle_peer_info(&self, _pi: PeerInfo) {}
|
||||
fn set_multiple_windows_session(&self, _sessions: Vec<WindowsSession>) {}
|
||||
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,
|
||||
) {
|
||||
}
|
||||
|
||||
+49
-19
@@ -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<tokio::sync::oneshot::Se
|
||||
use hbb_common::sleep;
|
||||
for i in 1..=tries {
|
||||
sleep(i as f32 * CONFIG_SYNC_INTERVAL_SECS).await;
|
||||
match crate::ipc::connect_service(1000).await {
|
||||
match crate::ipc::connect_trusted_device_service(1000).await {
|
||||
Ok(mut conn) => {
|
||||
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<tokio::sync::oneshot::Se
|
||||
#[cfg(target_os = "macos")]
|
||||
let _chk_pk = crate::CheckIfResendPk::new();
|
||||
if !config.is_empty() {
|
||||
if cfg0.0 != config {
|
||||
cfg0.0 = config.clone();
|
||||
Config::set(config);
|
||||
log::info!("sync config from root");
|
||||
}
|
||||
if cfg0.1 != config2 {
|
||||
cfg0.1 = config2.clone();
|
||||
Config2::set(config2);
|
||||
log::info!("sync config2 from root");
|
||||
let result =
|
||||
crate::trusted_devices::config::update(move || {
|
||||
crate::trusted_devices::config::sync_config(
|
||||
config.clone(),
|
||||
config2.clone(),
|
||||
)?;
|
||||
Ok((config, config2))
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(configs) => 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<tokio::sync::oneshot::Se
|
||||
match conn.send(&Data::SyncConfig(Some(cfg.clone().into()))).await {
|
||||
Err(e) => {
|
||||
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<tokio::sync::oneshot::Se
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
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",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-35
@@ -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<Message>)>;
|
||||
|
||||
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<UnauthorizedID>,
|
||||
require_2fa: Option<totp_rs::TOTP>,
|
||||
awaiting_2fa: bool,
|
||||
trusted_device: Option<crate::trusted_devices::host::Attempt>,
|
||||
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;
|
||||
|
||||
@@ -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::<Unavailable>()
|
||||
{
|
||||
// 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::<Unavailable>()
|
||||
{
|
||||
host::log_unavailable(&error);
|
||||
return true;
|
||||
}
|
||||
self.send_login_error(error).await;
|
||||
self.on_close("Trusted-device enrollment failed", false)
|
||||
.await;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Response> {
|
||||
super::config::update(move || handle(request)).await
|
||||
}
|
||||
|
||||
pub fn handle(request: Request) -> ResultType<Response> {
|
||||
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<T>(
|
||||
scope: &[u8; TOKEN_BYTES],
|
||||
apply: impl FnOnce(&mut super::Grants, u32) -> ResultType<(T, bool)>,
|
||||
) -> ResultType<T> {
|
||||
// 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<Credential>)> {
|
||||
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))
|
||||
})
|
||||
}
|
||||
@@ -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<Credentials>,
|
||||
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<Self> {
|
||||
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<Message> {
|
||||
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<bool> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
#[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<Self> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<T: Send + 'static>(
|
||||
apply: impl FnOnce() -> ResultType<T> + Send + 'static,
|
||||
) -> ResultType<T> {
|
||||
hbb_common::tokio::task::spawn_blocking(apply).await?
|
||||
}
|
||||
|
||||
pub async fn update_auth<T: Send + 'static>(
|
||||
affected: bool,
|
||||
apply: impl FnOnce() -> ResultType<T> + Send + 'static,
|
||||
) -> ResultType<T> {
|
||||
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<String, String>,
|
||||
}
|
||||
|
||||
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<T: serde::de::DeserializeOwned + Default>(path: &Path) -> ResultType<T> {
|
||||
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<Password> {
|
||||
Ok(serde_json::from_value(serde_json::to_value(config)?)?)
|
||||
}
|
||||
|
||||
pub(super) fn effective_options(options: HashMap<String, String>) -> HashMap<String, String> {
|
||||
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<String, String>,
|
||||
on_disk: bool,
|
||||
) -> ResultType<Snapshot> {
|
||||
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<Snapshot> {
|
||||
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<T>(affected: bool, apply: impl FnOnce() -> ResultType<T>) -> ResultType<T> {
|
||||
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<String, String>) -> ResultType<()> {
|
||||
policy::change_options(effective_options(options.clone()), || {
|
||||
Config::set_options(options);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_password(value: &str) -> ResultType<bool> {
|
||||
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<bool> {
|
||||
let affected =
|
||||
normalize(password(&Config::get())?, false) != normalize(password(&value)?, false);
|
||||
change(affected, || Ok(Config::set(value)))
|
||||
}
|
||||
|
||||
pub fn set_config2(value: Config2) -> ResultType<bool> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
pub challenge: String,
|
||||
pub salt: String,
|
||||
pub token: Vec<u8>,
|
||||
pub legacy: Option<[u8; TOKEN_BYTES]>,
|
||||
pub supports_v2: bool,
|
||||
}
|
||||
|
||||
impl LoginProof {
|
||||
pub fn from_login(request: &mut LoginRequest, hash: &Hash) -> ResultType<Self> {
|
||||
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<Credential>,
|
||||
pub enroll: bool,
|
||||
}
|
||||
|
||||
pub async fn begin(
|
||||
proof: LoginProof,
|
||||
) -> ResultType<Option<(Option<Attempt>, 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::<authority::Unavailable>() => {
|
||||
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<Vec<u8>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
#[serde(flatten)]
|
||||
info: DeviceInfo,
|
||||
}
|
||||
|
||||
pub(super) fn verifier(hwid: &[u8], info: &DeviceInfo) -> ResultType<Option<[u8; TOKEN_BYTES]>> {
|
||||
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<LegacyDevice> =
|
||||
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<bool> {
|
||||
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<Option<&Record>> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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)
|
||||
}
|
||||
@@ -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<i64>,
|
||||
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<Record>,
|
||||
}
|
||||
|
||||
#[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<Ticket> {
|
||||
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<bool> {
|
||||
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<GrantsStorage> {
|
||||
Ok(GrantsStorage::lock().map_err(authority::Unavailable)?)
|
||||
}
|
||||
|
||||
fn load_grants(storage: &GrantsStorage, scope: &[u8; TOKEN_BYTES]) -> ResultType<Grants> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<String, String>) -> 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<String, String>, new: &HashMap<String, String>) -> bool {
|
||||
AUTH_OPTIONS
|
||||
.iter()
|
||||
.any(|key| old.get(*key) != new.get(*key))
|
||||
}
|
||||
|
||||
pub fn changes_authentication(options: &HashMap<String, String>) -> 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<String, String>) -> 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<T>(
|
||||
options: HashMap<String, String>,
|
||||
apply: impl FnOnce() -> ResultType<T>,
|
||||
) -> ResultType<T> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Self> {
|
||||
Ok(Self {
|
||||
_lock: GRANTS_LOCK
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("Trusted-device storage lock is poisoned"))?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read(&self) -> ResultType<super::Grants> {
|
||||
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<bool> {
|
||||
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<super::Grants> {
|
||||
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"))
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
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());
|
||||
}
|
||||
@@ -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<serde_json::Value> {
|
||||
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::<u32>().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());
|
||||
}
|
||||
@@ -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::<authority::Unavailable>());
|
||||
|
||||
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();
|
||||
}
|
||||
+45
-29
@@ -419,17 +419,52 @@ pub fn get_sound_inputs() -> Vec<String> {
|
||||
|
||||
#[inline]
|
||||
pub fn set_options(m: HashMap<String, String>) {
|
||||
hbb_common::allow_err!(try_set_options(m));
|
||||
}
|
||||
|
||||
pub fn try_set_options(m: HashMap<String, String>) -> 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::<Vec<Bytes>>(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
|
||||
|
||||
+10
-14
@@ -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<T: InvokeUiSession> Session<T> {
|
||||
|
||||
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<T: InvokeUiSession> Interface for Session<T> {
|
||||
}
|
||||
}
|
||||
|
||||
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<T: InvokeUiSession> Interface for Session<T> {
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user