diff --git a/docs/remote-input-certificates.md b/docs/remote-input-certificates.md index 229019bfa..0631c3dbd 100644 --- a/docs/remote-input-certificates.md +++ b/docs/remote-input-certificates.md @@ -5,42 +5,65 @@ private certificate authority (CA) on each computer and a separate server certificate covering that computer's LAN addresses. The phone installs the public CA certificate. The CA private key stays in the computer's user data. -## iPhone and iPad - -1. Enable remote input on the computer. Use the address shown in settings, on - the same network as the computer. Settings also has a **Copy iPhone - certificate link** button for each address. -2. Open the address in Safari. On the initial certificate warning, check the - address against the computer, then use **Show Details → Visit This Website** - to reach your own computer's setup page. This exception is only a bootstrap - step, not the persistent trust setup. -3. Expand **First-time setup: trust this computer** and choose **iPhone: - download profile**. Alternatively, open the copied `/cert.mobileconfig` link - directly in Safari. -4. Install the downloaded profile in **Settings → General → VPN & Device - Management**. -5. In **Settings → General → About → Certificate Trust Settings**, enable full - trust for **OpenLess Remote Input CA**. Return to Safari and reload the page. -6. Enter the pairing code and allow microphone access when Safari asks. - -Installing a profile and enabling full SSL trust are separate steps. Apple -requires the latter for profiles downloaded from a website; a desktop app -cannot silently approve it on a personal iPhone. See -[Apple's certificate trust instructions](https://support.apple.com/en-gb/102390). -This setup removes certificate warnings after trust is established; browser -microphone permissions and the pairing code remain separate controls. - -Only install a CA from your own computer. A CA can issue certificates, so its -private key is sensitive. Remove the OpenLess profile from the phone when you -no longer use it. The certificate fingerprint in each profile identifier keeps -profiles for different computers from replacing one another. - -## Android - -Download `/cert.cer` using the **Android: download CA** link. Install it through -the system's **Install a certificate → CA certificate** settings, then return -to the browser. Menu names and browser support for user-installed CAs vary by -device. The download contains only the public root certificate. +## 首次信任前的一次性核验 + +根据 [#1037 的审核建议](https://github.com/Open-Less/openless/pull/1037#discussion_r3964815714), +在信任根证书前,通过电脑本地设置与手机系统证书详情核对身份。 + +电脑设置中的 **本机根证书 SHA-256** 来自正在运行的监听器所用的公开根证书, +经本地接口传给界面;Linux 原生界面也显示同一来源的完整指纹。 +指纹共 64 个十六进制字符,可忽略空格、冒号和大小写,但不能只核对开头几位。 +对比对象必须是将要信任的根证书,不能使用会随 IP 变化而重新签发的服务器证书。 + +手机端必须从**系统证书详情**取得实际证书的 SHA-256。 +网页、描述文件名称、标识和描述文字都可以被替换,不能作为校验依据。 +描述文件名称末尾的短指纹仅用于区分电脑,不代表已经验证身份。 +本功能提供人工核验依据,不会自动确认手机是否正确核对,也不会代替系统开启信任。 + +### iPhone 和 iPad + +1. 在电脑启用远程输入,保留本地设置中的完整 SHA-256。指纹不可用时停止安装。 +2. 在可信网络中,用 Safari 打开电脑显示的地址或复制的证书链接。 + 首次 TLS 警告说明身份尚未验证;即使地址与电脑相同,也不能据此认定证书可信。 + 只有准备执行下面的独立核验时,才继续下载描述文件;否则使用已有的可信文件传输渠道。 +3. 在“设置 → 通用 → VPN 与设备管理”打开下载的描述文件。 + 先检查它**只包含一张根证书**;若有额外证书、VPN 或设备管理配置,不要安装。 +4. 在“更多详细信息”中打开根证书,查找系统显示的 SHA-256, + 与电脑本地设置中的全部 64 个字符逐一核对。 + 若当前 iOS 只能在安装后显示完整详情,安装前仍需确认只有一张根证书, + 且安装后先保持“完全信任”关闭,核对完毕再开启。 +5. 若指纹不一致、看不全或找不到 SHA-256,停止操作,删除下载的描述文件; + 已安装的则移除。不要用名称、网页上的值或配对码代替核验。 +6. 只有全部一致,才到“通用 → 关于本机 → 证书信任设置”为这张根证书开启完全信任。 + 返回 Safari 刷新,输入配对码并允许麦克风访问。 + +安装描述文件和开启完全信任是两个步骤,见[苹果说明](https://support.apple.com/en-us/102390)。 +不同 iOS 版本的菜单和完整指纹入口需要真机确认;无法独立查看指纹的设备不能声称通过了本流程。 +根证书可签发其他证书,私钥应始终保留在自己的电脑上。不再使用远程输入时请移除手机上的根证书。 + +### Android + +通过“安卓:下载 CA 证书”取得 `/cert.cer`,在系统证书预览中核对完整 SHA-256, +一致后再安装。部分设备安装 CA 即代表信任,因此必须在安装前完成核验。 +若系统无法在信任前显示完整指纹,请停止从网页安装,改用已有的可信文件传输渠道。 +菜单名称和用户 CA 支持情况因设备而异。 + +### 真机验收与截图 + +自动化测试可验证指纹来源、替换证书时的差异及生命周期,不能代替以下真机操作: + +| 检查 | 需要记录的结果 | +| --- | --- | +| 首次安装 | 电脑完整指纹、手机系统完整指纹一致;描述文件只有一张根证书 | +| 信任与录音 | 核对后开启完全信任,录音能正常传到电脑 | +| 重启 | 电脑重启后指纹不变,手机无需重新安装证书,仍可录音 | +| IP 变化 | 使用新地址重新连接后指纹不变,仍可录音 | +| 替换证书 | 在独立测试环境用另一台电脑生成的同名证书或替换描述文件,系统指纹不同,用户能按引导停止安装/信任 | + +记录设备型号、系统版本、测试提交号,以及每项通过或失败。 +截图至少包含电脑指纹、手机系统指纹和描述文件内容;录音、重启和 IP 变化可以用简短录屏或文字结果补充。 +替换测试只核对差异,不要开启对测试证书的完全信任;结束后移除测试描述文件。 +Linux 原生界面的显示与录音也需要在 Linux 上实际验收。 ## What OpenLess automates diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 1aefdd1e8..9a34ba8f3 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -994,6 +994,9 @@ pub struct RemoteInputStatus { pub port: u16, pub urls: Vec, pub urls_stale: bool, + /// 由宿主提供,取自正在运行的监听器所使用的公开根证书。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_fingerprint_sha256: Option, pub locale: String, pub connection_count: usize, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1024,6 +1027,7 @@ pub struct RemoteInputServerBinding { pub port: u16, pub urls: Vec, pub urls_stale: bool, + pub ca_fingerprint_sha256: Option, } /// Native transport and shared-dictation bridge. TLS, sockets, H5 assets and @@ -2026,6 +2030,7 @@ mod tests { port: 18989, urls: vec!["https://192.168.1.2:18989".into()], urls_stale: false, + ca_fingerprint_sha256: None, locale: "zh-CN".into(), connection_count: 1, active_session_id: Some(SessionId::new()), diff --git a/openless-all/app/crates/openless-core/src/remote_input_service.rs b/openless-all/app/crates/openless-core/src/remote_input_service.rs index b7e7257fc..9992f628e 100644 --- a/openless-all/app/crates/openless-core/src/remote_input_service.rs +++ b/openless-all/app/crates/openless-core/src/remote_input_service.rs @@ -118,6 +118,7 @@ struct RemoteInputState { port: u16, urls: Vec, urls_stale: bool, + ca_fingerprint_sha256: Option, locale: String, pairing_pin: Option, connections: HashMap, @@ -165,6 +166,7 @@ impl RemoteInputService { port, urls: Vec::new(), urls_stale: false, + ca_fingerprint_sha256: None, locale, pairing_pin: None, connections: HashMap::new(), @@ -221,6 +223,7 @@ impl RemoteInputService { state.starting = false; state.urls.clear(); state.urls_stale = false; + state.ca_fingerprint_sha256 = None; sessions }; let mut first_error = None; @@ -247,6 +250,7 @@ impl RemoteInputService { state.running = false; state.urls.clear(); state.urls_stale = false; + state.ca_fingerprint_sha256 = None; } self.publish_status(); match self @@ -261,6 +265,7 @@ impl RemoteInputService { state.port = binding.port; state.urls = binding.urls; state.urls_stale = binding.urls_stale; + state.ca_fingerprint_sha256 = binding.ca_fingerprint_sha256; drop(state); self.publish_status(); Ok(()) @@ -272,6 +277,7 @@ impl RemoteInputService { state.running = false; state.urls.clear(); state.urls_stale = false; + state.ca_fingerprint_sha256 = None; } let public = public_remote_error(&error); self.event_publisher().publish( @@ -600,6 +606,7 @@ impl RemoteInputApi for RemoteInputService { port: state.port, urls: state.urls.clone(), urls_stale: state.urls_stale, + ca_fingerprint_sha256: state.ca_fingerprint_sha256.clone(), locale: state.locale.clone(), connection_count: state.connections.len(), active_session_id: state diff --git a/openless-all/app/crates/openless-core/src/testing.rs b/openless-all/app/crates/openless-core/src/testing.rs index 2ec695b79..8820b9524 100644 --- a/openless-all/app/crates/openless-core/src/testing.rs +++ b/openless-all/app/crates/openless-core/src/testing.rs @@ -188,6 +188,7 @@ impl RemoteInputRuntimeAdapter for RecordingRemoteInputRuntime { port: config.port, urls: vec![format!("https://127.0.0.1:{}", config.port)], urls_stale: false, + ca_fingerprint_sha256: None, }) }) } diff --git a/openless-all/app/crates/openless-core/tests/remote_input_contract.rs b/openless-all/app/crates/openless-core/tests/remote_input_contract.rs index 104ebb5d1..8d8e2d532 100644 --- a/openless-all/app/crates/openless-core/tests/remote_input_contract.rs +++ b/openless-all/app/crates/openless-core/tests/remote_input_contract.rs @@ -12,6 +12,7 @@ use openless_core::{ #[derive(Default)] struct FixtureRemoteRuntime { persisted_pin: Mutex>, + ca_fingerprint_sha256: Mutex>, persist_count: AtomicUsize, reject_persist: AtomicBool, start_count: AtomicUsize, @@ -72,6 +73,7 @@ impl RemoteInputRuntimeAdapter for FixtureRemoteRuntime { ) -> BoxFuture<'static, Result> { self.start_count.fetch_add(1, Ordering::AcqRel); let fail = self.fail_start.load(Ordering::Acquire); + let ca_fingerprint_sha256 = self.ca_fingerprint_sha256.lock().unwrap().clone(); Box::pin(async move { if fail { return Err(BackendError::new(BackendErrorCode::Platform, "port-in-use")); @@ -80,6 +82,7 @@ impl RemoteInputRuntimeAdapter for FixtureRemoteRuntime { port: config.port, urls: vec![format!("https://192.168.1.2:{}", config.port)], urls_stale: false, + ca_fingerprint_sha256, }) }) } @@ -280,6 +283,79 @@ fn contract_2_audio_frames_reject_invalid_headers_and_pcm() { ); } +#[tokio::test] +async fn ca_fingerprint_tracks_the_running_listener_and_clears_on_stop_or_failure() { + let runtime = Arc::new(FixtureRemoteRuntime::default()); + let first = "ab".repeat(32); + let replacement = "cd".repeat(32); + *runtime.ca_fingerprint_sha256.lock().unwrap() = Some(first.clone()); + let (backend, data_dir) = backend(Arc::clone(&runtime)); + let remote = &backend.services().remote_input; + assert_eq!(remote.status().unwrap().ca_fingerprint_sha256, None); + + remote + .configure(RemoteInputConfig { + enabled: true, + port: 8443, + }) + .await + .unwrap(); + assert_eq!( + remote.status().unwrap().ca_fingerprint_sha256, + Some(first.clone()) + ); + assert_eq!( + serde_json::to_value(remote.status().unwrap()).unwrap()["caFingerprintSha256"], + first + ); + + remote + .configure(RemoteInputConfig { + enabled: false, + port: 8443, + }) + .await + .unwrap(); + assert_eq!(remote.status().unwrap().ca_fingerprint_sha256, None); + assert!(serde_json::to_value(remote.status().unwrap()) + .unwrap() + .get("caFingerprintSha256") + .is_none()); + + // 启动失败时不能继续展示上一次监听器的指纹。 + *runtime.ca_fingerprint_sha256.lock().unwrap() = Some(replacement.clone()); + runtime.fail_start.store(true, Ordering::Release); + assert!(remote + .configure(RemoteInputConfig { + enabled: true, + port: 9443, + }) + .await + .is_err()); + assert_eq!(remote.status().unwrap().ca_fingerprint_sha256, None); + + runtime.fail_start.store(false, Ordering::Release); + remote + .configure(RemoteInputConfig { + enabled: true, + port: 9443, + }) + .await + .unwrap(); + assert_eq!( + remote.status().unwrap().ca_fingerprint_sha256, + Some(replacement) + ); + remote + .configure(RemoteInputConfig { + enabled: false, + port: 9443, + }) + .await + .unwrap(); + let _ = std::fs::remove_dir_all(data_dir); +} + #[tokio::test] async fn pairing_pin_is_explicit_persisted_and_absent_from_public_surfaces() { let runtime = Arc::new(FixtureRemoteRuntime::default()); diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 19942ce73..12df30456 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1454,6 +1454,27 @@ mod linux_app { "远程输入:已停止" }); if remote.enabled { + if remote.running { + ui.label("本机根证书 SHA-256"); + if let Some(fingerprint) = remote.ca_fingerprint_sha256.as_ref().filter(|value| { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + let display = fingerprint + .as_bytes() + .chunks(2) + .map(|pair| std::str::from_utf8(pair).unwrap().to_ascii_uppercase()) + .collect::>() + .join(" "); + ui.add(egui::Label::new(egui::RichText::new(&display).monospace()).wrap()); + if ui.button("复制完整指纹").clicked() { + ui.ctx().copy_text(display); + } + } else { + ui.label("完整指纹不可用。请勿安装或信任下载的证书。"); + } + ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。"); + ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。"); + } ui.monospace(format!("PIN:{pin}")); for url in &remote.urls { ui.monospace(url); diff --git a/openless-all/app/linux-egui/src/remote_input.rs b/openless-all/app/linux-egui/src/remote_input.rs index 070579d92..5417b945d 100644 --- a/openless-all/app/linux-egui/src/remote_input.rs +++ b/openless-all/app/linux-egui/src/remote_input.rs @@ -108,6 +108,7 @@ impl RemoteInputRuntimeAdapter for LinuxRemoteInputRuntime { port: handle.bound_port, urls: access_urls(handle.bound_port), urls_stale: false, + ca_fingerprint_sha256: Some(handle.ca_fingerprint_sha256.clone()), }; *server.lock().await = Some(handle); Ok(binding) @@ -217,6 +218,7 @@ struct LinuxRemoteServerHandle { connections_shutdown: tokio::sync::watch::Sender, join: tokio::task::JoinHandle<()>, bound_port: u16, + ca_fingerprint_sha256: String, } #[cfg(target_os = "linux")] @@ -233,6 +235,7 @@ impl LinuxRemoteServerHandle { #[cfg(not(target_os = "linux"))] struct LinuxRemoteServerHandle { bound_port: u16, + ca_fingerprint_sha256: String, } #[cfg(not(target_os = "linux"))] @@ -401,6 +404,7 @@ async fn start_server( sans.extend(local_lan_ipv4s()); let identity = tls_identity::load_or_create(&data_dir.join("remote-input"), &sans) .map_err(remote_platform_error)?; + let ca_fingerprint_sha256 = identity.ca_fingerprint_sha256; let cert_der = identity.trust_cert; let acceptor = TlsAcceptor::from(identity.server_config); let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))) @@ -443,6 +447,7 @@ async fn start_server( connections_shutdown, join, bound_port, + ca_fingerprint_sha256, }) } diff --git a/openless-all/app/src-tauri/src/commands/remote_input.rs b/openless-all/app/src-tauri/src/commands/remote_input.rs index 6837b0e5b..43843fbbb 100644 --- a/openless-all/app/src-tauri/src/commands/remote_input.rs +++ b/openless-all/app/src-tauri/src/commands/remote_input.rs @@ -36,6 +36,7 @@ fn map_remote_input_status( pin: pin.into_exposed(), urls: status.urls, urls_stale: status.urls_stale, + ca_fingerprint_sha256: status.ca_fingerprint_sha256, } } @@ -100,6 +101,7 @@ mod tests { port: 9443, urls: vec!["https://192.168.1.2:9443".into()], urls_stale: false, + ca_fingerprint_sha256: Some("ab".repeat(32)), locale: "zh-CN".into(), connection_count: 2, active_session_id: Some(openless_core::SessionId::new()), @@ -115,7 +117,8 @@ mod tests { "port": 9443, "pin": "123456", "urls": ["https://192.168.1.2:9443"], - "urlsStale": false + "urlsStale": false, + "caFingerprintSha256": "ab".repeat(32) }) ); } diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index b5e65b34b..c1745c2ed 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -1280,6 +1280,7 @@ impl openless_core::RemoteInputRuntimeAdapter for TauriRemoteInputRuntimeAdapter port: handle.bound_port, urls: crate::remote_server::access_urls(handle.bound_port), urls_stale: false, + ca_fingerprint_sha256: Some(handle.ca_fingerprint_sha256.clone()), }; *server.lock().await = Some(handle); Ok(binding) diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js index e515a58cd..f17535585 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/app.js +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -26,7 +26,7 @@ offlineTitle: '连接已断开', offlineSub: '与电脑的连接已中断。', btnReconnect: '重新连接', - certTip: "如遇证书提示,请核对地址与电脑显示一致,再按“首次设置:信任此电脑”完成安装和完全信任。", + certTip: "信任前,请将手机系统证书详情中的完整 SHA-256 与电脑 OpenLess 设置核对;仅核对 IP 或名称不够。", tipToggle: '点击大按钮开始录音,再次点击结束并识别。', tipHold: '按住大按钮说话,松开结束并识别。', labelToggleIdle: '点击开始', @@ -58,8 +58,9 @@ micUnknown: '❌ 无法启动录音{name}。', errGeneric: '发生错误', helpTitle: "首次设置:信任此电脑", - helpAndroid: "安卓:下载 CA 证书,在系统设置的“安装证书 → CA 证书”中安装,再回到本页。菜单名称因设备而异。", - helpIos: "iPhone / iPad:下载描述文件,打开“设置 → 通用 → VPN 与设备管理”安装;再到“通用 → 关于本机 → 证书信任设置”,为 OpenLess Remote Input CA 打开“完全信任”,返回 Safari 刷新。每台电脑只需设置一次;旧版证书需要重新安装。", + helpAndroid: "安卓:下载 CA 证书,在系统证书预览中核对完整 SHA-256 后再安装。若系统无法在信任前显示指纹,请勿从此页面安装,改用已有的可信文件传输渠道。菜单名称因设备而异。", + helpVerify: "先打开电脑上的 OpenLess 远程输入设置,保留“本机根证书 SHA-256”。在手机系统证书详情中核对全部 64 个字符;不要使用本网页、描述文件名称或标识里的值作为证明。不一致或无法查看时,请停止并移除描述文件。描述文件必须只含一张根证书,不得有其他证书、VPN 或管理配置。", + helpIos: "iPhone / iPad:下载描述文件,在“设置 → 通用 → VPN 与设备管理”中打开它,选择“更多详细信息”中的证书并核对指纹。确认一致且没有额外配置后再安装,并到“通用 → 关于本机 → 证书信任设置”开启完全信任。若只能在安装后查看详情,先保持完全信任关闭,核对后再开启。完成后返回 Safari 刷新。", helpDownloadCert: "↓ iPhone:下载描述文件", helpDownloadAndroid: "↓ 安卓:下载 CA 证书", helpTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。", @@ -81,7 +82,7 @@ offlineTitle: '連線已中斷', offlineSub: '與電腦的連線已中斷。', btnReconnect: '重新連線', - certTip: "如遇憑證提示,請核對位址與電腦顯示一致,再依「首次設定:信任這台電腦」完成安裝與完全信任。", + certTip: "信任前,請將手機系統憑證詳細資訊中的完整 SHA-256 與電腦 OpenLess 設定核對;僅核對 IP 或名稱不足以驗證。", tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。', tipHold: '按住大按鈕說話,放開結束並辨識。', labelToggleIdle: '點擊開始', @@ -113,8 +114,9 @@ micUnknown: '❌ 無法啟動錄音{name}。', errGeneric: '發生錯誤', helpTitle: "首次設定:信任這台電腦", - helpAndroid: "Android:下載 CA 憑證,在系統設定的「安裝憑證 → CA 憑證」中安裝,再返回本頁。選單名稱依裝置而異。", - helpIos: "iPhone / iPad:下載描述檔,開啟「設定 → 一般 → VPN 與裝置管理」安裝;再到「一般 → 關於本機 → 憑證信任設定」,為 OpenLess Remote Input CA 開啟「完全信任」,返回 Safari 重新整理。每台電腦只需設定一次;舊版憑證需要重新安裝。", + helpAndroid: "Android:下載 CA 憑證,在系統憑證預覽中核對完整 SHA-256 後再安裝。若系統無法在信任前顯示指紋,請勿從此頁安裝,改用既有的可信任檔案傳輸管道。選單名稱依裝置而異。", + helpVerify: "先開啟電腦的 OpenLess 遠端輸入設定,保留「本機根憑證 SHA-256」。在手機系統憑證詳細資訊中核對全部 64 個字元;不要使用本網頁、描述檔名稱或識別碼中的值作為證明。不一致或無法查看時,請停止並移除描述檔。描述檔必須只含一張根憑證,不得有其他憑證、VPN 或管理設定。", + helpIos: "iPhone / iPad:下載描述檔,在「設定 → 一般 → VPN 與裝置管理」中開啟,選擇「更多詳細資訊」中的憑證並核對指紋。確認一致且沒有額外設定後再安裝,並到「一般 → 關於本機 → 憑證信任設定」開啟完全信任。若只能在安裝後查看詳細資訊,請先保持完全信任關閉,核對後再開啟。完成後返回 Safari 重新整理。", helpDownloadCert: "↓ iPhone:下載描述檔", helpDownloadAndroid: "↓ Android:下載 CA 憑證", helpTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。", @@ -136,7 +138,7 @@ offlineTitle: 'Disconnected', offlineSub: 'The connection to your computer was lost.', btnReconnect: 'Reconnect', - certTip: "If a certificate warning appears, check that the address matches your computer, then follow “First-time setup: trust this computer” to install and fully trust the certificate.", + certTip: "Before trusting, compare the full SHA-256 in the phone's system certificate details with OpenLess settings on the computer. An IP address or name alone is not enough.", tipToggle: 'Tap the big button to start recording, tap again to finish and transcribe.', tipHold: 'Hold the big button to talk, release to finish and transcribe.', labelToggleIdle: 'Tap to start', @@ -168,8 +170,9 @@ micUnknown: '❌ Could not start recording{name}.', errGeneric: 'An error occurred', helpTitle: "First-time setup: trust this computer", - helpAndroid: "Android: download the CA certificate, install it under system Settings → Install a certificate → CA certificate, then return here. Menu names vary by device.", - helpIos: "iPhone / iPad: download the profile and install it in Settings → General → VPN & Device Management. Then enable full trust for OpenLess Remote Input CA in General → About → Certificate Trust Settings and reload Safari. Set up once per computer; certificates from older versions need this one-time replacement.", + helpAndroid: "Android: download the CA and verify its full SHA-256 in the system certificate preview before installing it. If your device cannot show the fingerprint before trust, do not install from this page; use an existing authenticated file-transfer channel instead. Menu names vary by device.", + helpVerify: "Open Remote Input settings in OpenLess on the computer and keep its root CA SHA-256 visible. Compare all 64 characters in the phone's system certificate details; do not use a value from this page, a profile name or identifier as proof. If it differs or cannot be viewed, stop and remove the profile. Expect only one root certificate, with no additional certificates, VPN or management settings.", + helpIos: "iPhone / iPad: download the profile, open it in Settings → General → VPN & Device Management, then open More Details → certificate and verify its fingerprint. Only after it matches and no extra settings are present, install and enable full trust in General → About → Certificate Trust Settings. If details are available only after installation, leave full trust off until verified. Reload Safari afterwards.", helpDownloadCert: "↓ iPhone: download profile", helpDownloadAndroid: "↓ Android: download CA", helpTrustWarning: "The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.", @@ -191,7 +194,7 @@ offlineTitle: '接続が切断されました', offlineSub: 'パソコンとの接続が切断されました。', btnReconnect: '再接続', - certTip: "証明書の警告が表示されたら、アドレスがコンピュータの表示と一致することを確認し、「初回設定:このコンピュータを信頼」の手順でインストールと完全な信頼を行ってください。", + certTip: "信頼する前に、スマートフォンのシステム証明書詳細にある SHA-256 全体をコンピューターの OpenLess 設定と照合してください。IP や名前だけでは確認できません。", tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。', tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。', labelToggleIdle: 'タップで開始', @@ -223,8 +226,9 @@ micUnknown: '❌ 録音を開始できませんでした{name}。', errGeneric: 'エラーが発生しました', helpTitle: "初回設定:このコンピュータを信頼", - helpAndroid: "Android:CA 証明書をダウンロードし、システム設定の「証明書のインストール → CA 証明書」でインストールしてから戻ってください。項目名は端末により異なります。", - helpIos: "iPhone / iPad:プロファイルをダウンロードし、「設定 → 一般 → VPN とデバイス管理」でインストールします。次に「一般 → 情報 → 証明書信頼設定」で OpenLess Remote Input CA を完全に信頼し、Safari を再読み込みしてください。各コンピュータで一度だけ必要です。旧バージョンの証明書は一度入れ替えてください。", + helpAndroid: "Android:CA をダウンロードし、システムの証明書プレビューで SHA-256 全体を確認してからインストールします。信頼する前に指紋を表示できない端末では、このページからインストールせず、既存の認証済みファイル転送手段を使用してください。項目名は端末によって異なります。", + helpVerify: "コンピューターの OpenLess でリモート入力設定を開き、ルート CA の SHA-256 を表示したままにします。スマートフォンのシステム証明書詳細で全 64 文字を照合してください。このページ、プロファイル名や識別子の値は証明に使えません。一致しない場合や表示できない場合は中止し、プロファイルを削除してください。含まれるのはルート証明書 1 枚のみで、追加の証明書、VPN、管理設定がないことも確認してください。", + helpIos: "iPhone / iPad:プロファイルをダウンロードし、「設定 → 一般 → VPN とデバイス管理」で開き、「詳細情報」の証明書で指紋を照合します。一致し、余分な設定がないことを確認してからインストールし、「一般 → 情報 → 証明書信頼設定」で完全に信頼してください。インストール後にしか詳細を表示できない場合は、確認が終わるまで完全な信頼をオフにしてください。その後 Safari を再読み込みします。", helpDownloadCert: "↓ iPhone:プロファイルをダウンロード", helpDownloadAndroid: "↓ Android:CA をダウンロード", helpTrustWarning: "初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。", @@ -246,7 +250,7 @@ offlineTitle: '연결이 끊겼습니다', offlineSub: '컴퓨터와의 연결이 끊겼습니다.', btnReconnect: '다시 연결', - certTip: "인증서 경고가 나타나면 주소가 컴퓨터에 표시된 주소와 일치하는지 확인한 뒤 “최초 설정: 이 컴퓨터 신뢰”에 따라 인증서를 설치하고 완전히 신뢰하세요.", + certTip: "신뢰하기 전에 휴대폰 시스템의 인증서 상세 정보에 있는 전체 SHA-256을 컴퓨터의 OpenLess 설정과 비교하세요. IP나 이름만 확인해서는 충분하지 않습니다.", tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.', tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.', labelToggleIdle: '탭하여 시작', @@ -278,8 +282,9 @@ micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.', errGeneric: '오류가 발생했습니다', helpTitle: "최초 설정: 이 컴퓨터 신뢰", - helpAndroid: "Android: CA 인증서를 다운로드하고 시스템 설정 → 인증서 설치 → CA 인증서에서 설치한 뒤 돌아오세요. 메뉴 이름은 기기마다 다릅니다.", - helpIos: "iPhone / iPad: 프로파일을 다운로드하고 설정 → 일반 → VPN 및 기기 관리에서 설치하세요. 일반 → 정보 → 인증서 신뢰 설정에서 OpenLess Remote Input CA를 완전히 신뢰한 뒤 Safari를 새로고침하세요. 컴퓨터마다 한 번만 설정하면 됩니다. 이전 버전의 인증서는 한 번 교체해야 합니다.", + helpAndroid: "Android: CA를 다운로드하고 시스템 인증서 미리보기에서 전체 SHA-256을 확인한 뒤 설치하세요. 신뢰하기 전에 지문을 볼 수 없는 기기에서는 이 페이지에서 설치하지 말고 기존의 인증된 파일 전송 수단을 사용하세요. 메뉴 이름은 기기마다 다릅니다.", + helpVerify: "컴퓨터의 OpenLess 원격 입력 설정에서 루트 CA SHA-256을 표시해 두세요. 휴대폰 시스템의 인증서 상세 정보에서 64자 전체를 비교하세요. 이 웹 페이지, 프로파일 이름이나 식별자의 값은 증명으로 사용할 수 없습니다. 일치하지 않거나 볼 수 없으면 중단하고 프로파일을 제거하세요. 루트 인증서 한 개만 있고 추가 인증서, VPN 또는 관리 설정이 없는지도 확인하세요.", + helpIos: "iPhone / iPad: 프로파일을 다운로드하고 설정 → 일반 → VPN 및 기기 관리에서 여세요. 추가 세부사항의 인증서에서 지문을 확인하세요. 일치하고 추가 설정이 없는 경우에만 설치한 뒤 일반 → 정보 → 인증서 신뢰 설정에서 완전한 신뢰를 켜세요. 설치 후에만 상세 정보를 볼 수 있다면 확인이 끝날 때까지 완전한 신뢰를 꺼 두세요. 이후 Safari를 새로고침하세요.", helpDownloadCert: "↓ iPhone: 프로파일 다운로드", helpDownloadAndroid: "↓ Android: CA 다운로드", helpTrustWarning: "최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.", diff --git a/openless-all/app/src-tauri/src/remote_server/assets/index.html b/openless-all/app/src-tauri/src/remote_server/assets/index.html index 14f6a12f1..b9a4e6637 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/index.html +++ b/openless-all/app/src-tauri/src/remote_server/assets/index.html @@ -38,9 +38,10 @@

OpenLess 远程输入

首次设置:信任此电脑 -

iPhone / iPad:下载描述文件,打开“设置 → 通用 → VPN 与设备管理”安装;再到“通用 → 关于本机 → 证书信任设置”,为 OpenLess Remote Input CA 打开“完全信任”,返回 Safari 刷新。每台电脑只需设置一次;旧版证书需要重新安装。

-

安卓:下载 CA 证书,在系统设置的“安装证书 → CA 证书”中安装,再回到本页。菜单名称因设备而异。

-

只安装你自己电脑提供的 OpenLess 根证书。它具备签发证书的能力,私钥保存在这台电脑;不再使用时请从手机移除。

+

先打开电脑上的 OpenLess 远程输入设置,保留“本机根证书 SHA-256”。在手机系统证书详情中核对全部 64 个字符;不要使用本网页、描述文件名称或标识里的值作为证明。不一致或无法查看时,请停止并移除描述文件。描述文件必须只含一张根证书,不得有其他证书、VPN 或管理配置。

+

iPhone / iPad:下载描述文件,在“设置 → 通用 → VPN 与设备管理”中打开它,选择“更多详细信息”中的证书并核对指纹。确认一致且没有额外配置后再安装,并到“通用 → 关于本机 → 证书信任设置”开启完全信任。若只能在安装后查看详情,先保持完全信任关闭,核对后再开启。完成后返回 Safari 刷新。

