fix(remote-input): preserve phone trust across restarts and IP changes (#1037)

* fix(remote-input): preserve phone trust with a persistent local CA

* fix(remote-input): disclose LAN certificate MITM risk

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
zhou zhichaoandChris233 authored and GitHub committed 2026-09-10 06:36:12 +08:00
1 parent 7584091464
commit 882fac63f0
21 files changed
+1148 -275

No files matched your search

+3
View File
@@ -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:
+92
View File
@@ -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.
+112
View File
@@ -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"
+6 -1
View File
@@ -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"] }
+28 -71
View File
@@ -289,71 +289,8 @@ fn access_urls(port: u16) -> Vec<String> {
}
#[cfg(target_os = "linux")]
fn load_or_generate_certificate(
directory: &std::path::Path,
sans: &[String],
) -> Result<(Vec<u8>, 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::<std::collections::HashSet<_>>();
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<u8>,
key: rustls::pki_types::PrivateKeyDer<'static>,
) -> Result<Arc<rustls::ServerConfig>, 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<WebState>) -> Router {
@@ -387,14 +324,32 @@ fn router(state: Arc<WebState>) -> Router {
"/cert.cer",
get(|State(state): State<Arc<WebState>>| 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<Arc<WebState>>| 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<LinuxRemoteServerHandle, BackendError> {
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}")))?;
+94 -1
View File
@@ -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"
@@ -4260,7 +4322,9 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"thiserror 1.0.69",
"time",
"tokenizers",
"tokio",
"tokio-rustls",
@@ -4272,6 +4336,7 @@ dependencies = [
"window-vibrancy 0.7.1",
"windows 0.58.0",
"winreg 0.52.0",
"x509-parser",
"zip 2.4.2",
]
@@ -5034,6 +5099,7 @@ dependencies = [
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
@@ -5331,6 +5397,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"
@@ -6769,7 +6844,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",
@@ -8886,6 +8961,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"
+4 -1
View File
@@ -81,7 +81,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"] }
+184
View File
@@ -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"
@@ -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"] }
@@ -0,0 +1,3 @@
// Exercise the production TLS implementation without linking the desktop UI.
#[path = "../../src/remote_server/tls_identity.rs"]
mod tls_identity;
@@ -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: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。",
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: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。",
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: "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.",
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: "初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。",
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: "최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.",
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);
@@ -35,16 +35,18 @@
<button id="btn-connect" class="btn btn-primary" data-i18n="btnConnect">连接</button>
</div>
<!-- 连接帮助:证书信任引导(手机上直接照做)。暂时隐藏(用户要求),需要时去掉 hidden 即可恢复。 -->
<div class="help" hidden>
<div class="help-title" data-i18n="helpTitle">连不上?多半是手机没信任证书</div>
<p class="help-step" data-i18n="helpAndroid">① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。</p>
<p class="help-step" data-i18n="helpIos">② iOS Safari 必须装证书:点下方按钮下载并安装描述文件,然后到「设置 → 通用 → 关于本机 → 证书信任设置」里打开它的“完全信任”,再回来连接。</p>
<!-- Keep setup discoverable without obscuring the pairing form. -->
<details class="help">
<summary class="help-title" data-i18n="helpTitle">首次设置:信任此电脑</summary>
<p class="help-step" data-i18n="helpIos">iPhone / iPad:下载描述文件,打开“设置 → 通用 → VPN 与设备管理”安装;再到“通用 → 关于本机 → 证书信任设置”,为 OpenLess Remote Input CA 打开“完全信任”,返回 Safari 刷新。每台电脑只需设置一次;旧版证书需要重新安装。</p>
<p class="help-step" data-i18n="helpAndroid">安卓:下载 CA 证书,在系统设置的“安装证书 → CA 证书”中安装,再回到本页。菜单名称因设备而异。</p>
<p class="help-step" data-i18n="helpTrustWarning">只安装你自己电脑提供的 OpenLess 根证书。它具备签发证书的能力,私钥保存在这台电脑;不再使用时请从手机移除。</p>
<div class="help-actions">
<a class="help-link" href="/cert.cer" data-i18n="helpDownloadCert">⬇ 下载并安装证书</a>
<a class="help-link" href="/cert.mobileconfig" data-i18n="helpDownloadCert">↓ iPhone:下载描述文件</a>
<a class="help-link help-link-ghost" href="/cert.cer" data-i18n="helpDownloadAndroid">↓ 安卓:下载 CA 证书</a>
<button type="button" class="help-link help-link-ghost" id="copy-cert-link" data-i18n="helpCopyLink">⧉ 复制链接</button>
</div>
</div>
</details>
</section>
<!-- ===== 录音屏 ===== -->
@@ -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);
@@ -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<String> {
// ───────────────────────── TLS ─────────────────────────
/// 自签名证书:持久化到磁盘并跨重启复用。否则每次启动证书都变 —— 手机(尤其 iOS
/// Safari)上一次信任过的证书立刻失效,wss 握手静默挂起,表现为"连接中"卡死。仅当
/// 磁盘无证书 / 解析失败 / 当前局域网 IP 不在已存 SAN 列表里(换了网络)时才重新生成。
/// 返回 (证书 DER 原始字节, 私钥)。
fn load_or_generate_cert(
dir: Option<&std::path::Path>,
sans: &[String],
) -> Result<(Vec<u8>, 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<u8>,
key_der: rustls::pki_types::PrivateKeyDer<'static>,
) -> Result<Arc<rustls::ServerConfig>, 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<openless_core::OpenLessBackend>,
/// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。
/// Public CA certificate offered for phone installation; never the server leaf or key.
cert_der: Vec<u8>,
/// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。
conn_shutdown_rx: tokio::sync::watch::Receiver<bool>,
@@ -336,10 +244,13 @@ fn build_router(state: Arc<WsState>) -> Router {
"/cert.cer",
get(|State(state): State<Arc<WsState>>| 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<Arc<WsState>>) -> 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<Arc<WsState>>) -> impl IntoResponse {
let b64 = base64_encode(&state.cert_der);
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>PayloadContent</key><array><dict><key>PayloadCertificateFileName</key><string>openless.cer</string><key>PayloadContent</key><data>{b64}</data><key>PayloadType</key><string>com.apple.security.root</string><key>PayloadIdentifier</key><string>com.openless.remote-input.cert</string><key>PayloadUUID</key><string>A1B2C3D4-0001-4000-8000-000000000001</string><key>PayloadVersion</key><integer>1</integer><key>PayloadDisplayName</key><string>OpenLess Remote Input Certificate</string></dict></array><key>PayloadDisplayName</key><string>OpenLess Remote Input</string><key>PayloadIdentifier</key><string>com.openless.remote-input</string><key>PayloadType</key><string>Configuration</string><key>PayloadUUID</key><string>A1B2C3D4-0002-4000-8000-000000000002</string><key>PayloadVersion</key><integer>1</integer></dict></plist>"#,
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<RemoteServerHandle, String
for ip in local_lan_ipv4s() {
sans.push(ip.to_string());
}
// 证书目录用 app 配置目录(跨重启稳定);拿不到则退回内存生成(不持久化)。
let cert_dir = cfg.app.path().app_config_dir().ok();
let (cert_der, key_der) = load_or_generate_cert(cert_dir.as_deref(), &sans)?;
let rustls_config = build_server_config(cert_der.clone(), key_der)?;
let acceptor = TlsAcceptor::from(rustls_config);
// Trust must survive restarts and upgrades; refuse ephemeral identities.
let cert_dir = cfg
.app
.path()
.app_config_dir()
.map_err(|error| format!("remote TLS config directory: {error}"))?;
let identity = tls_identity::load_or_create(&cert_dir, &sans)?;
let cert_der = identity.trust_cert;
let acceptor = TlsAcceptor::from(identity.server_config);
let addr = SocketAddr::from(([0, 0, 0, 0], cfg.port));
let listener = TcpListener::bind(addr).await.map_err(|e| {
@@ -0,0 +1,506 @@
//! A persistent, per-installation CA and a separate short-lived TLS leaf.
//! Phones trust the CA once; changes to LAN or virtual-adapter IPs only reissue
//! the leaf. Never silently replace an unreadable CA: that revokes phone trust.
use std::io::{Read, Write};
use std::path::Path;
use std::sync::Arc;
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa,
KeyPair, KeyUsagePurpose,
};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
const IDENTITY_FILE: &str = "remote-tls-identity-v1.json";
const MAX_IDENTITY_BYTES: u64 = 64 * 1024;
#[derive(Serialize, Deserialize)]
struct StoredIdentity {
ca_cert: Vec<u8>,
ca_key: Vec<u8>,
leaf_cert: Vec<u8>,
leaf_key: Vec<u8>,
}
pub(super) struct TlsIdentity {
/// Only this public CA certificate is offered for phone installation.
pub trust_cert: Vec<u8>,
pub server_config: Arc<rustls::ServerConfig>,
}
fn key_der(bytes: Vec<u8>) -> 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, String> {
CertificateParams::from_ca_cert_der(&CertificateDer::from(cert))
.map_err(|error| format!("invalid remote TLS certificate: {error}"))
}
fn read_identity(path: &Path) -> Result<Option<StoredIdentity>, 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<StoredIdentity, String> {
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<TlsIdentity, String> {
load_at(directory, sans, OffsetDateTime::now_utc())
}
fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result<TlsIdentity, String> {
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#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>PayloadContent</key><array><dict>
<key>PayloadCertificateFileName</key><string>openless-ca.cer</string>
<key>PayloadContent</key><data>{b64}</data>
<key>PayloadType</key><string>com.apple.security.root</string>
<key>PayloadIdentifier</key><string>com.openless.remote-input.{fingerprint}.cert</string>
<key>PayloadUUID</key><string>{cert_uuid}</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadDisplayName</key><string>OpenLess Remote Input CA ({short})</string>
</dict></array>
<key>PayloadDisplayName</key><string>OpenLess Remote Input ({short})</string>
<key>PayloadDescription</key><string>Trust only a profile downloaded from your own computer. After installation, enable full trust in Settings &gt; General &gt; About &gt; Certificate Trust Settings. This CA can issue certificates; remove this profile when you stop using remote input.</string>
<key>PayloadIdentifier</key><string>com.openless.remote-input.{fingerprint}</string>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadUUID</key><string>{profile_uuid}</string>
<key>PayloadVersion</key><integer>1</integer>
</dict></plist>"#,
short = &fingerprint[..8],
cert_uuid = uuid::Uuid::new_v4(),
profile_uuid = uuid::Uuid::new_v4(),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn names() -> Vec<String> {
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!("<data>{}</data>", 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
);
}
}
+3 -3
View File
@@ -1435,9 +1435,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: "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.',
starting: 'Starting the remote input service…',
urlsStale: 'These addresses come from the previous run and may be out of date.',
+3 -3
View File
@@ -1395,9 +1395,9 @@ export const ja: typeof zhCN = {
portInUse: 'ポート {{port}} は使用中です。変更してください',
startError: 'リモート入力サービスの起動に失敗しました:{{reason}}',
securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。',
certHint: '初回アクセス時、ブラウザが証明書は信頼されていないと警告します。案内に従って「続行」を選択してください。',
certTrustWarning:
'この証明書は本機のリモート入力サービス専用です(他の証明書を発行できません)。出所不明の証明書は信頼しないでください。不要になったらスマートフォンの設定から削除できます。',
certHint: "スマートフォンの「初回設定:このコンピュータを信頼」で証明書をインストールし信頼してください。旧バージョンからは一度設定が必要ですが、その後は再起動や IP 変更でも信頼が保持されます。",
certTrustWarning: "初回の証明書ダウンロードではコンピューターの身元を確認できず、LAN 上の悪意あるデバイスが中間者攻撃でルート証明書を置き換える可能性があります。信頼できる家庭内またはプライベートネットワークでのみインストールし、公共または共有ネットワークでは操作しないでください。ルート CA は証明書を発行でき、秘密鍵はこのコンピューターに保存されます。不要になったらスマートフォンから削除してください。",
certSetupLink: "iPhone 証明書リンクをコピー",
waitingStart: 'サービスはまだ起動していません。スイッチを一度オフにしてからオンにしてください。アプリを再起動しないでください。',
starting: 'リモート入力サービスを起動しています…',
urlsStale: 'これらのアドレスは前回の起動時のもので、古くなっている可能性があります。',
+3 -3
View File
@@ -1395,9 +1395,9 @@ export const ko: typeof zhCN = {
portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요',
startError: '원격 입력 서비스 시작에 실패했습니다: {{reason}}',
securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.',
certHint: '첫 접속 시 브라우저가 인증서를 신뢰할 수 없다고 경고합니다. 안내에 따라 "계속 진행"을 선택하세요.',
certTrustWarning:
'이 인증서는 이 PC의 원격 입력 서비스 전용입니다(다른 인증서를 발급할 수 없음). 출처를 알 수 없는 인증서는 신뢰하지 마세요. 더 이상 사용하지 않으면 휴대폰 설정에서 제거할 수 있습니다.',
certHint: "휴대폰에서 “최초 설정: 이 컴퓨터 신뢰”를 열고 인증서를 설치하고 신뢰하세요. 이전 버전에서는 한 번 설정해야 하며, 이후 재시작과 IP 변경 시 신뢰가 유지됩니다.",
certTrustWarning: "최초 인증서 다운로드에서는 컴퓨터의 신원을 확인할 수 없으며, LAN의 악성 기기가 중간자 공격으로 루트 인증서를 바꿀 수 있습니다. 신뢰할 수 있는 가정용 또는 사설 네트워크에서만 설치하고 공용 또는 공유 네트워크에서는 진행하지 마세요. 루트 CA는 인증서를 발급할 수 있고 개인 키는 이 컴퓨터에 저장됩니다. 더 이상 사용하지 않으면 휴대폰에서 제거하세요.",
certSetupLink: "iPhone 인증서 링크 복사",
waitingStart: '서비스가 아직 시작되지 않았습니다. 스위치를 끈 다음 다시 켜세요. 앱을 다시 시작하지 마세요.',
starting: '원격 입력 서비스를 시작하는 중입니다…',
urlsStale: '이 주소는 이전 실행에서 가져온 것으로 최신이 아닐 수 있습니다.',
+3 -3
View File
@@ -1433,9 +1433,9 @@ export const zhCN = {
portInUse: '端口 {{port}} 被占用,请更换',
startError: '远程输入服务启动失败:{{reason}}',
securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。',
certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。',
certTrustWarning:
'该证书仅用于本机远程输入服务(不能签发其他证书),请勿信任来源不明的证书;不再使用时可在手机系统设置中移除。',
certHint: "首次连接请在手机页面展开“首次设置:信任此电脑”,安装并信任证书。升级旧版需设置一次;以后重启和换 IP 会保留信任。",
certTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。",
certSetupLink: "复制 iPhone 证书链接",
waitingStart: '服务尚未启动。请关闭开关再打开一次,不要重启软件。',
starting: '正在启动远程输入服务…',
urlsStale: '这些地址来自上次运行,可能已经过期。',
+3 -3
View File
@@ -1401,9 +1401,9 @@ export const zhTW: typeof zhCN = {
portInUse: '連接埠 {{port}} 被佔用,請更換',
startError: '遠端輸入服務啟動失敗:{{reason}}',
securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。',
certHint: '首次存取瀏覽器會提示憑證不受信任,按提示選擇「繼續存取」。',
certTrustWarning:
'該憑證僅用於本機遠端輸入服務(無法簽發其他憑證),請勿信任來源不明的憑證;不再使用時可在手機系統設定中移除。',
certHint: "首次連線請在手機頁面展開「首次設定:信任這台電腦」,安裝並信任憑證。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。",
certTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。",
certSetupLink: "複製 iPhone 憑證連結",
waitingStart: '服務尚未啟動。請關閉開關再打開一次,不要重啟軟體。',
starting: '正在啟動遠端輸入服務…',
urlsStale: '這些地址來自上次執行,可能已經過期。',
@@ -205,7 +205,7 @@ export function RemoteInputSection() {
{status.urls.map((u) => (
<div
key={u}
style={{ display: 'flex', alignItems: 'center', gap: 8 }}
style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}
>
<span
style={{
@@ -224,6 +224,17 @@ export function RemoteInputSection() {
>
{copied === u ? '✓' : '⧉'}
</button>
<button
onClick={async () => {
const link = `${u}/cert.mobileconfig`;
await copyText(link);
setCopied(link);
window.setTimeout(() => setCopied((c) => c === link ? null : c), 1500);
}}
style={smallBtn}
>
{copied === `${u}/cert.mobileconfig` ? '✓' : t('settings.remoteInput.certSetupLink')}
</button>
</div>
))}
</div>