package main // The bastion as the SYSTEM's service on macOS and Windows. // // On Linux the networked bastion has always been a root-owned systemd unit // (deploy/install.sh). macOS and Windows had nothing like it: the Mac installer // refused --networked, and on Windows the bastion ran as a logon task under the // signed-in person's own account. That is right on a person's own computer and // wrong on a machine an organisation enrols, where the organisation's policy // has to hold whoever is signed in. A process the signed-in user started is a // process the signed-in user can stop. // // So a networked install on those platforms now registers the bastion with the // operating system's own service manager: a launchd daemon running as root on // macOS, and a Windows service running as LocalSystem. Whoever administers the // machine holds the service; the person using it works in their own account. // // Two rules follow, and they are decided here, in an untagged file, so the // suite checks them on every platform. The operating-system calls are in // sysservice_darwin.go and sysservice_windows.go. // // - ONE STATE DIRECTORY. The service and an administrator's commands must // read and write the same pairings. The state directory comes from // os.UserConfigDir, which follows HOME on macOS and APPDATA on Windows, and // an administrator's terminal does not carry the service's values: `sudo` // on macOS keeps the caller's HOME unless given -H, and an elevated Windows // terminal keeps the administrator's own profile. So when the service is // installed and the caller administers the machine, the process points // that one variable at the service's own before anything reads state. // - NO SECOND BASTION FROM AN ORDINARY ACCOUNT. With the service installed, // `pair` and `run` from an account that does not administer the machine // would act on an empty bastion of that account's own, beside the // machine's. They are refused with a sentence saying what to do instead. // The reads and the owner's acts (`status` among them) ask the machine's // bastion over its lane instead (serviceLaneReads). As // with the machine marker (singleton.go), this binds a cooperating binary // only; an older copy will not refuse. What stops the person from removing // the service is that they do not administer the machine. import ( "errors" "fmt" "io/fs" "os" "path" "runtime" "strings" "time" ) // serviceCarryFromSID is the account `service install --from-account` names: // the bastion state carried into the service is that account's // (servicecarry.go). "" carries the state of the account running the install. var serviceCarryFromSID string const ( launchdLabel = "ai.privatae.bastion" launchdPlistPath = "/Library/LaunchDaemons/" + launchdLabel + ".plist" // darwinRootHome is root's home on macOS. The daemon runs with it as HOME, // which puts its state under /var/root/Library/Application Support, a // directory only root can enter. darwinRootHome = "/var/root" darwinDaemonLog = "/var/log/privatae-bastion.log" winServiceName = "PrivataeBastion" winServiceDisplay = "Privatae bastion" winServiceDesc = "Serves this machine to the Hosts it is paired with. It runs as the system, " + "so the machine's administrator, not the signed-in user, decides whether it runs." ) // runningAsService is set by the Windows service entry point (sysservice_windows.go) // before anything else runs, and never changes after. The tray, the blocking // dialogs and the relaunch after an update all read it: a service has no // desktop to show them on, and the service manager restarts it. var runningAsService bool // windowsSystemAppData is LocalSystem's own roaming profile folder, which is // where a Windows service running as LocalSystem keeps the bastion's state. // It sits under System32\config, which only SYSTEM and administrators can open, // so the pairings and keys there are out of reach of the signed-in user // without any permission change of ours. func windowsSystemAppData(systemRoot string) string { systemRoot = strings.TrimRight(strings.TrimSpace(systemRoot), `\`) if systemRoot == "" { systemRoot = `C:\Windows` } return systemRoot + `\System32\config\systemprofile\AppData\Roaming` } // serviceStateEnv names the environment variable to set, and its value, so this // process reads and writes the system service's state. An empty key means // change nothing: no service is installed, or this caller does not administer // the machine (and so could not open the service's state anyway). func serviceStateEnv(goos string, installed, authority bool, systemRoot string) (key, value string) { if !installed || !authority { return "", "" } switch goos { case "darwin": return "HOME", darwinRootHome case "windows": return "APPDATA", windowsSystemAppData(systemRoot) } return "", "" } // serviceGuardedCommands would each act on a bastion in the caller's own // profile. Beside a system service that is a second bastion (pair, run), a // switch of its own, or a view of an empty one (status, `switch --list`, // activity, report and doctor, which would print the caller's own records, // or check its own empty bastion, as though they were the machine's). var serviceGuardedCommands = map[string]bool{"pair": true, "run": true, "status": true, "switch": true, "switch --list": true, "activity": true, "report": true, "doctor": true, "ops": true, "device-approvals": true, "backup read": true, // The narrowing acts (owneracts.go), and device-approve, which stays the // administrator's (the console keeps it adminOnly): from an ordinary // account each read that account's own, empty approvals, grants or asks. "approvals drop": true, "grants": true, "device-deny": true, "device-approve": true, // The machine's approvals: read, approved and denied through the owner's // lane (reads.go, owneracts.go): ask.sock on Linux and macOS, the ask pipe // on Windows. Run directly, these would read the account's own. "approvals": true, "approve": true, "deny": true} // serviceLaneReads are the guarded commands that read the machine's bastion // itself over its lane (reads.go: ask.sock on Linux and macOS, the ask pipe on // Windows) rather than a bastion of the caller's own: not refused here, and // refused there for any account but the owner's. var serviceLaneReads = map[string]bool{"status": true, "switch --list": true, "activity": true, "report": true, "doctor": true, "ops": true, "device-approvals": true, "backup read": true, // Not reads: the owner's account switches, and takes reach back (`ops // drop` is guarded as "ops"), through the same socket, with the console's // rules (owneracts.go), and any other account is refused there. "switch": true, "approvals drop": true, "grants": true, "device-deny": true, "approvals": true, "approve": true, "deny": true} // serviceGuard refuses a guarded command from an account that does not // administer a machine whose bastion is a system service. // // On Linux the service is the systemd unit, and the caller says whether it is // there (linuxSystemUnitPresent). Before the bastion was installed for the // whole machine by default, Linux was left out: `status` from the person's // account then said NOT RUNNING and told them to `run` a second, empty // bastion of their own. func serviceGuard(goos, cmd string, installed, authority bool) error { if !installed || authority || !serviceGuardedCommands[cmd] { return nil } if serviceLaneReads[cmd] { return nil } return fmt.Errorf("this machine's bastion runs as a system service, and it belongs to whoever administers the machine.\n"+ " `%s` from your own account would act on a separate, empty bastion of your own, not on the machine's.\n"+ " To work with the machine's bastion, %s", cmd, adminHowToFor(goos)) } // linuxSystemUnitPath is where the installers write the bastion's systemd unit // (deploy/install.sh; `npx cseries` writes the same unit). const linuxSystemUnitPath = "/etc/systemd/system/privatae-bastion.service" // linuxSystemUnitPresent: the bastion is installed for the whole machine on // this Linux box. A variable so the suite decides it. var linuxSystemUnitPresent = func() bool { _, err := os.Stat(linuxSystemUnitPath) return err == nil } // launchdPlist is the launchd job for the daemon. KeepAlive restarts it // whenever it exits, which is also how a self-update takes effect: update.go // exits after swapping the binary, exactly as it does under systemd's // Restart=always. RunAtLoad starts it at boot, before anyone signs in. func launchdPlist(binary string) ([]byte, error) { if !strings.HasPrefix(binary, "/") { return nil, fmt.Errorf("the service's program must be an absolute path, not %q", binary) } for _, r := range binary { if r < 0x20 || r == 0x7f { return nil, fmt.Errorf("the service's program path holds a control character") } } x := xmlText return []byte(` Label ` + x(launchdLabel) + ` ProgramArguments ` + x(binary) + ` run UserName root EnvironmentVariables HOME ` + x(darwinRootHome) + ` RunAtLoad KeepAlive StandardOutPath ` + x(darwinDaemonLog) + ` StandardErrorPath ` + x(darwinDaemonLog) + ` `), nil } func xmlText(s string) string { return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'").Replace(s) } // pathOwnerMode reports a path's owning uid and mode WITHOUT following a final // symlink. A function value so the rule below is tested off macOS. type pathOwnerMode func(p string) (uid uint32, mode fs.FileMode, err error) // binaryChainRootOnly refuses a program the root daemon would run if anyone but // root could replace it. The file and every directory above it must be owned by // root and writable by neither group nor others: a user who can write the // directory can rename the file away and put their own in its place, and the // next restart runs it as root. // // This is not hypothetical on macOS. /usr/local/bin is handed to the signed-in // user on many Macs by a common package manager, which is why the installer // puts the daemon's program under /Library/Application Support instead. func binaryChainRootOnly(binary string, stat pathOwnerMode) error { if !strings.HasPrefix(binary, "/") { return fmt.Errorf("%q is not an absolute path", binary) } p := path.Clean(binary) for { uid, mode, err := stat(p) if err != nil { return fmt.Errorf("cannot check %s: %w", p, err) } if mode&fs.ModeSymlink != 0 { return fmt.Errorf("%s is a symbolic link; the service must name the program itself", p) } if uid != 0 { return fmt.Errorf("%s is owned by uid %d, not root, so its owner could replace the program the service runs as root", p, uid) } if mode.Perm()&0o022 != 0 { return fmt.Errorf("%s can be written by its group or by everyone (%v), so they could replace the program the service runs as root", p, mode.Perm()) } if p == "/" { return nil } p = path.Dir(p) } } // windowsServiceBinaryOK refuses to register a program the signed-in user // could replace. Program Files is writable only by administrators unless // someone has changed that, which is why the installer puts the program there: // a service runs as LocalSystem, so whoever can swap its file runs code as // the system on the next restart. The per-user install folder under // %LOCALAPPDATA% is exactly such a file. func windowsServiceBinaryOK(exe, programFiles string) error { norm := func(s string) string { s = strings.ReplaceAll(strings.TrimSpace(s), "/", `\`) return strings.ToLower(strings.TrimRight(s, `\`)) } e, pf := norm(exe), norm(programFiles) if pf == "" { return errors.New("cannot tell where Program Files is (HKLM ProgramFilesDir is empty)") } for _, part := range strings.Split(e, `\`) { if part == ".." { return fmt.Errorf("%s holds a '..' step", exe) } } if !strings.HasPrefix(e, pf+`\`) { return fmt.Errorf("the service's program must live under %s, which only administrators can change; it is at %s.\n"+ " Install it with the installer's -Networked switch, which puts it there", programFiles, exe) } return nil } // serviceRestartHintFor / serviceRunHintFor are restartHint and runCommandHint // for a machine whose bastion is a system service. func serviceRestartHintFor(goos string) string { switch goos { case "darwin": return "Restart the service so the change takes: sudo launchctl kickstart -k system/" + launchdLabel case "windows": return "Restart the service so the change takes: Restart-Service " + winServiceName + " (from an Administrator terminal)" } return restartHintFor(goos) } func serviceRunHintFor(goos string) string { switch goos { case "darwin": return "sudo launchctl kickstart system/" + launchdLabel case "windows": return "Start-Service " + winServiceName + " (from an Administrator terminal)" } return runCommandHintFor(goos) } // serviceLogCap is the size past which a service's log is set aside when the // service starts. A service has no console, so its file is all that is kept of // what it says, and an unpaired service restarting adds a line each time. const serviceLogCap = 8 << 20 // keepOneLogAside sets a log aside once it is larger than limit: its content // is copied to .1, replacing the previous one, and the file is emptied // in place. In place rather than renamed, because on macOS launchd opened the // file for the daemon and keeps writing to whatever file that is; after a // rename it would go on filling the copy. A missing log is not an error. func keepOneLogAside(p string, limit int64) error { fi, err := os.Stat(p) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil } return err } if fi.Size() <= limit { return nil } data, err := os.ReadFile(p) if err != nil { return err } if err := os.WriteFile(p+".1", data, 0o600); err != nil { return err } return os.Truncate(p, 0) } // waitForPairing holds the service until this machine has a pairing, checking // every `every`, and reports false if asked to stop first. // // A fresh service is installed before anything is paired. On Linux the unit // then exits "not paired" and restarts every two seconds until a pairing // lands. A Windows service that did that would fill the event log with // failures, so it waits instead, and starts serving within one check of the // first pairing. func waitForPairing(paired func() bool, stop <-chan struct{}, every time.Duration) bool { said := false for !paired() { if !said { fmt.Println("service: not paired yet. It starts serving as soon as a pairing is added (from an Administrator terminal).") said = true } select { case <-stop: return false case <-time.After(every): } } return true } // systemService is what this process learned about the system service before // it ran its command (adoptSystemServiceState). var systemService struct { installed bool authority bool err error // callerAppData is APPDATA as this process was started with, before an // administrator's command is pointed at the service's: where a bastion of // this account's own keeps its state (servicecarry.go). callerAppData string } // adoptSystemServiceState runs before any command. It records whether the // system service is installed and whether this caller administers the machine, // and points the state directory at the service's when both are true. // // A machine where the question cannot be answered is treated as one that HAS // the service for the guard (so an ordinary account is refused rather than // forking state on a box that may well be enrolled) and as one that does NOT // for the redirection (the state is left where it was). // // Authority is recorded whether or not a service is installed. On Linux // systemServiceInstalled always answers no, because the service there is the // systemd unit, and root's own state is the state that unit reads. Recording // authority only after an installed service left root on Linux looking like // an ordinary account, and enrolServiceStateRefusal refused it. func adoptSystemServiceState() { systemService.callerAppData = os.Getenv("APPDATA") installed, err := systemServiceInstalled() systemService.err = err systemService.installed = installed || err != nil systemService.authority = machineAuthorityHeld() if !systemService.installed { return } if err != nil { fmt.Fprintf(os.Stderr, "service: cannot tell whether this machine runs the bastion as a system service: %v\n", err) return } if k, v := serviceStateEnv(runtime.GOOS, installed, systemService.authority, os.Getenv("SystemRoot")); k != "" { _ = os.Setenv(k, v) } } // guardServiceFork is serviceGuard for this process. `switch --list` is a // read and `switch` a change, and the backup reads are not the rest of // backup, so each is guarded apart. func guardServiceFork(cmd string) error { cmd = serviceGuardKey(cmd, os.Args) installed := systemService.installed if runtime.GOOS == "linux" { installed = linuxSystemUnitPresent() } return serviceGuard(runtime.GOOS, cmd, installed, systemService.authority) } // serviceGuardKey is the name the guard knows a command line by. func serviceGuardKey(cmd string, args []string) string { arg := func(i int) string { if len(args) > i { return args[i] } return "" } switch { case cmd == "switch" && arg(2) == "--list": return "switch --list" case cmd == "backup" && (arg(2) == "requests" || arg(2) == "shares" || (arg(2) == "key" && arg(3) == "show")): // The backup reads; the rest of backup works offline (reseal, // restore-plan) or is not yet asked of the machine's bastion. return "backup read" case cmd == "approvals" && len(args) > 2: // `approvals drop` is an act, the rest of approvals a read; the // flags may come first, as main takes them (takeHostFlag). _, rest := takeHostFlag(args[2:]) if _, rest = takeMachineFlag(rest); len(rest) > 0 && rest[0] == "drop" { return "approvals drop" } } return cmd } // cmdService is `privatae-bastion service install|uninstall|status`. func cmdService(args []string) error { const usage = "usage: privatae-bastion service install [--from-account ] | uninstall | status" if len(args) == 3 && args[0] == "install" && args[1] == "--from-account" { if !approvalsOwnerSIDOK(args[2]) { return fmt.Errorf("%q is not the SID of a person's account (S-1-5-21-… or S-1-12-1-…) — nothing was installed", args[2]) } serviceCarryFromSID = args[2] args = args[:1] } if len(args) != 1 { return errors.New(usage) } switch args[0] { case "install": if err := requireMachineAuthority("service install"); err != nil { return err } return installSystemService() case "uninstall": if err := requireMachineAuthority("service uninstall"); err != nil { return err } return uninstallSystemService() case "status": return printSystemServiceStatus() } return fmt.Errorf("unknown service command %q: use install, uninstall or status", args[0]) }