Files
privatae-bastion/client-go/freeagent_drop.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

369 lines
14 KiB
Go

package main
// Privilege drop for MANAGED agents.
//
// The daemon runs as root on every fleet install (install.sh: no User= line),
// and until this file a managed agent — Python, over the granted folder, with
// the store's plaintext PII vault — ran as root too, activated by one `search`
// store grant. This drops every functional child (`serve`, `sweep`,
// `sweep --once`) to the uid that OWNS the granted folder:
//
// - bounded by construction: that user can already read their own files, so
// the agent gains nothing the grant did not already name;
// - different per store, with no new account: a single shared drop uid (a
// `privatae-freeagent` account, or `nobody`) would pool every store's PII
// vault under one uid readable across hosts and entities, which is the
// cross-host bleed the design refuses;
// - refused when the owner is root, nobody (65534 — what an NFS export with
// root_squash reports for files root created through the squash), or has
// no passwd entry, or cannot traverse the folder's own ancestors. "Cannot
// resolve" is not enough: uid 0 resolves, and a grant over /srv, /opt or
// anything under /root would otherwise yield a root agent with no error —
// the exact defect this step removes, surviving silently.
//
// The trade-off, stated so it is a decision and not an omission: the child
// runs as a REAL user, so a compromised runtime or extract tool can write that
// user's .bashrc, .ssh or crontab — and where that user holds passwordless
// sudo (common on cloud images) it can escalate through it. Accepted, because
// the alternative pools the vaults. The credential also drops the owner's
// supplementary groups (setgroups with an empty list, as the session lane
// does), so a file in the folder readable only through one of those groups is
// not readable by the agent.
//
// THE LAYOUT, and why root never writes by path into anything the child owns.
// A dropped child needs `x` on every ancestor of its state, and configDir()
// is 0700 root — pairing credentials live there, so it is not opened. On a
// root install the store therefore gets three siblings under a root-owned,
// traversable base (faStateBase):
//
// <base>/<pairing>/<id> 0700 OWNER — vault, port file, logs, the child's key copy
// <base>/<pairing>/<id>.private 0700 root — the minted identity, the recipient, manifest staging
// <base>/<pairing>/<id>.pub 0755 root — host.crt, 0644: the cert the manifest signature names
//
// Root reads only from .private and .pub (paths it owns). Every root-side
// write INTO an owner-writable directory goes through os.Root on that
// directory (faOwnedWrite / faOwnedMkdir / faOpenLog), which refuses a symlink
// that leaves it: without that, a child could
// replace serve.log or manifest.yaml with a link and have root append to, or
// clobber, any file on the box. The signature's cert path is under .pub so the
// child can verify it but can never swap what its signature names; `sign`
// runs root-side over the .private staging copy and only the finished bytes
// are placed in the folder. The child still holds a COPY of the signing key
// (it serves with it), so it can produce a valid signature for a manifest it
// widened — what bounds that is the per-store uid plus dcnLoadGrantMask on
// every op, the accepted trade-off; splitting serve-key from
// signing key is a change in the agent itself. Each directory root writes
// into is opened ONCE per ensure and verified to be the owner's (faOpenOwned),
// and every deeper name is resolved inside that handle — a component the
// child swaps for a link mid-operation errors instead of being followed.
// Non-root installs keep the in-configDir layout
// with all three the same directory and take no drop: the daemon is already
// the user. Windows takes no drop either (no setuid); freeagent_drop_windows.go
// says what that means for a Windows service.
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// faOwner is who a store's managed agent runs as. drop=false means the
// daemon's own identity (non-root install, or a platform with no drop).
type faOwner struct {
drop bool
uid uint32
gid uint32
name string
home string
// plat is what this platform needs beyond the above: nothing on unix, the
// owner's SID on Windows (freeagent_drop_windows.go).
plat faOwnerPlat
}
// faResolveOwner decides the identity for a store's agent. Where this
// platform drops and the daemon holds machine authority it is the granted
// folder's owner, refused for the machine's own accounts and for anyone who
// cannot reach the folder (faResolveDropOwner, per platform); otherwise it is
// the daemon's own identity.
func faResolveOwner(root, stateDir string) (*faOwner, error) {
if !faDropSupported() || !processIsRoot() {
return &faOwner{drop: false}, nil
}
return faResolveDropOwner(root, stateDir)
}
// faChild is a started agent child: its pid, for the observe plane, and a
// wait that returns when it exits.
type faChild interface {
Pid() int
Wait() error
}
// execChild is a child started through os/exec.
type execChild struct{ cmd *exec.Cmd }
func (c execChild) Pid() int { return c.cmd.Process.Pid }
func (c execChild) Wait() error { return c.cmd.Wait() }
// faAgentDirInFolder says whether the agent's directory may be created inside
// the granted folder (`.agent`). It may wherever the hand-over is a chown, or
// when nothing is dropped. Where the hand-over is a permission entry (Windows),
// the daemon writes nothing into the person's folder at all: an entry added
// there by path could be steered by a junction the child plants, and the agent
// keeps its directory in the bastion's own state instead.
func faAgentDirInFolder(o *faOwner) bool {
return faAgentDirInFolderFor(o != nil && o.drop, faHandsOverByChown)
}
func faAgentDirInFolderFor(drop, handsOverByChown bool) bool { return !drop || handsOverByChown }
// faAgentKeptInStateNote is the store card's line where the agent's directory
// is kept in the bastion's state by design rather than because the folder
// could not take it.
const faAgentKeptInStateNote = "agent kept in the bastion's state: on this machine the bastion writes nothing into the shared folder"
// faAgentDirNote is the store card's line when the agent's directory is not in
// the folder: by design (inFolder false), or because the folder refused it (a
// read-only mount or share), which names the reason.
func faAgentDirNote(inFolder bool, err error) string {
if !inFolder {
return faAgentKeptInStateNote
}
return "folder not usable for the agent dir; agent kept in bastion state (" + err.Error() + ")"
}
// faOwn hands a path to the owner (no-op when not dropping). Lchown: a link
// is re-owned as a link, never followed.
func faOwn(o *faOwner, path string) error {
if o == nil || !o.drop {
return nil
}
return faHandPath(o, path)
}
// faOpenOwned opens a directory the CHILD may write into (its state leaf, the
// granted folder) as an os.Root, after checking the directory itself belongs
// to the owner: the path to it can be user-controlled (a granted folder under
// the user's home), so a folder the child swapped for a link to /etc must not
// become the place root creates `.agent`. Every name is then resolved inside
// this handle. Not dropping: a plain OpenRoot.
func faOpenOwned(dir string, o *faOwner) (*os.Root, error) {
r, err := os.OpenRoot(dir)
if err != nil {
return nil, err
}
if o != nil && o.drop {
if err := faCheckOwnedRoot(r, dir, o); err != nil {
r.Close()
return nil, err
}
}
return r, nil
}
// faOwnTree re-owns name and everything under it INSIDE r. The walk and each
// Lchown resolve through the root handle, so a directory the child swaps for
// a link during the walk errors instead of steering the chown outside.
// A non-directory with more than one link is skipped: a hard
// link to a root file would otherwise be handed over, and the sysctl that
// forbids creating one is not a guarantee.
func faOwnTree(r *os.Root, name string, o *faOwner) error {
if o == nil || !o.drop || !faHandsOverByChown {
return nil
}
return fs.WalkDir(r.FS(), name, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
st, err := r.Lstat(p)
if err != nil {
return err
}
if !st.IsDir() {
if n, ok := faInfoLinks(st); ok && n != 1 {
return nil
}
}
return r.Lchown(p, int(o.uid), int(o.gid))
})
}
// ── root-side writes into owner-writable directories ────────────────────────
// Every one of these takes the os.Root of the directory (faOpenOwned) and
// works by name inside it: a symlink that would leave the root is refused by
// the walk, so the child cannot aim root's write at a file of its choosing.
// faOwnedWrite replaces name with data (mode) inside r, then hands it to the
// owner. Remove + O_EXCL: an existing entry — file or link — is dropped first,
// and the create refuses anything that reappears in between.
func faOwnedWrite(r *os.Root, name string, data []byte, mode os.FileMode, o *faOwner) error {
_ = r.Remove(name)
f, err := r.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
_ = f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if o != nil && o.drop && faHandsOverByChown {
return r.Lchown(name, int(o.uid), int(o.gid))
}
return nil
}
// faOwnedMkdir ensures name inside r is a real directory (not a link) owned by
// the owner. A read-only parent errors, which the caller treats as "keep the
// agent in the state dir instead".
func faOwnedMkdir(r *os.Root, name string, o *faOwner) error {
if err := r.Mkdir(name, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
return err
}
st, err := r.Lstat(name)
if err != nil {
return err
}
if !st.IsDir() {
return fmt.Errorf("%s is not a directory", name)
}
if o != nil && o.drop && faHandsOverByChown {
return r.Lchown(name, int(o.uid), int(o.gid))
}
return nil
}
// faOwnIn re-owns an existing name inside r without following a link out.
func faOwnIn(r *os.Root, name string, o *faOwner) error {
if o == nil || !o.drop || !faHandsOverByChown {
return nil
}
return r.Lchown(name, int(o.uid), int(o.gid))
}
// faReadIn reads dir/name without following a link out of dir — the port
// file lives in the directory the child writes. dir here is the state leaf,
// whose own path is root-owned all the way down.
func faReadIn(dir, name string) ([]byte, error) {
r, err := os.OpenRoot(dir)
if err != nil {
return nil, err
}
defer r.Close()
return r.ReadFile(name)
}
// faOpenLog opens the child's log for append inside its state dir, refusing a
// link out of it, a hard link, or anything that is not a plain file — then
// hands the open file to the owner by descriptor, not by path. The child gets
// this fd as stdout/stderr; it must never be an append handle on a file root
// was tricked into opening.
func faOpenLog(dir, name string, o *faOwner) (*os.File, error) {
r, err := faOpenOwned(dir, o)
if err != nil {
return nil, err
}
defer r.Close()
f, err := r.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
if err := faFdSane(f); err != nil {
_ = f.Close()
return nil, fmt.Errorf("%s: %v", filepath.Join(dir, name), err)
}
if o != nil && o.drop && faHandsOverByChown {
if err := f.Chown(int(o.uid), int(o.gid)); err != nil {
_ = f.Close()
return nil, err
}
}
return f, nil
}
// ── layout ──────────────────────────────────────────────────────────────────
// faStateBaseOverride lets a test relocate the root-install base.
var faStateBaseOverride = ""
// faStateBase is where a ROOT install keeps managed-agent state: a root-owned
// base every dropped child can reach its own leaf through (faRootStateBase, per
// platform). "" on a non-root install (configDir layout).
func faStateBase() string {
if faStateBaseOverride != "" {
return faStateBaseOverride
}
if !faDropSupported() || !processIsRoot() {
return ""
}
return faRootStateBase()
}
var faPairingKeyBad = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// faPairingKey names the pairing's slot under the base from its DCN dir
// (<config>/dcn/<host_name> by default): the host name, made path-safe.
func faPairingKey(dcnDir string) string {
k := faPairingKeyBad.ReplaceAllString(filepath.Base(dcnDir), "_")
k = strings.Trim(k, "._")
if k == "" {
return "default"
}
return k
}
// faStoreDirs returns the store's three directories (see the header): the
// owner leaf, the root-private sibling and the root-owned public sibling. On
// a non-root install all three are <dcnDir>/freeagent/<id>.
func faStoreDirs(dcnDir string, storeID int64) (dir, priv, pub string, err error) {
id := strconv.FormatInt(storeID, 10)
if base := faStateBase(); base != "" {
slot := filepath.Join(base, faPairingKey(dcnDir))
dir, priv, pub = filepath.Join(slot, id), filepath.Join(slot, id+".private"), filepath.Join(slot, id+".pub")
if err = faMkdirAllMode(dir, 0o700); err != nil {
return
}
if err = faMkdirAllMode(priv, 0o700); err != nil {
return
}
err = faMkdirAllMode(pub, 0o755)
return
}
dir = filepath.Join(dcnDir, "freeagent", id)
return dir, dir, dir, os.MkdirAll(dir, 0o700)
}
// faMkdirAllMode is MkdirAll where the LEAF gets mode and every created
// parent is chmod'd 0755 explicitly — not left to the umask, which the unit
// does not set.
func faMkdirAllMode(p string, mode os.FileMode) error {
parent := filepath.Dir(p)
var missing []string
for d := parent; d != "/" && d != "."; d = filepath.Dir(d) {
if _, err := os.Stat(d); err == nil {
break
}
missing = append(missing, d)
}
if err := os.MkdirAll(parent, 0o755); err != nil {
return err
}
for _, d := range missing {
if err := os.Chmod(d, 0o755); err != nil {
return err
}
}
if err := os.MkdirAll(p, mode); err != nil {
return err
}
return os.Chmod(p, mode)
}