diff --git a/apps/desktop/src/gui/runtime/device_trust_runtime.cpp b/apps/desktop/src/gui/runtime/device_trust_runtime.cpp index bf695baf..605a9619 100644 --- a/apps/desktop/src/gui/runtime/device_trust_runtime.cpp +++ b/apps/desktop/src/gui/runtime/device_trust_runtime.cpp @@ -45,9 +45,16 @@ bool GuiRuntime::BeginControllerTrust(const std::string& id, PeerEventHandler* e 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; } + if (!trusted_devices_) { + state.invalid = true; + state.failure_reason = "identity_unavailable"; + } else { + state.nonce = TrustedDevices::Nonce(); + } + } catch (...) { + state.invalid = true; + state.failure_reason = "challenge_generation_failed"; + } controller_trust_[id] = std::move(state); return !required; } @@ -112,8 +119,12 @@ bool GuiRuntime::HandleDeviceTrustMessage(const std::string& id, PeerEventHandle 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; + TrustedDevices::ProofFailure failure; + state.verified = TrustedDevices::Verify(message, state.nonce, &failure); + if (!state.verified) { + state.invalid = true; + state.failure_reason = TrustedDevices::ProofFailureReason(failure); + } } } catch (const std::exception&) { // Untrusted protocol input never escapes the transport callback. @@ -128,6 +139,7 @@ void GuiRuntime::TickControllerTrust() { std::string nonce; int kind; std::string host_info; + const char* rejection_reason = nullptr; }; std::vector actions; const auto now = std::chrono::steady_clock::now(); @@ -140,9 +152,12 @@ void GuiRuntime::TickControllerTrust() { const bool trusted = state.IsTrusted(trusted_devices_.get()); if (required && (state.invalid || (state.verified && !trusted) || (!state.verified && now >= state.deadline))) { + const char* reason = state.invalid + ? (state.failure_reason ? state.failure_reason : "invalid_proof") + : (state.verified ? "public_key_not_trusted" : "proof_timeout"); state.invalid = true; state.closing = true; - actions.push_back({id, state.events, {}, -1}); + actions.push_back({id, state.events, {}, -1, {}, reason}); } else if (state.pending && (!required || trusted)) { state.pending = false; actions.push_back({id, state.events, {}, 1, std::exchange(state.early_host_info, {})}); @@ -160,7 +175,8 @@ void GuiRuntime::TickControllerTrust() { auto callback = events->EnterCallback(); if (!callback) continue; if (action.kind < 0) { - LOG_WARN("Reject controller: trusted device verification required"); + LOG_WARN("Reject controller: trusted device verification required; reason={}", + action.rejection_reason); transfers_.Cancel(events.get(), action.id); remote_input_.Disconnect(action.id); CloseServerController(action.id); diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index dc5b5744..90e78a94 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -140,6 +140,7 @@ class GuiRuntime : protected gui_detail::GuiState { std::string nonce; std::optional verified; std::string early_host_info; + const char* failure_reason = nullptr; std::chrono::steady_clock::time_point deadline{}, next_challenge{}; bool pending = false, invalid = false, closing = false, capable = false; bool IsTrusted(const TrustedDevices* devices) const { diff --git a/apps/desktop/src/gui/runtime/trusted_devices.cpp b/apps/desktop/src/gui/runtime/trusted_devices.cpp index 12f9b27e..113b620a 100644 --- a/apps/desktop/src/gui/runtime/trusted_devices.cpp +++ b/apps/desktop/src/gui/runtime/trusted_devices.cpp @@ -202,12 +202,18 @@ std::string TrustedDevices::Sign(std::string_view text) const { } std::optional TrustedDevices::Verify( - const nlohmann::json& proof, const std::string& nonce) { + const nlohmann::json& proof, const std::string& nonce, ProofFailure* failure) { + if (failure) *failure = ProofFailure::None; + const auto reject = [failure](ProofFailure reason) -> std::optional { + if (failure) *failure = reason; + return std::nullopt; + }; try { - if (proof.at("version") != 1 || proof.at("nonce") != nonce || !IsHex(nonce, 32)) - return std::nullopt; + if (proof.at("version") != 1) return reject(ProofFailure::UnsupportedVersion); + if (!IsHex(nonce, 32)) return reject(ProofFailure::Malformed); + if (proof.at("nonce") != nonce) return reject(ProofFailure::NonceMismatch); Device device{proof.at("public_key"), proof.at("name"), proof.at("device_id")}; - if (!Label(device.name) || !Label(device.device_id)) return std::nullopt; + if (!Label(device.name) || !Label(device.device_id)) return reject(ProofFailure::Malformed); const auto public_key = Unhex(device.public_key, 32); const auto signature = Unhex(proof.at("signature"), 64); std::unique_ptr key( @@ -215,12 +221,28 @@ std::optional TrustedDevices::Verify( 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; + if (!key || !context || + EVP_DigestVerifyInit(context.get(), nullptr, nullptr, nullptr, key.get()) != 1) + return reject(ProofFailure::CryptoUnavailable); + const int verified = EVP_DigestVerify(context.get(), signature.data(), signature.size(), + reinterpret_cast(text.data()), text.size()); + if (verified == 1) return device; + return reject(verified == 0 ? ProofFailure::InvalidSignature : ProofFailure::CryptoUnavailable); + } catch (const std::exception&) { + return reject(ProofFailure::Malformed); + } +} + +const char* TrustedDevices::ProofFailureReason(ProofFailure failure) { + switch (failure) { + case ProofFailure::None: return "none"; + case ProofFailure::Malformed: return "malformed_proof"; + case ProofFailure::UnsupportedVersion: return "unsupported_proof_version"; + case ProofFailure::NonceMismatch: return "nonce_mismatch"; + case ProofFailure::InvalidSignature: return "invalid_signature"; + case ProofFailure::CryptoUnavailable: return "crypto_unavailable"; + } + return "invalid_proof"; } bool TrustedDevices::Contains(const std::string& public_key) const { diff --git a/apps/desktop/src/gui/runtime/trusted_devices.h b/apps/desktop/src/gui/runtime/trusted_devices.h index cee5e7bf..71706560 100644 --- a/apps/desktop/src/gui/runtime/trusted_devices.h +++ b/apps/desktop/src/gui/runtime/trusted_devices.h @@ -27,6 +27,14 @@ class TrustedDevices { std::string public_key, name, device_id; bool operator==(const Device&) const = default; }; + enum class ProofFailure { + None, + Malformed, + UnsupportedVersion, + NonceMismatch, + InvalidSignature, + CryptoUnavailable + }; TrustedDevices(const std::filesystem::path& directory, const std::filesystem::path& cache, @@ -40,7 +48,9 @@ class TrustedDevices { 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); + const std::string& nonce, ProofFailure* failure = nullptr); + // Stable diagnostic codes contain no peer-supplied data or key material. + static const char* ProofFailureReason(ProofFailure failure); 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);