Files
privatae-bastion/client-go/contextsock.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

215 lines
8.9 KiB
Go

package main
// contextsock.go — how a person's own context surface reaches the daemon, on a
// machine where the daemon is not theirs.
//
// THE PROBLEM, stated plainly. The context surface (contextui.go) runs as the
// person, which is what makes it safe: the files it can draw are the ones the
// kernel already lets them open. But ASKING for a folder to become context has
// to reach the host, and the only thing on the box with a tunnel is the daemon.
// Its loopback endpoint is guarded by the OperatorSecret, which lives at 0600
// in the daemon's own config dir — and a non-root person cannot read it, which
// is correct, because that same secret opens the git-credential lane.
//
// So on a single-user machine the ask works (the daemon is that person's) and
// on a networked bastion it does not. That gap shipped, and the page said so in
// words, which is better than failing silently and still not good enough.
//
// THE FIX, AND WHY IT IS THIS ONE. The daemon needs to know WHO is asking, and
// it must not learn it from anything the asker says — a client that states its
// own username is a client that can state somebody else's. Every local IPC on
// every platform this binary targets can ask the KERNEL who is on the other end
// of a connection:
//
// linux SO_PEERCRED on a unix socket -> uid, gid, pid
// darwin LOCAL_PEERCRED / getpeereid -> uid, gid
// windows GetNamedPipeClientProcessId + the
// process token -> the caller's SID
//
// That is the same principle as the sanction mark being keyed on the kernel's
// executable path rather than argv[0] (sanction.go), and as the context
// surface's file walk being done BY the user rather than filtered FOR them:
// where an answer can come from the kernel, this code does not decide it.
//
// WHAT THE DAEMON DOES WITH THE IDENTITY. It forwards the draft with the
// asking user's name attached, and the draft still lands INERT — a person on
// the host approves it with a step-up code before a single file is read
// (addfolder.go:13-15). Nothing here grants anything. The identity is what
// makes "the username is the ceiling for their own context folder" enforceable
// rather than asserted: the daemon knows who asked because the kernel told it,
// so the ask cannot be made in somebody else's name.
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"time"
)
// contextSockName is the socket the daemon listens on. It sits in the DAEMON's
// config dir, and its permissions are the first gate: 0666 on the socket itself
// so any local user can connect, because the identity check is the kernel's and
// not the filesystem's. The DIRECTORY above it is 0700, so this is deliberate
// and needs saying — see contextSockListen.
const contextSockName = "context.sock"
// contextAskRequest is what a person's surface sends. NOTE WHAT IS NOT IN IT:
// no username, no uid, no token. Anything the client could put here is
// something the client could lie about, so the daemon reads the identity off
// the connection instead and this carries only the request itself.
type contextAskRequest struct {
Root string `json:"root"`
Name string `json:"display_name"`
}
type contextAskReply struct {
OK bool `json:"ok"`
Host string `json:"host,omitempty"`
Why string `json:"why,omitempty"`
// User is the name the DAEMON resolved from the kernel, echoed back so the
// person can see whose ask it was recorded as. If this ever disagrees with
// who they think they are, that is worth them noticing.
User string `json:"user,omitempty"`
}
// contextSockPath is MACHINE-WIDE, not per-user, and that was the bug that made
// the first version of this dead code.
//
// It lived under configDir(), which is os.UserConfigDir()-derived — so the root
// daemon bound a path inside /root/.config and the person dialled one inside
// their own home. Two different sockets that could never meet, in exactly the
// case this endpoint exists for. The client fell back silently every time and
// the daemon logged an address nothing was listening on.
//
// /var/run is the right home and it is not only about agreement:
//
// - It is ROOT-OWNED on every unix this ships to, so only root can create the
// directory. A non-root process cannot squat this path and pose as the
// daemon, which a world-writable /tmp would have allowed outright.
// - It is a tmpfs, so a stale socket does not survive a reboot.
//
// The client additionally requires the socket to be OWNED BY ROOT before it
// will send anything (contextSockDialable). Belt to the directory's brace: if
// the path is ever reachable by another uid, an impostor still cannot be
// mistaken for the daemon.
func contextSockPath() (string, error) {
return filepath.Join(contextSockDir, contextSockName), nil
}
// ── the daemon side ─────────────────────────────────────────────────────────
// startContextSock serves asks from local users until ctx ends.
//
// It is started by the daemon, so it runs with the daemon's rights — which is
// the point: it can read the OperatorSecret and reach the tunnel, and the
// person asking cannot.
func startContextSock(done <-chan struct{}) {
p, err := contextSockPath()
if err != nil {
uilogf("context socket: %v\n", err)
return
}
ln, err := contextSockListen(p)
if err != nil {
// Not fatal to the daemon. A box with no local users asking for context
// is the ordinary case, and the rest of the bastion must not stop
// because an IPC endpoint could not bind.
uilogf("context socket not available (%v) — `privatae-bastion context` "+
"will say so rather than failing quietly\n", err)
return
}
go func() {
<-done
_ = ln.Close()
_ = os.Remove(p)
}()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go contextSockServe(c)
}
}()
uilogf("context socket listening on %s\n", p)
}
func contextSockServe(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
// THE IDENTITY, FROM THE KERNEL, BEFORE THE REQUEST IS EVEN READ. Order
// matters: nothing the client sends should be able to influence how it is
// identified, and the surest way to guarantee that is to have the answer
// already in hand.
user, err := contextPeerUser(c)
if err != nil {
_ = json.NewEncoder(c).Encode(contextAskReply{Why: "this machine could not " +
"establish who is asking (" + err.Error() + "), so it will not forward the " +
"request. An ask that cannot be attributed is one nobody can approve."})
return
}
var req contextAskRequest
if err := json.NewDecoder(c).Decode(&req); err != nil {
_ = json.NewEncoder(c).Encode(contextAskReply{User: user, Why: "the request could not be read"})
return
}
sent, host, note := contextForwardDraftAs(req.Root, req.Name, user)
_ = json.NewEncoder(c).Encode(contextAskReply{OK: sent, Host: host, Why: note, User: user})
}
// ── the asking side ─────────────────────────────────────────────────────────
// contextAskViaSock hands one draft to the daemon and returns what it said.
func contextAskViaSock(root, name string) (sent bool, host, note string, available bool) {
p, err := contextSockPath()
if err != nil {
return false, "", err.Error(), false
}
if why := contextSockDialable(p); why != "" {
// A socket at the right path that is NOT root's is not the daemon.
// Refusing loudly rather than falling back: the fallback path would
// send this person's folder somewhere, and "somewhere" is the problem.
return false, "", why, true
}
c, err := net.DialTimeout("unix", p, 5*time.Second)
if err != nil {
// The daemon may be older than this socket, not running, or running as
// somebody whose config dir this process cannot even see. All three
// mean the same thing to the caller: this route is not available, try
// the direct one.
return false, "", "", false
}
defer c.Close()
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
if err := json.NewEncoder(c).Encode(contextAskRequest{Root: root, Name: name}); err != nil {
return false, "", err.Error(), true
}
var rep contextAskReply
if err := json.NewDecoder(c).Decode(&rep); err != nil {
return false, "", "the bastion did not answer: " + err.Error(), true
}
if !rep.OK {
return false, rep.Host, rep.Why, true
}
return true, rep.Host, "", true
}
// contextSockUnsupported is the note used where the platform has no
// implementation yet, so the reason a person sees names the platform rather
// than pretending the feature is merely broken.
var contextSockUnsupported = errors.New("peer identity over a local socket is not " +
"implemented for this platform yet")
func contextSockFmtUser(uid uint32, name string) string {
if name != "" {
return name
}
return fmt.Sprintf("uid %d", uid)
}