From ebc0a7462a30fcb287d3aecb6861407c4e21723b Mon Sep 17 00:00:00 2001 From: zhou-zhichao Date: Mon, 7 Sep 2026 14:09:14 +0200 Subject: [PATCH] fix(remote-input): preserve phone trust with a persistent local CA --- .github/workflows/ci.yml | 3 + docs/remote-input-certificates.md | 92 ++++ openless-all/app/Cargo.lock | 112 ++++ openless-all/app/linux-egui/Cargo.toml | 7 +- .../app/linux-egui/src/remote_input.rs | 99 +--- openless-all/app/src-tauri/Cargo.lock | 95 +++- openless-all/app/src-tauri/Cargo.toml | 5 +- .../app/src-tauri/backend-tests/Cargo.lock | 184 +++++++ .../app/src-tauri/backend-tests/Cargo.toml | 11 + .../backend-tests/tests/remote_tls.rs | 3 + .../src-tauri/src/remote_server/assets/app.js | 62 ++- .../src/remote_server/assets/index.html | 16 +- .../src/remote_server/assets/style.css | 2 + .../app/src-tauri/src/remote_server/mod.rs | 183 ++----- .../src/remote_server/tls_identity.rs | 506 ++++++++++++++++++ openless-all/app/src/i18n/en.ts | 6 +- openless-all/app/src/i18n/ja.ts | 6 +- openless-all/app/src/i18n/ko.ts | 6 +- openless-all/app/src/i18n/zh-CN.ts | 6 +- openless-all/app/src/i18n/zh-TW.ts | 6 +- .../src/pages/settings/RemoteInputSection.tsx | 13 +- 21 files changed, 1148 insertions(+), 275 deletions(-) create mode 100644 docs/remote-input-certificates.md create mode 100644 openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs create mode 100644 openless-all/app/src-tauri/src/remote_server/tls_identity.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 591589e56..b6515e726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,9 @@ jobs: - name: Test Core run: cargo test --locked -p openless-core + - name: Test remote input TLS identity and Unix key permissions + run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls + cross-platform: name: ${{ matrix.label }} checks strategy: diff --git a/docs/remote-input-certificates.md b/docs/remote-input-certificates.md new file mode 100644 index 000000000..229019bfa --- /dev/null +++ b/docs/remote-input-certificates.md @@ -0,0 +1,92 @@ +# Remote input: one-time phone certificate setup + +Remote input needs HTTPS for browser microphone access. OpenLess generates a +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. + +## What OpenLess automates + +- Creates and atomically persists a unique CA and server identity on first use. +- Reuses the same CA and server certificate after restarts and upgrades that + preserve application data. +- Reissues the server certificate when required LAN or virtual-adapter IPs + change, keeping the same CA and therefore the phone's trust. +- Renews the server certificate on service startup when less than 30 days + remain. Leaves are valid for at most 366 days including clock-skew allowance; + a continuously running service must restart before its leaf expires. +- Serves the public CA as a `.cer` file or iOS configuration profile on both + Tauri desktop and Linux egui hosts. The private keys are never downloaded. +- Refuses to start with a damaged, unreadable, mismatched, or expiring CA rather + than silently replacing the phone's trust anchor. + +## Upgrades, reinstalling, and recovery + +Older releases used a directly self-signed leaf (`remote-cert-v4.der`) and +regenerated it whenever a newly observed IP was absent from a sidecar SAN list. +Even a virtual-adapter change could invalidate the certificate trusted by the +phone. Some releases also hid certificate setup and packaged the non-CA leaf +as a root profile. Upgrading from this format requires the one-time setup above. +Old files are left intact for rollback; they are not promoted to a CA. + +Keep `remote-tls-identity-v1.json` with the application's user configuration +when backing up or reinstalling. On Windows it is in +`%APPDATA%\com.openless.app`; on Tauri macOS it is in the application's config +directory; on Linux egui it is in the host data directory's `remote-input` +subdirectory. This file contains private keys: do not publish it, send it to a +phone, or copy it to another computer. Unix files are owner-readable/writable +only; Windows files inherit the user's application-data ACL. + +If this file is damaged, restore the computer's own backup. If it is lost, or +the CA expires (ten years), explicitly back up/remove the old identity while +OpenLess is stopped, restart, and install/trust the newly generated CA on each +phone. Deleting all application data necessarily loses the old trust identity. + +## Regression checks + +```sh +cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml --test remote_tls +node openless-all/app/scripts/remote-input-audio-queue.test.mjs +``` + +The TLS tests exercise real rustls handshakes with only the downloaded CA as a +trust anchor, IP changes, restart reuse, renewal, corrupted keys, persistence +failure, migration, and separation between two computers. iOS installation and +microphone recording still require a physical-device smoke test. diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index 646d0d38a..a4b807be9 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -170,6 +170,45 @@ dependencies = [ "libloading", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -978,6 +1017,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2254,6 +2307,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -3080,6 +3139,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3122,6 +3190,7 @@ version = "0.1.0" dependencies = [ "arboard", "axum", + "base64", "cpal", "dbus", "eframe", @@ -3137,9 +3206,13 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2", + "tempfile", + "time", "tokio", "tokio-rustls", "uuid", + "x509-parser", ] [[package]] @@ -3638,6 +3711,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser", "yasna", ] @@ -3819,6 +3893,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -4396,6 +4479,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -4404,6 +4488,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -5698,6 +5792,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index 2cb80cf7b..1e35030d1 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -27,6 +27,11 @@ libc = "0.2" axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } local-ip-address = "0.6" -rcgen = "0.13" +base64 = "0.22" +sha2 = "0.10" +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +time = "0.3" +tempfile = "3" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } diff --git a/openless-all/app/linux-egui/src/remote_input.rs b/openless-all/app/linux-egui/src/remote_input.rs index c2be62471..070579d92 100644 --- a/openless-all/app/linux-egui/src/remote_input.rs +++ b/openless-all/app/linux-egui/src/remote_input.rs @@ -289,71 +289,8 @@ fn access_urls(port: u16) -> Vec { } #[cfg(target_os = "linux")] -fn load_or_generate_certificate( - directory: &std::path::Path, - sans: &[String], -) -> Result<(Vec, rustls::pki_types::PrivateKeyDer<'static>), BackendError> { - use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; - - let cert_path = directory.join("remote-cert-v4.der"); - let key_path = directory.join("remote-key-v4.der"); - let sans_path = directory.join("remote-cert-sans-v4.txt"); - if let (Ok(cert), Ok(key), Ok(saved)) = ( - std::fs::read(&cert_path), - std::fs::read(&key_path), - std::fs::read_to_string(&sans_path), - ) { - let saved = saved.lines().collect::>(); - if sans.iter().all(|value| saved.contains(value.as_str())) { - return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))); - } - } - let mut params = rcgen::CertificateParams::new(sans.to_vec()) - .map_err(|error| remote_platform_error(format!("invalid TLS names: {error}")))?; - let mut name = rcgen::DistinguishedName::new(); - name.push(rcgen::DnType::CommonName, "OpenLess Remote Input"); - params.distinguished_name = name; - params - .extended_key_usages - .push(rcgen::ExtendedKeyUsagePurpose::ServerAuth); - let key = rcgen::KeyPair::generate() - .map_err(|error| remote_platform_error(format!("TLS key generation failed: {error}")))?; - let cert = params - .self_signed(&key) - .map_err(|error| remote_platform_error(format!("TLS certificate failed: {error}")))?; - let cert_der = cert.der().as_ref().to_vec(); - let key_der = key.serialize_der(); - std::fs::create_dir_all(directory) - .map_err(|error| remote_platform_error(format!("TLS directory failed: {error}")))?; - std::fs::write(&cert_path, &cert_der) - .map_err(|error| remote_platform_error(format!("TLS certificate save failed: {error}")))?; - std::fs::write(&key_path, &key_der) - .map_err(|error| remote_platform_error(format!("TLS key save failed: {error}")))?; - std::fs::write(&sans_path, sans.join("\n")) - .map_err(|error| remote_platform_error(format!("TLS names save failed: {error}")))?; - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) - .map_err(|error| remote_platform_error(format!("TLS key permissions failed: {error}")))?; - Ok(( - cert_der, - PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), - )) -} - -#[cfg(target_os = "linux")] -fn tls_config( - cert: Vec, - key: rustls::pki_types::PrivateKeyDer<'static>, -) -> Result, BackendError> { - let provider = Arc::new(rustls::crypto::ring::default_provider()); - rustls::ServerConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(|error| remote_platform_error(format!("TLS protocol failed: {error}")))? - .with_no_client_auth() - .with_single_cert(vec![rustls::pki_types::CertificateDer::from(cert)], key) - .map(Arc::new) - .map_err(|error| remote_platform_error(format!("TLS certificate failed: {error}"))) -} +#[path = "../../src-tauri/src/remote_server/tls_identity.rs"] +mod tls_identity; #[cfg(target_os = "linux")] fn router(state: Arc) -> Router { @@ -387,14 +324,32 @@ fn router(state: Arc) -> Router { "/cert.cer", get(|State(state): State>| async move { ( - [( - axum::http::header::CONTENT_TYPE, - "application/x-x509-ca-cert", - )], + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-x509-ca-cert", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], state.cert_der.clone(), ) }), ) + .route( + "/cert.mobileconfig", + get(|State(state): State>| async move { + ( + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-apple-aspen-config", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], + tls_identity::mobileconfig(&state.cert_der), + ) + }), + ) .route("/ws", get(websocket_upgrade)) .with_state(state) } @@ -444,8 +399,10 @@ async fn start_server( ) -> Result { let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; sans.extend(local_lan_ipv4s()); - let (cert_der, key) = load_or_generate_certificate(&data_dir.join("remote-input"), &sans)?; - let acceptor = TlsAcceptor::from(tls_config(cert_der.clone(), key)?); + let identity = tls_identity::load_or_create(&data_dir.join("remote-input"), &sans) + .map_err(remote_platform_error)?; + let cert_der = identity.trust_cert; + let acceptor = TlsAcceptor::from(identity.server_config); let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))) .await .map_err(|error| remote_platform_error(format!("remote input bind failed: {error}")))?; diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 93621dfb6..52199303a 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -180,6 +180,45 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1394,6 +1433,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -4147,6 +4200,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4259,7 +4321,9 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "thiserror 1.0.69", + "time", "tokenizers", "tokio", "tokio-rustls", @@ -4271,6 +4335,7 @@ dependencies = [ "window-vibrancy 0.7.1", "windows 0.58.0", "winreg 0.52.0", + "x509-parser", "zip 2.4.2", ] @@ -5033,6 +5098,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser", "yasna", ] @@ -5330,6 +5396,15 @@ dependencies = [ "transpose", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -6768,7 +6843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -8885,6 +8960,24 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 347d85a22..62a929546 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -80,7 +80,10 @@ tauri-plugin-autostart = "2" global-hotkey = "0.6" enigo = "0.3" arboard = { version = "3", features = ["wayland-data-control"] } -rcgen = "^0.13" +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +time = "0.3" +tempfile = "3" local-ip-address = "^0.6" rustls = { version = "^0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } tokio-rustls = { version = "^0.26", default-features = false, features = ["ring", "tls12", "logging"] } diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index e95ee7d3e..a4041cda6 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -52,6 +52,45 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -303,6 +342,20 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -840,6 +893,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -932,6 +991,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -973,12 +1038,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -988,6 +1082,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -998,8 +1101,18 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "openless-backend-tests" version = "0.1.0" dependencies = [ + "base64", "openless-core", + "rcgen", + "rustls", + "serde", + "serde_json", + "sha2", + "tempfile", + "time", "tokio", + "uuid", + "x509-parser", ] [[package]] @@ -1065,6 +1178,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1331,6 +1454,20 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1470,6 +1607,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1809,6 +1955,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -1817,6 +1964,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2358,6 +2515,24 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -2377,6 +2552,15 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index 9a307a768..4588a1269 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -8,3 +8,14 @@ publish = false [dependencies] openless-core = { path = "../../crates/openless-core" } tokio = { version = "1", features = ["rt-multi-thread"] } + +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = { version = "0.16", features = ["verify"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = "0.3" +tempfile = "3" +base64 = "0.22" +sha2 = "0.10" +uuid = { version = "1", features = ["v4"] } diff --git a/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs b/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs new file mode 100644 index 000000000..a2c4ea46c --- /dev/null +++ b/openless-all/app/src-tauri/backend-tests/tests/remote_tls.rs @@ -0,0 +1,3 @@ +// Exercise the production TLS implementation without linking the desktop UI. +#[path = "../../src/remote_server/tls_identity.rs"] +mod tls_identity; 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 665542862..7e7847ea9 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: '首次访问浏览器会提示“连接不安全”(本地自签名证书)。Android Chrome:点“高级”→“继续前往”;iOS Safari:点“显示详情”→“访问此网站”。', + certTip: "如遇证书提示,请核对地址与电脑显示一致,再按“首次设置:信任此电脑”完成安装和完全信任。", tipToggle: '点击大按钮开始录音,再次点击结束并识别。', tipHold: '按住大按钮说话,松开结束并识别。', labelToggleIdle: '点击开始', @@ -57,10 +57,12 @@ pcmQueueOverflow: '❌ 音频缓存已满,请重试。', micUnknown: '❌ 无法启动录音{name}。', errGeneric: '发生错误', - helpTitle: '连不上?多半是手机没信任证书', - helpAndroid: '① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。', - helpIos: '② iOS Safari:用无痕模式打开本页,出现“不安全”提示时点“显示详情 → 访问此网站”,再输入配对码连接(无需安装证书)。', - helpDownloadCert: '⬇ 下载并安装证书', + helpTitle: "首次设置:信任此电脑", + helpAndroid: "安卓:下载 CA 证书,在系统设置的“安装证书 → CA 证书”中安装,再回到本页。菜单名称因设备而异。", + helpIos: "iPhone / iPad:下载描述文件,打开“设置 → 通用 → VPN 与设备管理”安装;再到“通用 → 关于本机 → 证书信任设置”,为 OpenLess Remote Input CA 打开“完全信任”,返回 Safari 刷新。每台电脑只需设置一次;旧版证书需要重新安装。", + helpDownloadCert: "↓ iPhone:下载描述文件", + helpDownloadAndroid: "↓ 安卓:下载 CA 证书", + helpTrustWarning: "只安装你自己电脑提供的 OpenLess 根证书。它具备签发证书的能力,私钥保存在这台电脑;不再使用时请从手机移除。", helpCopyLink: '⧉ 复制链接', helpCopied: '已复制 ✓', copy: '复制', @@ -79,7 +81,7 @@ offlineTitle: '連線已中斷', offlineSub: '與電腦的連線已中斷。', btnReconnect: '重新連線', - certTip: '首次造訪瀏覽器會提示「連線不安全」(本機自簽憑證)。Android Chrome:點「進階」→「繼續前往」;iOS Safari:點「顯示詳細資訊」→「瀏覽此網站」。', + certTip: "如遇憑證提示,請核對位址與電腦顯示一致,再依「首次設定:信任這台電腦」完成安裝與完全信任。", tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。', tipHold: '按住大按鈕說話,放開結束並辨識。', labelToggleIdle: '點擊開始', @@ -110,10 +112,12 @@ pcmQueueOverflow: '❌ 音訊暫存已滿,請重試。', micUnknown: '❌ 無法啟動錄音{name}。', errGeneric: '發生錯誤', - helpTitle: '連不上?多半是手機沒信任憑證', - helpAndroid: '① 安卓 / 一般情況:用瀏覽器無痕模式開啟本頁,出現“不安全”警告時選“繼續前往”,再輸入配對碼連線。', - helpIos: '② iOS Safari:用無痕模式開啟本頁,出現“不安全”提示時點“顯示詳細資訊 → 瀏覽此網站”,再輸入配對碼連線(無需安裝憑證)。', - helpDownloadCert: '⬇ 下載並安裝憑證', + helpTitle: "首次設定:信任這台電腦", + helpAndroid: "Android:下載 CA 憑證,在系統設定的「安裝憑證 → CA 憑證」中安裝,再返回本頁。選單名稱依裝置而異。", + helpIos: "iPhone / iPad:下載描述檔,開啟「設定 → 一般 → VPN 與裝置管理」安裝;再到「一般 → 關於本機 → 憑證信任設定」,為 OpenLess Remote Input CA 開啟「完全信任」,返回 Safari 重新整理。每台電腦只需設定一次;舊版憑證需要重新安裝。", + helpDownloadCert: "↓ iPhone:下載描述檔", + helpDownloadAndroid: "↓ Android:下載 CA 憑證", + helpTrustWarning: "只安裝你自己電腦提供的 OpenLess 根憑證。它能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。", helpCopyLink: '⧉ 複製連結', helpCopied: '已複製 ✓', copy: '複製', @@ -132,7 +136,7 @@ offlineTitle: 'Disconnected', offlineSub: 'The connection to your computer was lost.', btnReconnect: 'Reconnect', - certTip: 'On first visit the browser will warn "Not secure" (local self-signed certificate). Android Chrome: tap "Advanced" → "Proceed"; iOS Safari: tap "Show Details" → "visit this website".', + 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.", 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', @@ -163,10 +167,12 @@ pcmQueueOverflow: '❌ Audio buffer is full. Please try again.', micUnknown: '❌ Could not start recording{name}.', errGeneric: 'An error occurred', - helpTitle: "Can't connect? The phone probably doesn't trust the certificate", - helpAndroid: '① Android / general: open this page in an incognito tab, choose "Proceed" on the "Not secure" warning, then enter the pairing code.', - helpIos: '② iOS Safari: open this page in an incognito tab; on the "Not Private" warning tap "Show Details → visit this website", then enter the code (no certificate install needed).', - helpDownloadCert: '⬇ Download & install cert', + 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.", + helpDownloadCert: "↓ iPhone: download profile", + helpDownloadAndroid: "↓ Android: download CA", + helpTrustWarning: "Only install the OpenLess root certificate from your own computer. It can issue certificates and its private key stays on that computer; remove it from your phone when no longer needed.", helpCopyLink: '⧉ Copy link', helpCopied: 'Copied ✓', copy: 'Copy', @@ -185,7 +191,7 @@ offlineTitle: '接続が切断されました', offlineSub: 'パソコンとの接続が切断されました。', btnReconnect: '再接続', - certTip: '初回アクセス時、ブラウザに「保護されていません」と表示されます(ローカル自己署名証明書)。Android Chrome:「詳細設定」→「アクセスする」、iOS Safari:「詳細を表示」→「このWebサイトを閲覧」をタップしてください。', + certTip: "証明書の警告が表示されたら、アドレスがコンピュータの表示と一致することを確認し、「初回設定:このコンピュータを信頼」の手順でインストールと完全な信頼を行ってください。", tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。', tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。', labelToggleIdle: 'タップで開始', @@ -216,10 +222,12 @@ pcmQueueOverflow: '❌ 音声バッファがいっぱいです。もう一度お試しください。', micUnknown: '❌ 録音を開始できませんでした{name}。', errGeneric: 'エラーが発生しました', - helpTitle: '接続できない?多くは証明書が信頼されていません', - helpAndroid: '① Android / 一般:ブラウザのシークレットモードで本ページを開き、「保護されていません」で「アクセスする」を選び、ペアリングコードを入力。', - helpIos: '② iOS Safari:シークレットモードで本ページを開き、「安全ではありません」で「詳細を表示 → このWebサイトにアクセス」をタップしてコードを入力(証明書のインストール不要)。', - helpDownloadCert: '⬇ 証明書をインストール', + helpTitle: "初回設定:このコンピュータを信頼", + helpAndroid: "Android:CA 証明書をダウンロードし、システム設定の「証明書のインストール → CA 証明書」でインストールしてから戻ってください。項目名は端末により異なります。", + helpIos: "iPhone / iPad:プロファイルをダウンロードし、「設定 → 一般 → VPN とデバイス管理」でインストールします。次に「一般 → 情報 → 証明書信頼設定」で OpenLess Remote Input CA を完全に信頼し、Safari を再読み込みしてください。各コンピュータで一度だけ必要です。旧バージョンの証明書は一度入れ替えてください。", + helpDownloadCert: "↓ iPhone:プロファイルをダウンロード", + helpDownloadAndroid: "↓ Android:CA をダウンロード", + helpTrustWarning: "自分のコンピュータの OpenLess ルート証明書のみをインストールしてください。証明書を発行できる秘密鍵はそのコンピュータに保存されます。不要になったらスマートフォンから削除してください。", helpCopyLink: '⧉ リンクをコピー', helpCopied: 'コピーしました ✓', copy: 'コピー', @@ -238,7 +246,7 @@ offlineTitle: '연결이 끊겼습니다', offlineSub: '컴퓨터와의 연결이 끊겼습니다.', btnReconnect: '다시 연결', - certTip: '처음 접속하면 브라우저에 "안전하지 않음" 경고가 표시됩니다(로컬 자체 서명 인증서). Android Chrome: "고급" → "계속 진행"; iOS Safari: "세부정보 표시" → "이 웹사이트 방문"을 탭하세요.', + certTip: "인증서 경고가 나타나면 주소가 컴퓨터에 표시된 주소와 일치하는지 확인한 뒤 “최초 설정: 이 컴퓨터 신뢰”에 따라 인증서를 설치하고 완전히 신뢰하세요.", tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.', tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.', labelToggleIdle: '탭하여 시작', @@ -269,10 +277,12 @@ pcmQueueOverflow: '❌ 오디오 버퍼가 가득 찼습니다. 다시 시도하세요.', micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.', errGeneric: '오류가 발생했습니다', - helpTitle: '연결이 안 되나요? 대개 인증서를 신뢰하지 않아서입니다', - helpAndroid: '① Android / 일반: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "계속"을 선택한 뒤 페어링 코드를 입력하세요.', - helpIos: '② iOS Safari: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "세부사항 표시 → 이 웹사이트 방문"을 누른 뒤 코드를 입력하세요(인증서 설치 불필요).', - helpDownloadCert: '⬇ 인증서 설치', + helpTitle: "최초 설정: 이 컴퓨터 신뢰", + helpAndroid: "Android: CA 인증서를 다운로드하고 시스템 설정 → 인증서 설치 → CA 인증서에서 설치한 뒤 돌아오세요. 메뉴 이름은 기기마다 다릅니다.", + helpIos: "iPhone / iPad: 프로파일을 다운로드하고 설정 → 일반 → VPN 및 기기 관리에서 설치하세요. 일반 → 정보 → 인증서 신뢰 설정에서 OpenLess Remote Input CA를 완전히 신뢰한 뒤 Safari를 새로고침하세요. 컴퓨터마다 한 번만 설정하면 됩니다. 이전 버전의 인증서는 한 번 교체해야 합니다.", + helpDownloadCert: "↓ iPhone: 프로파일 다운로드", + helpDownloadAndroid: "↓ Android: CA 다운로드", + helpTrustWarning: "본인 컴퓨터의 OpenLess 루트 인증서만 설치하세요. 이 인증서는 다른 인증서를 발급할 수 있으며 개인 키는 해당 컴퓨터에 저장됩니다. 더 이상 필요하지 않으면 휴대폰에서 제거하세요.", helpCopyLink: '⧉ 링크 복사', helpCopied: '복사됨 ✓', copy: '복사', @@ -838,7 +848,7 @@ } if (copyCertBtn) { copyCertBtn.addEventListener('click', function () { - var url = location.origin + '/cert.cer'; + var url = location.origin + '/cert.mobileconfig'; var ok = function () { copyCertBtn.textContent = L.helpCopied; setTimeout(function () { copyCertBtn.textContent = L.helpCopyLink; }, 1500); 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 05c26cbba..14f6a12f1 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 @@ -35,16 +35,18 @@

