Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 59 additions & 36 deletions docs/remote-input-certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/crates/openless-core/src/domains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,9 @@ pub struct RemoteInputStatus {
pub port: u16,
pub urls: Vec<String>,
pub urls_stale: bool,
/// 由宿主提供,取自正在运行的监听器所使用的公开根证书。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_fingerprint_sha256: Option<String>,
pub locale: String,
pub connection_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -1024,6 +1027,7 @@ pub struct RemoteInputServerBinding {
pub port: u16,
pub urls: Vec<String>,
pub urls_stale: bool,
pub ca_fingerprint_sha256: Option<String>,
}

/// Native transport and shared-dictation bridge. TLS, sockets, H5 assets and
Expand Down Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ struct RemoteInputState {
port: u16,
urls: Vec<String>,
urls_stale: bool,
ca_fingerprint_sha256: Option<String>,
locale: String,
pairing_pin: Option<SecretValue>,
connections: HashMap<SessionId, RemoteConnectionState>,
Expand Down Expand Up @@ -165,6 +166,7 @@ impl RemoteInputService {
port,
urls: Vec::new(),
urls_stale: false,
ca_fingerprint_sha256: None,
locale,
pairing_pin: None,
connections: HashMap::new(),
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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(())
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/crates/openless-core/src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use openless_core::{
#[derive(Default)]
struct FixtureRemoteRuntime {
persisted_pin: Mutex<Option<String>>,
ca_fingerprint_sha256: Mutex<Option<String>>,
persist_count: AtomicUsize,
reject_persist: AtomicBool,
start_count: AtomicUsize,
Expand Down Expand Up @@ -72,6 +73,7 @@ impl RemoteInputRuntimeAdapter for FixtureRemoteRuntime {
) -> BoxFuture<'static, Result<RemoteInputServerBinding, BackendError>> {
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"));
Expand All @@ -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,
})
})
}
Expand Down Expand Up @@ -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());
Expand Down
21 changes: 21 additions & 0 deletions openless-all/app/linux-egui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.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);
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/linux-egui/src/remote_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -217,6 +218,7 @@ struct LinuxRemoteServerHandle {
connections_shutdown: tokio::sync::watch::Sender<bool>,
join: tokio::task::JoinHandle<()>,
bound_port: u16,
ca_fingerprint_sha256: String,
}

#[cfg(target_os = "linux")]
Expand All @@ -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"))]
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -443,6 +447,7 @@ async fn start_server(
connections_shutdown,
join,
bound_port,
ca_fingerprint_sha256,
})
}

Expand Down
5 changes: 4 additions & 1 deletion openless-all/app/src-tauri/src/commands/remote_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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()),
Expand All @@ -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)
})
);
}
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/core_adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading