bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user