OpenLess 远程输入

- - + 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 3b3ab4fd7..36a459584 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 @@ -243,7 +243,9 @@ body { font-weight: 600; color: var(--ink-2); margin-bottom: 10px; + cursor: pointer; } +.help:not([open]) .help-title { margin-bottom: 0; } .help-step { font-size: 12.5px; color: var(--ink-3); 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 6c2352fee..e78df6e92 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -5,9 +5,10 @@ //! 手机录音以 16k/单声道/16-bit LE PCM 经 WebSocket 实时推回 PC,并通过共享 //! [`openless_core::OpenLessBackend`] 的 external-audio seam 进入同一听写管线。 //! -//! 关键约束:浏览器 `getUserMedia` 仅在安全上下文可用,所以必须 HTTPS。证书用 -//! rcgen 自签名(SAN 含本机局域网 IP),手机首次访问需手动信任。TLS 走 ring -//! 后端(与项目 reqwest/tungstenite 一致,避免 aws-lc-sys 的 C 编译依赖)。 +//! Browser microphone access requires a secure context, so this server uses HTTPS. +//! A persistent per-installation CA signs a separate LAN server certificate. +//! Phones install and trust the CA once. TLS uses ring +//! (matching reqwest/tungstenite and avoiding an additional aws-lc-sys dependency). use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; @@ -172,106 +173,13 @@ pub fn access_urls(port: u16) -> Vec { // ───────────────────────── TLS ───────────────────────── -/// 自签名证书:持久化到磁盘并跨重启复用。否则每次启动证书都变 —— 手机(尤其 iOS -/// Safari)上一次信任过的证书立刻失效,wss 握手静默挂起,表现为"连接中"卡死。仅当 -/// 磁盘无证书 / 解析失败 / 当前局域网 IP 不在已存 SAN 列表里(换了网络)时才重新生成。 -/// 返回 (证书 DER 原始字节, 私钥)。 -fn load_or_generate_cert( - dir: Option<&std::path::Path>, - sans: &[String], -) -> Result<(Vec, rustls::pki_types::PrivateKeyDer<'static>), String> { - use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; - // 文件名带 schema 版本:证书结构变更(v4 改回非 CA 服务器证书)时旧文件自动失效、重新生成。 - const CERT_FILE: &str = "remote-cert-v4.der"; - const KEY_FILE: &str = "remote-key-v4.der"; - const SANS_FILE: &str = "remote-cert-sans-v4.txt"; - if let Some(dir) = dir { - if let (Ok(cert), Ok(key), Ok(saved)) = ( - std::fs::read(dir.join(CERT_FILE)), - std::fs::read(dir.join(KEY_FILE)), - std::fs::read_to_string(dir.join(SANS_FILE)), - ) { - let saved_set: std::collections::HashSet<&str> = saved.lines().collect(); - // 当前需要的 SAN 都在已存证书里 → 复用,证书保持稳定(手机信任一次长期有效)。 - if sans.iter().all(|s| saved_set.contains(s.as_str())) { - log::info!("[remote-input] reusing persisted self-signed server cert"); - return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))); - } - } - } - // 生成自签名服务器证书(SAN 含本机各局域网 IP)。主路径是浏览器页面级 - // “继续访问/访问此网站”例外;/cert.cer 与 /cert.mobileconfig 是手机系统级 - // 安装信任的兜底(部分浏览器的 wss 不复用页面级例外时使用)。 - let (cert_der, key_der) = { - use rcgen::{ - CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair, - KeyUsagePurpose, - }; - let mut params = - CertificateParams::new(sans.to_vec()).map_err(|e| format!("rcgen params: {e}"))?; - // 关键:做成普通服务器证书(非 CA,rcgen 默认即 NoCa)。iOS Safari 用页面级 - // “访问此网站”即可信任、无需安装证书 —— 这正是之前一直能用的方式。把证书做成 CA - // 反而会让 iOS 拒绝页面级例外(CA 不能直接当服务器证书),导致一直超时。 - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "OpenLess Remote Input"); - dn.push(DnType::OrganizationName, "OpenLess"); - params.distinguished_name = dn; - params.key_usages.push(KeyUsagePurpose::DigitalSignature); - params - .extended_key_usages - .push(ExtendedKeyUsagePurpose::ServerAuth); - let key_pair = KeyPair::generate().map_err(|e| format!("rcgen keypair: {e}"))?; - let cert = params - .self_signed(&key_pair) - .map_err(|e| format!("rcgen self_signed: {e}"))?; - (cert.der().as_ref().to_vec(), key_pair.serialize_der()) - }; - if let Some(dir) = dir { - let _ = std::fs::create_dir_all(dir); - let _ = std::fs::write(dir.join(CERT_FILE), &cert_der); - let _ = std::fs::write(dir.join(KEY_FILE), &key_der); - // 私钥收紧为 0600:app 配置目录通常已是用户私有,但多用户/共享主机上 - // 默认 umask 可能给到组/其他用户可读。Windows 下 %APPDATA% 的 ACL - // 本身仅限本用户,无对应权限位可设。 - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - dir.join(KEY_FILE), - std::fs::Permissions::from_mode(0o600), - ); - } - let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n")); - log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})"); - } - Ok(( - cert_der, - PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), - )) -} - -fn build_server_config( - cert_der: Vec, - key_der: rustls::pki_types::PrivateKeyDer<'static>, -) -> Result, String> { - let provider = Arc::new(rustls::crypto::ring::default_provider()); - let config = rustls::ServerConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(|e| format!("tls protocol: {e}"))? - .with_no_client_auth() - .with_single_cert( - vec![rustls::pki_types::CertificateDer::from(cert_der)], - key_der, - ) - .map_err(|e| format!("tls cert: {e}"))?; - Ok(Arc::new(config)) -} +mod tls_identity; // ───────────────────────── 启动 ───────────────────────── struct WsState { backend: Arc, - /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 + /// Public CA certificate offered for phone installation; never the server leaf or key. cert_der: Vec, /// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。 conn_shutdown_rx: tokio::sync::watch::Receiver, @@ -336,10 +244,13 @@ fn build_router(state: Arc) -> Router { "/cert.cer", get(|State(state): State>| async move { ( - [( - axum::http::header::CONTENT_TYPE, - "application/x-x509-ca-cert", - )], + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-x509-ca-cert", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], state.cert_der.clone(), ) }), @@ -372,53 +283,17 @@ async fn index_handler(State(state): State>) -> impl IntoResponse { ) } -/// 极简标准 base64:构造 .mobileconfig 时把证书 DER 编码进 XML,避免引入额外依赖。 -fn base64_encode(data: &[u8]) -> String { - const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity((data.len() + 2) / 3 * 4); - for chunk in data.chunks(3) { - let b0 = chunk[0]; - let b1 = *chunk.get(1).unwrap_or(&0); - let b2 = *chunk.get(2).unwrap_or(&0); - out.push(T[(b0 >> 2) as usize] as char); - out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); - out.push(if chunk.len() > 1 { - T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char - } else { - '=' - }); - out.push(if chunk.len() > 2 { - T[(b2 & 0x3f) as usize] as char - } else { - '=' - }); - } - out -} - -/// iOS 配置描述文件:把证书包成 .mobileconfig。Safari 点击后凭 content-type -/// (application/x-apple-aspen-config) 直接进入“安装描述文件”流程,比裸 .cer 顺滑、 -/// 也不会把当前页面导航走。安装后仍需到「设置→通用→关于本机→证书信任设置」打开完全信任。 -/// -/// 安全边界:PayloadType `com.apple.security.root` 只是 iOS 安装证书的固定入口, -/// 证书本身是非 CA 的纯服务器证书(rcgen NoCa + EKU=ServerAuth,见 -/// load_or_generate_cert)——不含签发能力,无法用来给其他域名签证书做 MITM。 -/// 信任它的影响范围仅限「持有本机私钥者可冒充 SAN 里列出的本机局域网 IP」, -/// 私钥只存在用户 PC 的应用配置目录。设置页 certTrustWarning 同步向用户说明。 +// iOS still requires the user to install the profile and enable full trust. async fn mobileconfig_handler(State(state): State>) -> impl IntoResponse { - let b64 = base64_encode(&state.cert_der); - let xml = format!( - r#" - -PayloadContentPayloadCertificateFileNameopenless.cerPayloadContent{b64}PayloadTypecom.apple.security.rootPayloadIdentifiercom.openless.remote-input.certPayloadUUIDA1B2C3D4-0001-4000-8000-000000000001PayloadVersion1PayloadDisplayNameOpenLess Remote Input CertificatePayloadDisplayNameOpenLess Remote InputPayloadIdentifiercom.openless.remote-inputPayloadTypeConfigurationPayloadUUIDA1B2C3D4-0002-4000-8000-000000000002PayloadVersion1"#, - b64 = b64 - ); ( - [( - axum::http::header::CONTENT_TYPE, - "application/x-apple-aspen-config", - )], - xml, + [ + ( + axum::http::header::CONTENT_TYPE, + "application/x-apple-aspen-config", + ), + (axum::http::header::CACHE_CONTROL, "no-store"), + ], + tls_identity::mobileconfig(&state.cert_der), ) } @@ -428,11 +303,15 @@ pub async fn start(cfg: RemoteServerConfig) -> Result, + ca_key: Vec, + leaf_cert: Vec, + leaf_key: Vec, +} + +pub(super) struct TlsIdentity { + /// Only this public CA certificate is offered for phone installation. + pub trust_cert: Vec, + pub server_config: Arc, +} + +fn key_der(bytes: Vec) -> PrivateKeyDer<'static> { + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(bytes)) +} + +fn matching_key(cert: &[u8], key: &[u8]) -> Result<(), String> { + rustls::sign::CertifiedKey::from_der( + vec![CertificateDer::from(cert.to_vec())], + key_der(key.to_vec()), + &rustls::crypto::ring::default_provider(), + ) + .map(|_| ()) + .map_err(|error| format!("remote TLS certificate/key mismatch: {error}")) +} + +fn parse_cert(cert: &[u8]) -> Result { + CertificateParams::from_ca_cert_der(&CertificateDer::from(cert)) + .map_err(|error| format!("invalid remote TLS certificate: {error}")) +} + +fn read_identity(path: &Path) -> Result, String> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("cannot inspect remote TLS identity: {error}")), + }; + if !metadata.is_file() || metadata.len() > MAX_IDENTITY_BYTES { + return Err("remote TLS identity must be a regular file under 64 KiB".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("cannot protect remote TLS identity: {error}"))?; + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .and_then(|file| file.take(MAX_IDENTITY_BYTES + 1).read_to_end(&mut bytes)) + .map_err(|error| format!("cannot read remote TLS identity: {error}"))?; + if bytes.len() as u64 > MAX_IDENTITY_BYTES { + return Err("remote TLS identity exceeds 64 KiB".into()); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| "remote TLS identity is damaged; restore it from backup instead of replacing phone trust".into()) +} + +fn save_identity( + path: &Path, + identity: &StoredIdentity, + first_install: bool, +) -> Result<(), String> { + let directory = path.parent().ok_or("remote TLS directory is unavailable")?; + let bytes = serde_json::to_vec(identity).map_err(|error| error.to_string())?; + // tempfile creates private files (0600 on Unix). Windows inherits the user's + // application-data ACL. Keep the key and certificates in one atomic bundle. + let mut file = tempfile::NamedTempFile::new_in(directory) + .map_err(|error| format!("cannot create remote TLS identity: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.as_file().sync_all()) + .map_err(|error| format!("cannot write remote TLS identity: {error}"))?; + if first_install { + file.persist_noclobber(path) + } else { + file.persist(path) + } + .map_err(|error| format!("cannot persist remote TLS identity: {error}"))?; + Ok(()) +} + +fn create_ca(now: OffsetDateTime) -> Result { + let mut params = CertificateParams::default(); + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "OpenLess Remote Input CA"); + name.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = name; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params.not_before = now - Duration::days(1); + params.not_after = now + Duration::days(3650); + let key = KeyPair::generate().map_err(|error| error.to_string())?; + let cert = params + .self_signed(&key) + .map_err(|error| error.to_string())?; + Ok(StoredIdentity { + ca_cert: cert.der().to_vec(), + ca_key: key.serialize_der(), + leaf_cert: Vec::new(), + leaf_key: Vec::new(), + }) +} + +fn valid_leaf(identity: &StoredIdentity, sans: &[String], now: OffsetDateTime) -> bool { + let Ok(params) = parse_cert(&identity.leaf_cert) else { + return false; + }; + let Ok(required) = CertificateParams::new(sans.to_vec()) else { + return false; + }; + if !matches!(params.is_ca, IsCa::NoCa | IsCa::ExplicitNoCa) + || params.not_before > now + || params.not_after < now + Duration::days(30) + || !required + .subject_alt_names + .iter() + .all(|san| params.subject_alt_names.contains(san)) + || matching_key(&identity.leaf_cert, &identity.leaf_key).is_err() + { + return false; + } + // Validate the cached leaf against the stored trust anchor, not a sidecar + // list of SANs. A stale or mismatched leaf must never reach the listener. + use rustls::client::danger::ServerCertVerifier; + let mut roots = rustls::RootCertStore::empty(); + if roots + .add(CertificateDer::from(identity.ca_cert.clone())) + .is_err() + { + return false; + } + let Ok(verifier) = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + Arc::new(rustls::crypto::ring::default_provider()), + ) + .build() else { + return false; + }; + let Some(name) = sans + .first() + .and_then(|name| rustls::pki_types::ServerName::try_from(name.clone()).ok()) + else { + return false; + }; + let Ok(timestamp) = u64::try_from(now.unix_timestamp()) else { + return false; + }; + verifier + .verify_server_cert( + &CertificateDer::from(identity.leaf_cert.as_slice()), + &[], + &name, + &[], + rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs( + timestamp, + )), + ) + .is_ok() +} + +pub(super) fn load_or_create(directory: &Path, sans: &[String]) -> Result { + load_at(directory, sans, OffsetDateTime::now_utc()) +} + +fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result { + if sans.is_empty() { + return Err("remote TLS requires at least one server name".into()); + } + std::fs::create_dir_all(directory) + .map_err(|error| format!("cannot create remote TLS directory: {error}"))?; + let path = directory.join(IDENTITY_FILE); + let saved = read_identity(&path)?; + let first_install = saved.is_none(); + let mut identity = match saved { + Some(identity) => identity, + None => create_ca(now)?, + }; + matching_key(&identity.ca_cert, &identity.ca_key)?; + let (remainder, ca_x509) = x509_parser::parse_x509_certificate(&identity.ca_cert) + .map_err(|_| "invalid remote TLS CA encoding")?; + if !remainder.is_empty() + || ca_x509.issuer() != ca_x509.subject() + || ca_x509.verify_signature(None).is_err() + { + return Err("remote TLS CA signature is invalid; restore the identity from backup".into()); + } + let ca_params = parse_cert(&identity.ca_cert)?; + if !matches!(ca_params.is_ca, IsCa::Ca(_)) + || !ca_params.key_usages.contains(&KeyUsagePurpose::KeyCertSign) + || ca_params.not_before > now + || ca_params.not_after <= now + Duration::days(30) + { + return Err("remote TLS CA is invalid or expires soon; restore a valid identity or explicitly reset it and trust the new certificate on each phone".into()); + } + if !valid_leaf(&identity, sans, now) { + let ca_key = KeyPair::try_from(&key_der(identity.ca_key.clone())) + .map_err(|error| error.to_string())?; + let ca_expiry = ca_params.not_after; + let ca = ca_params + .self_signed(&ca_key) + .map_err(|error| error.to_string())?; + let key = KeyPair::generate().map_err(|error| error.to_string())?; + let mut params = + CertificateParams::new(sans.to_vec()).map_err(|error| error.to_string())?; + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "OpenLess Remote Input"); + name.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = name; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params.not_before = now - Duration::days(1); + params.not_after = (now + Duration::days(365)).min(ca_expiry); + params.use_authority_key_identifier_extension = true; + let cert = params + .signed_by(&key, &ca, &ca_key) + .map_err(|error| error.to_string())?; + identity.leaf_cert = cert.der().to_vec(); + identity.leaf_key = key.serialize_der(); + if !valid_leaf(&identity, sans, now) { + return Err("generated remote TLS certificate failed validation".into()); + } + save_identity(&path, &identity, first_install)?; + } + let config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|error| error.to_string())? + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(identity.leaf_cert)], + key_der(identity.leaf_key), + ) + .map_err(|error| format!("remote TLS config: {error}"))?; + Ok(TlsIdentity { + trust_cert: identity.ca_cert, + server_config: Arc::new(config), + }) +} + +/// 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 b64 = base64::engine::general_purpose::STANDARD.encode(cert); + format!( + r#" + + +PayloadContent +PayloadCertificateFileNameopenless-ca.cer +PayloadContent{b64} +PayloadTypecom.apple.security.root +PayloadIdentifiercom.openless.remote-input.{fingerprint}.cert +PayloadUUID{cert_uuid} +PayloadVersion1 +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. +PayloadIdentifiercom.openless.remote-input.{fingerprint} +PayloadTypeConfiguration +PayloadUUID{profile_uuid} +PayloadVersion1 +"#, + short = &fingerprint[..8], + cert_uuid = uuid::Uuid::new_v4(), + profile_uuid = uuid::Uuid::new_v4(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names() -> Vec { + vec!["localhost".into(), "127.0.0.1".into(), "192.168.1.2".into()] + } + + fn stored(directory: &Path) -> StoredIdentity { + read_identity(&directory.join(IDENTITY_FILE)) + .unwrap() + .unwrap() + } + + fn verify_server(identity: &TlsIdentity, trusted_ca: &[u8], name: &str) -> bool { + let mut roots = rustls::RootCertStore::empty(); + roots + .add(CertificateDer::from(trusted_ca.to_vec())) + .unwrap(); + let client_config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let mut client = rustls::ClientConnection::new( + Arc::new(client_config), + name.to_owned().try_into().unwrap(), + ) + .unwrap(); + let mut server = + rustls::ServerConnection::new(Arc::clone(&identity.server_config)).unwrap(); + for _ in 0..10 { + let mut bytes = Vec::new(); + client.write_tls(&mut bytes).unwrap(); + server.read_tls(&mut bytes.as_slice()).unwrap(); + if server.process_new_packets().is_err() { + return false; + } + bytes.clear(); + server.write_tls(&mut bytes).unwrap(); + client.read_tls(&mut bytes.as_slice()).unwrap(); + if client.process_new_packets().is_err() { + return false; + } + if !client.is_handshaking() && !server.is_handshaking() { + return true; + } + } + false + } + + #[test] + fn restart_reuses_identity_and_tls_works_with_only_the_downloaded_ca() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let before = std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap(); + let second = load_or_create(dir.path(), &names()).unwrap(); + assert_eq!( + before, + std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() + ); + assert_eq!(first.trust_cert, second.trust_cert); + for name in names() { + assert!(verify_server(&second, &first.trust_cert, &name)); + } + assert!(!verify_server(&second, &first.trust_cert, "192.168.1.99")); + let saved = stored(dir.path()); + let ca = parse_cert(&saved.ca_cert).unwrap(); + let leaf = parse_cert(&saved.leaf_cert).unwrap(); + assert!(matches!( + ca.is_ca, + IsCa::Ca(BasicConstraints::Constrained(0)) + )); + assert!(matches!(leaf.is_ca, IsCa::NoCa | IsCa::ExplicitNoCa)); + assert!(leaf + .extended_key_usages + .contains(&ExtendedKeyUsagePurpose::ServerAuth)); + assert!(leaf.not_after - leaf.not_before <= Duration::days(366)); + } + + #[test] + fn virtual_adapter_and_lan_changes_preserve_phone_trust() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let before = stored(dir.path()); + let mut changed = names(); + changed.push("172.26.112.1".into()); + changed.push("192.168.2.3".into()); + let second = load_or_create(dir.path(), &changed).unwrap(); + let after = stored(dir.path()); + assert_eq!(before.ca_cert, after.ca_cert); + assert_eq!(before.ca_key, after.ca_key); + 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. + load_or_create(dir.path(), &names()).unwrap(); + assert_eq!(after.leaf_cert, stored(dir.path()).leaf_cert); + } + + #[test] + fn renews_expiring_leaf_without_rotating_ca() { + let dir = tempfile::tempdir().unwrap(); + let now = OffsetDateTime::now_utc(); + load_at(dir.path(), &names(), now).unwrap(); + let before = stored(dir.path()); + load_at(dir.path(), &names(), now + Duration::days(340)).unwrap(); + let after = stored(dir.path()); + assert_eq!(before.ca_cert, after.ca_cert); + assert_ne!(before.leaf_cert, after.leaf_cert); + assert!(parse_cert(&after.leaf_cert).unwrap().not_after > now + Duration::days(700)); + } + + #[test] + fn repairs_bad_leaf_but_never_replaces_damaged_or_mismatched_ca() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let path = dir.path().join(IDENTITY_FILE); + let mut saved = stored(dir.path()); + saved.leaf_cert = vec![0]; + save_identity(&path, &saved, false).unwrap(); + let repaired = load_or_create(dir.path(), &names()).unwrap(); + assert!(verify_server(&repaired, &first.trust_cert, "localhost")); + saved = stored(dir.path()); + let mut damaged = stored(dir.path()); + *damaged.ca_cert.last_mut().unwrap() ^= 1; + save_identity(&path, &damaged, false).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!(damaged.ca_cert, stored(dir.path()).ca_cert); + saved.ca_key = KeyPair::generate().unwrap().serialize_der(); + save_identity(&path, &saved, false).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!(before, std::fs::read(&path).unwrap()); + std::fs::write(&path, b"interrupted write").unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!( + b"interrupted write", + std::fs::read(&path).unwrap().as_slice() + ); + } + + #[test] + fn refuses_expired_ca_instead_of_silently_revoking_trust() { + let dir = tempfile::tempdir().unwrap(); + let now = OffsetDateTime::now_utc(); + load_at(dir.path(), &names(), now).unwrap(); + let before = std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap(); + assert!(load_at(dir.path(), &names(), now + Duration::days(3651)).is_err()); + assert_eq!( + before, + std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() + ); + } + + #[test] + fn migration_keeps_legacy_files_and_profiles_only_contain_public_ca() { + use base64::Engine; + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("remote-cert-v4.der"), b"legacy cert").unwrap(); + std::fs::write(dir.path().join("remote-key-v4.der"), b"legacy key").unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let profile = mobileconfig(&first.trust_cert); + let saved = stored(dir.path()); + let encoded = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + assert!(profile.contains(&format!("{}", encoded(&first.trust_cert)))); + assert!(!profile.contains(&encoded(&saved.ca_key))); + assert!(!profile.contains(&encoded(&saved.leaf_key))); + assert!(!profile.contains(&encoded(&saved.leaf_cert))); + assert_eq!( + std::fs::read(dir.path().join("remote-cert-v4.der")).unwrap(), + b"legacy cert" + ); + assert_eq!( + std::fs::read(dir.path().join("remote-key-v4.der")).unwrap(), + b"legacy key" + ); + let other = tempfile::tempdir().unwrap(); + let second = load_or_create(other.path(), &names()).unwrap(); + assert_ne!(first.trust_cert, second.trust_cert); + assert!(!verify_server(&second, &first.trust_cert, "localhost")); + assert_ne!(profile, mobileconfig(&second.trust_cert)); + } + + #[test] + fn persistence_errors_never_fall_back_to_an_ephemeral_identity() { + let dir = tempfile::tempdir().unwrap(); + let blocked = dir.path().join("blocked"); + std::fs::write(&blocked, b"not a directory").unwrap(); + assert!(load_or_create(&blocked, &names()).is_err()); + std::fs::create_dir(dir.path().join(IDENTITY_FILE)).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert!(load_or_create(dir.path(), &[]).is_err()); + } + + #[cfg(unix)] + #[test] + fn stored_private_keys_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + load_or_create(dir.path(), &names()).unwrap(); + assert_eq!( + std::fs::metadata(dir.path().join(IDENTITY_FILE)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } +} diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b41cfd459..9f4c6466d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1395,9 +1395,9 @@ 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: 'On first visit the browser warns the certificate is untrusted — choose "Proceed".', - certTrustWarning: - 'The certificate is only used by this PC’s remote input service (it cannot issue other certificates). Never trust certificates from unknown sources; remove it from your phone’s settings when no longer needed.', + 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.", + certTrustWarning: "Only install the OpenLess root certificate from your own computer. It can issue certificates and its private key stays on that 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.', starting: 'Starting the remote input service…', urlsStale: 'These addresses come from the previous run and may be out of date.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 6d11101fa..2f07ccafd 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1363,9 +1363,9 @@ export const ja: typeof zhCN = { portInUse: 'ポート {{port}} は使用中です。変更してください', startError: 'リモート入力サービスの起動に失敗しました:{{reason}}', securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。', - certHint: '初回アクセス時、ブラウザが証明書は信頼されていないと警告します。案内に従って「続行」を選択してください。', - certTrustWarning: - 'この証明書は本機のリモート入力サービス専用です(他の証明書を発行できません)。出所不明の証明書は信頼しないでください。不要になったらスマートフォンの設定から削除できます。', + certHint: "スマートフォンの「初回設定:このコンピュータを信頼」で証明書をインストールし信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。", + certTrustWarning: "自分のコンピュータの OpenLess ルート証明書のみをインストールしてください。証明書を発行できる秘密鍵はそのコンピュータに保存されます。不要になったらスマートフォンから削除してください。", + certSetupLink: "iPhone 証明書リンクをコピー", waitingStart: 'サービスはまだ起動していません。スイッチを一度オフにしてからオンにしてください。アプリを再起動しないでください。', starting: 'リモート入力サービスを起動しています…', urlsStale: 'これらのアドレスは前回の起動時のもので、古くなっている可能性があります。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f35c8425f..397b55d50 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1363,9 +1363,9 @@ export const ko: typeof zhCN = { portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요', startError: '원격 입력 서비스 시작에 실패했습니다: {{reason}}', securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.', - certHint: '첫 접속 시 브라우저가 인증서를 신뢰할 수 없다고 경고합니다. 안내에 따라 "계속 진행"을 선택하세요.', - certTrustWarning: - '이 인증서는 이 PC의 원격 입력 서비스 전용입니다(다른 인증서를 발급할 수 없음). 출처를 알 수 없는 인증서는 신뢰하지 마세요. 더 이상 사용하지 않으면 휴대폰 설정에서 제거할 수 있습니다.', + certHint: "휴대폰에서 “최초 설정: 이 컴퓨터 신뢰”를 열고 인증서를 설치하고 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.", + certTrustWarning: "본인 컴퓨터의 OpenLess 루트 인증서만 설치하세요. 이 인증서는 다른 인증서를 발급할 수 있으며 개인 키는 해당 컴퓨터에 저장됩니다. 더 이상 필요하지 않으면 휴대폰에서 제거하세요.", + certSetupLink: "iPhone 인증서 링크 복사", waitingStart: '서비스가 아직 시작되지 않았습니다. 스위치를 끈 다음 다시 켜세요. 앱을 다시 시작하지 마세요.', starting: '원격 입력 서비스를 시작하는 중입니다…', urlsStale: '이 주소는 이전 실행에서 가져온 것으로 최신이 아닐 수 있습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 88a69f24f..63a9f6ea6 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1393,9 +1393,9 @@ export const zhCN = { portInUse: '端口 {{port}} 被占用,请更换', startError: '远程输入服务启动失败:{{reason}}', securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。', - certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。', - certTrustWarning: - '该证书仅用于本机远程输入服务(不能签发其他证书),请勿信任来源不明的证书;不再使用时可在手机系统设置中移除。', + certHint: "首次连接请在手机页面展开“首次设置:信任此电脑”,安装并信任证书。升级旧版需设置一次;以后重启和换 IP 会保留信任。", + certTrustWarning: "只安装你自己电脑提供的 OpenLess 根证书。它具备签发证书的能力,私钥保存在这台电脑;不再使用时请从手机移除。", + certSetupLink: "复制 iPhone 证书链接", waitingStart: '服务尚未启动。请关闭开关再打开一次,不要重启软件。', starting: '正在启动远程输入服务…', urlsStale: '这些地址来自上次运行,可能已经过期。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 905fd5d24..f6e06aa7d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1361,9 +1361,9 @@ export const zhTW: typeof zhCN = { portInUse: '連接埠 {{port}} 被佔用,請更換', startError: '遠端輸入服務啟動失敗:{{reason}}', securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。', - certHint: '首次存取瀏覽器會提示憑證不受信任,按提示選擇「繼續存取」。', - certTrustWarning: - '該憑證僅用於本機遠端輸入服務(無法簽發其他憑證),請勿信任來源不明的憑證;不再使用時可在手機系統設定中移除。', + certHint: "首次連線請在手機頁面展開「首次設定:信任這台電腦」,安裝並信任憑證。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。", + certTrustWarning: "只安裝你自己電腦提供的 OpenLess 根憑證。它能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。", + certSetupLink: "複製 iPhone 憑證連結", waitingStart: '服務尚未啟動。請關閉開關再打開一次,不要重啟軟體。', starting: '正在啟動遠端輸入服務…', urlsStale: '這些地址來自上次執行,可能已經過期。', diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index e43369157..d3c38cab2 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -205,7 +205,7 @@ export function RemoteInputSection() { {status.urls.map((u) => (
{copied === u ? '✓' : '⧉'} +
))}