From 8a1e58d62dfd42ad5f999a9c24085dbe84e26e0e Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 13 Nov 2026 11:07:00 +0800 Subject: [PATCH] [feat] add iOS trusted device authentication --- .../Bridge/CrossDeskRTCBridge.h | 1 + .../Bridge/CrossDeskRTCBridge.mm | 77 ++++++++++++++++++- apps/ios/CrossDeskMobile/ContentView.swift | 36 +++++++++ apps/ios/README.md | 6 ++ apps/ios/scripts/build_minirtc_ios.sh | 6 +- xmake.lua | 13 ++++ 6 files changed, 136 insertions(+), 3 deletions(-) diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index 0200862d..f9507e05 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -151,6 +151,7 @@ typedef struct { /// All MiniRTC ownership and calls are serialized internally. Delegate methods /// are delivered on the main queue so SwiftUI never touches callback threads. @interface CrossDeskRTCBridge : NSObject ++ (nullable NSDictionary *)deviceTrustInfoForDeviceID:(NSString *)deviceID; @property(nonatomic, weak, nullable) id delegate; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index 4280b95c..bc757bd2 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -23,9 +23,12 @@ #include #include #include "minirtc.h" +#include "../../../../libs/controller/include/mobile_device_trust.h" namespace { +using crossdesk::mobile_controller::DeviceTrustIdentity; + using crossdesk::ControlType; using crossdesk::FileTransferAck; using crossdesk::KeyFlag; @@ -71,6 +74,7 @@ struct PresenceRequest { }; struct RTCState { + std::unique_ptr trust; MiniRtcPeerPtr *identity_peer = nullptr; MiniRtcPeerPtr *controller_peer = nullptr; CallbackContext identity_context; @@ -211,6 +215,47 @@ OSStatus RemoveIdentityCredential(NSString *account) { return status; } +std::unique_ptr LoadDeviceTrustIdentity() { + // Pairing export and all bridges share one installation-wide Keychain item. + static std::mutex mutex; + std::lock_guard lock(mutex); + NSDictionary *query = @{ + (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrService: @"cn.crossdesk.mobile.device-trust", + (__bridge id)kSecAttrAccount: @"v1", + (__bridge id)kSecAttrSynchronizable: @NO, + }; + for (int attempt = 0; attempt < 2; ++attempt) { + NSMutableDictionary *read = [query mutableCopy]; + read[(__bridge id)kSecReturnData] = @YES; + read[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne; + CFTypeRef result = nullptr; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)read, &result); + id object = CFBridgingRelease(result); + std::string seed; + struct Clean { std::string& value; ~Clean() { DeviceTrustIdentity::ClearSecret(value); } } clean{seed}; + if (status == errSecSuccess && [object isKindOfClass:NSData.class]) { + NSData *data = object; + seed.assign(static_cast(data.bytes), data.length); + } else if (status == errSecItemNotFound && + ![NSUserDefaults.standardUserDefaults boolForKey:@"CrossDeskDeviceTrustInitialized.v1"]) { + seed = DeviceTrustIdentity::GeneratePrivateKey(); + NSMutableDictionary *item = [query mutableCopy]; + item[(__bridge id)kSecValueData] = [NSData dataWithBytes:seed.data() length:seed.size()]; + item[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; + status = SecItemAdd((__bridge CFDictionaryRef)item, nullptr); + if (status == errSecDuplicateItem) continue; + if (status != errSecSuccess) throw std::runtime_error("Device signing identity save failed"); + } else { + throw std::runtime_error("Device signing identity unavailable"); + } + auto identity = std::make_unique(seed); + [NSUserDefaults.standardUserDefaults setBool:YES forKey:@"CrossDeskDeviceTrustInitialized.v1"]; + return identity; + } + throw std::runtime_error("Device signing identity unavailable"); +} + void CopyCString(char *destination, size_t capacity, const std::string &source) { if (!destination || capacity == 0) return; @@ -499,6 +544,15 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, std::atomic _networkingGeneration; } ++ (nullable NSDictionary *)deviceTrustInfoForDeviceID:(NSString *)deviceID { + try { + auto trust = LoadDeviceTrustIdentity(); + auto pairing = trust->PairingInfo(BaseIdentity(deviceID.UTF8String ?: ""), "CrossDesk iOS"); + return @{@"pairing": [NSString stringWithUTF8String:pairing.c_str()], + @"fingerprint": [NSString stringWithUTF8String:trust->Fingerprint().c_str()]}; + } catch (const std::exception&) { return nil; } +} + + (nullable NSString *)availableUpdateForAppVersion:(NSString *)appVersion releaseJSON:(NSData *)releaseJSON { const auto latest = crossdesk::ParseVersionInfoJSON(std::string_view( @@ -610,6 +664,15 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, self->_state->identity_with_password.clear(); self->_state->identity_base.clear(); + try { + self->_state->trust = LoadDeviceTrustIdentity(); + } catch (const std::exception&) { + [self reportIdentityStorageStatus:errSecDecode]; + [self handleSignalState:CrossDeskSignalStateCredentialUnavailable + role:PeerRole::Identity generation:self->_identityGeneration.load()]; + return; + } + NSString *cached = nil; OSStatus load_status = LoadIdentityCredential( IdentityCredentialAccount(trimmed, signalPort), &cached); @@ -1484,7 +1547,7 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, action.i.top = nullptr; action.i.right = nullptr; action.i.bottom = nullptr; - const std::string host_info = action.to_json(); + const std::string host_info = DeviceTrustIdentity::Advertise(action.to_json()); MiniRtcSendReliableDataFrame(self->_state->controller_peer, host_info.data(), host_info.size(), kControlStream); const std::string stream = crossdesk::MakeDisplayStreamId( @@ -1754,6 +1817,18 @@ MiniRtcParams MakeParams(const RTCState &state, const std::string &user_id, if (!data || size == 0 || !sourceID) return; const std::string source(sourceID, sourceIDSize); if (source == kControlStream) { + if (DeviceTrustIdentity::IsChallenge(std::string_view(data, size))) { + const std::string challenge(data, size); + dispatch_async(_rtcQueue, ^{ + if (![self isControllerGenerationActive:generation] || !self->_state->controller_peer || + !self->_state->trust) return; + auto proof = self->_state->trust->ChallengeResponse(challenge, + self->_state->identity_base, "CrossDesk iOS"); + if (proof && !proof->empty()) + MiniRtcSendReliableDataFrame(self->_state->controller_peer, proof->data(), proof->size(), kControlStream); + }); + return; + } RemoteAction action{}; if (!action.from_json(std::string(data, size))) return; if (action.type == ControlType::video_codec_status) { diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index 5318f15d..8bb2242a 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -804,6 +804,37 @@ private struct PasswordPromptView: View { } } +private struct DevicePairingView: View { + let deviceID: String + @State private var info: [String: String]? + @State private var copied = false + + var body: some View { + Form { + Section { + Text(NSLocalizedString("将配对信息添加到被控端的可信设备列表,并核对指纹。", comment: "")) + if let info, let fingerprint = info["fingerprint"], let pairing = info["pairing"] { + Text(fingerprint.enumerated().map { index, character in + (index > 0 && index % 4 == 0 ? " " : "") + String(character) + }.joined()).font(.footnote.monospaced()).textSelection(.enabled) + Button(NSLocalizedString("复制配对信息", comment: "")) { + UIPasteboard.general.setItems([["public.utf8-plain-text": pairing]], options: [.localOnly: true]) + copied = true + } + if copied { Text(NSLocalizedString("配对信息已复制。", comment: "")).foregroundStyle(.secondary) } + } else { + Text(NSLocalizedString("安全存储不可用,请解锁设备后重试", comment: "")).foregroundStyle(.orange) + Button(NSLocalizedString("重试", comment: "")) { load() } + } + } + } + .navigationTitle(NSLocalizedString("本机配对信息", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .task { load() } + } + private func load() { info = CrossDeskRTCBridge.deviceTrustInfo(forDeviceID: deviceID); copied = false } +} + private struct ServerSettingsView: View { @EnvironmentObject private var appUpdates: AppUpdateChecker @ObservedObject var session: RemoteSessionModel @@ -901,6 +932,11 @@ private struct ServerSettingsView: View { .textSelection(.enabled) } PrivacySettingsSection(session: session) + Section { + NavigationLink(NSLocalizedString("本机配对信息", comment: "")) { + DevicePairingView(deviceID: session.localIdentity) + } + } Section { NavigationLink { ThirdPartyLicensesView(session: session) diff --git a/apps/ios/README.md b/apps/ios/README.md index d3e737aa..5cfe5b07 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -8,6 +8,12 @@ UPnP discovery and router port mapping are disabled in iOS builds, matching Android. Local connections, ICE/STUN hole punching, and TURN UDP/TCP relay remain available. The native archive excludes GUPnP and its HTTP/XML dependencies. +## Trusted device authentication + +Open **Settings → This device’s pairing info** to view the fingerprint and copy the public pairing information. Import it under **Manage trusted devices → Add a trusted device** on the controlled desktop and compare fingerprints. Once trusted, this iPhone or iPad can connect with **Only allow trusted devices** enabled. The connection password is still required. + +The controller responds to the shared Ed25519 challenge protocol. Its signing seed is stored in a separate, non-synchronizing, device-only Keychain item, never in caches, logs or the exported pairing information. Restarting, clearing caches and changing signaling servers or login IDs preserve the same key. An unavailable, corrupt or unexpectedly missing identity fails initialization instead of silently generating a replacement. A new installation on another device needs its own pairing. + ## Remote privacy screen The floating menu includes a **Privacy screen** button that toggles the host's diff --git a/apps/ios/scripts/build_minirtc_ios.sh b/apps/ios/scripts/build_minirtc_ios.sh index 17cca48f..0ea5e3f9 100755 --- a/apps/ios/scripts/build_minirtc_ios.sh +++ b/apps/ios/scripts/build_minirtc_ios.sh @@ -155,6 +155,7 @@ WIRE_BUILD_DIR="${IOS_DIR}/.xmake/wire-build${CACHE_SUFFIX}" WIRE_WORK_DIR="${IOS_DIR}/.xmake/wire-work${CACHE_SUFFIX}" WIRE_MANIFEST_FILE="${WIRE_WORK_DIR}/xmake.lua" WIRE_LIBRARY="${WIRE_BUILD_DIR}/iphoneos/arm64/${MODE}/libcrossdesk_wire.a" +TRUST_LIBRARY="${WIRE_BUILD_DIR}/iphoneos/arm64/${MODE}/libcrossdesk_mobile_trust.a" mkdir -p "${OUTPUT_DIR}" "${WIRE_WORK_DIR}" if ! cmp -s "${REPO_DIR}/xmake.lua" "${WIRE_MANIFEST_FILE}"; then @@ -228,6 +229,7 @@ fi cd "${WIRE_WORK_DIR}" print "Building CrossDesk wire for ${SDK_NAME} (${MODE})" run_xmake b -P "${WIRE_WORK_DIR}" "${XMAKE_LOG_FLAGS[@]}" crossdesk_wire + run_xmake b -P "${WIRE_WORK_DIR}" "${XMAKE_LOG_FLAGS[@]}" crossdesk_mobile_trust ) if [[ ! -f "${WIRE_LIBRARY}" ]]; then @@ -304,7 +306,7 @@ verify_native_symbols() { # timestamp tick cannot leave a stale merged library. Preserve the output mtime # on no-op builds to avoid relinking the Xcode app. shasum -a 256 "${SCRIPT_DIR}/build_minirtc_ios.sh" "${MINIRTC_LIBRARY}" \ - "${DEPENDENCY_ARCHIVES[@]}" "${WIRE_LIBRARY}" > "${TEMP_MANIFEST}" + "${DEPENDENCY_ARCHIVES[@]}" "${WIRE_LIBRARY}" "${TRUST_LIBRARY}" > "${TEMP_MANIFEST}" if [[ -f "${OUTPUT_LIBRARY}" ]] && cmp -s "${TEMP_MANIFEST}" "${MERGE_MANIFEST}"; then verify_native_symbols "${OUTPUT_LIBRARY}" print "Up to date: ${OUTPUT_LIBRARY}" @@ -315,7 +317,7 @@ fi # Suppress only that diagnostic; retain the members and other warnings. /usr/bin/libtool -static -no_warning_for_no_symbols -o "${TEMP_LIBRARY}" \ "${MINIRTC_LIBRARY}" "${MERGE_ARCHIVES[@]}" \ - "${WIRE_LIBRARY}" + "${WIRE_LIBRARY}" "${TRUST_LIBRARY}" verify_native_symbols "${TEMP_LIBRARY}" mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}" mv -f "${TEMP_MANIFEST}" "${MERGE_MANIFEST}" diff --git a/xmake.lua b/xmake.lua index 566d1165..9dc16bf4 100644 --- a/xmake.lua +++ b/xmake.lua @@ -59,6 +59,19 @@ target("crossdesk_wire") add_includedirs(path.join(wire_dir, "include"), {public = true}) target_end() +-- iOS packages this controller helper alongside the wire and MiniRTC archives. +-- Keep crypto headers out of the Objective-C++ bridge's public include paths. +if is_plat("iphoneos") then + includes(path.join(source_dir, "deps/submodules/minirtc/thirdparty/openssl")) + add_requires("openssl3 3.3.2", {system = false, configs = {shared = false}}) + target("crossdesk_mobile_trust") + set_kind("static") + add_packages("nlohmann_json", "openssl3") + add_includedirs(path.join(source_dir, "libs/controller/include")) + add_files(path.join(source_dir, "libs/controller/src/mobile_device_trust.cpp")) + target_end() +end + target("crossdesk_file_browser") set_kind("static") set_languages("c++20")