Use the browser bridge from kestrove-core; webpki security update
The broker, pairing channel, IPC and kestrove-nmh moved to kestrove-core so the macOS app serves the extension too (a Mac no longer needs this app). Old module paths are re-exports. rustls-webpki 0.103.16 and webpki-roots 1.0.10 (RUSTSEC-2026-0381/0382). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
directly. The web UI only renders and never touches keys. macOS uses the native `kestrove-apple` app.
|
directly. The web UI only renders and never touches keys. macOS uses the native `kestrove-apple` app.
|
||||||
|
|
||||||
Also ships `kestrove-nmh`, the **native-messaging host** that bridges `kestrove-browser` to the app.
|
Also ships `kestrove-nmh`, the **native-messaging host** that bridges `kestrove-browser` to the app.
|
||||||
|
On a Mac, the Kestrove Mac app serves the browser extension itself (Chrome, Edge, Brave, Vivaldi, Arc,
|
||||||
|
Firefox), so this app isn't needed there; it still builds on macOS for development.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/)
|
# prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/)
|
||||||
@@ -22,23 +24,27 @@ keeps working; Quit is in the tray menu.
|
|||||||
## Browser integration (docs/05)
|
## Browser integration (docs/05)
|
||||||
|
|
||||||
```
|
```
|
||||||
extension ─ native messaging (stdio) ─▶ kestrove-nmh ─ local IPC ─▶ app (src-tauri/src/broker.rs)
|
extension ─ native messaging (stdio) ─▶ kestrove-nmh ─ local IPC ─▶ app (broker)
|
||||||
└──────────── pairing channel: X25519 → 6 words → AES-256-GCM, end to end ───────────┘
|
└──────────── pairing channel: X25519 → 6 words → AES-256-GCM, end to end ───────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
* **IPC** (`ipc.rs`): Unix socket `$XDG_RUNTIME_DIR/kestrove/nmh.sock` (macOS: the per-user temp
|
The bridge is shared with the macOS app and lives in kestrove-core: `crates/kv-ipc` (framing, IPC),
|
||||||
|
`crates/kv-nmh` (the host) and `kv-ffi`'s `browser-bridge` feature (`src/bridge/`: broker, pairing
|
||||||
|
channel). This crate re-exports them under the old module names.
|
||||||
|
|
||||||
|
* **IPC** (`kv-ipc`): Unix socket `$XDG_RUNTIME_DIR/kestrove/nmh.sock` (macOS: the per-user temp
|
||||||
dir), dir 0700, socket 0600, peer uid checked on both ends. Windows: `\\.\pipe\kestrove-nmh-<user>`,
|
dir), dir 0700, socket 0600, peer uid checked on both ends. Windows: `\\.\pipe\kestrove-nmh-<user>`,
|
||||||
first instance only, local clients only, DACL for the current user, client token SID checked
|
first instance only, local clients only, DACL for the current user, client token SID checked
|
||||||
(compile-checked only). `KESTROVE_IPC_DIR` isolates an instance for tests.
|
(compile-checked only). `KESTROVE_IPC_DIR` isolates an instance for tests.
|
||||||
* **Pairing** (`channel.rs`, mirrored by kestrove-browser `src/channel.ts`, shared test vectors):
|
* **Pairing** (`bridge/channel.rs`, mirrored by kestrove-browser `src/channel.ts`, shared test vectors):
|
||||||
both UIs show 6 words; the user confirms in the app (needs an unlocked vault) and in the extension.
|
both UIs show 6 words; the user confirms in the app (needs an unlocked vault) and in the extension.
|
||||||
Each connection derives a fresh session key from both sides' nonces; messages carry strict
|
Each connection derives a fresh session key from both sides' nonces; messages carry strict
|
||||||
counters, so replay and reordering fail. `kestrove-nmh` only relays ciphertext.
|
counters, so replay and reordering fail. `kestrove-nmh` only relays ciphertext.
|
||||||
* **Protocol** (`broker.rs`): `query` → identities for the origin (`items_for_url`); `fill`/`totp`
|
* **Protocol** (`bridge/broker.rs`): `query` → identities for the origin (`items_for_url`); `fill`/`totp`
|
||||||
re-check that the item matches the origin and need an unlocked vault; `save` shows a prompt in
|
re-check that the item matches the origin and need an unlocked vault; `save` shows a prompt in
|
||||||
the app (new login or password update).
|
the app (new login or password update).
|
||||||
* **Native host manifests**: `native-messaging/install.sh` (Linux, macOS) and `install.ps1`
|
* **Native host manifests**: `native-messaging/install.sh` (Linux; macOS for development only, the Mac
|
||||||
(Windows, HKCU registry) fill in the host path and extension ids.
|
app registers its own) and `install.ps1` (Windows, HKCU registry) fill in the host path and extension ids.
|
||||||
* **Tests**: `cargo test` (unit tests + `tests/nmh_stdio.rs`, which drives the real `kestrove-nmh`
|
* **Tests**: `cargo test` (unit tests + `tests/nmh_stdio.rs`, which drives the real `kestrove-nmh`
|
||||||
over stdio through the paired channel). `examples/test-host.rs` is a headless, auto-approving
|
over stdio through the paired channel). `examples/test-host.rs` is a headless, auto-approving
|
||||||
stand-in for the app used by kestrove-browser's interop and Chrome end-to-end tests.
|
stand-in for the app used by kestrove-browser's interop and Chrome end-to-end tests.
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ fi
|
|||||||
|
|
||||||
case "$(uname -s)" in
|
case "$(uname -s)" in
|
||||||
Darwin)
|
Darwin)
|
||||||
|
# The Kestrove Mac app registers its own host on launch; this points the browsers at a desktop dev build.
|
||||||
|
[ "$UNINSTALL" = 1 ] || echo "note: on macOS the Kestrove app registers itself; re-open it to switch back" >&2
|
||||||
BASE="$HOME/Library/Application Support"
|
BASE="$HOME/Library/Application Support"
|
||||||
dir_chrome="$BASE/Google/Chrome"
|
dir_chrome="$BASE/Google/Chrome"
|
||||||
dir_chromium="$BASE/Chromium"
|
dir_chromium="$BASE/Chromium"
|
||||||
|
|||||||
Generated
+20
-17
@@ -3014,16 +3014,15 @@ dependencies = [
|
|||||||
name = "kestrove-desktop"
|
name = "kestrove-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
|
||||||
"arboard",
|
"arboard",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hayro",
|
"hayro",
|
||||||
"hkdf 0.12.4",
|
|
||||||
"image",
|
"image",
|
||||||
"keyring",
|
"keyring",
|
||||||
"kv-ffi",
|
"kv-ffi",
|
||||||
|
"kv-ipc",
|
||||||
"kv-sync",
|
"kv-sync",
|
||||||
"libc",
|
"libc",
|
||||||
"p256",
|
"p256",
|
||||||
@@ -3039,7 +3038,6 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"x25519-dalek",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3108,10 +3106,13 @@ dependencies = [
|
|||||||
name = "kv-ffi"
|
name = "kv-ffi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"getrandom 0.2.17",
|
||||||
"hkdf 0.12.4",
|
"hkdf 0.12.4",
|
||||||
"kv-crypto",
|
"kv-crypto",
|
||||||
"kv-import",
|
"kv-import",
|
||||||
|
"kv-ipc",
|
||||||
"kv-model",
|
"kv-model",
|
||||||
"kv-proto",
|
"kv-proto",
|
||||||
"kv-store",
|
"kv-store",
|
||||||
@@ -3122,7 +3123,9 @@ dependencies = [
|
|||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uniffi",
|
"uniffi",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"x25519-dalek",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3146,6 +3149,15 @@ dependencies = [
|
|||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kv-ipc"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"serde_json",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kv-model"
|
name = "kv-model"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3213,7 +3225,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webpki-roots 0.26.11",
|
"webpki-roots",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -4620,9 +4632,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.15"
|
version = "0.103.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -6448,18 +6460,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.26.11"
|
version = "1.0.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||||
dependencies = [
|
|
||||||
"webpki-roots 1.0.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-roots"
|
|
||||||
version = "1.0.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,26 +15,24 @@ path = "src/lib.rs"
|
|||||||
name = "kestrove-desktop"
|
name = "kestrove-desktop"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
# Native messaging host: started by the browser, relays to the running app (docs/05).
|
# Native messaging host: started by the browser, relays to the running app (docs/05). Shared with the macOS
|
||||||
|
# app, so the source lives in kestrove-core (crates/kv-nmh).
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "kestrove-nmh"
|
name = "kestrove-nmh"
|
||||||
path = "src/bin/kestrove-nmh.rs"
|
path = "../../kestrove-core/crates/kv-nmh/src/main.rs"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Repos are checked out side by side (see ../../README.md).
|
# Repos are checked out side by side (see ../../README.md).
|
||||||
kv-ffi = { path = "../../kestrove-core/crates/kv-ffi" }
|
kv-ffi = { path = "../../kestrove-core/crates/kv-ffi", features = ["browser-bridge"] }
|
||||||
|
kv-ipc = { path = "../../kestrove-core/crates/kv-ipc" }
|
||||||
tauri = { version = "2", features = ["tray-icon"] }
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
arboard = "3"
|
arboard = "3"
|
||||||
# Browser pairing channel (docs/05): X25519 + HKDF-SHA256 + AES-256-GCM (what WebCrypto offers on the extension side).
|
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets", "zeroize"] }
|
|
||||||
hkdf = "0.12"
|
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
aes-gcm = "0.10"
|
|
||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
//! Native messaging host for Chrome/Edge/Brave/Firefox (kestrove-spec/docs/05).
|
|
||||||
//!
|
|
||||||
//! The browser starts this process and talks over stdio: each message is a u32 length (native
|
|
||||||
//! byte order) followed by UTF-8 JSON. This process holds **no** secrets and can't read the
|
|
||||||
//! traffic: frames are end-to-end encrypted between the extension and the app (pairing channel,
|
|
||||||
//! see `channel.rs`). It relays each frame to the running app over local IPC (`ipc.rs`) and
|
|
||||||
//! relays the app's reply back, strictly one reply per request.
|
|
||||||
//!
|
|
||||||
//! When the app isn't running, every request gets `{t:"error", code:"unavailable"}`; it
|
|
||||||
//! reconnects on the next request, so starting the app later just works.
|
|
||||||
|
|
||||||
use std::io::{self, Write};
|
|
||||||
|
|
||||||
use kestrove_desktop::framing::{read_frame, read_json, write_frame, write_json};
|
|
||||||
use kestrove_desktop::ipc::{self, Conn};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
/// Chrome passes the caller origin (`chrome-extension://<id>/`) as the first argument; Firefox
|
|
||||||
/// passes the manifest path and then the extension id. Informational only (shown when pairing):
|
|
||||||
/// the browser's `allowed_origins` check and the pairing key are what actually gate access.
|
|
||||||
fn caller() -> String {
|
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
||||||
match args.as_slice() {
|
|
||||||
[a, ..] if a.starts_with("chrome-extension://") => a.clone(),
|
|
||||||
[_, id, ..] if !id.starts_with("--") => id.clone(),
|
|
||||||
_ => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect(caller: &str) -> io::Result<Conn> {
|
|
||||||
let mut c = ipc::connect()?;
|
|
||||||
write_json(&mut c, &json!({ "t": "nmh_hello", "caller": caller }))?;
|
|
||||||
match read_json(&mut c)? {
|
|
||||||
Some(v) if v["t"] == "ok" => Ok(c),
|
|
||||||
_ => Err(io::Error::new(io::ErrorKind::InvalidData, "unexpected hello reply")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relay(conn: &mut Conn, frame: &[u8]) -> io::Result<Vec<u8>> {
|
|
||||||
write_frame(conn, frame)?;
|
|
||||||
read_frame(conn)?.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "app closed the connection"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> io::Result<()> {
|
|
||||||
let caller = caller();
|
|
||||||
let (mut stdin, mut stdout) = (io::stdin().lock(), io::stdout().lock());
|
|
||||||
let unavailable = serde_json::to_vec(&json!({ "t": "error", "code": "unavailable" }))?;
|
|
||||||
let mut conn: Option<Conn> = None;
|
|
||||||
while let Some(frame) = read_frame(&mut stdin)? {
|
|
||||||
if conn.is_none() {
|
|
||||||
conn = connect(&caller).ok();
|
|
||||||
}
|
|
||||||
let reply = match conn.as_mut().map(|c| relay(c, &frame)) {
|
|
||||||
Some(Ok(r)) => r,
|
|
||||||
Some(Err(_)) => {
|
|
||||||
conn = None;
|
|
||||||
unavailable.clone()
|
|
||||||
}
|
|
||||||
None => unavailable.clone(),
|
|
||||||
};
|
|
||||||
write_frame(&mut stdout, &reply)?;
|
|
||||||
stdout.flush()?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
//! The app's side of the browser protocol: pairing, encrypted sessions and request handling.
|
|
||||||
//!
|
|
||||||
//! Outer frames (JSON, field `t`), relayed unchanged by `kestrove-nmh`:
|
|
||||||
//!
|
|
||||||
//! | extension → app | app → extension |
|
|
||||||
//! |---|---|
|
|
||||||
//! | `{t:"nmh_hello", caller}` (sent by the host itself) | `{t:"ok"}` |
|
|
||||||
//! | `{t:"ping"}` | `{t:"pong", version}` |
|
|
||||||
//! | `{t:"pair_init", pub, name}` | `{t:"pair_started", pairing_id, pub}` (app shows the words) |
|
|
||||||
//! | `{t:"pair_confirm", pairing_id, proof}` | `{t:"pair_waiting"}` / `{t:"pair_done"}` / error |
|
|
||||||
//! | `{t:"open", pairing_id, nonce}` | `{t:"opened", nonce}` / `{t:"error", code:"not_paired"}` |
|
|
||||||
//! | `{t:"msg", n, c}` | `{t:"msg", n, c}` / `{t:"error", code:"bad_session"}` |
|
|
||||||
//!
|
|
||||||
//! `msg` plaintexts are `{rid, body}` where `body` is a `NativeRequest` / `NativeResponse` from
|
|
||||||
//! kestrove-browser/src/protocol.ts. Origins come from the extension's `sender`, and are
|
|
||||||
//! re-checked here against the vault before any secret leaves the app.
|
|
||||||
|
|
||||||
use std::io::{self, Read, Write};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
|
||||||
use base64::Engine;
|
|
||||||
use kv_ffi::{CoreError, ItemDto, ItemKindDto, VaultCore};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
use crate::channel::{random, verify_confirm, Ephemeral, Session};
|
|
||||||
use crate::framing::{read_frame, write_json};
|
|
||||||
use crate::private_file;
|
|
||||||
|
|
||||||
/// How long a pairing request waits for the user in the app.
|
|
||||||
const PAIRING_TTL: Duration = Duration::from_secs(300);
|
|
||||||
/// How long a save prompt stays in the app.
|
|
||||||
const SAVE_TTL: Duration = Duration::from_secs(600);
|
|
||||||
const MAX_PENDING_SAVES: usize = 8;
|
|
||||||
|
|
||||||
/// What the app UI must show. Implemented with Tauri events in the app, and auto-approving in tests.
|
|
||||||
pub trait Ui: Send + Sync {
|
|
||||||
fn pairing_requested(&self, req: &PairingView);
|
|
||||||
fn save_requested(&self, req: &SaveView);
|
|
||||||
/// A user-driven request (a fill) arrived: counts as activity for auto-lock.
|
|
||||||
fn activity(&self) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct PairingView {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub caller: String,
|
|
||||||
pub words: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct SaveView {
|
|
||||||
pub id: String,
|
|
||||||
pub origin: String,
|
|
||||||
pub username: String,
|
|
||||||
/// Some item already has this username for this site: saving updates its password.
|
|
||||||
pub update: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct StoredPairing {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub caller: String,
|
|
||||||
pub words: Vec<String>,
|
|
||||||
pub created_at: u64,
|
|
||||||
key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StoredPairing {
|
|
||||||
fn key(&self) -> Option<Zeroizing<[u8; 32]>> {
|
|
||||||
let raw = Zeroizing::new(B64.decode(&self.key).ok()?);
|
|
||||||
let arr: [u8; 32] = raw.as_slice().try_into().ok()?;
|
|
||||||
Some(Zeroizing::new(arr))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persisted pairings (`pairings.json`, 0600). The pairing key only authenticates the
|
|
||||||
/// extension to the app; it can't decrypt the vault. Every secret still needs an unlocked vault.
|
|
||||||
pub struct PairingStore {
|
|
||||||
path: Option<PathBuf>,
|
|
||||||
items: Mutex<Vec<StoredPairing>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PairingStore {
|
|
||||||
pub fn in_memory() -> Self {
|
|
||||||
Self { path: None, items: Mutex::new(Vec::new()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open(path: PathBuf) -> io::Result<Self> {
|
|
||||||
let items = match private_file::read(&path)? {
|
|
||||||
Some(b) => serde_json::from_slice(&b).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
Ok(Self { path: Some(path), items: Mutex::new(items) })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn guard(&self) -> MutexGuard<'_, Vec<StoredPairing>> {
|
|
||||||
self.items.lock().unwrap_or_else(|e| e.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist(&self, items: &[StoredPairing]) -> io::Result<()> {
|
|
||||||
match &self.path {
|
|
||||||
Some(path) => private_file::write(path, &serde_json::to_vec_pretty(items)?),
|
|
||||||
None => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list(&self) -> Vec<StoredPairing> {
|
|
||||||
self.guard().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get(&self, id: &str) -> Option<StoredPairing> {
|
|
||||||
self.guard().iter().find(|p| p.id == id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add(&self, p: StoredPairing) -> io::Result<()> {
|
|
||||||
let mut items = self.guard();
|
|
||||||
items.push(p);
|
|
||||||
self.persist(&items)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&self, id: &str) -> io::Result<bool> {
|
|
||||||
let mut items = self.guard();
|
|
||||||
let before = items.len();
|
|
||||||
items.retain(|p| p.id != id);
|
|
||||||
self.persist(&items)?;
|
|
||||||
Ok(items.len() != before)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PendingPairing {
|
|
||||||
view: PairingView,
|
|
||||||
key: Zeroizing<[u8; 32]>,
|
|
||||||
at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PendingSave {
|
|
||||||
id: String,
|
|
||||||
origin: String,
|
|
||||||
username: String,
|
|
||||||
password: Zeroizing<String>,
|
|
||||||
at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Broker {
|
|
||||||
core: Arc<VaultCore>,
|
|
||||||
ui: Arc<dyn Ui>,
|
|
||||||
pairings: PairingStore,
|
|
||||||
pending_pairing: Mutex<Option<PendingPairing>>,
|
|
||||||
saves: Mutex<Vec<PendingSave>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
||||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn err(code: &str) -> Value {
|
|
||||||
json!({ "type": "error", "code": code })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn core_err(e: &CoreError) -> Value {
|
|
||||||
match e {
|
|
||||||
CoreError::Locked | CoreError::NoAccount => err("locked"),
|
|
||||||
CoreError::NotFound => err("origin_mismatch"),
|
|
||||||
_ => err("internal"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn b64_array<const N: usize>(v: &Value) -> Option<[u8; N]> {
|
|
||||||
B64.decode(v.as_str()?).ok()?.try_into().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `scheme://host[:port]` for http(s) origins; anything else (paths, credentials, other schemes) is rejected.
|
|
||||||
pub fn normalize_origin(raw: &str) -> Option<String> {
|
|
||||||
let u = url::Url::parse(raw).ok()?;
|
|
||||||
if !matches!(u.scheme(), "https" | "http") || u.host_str().is_none_or(str::is_empty) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if !u.username().is_empty() || u.password().is_some() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(u.origin().ascii_serialization())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-connection state.
|
|
||||||
pub struct Conn {
|
|
||||||
caller: String,
|
|
||||||
session: Option<Session>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Broker {
|
|
||||||
pub fn new(core: Arc<VaultCore>, ui: Arc<dyn Ui>, pairings: PairingStore) -> Arc<Self> {
|
|
||||||
Arc::new(Self { core, ui, pairings, pending_pairing: Mutex::new(None), saves: Mutex::new(Vec::new()) })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn core(&self) -> &Arc<VaultCore> {
|
|
||||||
&self.core
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serves one `kestrove-nmh` connection until it closes. Strict request → response.
|
|
||||||
pub fn serve(&self, mut io: impl Read + Write) -> io::Result<()> {
|
|
||||||
let mut conn = Conn { caller: String::new(), session: None };
|
|
||||||
while let Some(frame) = read_frame(&mut io)? {
|
|
||||||
let reply = match serde_json::from_slice::<Value>(&frame) {
|
|
||||||
Ok(v) => self.handle_frame(&mut conn, &v),
|
|
||||||
Err(_) => json!({ "t": "error", "code": "bad_request" }),
|
|
||||||
};
|
|
||||||
write_json(&mut io, &reply)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_frame(&self, conn: &mut Conn, v: &Value) -> Value {
|
|
||||||
let outer_err = |code: &str| json!({ "t": "error", "code": code });
|
|
||||||
match v.get("t").and_then(Value::as_str) {
|
|
||||||
Some("nmh_hello") => {
|
|
||||||
conn.caller = v.get("caller").and_then(Value::as_str).unwrap_or("").chars().take(200).collect();
|
|
||||||
json!({ "t": "ok" })
|
|
||||||
}
|
|
||||||
Some("ping") => json!({ "t": "pong", "version": env!("CARGO_PKG_VERSION") }),
|
|
||||||
Some("pair_init") => {
|
|
||||||
let Some(ext_pub) = v.get("pub").and_then(b64_array::<32>) else { return outer_err("bad_request") };
|
|
||||||
let name: String =
|
|
||||||
v.get("name").and_then(Value::as_str).unwrap_or("Browser").chars().take(80).collect();
|
|
||||||
self.pair_init(conn, &ext_pub, name).unwrap_or_else(|| outer_err("bad_request"))
|
|
||||||
}
|
|
||||||
Some("pair_confirm") => {
|
|
||||||
let (Some(id), Some(proof)) = (
|
|
||||||
v.get("pairing_id").and_then(Value::as_str),
|
|
||||||
v.get("proof").and_then(Value::as_str).and_then(|p| B64.decode(p).ok()),
|
|
||||||
) else {
|
|
||||||
return outer_err("bad_request");
|
|
||||||
};
|
|
||||||
self.pair_confirm(id, &proof)
|
|
||||||
}
|
|
||||||
Some("open") => {
|
|
||||||
let (Some(id), Some(ne)) =
|
|
||||||
(v.get("pairing_id").and_then(Value::as_str), v.get("nonce").and_then(b64_array::<16>))
|
|
||||||
else {
|
|
||||||
return outer_err("bad_request");
|
|
||||||
};
|
|
||||||
let Some(key) = self.pairings.get(id).and_then(|p| p.key()) else { return outer_err("not_paired") };
|
|
||||||
let na = random::<16>();
|
|
||||||
conn.session = Some(Session::app(&key, &ne, &na));
|
|
||||||
json!({ "t": "opened", "nonce": B64.encode(na) })
|
|
||||||
}
|
|
||||||
Some("msg") => {
|
|
||||||
let Some(session) = conn.session.as_mut() else { return outer_err("bad_session") };
|
|
||||||
let (Some(n), Some(c)) = (
|
|
||||||
v.get("n").and_then(Value::as_u64),
|
|
||||||
v.get("c").and_then(Value::as_str).and_then(|c| B64.decode(c).ok()),
|
|
||||||
) else {
|
|
||||||
return outer_err("bad_request");
|
|
||||||
};
|
|
||||||
let Ok(pt) = session.open(n, &c) else {
|
|
||||||
conn.session = None; // the extension re-opens a fresh session
|
|
||||||
return outer_err("bad_session");
|
|
||||||
};
|
|
||||||
let parsed: Option<Value> = serde_json::from_slice(&pt).ok();
|
|
||||||
drop(pt);
|
|
||||||
let (rid, body) = match &parsed {
|
|
||||||
Some(p) => {
|
|
||||||
(p.get("rid").cloned().unwrap_or(Value::Null), p.get("body").cloned().unwrap_or(Value::Null))
|
|
||||||
}
|
|
||||||
None => (Value::Null, Value::Null),
|
|
||||||
};
|
|
||||||
let reply = Zeroizing::new(
|
|
||||||
serde_json::to_vec(&json!({ "rid": rid, "body": self.handle_request(&body) })).unwrap_or_default(),
|
|
||||||
);
|
|
||||||
let session = conn.session.as_mut().expect("session still open");
|
|
||||||
let (n, c) = session.seal(&reply);
|
|
||||||
json!({ "t": "msg", "n": n, "c": B64.encode(c) })
|
|
||||||
}
|
|
||||||
_ => outer_err("unknown_type"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ pairing
|
|
||||||
|
|
||||||
fn pair_init(&self, conn: &Conn, ext_pub: &[u8; 32], name: String) -> Option<Value> {
|
|
||||||
let eph = Ephemeral::generate();
|
|
||||||
let p = eph.pair(ext_pub)?;
|
|
||||||
let view = PairingView {
|
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
|
||||||
name,
|
|
||||||
caller: conn.caller.clone(),
|
|
||||||
words: p.words.iter().map(|w| w.to_string()).collect(),
|
|
||||||
};
|
|
||||||
let reply = json!({ "t": "pair_started", "pairing_id": view.id, "pub": B64.encode(eph.public) });
|
|
||||||
self.ui.pairing_requested(&view);
|
|
||||||
// One pending request at a time; a new one replaces the old (and its words).
|
|
||||||
*lock(&self.pending_pairing) = Some(PendingPairing { view, key: p.key, at: Instant::now() });
|
|
||||||
Some(reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pair_confirm(&self, id: &str, proof: &[u8]) -> Value {
|
|
||||||
if let Some(key) = self.pairings.get(id).and_then(|p| p.key()) {
|
|
||||||
return if verify_confirm(&key, id, proof) {
|
|
||||||
json!({ "t": "pair_done" })
|
|
||||||
} else {
|
|
||||||
json!({ "t": "error", "code": "not_paired" })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
match lock(&self.pending_pairing).as_ref() {
|
|
||||||
Some(p) if p.view.id == id && p.at.elapsed() < PAIRING_TTL && verify_confirm(&p.key, id, proof) => {
|
|
||||||
json!({ "t": "pair_waiting" })
|
|
||||||
}
|
|
||||||
_ => json!({ "t": "error", "code": "not_paired" }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pending_pairing(&self) -> Option<PairingView> {
|
|
||||||
let mut g = lock(&self.pending_pairing);
|
|
||||||
if g.as_ref().is_some_and(|p| p.at.elapsed() >= PAIRING_TTL) {
|
|
||||||
*g = None;
|
|
||||||
}
|
|
||||||
g.as_ref().map(|p| p.view.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The user compared the words and accepted (or rejected) the pairing. Requires an unlocked
|
|
||||||
/// vault, so someone at a locked machine can't pair a new extension.
|
|
||||||
pub fn resolve_pairing(&self, id: &str, accept: bool) -> Result<(), String> {
|
|
||||||
let mut g = lock(&self.pending_pairing);
|
|
||||||
let Some(p) = g.take_if(|p| p.view.id == id) else { return Err("This pairing request expired.".into()) };
|
|
||||||
if !accept {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if self.core.is_locked() {
|
|
||||||
*g = Some(p);
|
|
||||||
return Err("Unlock Kestrove to pair a browser.".into());
|
|
||||||
}
|
|
||||||
if p.at.elapsed() >= PAIRING_TTL {
|
|
||||||
return Err("This pairing request expired.".into());
|
|
||||||
}
|
|
||||||
self.pairings
|
|
||||||
.add(StoredPairing {
|
|
||||||
id: p.view.id,
|
|
||||||
name: p.view.name,
|
|
||||||
caller: p.view.caller,
|
|
||||||
words: p.view.words,
|
|
||||||
created_at: now_secs(),
|
|
||||||
key: B64.encode(p.key.as_ref()),
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Could not save the pairing: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pairings(&self) -> Vec<StoredPairing> {
|
|
||||||
self.pairings.list()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn revoke_pairing(&self, id: &str) -> Result<(), String> {
|
|
||||||
self.pairings.remove(id).map(|_| ()).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ requests
|
|
||||||
|
|
||||||
pub fn handle_request(&self, body: &Value) -> Value {
|
|
||||||
let Some(kind) = body.get("type").and_then(Value::as_str) else { return err("bad_request") };
|
|
||||||
if kind == "hello" {
|
|
||||||
return json!({ "type": "hello", "version": env!("CARGO_PKG_VERSION"), "paired": true });
|
|
||||||
}
|
|
||||||
let Some(origin) = body.get("origin").and_then(Value::as_str).and_then(normalize_origin) else {
|
|
||||||
return err("bad_request");
|
|
||||||
};
|
|
||||||
let item_id = || body.get("itemId").and_then(Value::as_str).filter(|s| !s.is_empty());
|
|
||||||
match kind {
|
|
||||||
"query" => match self.core.items_for_url(origin) {
|
|
||||||
Ok(items) => json!({
|
|
||||||
"type": "identities",
|
|
||||||
"items": items.iter().map(|i| json!({
|
|
||||||
"id": i.id, "title": i.title, "username": i.username.clone().unwrap_or_default(), "totp": i.has_totp,
|
|
||||||
})).collect::<Vec<_>>(),
|
|
||||||
}),
|
|
||||||
Err(e) => core_err(&e),
|
|
||||||
},
|
|
||||||
"fill" => {
|
|
||||||
let Some(id) = item_id() else { return err("bad_request") };
|
|
||||||
self.ui.activity();
|
|
||||||
match self.matching_item(&origin, id) {
|
|
||||||
Ok(d) => json!({
|
|
||||||
"type": "credential",
|
|
||||||
"username": d.username.clone().unwrap_or_default(),
|
|
||||||
"password": d.password.clone().unwrap_or_default(),
|
|
||||||
}),
|
|
||||||
Err(v) => v,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"totp" => {
|
|
||||||
let Some(id) = item_id() else { return err("bad_request") };
|
|
||||||
self.ui.activity();
|
|
||||||
match self.matching_item(&origin, id) {
|
|
||||||
Ok(d) => match d.totp.as_deref().map(|t| kv_ffi::totp_code(t.to_string(), now_secs())) {
|
|
||||||
Some(Ok(c)) => {
|
|
||||||
json!({ "type": "totp", "code": c.code, "secondsRemaining": c.seconds_remaining })
|
|
||||||
}
|
|
||||||
_ => err("bad_request"),
|
|
||||||
},
|
|
||||||
Err(v) => v,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"save" => {
|
|
||||||
let username = body.get("username").and_then(Value::as_str).unwrap_or("");
|
|
||||||
let Some(password) = body.get("password").and_then(Value::as_str).filter(|p| !p.is_empty()) else {
|
|
||||||
return err("bad_request");
|
|
||||||
};
|
|
||||||
if username.chars().count() > 512 || password.chars().count() > 4096 {
|
|
||||||
return err("bad_request");
|
|
||||||
}
|
|
||||||
self.queue_save(origin, username.to_string(), Zeroizing::new(password.to_string()))
|
|
||||||
}
|
|
||||||
_ => err("unknown_type"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The item, if (and only if) it matches the requesting origin. The id alone is never enough.
|
|
||||||
fn matching_item(&self, origin: &str, id: &str) -> Result<ItemDto, Value> {
|
|
||||||
let candidates = self.core.items_for_url(origin.to_string()).map_err(|e| core_err(&e))?;
|
|
||||||
if !candidates.iter().any(|i| i.id == id) {
|
|
||||||
return Err(err("origin_mismatch"));
|
|
||||||
}
|
|
||||||
self.core.get_item(id.to_string()).map_err(|e| core_err(&e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The existing login on this origin with this username, if the vault is unlocked.
|
|
||||||
fn existing(&self, origin: &str, username: &str) -> Option<ItemDto> {
|
|
||||||
let items = self.core.items_for_url(origin.to_string()).ok()?;
|
|
||||||
let s = items.into_iter().find(|i| i.username.as_deref().unwrap_or("") == username)?;
|
|
||||||
self.core.get_item(s.id).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn queue_save(&self, origin: String, username: String, password: Zeroizing<String>) -> Value {
|
|
||||||
if let Some(e) = self.existing(&origin, &username) {
|
|
||||||
if e.password.as_deref() == Some(password.as_str()) {
|
|
||||||
return json!({ "type": "saved" }); // already up to date: no prompt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let save = PendingSave { id: uuid::Uuid::new_v4().to_string(), origin, username, password, at: Instant::now() };
|
|
||||||
let view = self.save_view(&save);
|
|
||||||
{
|
|
||||||
let mut q = lock(&self.saves);
|
|
||||||
q.retain(|s| s.at.elapsed() < SAVE_TTL && !(s.origin == save.origin && s.username == save.username));
|
|
||||||
if q.len() >= MAX_PENDING_SAVES {
|
|
||||||
q.remove(0);
|
|
||||||
}
|
|
||||||
q.push(save);
|
|
||||||
}
|
|
||||||
self.ui.save_requested(&view);
|
|
||||||
json!({ "type": "save_prompted" })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_view(&self, s: &PendingSave) -> SaveView {
|
|
||||||
SaveView {
|
|
||||||
id: s.id.clone(),
|
|
||||||
origin: s.origin.clone(),
|
|
||||||
username: s.username.clone(),
|
|
||||||
update: self.existing(&s.origin, &s.username).is_some(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pending_saves(&self) -> Vec<SaveView> {
|
|
||||||
let mut q = lock(&self.saves);
|
|
||||||
q.retain(|s| s.at.elapsed() < SAVE_TTL);
|
|
||||||
let pending: Vec<_> = q.iter().map(|s| (s.id.clone(), s.origin.clone(), s.username.clone())).collect();
|
|
||||||
drop(q);
|
|
||||||
pending
|
|
||||||
.into_iter()
|
|
||||||
.map(|(id, origin, username)| SaveView {
|
|
||||||
update: self.existing(&origin, &username).is_some(),
|
|
||||||
id,
|
|
||||||
origin,
|
|
||||||
username,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The user answered a save prompt in the app. Returns the saved item id.
|
|
||||||
pub fn resolve_save(&self, id: &str, accept: bool) -> Result<Option<String>, String> {
|
|
||||||
let s = {
|
|
||||||
let mut q = lock(&self.saves);
|
|
||||||
let Some(pos) = q.iter().position(|s| s.id == id) else { return Err("This save request expired.".into()) };
|
|
||||||
if accept && self.core.is_locked() {
|
|
||||||
return Err("Unlock Kestrove to save.".into());
|
|
||||||
}
|
|
||||||
q.remove(pos)
|
|
||||||
};
|
|
||||||
if !accept {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let item = match self.existing(&s.origin, &s.username) {
|
|
||||||
Some(mut e) => {
|
|
||||||
e.password = Some(s.password.to_string());
|
|
||||||
e
|
|
||||||
}
|
|
||||||
None => ItemDto {
|
|
||||||
id: String::new(),
|
|
||||||
kind: ItemKindDto::Login,
|
|
||||||
title: url::Url::parse(&s.origin)
|
|
||||||
.ok()
|
|
||||||
.and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string()))
|
|
||||||
.unwrap_or_else(|| s.origin.clone()),
|
|
||||||
username: (!s.username.is_empty()).then(|| s.username.clone()),
|
|
||||||
password: Some(s.password.to_string()),
|
|
||||||
urls: vec![s.origin.clone()],
|
|
||||||
totp: None,
|
|
||||||
notes: None,
|
|
||||||
favorite: false,
|
|
||||||
tags: vec![],
|
|
||||||
modified_at: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
self.core.save_item(item).map(Some).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::channel::{make_confirm, Session as Sess};
|
|
||||||
|
|
||||||
struct AutoUi;
|
|
||||||
impl Ui for AutoUi {
|
|
||||||
fn pairing_requested(&self, _: &PairingView) {}
|
|
||||||
fn save_requested(&self, _: &SaveView) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn broker_with_item() -> (Arc<Broker>, String) {
|
|
||||||
let core = VaultCore::new_in_memory();
|
|
||||||
core.create_account("correct horse battery".into()).unwrap();
|
|
||||||
let id = core
|
|
||||||
.save_item(ItemDto {
|
|
||||||
id: String::new(),
|
|
||||||
kind: ItemKindDto::Login,
|
|
||||||
title: "Example".into(),
|
|
||||||
username: Some("alice".into()),
|
|
||||||
password: Some("s3cret".into()),
|
|
||||||
urls: vec!["https://example.com".into()],
|
|
||||||
totp: Some("JBSWY3DPEHPK3PXP".into()),
|
|
||||||
notes: None,
|
|
||||||
favorite: false,
|
|
||||||
tags: vec![],
|
|
||||||
modified_at: 0,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
(Broker::new(core, Arc::new(AutoUi), PairingStore::in_memory()), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Plays the extension: pairs, has the "user" approve in the app, opens a session.
|
|
||||||
fn pair_and_open(b: &Broker, conn: &mut Conn) -> Sess {
|
|
||||||
let ext = Ephemeral::generate();
|
|
||||||
let r = b.handle_frame(conn, &json!({ "t": "pair_init", "pub": B64.encode(ext.public), "name": "Test" }));
|
|
||||||
assert_eq!(r["t"], "pair_started");
|
|
||||||
let id = r["pairing_id"].as_str().unwrap().to_string();
|
|
||||||
let app_pub: [u8; 32] = B64.decode(r["pub"].as_str().unwrap()).unwrap().try_into().unwrap();
|
|
||||||
let shared_ext = ext.pair_as_ext(&app_pub).unwrap();
|
|
||||||
assert_eq!(shared_ext.words.to_vec(), b.pending_pairing().unwrap().words);
|
|
||||||
let proof = B64.encode(make_confirm(&shared_ext.key, &id));
|
|
||||||
let r = b.handle_frame(conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": proof }));
|
|
||||||
assert_eq!(r["t"], "pair_waiting");
|
|
||||||
b.resolve_pairing(&id, true).unwrap();
|
|
||||||
let r = b.handle_frame(conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": proof }));
|
|
||||||
assert_eq!(r["t"], "pair_done");
|
|
||||||
|
|
||||||
let ne = random::<16>();
|
|
||||||
let r = b.handle_frame(conn, &json!({ "t": "open", "pairing_id": id, "nonce": B64.encode(ne) }));
|
|
||||||
assert_eq!(r["t"], "opened");
|
|
||||||
let na: [u8; 16] = B64.decode(r["nonce"].as_str().unwrap()).unwrap().try_into().unwrap();
|
|
||||||
Sess::ext(&shared_ext.key, &ne, &na)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call(b: &Broker, conn: &mut Conn, s: &mut Sess, body: Value) -> Value {
|
|
||||||
let (n, c) = s.seal(&serde_json::to_vec(&json!({ "rid": 7, "body": body })).unwrap());
|
|
||||||
let r = b.handle_frame(conn, &json!({ "t": "msg", "n": n, "c": B64.encode(c) }));
|
|
||||||
assert_eq!(r["t"], "msg", "{r}");
|
|
||||||
let pt = s.open(r["n"].as_u64().unwrap(), &B64.decode(r["c"].as_str().unwrap()).unwrap()).unwrap();
|
|
||||||
let v: Value = serde_json::from_slice(&pt).unwrap();
|
|
||||||
assert_eq!(v["rid"], 7);
|
|
||||||
v["body"].clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pair_query_fill_and_origin_checks() {
|
|
||||||
let (b, id) = broker_with_item();
|
|
||||||
let mut conn = Conn { caller: String::new(), session: None };
|
|
||||||
let mut s = pair_and_open(&b, &mut conn);
|
|
||||||
|
|
||||||
let r = call(&b, &mut conn, &mut s, json!({ "type": "query", "origin": "https://example.com" }));
|
|
||||||
assert_eq!(r["items"][0]["username"], "alice");
|
|
||||||
assert!(r["items"][0].get("password").is_none());
|
|
||||||
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://example.com", "itemId": id }));
|
|
||||||
assert_eq!(r, json!({ "type": "credential", "username": "alice", "password": "s3cret" }));
|
|
||||||
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://evil.test", "itemId": id }));
|
|
||||||
assert_eq!(r["code"], "origin_mismatch");
|
|
||||||
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "javascript:alert(1)", "itemId": id }));
|
|
||||||
assert_eq!(r["code"], "bad_request");
|
|
||||||
let r = call(&b, &mut conn, &mut s, json!({ "type": "totp", "origin": "https://example.com", "itemId": id }));
|
|
||||||
assert_eq!(r["type"], "totp");
|
|
||||||
assert_eq!(r["code"].as_str().unwrap().len(), 6);
|
|
||||||
|
|
||||||
b.core().lock();
|
|
||||||
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://example.com", "itemId": id }));
|
|
||||||
assert_eq!(r["code"], "locked");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replayed_frame_kills_the_session() {
|
|
||||||
let (b, _) = broker_with_item();
|
|
||||||
let mut conn = Conn { caller: String::new(), session: None };
|
|
||||||
let mut s = pair_and_open(&b, &mut conn);
|
|
||||||
let (n, c) = s.seal(br#"{"rid":1,"body":{"type":"hello","version":"1"}}"#);
|
|
||||||
let frame = json!({ "t": "msg", "n": n, "c": B64.encode(c) });
|
|
||||||
assert_eq!(b.handle_frame(&mut conn, &frame)["t"], "msg");
|
|
||||||
assert_eq!(b.handle_frame(&mut conn, &frame)["code"], "bad_session");
|
|
||||||
assert_eq!(b.handle_frame(&mut conn, &frame)["code"], "bad_session");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unpaired_and_revoked_extensions_are_refused() {
|
|
||||||
let (b, _) = broker_with_item();
|
|
||||||
let mut conn = Conn { caller: String::new(), session: None };
|
|
||||||
let r =
|
|
||||||
b.handle_frame(&mut conn, &json!({ "t": "open", "pairing_id": "nope", "nonce": B64.encode([0u8; 16]) }));
|
|
||||||
assert_eq!(r["code"], "not_paired");
|
|
||||||
let r = b.handle_frame(&mut conn, &json!({ "t": "msg", "n": 0, "c": "AAAA" }));
|
|
||||||
assert_eq!(r["code"], "bad_session");
|
|
||||||
|
|
||||||
let _ = pair_and_open(&b, &mut conn);
|
|
||||||
let id = b.pairings()[0].id.clone();
|
|
||||||
b.revoke_pairing(&id).unwrap();
|
|
||||||
let r = b.handle_frame(&mut conn, &json!({ "t": "open", "pairing_id": id, "nonce": B64.encode([0u8; 16]) }));
|
|
||||||
assert_eq!(r["code"], "not_paired");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pairing_needs_unlock_and_rejects_bad_proof() {
|
|
||||||
let (b, _) = broker_with_item();
|
|
||||||
let mut conn = Conn { caller: String::new(), session: None };
|
|
||||||
let ext = Ephemeral::generate();
|
|
||||||
let r = b.handle_frame(&mut conn, &json!({ "t": "pair_init", "pub": B64.encode(ext.public) }));
|
|
||||||
let id = r["pairing_id"].as_str().unwrap().to_string();
|
|
||||||
let r = b
|
|
||||||
.handle_frame(&mut conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": B64.encode([0u8; 40]) }));
|
|
||||||
assert_eq!(r["code"], "not_paired");
|
|
||||||
b.core().lock();
|
|
||||||
assert!(b.resolve_pairing(&id, true).is_err());
|
|
||||||
assert!(b.pending_pairing().is_some(), "still pending after a locked attempt");
|
|
||||||
assert!(b.pairings().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_prompts_then_creates_or_updates() {
|
|
||||||
let (b, id) = broker_with_item();
|
|
||||||
let r = b.handle_request(
|
|
||||||
&json!({ "type": "save", "origin": "https://example.com", "username": "alice", "password": "s3cret" }),
|
|
||||||
);
|
|
||||||
assert_eq!(r["type"], "saved"); // identical: nothing to ask
|
|
||||||
let r = b.handle_request(
|
|
||||||
&json!({ "type": "save", "origin": "https://example.com/login", "username": "alice", "password": "new" }),
|
|
||||||
);
|
|
||||||
assert_eq!(r["type"], "save_prompted");
|
|
||||||
let views = b.pending_saves();
|
|
||||||
assert_eq!((views.len(), views[0].update, views[0].origin.as_str()), (1, true, "https://example.com"));
|
|
||||||
assert_eq!(b.resolve_save(&views[0].id, true).unwrap().as_deref(), Some(id.as_str()));
|
|
||||||
assert_eq!(b.core().get_item(id).unwrap().password.as_deref(), Some("new"));
|
|
||||||
|
|
||||||
b.handle_request(
|
|
||||||
&json!({ "type": "save", "origin": "https://new.example.org", "username": "bob", "password": "pw" }),
|
|
||||||
);
|
|
||||||
let v = b.pending_saves().pop().unwrap();
|
|
||||||
assert!(!v.update);
|
|
||||||
let new_id = b.resolve_save(&v.id, true).unwrap().unwrap();
|
|
||||||
let d = b.core().get_item(new_id).unwrap();
|
|
||||||
assert_eq!((d.title.as_str(), d.urls[0].as_str()), ("new.example.org", "https://new.example.org"));
|
|
||||||
assert!(b.pending_saves().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalizes_origins() {
|
|
||||||
assert_eq!(normalize_origin("https://Example.com:443/x?y").as_deref(), Some("https://example.com"));
|
|
||||||
assert_eq!(normalize_origin("http://localhost:8080").as_deref(), Some("http://localhost:8080"));
|
|
||||||
assert_eq!(normalize_origin("https://u:[email protected]"), None);
|
|
||||||
assert_eq!(normalize_origin("file:///etc/passwd"), None);
|
|
||||||
assert_eq!(normalize_origin("chrome-extension://abc/"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pairing_store_persists_with_private_mode() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("pairings.json");
|
|
||||||
let s = PairingStore::open(path.clone()).unwrap();
|
|
||||||
s.add(StoredPairing {
|
|
||||||
id: "a".into(),
|
|
||||||
name: "n".into(),
|
|
||||||
caller: String::new(),
|
|
||||||
words: vec![],
|
|
||||||
created_at: 1,
|
|
||||||
key: B64.encode([1u8; 32]),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(PairingStore::open(path.clone()).unwrap().list().len(), 1);
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
|
||||||
}
|
|
||||||
assert!(s.remove("a").unwrap());
|
|
||||||
assert!(PairingStore::open(path).unwrap().list().is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
//! Pairing channel between the browser extension and the app (docs/05 "Pairing", threat A8).
|
|
||||||
//!
|
|
||||||
//! The same construction is implemented with WebCrypto in kestrove-browser/src/channel.ts;
|
|
||||||
//! the test vectors below are asserted on both sides.
|
|
||||||
//!
|
|
||||||
//! 1. **Pair.** Both sides generate an X25519 key. With `ss = X25519(ext, app)` and
|
|
||||||
//! `salt = SHA-256("kestrove/v1/pair" || ext_pub || app_pub)`:
|
|
||||||
//! `pairing_key = HKDF-SHA256(ss, salt, "kestrove/v1/pair/key", 32)` and
|
|
||||||
//! `fingerprint = HKDF-SHA256(ss, salt, "kestrove/v1/pair/fingerprint", 6)`, shown as 6 words
|
|
||||||
//! in both UIs. The user confirms they match in the app (which is what makes the pairing
|
|
||||||
//! trusted) and in the extension. A man in the middle would have to win a 2^-48 collision.
|
|
||||||
//! 2. **Session.** Per connection, each side sends a random 16-byte nonce and
|
|
||||||
//! `session_key = HKDF-SHA256(pairing_key, nonce_ext || nonce_app, "kestrove/v1/session", 32)`.
|
|
||||||
//! Fresh nonces on both sides mean a recorded session can't be replayed in a new one.
|
|
||||||
//! 3. **Messages.** AES-256-GCM, nonce `dir || 0x000000 || counter_be64`, AAD `"kestrove/v1/msg"`.
|
|
||||||
//! `dir` is 1 for extension → app and 2 for app → extension (no reflection). Each side expects
|
|
||||||
//! the exact next counter, so replays, drops and reordering within a session all fail.
|
|
||||||
|
|
||||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
|
||||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
|
||||||
use hkdf::Hkdf;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use x25519_dalek::{PublicKey, StaticSecret};
|
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
use crate::words::WORDS;
|
|
||||||
|
|
||||||
pub const DIR_EXT_TO_APP: u8 = 1;
|
|
||||||
pub const DIR_APP_TO_EXT: u8 = 2;
|
|
||||||
const DIR_CONFIRM: u8 = 3;
|
|
||||||
const MSG_AAD: &[u8] = b"kestrove/v1/msg";
|
|
||||||
const CONFIRM_AAD: &[u8] = b"kestrove/v1/pair/confirm";
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub struct CryptoFailure;
|
|
||||||
|
|
||||||
pub fn random<const N: usize>() -> [u8; N] {
|
|
||||||
let mut b = [0u8; N];
|
|
||||||
getrandom::getrandom(&mut b).expect("OS RNG unavailable");
|
|
||||||
b
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The app's half of a pairing handshake.
|
|
||||||
pub struct Ephemeral {
|
|
||||||
secret: StaticSecret,
|
|
||||||
pub public: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ephemeral {
|
|
||||||
pub fn generate() -> Self {
|
|
||||||
Self::from_secret(random())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_secret(bytes: [u8; 32]) -> Self {
|
|
||||||
let secret = StaticSecret::from(bytes);
|
|
||||||
let public = PublicKey::from(&secret).to_bytes();
|
|
||||||
Self { secret, public }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The app's derivation of the pairing key and words. `None` for a low-order peer key.
|
|
||||||
pub fn pair(&self, ext_pub: &[u8; 32]) -> Option<Pairing> {
|
|
||||||
let ss = self.secret.diffie_hellman(&PublicKey::from(*ext_pub));
|
|
||||||
ss.was_contributory().then(|| Pairing::derive(ss.as_bytes(), ext_pub, &self.public))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The extension's derivation (used by tests that play the extension).
|
|
||||||
pub fn pair_as_ext(&self, app_pub: &[u8; 32]) -> Option<Pairing> {
|
|
||||||
let ss = self.secret.diffie_hellman(&PublicKey::from(*app_pub));
|
|
||||||
ss.was_contributory().then(|| Pairing::derive(ss.as_bytes(), &self.public, app_pub))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Pairing {
|
|
||||||
pub key: Zeroizing<[u8; 32]>,
|
|
||||||
pub words: [&'static str; 6],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Pairing {
|
|
||||||
pub fn derive(shared: &[u8; 32], ext_pub: &[u8; 32], app_pub: &[u8; 32]) -> Self {
|
|
||||||
let salt =
|
|
||||||
Sha256::new().chain_update(b"kestrove/v1/pair").chain_update(ext_pub).chain_update(app_pub).finalize();
|
|
||||||
let hk = Hkdf::<Sha256>::new(Some(&salt), shared);
|
|
||||||
let mut key = Zeroizing::new([0u8; 32]);
|
|
||||||
hk.expand(b"kestrove/v1/pair/key", key.as_mut()).expect("32 bytes is a valid HKDF length");
|
|
||||||
let mut fp = [0u8; 6];
|
|
||||||
hk.expand(b"kestrove/v1/pair/fingerprint", &mut fp).expect("6 bytes is a valid HKDF length");
|
|
||||||
Self { key, words: fp.map(|b| WORDS[b as usize]) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cipher(key: &[u8; 32]) -> Aes256Gcm {
|
|
||||||
Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nonce(dir: u8, n: u64) -> [u8; 12] {
|
|
||||||
let mut out = [0u8; 12];
|
|
||||||
out[0] = dir;
|
|
||||||
out[4..].copy_from_slice(&n.to_be_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks the extension's proof that it derived the same pairing key: AES-GCM of the pairing id.
|
|
||||||
pub fn verify_confirm(pairing_key: &[u8; 32], pairing_id: &str, proof: &[u8]) -> bool {
|
|
||||||
cipher(pairing_key)
|
|
||||||
.decrypt(Nonce::from_slice(&nonce(DIR_CONFIRM, 0)), Payload { msg: proof, aad: CONFIRM_AAD })
|
|
||||||
.is_ok_and(|pt| pt == pairing_id.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The extension's proof (tests play the extension).
|
|
||||||
pub fn make_confirm(pairing_key: &[u8; 32], pairing_id: &str) -> Vec<u8> {
|
|
||||||
cipher(pairing_key)
|
|
||||||
.encrypt(Nonce::from_slice(&nonce(DIR_CONFIRM, 0)), Payload { msg: pairing_id.as_bytes(), aad: CONFIRM_AAD })
|
|
||||||
.expect("AES-GCM encryption cannot fail for small inputs")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One encrypted session over one connection.
|
|
||||||
pub struct Session {
|
|
||||||
cipher: Aes256Gcm,
|
|
||||||
send_dir: u8,
|
|
||||||
recv_dir: u8,
|
|
||||||
send_n: u64,
|
|
||||||
recv_n: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Session {
|
|
||||||
pub fn key(pairing_key: &[u8; 32], nonce_ext: &[u8; 16], nonce_app: &[u8; 16]) -> Zeroizing<[u8; 32]> {
|
|
||||||
let mut salt = [0u8; 32];
|
|
||||||
salt[..16].copy_from_slice(nonce_ext);
|
|
||||||
salt[16..].copy_from_slice(nonce_app);
|
|
||||||
let mut key = Zeroizing::new([0u8; 32]);
|
|
||||||
Hkdf::<Sha256>::new(Some(&salt), pairing_key)
|
|
||||||
.expand(b"kestrove/v1/session", key.as_mut())
|
|
||||||
.expect("32 bytes is a valid HKDF length");
|
|
||||||
key
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The app's end: receives extension → app, sends app → extension.
|
|
||||||
pub fn app(pairing_key: &[u8; 32], nonce_ext: &[u8; 16], nonce_app: &[u8; 16]) -> Self {
|
|
||||||
Self::new(&Self::key(pairing_key, nonce_ext, nonce_app), DIR_APP_TO_EXT, DIR_EXT_TO_APP)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The extension's end (tests stand in for the extension).
|
|
||||||
pub fn ext(pairing_key: &[u8; 32], nonce_ext: &[u8; 16], nonce_app: &[u8; 16]) -> Self {
|
|
||||||
Self::new(&Self::key(pairing_key, nonce_ext, nonce_app), DIR_EXT_TO_APP, DIR_APP_TO_EXT)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new(key: &[u8; 32], send_dir: u8, recv_dir: u8) -> Self {
|
|
||||||
Self { cipher: cipher(key), send_dir, recv_dir, send_n: 0, recv_n: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn seal(&mut self, plaintext: &[u8]) -> (u64, Vec<u8>) {
|
|
||||||
let n = self.send_n;
|
|
||||||
self.send_n += 1;
|
|
||||||
let ct = self
|
|
||||||
.cipher
|
|
||||||
.encrypt(Nonce::from_slice(&nonce(self.send_dir, n)), Payload { msg: plaintext, aad: MSG_AAD })
|
|
||||||
.expect("AES-GCM encryption cannot fail for small inputs");
|
|
||||||
(n, ct)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the next message. Anything but the exact next counter is rejected (replay, reorder).
|
|
||||||
pub fn open(&mut self, n: u64, ciphertext: &[u8]) -> Result<Zeroizing<Vec<u8>>, CryptoFailure> {
|
|
||||||
if n != self.recv_n {
|
|
||||||
return Err(CryptoFailure);
|
|
||||||
}
|
|
||||||
let pt = self
|
|
||||||
.cipher
|
|
||||||
.decrypt(Nonce::from_slice(&nonce(self.recv_dir, n)), Payload { msg: ciphertext, aad: MSG_AAD })
|
|
||||||
.map_err(|_| CryptoFailure)?;
|
|
||||||
self.recv_n += 1;
|
|
||||||
Ok(Zeroizing::new(pt))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn hex(b: &[u8]) -> String {
|
|
||||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixed vectors, also asserted in kestrove-browser/test/channel.test.ts.
|
|
||||||
#[test]
|
|
||||||
fn vectors() {
|
|
||||||
let ext = Ephemeral::from_secret([1u8; 32]);
|
|
||||||
let app = Ephemeral::from_secret([2u8; 32]);
|
|
||||||
assert_eq!(hex(&ext.public), "a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209");
|
|
||||||
assert_eq!(hex(&app.public), "ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59");
|
|
||||||
let p = app.pair(&ext.public).unwrap();
|
|
||||||
let q = ext.pair_as_ext(&app.public).unwrap();
|
|
||||||
assert_eq!((p.words, p.key.as_ref()), (q.words, q.key.as_ref()));
|
|
||||||
let fp = p.words.join(" ");
|
|
||||||
let key = hex(p.key.as_ref());
|
|
||||||
assert_eq!(fp, VECTOR_WORDS);
|
|
||||||
assert_eq!(key, VECTOR_KEY);
|
|
||||||
|
|
||||||
let mut s = Session::ext(&p.key, &[3u8; 16], &[4u8; 16]);
|
|
||||||
let (n, ct) = s.seal(br#"{"rid":0}"#);
|
|
||||||
assert_eq!(n, 0);
|
|
||||||
assert_eq!(hex(&ct), VECTOR_CT0);
|
|
||||||
assert_eq!(hex(&make_confirm(&p.key, "pid")), VECTOR_CONFIRM);
|
|
||||||
}
|
|
||||||
|
|
||||||
const VECTOR_WORDS: &str = "cargo bucket blade cricket bamboo radar";
|
|
||||||
const VECTOR_KEY: &str = "7dd958e1408eb2e772ed71c44763a05c7df77f6c7a6620d2e7e9049ec4573ae7";
|
|
||||||
const VECTOR_CT0: &str = "f4204547c03bc11c2c0f1969ebcfd68985571ff7e10f7a6bd6";
|
|
||||||
const VECTOR_CONFIRM: &str = "42eea959ea11c1e5aadcbcd3118c2f5878bab1";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_rejects_replay_reorder_and_reflection() {
|
|
||||||
let key = [9u8; 32];
|
|
||||||
let (ne, na) = ([1u8; 16], [2u8; 16]);
|
|
||||||
let mut ext = Session::ext(&key, &ne, &na);
|
|
||||||
let mut app = Session::app(&key, &ne, &na);
|
|
||||||
|
|
||||||
let (n0, c0) = ext.seal(b"one");
|
|
||||||
let (n1, c1) = ext.seal(b"two");
|
|
||||||
assert_eq!(app.open(n1, &c1), Err(CryptoFailure)); // out of order
|
|
||||||
assert_eq!(app.open(n0, &c0).unwrap().as_slice(), b"one");
|
|
||||||
assert_eq!(app.open(n0, &c0), Err(CryptoFailure)); // replay
|
|
||||||
assert_eq!(app.open(n1, &c1).unwrap().as_slice(), b"two");
|
|
||||||
|
|
||||||
// An app → ext message can't be reflected back to the app.
|
|
||||||
let (rn, rc) = app.seal(b"reply");
|
|
||||||
let mut app2 = Session::app(&key, &ne, &na);
|
|
||||||
assert_eq!(app2.open(rn, &rc), Err(CryptoFailure));
|
|
||||||
assert_eq!(ext.open(rn, &rc).unwrap().as_slice(), b"reply");
|
|
||||||
|
|
||||||
// A different session (new app nonce) can't open old traffic.
|
|
||||||
let mut other = Session::app(&key, &ne, &[5u8; 16]);
|
|
||||||
assert_eq!(other.open(n0, &c0), Err(CryptoFailure));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn confirm_proof_binds_key_and_id() {
|
|
||||||
let key = [7u8; 32];
|
|
||||||
let proof = make_confirm(&key, "abc");
|
|
||||||
assert!(verify_confirm(&key, "abc", &proof));
|
|
||||||
assert!(!verify_confirm(&key, "abd", &proof));
|
|
||||||
assert!(!verify_confirm(&[8u8; 32], "abc", &proof));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_low_order_point() {
|
|
||||||
assert!(Ephemeral::generate().pair(&[0u8; 32]).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//! Length-prefixed JSON frames, the native-messaging wire format (a u32 length in native byte
|
|
||||||
//! order, then UTF-8 JSON). The same framing is used on the local IPC channel between
|
|
||||||
//! `kestrove-nmh` and the app, so the host can relay frames without parsing them.
|
|
||||||
|
|
||||||
use std::io::{self, Read, Write};
|
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
/// Chrome caps host → browser messages at 1 MiB; browser → host at 4 GiB, but we never need more than this.
|
|
||||||
pub const MAX_FRAME: usize = 1024 * 1024;
|
|
||||||
|
|
||||||
/// Reads one raw frame. `Ok(None)` on a clean EOF before the length prefix.
|
|
||||||
pub fn read_frame(r: &mut impl Read) -> io::Result<Option<Vec<u8>>> {
|
|
||||||
let mut len = [0u8; 4];
|
|
||||||
match r.read_exact(&mut len) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
}
|
|
||||||
let len = u32::from_ne_bytes(len) as usize;
|
|
||||||
if len > MAX_FRAME {
|
|
||||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
|
|
||||||
}
|
|
||||||
let mut buf = vec![0u8; len];
|
|
||||||
r.read_exact(&mut buf)?;
|
|
||||||
Ok(Some(buf))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_frame(w: &mut impl Write, bytes: &[u8]) -> io::Result<()> {
|
|
||||||
if bytes.len() > MAX_FRAME {
|
|
||||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
|
|
||||||
}
|
|
||||||
w.write_all(&(bytes.len() as u32).to_ne_bytes())?;
|
|
||||||
w.write_all(bytes)?;
|
|
||||||
w.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_json(r: &mut impl Read) -> io::Result<Option<Value>> {
|
|
||||||
match read_frame(r)? {
|
|
||||||
Some(b) => serde_json::from_slice(&b).map(Some).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_json(w: &mut impl Write, v: &Value) -> io::Result<()> {
|
|
||||||
write_frame(w, &serde_json::to_vec(v)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_and_eof() {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
write_json(&mut buf, &json!({"t": "ping"})).unwrap();
|
|
||||||
let mut r = buf.as_slice();
|
|
||||||
assert_eq!(read_json(&mut r).unwrap(), Some(json!({"t": "ping"})));
|
|
||||||
assert_eq!(read_json(&mut r).unwrap(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_oversized_length() {
|
|
||||||
let bytes = ((MAX_FRAME + 1) as u32).to_ne_bytes();
|
|
||||||
assert!(read_frame(&mut bytes.as_slice()).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
//! Local IPC between `kestrove-nmh` (started by the browser) and the running app.
|
|
||||||
//!
|
|
||||||
//! * Unix: a socket in a 0700 per-user directory, socket mode 0600, and a peer-credential check
|
|
||||||
//! on both ends (`SO_PEERCRED` on Linux, `getpeereid` on macOS/BSD). Linux uses
|
|
||||||
//! `$XDG_RUNTIME_DIR/kestrove/nmh.sock`; macOS uses the per-user temp dir from
|
|
||||||
//! `confstr(_CS_DARWIN_USER_TEMP_DIR)`.
|
|
||||||
//! * Windows: the named pipe `\\.\pipe\kestrove-nmh-<user>`, created first-instance-only (no
|
|
||||||
//! squatting), local clients only, with a DACL for the current user, plus a client token check.
|
|
||||||
//!
|
|
||||||
//! The channel carries only already-encrypted pairing frames (see `channel`), so this layer keeps
|
|
||||||
//! other users and remote machines out; the pairing key keeps other same-user processes out.
|
|
||||||
//! Traffic is strictly request → response: no concurrent read and write on one connection
|
|
||||||
//! (synchronous Windows pipe handles serialize I/O, so that would deadlock).
|
|
||||||
|
|
||||||
use std::io::{self, Read, Write};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Overrides the IPC directory (Unix) or pipe name suffix (Windows). Tests use it to run an
|
|
||||||
/// isolated app; the browser passes its environment through to the host, so a browser started
|
|
||||||
/// with it set reaches that app.
|
|
||||||
pub const ENV_IPC_DIR: &str = "KESTROVE_IPC_DIR";
|
|
||||||
|
|
||||||
pub use imp::{connect, Conn, Listener};
|
|
||||||
|
|
||||||
/// Where the socket lives (Unix) or a display string for the pipe (Windows).
|
|
||||||
pub fn endpoint() -> io::Result<PathBuf> {
|
|
||||||
imp::endpoint()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Read for Conn {
|
|
||||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
|
||||||
self.0.read(buf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Write for Conn {
|
|
||||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
||||||
self.0.write(buf)
|
|
||||||
}
|
|
||||||
fn flush(&mut self) -> io::Result<()> {
|
|
||||||
self.0.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
mod imp {
|
|
||||||
use std::fs;
|
|
||||||
use std::io;
|
|
||||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
|
|
||||||
use std::os::unix::io::AsRawFd;
|
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use super::ENV_IPC_DIR;
|
|
||||||
|
|
||||||
pub struct Conn(pub(super) UnixStream);
|
|
||||||
|
|
||||||
pub struct Listener {
|
|
||||||
inner: UnixListener,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uid() -> u32 {
|
|
||||||
// SAFETY: geteuid has no preconditions and cannot fail.
|
|
||||||
unsafe { libc::geteuid() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_dir() -> io::Result<PathBuf> {
|
|
||||||
if let Some(d) = std::env::var_os(ENV_IPC_DIR).filter(|d| !d.is_empty()) {
|
|
||||||
return Ok(PathBuf::from(d));
|
|
||||||
}
|
|
||||||
platform_dir()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn platform_dir() -> io::Result<PathBuf> {
|
|
||||||
use std::ffi::{CStr, OsStr};
|
|
||||||
use std::os::unix::ffi::OsStrExt;
|
|
||||||
let mut buf = vec![0u8; 1024];
|
|
||||||
// SAFETY: buf is valid for buf.len() bytes; confstr NUL-terminates within that length.
|
|
||||||
let n = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buf.as_mut_ptr().cast(), buf.len()) };
|
|
||||||
if n == 0 || n > buf.len() {
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
let s = CStr::from_bytes_until_nul(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
||||||
Ok(PathBuf::from(OsStr::from_bytes(s.to_bytes())).join("kestrove"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
fn platform_dir() -> io::Result<PathBuf> {
|
|
||||||
match std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from) {
|
|
||||||
Some(d) if d.is_absolute() => Ok(d.join("kestrove")),
|
|
||||||
// No runtime dir (e.g. no systemd session): a per-uid dir in /tmp, ownership checked in secure_dir.
|
|
||||||
_ => Ok(PathBuf::from(format!("/tmp/kestrove-{}", uid()))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn endpoint() -> io::Result<PathBuf> {
|
|
||||||
Ok(base_dir()?.join("nmh.sock"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates the directory 0700, or checks that an existing one is ours and not group/world accessible.
|
|
||||||
fn secure_dir(dir: &std::path::Path) -> io::Result<()> {
|
|
||||||
match fs::DirBuilder::new().recursive(true).mode(0o700).create(dir) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
}
|
|
||||||
let meta = fs::symlink_metadata(dir)?;
|
|
||||||
if !meta.is_dir() || meta.uid() != uid() {
|
|
||||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC directory is not owned by this user"));
|
|
||||||
}
|
|
||||||
if meta.mode() & 0o077 != 0 {
|
|
||||||
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
|
||||||
pub(super) fn peer_uid(s: &UnixStream) -> io::Result<u32> {
|
|
||||||
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 };
|
|
||||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
|
||||||
// SAFETY: cred and len are valid out-pointers of the sizes passed.
|
|
||||||
let rc = unsafe {
|
|
||||||
libc::getsockopt(
|
|
||||||
s.as_raw_fd(),
|
|
||||||
libc::SOL_SOCKET,
|
|
||||||
libc::SO_PEERCRED,
|
|
||||||
(&mut cred as *mut libc::ucred).cast(),
|
|
||||||
&mut len,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if rc != 0 {
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
Ok(cred.uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
|
||||||
pub(super) fn peer_uid(s: &UnixStream) -> io::Result<u32> {
|
|
||||||
let (mut uid, mut gid) = (0, 0);
|
|
||||||
// SAFETY: uid and gid are valid out-pointers.
|
|
||||||
if unsafe { libc::getpeereid(s.as_raw_fd(), &mut uid, &mut gid) } != 0 {
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
Ok(uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_peer(s: &UnixStream) -> io::Result<()> {
|
|
||||||
if peer_uid(s)? != uid() {
|
|
||||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC peer is a different user"));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Listener {
|
|
||||||
/// Binds the socket. Fails with `AddrInUse` if another app instance is already serving it.
|
|
||||||
pub fn bind() -> io::Result<Self> {
|
|
||||||
let path = endpoint()?;
|
|
||||||
secure_dir(path.parent().expect("socket path has a parent"))?;
|
|
||||||
if path.exists() {
|
|
||||||
if UnixStream::connect(&path).is_ok() {
|
|
||||||
return Err(io::Error::new(io::ErrorKind::AddrInUse, "Kestrove is already running"));
|
|
||||||
}
|
|
||||||
fs::remove_file(&path)?; // stale socket from a crashed instance
|
|
||||||
}
|
|
||||||
let inner = UnixListener::bind(&path)?;
|
|
||||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
|
||||||
Ok(Self { inner, path })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blocks for the next same-user client. Other users' connections are dropped and skipped.
|
|
||||||
pub fn accept(&self) -> io::Result<Conn> {
|
|
||||||
loop {
|
|
||||||
let (s, _) = self.inner.accept()?;
|
|
||||||
if check_peer(&s).is_ok() {
|
|
||||||
// A stuck peer must not pin a thread forever.
|
|
||||||
s.set_read_timeout(Some(Duration::from_secs(600)))?;
|
|
||||||
return Ok(Conn(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Listener {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = fs::remove_file(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connect() -> io::Result<Conn> {
|
|
||||||
let s = UnixStream::connect(endpoint()?)?;
|
|
||||||
check_peer(&s)?; // don't talk to a socket another user planted
|
|
||||||
Ok(Conn(s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
mod imp {
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io;
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
|
||||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::ptr;
|
|
||||||
|
|
||||||
use windows_sys::Win32::Foundation::{
|
|
||||||
CloseHandle, GetLastError, LocalFree, ERROR_PIPE_CONNECTED, GENERIC_READ, GENERIC_WRITE, HANDLE,
|
|
||||||
INVALID_HANDLE_VALUE,
|
|
||||||
};
|
|
||||||
use windows_sys::Win32::Security::Authorization::{
|
|
||||||
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
|
||||||
};
|
|
||||||
use windows_sys::Win32::Security::{
|
|
||||||
EqualSid, GetTokenInformation, TokenUser, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER,
|
|
||||||
};
|
|
||||||
use windows_sys::Win32::Storage::FileSystem::{
|
|
||||||
CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, OPEN_EXISTING, PIPE_ACCESS_DUPLEX,
|
|
||||||
};
|
|
||||||
use windows_sys::Win32::System::Pipes::{
|
|
||||||
ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, GetNamedPipeServerProcessId,
|
|
||||||
PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
|
|
||||||
};
|
|
||||||
use windows_sys::Win32::System::Threading::{
|
|
||||||
GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::ENV_IPC_DIR;
|
|
||||||
|
|
||||||
pub struct Conn(pub(super) File);
|
|
||||||
|
|
||||||
pub struct Listener {
|
|
||||||
name: Vec<u16>,
|
|
||||||
sddl: Vec<u16>,
|
|
||||||
first: std::sync::atomic::AtomicBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wide(s: &str) -> Vec<u16> {
|
|
||||||
std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pipe_name() -> String {
|
|
||||||
let suffix = std::env::var(ENV_IPC_DIR)
|
|
||||||
.ok()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.or_else(|| std::env::var("USERNAME").ok())
|
|
||||||
.unwrap_or_else(|| "user".into());
|
|
||||||
let clean: String = suffix.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
|
|
||||||
format!(r"\\.\pipe\kestrove-nmh-{clean}")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn endpoint() -> io::Result<PathBuf> {
|
|
||||||
Ok(PathBuf::from(pipe_name()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The TOKEN_USER buffer of a process (the SID pointer points into it).
|
|
||||||
fn token_user(process: HANDLE) -> io::Result<Vec<u8>> {
|
|
||||||
let mut token: HANDLE = ptr::null_mut();
|
|
||||||
// SAFETY: valid process handle and out-pointer.
|
|
||||||
if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 {
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
// SAFETY: we own the token handle from here on.
|
|
||||||
let token = unsafe { OwnedHandle::from_raw_handle(token) };
|
|
||||||
let mut len = 0u32;
|
|
||||||
// SAFETY: a size query with a null buffer is the documented pattern.
|
|
||||||
unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, ptr::null_mut(), 0, &mut len) };
|
|
||||||
let mut buf = vec![0u8; len as usize];
|
|
||||||
// SAFETY: buf has len bytes.
|
|
||||||
if unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, buf.as_mut_ptr().cast(), len, &mut len) } == 0
|
|
||||||
{
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
Ok(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sid_of(buf: &[u8]) -> *mut core::ffi::c_void {
|
|
||||||
// SAFETY: buf came from GetTokenInformation(TokenUser) and starts with a TOKEN_USER.
|
|
||||||
unsafe { (*(buf.as_ptr() as *const TOKEN_USER)).User.Sid }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn same_user_as_process(pid: u32) -> io::Result<()> {
|
|
||||||
// SAFETY: plain Win32 calls; every handle is closed before returning.
|
|
||||||
unsafe {
|
|
||||||
let ours = token_user(GetCurrentProcess())?;
|
|
||||||
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
|
||||||
if h.is_null() {
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
let theirs = token_user(h);
|
|
||||||
CloseHandle(h);
|
|
||||||
let theirs = theirs?;
|
|
||||||
if EqualSid(sid_of(&ours), sid_of(&theirs)) == 0 {
|
|
||||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC peer is a different user"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SDDL granting full access to the current user only (no Everyone read like the default DACL).
|
|
||||||
fn user_only_sddl() -> io::Result<Vec<u16>> {
|
|
||||||
// SAFETY: ConvertSidToStringSidW allocates with LocalAlloc; we free it after copying.
|
|
||||||
unsafe {
|
|
||||||
let ours = token_user(GetCurrentProcess())?;
|
|
||||||
let mut s: *mut u16 = ptr::null_mut();
|
|
||||||
if ConvertSidToStringSidW(sid_of(&ours), &mut s) == 0 {
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
let mut n = 0;
|
|
||||||
while *s.add(n) != 0 {
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
let sid = String::from_utf16_lossy(std::slice::from_raw_parts(s, n));
|
|
||||||
LocalFree(s.cast());
|
|
||||||
Ok(wide(&format!("D:P(A;;GA;;;{sid})")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Listener {
|
|
||||||
pub fn bind() -> io::Result<Self> {
|
|
||||||
let me = Self {
|
|
||||||
name: wide(&pipe_name()),
|
|
||||||
sddl: user_only_sddl()?,
|
|
||||||
first: std::sync::atomic::AtomicBool::new(true),
|
|
||||||
};
|
|
||||||
Ok(me)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_instance(&self) -> io::Result<HANDLE> {
|
|
||||||
let first = self.first.swap(false, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
let mut sd: PSECURITY_DESCRIPTOR = ptr::null_mut();
|
|
||||||
// SAFETY: sddl is NUL-terminated; sd is freed with LocalFree below.
|
|
||||||
unsafe {
|
|
||||||
if ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
|
||||||
self.sddl.as_ptr(),
|
|
||||||
SDDL_REVISION_1,
|
|
||||||
&mut sd,
|
|
||||||
ptr::null_mut(),
|
|
||||||
) == 0
|
|
||||||
{
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
let sa = SECURITY_ATTRIBUTES {
|
|
||||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
|
||||||
lpSecurityDescriptor: sd,
|
|
||||||
bInheritHandle: 0,
|
|
||||||
};
|
|
||||||
let h = CreateNamedPipeW(
|
|
||||||
self.name.as_ptr(),
|
|
||||||
PIPE_ACCESS_DUPLEX | if first { FILE_FLAG_FIRST_PIPE_INSTANCE } else { 0 },
|
|
||||||
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
|
|
||||||
PIPE_UNLIMITED_INSTANCES,
|
|
||||||
64 * 1024,
|
|
||||||
64 * 1024,
|
|
||||||
0,
|
|
||||||
&sa,
|
|
||||||
);
|
|
||||||
LocalFree(sd);
|
|
||||||
if h == INVALID_HANDLE_VALUE {
|
|
||||||
let e = io::Error::last_os_error();
|
|
||||||
return Err(if first { io::Error::new(io::ErrorKind::AddrInUse, e) } else { e });
|
|
||||||
}
|
|
||||||
Ok(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn accept(&self) -> io::Result<Conn> {
|
|
||||||
loop {
|
|
||||||
let h = self.create_instance()?;
|
|
||||||
// SAFETY: h is a fresh pipe handle owned by `file` from here on.
|
|
||||||
let file = unsafe { File::from_raw_handle(h) };
|
|
||||||
// SAFETY: blocking connect on our own pipe handle.
|
|
||||||
let ok = unsafe { ConnectNamedPipe(h, ptr::null_mut()) } != 0
|
|
||||||
|| unsafe { GetLastError() } == ERROR_PIPE_CONNECTED;
|
|
||||||
if !ok {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut pid = 0u32;
|
|
||||||
// SAFETY: valid handle and out-pointer.
|
|
||||||
if unsafe { GetNamedPipeClientProcessId(h, &mut pid) } != 0 && same_user_as_process(pid).is_ok() {
|
|
||||||
return Ok(Conn(file));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connect() -> io::Result<Conn> {
|
|
||||||
let name = wide(&pipe_name());
|
|
||||||
// SAFETY: name is NUL-terminated; the handle is owned by File on success.
|
|
||||||
let h = unsafe {
|
|
||||||
CreateFileW(name.as_ptr(), GENERIC_READ | GENERIC_WRITE, 0, ptr::null(), OPEN_EXISTING, 0, ptr::null_mut())
|
|
||||||
};
|
|
||||||
if h == INVALID_HANDLE_VALUE {
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
// SAFETY: h is a valid, owned handle.
|
|
||||||
let file = unsafe { File::from_raw_handle(h) };
|
|
||||||
let mut pid = 0u32;
|
|
||||||
// SAFETY: valid handle and out-pointer.
|
|
||||||
if unsafe { GetNamedPipeServerProcessId(h, &mut pid) } == 0 {
|
|
||||||
return Err(io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
same_user_as_process(pid)?;
|
|
||||||
Ok(Conn(file))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::framing::{read_json, write_json};
|
|
||||||
use serde_json::json;
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn socket_is_private_and_round_trips() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let ipc_dir = dir.path().join("ipc");
|
|
||||||
// SAFETY: tests in this module are the only ones touching this variable.
|
|
||||||
unsafe { std::env::set_var(ENV_IPC_DIR, &ipc_dir) };
|
|
||||||
let l = Listener::bind().unwrap();
|
|
||||||
let path = endpoint().unwrap();
|
|
||||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
|
||||||
assert_eq!(std::fs::metadata(&ipc_dir).unwrap().permissions().mode() & 0o777, 0o700);
|
|
||||||
|
|
||||||
let t = std::thread::spawn(move || {
|
|
||||||
let mut c = l.accept().unwrap();
|
|
||||||
let v = read_json(&mut c).unwrap().unwrap();
|
|
||||||
write_json(&mut c, &json!({"echo": v})).unwrap();
|
|
||||||
l
|
|
||||||
});
|
|
||||||
let mut c = connect().unwrap();
|
|
||||||
write_json(&mut c, &json!({"t": "ping"})).unwrap();
|
|
||||||
assert_eq!(read_json(&mut c).unwrap(), Some(json!({"echo": {"t": "ping"}})));
|
|
||||||
let l = t.join().unwrap();
|
|
||||||
// A second instance sees the live socket and refuses to steal it.
|
|
||||||
assert_eq!(Listener::bind().err().map(|e| e.kind()), Some(io::ErrorKind::AddrInUse));
|
|
||||||
drop(l);
|
|
||||||
assert!(!path.exists(), "socket removed on shutdown");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
//! Kestrove desktop internals shared by the app, `kestrove-nmh` and the integration tests.
|
//! Kestrove desktop internals shared by the app and the integration tests.
|
||||||
|
//!
|
||||||
|
//! The browser bridge (broker, pairing channel, local IPC, `kestrove-nmh`) lives in kestrove-core, where the
|
||||||
|
//! macOS app uses it too; the old module paths are kept as re-exports.
|
||||||
|
|
||||||
pub mod autolock;
|
pub mod autolock;
|
||||||
pub mod broker;
|
|
||||||
pub mod channel;
|
|
||||||
pub mod device_key;
|
pub mod device_key;
|
||||||
pub mod framing;
|
|
||||||
pub mod ipc;
|
|
||||||
pub mod private_file;
|
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod title_bar;
|
pub mod title_bar;
|
||||||
pub mod words;
|
|
||||||
|
pub use kv_ffi::bridge::{broker, channel, words};
|
||||||
|
pub use kv_ipc::{framing, ipc, private_file};
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
//! Small files only this user may read (0600 on Unix; on Windows the per-user app-data ACL).
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::io::{self, Write};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Writes atomically (temp file + rename) with owner-only permissions.
|
|
||||||
pub fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
|
||||||
let tmp = path.with_extension("tmp");
|
|
||||||
let mut opts = fs::OpenOptions::new();
|
|
||||||
opts.write(true).create(true).truncate(true);
|
|
||||||
#[cfg(unix)]
|
|
||||||
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
|
||||||
let mut f = opts.open(&tmp)?;
|
|
||||||
f.write_all(bytes)?;
|
|
||||||
f.sync_all()?;
|
|
||||||
fs::rename(tmp, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Ok(None)` when the file doesn't exist.
|
|
||||||
pub fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {
|
|
||||||
match fs::read(path) {
|
|
||||||
Ok(b) => Ok(Some(b)),
|
|
||||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//! 256 words for pairing fingerprints: one word per byte, 6 words = 48 bits, compared by a person.
|
|
||||||
//! The same list, in the same order, is in kestrove-browser/src/words.ts.
|
|
||||||
|
|
||||||
pub const WORDS: [&str; 256] = [
|
|
||||||
"acid", "acorn", "actor", "adult", "agent", "alarm", "album", "alley", "amber", "angle", "ankle", "apple", "april",
|
|
||||||
"apron", "arena", "armor", "arrow", "atlas", "attic", "audio", "aunt", "autumn", "award", "bacon", "badge",
|
|
||||||
"bagel", "baker", "bamboo", "banjo", "barn", "basil", "basket", "beach", "beard", "beetle", "bell", "bench",
|
|
||||||
"berry", "bike", "birch", "bison", "blade", "blanket", "blimp", "bloom", "boat", "bonus", "boot", "bottle",
|
|
||||||
"boxer", "brain", "brick", "bridge", "broom", "brush", "bucket", "bugle", "cabin", "cable", "cactus", "camel",
|
|
||||||
"camera", "canal", "candle", "canoe", "canyon", "carbon", "cargo", "carpet", "castle", "cedar", "cello", "chalk",
|
|
||||||
"cherry", "chess", "chief", "cider", "cinema", "circus", "clay", "cliff", "clock", "cloud", "clover", "cobra",
|
|
||||||
"cocoa", "comet", "coral", "cotton", "cousin", "coyote", "crane", "crayon", "cricket", "crown", "cup", "daisy",
|
|
||||||
"dancer", "delta", "denim", "desert", "diary", "diesel", "dingo", "disco", "doctor", "dolphin", "donkey", "dragon",
|
|
||||||
"drum", "duck", "eagle", "earth", "easel", "echo", "eclipse", "elbow", "elder", "ember", "emerald", "engine",
|
|
||||||
"falcon", "fang", "farmer", "feather", "fence", "ferry", "fiddle", "finch", "flag", "flute", "forest", "fossil",
|
|
||||||
"fox", "frost", "galaxy", "garden", "garlic", "gecko", "ghost", "ginger", "giraffe", "glacier", "globe", "goat",
|
|
||||||
"gold", "gorilla", "grape", "gravel", "guitar", "hammer", "harbor", "harp", "hawk", "hazel", "helmet", "heron",
|
|
||||||
"hiker", "honey", "hornet", "hotel", "igloo", "index", "iris", "island", "ivory", "jacket", "jaguar", "jelly",
|
|
||||||
"jewel", "jungle", "kayak", "kettle", "kitten", "koala", "ladder", "lagoon", "lake", "lantern", "laser", "lemon",
|
|
||||||
"lentil", "lily", "lion", "lizard", "lobster", "locket", "lotus", "magnet", "mango", "maple", "marble", "meadow",
|
|
||||||
"melon", "mint", "mirror", "monkey", "moose", "mosaic", "motor", "mountain", "mulberry", "museum", "napkin",
|
|
||||||
"nectar", "needle", "nickel", "noodle", "novel", "nugget", "oasis", "ocean", "olive", "onion", "orange", "orbit",
|
|
||||||
"orchid", "otter", "owl", "oyster", "paddle", "palace", "panda", "paper", "parrot", "peach", "pebble", "pepper",
|
|
||||||
"piano", "pickle", "pilot", "pine", "planet", "plum", "poem", "pony", "puzzle", "quartz", "quill", "rabbit",
|
|
||||||
"radar", "radio", "raven", "ribbon", "river", "robin", "rocket", "rose", "ruby", "saddle", "salmon", "sandal",
|
|
||||||
"satin", "scarf", "scout", "sugar",
|
|
||||||
];
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
#[test]
|
|
||||||
fn unique() {
|
|
||||||
let mut v = super::WORDS.to_vec();
|
|
||||||
v.sort_unstable();
|
|
||||||
v.dedup();
|
|
||||||
assert_eq!(v.len(), 256);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user