Use the browser bridge from kestrove-core; webpki security update
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

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:
2026-10-21 10:02:49 +09:00
co-authored by Claude Opus 5.5
parent cf61f385d3
commit 5e59f50abc
12 changed files with 46 additions and 1643 deletions
+12 -6
View File
@@ -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.
+2
View File
@@ -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"
+20 -17
View File
@@ -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",
] ]
+5 -7
View File
@@ -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"] }
-65
View File
@@ -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(())
}
-718
View File
@@ -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());
}
}
-248
View File
@@ -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());
}
}
-68
View File
@@ -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());
}
}
-442
View File
@@ -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");
}
}
+7 -7
View File
@@ -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};
-27
View 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),
}
}
-38
View File
@@ -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);
}
}