diff --git a/apps/android/README.md b/apps/android/README.md index 5f7531a1..7e0d344f 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -2,6 +2,12 @@ 原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。支持 Android 8.0(API 26)及以上的 arm64 手机和平板。桌面、Android、iOS 和 HarmonyOS 共用客户端发布版本,各移动平台构建号独立;可在仓库根目录执行 `python3 scripts/version.py show client` 查看,使用 `set client <版本>` 同步修改,详见[组件版本管理](../../docs/VERSIONING.md)。 +## 可信设备鉴权 + +“设置 → 本机配对信息 → 查看指纹”可查看本机指纹并复制配对信息。将信息粘贴到被控电脑的“管理可信设备 → 添加可信设备”,核对指纹后添加,即可连接开启“仅允许可信设备控制”的电脑;连接密码仍需验证。 + +移动端使用共享 Ed25519 挑战响应协议。私钥通过 Android Keystore / AES-GCM 加密后保存在应用私有持久存储,不写入缓存、日志或配对信息。重启、清理缓存、更换服务器和本机登录 ID 不改变公钥;已有密钥损坏、无法读取或丢失时停止初始化并提示错误,不自动换钥。清除应用数据或卸载后重新安装,需要重新配对。 + ## 当前功能 - 官方或自托管 WSS 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权会关闭连接。已有远程会话优先通过系统画中画保持连接,画中画不可用时由带连接通知的前台服务兜底;首页空闲信令在后台关闭。 diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index 1367f5fc..5941bd36 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -397,6 +397,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen toggle.setOnCheckedChangeListener((button,checked)->{inputs.setVisibility(checked?View.VISIBLE:View.GONE);if(!checked){host="api.crossdesk.cn";port=9099;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();} }); backAction=()->{if(toggle.isChecked()&&!saveServer(address,number))return;hideKeyboard();home("");}; identitySummary=text("",12,false);identitySummary.setTextColor(SECONDARY);identitySummary.setGravity(Gravity.CENTER);identitySummary.setPadding(dp(16),dp(12),dp(16),dp(16));identitySummary.setTextIsSelectable(true);content.addView(identitySummary);renderSignaling(); + LinearLayout trust=section(content,L10n.tr("本机配对信息"),MobileUi.SETTINGS_CARD_RADIUS); + link(trust,L10n.tr("查看指纹"),"",this::showPairingInfo);ui.gap(content,28); LinearLayout privacy=section(content,L10n.tr("隐私"),MobileUi.SETTINGS_CARD_RADIUS);link(privacy,L10n.tr("隐私与授权"),preferences.getBoolean("networkConsent",false)?L10n.tr("已授权"):L10n.tr("未授权"),this::privacyPage);ui.divider(privacy); LinearLayout preview=row();preview.setPadding(dp(16),dp(10),dp(16),dp(10));preview.addView(text(L10n.tr("保存远程画面预览"),16,false),new LinearLayout.LayoutParams(0,-2,1));previewSwitch=new Switch(this);previewSwitch.setContentDescription(L10n.tr("保存远程画面预览"));preview.addView(previewSwitch);privacy.addView(preview); previewSwitch.setOnCheckedChangeListener((button,checked)->{if(updatingPreviewSwitch)return;if(checked){renderPreviews();confirmPreviewSaving();}else previews.setEnabled(false);}); @@ -406,6 +408,21 @@ public final class MainActivity extends Activity implements NativeSession.Listen aboutUpdateLabel=(TextView)aboutLink.getChildAt(1);aboutUpdateLabel.setPadding(0,0,dp(8),0); aboutUpdateDot=updateDot();LinearLayout.LayoutParams dotPosition=new LinearLayout.LayoutParams(dp(8),dp(8));dotPosition.setMarginEnd(dp(8));aboutLink.addView(aboutUpdateDot,2,dotPosition);renderAppUpdates(); } + private void showPairingInfo(){ + NativeSession.pairingInfo(this,localIdentity,info->{ + if(isFinishing()||isDestroyed()||!page.equals("settings"))return; + if(info==null){toast(L10n.tr("安全存储不可用,请解锁设备后重试"));return;} + TextView fingerprint=text(info.optString("fingerprint").replaceAll("(.{4})(?!$)","$1 "),14,false); + fingerprint.setTypeface(android.graphics.Typeface.MONOSPACE);fingerprint.setTextIsSelectable(true);fingerprint.setPadding(dp(24),dp(16),dp(24),dp(16)); + new AlertDialog.Builder(this).setTitle(L10n.tr("本机配对信息")) + .setMessage(L10n.tr("将配对信息添加到被控端的可信设备列表,并核对指纹。")) + .setView(fingerprint).setNegativeButton(L10n.tr("取消"),null) + .setPositiveButton(L10n.tr("复制配对信息"),(dialog,which)->{ + try{((ClipboardManager)getSystemService(CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("CrossDesk",info.getString("pairing")));toast(L10n.tr("配对信息已复制。"));} + catch(Exception error){toast(L10n.tr("无法复制配对信息,请重试。"));} + }).show(); + }); + } private void confirmPreviewSaving(){ previewDialog=new AlertDialog.Builder(this).setTitle(L10n.tr("保存远程画面预览?")) .setMessage(L10n.tr("预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。")) diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java index 8505496e..2bfce7a2 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java @@ -94,7 +94,7 @@ final class NativeSession implements AutoCloseable { File certificates = exportTrust(); File logs = new File(context.getNoBackupFilesDir(), "logs"); if (!logs.isDirectory() && !logs.mkdirs()) throw new IllegalStateException("Log directory unavailable"); - handle = nCreate(this, host, port, identity, logs.getAbsolutePath(), certificates.getAbsolutePath()); + handle = nCreate(this, host, port, identity, logs.getAbsolutePath(), certificates.getAbsolutePath(), trustKey(context)); if (handle == 0) { fail(L10n.tr("原生连接初始化失败")); return; } nFileDirectory(handle,new File(documents.directory,"received").getAbsolutePath()); if (surface != null && surface.isValid()) nSurface(handle, surface); @@ -116,6 +116,33 @@ final class NativeSession implements AutoCloseable { }); } boolean isClosed() { return closed; } + // Called only on RTC, shared by every foreground/background session and UI export. + // This key belongs to the installation, not to a signaling server or login ID. + private static String trustKey(Context context) throws Exception { + SecretStore store = new SecretStore(context); + var preferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE); + String seed = store.get("device-trust:v1"); + if (seed.isEmpty()) { + if (preferences.getBoolean("deviceTrustInitialized", false)) + throw new IllegalStateException("Device signing identity missing"); + seed = nGenerateTrustKey(); + if (seed == null) throw new IllegalStateException("Device signing identity unavailable"); + store.put("device-trust:v1", seed); + } + if (!preferences.edit().putBoolean("deviceTrustInitialized", true).commit()) + throw new IllegalStateException("Device signing identity marker unavailable"); + return seed; + } + static void pairingInfo(Context context, String deviceId, java.util.function.Consumer completed) { + Context app = context.getApplicationContext(); + RTC.execute(() -> { + JSONObject info = null; + try { info = new JSONObject(nTrustInfo(trustKey(app), deviceId.split("@", 2)[0])); } + catch (Exception ignored) { } + JSONObject result = info; + new Handler(Looper.getMainLooper()).post(() -> completed.accept(result)); + }); + } // UI callbacks run on the main thread. A service-owned state can replace // the Activity listener without recreating either native peer. void setListener(Listener value) { listener = java.util.Objects.requireNonNull(value); } @@ -347,7 +374,9 @@ final class NativeSession implements AutoCloseable { static native boolean nValidAppVersion(String version); static native boolean nHasAppUpdate(String version, byte[] releaseJson); static native String nCheckMobileUpdate(String version, byte[] releaseJson); - private static native long nCreate(NativeSession owner, String host, int port, String identity, String logs, String roots); + private static native long nCreate(NativeSession owner, String host, int port, String identity, String logs, String roots, String trustKey); + private static native String nGenerateTrustKey(); + private static native String nTrustInfo(String seed, String deviceId); private static native boolean nConnect(long handle, String identity, String remote, String password); private static native void nReady(long handle,byte[] displayResolutions); private static native void nPointer(long handle, float x, float y, int flag, int wheel); diff --git a/apps/android/native/rtc_bridge.cpp b/apps/android/native/rtc_bridge.cpp index a04968f8..271216cd 100644 --- a/apps/android/native/rtc_bridge.cpp +++ b/apps/android/native/rtc_bridge.cpp @@ -17,6 +17,7 @@ #include #include #include "controller_protocol.h" +#include "../../../libs/controller/include/mobile_device_trust.h" #include "file_transfer_session.h" #include "latest_frame_worker.h" #include "video_picture.h" @@ -54,6 +55,7 @@ struct Session { MiniRtcPeerPtr* controller = nullptr; CallbackContext identity_context{this, false}, controller_context{this, true}; std::string host, log_path, identity_login, controller_login, remote; + std::unique_ptr trust; int port; std::atomic stopping{false}; std::atomic signal_generation{0}; @@ -190,6 +192,11 @@ void Data(const char* data, size_t size, const char*, size_t, if (name == kFileStream) { ctx->owner->files.Receive(data, size); return; } if (name == kFileFeedbackStream) { ctx->owner->files.Ack(data, size); return; } if (name == kControlStream) { + if (auto proof = ctx->owner->trust->ChallengeResponse(std::string_view(data, size), + ctx->owner->controller_login.substr(2), "CrossDesk Android")) { + ctx->owner->Send(kControlStream, *proof); + return; + } RemoteAction action{}; if (action.from_json(std::string(data, size))) { const auto message = action.to_json(); @@ -291,6 +298,26 @@ extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* java_vm, void*) { return JNI_VERSION_1_6; } #define JNI_METHOD(name) Java_cn_crossdesk_mobile_NativeSession_##name +extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nGenerateTrustKey)(JNIEnv* env, jclass) { + try { + auto seed = mobile_controller::DeviceTrustIdentity::GeneratePrivateKey(); + auto result = env->NewStringUTF(seed.c_str()); + mobile_controller::DeviceTrustIdentity::ClearSecret(seed); + return result; + } catch (const std::exception&) { return nullptr; } +} +extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nTrustInfo)( + JNIEnv* env, jclass, jstring key, jstring device_id) { + try { + auto seed = String(env, key); + // Clean the temporary JNI copy even when key validation fails. + struct Clean { std::string& value; ~Clean() { mobile_controller::DeviceTrustIdentity::ClearSecret(value); } } clean{seed}; + mobile_controller::DeviceTrustIdentity trust(seed); + const auto info = json{{"pairing", trust.PairingInfo(String(env, device_id), "CrossDesk Android")}, + {"fingerprint", trust.Fingerprint()}}.dump(); + return env->NewStringUTF(info.c_str()); + } catch (const std::exception&) { return nullptr; } +} extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nValidAppVersion)( JNIEnv* env, jclass, jstring version) { return version && env->GetStringUTFLength(version) < 64 && @@ -321,7 +348,7 @@ extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nCheckMobileUpdate)( } extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nCreate)( JNIEnv* env, jclass, jobject owner, jstring host, jint port, jstring identity, - jstring log_path, jstring certificates) { + jstring log_path, jstring certificates, jstring trust_key) { try { auto s = std::make_unique(); s->owner = env->NewGlobalRef(owner); @@ -332,6 +359,9 @@ extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nCreate)( env->DeleteLocalRef(cls); s->host = String(env, host); s->port = port; s->identity_login = String(env, identity); s->log_path = String(env, log_path); + auto seed = String(env, trust_key); + struct Clean { std::string& value; ~Clean() { mobile_controller::DeviceTrustIdentity::ClearSecret(value); } } clean{seed}; + s->trust = std::make_unique(seed); // The bundle is exported from Android's default TrustManager, never the // build machine's certificate store. MiniRTC on Android explicitly // loads this path: OpenSSL's default loader ignores SSL_CERT_FILE in an @@ -376,7 +406,8 @@ extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv* env, jclass, jlong if (!env->ExceptionCheck()) ParseDisplayResolutionPreferences(bytes, action.preferred_display_resolutions); } - s->Send(kControlStream, action.to_json()); FreeRemoteAction(action); + s->Send(kControlStream, mobile_controller::DeviceTrustIdentity::Advertise(action.to_json())); + FreeRemoteAction(action); } extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)( JNIEnv*, jclass, jlong handle, jfloat x, jfloat y, jint flag, jint wheel) { diff --git a/apps/android/native/xmake.lua b/apps/android/native/xmake.lua index b245d572..699fe2d7 100644 --- a/apps/android/native/xmake.lua +++ b/apps/android/native/xmake.lua @@ -5,12 +5,14 @@ add_rules("mode.release", "mode.debug") set_policy("package.precompiled", false) add_cxflags("-fPIC") includes("../../../deps/submodules/minirtc") +add_requires("openssl3 3.3.2", {system = false, configs = {shared = false}}) target("crossdesk_android") set_kind("shared") add_deps("minirtc") - add_packages("nlohmann_json", "libyuv") - add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp", "../../../libs/wire/src/file_transfer.cpp") + add_packages("nlohmann_json", "libyuv", "openssl3") + add_files("rtc_bridge.cpp", "../../../libs/wire/src/remote_action.cpp", "../../../libs/wire/src/app_version.cpp", "../../../libs/wire/src/file_transfer.cpp", "../../../libs/controller/src/mobile_device_trust.cpp") + add_includedirs("../../../libs/controller/include") add_includedirs("../../../deps/submodules/minirtc/src/api", "../../../libs/wire/include") add_syslinks("android", "log") add_shflags("-Wl,-z,max-page-size=16384", "-Wl,-z,common-page-size=16384", {force = true})