Browser bridge in the core (shared by the Mac and desktop apps); webpki security update
- crates/kv-ipc: native-messaging framing and the local IPC socket/pipe (moved from kestrove-desktop), plus Listener::bind_in/connect_in and, on macOS, the App Group socket for the kestrove-nmh bundled in Kestrove.app (Contents/Resources/kestrove-app-group). - crates/kv-nmh: the native-messaging host, with an embedded Info.plist on macOS so it can run sandboxed (KV_NMH_BUNDLE_ID). - kv-ffi feature browser-bridge: broker, pairing channel and word list (moved from kestrove-desktop) and BrowserBridge for Swift. build-apple.sh enables it and builds a universal kestrove-nmh. - RUSTSEC-2026-0381/0382: rustls-webpki 0.103.16, webpki-roots 1.0.10 (distrusted emSign roots). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "kv-nmh"
|
||||
description = "Native messaging host for the browser extension: relays encrypted frames to the running app."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "kestrove-nmh"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kv-ipc = { path = "../kv-ipc" }
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,40 @@
|
||||
//! macOS: embed an Info.plist (`__TEXT,__info_plist`). The copy inside Kestrove.app is signed sandboxed, and the
|
||||
//! sandbox refuses to start an executable without a bundle identifier. `KV_NMH_BUNDLE_ID` is set by
|
||||
//! scripts/build-apple.sh to the app's bundle id + ".nmh"; codesign takes the signing identifier from it.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=KV_NMH_BUNDLE_ID");
|
||||
if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") {
|
||||
return;
|
||||
}
|
||||
let id = env::var("KV_NMH_BUNDLE_ID").unwrap_or_else(|_| "app.kestrove.nmh".into());
|
||||
assert!(
|
||||
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-'),
|
||||
"KV_NMH_BUNDLE_ID must be a reverse-DNS identifier"
|
||||
);
|
||||
let plist = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>{id}</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>kestrove-nmh</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{version}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#,
|
||||
version = env::var("CARGO_PKG_VERSION").unwrap()
|
||||
);
|
||||
let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join("Info.plist");
|
||||
fs::write(&out, plist).unwrap();
|
||||
println!("cargo:rustc-link-arg-bins=-Wl,-sectcreate,__TEXT,__info_plist,{}", out.display());
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 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 kv_ipc::framing::{read_frame, read_json, write_frame, write_json};
|
||||
use kv_ipc::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(())
|
||||
}
|
||||
Reference in New Issue
Block a user