security: gate IPC on peer credentials and require out-of-band destructive confirmation - #226
Open
megabyte0x wants to merge 7 commits into
Open
security: gate IPC on peer credentials and require out-of-band destructive confirmation#226megabyte0x wants to merge 7 commits into
megabyte0x wants to merge 7 commits into
Conversation
UnixListener::bind honours the process umask, so binding at the final path and chmodding afterwards leaves a window in which another uid can connect whenever the umask is permissive (a manual launch without the packaged unit's UMask=0077). Overriding the umask around bind is not safe here: it is process-wide and would strip bits from files created concurrently by other threads. The socket is now bound inside a freshly created 0700 staging directory, tightened to 0600 there, and renamed into place. rename is atomic and preserves the inode and its mode, so the final path never exists in a world-accessible state and clients connect to the same listening socket through it.
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #225.
The daemon accepted every IPC connection without checking peer credentials, and its two destructive commands were guarded by in-band constants —
"ERASE"forpanic_erase, the handle echoed back to itself forClaimRegistryHandle. Any same-uid process that knew the socket path also knew the constants, so a one-line script could impersonate the account or destroy the master key and every sealed record. The socket was also chmodded afterbind()rather than being published private.What changed
Peer-credential gate.
IpcServer::runreadsSO_PEERCREDon every accepted connection and drops any peer whose uid differs from the daemon's effective uid beforeserve_clientis spawned — no protocol bytes, not even an error frame. uid only: pid is subject to reuse races. Root is not exempted; a root peer can bypass the socket anyway, so accepting it would only widen the accepted-input surface. The decision is a pure function so the rejection branch is unit-testable.Socket published private (finding 7).
UnixListener::bindhonours the process umask, so bind-then-chmod leaves a window whenever the umask is permissive — a manual launch without the packaged unit'sUMask=0077. Overriding the umask aroundbindwould close that window but is not safe: umask is process-wide and would strip bits from files and directories other threads create concurrently (this reproduces asEACCESon directories created by parallel tests). Instead the socket is bound inside a freshly created0700staging directory, tightened to0600there, and renamed into place.renameis atomic and preserves the inode and its mode, so the final path never exists world-accessible and clients connect to the same listening socket through it.Out-of-band destructive confirmation. A new
confirmationmodule mints 32 random bytes per request, writes them to a0600file inside a0700<state_dir>/confirmations/, and redeems them exactly once within 120 seconds. Redemption burns the outstanding token on every attempt, matched or not, so a wrong guess costs the token instead of enabling retries; claim tokens are bound to the exact handle.panicandclaim-registry-handlenow require such a token, and two new commands (request-panic-confirmation,request-registry-claim-confirmation) mint them.IPC
VERSION1 → 2.PanicandClaimRegistryHandlechanged incompatibly, so a version-1 client's destructive flow is semantically broken and negotiation now says so instead of failing at use time. Every in-repo client and fixture moves in lockstep; the protocol has never shipped (0.0.1, no release).Client UX is unchanged.
omachat-ctl panic --confirm ERASE,claim-handle H --confirm H, and the TUI's/panic ERASEall still work. A sharedrequest_with_confirmationhelper validates the typed intent locally, requests a token, reads it out of band from the state directory, and commits. A mistyped intent is refused before anything reaches the daemon.panicalso prints a warning that erasure is local-only and cannot retract replicated data.Documentation.
SECURITY.mdanddocs/installation.mdnow state plainly that these are deliberate-two-step and freshness guarantees, not an authorization boundary: a same-user process can ptrace the daemon or read the state directory holding the tokens, so protecting the account from hostile same-user code — including coding agents — requires OS-level sandboxing of that code, not daemon-side checks.rustix(already a workspace dependency, used the same way byomachat-registry-host) providesgeteuidbecause the workspace setsunsafe_code = "forbid".Test plan
cargo fmt --all -- --check(clean)cargo clippy --workspace --all-targets --locked -- -D warnings(clean)cargo test --workspace --locked— 534 tests pass, including:ipc_server::tests::only_the_daemon_uid_is_permitted(uid gate, incl. root rejection)ipc_server::tests::the_published_socket_is_private_and_leaves_no_staging_directoryipc_socket_mode::socket_is_published_private_even_under_a_permissive_umask— binds underumask 0, asserts0600at the final path, then completes ahelloexchange through it to prove the rename did not break the listenerconfirmation::tests::*— private single-use token file, burn-on-wrong-guess, expiry, handle bindingconfirmation_ipc::*— issuance shape/mode, invalid handles mint nothing,"ERASE"no longer erases, a minted token authorizes exactly onceomachat-ctl confirmation_flow::*— two-phase orchestration against a scripted stub daemon, and local refusal of a mistyped intentRUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --locked,cargo build --workspace --bins --locked,./scripts/check-version-contract.sh,./scripts/test-check-release-size.sh, release build +./scripts/check-release-size.sh(aggregate 4944 KiB of a 10 MiB ceiling),sh ./scripts/check-packaging.sh— all clean.Cargo.lockgains no new packages; only two dependency edges to crates already in the graph.