M3: working desktop app + browser pairing channel
- Builds and runs (tauri build --debug); placeholder icons; persistent VaultCore::open in the OS app-data dir; Secret Key remembered on the device; tray keeps the app serving the extension. - Auto-lock: idle (system idle on Windows/macOS, in-app activity on Linux), screen lock, sleep. - Local IPC for kestrove-nmh: 0700 dir + 0600 Unix socket with peer-uid checks; Windows named pipe (first instance, local only, user DACL, client SID check) behind cfg. - Pairing channel: X25519 → HKDF → 6-word fingerprint confirmed in both UIs, per-connection session keys, AES-256-GCM with strict counters. Test vectors shared with kestrove-browser. - Broker: query/fill/totp/save with origin re-checks; pairing and save prompts in the UI. - kestrove-nmh relays ciphertext only; native-host installers for Linux/macOS/Windows. - Tests: unit tests, tests/nmh_stdio.rs (real kestrove-nmh over stdio), examples/test-host. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user