mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-04 04:52:39 +08:00
[feat] use device keys for server login
This commit is contained in:
@@ -73,7 +73,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
|
||||
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
|
||||
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
|
||||
|
||||
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
|
||||
使用自建服务器时,两端需要接入同一服务。新版桌面端使用设备密钥登录服务器,请先升级服务端:新 ID 注册时绑定公钥,旧 ID 首次升级时验证原密码后绑定;绑定后,针对该 ID 的错误密码尝试不会阻塞正常签名登录。远程控制仍需输入连接密码。身份备份与兼容要求见 [设备登录说明](docs/connection-architecture.md#41-id-模式)。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
|
||||
|
||||
<a id="lan-direct"></a>
|
||||
|
||||
@@ -101,7 +101,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
|
||||
|
||||
**兼容范围:** 两端均需使用支持可信设备认证的客户端。不支持该协议的旧版客户端无法连接开启限制的被控端;当前 CLI、iOS、Android 和鸿蒙移动端尚未实现该认证。关闭限制时沿用原有连接方式。
|
||||
|
||||
配对私钥保存在缓存目录的 `identity.json`,可信名单保存在配置目录的 `trusted-devices/devices.json`。只清理普通 ID/密码缓存不影响配对身份;清空整个缓存目录后需让其他设备重新信任本机。备份、迁移及认证机制见 [可信设备技术说明](docs/connection-architecture.md#43-可选可信设备认证)。
|
||||
设备登录与配对共用缓存目录 `identity.json` 中的私钥,可信名单保存在配置目录的 `trusted-devices/devices.json`。迁移本机身份时应同时备份 ID/密码缓存与 `identity.json`;已绑定的 ID 无法仅凭密码恢复登录。清空整个缓存目录后需重新登记 ID,并让其他设备重新信任本机。备份、迁移及认证机制见 [可信设备技术说明](docs/connection-architecture.md#43-可选可信设备认证)。
|
||||
|
||||
<a id="preview"></a>
|
||||
|
||||
|
||||
+2
-2
@@ -73,7 +73,7 @@ Requirements follow the current source and build configuration. Check the releas
|
||||
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
|
||||
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
|
||||
|
||||
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems.
|
||||
For self-hosting, both ends must use the same service. Upgrade the server before using the new desktop client: device login uses a private key, with its public key bound at registration or after a one-time password check for an existing ID. Once bound, wrong-password attempts against that ID cannot block valid signature login. Remote control still requires the connection password. See the [device login guide](docs/connection-architecture.md#41-id-模式) (Chinese) for identity backups and compatibility, the [user guide](docs/USER_GUIDE_EN.md#quick-start) for controls, or the [FAQ](docs/FAQ.md#english) for connection problems.
|
||||
|
||||
<a id="lan-direct"></a>
|
||||
|
||||
@@ -101,7 +101,7 @@ Adding or removing a device takes effect immediately. With the restriction enabl
|
||||
|
||||
**Compatibility:** both clients must support trusted-device authentication. Older clients without the protocol cannot connect to a restricted host. The CLI, iOS, Android, and HarmonyOS mobile clients do not currently implement it. With the restriction disabled, the existing connection behavior applies.
|
||||
|
||||
The pairing private key is stored in `identity.json` in the cache directory; the trusted list is stored in `trusted-devices/devices.json` in the configuration directory. Clearing only the ordinary ID/password cache preserves the pairing identity. Clearing the entire cache directory requires other devices to trust this computer again. See the [trusted-device technical guide](docs/connection-architecture.md#43-可选可信设备认证) (Chinese) for backups, migration, and authentication details.
|
||||
Device login and pairing share the private key in the cache directory's `identity.json`; the trusted list is stored in `trusted-devices/devices.json` in the configuration directory. Back up the ID/password cache and `identity.json` together when migrating a device: a bound ID cannot be recovered with its password alone. Clearing the entire cache requires a new ID and renewed trust from other devices. See the [trusted-device technical guide](docs/connection-architecture.md#43-可选可信设备认证) (Chinese) for details.
|
||||
|
||||
<a id="preview"></a>
|
||||
|
||||
|
||||
@@ -2983,6 +2983,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
||||
password_change_result_ready_ = false;
|
||||
password_change_succeeded_ = false;
|
||||
password_change_result_uncertain_ = false;
|
||||
password_change_recovery_ = false;
|
||||
password_change_requested_at_ = std::chrono::steady_clock::now();
|
||||
pending_password_change_request_id_ = request_id;
|
||||
pending_local_password_ = password;
|
||||
@@ -3062,6 +3063,7 @@ void GuiApplication::HandlePasswordChangeResult() {
|
||||
password_change_result_ready_ = false;
|
||||
password_change_succeeded_ = false;
|
||||
password_change_result_uncertain_ = false;
|
||||
password_change_recovery_ = false;
|
||||
pending_password_change_request_id_.clear();
|
||||
pending_local_password_.clear();
|
||||
password_change_error_.clear();
|
||||
@@ -3108,23 +3110,53 @@ void GuiApplication::HandlePasswordChangeResult() {
|
||||
}
|
||||
|
||||
void GuiApplication::HandleCredentialRecovery() {
|
||||
bool retry_active = false;
|
||||
bool promote_pending = false;
|
||||
bool clear_pending = false;
|
||||
CredentialRecoveryAction action;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(password_change_mutex_);
|
||||
retry_active = credential_recovery_retry_active_;
|
||||
promote_pending = credential_recovery_promote_pending_;
|
||||
clear_pending = credential_recovery_clear_pending_;
|
||||
credential_recovery_retry_active_ = false;
|
||||
credential_recovery_promote_pending_ = false;
|
||||
credential_recovery_clear_pending_ = false;
|
||||
if (retry_active) {
|
||||
action = std::exchange(credential_recovery_action_, CredentialRecoveryAction::None);
|
||||
if (action == CredentialRecoveryAction::RetryActive) {
|
||||
credential_recovery_attempt_pending_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (retry_active) {
|
||||
if (action == CredentialRecoveryAction::ResumePending) {
|
||||
// Key authentication proves device ownership, not which password committed.
|
||||
// Replay the durable request and promote only its successful server reply.
|
||||
const bool self_hosted = config_center_->IsSelfHosted();
|
||||
std::string request_id;
|
||||
const auto identity = settings_.PendingPasswordChangeIdentity(self_hosted,
|
||||
self_hosted ? config_center_->GetSignalServerHost() : config_center_->GetDefaultServerHost(),
|
||||
self_hosted ? config_center_->GetSignalServerPort() : config_center_->GetDefaultSignalServerPort(),
|
||||
&request_id);
|
||||
const auto at = identity.find('@');
|
||||
if (!peer_ || !signal_connected_ || at == std::string::npos || request_id.empty() ||
|
||||
identity.substr(0, at) != client_id_) return;
|
||||
const auto password = identity.substr(at + 1);
|
||||
{
|
||||
std::lock_guard lock(password_change_mutex_);
|
||||
if (password_change_pending_) return;
|
||||
password_change_pending_ = true;
|
||||
password_change_result_ready_ = false;
|
||||
password_change_succeeded_ = false;
|
||||
password_change_result_uncertain_ = false;
|
||||
password_change_recovery_ = true;
|
||||
password_change_requested_at_ = std::chrono::steady_clock::now();
|
||||
pending_password_change_request_id_ = request_id;
|
||||
pending_local_password_ = password;
|
||||
password_change_error_.clear();
|
||||
}
|
||||
const auto message = nlohmann::json{{"type", "change_password"},
|
||||
{"request_id", request_id}, {"new_password", password}}.dump();
|
||||
if (SendSignalMessage(peer_, message.data(), message.size()) != 0) {
|
||||
std::lock_guard lock(password_change_mutex_);
|
||||
password_change_result_ready_ = true;
|
||||
password_change_result_uncertain_ = true;
|
||||
password_change_error_ = "Password recovery connection unavailable";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == CredentialRecoveryAction::RetryActive) {
|
||||
LOG_INFO("Pending credential was not accepted; retrying active credential");
|
||||
if (peer_) {
|
||||
CloseConnectionPeer();
|
||||
@@ -3132,7 +3164,7 @@ void GuiApplication::HandleCredentialRecovery() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (promote_pending) {
|
||||
if (action == CredentialRecoveryAction::PromotePending) {
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().NotifyKey("console_password_recovered");
|
||||
#endif
|
||||
@@ -3141,7 +3173,7 @@ void GuiApplication::HandleCredentialRecovery() {
|
||||
} else {
|
||||
LOG_INFO("Recovered password change with pending credential");
|
||||
}
|
||||
} else if (clear_pending) {
|
||||
} else if (action == CredentialRecoveryAction::ClearPending) {
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().NotifyKey("console_password_retained");
|
||||
#endif
|
||||
|
||||
@@ -593,14 +593,17 @@ bool SettingsManager::StagePendingPasswordChange(
|
||||
}
|
||||
|
||||
std::string SettingsManager::PendingPasswordChangeIdentity(
|
||||
bool self_hosted, const std::string &server_host, int server_port) const {
|
||||
bool self_hosted, const std::string &server_host, int server_port,
|
||||
std::string* request_id) const {
|
||||
std::lock_guard<std::mutex> lock(cache_mutex_);
|
||||
if (request_id) request_id->clear();
|
||||
if (cache_v3_.pending_identity[0] == '\0' ||
|
||||
cache_v3_.pending_self_hosted != self_hosted ||
|
||||
cache_v3_.pending_server_port != server_port ||
|
||||
server_host != cache_v3_.pending_server_host) {
|
||||
return {};
|
||||
}
|
||||
if (request_id) *request_id = cache_v3_.pending_request_id;
|
||||
return cache_v3_.pending_identity;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ public:
|
||||
const std::string &request_id);
|
||||
std::string PendingPasswordChangeIdentity(
|
||||
bool self_hosted, const std::string &server_host,
|
||||
int server_port) const;
|
||||
int server_port, std::string* request_id = nullptr) const;
|
||||
bool PromotePendingPasswordChange();
|
||||
bool ClearPendingPasswordChange();
|
||||
|
||||
|
||||
@@ -709,6 +709,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
props->audio_capture_button_pressed_ = !background;
|
||||
memcpy(&props->params_, ¶ms_, sizeof(Params));
|
||||
props->params_.user_id = props->local_id_.c_str();
|
||||
props->params_.on_signal_exchange = nullptr;
|
||||
props->peer_events_ = std::make_shared<PeerEventHandler>(*this);
|
||||
props->params_.user_data = props->peer_events_.get();
|
||||
props->peer_ = CreatePeer(&props->params_);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
namespace crossdesk::device_identity_store {
|
||||
|
||||
// The trusted-device pairing key lives in cache/identity.json. Read the latest
|
||||
// The shared device signing 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,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "runtime/device_login.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "runtime/trusted_devices.h"
|
||||
|
||||
namespace crossdesk {
|
||||
int DeviceLogin::Exchange(SignalExchangeStage stage, const nlohmann::json& message,
|
||||
const TrustedDevices* identity, char* response, size_t capacity) {
|
||||
auto output = [&](const nlohmann::json& reply) {
|
||||
const auto text = reply.dump();
|
||||
if (text.empty() || text.size() > capacity) return -1;
|
||||
std::memcpy(response, text.data(), text.size());
|
||||
return static_cast<int>(text.size());
|
||||
};
|
||||
if (stage == SignalExchangeStage::PrepareLogin) {
|
||||
phase_ = Phase::Idle;
|
||||
used_password_ = false;
|
||||
password_.clear();
|
||||
request_ = message;
|
||||
const auto login = message.at("user_id").get<std::string>();
|
||||
if (login.rfind("C-", 0) == 0) return 0;
|
||||
if (!identity) return -1; // Never fall back when local key storage fails.
|
||||
const auto at = login.find('@');
|
||||
request_["user_id"] = login.substr(0, at);
|
||||
if (at != std::string::npos) password_ = login.substr(at + 1);
|
||||
request_["device_key"] = {{"version", 1}, {"public_key", identity->PublicKey()}};
|
||||
request_.erase("reconnect_token");
|
||||
request_.erase("reconnect_version");
|
||||
phase_ = Phase::AwaitingChallenge;
|
||||
return output(request_);
|
||||
}
|
||||
if (!Pending()) return 0;
|
||||
const auto type = message.value("type", "");
|
||||
if (type == "login_challenge") {
|
||||
if (!identity || phase_ != Phase::AwaitingChallenge || message.at("device_key_version") != 1 ||
|
||||
message.at("user_id") != request_.at("user_id")) return -1;
|
||||
const auto nonce = message.at("nonce").get<std::string>();
|
||||
if (nonce.size() != 64 || !std::all_of(nonce.begin(), nonce.end(),
|
||||
[](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }))
|
||||
return -1;
|
||||
const auto id = request_.at("user_id").get<std::string>();
|
||||
auto reply = request_;
|
||||
used_password_ = message.at("password_required").get<bool>();
|
||||
if (used_password_) {
|
||||
if (id.empty() || password_.empty()) return -1;
|
||||
reply["user_id"] = id + "@" + password_;
|
||||
}
|
||||
const auto transcript = nlohmann::json::array(
|
||||
{"crossdesk.device-login.v1", id, identity->PublicKey(), nonce}).dump();
|
||||
reply["device_key"]["nonce"] = nonce;
|
||||
reply["device_key"]["signature"] = identity->Sign(transcript);
|
||||
phase_ = Phase::AwaitingResult;
|
||||
password_.clear();
|
||||
return output(reply);
|
||||
}
|
||||
if (type == "login") {
|
||||
if (message.at("status") == "success") {
|
||||
if (phase_ != Phase::AwaitingResult || message.value("device_key_version", 0) != 1) return -1;
|
||||
const auto assigned = message.at("user_id").get<std::string>();
|
||||
const auto expected = request_.at("user_id").get<std::string>();
|
||||
if (assigned.empty() || (!expected.empty() && assigned != expected)) return -1;
|
||||
}
|
||||
password_.clear();
|
||||
request_.clear();
|
||||
phase_ = Phase::Idle;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-11-02
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _DEVICE_LOGIN_H_
|
||||
#define _DEVICE_LOGIN_H_
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
|
||||
#include "minirtc.h"
|
||||
|
||||
namespace crossdesk {
|
||||
class TrustedDevices;
|
||||
|
||||
// One signaling connection's application authentication, owned by its callbacks.
|
||||
class DeviceLogin {
|
||||
public:
|
||||
int Exchange(SignalExchangeStage stage, const nlohmann::json& message,
|
||||
const TrustedDevices* identity, char* response, size_t capacity);
|
||||
bool Pending() const { return phase_ != Phase::Idle; }
|
||||
bool UsedPassword() const { return used_password_; }
|
||||
|
||||
private:
|
||||
enum class Phase { Idle, AwaitingChallenge, AwaitingResult };
|
||||
nlohmann::json request_;
|
||||
std::string password_;
|
||||
Phase phase_ = Phase::Idle;
|
||||
bool used_password_ = false;
|
||||
};
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -190,9 +190,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
if (pending_identity.empty()) {
|
||||
credential_recovery_in_progress_ = false;
|
||||
credential_recovery_attempt_pending_ = false;
|
||||
credential_recovery_retry_active_ = false;
|
||||
credential_recovery_promote_pending_ = false;
|
||||
credential_recovery_clear_pending_ = false;
|
||||
credential_recovery_action_ = CredentialRecoveryAction::None;
|
||||
} else if (!credential_recovery_in_progress_) {
|
||||
credential_recovery_in_progress_ = true;
|
||||
credential_recovery_attempt_pending_ = true;
|
||||
@@ -270,6 +268,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
|
||||
params_.on_signal_status = PeerEventHandler::OnSignalStatus;
|
||||
params_.on_signal_message = PeerEventHandler::OnSignalMessage;
|
||||
params_.on_signal_exchange = PeerEventHandler::OnSignalExchange;
|
||||
params_.on_connection_status = PeerEventHandler::OnConnectionStatus;
|
||||
params_.on_net_status_report = PeerEventHandler::OnNetStatusReport;
|
||||
|
||||
|
||||
@@ -84,6 +84,24 @@ PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {
|
||||
owner_.transfers_.Connected(this, "");
|
||||
}
|
||||
|
||||
int PeerEventHandler::OnSignalExchange(SignalExchangeStage stage, const char* message,
|
||||
size_t size, char* response, size_t capacity, void* user_data) {
|
||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||
if (!handler || !message) return -1;
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return -1;
|
||||
if (stage == SignalExchangeStage::IncomingMessage &&
|
||||
!handler->device_login_.Pending()) return 0;
|
||||
try {
|
||||
return handler->device_login_.Exchange(stage,
|
||||
nlohmann::json::parse(message, message + size),
|
||||
handler->owner_.trusted_devices_.get(), response, capacity);
|
||||
} catch (const std::exception&) {
|
||||
LOG_ERROR("Device login protocol or identity unavailable");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
||||
void* user_data) {
|
||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||
@@ -184,13 +202,16 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
||||
j.contains("reason") && j["reason"].is_string()
|
||||
? j["reason"].get<std::string>()
|
||||
: "Password change failed";
|
||||
runtime->password_change_result_uncertain_ = false;
|
||||
// A rejected replay cannot tell us whether the original request committed.
|
||||
runtime->password_change_result_uncertain_ = runtime->password_change_recovery_ &&
|
||||
!runtime->password_change_succeeded_;
|
||||
runtime->password_change_result_ready_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
||||
size_t user_id_size, void* user_data) {
|
||||
using RecoveryAction = GuiRuntime::CredentialRecoveryAction;
|
||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||
if (!handler) return;
|
||||
auto callback = handler->EnterCallback();
|
||||
@@ -212,10 +233,12 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
||||
SendClientInfo(runtime->peer_, client_id);
|
||||
std::lock_guard<std::mutex> lock(runtime->password_change_mutex_);
|
||||
if (runtime->credential_recovery_in_progress_) {
|
||||
if (runtime->credential_recovery_attempt_pending_) {
|
||||
runtime->credential_recovery_promote_pending_ = true;
|
||||
if (!handler->device_login_.UsedPassword()) {
|
||||
runtime->credential_recovery_action_ = RecoveryAction::ResumePending;
|
||||
} else if (runtime->credential_recovery_attempt_pending_) {
|
||||
runtime->credential_recovery_action_ = RecoveryAction::PromotePending;
|
||||
} else {
|
||||
runtime->credential_recovery_clear_pending_ = true;
|
||||
runtime->credential_recovery_action_ = RecoveryAction::ClearPending;
|
||||
}
|
||||
}
|
||||
} else if (SignalStatus::SignalFailed == status) {
|
||||
@@ -223,7 +246,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
||||
std::lock_guard<std::mutex> lock(runtime->password_change_mutex_);
|
||||
if (runtime->credential_recovery_in_progress_ &&
|
||||
runtime->credential_recovery_attempt_pending_) {
|
||||
runtime->credential_recovery_retry_active_ = true;
|
||||
runtime->credential_recovery_action_ = RecoveryAction::RetryActive;
|
||||
}
|
||||
} else if (SignalStatus::SignalClosed == status) {
|
||||
runtime->signal_connected_ = false;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "minirtc.h"
|
||||
#include "runtime/session_lifecycle.h"
|
||||
#include "runtime/device_login.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -63,6 +64,9 @@ public:
|
||||
size_t user_id_size, void *user_data);
|
||||
static void OnSignalMessage(const char *message, size_t size,
|
||||
void *user_data);
|
||||
static int OnSignalExchange(SignalExchangeStage stage, const char* message,
|
||||
size_t size, char* response, size_t capacity,
|
||||
void* user_data);
|
||||
static void OnConnectionStatus(ConnectionStatus status, const char *user_id,
|
||||
size_t user_id_size, void *user_data);
|
||||
static void OnNetStatusReport(const char *client_id, size_t client_id_size,
|
||||
@@ -85,6 +89,7 @@ private:
|
||||
size_t early_data_bytes_ = 0;
|
||||
|
||||
GuiRuntime& owner_;
|
||||
DeviceLogin device_login_;
|
||||
gui_detail::SessionCallbackGate callbacks_;
|
||||
};
|
||||
|
||||
|
||||
@@ -167,21 +167,23 @@ struct UserSettingsState {
|
||||
};
|
||||
|
||||
struct PasswordChangeState {
|
||||
enum class CredentialRecoveryAction {
|
||||
None, RetryActive, PromotePending, ClearPending, ResumePending
|
||||
};
|
||||
std::mutex password_change_mutex_;
|
||||
uint64_t next_password_change_request_id_ = 0;
|
||||
bool password_change_pending_ = false;
|
||||
bool password_change_result_ready_ = false;
|
||||
bool password_change_succeeded_ = false;
|
||||
bool password_change_result_uncertain_ = false;
|
||||
bool password_change_recovery_ = false;
|
||||
std::chrono::steady_clock::time_point password_change_requested_at_;
|
||||
std::string pending_password_change_request_id_;
|
||||
std::string pending_local_password_;
|
||||
std::string password_change_error_;
|
||||
bool credential_recovery_in_progress_ = false;
|
||||
bool credential_recovery_attempt_pending_ = false;
|
||||
bool credential_recovery_retry_active_ = false;
|
||||
bool credential_recovery_promote_pending_ = false;
|
||||
bool credential_recovery_clear_pending_ = false;
|
||||
CredentialRecoveryAction credential_recovery_action_ = CredentialRecoveryAction::None;
|
||||
};
|
||||
|
||||
struct ConnectionState {
|
||||
|
||||
@@ -156,15 +156,19 @@ nlohmann::json TrustedDevices::Prove(const std::string& nonce,
|
||||
Require(IsHex(nonce, 32) && Label(device_id) && Label(name));
|
||||
const Device device{public_key_, name, device_id};
|
||||
const auto text = Transcript(nonce, device);
|
||||
return {{"type", "device_trust_proof"}, {"version", 1}, {"nonce", nonce},
|
||||
{"public_key", public_key_}, {"device_id", device_id}, {"name", name},
|
||||
{"signature", Sign(text)}};
|
||||
}
|
||||
|
||||
std::string TrustedDevices::Sign(std::string_view text) const {
|
||||
Context context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
|
||||
std::array<uint8_t, 64> 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<const uint8_t*>(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())}};
|
||||
return Hex(signature.data(), signature.size());
|
||||
}
|
||||
|
||||
std::optional<TrustedDevices::Device> TrustedDevices::Verify(
|
||||
|
||||
@@ -31,6 +31,9 @@ class TrustedDevices {
|
||||
TrustedDevices(const std::filesystem::path& directory,
|
||||
const std::filesystem::path& cache);
|
||||
std::string Fingerprint() const;
|
||||
const std::string& PublicKey() const { return public_key_; }
|
||||
// Callers supply a canonical, purpose-specific transcript.
|
||||
std::string Sign(std::string_view transcript) const;
|
||||
static std::string Fingerprint(const std::string& public_key);
|
||||
static std::string Nonce();
|
||||
nlohmann::json Prove(const std::string& nonce,
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: 539258d1b9...f499567442
@@ -180,11 +180,17 @@ WSS 在连接成功后继续承担心跳和会话关闭消息。当前心跳约
|
||||
|
||||
| 场景 | 登录身份及职责 |
|
||||
| --- | --- |
|
||||
| 本机常驻 Peer | 使用本机缓存身份;已有 ID 与密码时,普通登录提交 `本机ID@本机密码`,用于本机上线与接受远控 |
|
||||
| 本机常驻 Peer | 使用缓存 ID 和本地 Ed25519 私钥完成挑战签名,服务端按该 ID 绑定的公钥验签 |
|
||||
| 控制端会话 Peer | 以 `C-<本机ID>` 登录;目标设备的密码在后续 Join 中提交,不附加在该登录身份中 |
|
||||
| 自托管首次身份分配 | 无缓存身份时,常驻 Peer 以空 `user_id` 请求服务端分配身份,不能假定首次登录时已经具备 ID 和密码 |
|
||||
| 首次身份分配 | 无缓存身份时,以空 `user_id` 和本机公钥发起挑战;证明私钥持有权后,服务端原子分配 ID、远控密码并绑定公钥 |
|
||||
|
||||
服务端支持重连令牌且客户端持有有效令牌时,后续登录可使用不带密码的 ID 与令牌;令牌被拒绝后可回退到原登录身份。连接目标设备时,控制端会话 Peer 将 `目标ID@目标密码` 放入 Join,由服务端返回成功、凭据错误、限流或繁忙结果。客户端解析结果并驱动密码弹窗与重试;服务端保存密码的具体方式及配额以配套服务端实现为准。
|
||||
每次登录和重连都先请求 `login_challenge`,再签署规范 JSON 数组 `["crossdesk.device-login.v1", ID, 公钥, 随机挑战]`。挑战为 32 字节随机数,只属于当前 WSS 连接,有效期 10 秒,验证尝试时即消费。已绑定 ID 的签名登录不进入密码计算队列,也不读取密码失败计数;伪造签名不能锁定该 ID。服务端另外限制挑战来源和待处理数量,仍受连接及消息资源上限约束。
|
||||
|
||||
旧 ID 首次升级时,客户端先完成签名证明,再按服务端要求提交原有密码;密码验证成功后仅允许绑定一次公钥,保留 ID 和密码。绑定后撤销旧重连令牌并拒绝密码登录,不能用密码覆盖公钥;尚未完成绑定的旧 ID 仍受原密码限流。部署时先升级服务端;新桌面客户端要求签名协议,不自动回退到旧服务端的密码登录。混部或回滚到忽略公钥绑定的旧服务端会失去这项保护。
|
||||
|
||||
MiniRTC 的可选 `OnSignalExchange` 回调提供登录请求替换、入站消息处理及回复发送能力,不包含设备密钥、可信名单或 CrossDesk 协议字段。应用层负责签名和协议状态;未配置回调的使用方保留原有登录及内存重连令牌行为。改密中断后,签名登录不代表待定密码已生效,客户端重发持久保存的改密请求,收到成功回复后才更新本地密码。
|
||||
|
||||
连接目标设备时,控制端会话 Peer 仍将 `目标ID@目标密码` 放入 Join,由服务端返回成功、凭据错误、限流或繁忙结果。设备签名登录仅证明本机 ID 所有权,不授予控制其他设备的权限。
|
||||
|
||||
信令 TLS 与远控数据加密属于两条路径。原生远控的 DTLS 握手和 SRTP 处理位于 MiniRTC,不能把“信令使用 TLS”等同于“画面经 TLS WebSocket 传输”。
|
||||
|
||||
@@ -198,7 +204,7 @@ WSS 在连接成功后继续承担心跳和会话关闭消息。当前心跳约
|
||||
|
||||
该表适用于 `MiniRtcConnection` 原生传输。浏览器使用 `DataChannelConnection` 的 WebRTC 适配路径,不按此表推断其加密行为。
|
||||
|
||||
对应代码:[常驻身份初始化](../apps/desktop/src/gui/runtime/gui_runtime.cpp)、[控制端会话登录与 Join](../apps/desktop/src/gui/runtime/connection_runtime.cpp)、[重连令牌](../deps/submodules/minirtc/src/pc/reconnect_login.h)、[SRTP 能力协商](../deps/submodules/minirtc/src/transport/ice_transport.cpp)。
|
||||
对应代码:[应用层登录认证](../apps/desktop/src/gui/runtime/device_login.cpp)、[常驻身份初始化](../apps/desktop/src/gui/runtime/gui_runtime.cpp)、[控制端会话登录与 Join](../apps/desktop/src/gui/runtime/connection_runtime.cpp)、[通用信令回调](../deps/submodules/minirtc/src/api/minirtc.h)、[SRTP 能力协商](../deps/submodules/minirtc/src/transport/ice_transport.cpp)。
|
||||
|
||||
### 4.2 IP 模式
|
||||
|
||||
@@ -229,11 +235,11 @@ IP 直连采用 **TLS 内提交密码,由被控端本地验证**:
|
||||
|
||||
MiniRTC 仅通过现有可靠数据通道收发这些应用消息,不提供可信设备专用接口,也不参与名单、签名或授权判断。认证证明不包含 DTLS 连接绑定:随机挑战可防止历史证明重放,但不防护攻击者在两条连接之间实时转发挑战和签名的情形。原有传输加密及其兼容规则仍由第 4.1、4.2 节描述。
|
||||
|
||||
可信设备配对私钥保存在缓存目录中的 `identity.json`,仅用于控制端与被控端之间的签名认证。服务器登录使用原有 ID、密码缓存及重连令牌,不使用这个私钥。macOS/Linux 文件权限为 `0600`,Windows 使用当前用户 ACL;身份存储通过 `identity.json.lock` 保护读写,并基于最新内容原子更新。锁文件不含凭据,无需备份。
|
||||
设备私钥保存在缓存目录中的 `identity.json`,服务器登录与可信设备认证复用这一份密钥,通过不同协议域区分签名用途。macOS/Linux 文件权限为 `0600`,Windows 使用当前用户 ACL;身份存储通过 `identity.json.lock` 保护读写,并基于最新内容原子更新。锁文件不含凭据,无需备份。
|
||||
|
||||
可信列表保存在配置目录的 `trusted-devices/devices.json`,新版格式只含可信设备的公钥、名称和 ID,可包含本机。旧格式中的本机私钥会自动迁入 `identity.json`,持久写入成功后才从列表文件移除,因此原有指纹和配对关系保持不变。迁移中断可重试;发现两处密钥冲突时停止加载,不自动覆盖。Windows 无人值守服务使用其独立资料目录中的同一布局。
|
||||
|
||||
只删除普通 ID/密码缓存不会删除配对私钥,但配对私钥不能恢复服务器分配的 ID;后续身份分配沿用原有缓存流程。删除整个缓存目录会丢失配对私钥,重新生成后需要其他设备重新信任本机的新指纹。备份配对身份应保存 `identity.json`,保留允许控制本机的设备名单还应备份可信列表。身份文件损坏或权限不安全时停止加载;已开启的可信设备限制不会因此自动关闭。
|
||||
只删除普通 ID/密码缓存不会删除私钥,但单凭私钥不能找回已分配的 ID,客户端会请求新 ID。保留旧 ID 而丢失私钥后无法登录已绑定的身份,需要恢复原私钥备份,或清理旧 ID 缓存重新登记。复制 ID 缓存及私钥会复制登录身份,不具备硬件绑定;备份时应一起保护这些文件。清空整个缓存目录还会改变配对指纹,需要其他设备重新信任本机。保留允许控制本机的设备名单还应备份可信列表。身份文件损坏或权限不安全时停止加载和设备登录;已开启的可信设备限制不会自动关闭。
|
||||
|
||||
对应代码:[设备签名与名单存储](../apps/desktop/src/gui/runtime/trusted_devices.cpp)、[应用层认证及授权](../apps/desktop/src/gui/runtime/device_trust_runtime.cpp)、[连接状态与认证生命周期](../apps/desktop/src/gui/runtime/peer_event_handler.cpp)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user