From e15e2c33bdfa5d2b47979a9bfff8549a94e55aed Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sun, 1 Nov 2026 23:26:48 +0800 Subject: [PATCH] [feat] add application-layer trusted device authentication --- .../src/config_center/config_center.cpp | 19 ++ .../desktop/src/config_center/config_center.h | 3 + .../src/gui/application/gui_application.cpp | 2 + .../file_transfer/file_transfer_manager.cpp | 8 +- .../src/gui/runtime/connection_runtime.cpp | 6 + .../src/gui/runtime/device_identity_store.cpp | 97 ++++++++ .../src/gui/runtime/device_identity_store.h | 24 ++ .../src/gui/runtime/device_trust_runtime.cpp | 192 +++++++++++++++ apps/desktop/src/gui/runtime/gui_runtime.h | 30 ++- apps/desktop/src/gui/runtime/lan_runtime.cpp | 4 +- .../src/gui/runtime/peer_data_callbacks.cpp | 26 +- .../src/gui/runtime/peer_event_handler.cpp | 12 +- .../src/gui/runtime/privacy_runtime.cpp | 3 + .../src/gui/runtime/trusted_devices.cpp | 229 ++++++++++++++++++ .../desktop/src/gui/runtime/trusted_devices.h | 60 +++++ .../application/unattended_application.cpp | 2 + 16 files changed, 699 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/gui/runtime/device_identity_store.cpp create mode 100644 apps/desktop/src/gui/runtime/device_identity_store.h create mode 100644 apps/desktop/src/gui/runtime/device_trust_runtime.cpp create mode 100644 apps/desktop/src/gui/runtime/trusted_devices.cpp create mode 100644 apps/desktop/src/gui/runtime/trusted_devices.h diff --git a/apps/desktop/src/config_center/config_center.cpp b/apps/desktop/src/config_center/config_center.cpp index 1be7b836..b7919b35 100644 --- a/apps/desktop/src/config_center/config_center.cpp +++ b/apps/desktop/src/config_center/config_center.cpp @@ -36,6 +36,14 @@ ConfigCenter::~ConfigCenter() {} int ConfigCenter::Load() { SI_Error rc = ini_->LoadFile(config_path_.c_str()); if (rc < 0) { + std::error_code error; + if (std::filesystem::exists(config_path_, error) || error) { + // An unreadable existing configuration must not silently disable access + // restrictions or overwrite the user's settings with default values. + trusted_devices_only_.store(true); + LOG_ERROR("Cannot read existing configuration; restricting incoming connections"); + return -1; + } Save(); return -1; } @@ -141,6 +149,9 @@ int ConfigCenter::Load() { ini_->GetBoolValue(section_, "privacy_local_unlock_allowed", true)); lock_on_disconnect_.store( ini_->GetBoolValue(section_, "lock_on_disconnect", true)); + trusted_devices_only_.store( + ini_->GetValue(section_, "trusted_devices_only", nullptr) != nullptr && + ini_->GetBoolValue(section_, "trusted_devices_only", true)); save_remote_previews_.store( ini_->GetBoolValue(section_, "save_remote_previews", true)); portable_service_prompt_suppressed_ = @@ -239,6 +250,7 @@ int ConfigCenter::Save() { ini_->SetBoolValue(section_, "privacy_local_unlock_allowed", privacy_local_unlock_allowed_.load()); ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load()); + ini_->SetBoolValue(section_, "trusted_devices_only", trusted_devices_only_.load()); ini_->SetBoolValue(section_, "save_remote_previews", save_remote_previews_.load()); ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", @@ -425,6 +437,13 @@ bool ConfigCenter::IsPrivacyLocalUnlockAllowed() const { return privacy_local_unlock_allowed_.load(); } +int ConfigCenter::SetTrustedDevicesOnly(bool enabled) { + if (StoreValues({{"trusted_devices_only", enabled ? "true" : "false"}}) != 0) return -1; + trusted_devices_only_.store(enabled); + return 0; +} +bool ConfigCenter::IsTrustedDevicesOnly() const { return trusted_devices_only_.load(); } + int ConfigCenter::SetLockOnDisconnect(bool enabled) { if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0) return -1; diff --git a/apps/desktop/src/config_center/config_center.h b/apps/desktop/src/config_center/config_center.h index 33439b75..368e2365 100644 --- a/apps/desktop/src/config_center/config_center.h +++ b/apps/desktop/src/config_center/config_center.h @@ -55,6 +55,7 @@ class ConfigCenter { int SetPrivacyScreen(bool enable_privacy_screen); int SetPrivacyLocalUnlock(bool allowed); int SetLockOnDisconnect(bool enabled); + int SetTrustedDevicesOnly(bool enabled); int SetSaveRemotePreviews(bool enabled); int SetPortableServicePromptSuppressed(bool suppressed); int SetSponsorshipPromptDismissedVersion(const std::string& version); @@ -80,6 +81,7 @@ class ConfigCenter { bool IsEnablePrivacyScreen() const; bool IsPrivacyLocalUnlockAllowed() const; bool IsLockOnDisconnect() const; + bool IsTrustedDevicesOnly() const; bool IsSaveRemotePreviews() const; bool IsPortableServicePromptSuppressed() const; std::string GetSponsorshipPromptDismissedVersion() const; @@ -113,6 +115,7 @@ class ConfigCenter { std::atomic enable_privacy_screen_{true}; std::atomic privacy_local_unlock_allowed_{true}; std::atomic lock_on_disconnect_{true}; + std::atomic trusted_devices_only_{false}; std::atomic save_remote_previews_{true}; bool portable_service_prompt_suppressed_ = false; std::string sponsorship_prompt_dismissed_version_; diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index e6f5c609..ea4d9fa8 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1153,6 +1153,7 @@ int GuiApplication::Run() { announcements_.SetStorageDirectory(path_manager_->GetCachePath() / "announcement_reads"); config_center_ = std::make_unique(cache_path_ + "/config.ini"); + InitializeTrustedDevices(path_manager_->GetConfigPath() / "trusted-devices"); LOG_INFO("CrossDesk version: {} (Slint UI)", CROSSDESK_VERSION); @@ -2472,6 +2473,7 @@ void GuiApplication::Tick() { return; } TickLan(); + TickControllerTrust(); if (LanAvailable()) { const auto address = lan_->identity().settings().value("address", ""); ui_->main->set_lan_listen_all(lan_->identity().listen_all()); diff --git a/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp b/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp index c9b21b0d..9f19ea6f 100644 --- a/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp +++ b/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp @@ -99,8 +99,9 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { peer = events ? events->DirectPeer() : nullptr; } auto session = std::make_shared( - [peer, events, file, props](const char*, const char* data, size_t size) { - if (!peer || !events || (props && props->closing_)) return -1; + [this, peer, events, file, props](const char*, const char* data, size_t size) { + if (!peer || !events || (props && props->closing_) || + (!props && !owner_.IsAuthorizedController(file.remote_id))) return -1; auto callback = events->EnterCallback(); if (!callback || !events->DataAllowed()) return -1; const auto wire_id = events->WireId(file.remote_id); @@ -341,7 +342,8 @@ void FileTransferManager::Receive(const char* data, size_t size, (const file_transfer::ReceiveState& progress) mutable { UpdateReceiveProgress(progress, notifications, remote); }); - entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) { + entry.session->SetOnSendAck([this, peer, events, remote, props](const FileTransferAck& ack) { + if (!props && !owner_.IsAuthorizedController(remote)) return -1; auto callback = events->EnterCallback(); if (!callback || !events->DataAllowed()) return -1; const auto bytes = EncodeFileTransferAck(ack); diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index 99e3c5ca..8d453576 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -443,6 +443,12 @@ void GuiRuntime::CloseConnectionPeer() { announcements_.Reset(); return; } + { + std::lock_guard lock(controller_trust_mutex_); + std::erase_if(controller_trust_, [this](const auto& item) { + return item.second.events == peer_events_.get(); + }); + } peer_events_->StopAccepting(); transfers_.Cancel(peer_events_.get()); announcements_.Reset(); diff --git a/apps/desktop/src/gui/runtime/device_identity_store.cpp b/apps/desktop/src/gui/runtime/device_identity_store.cpp new file mode 100644 index 00000000..0e83a1cc --- /dev/null +++ b/apps/desktop/src/gui/runtime/device_identity_store.cpp @@ -0,0 +1,97 @@ +#include "runtime/device_identity_store.h" + +#include + +#include +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#endif + +#include "private_file.h" + +namespace crossdesk::device_identity_store { +namespace { +void Require(bool ok) { + if (!ok) throw std::runtime_error("Device identity store unavailable or invalid"); +} + +// Keep the lock file's inode stable; it contains no credentials. +class StoreLock { + public: + explicit StoreLock(const std::filesystem::path& path) { +#ifdef _WIN32 + handle_ = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, + OPEN_ALWAYS, FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + BY_HANDLE_FILE_INFORMATION info{}; + if (handle_ == INVALID_HANDLE_VALUE || !GetFileInformationByHandle(handle_, &info) || + (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT))) { + if (handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_); + throw std::runtime_error("Device identity store unavailable"); + } +#else + handle_ = open(path.c_str(), O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0600); + struct stat info{}; + if (handle_ < 0 || fstat(handle_, &info) != 0 || !S_ISREG(info.st_mode) || + info.st_uid != getuid() || (info.st_mode & 0077) != 0 || + flock(handle_, LOCK_EX | LOCK_NB) != 0) { + if (handle_ >= 0) close(handle_); + throw std::runtime_error("Device identity store unavailable"); + } +#endif + } + ~StoreLock() { +#ifdef _WIN32 + CloseHandle(handle_); +#else + close(handle_); +#endif + } + StoreLock(const StoreLock&) = delete; + StoreLock& operator=(const StoreLock&) = delete; + + private: +#ifdef _WIN32 + HANDLE handle_ = INVALID_HANDLE_VALUE; +#else + int handle_ = -1; +#endif +}; + +struct PrivateText { + std::string value; + ~PrivateText() { OPENSSL_cleanse(value.data(), value.size()); } +}; + +void Validate(const nlohmann::json& document) { + Require(document.is_object() && document.at("version") == 1); +} +} // namespace + +void Update(const std::filesystem::path& cache, + const std::function& update) { + private_file::EnsureDirectory(cache, true); + StoreLock lock(cache / "identity.json.lock"); + const auto path = cache / "identity.json"; + const bool exists = std::filesystem::symlink_status(path).type() != + std::filesystem::file_type::not_found; + nlohmann::json saved = {{"version", 1}}; + if (exists) { + PrivateText text{private_file::Read(path)}; + saved = nlohmann::json::parse(text.value, nullptr, false); + Require(!saved.is_discarded()); + } + Validate(saved); + PrivateText before{saved.dump()}; + update(saved); + Validate(saved); + PrivateText after{saved.dump()}; + Require(after.value.size() <= 64 * 1024); + if (!exists || before.value != after.value) private_file::Write(path, after.value); +} + +} // namespace crossdesk::device_identity_store diff --git a/apps/desktop/src/gui/runtime/device_identity_store.h b/apps/desktop/src/gui/runtime/device_identity_store.h new file mode 100644 index 00000000..7dbdbe3e --- /dev/null +++ b/apps/desktop/src/gui/runtime/device_identity_store.h @@ -0,0 +1,24 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-01 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _DEVICE_IDENTITY_STORE_H_ +#define _DEVICE_IDENTITY_STORE_H_ + +#include +#include +#include + +namespace crossdesk::device_identity_store { + +// The trusted-device pairing key lives in cache/identity.json. Read the latest +// document under a process lock, apply an update, then atomically persist it. +// Throw on corruption, unsafe permissions, lock contention or persistence error. +void Update(const std::filesystem::path& cache, + const std::function& update); + +} // namespace crossdesk::device_identity_store + +#endif diff --git a/apps/desktop/src/gui/runtime/device_trust_runtime.cpp b/apps/desktop/src/gui/runtime/device_trust_runtime.cpp new file mode 100644 index 00000000..86aaf3bf --- /dev/null +++ b/apps/desktop/src/gui/runtime/device_trust_runtime.cpp @@ -0,0 +1,192 @@ +#include "runtime/gui_runtime.h" + +#include "platform.h" +#include "rd_log.h" + +namespace crossdesk { +void GuiRuntime::InitializeTrustedDevices(const std::filesystem::path& directory) { + try { + trusted_devices_ = std::make_unique(directory, cache_path_); + } catch (const std::exception& error) { + LOG_ERROR("Cannot load trusted devices: {}", error.what()); + // The independent persisted switch stays enabled on identity/store failure. + } +} + +bool GuiRuntime::TrustedControllerAllowed(const std::string& id) { + if (!config_center_ || !config_center_->IsTrustedDevicesOnly()) return true; + std::lock_guard lock(controller_trust_mutex_); + const auto it = controller_trust_.find(id); + return it != controller_trust_.end() && it->second.IsTrusted(trusted_devices_.get()); +} + +bool GuiRuntime::BeginControllerTrust(const std::string& id, PeerEventHandler* events) { + const bool required = config_center_->IsTrustedDevicesOnly(); + std::lock_guard lock(controller_trust_mutex_); + const auto previous = controller_trust_.find(id); + // Non-connected statuses clear this state. Duplicate Connected notifications + // in the same application session must not restart the challenge or timeout. + if (previous != controller_trust_.end() && previous->second.events == events) { + auto& state = previous->second; + const bool allowed = !required || state.IsTrusted(trusted_devices_.get()); + state.pending = !allowed; + return allowed; + } + ControllerTrust state; + state.events = events; + state.pending = required; + state.deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + try { + if (!trusted_devices_) state.invalid = true; + else state.nonce = TrustedDevices::Nonce(); + } catch (...) { state.invalid = true; } + controller_trust_[id] = std::move(state); + return !required; +} + +void GuiRuntime::ForgetControllerTrust(const std::string& id, PeerEventHandler* events) { + std::lock_guard lock(controller_trust_mutex_); + const auto it = controller_trust_.find(id); + if (it != controller_trust_.end() && it->second.events == events) + controller_trust_.erase(it); +} + +int GuiRuntime::SendTrustMessage(const std::string& id, const nlohmann::json& message) { + const auto text = message.dump(); + return WithController(id, [&](PeerPtr* peer, const std::string& wire) { + return SendReliableDataFrameToPeer(peer, text.data(), text.size(), + control_data_label_.c_str(), wire.data(), wire.size()); + }, false); +} + +bool GuiRuntime::HandleDeviceTrustMessage(const std::string& id, PeerEventHandler* events, + const char* data, size_t size) { + if (!data || size > 4096) return false; + const auto message = nlohmann::json::parse(data, data + size, nullptr, false); + if (!message.is_object()) return false; + const auto type_it = message.find("type"); + if (type_it == message.end()) return false; + if (*type_it == static_cast(ControlType::host_infomation)) { + // Advertise through an existing message so disabled restrictions send no + // unfamiliar protocol messages to legacy clients. Retain only bounded, + // inert metadata until the connection's proof has been accepted. + std::lock_guard lock(controller_trust_mutex_); + const auto it = controller_trust_.find(id); + if (it != controller_trust_.end() && it->second.events == events) { + const auto capability = message.find("device_trust_version"); + if (capability != message.end() && *capability == 1) it->second.capable = true; + if (it->second.pending) { + it->second.early_host_info.assign(data, size); + return true; + } + } + return false; + } + if (!type_it->is_string()) return false; + const auto type = type_it->get(); + if (type.rfind("device_trust_", 0) != 0) return false; + if (!trusted_devices_) return true; + try { + if (type == "device_trust_challenge") { + const auto props = FindRemoteSession(id); + if (!props || props->closing_ || props->peer_events_.get() != events || + message.at("version") != 1) return true; + const auto wire = events->WireId(id); + const auto proof = trusted_devices_->Prove(message.at("nonce"), + props->local_id_, GetHostName()).dump(); + SendReliableDataFrameToPeer(props->peer_, proof.data(), proof.size(), + control_data_label_.c_str(), wire.data(), wire.size()); + } else if (type == "device_trust_proof") { + std::lock_guard lock(controller_trust_mutex_); + const auto it = controller_trust_.find(id); + if (it == controller_trust_.end() || it->second.events != events) return true; + auto& state = it->second; + if (state.invalid || state.verified || std::chrono::steady_clock::now() >= state.deadline) + return true; + // Accept once per fresh challenge; malformed/replayed proofs grant nothing. + state.verified = TrustedDevices::Verify(message, state.nonce); + if (!state.verified) state.invalid = true; + } + } catch (const std::exception&) { + // Untrusted protocol input never escapes the transport callback. + } + return true; +} + +void GuiRuntime::TickControllerTrust() { + struct Action { + std::string id; + PeerEventHandler* events; + std::string nonce; + int kind; + std::string host_info; + }; + std::vector actions; + const auto now = std::chrono::steady_clock::now(); + const bool required = config_center_->IsTrustedDevicesOnly(); + { + std::lock_guard lock(controller_trust_mutex_); + for (auto& [id, state] : controller_trust_) { + if (state.closing) continue; + const bool trusted = state.IsTrusted(trusted_devices_.get()); + if (required && (state.invalid || (state.verified && !trusted) || + (!state.verified && now >= state.deadline))) { + state.invalid = true; + state.closing = true; + actions.push_back({id, state.events, {}, -1}); + } else if (state.pending && (!required || trusted)) { + state.pending = false; + actions.push_back({id, state.events, {}, 1, std::exchange(state.early_host_info, {})}); + } + if ((required || state.capable) && !state.invalid && !state.verified && + now < state.deadline && now >= state.next_challenge) { + state.next_challenge = now + std::chrono::seconds(1); + actions.push_back({id, state.events, state.nonce, 0}); + } + } + } + for (const auto& action : actions) { + const auto events = ControllerEvents(action.id); + if (!events || events.get() != action.events) continue; + auto callback = events->EnterCallback(); + if (!callback) continue; + if (action.kind < 0) { + LOG_WARN("Reject controller [{}]: trusted device verification required", action.id); + transfers_.Cancel(events.get(), action.id); + remote_input_.Disconnect(action.id); + CloseServerController(action.id); + } else if (action.kind > 0) { + events->ApplyConnectionStatus(ConnectionStatus::Connected, action.id); + if (!action.host_info.empty()) { + const auto wire = events->WireId(action.id); + PeerEventHandler::OnReceiveDataBuffer(action.host_info.data(), action.host_info.size(), + wire.data(), wire.size(), control_data_label_.data(), control_data_label_.size(), events.get()); + } + } else { + SendTrustMessage(action.id, {{"type", "device_trust_challenge"}, + {"version", 1}, {"nonce", action.nonce}}); + } + } +} + +std::optional GuiRuntime::VerifiedController(const std::string& id) { + std::lock_guard lock(controller_trust_mutex_); + const auto it = controller_trust_.find(id); + return it == controller_trust_.end() || it->second.invalid + ? std::nullopt : it->second.verified; +} +bool GuiRuntime::TrustController(const std::string& id, const std::string& expected_key) { + if (!trusted_devices_ || !IsAuthorizedController(id)) return false; + const auto device = VerifiedController(id); + if (!device || device->public_key != expected_key) return false; + try { trusted_devices_->Add(*device); return true; } + catch (const std::exception& error) { LOG_ERROR("Cannot trust device: {}", error.what()); return false; } +} +bool GuiRuntime::RemoveTrustedDevice(const std::string& public_key) { + if (!trusted_devices_) return false; + try { trusted_devices_->Remove(public_key); } + catch (const std::exception& error) { LOG_ERROR("Cannot remove trusted device: {}", error.what()); return false; } + TickControllerTrust(); + return true; +} +} // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index cd6702a6..c87483cc 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -26,6 +26,7 @@ #include "runtime/announcement_inbox.h" #include "runtime/peer_event_handler.h" #include "runtime/privacy_session_policy.h" +#include "runtime/trusted_devices.h" namespace crossdesk { @@ -63,7 +64,8 @@ class GuiRuntime : protected gui_detail::GuiState { std::string password, bool remember_password = false); int WithController( const std::string& id, - const std::function& operation); + const std::function& operation, + bool require_trusted = true); int SendControllerData(const std::string& id, const char* data, size_t size, const char* label); std::shared_ptr ControllerEvents(const std::string& id); @@ -105,6 +107,32 @@ class GuiRuntime : protected gui_detail::GuiState { void HandlePrivacy(); void HandleScreenLock(); bool IsAuthorizedController(const std::string& remote_id); + void InitializeTrustedDevices(const std::filesystem::path& directory); + bool TrustedControllerAllowed(const std::string& remote_id); + bool BeginControllerTrust(const std::string& remote_id, PeerEventHandler* events); + void ForgetControllerTrust(const std::string& remote_id, PeerEventHandler* events); + bool HandleDeviceTrustMessage(const std::string& remote_id, PeerEventHandler* events, + const char* data, size_t size); + void TickControllerTrust(); + std::optional VerifiedController(const std::string& remote_id); + bool TrustController(const std::string& remote_id, const std::string& expected_key); + bool RemoveTrustedDevice(const std::string& public_key); + int SendTrustMessage(const std::string& remote_id, const nlohmann::json& message); + std::unique_ptr trusted_devices_; + struct ControllerTrust { + PeerEventHandler* events = nullptr; + std::string nonce; + std::optional verified; + std::string early_host_info; + std::chrono::steady_clock::time_point deadline{}, next_challenge{}; + bool pending = false, invalid = false, closing = false, capable = false; + bool IsTrusted(const TrustedDevices* devices) const { + return devices && !invalid && !closing && verified && + devices->Contains(verified->public_key); + } + }; + std::mutex controller_trust_mutex_; + std::map controller_trust_; void SetControllerPrivacySupport(const std::string& remote_id, bool supported); // Caller holds connection_status_mutex_ exclusively. Turns privacy off when // any connected controller lacks support, or on for the first automatic one. diff --git a/apps/desktop/src/gui/runtime/lan_runtime.cpp b/apps/desktop/src/gui/runtime/lan_runtime.cpp index 1d471016..16b3fb4b 100644 --- a/apps/desktop/src/gui/runtime/lan_runtime.cpp +++ b/apps/desktop/src/gui/runtime/lan_runtime.cpp @@ -180,7 +180,9 @@ std::shared_ptr GuiRuntime::ControllerEvents( } int GuiRuntime::WithController( const std::string& id, - const std::function& operation) { + const std::function& operation, + bool require_trusted) { + if (require_trusted && !TrustedControllerAllowed(id)) return -1; std::shared_lock lock(remote_sessions_mutex_); auto it = lan_controllers_.find(id); auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events; diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 0a43efc9..61124ce5 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -133,6 +133,12 @@ void PeerEventHandler::OnReceiveDataBuffer( GuiRuntime* runtime = &handler->owner_; std::string source_id = std::string(src_id, src_id_size); + const auto remote_id = handler->ConnectionKey(user_id, user_id_size); + if (source_id == runtime->control_data_label_ && + runtime->HandleDeviceTrustMessage(remote_id, handler, data, size)) return; + const auto viewer = runtime->FindRemoteSession(remote_id); + if (viewer && viewer->peer_events_.get() != handler) return; + if (!viewer && !runtime->TrustedControllerAllowed(remote_id)) return; // The service-owned host runs as SYSTEM. Do not expose filesystem or // clipboard operations in that security context. if (runtime->unattended_host_ && @@ -143,34 +149,30 @@ void PeerEventHandler::OnReceiveDataBuffer( return; if (source_id == web_clipboard::kStream) { runtime->clipboard_.ReceiveWeb( - data, size, handler->ConnectionKey(user_id, user_id_size), handler); + data, size, remote_id, handler); return; } if (source_id == runtime->file_label_) { - runtime->transfers_.Receive(data, size, handler, - handler->ConnectionKey(user_id, user_id_size)); + runtime->transfers_.Receive(data, size, handler, remote_id); return; } else if (source_id == runtime->clipboard_label_) { if (size > 0) { - std::string remote_user_id = - handler->ConnectionKey(user_id, user_id_size); - auto props = - runtime->FindRemoteSession(remote_user_id); - if (props && !props->clipboard_allowed_.load()) { + if (viewer && !viewer->clipboard_allowed_.load()) { return; } runtime->clipboard_.QueueRemoteText( - data, size, props ? remote_user_id : std::string{}); + data, size, viewer ? remote_id : std::string{}, {}, + viewer ? std::function{} : [runtime, remote_id] { + return runtime->IsAuthorizedController(remote_id); + }); } return; } else if (source_id == runtime->file_feedback_label_) { - runtime->transfers_.HandleAck( - data, size, handler, handler->ConnectionKey(user_id, user_id_size)); + runtime->transfers_.HandleAck(data, size, handler, remote_id); return; } - const std::string remote_id = handler->ConnectionKey(user_id, user_id_size); const std::string wire_id = handler->WireId(remote_id); PeerPtr* bound_peer = handler->IsDirect() ? handler->DirectPeer() : runtime->peer_; diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 406f5d59..2460541f 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -300,6 +300,14 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status, } } + if (!props) { + if (status == ConnectionStatus::Connected) { + if (!runtime->BeginControllerTrust(remote_id, handler)) return; + } else { + runtime->ForgetControllerTrust(remote_id, handler); + } + } + if (props) { runtime->is_client_mode_ = true; if (!props->wall_managed_) runtime->show_connection_status_window_ = true; @@ -332,7 +340,9 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status, LOG_INFO("Local display [{}:{}]", i + 1, remote_action.i.display_list[i]); } - std::string msg = remote_action.to_json(); + auto information = nlohmann::json::parse(remote_action.to_json()); + information["device_trust_version"] = 1; + std::string msg = information.dump(); const int ret = SendReliableDataFrame(props->peer_, msg.data(), msg.size(), runtime->control_data_label_.c_str()); diff --git a/apps/desktop/src/gui/runtime/privacy_runtime.cpp b/apps/desktop/src/gui/runtime/privacy_runtime.cpp index d959c0ad..47aa68a8 100644 --- a/apps/desktop/src/gui/runtime/privacy_runtime.cpp +++ b/apps/desktop/src/gui/runtime/privacy_runtime.cpp @@ -16,6 +16,7 @@ namespace crossdesk { bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) { + if (!TrustedControllerAllowed(remote_id)) return false; if (remote_id.rfind("lan/", 0) == 0) { const auto events = ControllerEvents(remote_id); if (!events || !events->DataAllowed()) return false; @@ -59,6 +60,7 @@ void GuiRuntime::ApplyPrivacyAdmission(bool automatic_enable) { void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id, const PrivacyCommand& command) { + if (!TrustedControllerAllowed(remote_id)) return; std::shared_lock session_lock(connection_status_mutex_); const auto found = connection_status_.find(remote_id); if (found == connection_status_.end() || @@ -86,6 +88,7 @@ void GuiRuntime::HandlePrivacy() { commands.swap(privacy_commands_); } for (const auto& [remote_id, command] : commands) { + if (!TrustedControllerAllowed(remote_id)) continue; // Keep admission and activation under the connection lock so an unknown // controller cannot join between the capability check and enabling. std::shared_lock lock(connection_status_mutex_); diff --git a/apps/desktop/src/gui/runtime/trusted_devices.cpp b/apps/desktop/src/gui/runtime/trusted_devices.cpp new file mode 100644 index 00000000..62917025 --- /dev/null +++ b/apps/desktop/src/gui/runtime/trusted_devices.cpp @@ -0,0 +1,229 @@ +#include "runtime/trusted_devices.h" + +#include +#include +#include + +#include +#include +#include + +#include "private_file.h" +#include "runtime/device_identity_store.h" + +namespace crossdesk { +namespace { +constexpr size_t kMaxTrustedDevices = 128; +void Require(bool ok) { + if (!ok) throw std::runtime_error("Invalid or unavailable trusted device identity"); +} +std::string Hex(const uint8_t* data, size_t size) { + constexpr char digits[] = "0123456789abcdef"; + std::string result; + result.reserve(size * 2); + for (size_t i = 0; i < size; ++i) { + result += digits[data[i] >> 4]; result += digits[data[i] & 15]; + } + return result; +} +bool IsHex(const std::string& value, size_t bytes) { + return value.size() == bytes * 2 && std::all_of(value.begin(), value.end(), + [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }); +} +std::vector Unhex(const std::string& text, size_t bytes) { + Require(IsHex(text, bytes)); + auto digit = [](char c) { return c <= '9' ? c - '0' : c - 'a' + 10; }; + std::vector result(bytes); + for (size_t i = 0; i < bytes; ++i) + result[i] = (digit(text[2 * i]) << 4) | digit(text[2 * i + 1]); + return result; +} +bool Label(const std::string& value) { + return value.size() <= 128 && std::none_of(value.begin(), value.end(), + [](unsigned char c) { return c < 32 || c == 127; }); +} +std::string Transcript(const std::string& nonce, const TrustedDevices::Device& device) { + // Canonical encoding and a domain tag prevent ambiguous or cross-protocol use. + return nlohmann::json::array({"crossdesk.device-trust.v1", nonce, device.public_key, + device.device_id, device.name}).dump(); +} +using Context = std::unique_ptr; +struct PrivateText { + std::string value; + ~PrivateText() { OPENSSL_cleanse(value.data(), value.size()); } +}; +} + +TrustedDevices::TrustedDevices(const std::filesystem::path& directory, + const std::filesystem::path& cache) + : path_(directory / "devices.json") { + private_file::EnsureDirectory(directory); + const bool exists = std::filesystem::symlink_status(path_).type() != + std::filesystem::file_type::not_found; + PrivateText legacy_key; + if (exists) { + PrivateText text{private_file::Read(path_)}; + auto saved = nlohmann::json::parse(text.value, nullptr, false); + Require(!saved.is_discarded()); + const bool legacy = saved.at("version") == 1; + Require((legacy || saved.at("version") == 2) && saved.at("devices").is_array() && + saved.at("devices").size() <= kMaxTrustedDevices); + if (legacy) { + legacy_key.value = saved.at("private_key").get(); + auto& encoded = saved["private_key"].get_ref(); + OPENSSL_cleanse(encoded.data(), encoded.size()); + Require(IsHex(legacy_key.value, 32)); + } else Require(!saved.contains("private_key")); + for (const auto& row : saved.at("devices")) { + Device device{row.at("public_key"), row.at("name"), row.at("device_id")}; + Require(IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id)); + Require(devices_.emplace(device.public_key, device).second); + } + } + + device_identity_store::Update(cache, [&](nlohmann::json& identity) { + if (!identity.contains("trust_private_key") && !legacy_key.value.empty()) + identity["trust_private_key"] = legacy_key.value; + if (identity.contains("trust_private_key")) { + const auto& encoded = identity.at("trust_private_key").get_ref(); + // Interrupted migration may leave both copies. Never replace either key + // on a mismatch: that would silently invalidate existing pairings. + Require(legacy_key.value.empty() || encoded == legacy_key.value); + auto secret = Unhex(encoded, 32); + key_.reset(EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, + secret.data(), secret.size())); + OPENSSL_cleanse(secret.data(), secret.size()); + } else { + key_.reset(EVP_PKEY_Q_keygen(nullptr, nullptr, "ED25519")); + Require(bool(key_)); + std::array secret{}; + size_t size = secret.size(); + Require(EVP_PKEY_get_raw_private_key(key_.get(), secret.data(), &size) == 1 && size == 32); + identity["trust_private_key"] = Hex(secret.data(), secret.size()); + OPENSSL_cleanse(secret.data(), secret.size()); + } + Require(bool(key_)); + }); + + std::array public_key{}; + size_t size = public_key.size(); + Require(EVP_PKEY_get_raw_public_key(key_.get(), public_key.data(), &size) == 1 && size == 32); + public_key_ = Hex(public_key.data(), public_key.size()); + // The identity update is durable before removing the legacy secret. If this + // write fails, reopening can safely retry with the same key and device list. + if (!exists || !legacy_key.value.empty()) Save(devices_); +} + +std::string TrustedDevices::Fingerprint(const std::string& public_key) { + const auto bytes = Unhex(public_key, 32); + std::array hash{}; + SHA256(bytes.data(), bytes.size(), hash.data()); + return Hex(hash.data(), hash.size()); +} +std::string TrustedDevices::Fingerprint() const { return Fingerprint(public_key_); } + +std::string TrustedDevices::PairingInfo(const std::string& device_id, + const std::string& name) const { + Require(Label(device_id) && Label(name)); + // Export public information only. Connection admission still requires a fresh + // proof of private-key possession; importing this document grants no bypass. + return nlohmann::json{{"type", "crossdesk-pairing"}, {"version", 1}, + {"public_key", public_key_}, {"device_id", device_id}, {"name", name}}.dump(); +} + +std::optional TrustedDevices::ParsePairingInfo(std::string_view text) { + if (text.empty() || text.size() > kMaxPairingInfoSize) return std::nullopt; + try { + const auto info = nlohmann::json::parse(text.begin(), text.end(), nullptr, false); + if (!info.is_object() || info.size() != 5 || info.at("type") != "crossdesk-pairing" || + !info.at("version").is_number_integer() || info.at("version") != 1) + return std::nullopt; + Device device{info.at("public_key"), info.at("name"), info.at("device_id")}; + if (IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id)) + return device; + } catch (const std::exception&) {} + return std::nullopt; +} + +std::string TrustedDevices::Nonce() { + std::array bytes{}; + Require(RAND_bytes(bytes.data(), bytes.size()) == 1); + return Hex(bytes.data(), bytes.size()); +} + +nlohmann::json TrustedDevices::Prove(const std::string& nonce, + const std::string& device_id, const std::string& name) const { + Require(IsHex(nonce, 32) && Label(device_id) && Label(name)); + const Device device{public_key_, name, device_id}; + const auto text = Transcript(nonce, device); + Context context(EVP_MD_CTX_new(), EVP_MD_CTX_free); + std::array signature{}; + size_t size = signature.size(); + Require(context && EVP_DigestSignInit(context.get(), nullptr, nullptr, nullptr, key_.get()) == 1 && + EVP_DigestSign(context.get(), signature.data(), &size, + reinterpret_cast(text.data()), text.size()) == 1 && size == 64); + return {{"type", "device_trust_proof"}, {"version", 1}, {"nonce", nonce}, + {"public_key", public_key_}, {"device_id", device_id}, {"name", name}, + {"signature", Hex(signature.data(), signature.size())}}; +} + +std::optional TrustedDevices::Verify( + const nlohmann::json& proof, const std::string& nonce) { + try { + if (proof.at("version") != 1 || proof.at("nonce") != nonce || !IsHex(nonce, 32)) + return std::nullopt; + Device device{proof.at("public_key"), proof.at("name"), proof.at("device_id")}; + if (!Label(device.name) || !Label(device.device_id)) return std::nullopt; + const auto public_key = Unhex(device.public_key, 32); + const auto signature = Unhex(proof.at("signature"), 64); + std::unique_ptr key( + EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, public_key.data(), public_key.size()), + EVP_PKEY_free); + Context context(EVP_MD_CTX_new(), EVP_MD_CTX_free); + const auto text = Transcript(nonce, device); + if (key && context && EVP_DigestVerifyInit(context.get(), nullptr, nullptr, nullptr, key.get()) == 1 && + EVP_DigestVerify(context.get(), signature.data(), signature.size(), + reinterpret_cast(text.data()), text.size()) == 1) + return device; + } catch (const std::exception&) {} + return std::nullopt; +} + +bool TrustedDevices::Contains(const std::string& public_key) const { + std::lock_guard lock(mutex_); + return devices_.count(public_key) != 0; +} +std::vector TrustedDevices::List() const { + std::lock_guard lock(mutex_); + std::vector result; + for (const auto& [key, device] : devices_) result.push_back(device); + return result; +} +void TrustedDevices::Save(const std::map& devices) const { + nlohmann::json saved{{"version", 2}, {"devices", nlohmann::json::array()}}; + for (const auto& [key, device] : devices) + saved["devices"].push_back({{"public_key", key}, {"name", device.name}, {"device_id", device.device_id}}); + const auto text = saved.dump(2); + Require(text.size() <= 64 * 1024); + private_file::Write(path_, text); +} +void TrustedDevices::Add(const Device& device) { + Require(IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id)); + std::lock_guard lock(mutex_); + const auto existing = devices_.find(device.public_key); + if (existing != devices_.end() && existing->second == device) return; + Require(existing != devices_.end() || devices_.size() < kMaxTrustedDevices); + auto next = devices_; + next[device.public_key] = device; + Save(next); + devices_.swap(next); +} +void TrustedDevices::Remove(const std::string& public_key) { + std::lock_guard lock(mutex_); + if (!devices_.contains(public_key)) return; + auto next = devices_; + next.erase(public_key); + Save(next); + devices_.swap(next); +} +} // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/trusted_devices.h b/apps/desktop/src/gui/runtime/trusted_devices.h new file mode 100644 index 00000000..e28cd43e --- /dev/null +++ b/apps/desktop/src/gui/runtime/trusted_devices.h @@ -0,0 +1,60 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-01 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _TRUSTED_DEVICES_H_ +#define _TRUSTED_DEVICES_H_ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk { + +class TrustedDevices { + public: + struct Device { + std::string public_key, name, device_id; + bool operator==(const Device&) const = default; + }; + + TrustedDevices(const std::filesystem::path& directory, + const std::filesystem::path& cache); + std::string Fingerprint() const; + static std::string Fingerprint(const std::string& public_key); + static std::string Nonce(); + nlohmann::json Prove(const std::string& nonce, + const std::string& device_id, const std::string& name) const; + static std::optional Verify(const nlohmann::json& proof, + const std::string& nonce); + static constexpr size_t kMaxPairingInfoSize = 2048; + std::string PairingInfo(const std::string& device_id, const std::string& name) const; + static std::optional ParsePairingInfo(std::string_view text); + bool Contains(const std::string& public_key) const; + std::vector List() const; + // Call only after a local add action for a verified peer or valid pairing info. + void Add(const Device& device); + void Remove(const std::string& public_key); + + private: + void Save(const std::map& devices) const; + std::filesystem::path path_; + std::unique_ptr key_{nullptr, EVP_PKEY_free}; + std::string public_key_; + mutable std::mutex mutex_; + std::map devices_; +}; + +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp b/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp index a29ba85a..97a7dfcb 100644 --- a/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp +++ b/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp @@ -24,6 +24,7 @@ int GuiApplication::RunUnattended(void* stop_event) { dll_log_path_ = exec_log_path_; InitializeLogger(); config_center_ = std::make_unique(cache_path_ + "/config.ini"); + InitializeTrustedDevices(root / "trusted-devices"); if (config_center_->SetPrivacyScreen(false) != 0 || config_center_->SetSaveRemotePreviews(false) != 0) return 1; @@ -44,6 +45,7 @@ int GuiApplication::RunUnattended(void* stop_event) { next_peer_attempt = now + 3000; if (CreateConnectionPeer() != 0) CloseConnectionPeer(); } + TickControllerTrust(); HandleConnectionStatusChange(); HandleScreenLock(); HandleWindowsServiceIntegration();