package main // consolelane.go — the machine owner's own account opens the console of the // bastion installed for the whole machine, without sudo on Linux and macOS // and without an Administrator terminal on Windows. // // THE PROBLEM. Installed for the whole machine, the bastion runs as root and // its console signs in with root's ui.secret. `privatae-bastion ui` run by the // person reads their own account's state, and the console refuses that // account's key, so the person reached their own machine's console only // through sudo, and a browser started by sudo is root's, not theirs. // // THE LANE. ask.sock (agentask.go), and on Windows the service's ask pipe // (asklane_windows.go), already tells the daemon, from the operating system, // which account connected. Asked for "console" by the account recorded as the // machine's owner (owner_account.go), the daemon mints a one-use ticket of a // LIMITED kind and answers with the link; `ui` opens it in the person's own // browser. Any other account is refused, and no request text is trusted. // // WHAT A LIMITED SESSION MAY DO. Everything a person can see, everything that // only narrows reach (deny, switch off, disconnect, unmark), and everything // that takes the owner's code, which an agent running as the person cannot // read on this install. It is refused what widens access with no code today: // pairing a Host, setting up or rotating the owner's code, choosing the Host // in charge, proposing a folder, and the approval device's approvals. Those // stay with the administrator (`sudo -H privatae-bastion ui`, or `ui` from an // Administrator terminal on Windows). Codes typed for // a Host's requests in a limited session are throttled as the owner's are // (machineCodeSpend), because guessing is the only attack left there. import ( "errors" "fmt" "net/http" "net/url" "os" "runtime" "sync/atomic" ) // consoleLaneTicketsMax is how many lane links may wait unopened at once. const consoleLaneTicketsMax = 16 // consoleLaneServer is the running console, for ask.sock to mint a ticket // on. nil until the console listens, and again after it stops. var consoleLaneServer atomic.Pointer[uiServer] // consoleLaneRefusal says why account uid may not open the console through // the lane, or "" when it may. func consoleLaneRefusal(uid uint32, who string) string { return consoleLaneRefusalFor(askPeer{UID: uid, Name: who}) } // consoleLaneRefusalFor is consoleLaneRefusal for a peer of either platform. func consoleLaneRefusalFor(p askPeer) string { admin := adminCommandFor(p.windows(), "ui") match, ownerName, present, err := laneOwnerMatch(p) switch { case err != nil: return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " + "the console with: " + admin case !present: return "no account is recorded as this machine's owner, so the console opens only for the administrator: " + admin + ". To open it from your own account, the administrator records it once: " + ownerRecordCommand(p) case !match: without := "without sudo" if p.windows() { without = "without an Administrator terminal" } return "this machine's console opens " + without + " only for its owner's account (" + ownerName + "), not " + p.Name + ". The administrator opens it with: " + admin } return "" } func accountForHint(who string) string { if who == "" || len(who) > 64 { return "" } return who } // consoleLaneEntry is what the daemon answers an owner's "console" ask with: // the link that redeems a fresh limited ticket. func consoleLaneEntry(uid uint32, who string) (string, error) { return consoleLaneEntryFor(askPeer{UID: uid, Name: who}) } func consoleLaneEntryFor(p askPeer) (string, error) { if why := consoleLaneRefusalFor(p); why != "" { return "", errors.New(why) } u := consoleLaneServer.Load() if u == nil || u.addr == "" { return "", errors.New("the bastion's console is not listening (no free port, or it is still starting)") } // A cap, so asking over and over cannot grow root's memory: a link lasts a // minute, and a person opens one at a time. tok, ok, err := u.limitedTickets.mintCapped(consoleLaneTicketsMax) if err != nil { return "", errors.New("could not mint a ticket") } if !ok { return "", errors.New("too many console links are waiting to be opened; open one, or wait a minute") } return "http://" + u.addr + "/?k=" + tok, nil } // limitedSession reports whether the request's session came through the lane. func (u *uiServer) limitedSession(r *http.Request) bool { return u.limitedSessions != nil && u.limitedSessions.valid(uiSessionFrom(r)) } // signedIn reports whether the request carries either kind of session. func (u *uiServer) signedIn(r *http.Request) bool { tok := uiSessionFrom(r) return u.sessions.valid(tok) || (u.limitedSessions != nil && u.limitedSessions.valid(tok)) } var consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " + "with no code, or reads beyond your own account. The machine's administrator does it: " + adminCommandFor(runtime.GOOS == "windows", "ui") // adminOnly wraps a route a limited session may not use. func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc { return u.guarded(func(w http.ResponseWriter, r *http.Request) { if u.limitedSession(r) { http.Error(w, consoleLaneAdminOnly, http.StatusForbidden) return } h(w, r) }) } // consoleEntryForThisAccount is the link `ui` opens. An account with a // bastion of its own signs in with its own key (localUIEntryURL). An ordinary // account beside the machine's bastion asks the lane, and is told why not // when it is not the owner's. func consoleEntryForThisAccount() (entry, addr string, err error) { if runtime.GOOS == "windows" { if systemService.installed && !machineAuthorityHeld() { return consoleEntryThroughLane() } return localUIEntryURL() } if os.Geteuid() != 0 && !uiDaemonListening() { p := askSockPath() if why := contextSockDialable(p); why != "" { return "", "", errors.New(why) } if _, statErr := os.Stat(p); statErr == nil { return consoleEntryThroughLane() } } return localUIEntryURL() } // consoleEntryThroughLane asks the machine's bastion for the owner's link. func consoleEntryThroughLane() (entry, addr string, err error) { p := askLaneAddr(askSockPath()) r, err := askViaSock(agentAskRequest{Op: "console"}) if err != nil { if runtime.GOOS == "windows" { // The service is there (systemService.installed) but its pipe is // not: a service older than this command, or one not running. return "", "", fmt.Errorf("this machine's bastion runs as the %s service, and it did not answer your "+ "account's request for its console on %s (%v).\n"+ " If the service is not running, start it: %s\n"+ " If it is older than this command, update it by running the installer again.\n"+ " Either way the administrator can open the console now: %s", winServiceName, p, err, serviceRunHintFor("windows"), adminCommandFor(true, "ui")) } return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error()) } if !r.OK { return "", "", errors.New(r.Why) } u, err := url.Parse(r.URL) if err != nil || u.Scheme != "http" || !uiLoopbackHost(u.Host, u.Port()) { return "", "", errors.New("this machine's bastion answered with a link that is not its own console") } return r.URL, u.Host, nil } // uiLimitedWriter marks a response as drawn for a limited session, so a page // shows the administrator's command in place of a form the session would be // refused (Settings, Approvals). It changes what is drawn, never what is // allowed: adminOnly decides that, from the session itself. type uiLimitedWriter struct{ http.ResponseWriter } func limitedWriter(w http.ResponseWriter) bool { _, ok := w.(uiLimitedWriter) return ok }