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:
2026-10-16 20:13:54 +09:00
co-authored by Claude Opus 5.5
parent 30cdcee3d2
commit 923c466dea
44 changed files with 9289 additions and 270 deletions
+450
View File
@@ -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");
}
}