[feat] add Android trusted device authentication

This commit is contained in:
dijunkun committed 2026-11-13 11:07:00 +08:00
1 parent e6e72ed511
commit 94a5f57bea
5 files changed
+91 -6

No files matched your search

+6
View File
@@ -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 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权会关闭连接。已有远程会话优先通过系统画中画保持连接,画中画不可用时由带连接通知的前台服务兜底;首页空闲信令在后台关闭。
@@ -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("预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。"))
@@ -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<JSONObject> 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);
+33 -2
View File
@@ -17,6 +17,7 @@
#include <stdexcept>
#include <vector>
#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<mobile_controller::DeviceTrustIdentity> trust;
int port;
std::atomic<bool> stopping{false};
std::atomic<uint64_t> 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<Session>();
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<mobile_controller::DeviceTrustIdentity>(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) {
+4 -2
View File
@@ -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})