[feat] add iOS trusted device authentication

This commit is contained in:
dijunkun committed 2026-11-13 11:07:00 +08:00
1 parent 94a5f57bea
commit 8a1e58d62d
6 files changed
+136 -3

No files matched your search

@@ -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<NSString *, NSString *> *)deviceTrustInfoForDeviceID:(NSString *)deviceID;
@property(nonatomic, weak, nullable) id<CrossDeskRTCBridgeDelegate> delegate;
@@ -23,9 +23,12 @@
#include <remote_action.h>
#include <stream_names.h>
#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<DeviceTrustIdentity> 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<DeviceTrustIdentity> LoadDeviceTrustIdentity() {
// Pairing export and all bridges share one installation-wide Keychain item.
static std::mutex mutex;
std::lock_guard<std::mutex> 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<const char *>(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<DeviceTrustIdentity>(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<uint64_t> _networkingGeneration;
}
+ (nullable NSDictionary<NSString *, NSString *> *)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) {
@@ -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)
+6
View File
@@ -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
+4 -2
View File
@@ -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}"