rightkit-daemon
Local service discovery and authentication for Right Suite apps. It owns the convention for service.json discovery, a current-user-only transport (Unix socket on macOS and Unix, named pipe on Windows), per-app request allowlists, rank-based takeover, and the framed request, response, and event protocol built on rightkit-sidecar.
Use it when two or more local processes must share one resident service on a machine, or when a local service must accept calls only from known apps running as the current user.
Do not use it for network services. The transport is local only: no TCP, no tokens.
Features
local (default, the local transport described below), link (the outbound device link client, no tokio, axum or hyper), remote (the daemon host; implies link), testing (test host; implies remote), test-mode (see CHANGELOG.md).
Outbound link (link / remote)
A machine with no inbound port reaches the host over rk-link/1: a TLS connection upgraded with a device-signed GET /v1/link, then signed, numbered JSON-line frames in both directions. The host leases durable tasks to the device (Daemon::enqueue_task); exactly one device owns a task at a time, results are fenced by a lease epoch, a dropped link resumes and the host sends again every leased task the device has not acked, a lapsed lease re-queues once. The device side is rightkit_daemon::link::client::LinkClient (blocking, std and rustls only). See CHANGELOG.md (0.2.0) for the frame and lease rules.
Platform support
- macOS and the BSDs: socket in a 0700 directory, mode 0600, peer uid checked with
getpeereidon every accept and by every client. - Linux and Android: same, with
SO_PEERCRED(generic ABI; the mips, powerpc and sparc families reportUnsupportedrather than guess). - Other Unix: the peer check reports
PeerError::Unsupported, so the host refuses every peer. It never assumes the peer is you. - Windows: named pipe with a DACL that allows only the current user SID, remote clients rejected, first-instance flag set, at most
HostOptions::max_pipe_instancesinstances, client token SID checked on every accept,SECURITY_IDENTIFICATIONclients that check the server SID. - Verifying the host (
Client::connect_expecting): macOS, Linux and Windows. Other Unix refuses with the typedHostIdentityReason::Unverifieduntil implemented; it never connects unverified. See "Verifying the host". - Platform gates:
cfg(windows),cfg(unix),cfg(target_os = "macos"), andcfg(target_os = "linux")insrc/discovery.rs,src/host_identity.rs,src/record.rs,src/suite.rs, andsrc/transport.rs.
Published version: see INDEX.md at the repository root. Release notes: see CHANGELOG.md.
Example
The compiled examples live in examples/ (svc_host.rs, svc_call.rs, svc_claim.rs, svc_mute.rs, svc_race.rs, svc_fakehost.rs, svc_verify_client.rs). Run them through rightkit cargo run -p rightkit-daemon --example <name>.
Convention
The one discovery and authentication convention for local Right Suite services: the shared speech service, the in-app rightkit-qa-driver server, right-ctl, and any future one.
| Concern | Convention |
|---|---|
| Discovery | <suite>/runtime/<service>/service.json (atomic, owner-only). Stale when its endpoint does not answer. Only a missing file means no service: a record or takeover.json that cannot be read (a permission error, a sharing violation while another process replaces it) is "unknown, try again". Client::connect reports it as the retryable service_unavailable (could not read service.json), host election does not replace the record, and the takeover monitor reads the request again on its next pass. The try_read_*, try_adopt_or_spawn and try_plan_host functions return the distinction as an io::Error; the older infallible readers still answer "absent" for it. |
| Transport | macOS/Unix: socket in a 0700 dir, mode 0600, peer uid checked (getpeereid). Windows: named pipe, DACL = current user SID only, remote clients rejected, first-instance flag. No TCP, no tokens. |
| Frames | rightkit-sidecar JSON lines: hello, request, event, response; errors carry retryable. |
| Allowlist | Per calling app: which methods it may call. Unknown app rejected at hello. ping always allowed. |
| Ranking | Lower rank wins. A higher-ranked host writes takeover.json; the incumbent stops admitting work, lets in-flight requests reach a boundary (they return the retryable host_handoff), releases, and the requester binds. Clients re-read discovery and resubmit (Client::call_retrying). The incumbent looks for a request from another process once a second (a stat of takeover.json, no faster polling), so a takeover starts up to about a second after the request is written; a request written in the incumbent's own process wakes it at once. |
| Roots | Suite::resolve() fails closed when no absolute app-data root exists; Suite::per_run_temp() is the explicit alternative. Never the working directory. Default <app data>/Orthic Labs/shared (the root HeardRight, ScrapeRight and CutRight already use); RIGHTKIT_SUITE_ROOT, RIGHT_SUITE_DIR, ORTHIC_SHARED_DIR override. Those variables and HOME are chosen by whoever launches the process: release builds use Suite::per_user_default() / ClientConfig::ignore_env_overrides(true) instead (see "Verifying the host"). |
| Host identity | Client::connect checks that the host runs as you, not which program it is. Client::connect_expecting also requires the host's executable to be one you name, before any byte is written. |
Why a crate of its own and not a module of rightkit-sidecar: the sidecar crate is a published, dependency-light, unsafe-free framing library. The service needs OS FFI (getpeereid, named pipes, SDDL), threads and a filesystem convention. Keeping them apart lets stdio-only consumers stay minimal; the service depends on the sidecar for its frames.
Tests are end-to-end: real host processes, real clients over the real socket. The examples/ directory has svc_host.rs (host), svc_fakehost.rs, svc_call.rs and svc_claim.rs (clients), svc_verify_client.rs, svc_mute.rs, and svc_race.rs. RIGHTKIT_SUITE_ROOT (aliases RIGHT_SUITE_DIR, ORTHIC_SHARED_DIR) isolates a run from the user's installed apps.
Verifying the host
The transport proves the program that answered runs as you. It does not prove which program it is. A client that reads discovery from a file and a path that any same-user program can write, and that sends its hello and requests (and any secret in them, such as a pairing key) to whatever answers, can be pointed at an impostor.
How discovery works, and who can influence each input
Client::connect(suite, service, app):
suiteis whatever the caller built.Suite::resolve()takes it from the environment:RIGHTKIT_SUITE_ROOT, thenRIGHT_SUITE_DIR, thenORTHIC_SHARED_DIR, then the per-user default derived fromHOME(macOS),XDG_DATA_HOMEorHOME(Linux),LOCALAPPDATA(Windows).- It reads
<suite>/runtime/<service>/service.jsonwith no owner, mode or liveness check, and rejects a differentprotocol_major. - It connects to the record's
endpointexactly as written (a Unix socket path, a pipe name). It does not recompute it. - After the connect, and before any byte: Unix compares the peer's uid with its own (
getpeereid/SO_PEERCRED); Windows compares the pipe server process's token SID with its own. - It writes
hello, then requests.
| Input | Who controls it |
|---|---|
RIGHTKIT_SUITE_ROOT, RIGHT_SUITE_DIR, ORTHIC_SHARED_DIR, HOME, XDG_DATA_HOME, LOCALAPPDATA |
whoever launches the client |
service.json (so the endpoint, and a pid nobody reads) |
any process of the same user (0600 in a 0700 directory keeps other users out, not the same user) |
| the socket path, when no host holds it | any process of the same user can bind it |
TMPDIR, USERNAME |
feed endpoint naming (try_endpoint_for, and the name-based endpoint_candidates/find_live_endpoint); Client connects to the recorded endpoint, so they do not steer it |
Step 4 is the only check, and it says "same user". A same-user impostor at the end of a redirected root, a forged record or a squatted socket passes it and receives the hello.
The API
use ;
// An engine shipped inside the app (MailRight.app/Contents/Resources/bin/engine) names its
// app without a hard-coded path: <bundle>/Contents/MacOS/mailright.
let expect = bundle_executable.expect;
let config = new
.ignore_env_overrides; // release builds refuse environment redirection
match connect_expecting
HostExpectation::executable(path),any_of(paths),or_executable(path): named paths. An expectation with no path matches nothing.sibling_executable(name)(next to the running executable),bundle_executable(name)(<App>.app/Contents/MacOS/<name>of the bundle that contains the running executable),relative_to_current_exe(levels_up, path): derive the host path from where the running executable lives. These fail with anio::Errorwhen the layout does not fit; they never guess.with_verifier(|host: &HostProcess| ...): an extra check that runs after the executable matched, before any byte is written;Err(reason)refuses the host asHostIdentityReason::Refused.Client::verified_host(): theHostProcess(pid, uid, executable, macOS audit token) a verified client was connected to.NoneforClient::connect.Client::call_retrying_expecting,open_stream_expecting: the retrying and raw-stream entry points with the same check.Client::connect,connect_with,call_retrying,open_streamare unchanged and remain unauthenticated-host. Their documentation says so.
A refusal is ConnectError::HostIdentity(HostIdentityError { expected, found_pid, found_executable, reason }), distinct from ConnectError::Service. It is not retryable. Collapsed with into_service_error() it is ServiceError code host_identity.
Order: nothing is sent before the check
connect_expecting resolves the suite, reads the record, calls connect() on the socket, asks the kernel who is on the other end, and only then wraps the stream and writes hello. The kernel-level connect is the only thing that has happened on the wire when a host is refused: the impostor sees a connection that closes without a byte, and the client's stream is dropped on every error path. The tests assert it with a real impostor process that counts the bytes it receives (zero).
What each platform verifies
| Process | Executable | The pid is proven to name one process | |
|---|---|---|---|
| macOS | uid by getpeereid; pid by LOCAL_PEERPID (the process that last owned the accepted socket, normally the acceptor) |
proc_pidpath(pid) |
start time (proc_pidinfo PROC_PIDTBSDINFO) and the LOCAL_PEERTOKEN audit token (it carries a pid version) read before and after the path, and the pid re-read from the socket: all equal |
| Linux | uid and pid by SO_PEERCRED (the process that called listen()) |
readlink /proc/<pid>/exe; file identity from stat /proc/<pid>/exe |
/proc/<pid>/stat start time before and after the path, and the pid re-read from the socket |
| Windows | user SID of the pipe's server process (GetNamedPipeServerProcessId, process token) |
QueryFullProcessImageNameW on a process handle; file identity from GetFileInformationByHandle (volume serial and file index) |
the process handle is held throughout (the pid cannot be reused while it is open); creation time (GetProcessTimes) before and after the path, still running (WaitForSingleObject), and the pid re-read from the pipe |
| other Unix | not implemented: Unverified |
The executable matches when the canonicalised found path equals a canonicalised expected path, or when both name the same file (device and inode). The second rule is needed on macOS: volumes are case-insensitive and realpath keeps the spelling it was given, so /applications/x and /Applications/x differ as strings and are one file. A hard link to the genuine binary is the genuine binary. An expected path that does not exist matches nothing.
No Security.framework call is made by this crate. The macOS code-signature team identifier check is the caller's: with_verifier receives HostProcess::audit_token, the race-free handle for SecCodeCopyGuestWithAttributes with kSecGuestAttributeAudit, then SecCodeCopySigningInformation for kSecCodeInfoTeamIdentifier, compared with the client's own (SecCodeCopySelf).
Environment hardening
ClientConfig::ignore_env_overrides(true) makes the suite root come only from Suite::per_user_default(): the home directory from the account database (getpwuid_r(geteuid())), never HOME, and none of RIGHTKIT_SUITE_ROOT, RIGHT_SUITE_DIR, ORTHIC_SHARED_DIR, XDG_DATA_HOME, LOCALAPPDATA. A suite handed in from Suite::resolve() (see Suite::env_derived()) is refused under the switch, so the switch cannot be defeated by passing the environment in. On Windows the root is <local app data>\Orthic Labs\shared with the folder from SHGetKnownFolderPath(FOLDERID_LocalAppData), which reads the profile's registry, never %LOCALAPPDATA%.
- Release builds of an app and of its command-line engine set
ignore_env_overrides(true). QA builds and isolated test runs do not, because they depend onRIGHTKIT_SUITE_ROOT. - Hosts read no environment variable themselves:
host()takes theSuitethe app built. The same switch for a host isSuite::resolve_ignoring_env_overrides(true)(orSuite::per_user_default()), and a host and its hardened clients must resolve the same way. On Linux that means a user who movedXDG_DATA_HOMEis hosted at~/.local/shareby a hardened host, not at theirXDG_DATA_HOME. - The switch and the expectation are independent. With the switch off, a redirected client still refuses the impostor it is redirected to, because it is not the app.
What this closes, and what it does not
Closes: redirection of a client by the environment (the switch); impersonation of the host by another program, through a redirected root, a forged service.json, a socket bound at the recorded path, a stale record whose socket another program now holds, or a copy of the app binary in another directory (the expectation).
Does not close:
- A same-user attacker who can replace the app binary, or write anything in its install directory, or attach a debugger or inject code into the real host or into the client. The attacker is then the host, or the client. On macOS a signed hardened-runtime app resists injection; an unsigned build does not. The team-identifier hook narrows binary replacement for signed apps.
- The sibling and bundle helpers inherit the trust of the running client's location. An attacker who can start a copy of the client from a directory they control also controls the "sibling" it names, and can capture what that client sends. A client that sends secrets should name an absolute install path or add a signature check with
with_verifier. - Identity is checked once, at connect. A host that later hands its connected socket to another process is not noticed.
- Root or Administrator.
- A host that is not the process that created or accepted the socket (a forking host): macOS reports the acceptor and Linux the process that called
listen(). The identity may then be a parent or worker, and an accept landing between the two pid reads on macOS refuses the host asHostChanged. - A host that replaced its binary on disk while running (an update): on Linux
/proc/<pid>/exethen names... (deleted), which does not match the new path, so the old host is refused until it restarts. On macOS the old inode is not reachable by path. The refusal is typed, never an accept. - A Linux host that is non-dumpable (setuid, or
prctl(PR_SET_DUMPABLE, 0)):/proc/<pid>/exeis unreadable to its own user and the host is refused asUnreadable. A pid namespace that hides the host's pid is refused asPidUnavailable.
Windows notes
HostProcess::sid is the host's user SID and uid is None. Paths are compared after canonicalisation without regard to case; HostProcess::executable and HostIdentityError::found_executable are given without the \\?\ prefix. A code-signature check is not built in: do it in HostExpectation::with_verifier (for example WinVerifyTrust on HostProcess::executable), which runs while the process handle is still held. A host running elevated, or as a protected process, may refuse OpenProcess to a non-elevated client and is then refused as Unreadable.
Threat model
Defended against: another local user (or a service running as one) connecting to the endpoint to call the service; another local user squatting on the endpoint name or path so a client sends its hello and requests to an impostor; a remote client (Windows pipes reject remote clients); a server that tries to impersonate the calling user through the pipe (clients open with SECURITY_IDENTIFICATION); connection floods growing the host without bound (Windows: max_pipe_instances).
Layers. (1) Filesystem or DACL: 0700 directory and 0600 socket on Unix, a protected DACL granting only the current user SID on Windows (owner_only_security_attributes, also for mutexes and events). (2) Independent kernel identity check of the peer on every accept and every connect: verify_peer_is_current_user (Unix) or verify_pipe_peer_is_current_user / verify_pipe_server_is_current_user (Windows). The check runs before any byte is read or written, fails closed (an unidentifiable peer is rejected, an unsupported platform rejects everyone), and a rejection is reported to the host as ServiceEvent::PeerRejected.
Not defended against: a client that uses Client::connect talking to a same-user impostor (see "Verifying the host" for Client::connect_expecting); code already running as the current user. It passes every check by definition; the per-app allowlist is a key for routing, not a boundary against it. Root or Administrator. A process that hijacks the pid between the kernel recording it and the token lookup on Windows (the pipe DACL is the primary boundary there and the SID check is defense in depth). Local denial of service by the same user. A Unix peer is identified by effective uid; a setuid binary a different user runs appears as its owner.
Platform note. Cross-user behaviour cannot be exercised by the unit tests (it needs a second account). The decision function is tested with a foreign uid, the listener's rejection path (peer closed unread, reported as Rejected) is tested with an injected verifier, and an ignored test accepts a socket served by another account (RIGHTKIT_SERVICE_FOREIGN_SOCKET).
Compatibility
Compatibility with HeardRight's speech service (see rightkit-speech-client): service.json/takeover.json tolerate peers that publish only role (rank via ServiceConfig::known_roles; an unresolvable rank is never displaced and never obliges a handoff), engine_version mirrors version, ServiceConfig::endpoint accepts a legacy endpoint name, open_stream gives raw line streams over the same user-only transport, and pid_alive is hardened (no process-group signals, Windows exit-code check).
Runtime budget
Generated from runtime-budget.json; edit that file, then run node packages/release/runtime-budgets.mjs sync <crate-dir>.
- Resident memory ceiling: 22.0 MiB.
- CPU ceiling (idle, steady state): 1%.
- Wake-ups ceiling: undeclared.
- Disk operations ceiling: 1 operations per second.
- Required scale: 64 connections or more.
- Required hosts: not declared in
runtime-budget.json. - Status exposure:
Hosted::status()insrc/server.rs; exposes resident memory, idle CPU, wake-ups, disk operations. - Measured: not yet measured.