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:
1 parent
7584091464
commit
882fac63f0
21 files changed
+1148
-275
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
Generated
+112
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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}")))?;
|
||||
|
||||
Generated
+94
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 > General > About > 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: 'これらのアドレスは前回の起動時のもので、古くなっている可能性があります。',
|
||||
|
||||
@@ -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: '이 주소는 이전 실행에서 가져온 것으로 최신이 아닐 수 있습니다.',
|
||||
|
||||
@@ -1433,9 +1433,9 @@ export const zhCN = {
|
||||
portInUse: '端口 {{port}} 被占用,请更换',
|
||||
startError: '远程输入服务启动失败:{{reason}}',
|
||||
securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。',
|
||||
certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。',
|
||||
certTrustWarning:
|
||||
'该证书仅用于本机远程输入服务(不能签发其他证书),请勿信任来源不明的证书;不再使用时可在手机系统设置中移除。',
|
||||
certHint: "首次连接请在手机页面展开“首次设置:信任此电脑”,安装并信任证书。升级旧版需设置一次;以后重启和换 IP 会保留信任。",
|
||||
certTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。",
|
||||
certSetupLink: "复制 iPhone 证书链接",
|
||||
waitingStart: '服务尚未启动。请关闭开关再打开一次,不要重启软件。',
|
||||
starting: '正在启动远程输入服务…',
|
||||
urlsStale: '这些地址来自上次运行,可能已经过期。',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user