+

安卓:下载 CA 证书,在系统证书预览中核对完整 SHA-256 后再安装。若系统无法在信任前显示指纹,请勿从此页面安装,改用已有的可信文件传输渠道。菜单名称因设备而异。

+

首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。

↓ iPhone:下载描述文件 ↓ 安卓:下载 CA 证书 diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css index 36a459584..08262f619 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/style.css +++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css @@ -258,6 +258,13 @@ body { gap: 8px; margin-top: 6px; } +.help-verify { + color: var(--ink); + padding: 10px; + border-left: 3px solid var(--blue); + background: var(--surface); + border-radius: 4px; +} .help-link { display: inline-block; padding: 9px 16px; diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index e78df6e92..a36a48720 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -64,6 +64,7 @@ pub struct RemoteServerHandle { conn_shutdown_tx: tokio::sync::watch::Sender, join: tauri::async_runtime::JoinHandle<()>, pub bound_port: u16, + pub ca_fingerprint_sha256: String, } impl RemoteServerHandle { @@ -86,6 +87,8 @@ pub struct RemoteInputStatus { pub pin: String, pub urls: Vec, pub urls_stale: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub ca_fingerprint_sha256: Option, } // ───────────────────────── 工具函数 ───────────────────────── @@ -310,6 +313,7 @@ pub async fn start(cfg: RemoteServerConfig) -> Result Result, + pub ca_fingerprint_sha256: String, pub server_config: Arc, } @@ -254,17 +255,24 @@ fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(cert)) +} + /// The profile contains a public CA only. IDs include its fingerprint so two /// computers can be trusted without replacing each other's iOS profiles. pub(super) fn mobileconfig(cert: &[u8]) -> String { use base64::Engine; - use sha2::{Digest, Sha256}; - let fingerprint = format!("{:x}", Sha256::digest(cert)); + let fingerprint = fingerprint_sha256(cert); let b64 = base64::engine::general_purpose::STANDARD.encode(cert); format!( r#" @@ -280,7 +288,7 @@ pub(super) fn mobileconfig(cert: &[u8]) -> String { PayloadDisplayNameOpenLess Remote Input CA ({short}) PayloadDisplayNameOpenLess Remote Input ({short}) -PayloadDescriptionTrust only a profile downloaded from your own computer. After installation, enable full trust in Settings > General > About > Certificate Trust Settings. This CA can issue certificates; remove this profile when you stop using remote input. +PayloadDescriptionBefore trusting, compare the certificate's full SHA-256 fingerprint in system certificate details with OpenLess settings on your computer. Names and profile identifiers are not proof of identity. Expect exactly one root certificate and no other settings. If the fingerprint differs or cannot be checked, do not install or enable full trust; remove the profile if already installed. This CA can issue certificates; remove it when no longer needed. PayloadIdentifiercom.openless.remote-input.{fingerprint} PayloadTypeConfiguration PayloadUUID{profile_uuid} @@ -306,6 +314,60 @@ mod tests { .unwrap() } + #[test] + fn fingerprint_uses_full_sha256() { + assert_eq!( + fingerprint_sha256(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn copied_profile_labels_do_not_authenticate_a_replacement_certificate() { + use base64::Engine; + let original_dir = tempfile::tempdir().unwrap(); + let replacement_dir = tempfile::tempdir().unwrap(); + let original = load_or_create(original_dir.path(), &names()).unwrap(); + let replacement = load_or_create(replacement_dir.path(), &names()).unwrap(); + + // 两张证书的名称完全相同,描述文件的名称和标识也可以照抄。 + assert_eq!( + parse_cert(&original.trust_cert).unwrap().distinguished_name, + parse_cert(&replacement.trust_cert).unwrap().distinguished_name + ); + let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + let substituted = mobileconfig(&original.trust_cert).replace( + &encode(&original.trust_cert), + &encode(&replacement.trust_cert), + ); + assert!(substituted.contains(&original.ca_fingerprint_sha256)); + let certificate_data = substituted + .split_once("") + .unwrap() + .1 + .split_once("") + .unwrap() + .0; + let actual_certificate = base64::engine::general_purpose::STANDARD + .decode(certificate_data) + .unwrap(); + + // 独立查看实际证书会发现指纹不同,且原 CA 的 TLS 验证拒绝替换证书。 + assert_ne!( + fingerprint_sha256(&actual_certificate), + original.ca_fingerprint_sha256 + ); + assert_eq!( + fingerprint_sha256(&actual_certificate), + replacement.ca_fingerprint_sha256 + ); + assert!(!verify_server(&replacement, &original.trust_cert, "localhost")); + assert_ne!( + original.ca_fingerprint_sha256, + fingerprint_sha256(&stored(original_dir.path()).leaf_cert) + ); + } + fn verify_server(identity: &TlsIdentity, trusted_ca: &[u8], name: &str) -> bool { let mut roots = rustls::RootCertStore::empty(); roots @@ -356,6 +418,11 @@ mod tests { std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() ); assert_eq!(first.trust_cert, second.trust_cert); + assert_eq!( + first.ca_fingerprint_sha256, + fingerprint_sha256(&first.trust_cert) + ); + assert_eq!(first.ca_fingerprint_sha256, second.ca_fingerprint_sha256); for name in names() { assert!(verify_server(&second, &first.trust_cert, &name)); } @@ -386,6 +453,7 @@ mod tests { let after = stored(dir.path()); assert_eq!(before.ca_cert, after.ca_cert); assert_eq!(before.ca_key, after.ca_key); + assert_eq!(first.ca_fingerprint_sha256, second.ca_fingerprint_sha256); assert_ne!(before.leaf_cert, after.leaf_cert); assert!(verify_server(&second, &first.trust_cert, "192.168.2.3")); // Removing an adapter does not require another leaf or CA. diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index b3dd51ce2..675edf0e9 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -1432,6 +1432,7 @@ mod tests { port: 9443, urls: vec!["https://192.168.1.2:9443".into()], urls_stale: false, + ca_fingerprint_sha256: None, locale: "zh-CN".into(), connection_count: 1, active_session_id: None, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 60fad2892..2a9b53787 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1435,7 +1435,13 @@ export const en: typeof zhCN = { portInUse: 'Port {{port}} is in use, please change it', startError: 'Failed to start the remote input service: {{reason}}', securityHint: 'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.', - certHint: "Open “First-time setup: trust this computer” on the phone to install and trust the certificate. Older versions require one-time setup; subsequent restarts and IP changes preserve trust.", + certHint: "Verify the root certificate fingerprint before trusting it on first use. Older versions require one-time setup; subsequent restarts and IP changes preserve trust.", + certFingerprintLabel: "This computer's root CA SHA-256", + certFingerprintCopy: "Copy full fingerprint", + certFingerprintCopied: "Fingerprint copied", + certFingerprintUnavailable: "The full fingerprint is unavailable. Do not install or trust a downloaded certificate.", + certVerifyHint: "Find SHA-256 in the phone's system certificate details and compare all 64 characters with this value (ignore spaces and colons) before enabling full trust. A web page, profile name or identifier cannot prove identity. If the fingerprint differs or cannot be viewed in full, stop and remove the downloaded or installed profile.", + certProfileHint: "Expect exactly one root certificate. Do not install a profile containing additional certificates, VPN or device management settings.", certTrustWarning: "The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.", certSetupLink: "Copy iPhone certificate link", waitingStart: 'The service is not running yet. Turn the switch off, then on again. Do not restart the app.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2467d0887..c2cfea033 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1395,7 +1395,13 @@ export const ja: typeof zhCN = { portInUse: 'ポート {{port}} は使用中です。変更してください', startError: 'リモート入力サービスの起動に失敗しました:{{reason}}', securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。', - certHint: "スマートフォンの「初回設定:このコンピュータを信頼」で証明書をインストールし信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。", + certHint: "初回接続ではルート証明書の指紋を確認してから信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。", + certFingerprintLabel: "このコンピューターのルート CA SHA-256", + certFingerprintCopy: "指紋全体をコピー", + certFingerprintCopied: "指紋をコピーしました", + certFingerprintUnavailable: "完全な指紋を取得できません。ダウンロードした証明書をインストールしたり信頼したりしないでください。", + certVerifyHint: "スマートフォンのシステム証明書詳細にある SHA-256 の全 64 文字を、空白とコロンを除いてこの値と照合し、完全に信頼する前に確認してください。Web ページ、プロファイル名や識別子は身元の証明にはなりません。一致しない場合や全体を表示できない場合は中止し、ダウンロード済みまたはインストール済みのプロファイルを削除してください。", + certProfileHint: "プロファイルにはルート証明書が 1 枚だけ含まれるはずです。追加の証明書、VPN、デバイス管理の設定がある場合はインストールしないでください。", certTrustWarning: "初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。", certSetupLink: "iPhone 証明書リンクをコピー", waitingStart: 'サービスはまだ起動していません。スイッチを一度オフにしてからオンにしてください。アプリを再起動しないでください。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 21423d9ed..eb97da5bf 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1395,7 +1395,13 @@ export const ko: typeof zhCN = { portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요', startError: '원격 입력 서비스 시작에 실패했습니다: {{reason}}', securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.', - certHint: "휴대폰에서 “최초 설정: 이 컴퓨터 신뢰”를 열고 인증서를 설치하고 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.", + certHint: "처음 연결할 때 루트 인증서 지문을 확인한 후 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.", + certFingerprintLabel: "이 컴퓨터의 루트 CA SHA-256", + certFingerprintCopy: "전체 지문 복사", + certFingerprintCopied: "지문 복사됨", + certFingerprintUnavailable: "전체 지문을 확인할 수 없습니다. 다운로드한 인증서를 설치하거나 신뢰하지 마세요.", + certVerifyHint: "휴대폰 시스템의 인증서 상세 정보에서 SHA-256을 찾아, 완전한 신뢰를 켜기 전에 공백과 콜론을 제외한 64자 전체를 이 값과 비교하세요. 웹 페이지, 프로파일 이름이나 식별자는 신원 증명이 아닙니다. 일치하지 않거나 전체 지문을 볼 수 없으면 중단하고 다운로드했거나 설치한 프로파일을 제거하세요.", + certProfileHint: "프로파일에는 루트 인증서 한 개만 있어야 합니다. 추가 인증서, VPN 또는 기기 관리 설정이 있으면 설치하지 마세요.", certTrustWarning: "최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.", certSetupLink: "iPhone 인증서 링크 복사", waitingStart: '서비스가 아직 시작되지 않았습니다. 스위치를 끈 다음 다시 켜세요. 앱을 다시 시작하지 마세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 70e9bd6b7..2f50906a0 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1433,7 +1433,13 @@ export const zhCN = { portInUse: '端口 {{port}} 被占用,请更换', startError: '远程输入服务启动失败:{{reason}}', securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。', - certHint: "首次连接请在手机页面展开“首次设置:信任此电脑”,安装并信任证书。升级旧版需设置一次;以后重启和换 IP 会保留信任。", + certHint: "首次连接需核对根证书指纹后再信任。升级旧版需设置一次;以后重启和换 IP 会保留信任。", + certFingerprintLabel: "本机根证书 SHA-256", + certFingerprintCopy: "复制完整指纹", + certFingerprintCopied: "指纹已复制", + certFingerprintUnavailable: "完整指纹不可用。请勿安装或信任下载的证书。", + certVerifyHint: "在手机系统的证书详情中找到 SHA-256,与这里的全部 64 个字符逐一核对(忽略空格和冒号)。必须在开启完全信任前完成。网页、描述文件名称和标识不能证明证书身份;若不一致或无法查看完整指纹,请停止并移除已下载或安装的描述文件。", + certProfileHint: "描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。", certTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。", certSetupLink: "复制 iPhone 证书链接", waitingStart: '服务尚未启动。请关闭开关再打开一次,不要重启软件。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f34c87818..7314c991d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1401,7 +1401,13 @@ export const zhTW: typeof zhCN = { portInUse: '連接埠 {{port}} 被佔用,請更換', startError: '遠端輸入服務啟動失敗:{{reason}}', securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。', - certHint: "首次連線請在手機頁面展開「首次設定:信任這台電腦」,安裝並信任憑證。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。", + certHint: "首次連線需核對根憑證指紋後再信任。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。", + certFingerprintLabel: "本機根憑證 SHA-256", + certFingerprintCopy: "複製完整指紋", + certFingerprintCopied: "已複製指紋", + certFingerprintUnavailable: "完整指紋無法取得。請勿安裝或信任下載的憑證。", + certVerifyHint: "在手機系統的憑證詳細資訊中找到 SHA-256,與此處全部 64 個字元逐一核對(忽略空格和冒號)。必須在開啟完全信任前完成。網頁、描述檔名稱與識別碼不能證明憑證身分;若不一致或無法查看完整指紋,請停止並移除已下載或安裝的描述檔。", + certProfileHint: "描述檔應只包含一張根憑證。若有其他憑證、VPN 或裝置管理設定,請勿安裝。", certTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。", certSetupLink: "複製 iPhone 憑證連結", waitingStart: '服務尚未啟動。請關閉開關再打開一次,不要重啟軟體。', diff --git a/openless-all/app/src/lib/ipc/remote-server.ts b/openless-all/app/src/lib/ipc/remote-server.ts index a9304b247..89a67305b 100644 --- a/openless-all/app/src/lib/ipc/remote-server.ts +++ b/openless-all/app/src/lib/ipc/remote-server.ts @@ -8,6 +8,8 @@ export interface RemoteInputStatus { pin: string urls: string[] urlsStale: boolean + /** 通过本地 IPC 获取正在运行的服务所用根证书的完整 SHA-256。 */ + caFingerprintSha256?: string } export function getRemoteInputStatus(): Promise { diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index d3c38cab2..097bebf5b 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -101,6 +101,12 @@ export function RemoteInputSection() { const enabled = prefs.remoteInputEnabled; const mode = prefs.remoteInputDefaultMode ?? 'toggle'; const viewState = getRemoteInputViewState(enabled, status, startError); + // 只有本地监听器返回的完整指纹才能作为手机核验的依据。 + const fingerprint = status?.caFingerprintSha256; + const canVerifyCertificate = typeof fingerprint === 'string' && /^[a-f0-9]{64}$/i.test(fingerprint); + const formattedFingerprint = canVerifyCertificate + ? fingerprint.toUpperCase().match(/.{2}/g)!.join(' ') + : ''; // 提交端口草稿:非法(非有限数/越界离谱)则丢弃还原显示,合法则取整并 clamp 到 [1024, 65535]。 const commitPort = () => { @@ -192,6 +198,43 @@ export function RemoteInputSection() { {enabled && (viewState === 'running' || viewState === 'stale') && status && ( <> + +
+ {canVerifyCertificate ? ( + <> + + {formattedFingerprint} + + + + ) : ( +

+ {t('settings.remoteInput.certFingerprintUnavailable')} +

+ )} +

+ {t('settings.remoteInput.certVerifyHint')} +

+

+ {t('settings.remoteInput.certProfileHint')} +

+
+
{status.urls.length > 0 && (