[client] Profile ownership console user tofu (#7529)

* Add consoleuser and stamp default profile on known username in migration

* Refactor consoleuser to verify Id, fix seats on linux and default stamp

* Add default profile claim

* Add disable auto-claim of default profile and always fail close

* Add disable auto-claim flag to migration

* Adding timeout to console user on Linux and close library load on darwin

* Fixed failed close test

* Close both Dlopen for darwin

* Replace RegisterFunc with purego.Dlsym to avoid possible panic

* Fix freebsd tty enumeration

* Fix active profile migration logic and add test

* Log defaultClaimDisabled error once

* Guard against panicking console user lookup.

* Fix merge conflict

* Fix broken tests
This commit is contained in:
Theodor Midtlien
2026-09-17 11:20:13 +02:00
committed by GitHub
parent 52b16e7a5c
commit 90052cbefb
12 changed files with 712 additions and 13 deletions
+43
View File
@@ -0,0 +1,43 @@
package ipcauth
import (
"sync"
log "github.com/sirupsen/logrus"
)
// logConsolePanic keeps the notice to once per process.
var logConsolePanic sync.Once
// IsConsoleUser reports whether a caller is sitting at one of this machine's
// consoles right now.
//
// It is false on a headless machine, which has no seat to sit at, on a
// platform that exposes no console-user lookup at all, and whenever the lookup
// fails. Callers must read that as "cannot confirm" rather than as proof of
// absence. It gates handing out ownership, so a lookup that cannot answer
// withholds a claim, and never grants one.
func IsConsoleUser(id Identity) bool {
if !id.Known() {
return false
}
return guardConsoleLookup(id, isConsoleUser)
}
// guardConsoleLookup runs a platform lookup and turns a panic out of it into
// "cannot confirm".
func guardConsoleLookup(id Identity, lookup func(Identity) bool) (atConsole bool) {
defer func() {
r := recover()
if r == nil {
return
}
atConsole = false
logConsolePanic.Do(func() {
log.Errorf("console user lookup panicked, no caller will be treated as being at the console: %v", r)
})
}()
return lookup(id)
}
@@ -0,0 +1,95 @@
package ipcauth
import (
"unsafe"
"github.com/ebitengine/purego"
)
// isConsoleUser reports whether id is the user currently logged into the macOS
// GUI console session. Uses SCDynamicStoreCopyConsoleUser from the
// SystemConfiguration framework via purego (no cgo).
func isConsoleUser(id Identity) bool {
// A SID belongs to a Windows principal and has no uid to compare.
if id.IsWindows() {
return false
}
uid, ok := consoleUID()
return ok && uid == id.UID
}
// consoleUID returns the uid of the GUI console session, and false when nobody
// is logged in at it.
func consoleUID() (uint32, bool) {
sc, err := purego.Dlopen(
"/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration",
purego.RTLD_NOW|purego.RTLD_GLOBAL,
)
if err != nil {
return 0, false
}
defer func() {
_ = purego.Dlclose(sc)
}()
cf, err := purego.Dlopen(
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation",
purego.RTLD_NOW|purego.RTLD_GLOBAL,
)
if err != nil {
return 0, false
}
defer func() {
_ = purego.Dlclose(cf)
}()
// CFStringRef SCDynamicStoreCopyConsoleUser(SCDynamicStoreRef store,
// uid_t *uid, gid_t *gid);
//
// We pass nil for the store (NULL is accepted; the framework creates a
// transient one), discard the returned CFStringRef username (we only
// need the UID), and read uid via the out-pointer.
copyConsoleUserSym, ok := resolveSymbol(sc, "SCDynamicStoreCopyConsoleUser")
if !ok {
return 0, false
}
cfReleaseSym, ok := resolveSymbol(cf, "CFRelease")
if !ok {
return 0, false
}
var copyConsoleUser func(store uintptr, uidPtr, gidPtr unsafe.Pointer) uintptr
purego.RegisterFunc(&copyConsoleUser, copyConsoleUserSym)
var cfRelease func(uintptr)
purego.RegisterFunc(&cfRelease, cfReleaseSym)
var uid uint32
var gid uint32
cfStr := copyConsoleUser(0, unsafe.Pointer(&uid), unsafe.Pointer(&gid))
if cfStr == 0 {
return 0, false
}
cfRelease(cfStr)
// loginwindow / no GUI session reports uid 0. We don't want the
// console-user path to grant anything to root, so treat uid 0 as "no
// console user".
if uid == 0 {
return 0, false
}
return uid, true
}
// resolveSymbol looks up one symbol and reports false when it is not there,
// rather than passing it to RegisterFunc which would panic.
func resolveSymbol(handle uintptr, name string) (uintptr, bool) {
sym, err := purego.Dlsym(handle, name)
if err != nil || sym == 0 {
return 0, false
}
return sym, true
}
@@ -0,0 +1,64 @@
package ipcauth
import (
"os"
"path/filepath"
"strings"
"syscall"
)
const (
devDir = "/dev"
// vtPrefix names the virtual terminals vt(4) publishes. How many there are
// is a kernel constant rather than a fixed number, and past the tenth they
// are not spelled in decimal: the unit is rendered in base 32, so the one
// after ttyv9 is ttyva.
vtPrefix = "ttyv"
)
// isConsoleUser reports whether id is logged into the FreeBSD console.
// FreeBSD's vt(4) chowns the virtual terminal device to the user logged in on
// it, so a non-root owner of any /dev/ttyv* reliably identifies a console user.
//
// Network ptys (pts) are intentionally not considered: SSH'd users are not "at
// the console".
func isConsoleUser(id Identity) bool {
// A SID belongs to a Windows principal and has no uid to compare.
if id.IsWindows() {
return false
}
// A root-owned ttyv is an unclaimed terminal rather than a root login, so
// uid 0 never matches. A root caller is privileged anyway and does not
// reach ownership checks through here.
if id.UID == 0 {
return false
}
entries, err := os.ReadDir(devDir)
if err != nil {
return false
}
for _, entry := range entries {
name := entry.Name()
suffix, ok := strings.CutPrefix(name, vtPrefix)
if !ok || suffix == "" {
continue
}
fi, err := os.Stat(filepath.Join(devDir, name))
if err != nil {
continue
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
continue
}
if st.Uid == id.UID {
return true
}
}
return false
}
@@ -0,0 +1,173 @@
package ipcauth
import (
"context"
"fmt"
"strings"
"time"
"github.com/godbus/dbus/v5"
log "github.com/sirupsen/logrus"
)
const (
loginDest = "org.freedesktop.login1"
loginPath = dbus.ObjectPath("/org/freedesktop/login1")
loginInterface = "org.freedesktop.login1.Manager"
listSeats = loginInterface + ".ListSeats"
seatInterface = "org.freedesktop.login1.Seat"
seatActiveSession = seatInterface + ".ActiveSession"
sessionInterface = "org.freedesktop.login1.Session"
sessionActive = sessionInterface + ".Active"
sessionClass = sessionInterface + ".Class"
sessionRemote = sessionInterface + ".Remote"
sessionUser = sessionInterface + ".User"
// propertiesGet is the standard property reader. godbus offers no
// context-aware GetProperty, and GetProperty is only this call underneath,
// so a bounded read has to make it directly.
propertiesGet = "org.freedesktop.DBus.Properties.Get"
// nullObjectPath is what logind puts in an object path field that refers to
// nothing, a seat with no session in the foreground being the one that
// matters here.
nullObjectPath = dbus.ObjectPath("/")
// consoleLookupTimeout bounds the whole seat walk, not each call in it, so
// a machine with several seats cannot multiply what an unanswering bus
// costs. A healthy lookup is well under a millisecond.
consoleLookupTimeout = 1 * time.Second
)
// isConsoleUser reports whether id holds the foreground session of one of this
// machine's seats.
//
// No seats means no console, which is the honest answer for a headless machine
// and the one that keeps its profiles to the privileged caller.
func isConsoleUser(id Identity) bool {
// A SID belongs to a Windows principal and has no uid to compare. Nothing
// on Linux produces one, and comparing anyway would match uid 0.
if id.IsWindows() {
return false
}
conn, err := dbus.SystemBus()
if err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), consoleLookupTimeout)
defer cancel()
// ListSeats returns a(so): seat id and object path.
var seats []struct {
ID string
Path dbus.ObjectPath
}
if err := conn.Object(loginDest, loginPath).CallWithContext(ctx, listSeats, 0).Store(&seats); err != nil {
log.Debugf("cannot list seats, treating the caller as not at the console: %v", err)
return false
}
for _, seat := range seats {
uid, ok := consoleSessionUID(ctx, conn, seat.Path)
if ok && uid == id.UID {
return true
}
}
return false
}
// consoleSessionUID returns who holds a seat's foreground session, and false
// unless that session is a person logged in locally.
func consoleSessionUID(ctx context.Context, conn *dbus.Conn, seatPath dbus.ObjectPath) (uint32, bool) {
// ActiveSession is (so): session id and object path. It names nothing on a
// seat whose VT has been switched away from, and on one whose display
// manager has not started a session yet.
prop, err := property(ctx, conn.Object(loginDest, seatPath), seatActiveSession)
if err != nil {
return 0, false
}
var active struct {
ID string
Path dbus.ObjectPath
}
if err := prop.Store(&active); err != nil {
return 0, false
}
if active.ID == "" || active.Path == "" || active.Path == nullObjectPath {
return 0, false
}
session := conn.Object(loginDest, active.Path)
// Only "user" sessions count: a greeter or a lock screen is the display
// manager sitting at the seat, not somebody to hand a profile to.
if class, ok := stringProperty(ctx, session, sessionClass); !ok || class != "user" {
return 0, false
}
// A remote session can hold a seat, and unlike the session's class and
// type, which pam_systemd takes from the environment of whoever opened the
// session, remoteness is set by the thing that accepted the connection. So
// it is worth asking even once the seat is established.
if remote, ok := boolProperty(ctx, session, sessionRemote); !ok || remote {
return 0, false
}
// Implied by the seat having named this session, and kept as a cross-check
// against a foreground that moved between the two calls.
if isActive, ok := boolProperty(ctx, session, sessionActive); !ok || !isActive {
return 0, false
}
// User is (uo): uid and the user object's path. Read from the session
// object rather than carried over from a listing, so the uid returned and
// the checks above are known to describe the same session.
prop, err = property(ctx, session, sessionUser)
if err != nil {
return 0, false
}
var owner struct {
UID uint32
Path dbus.ObjectPath
}
if err := prop.Store(&owner); err != nil {
return 0, false
}
return owner.UID, true
}
// property reads one property under ctx, name being the interface.member form
// godbus takes. It is what GetProperty does, with a deadline the caller owns.
func property(ctx context.Context, obj dbus.BusObject, name string) (dbus.Variant, error) {
idx := strings.LastIndex(name, ".")
if idx < 0 || idx+1 == len(name) {
return dbus.Variant{}, fmt.Errorf("invalid property name %q", name)
}
var v dbus.Variant
err := obj.CallWithContext(ctx, propertiesGet, 0, name[:idx], name[idx+1:]).Store(&v)
return v, err
}
func stringProperty(ctx context.Context, obj dbus.BusObject, name string) (string, bool) {
prop, err := property(ctx, obj, name)
if err != nil {
return "", false
}
s, ok := prop.Value().(string)
return s, ok
}
func boolProperty(ctx context.Context, obj dbus.BusObject, name string) (bool, bool) {
prop, err := property(ctx, obj, name)
if err != nil {
return false, false
}
b, ok := prop.Value().(bool)
return b, ok
}
@@ -0,0 +1,13 @@
//go:build !linux && !darwin && !freebsd && !windows
package ipcauth
// isConsoleUser has no meaning on a platform that exposes no console-user
// lookup, where nobody is ever at a console.
//
// Mobile is not built from here: ios satisfies darwin and android satisfies
// linux, so both take those lookups, which are present but never find a GUI
// session or a seat.
func isConsoleUser(Identity) bool {
return false
}
@@ -0,0 +1,54 @@
package ipcauth
import "testing"
// An identity the kernel never vouched for must not reach a console lookup at
// all: the zero Identity carries uid 0, which a platform lookup would
// otherwise compare against a root session.
func TestIsConsoleUserRejectsUnattestedIdentity(t *testing.T) {
for _, tc := range []struct {
name string
id Identity
}{
{"zero identity", Identity{}},
{"unattested uid 0", Identity{UID: 0}},
{"unattested uid", Identity{UID: 1000}},
{"unattested sid", Identity{SID: "S-1-5-21-1-2-3-1001"}},
} {
t.Run(tc.name, func(t *testing.T) {
if IsConsoleUser(tc.id) {
t.Fatal("an identity the kernel did not vouch for was reported as a console user")
}
})
}
}
// A lookup that panics must read as "cannot confirm". The daemon installs no
// gRPC recovery interceptor, so without this the panic ends the process from
// inside the per-RPC authorization path.
func TestGuardConsoleLookupContainsAPanic(t *testing.T) {
id := KnownForTest(Identity{UID: 1000})
if guardConsoleLookup(id, func(Identity) bool {
panic("pretending purego could not map a signature")
}) {
t.Fatal("a panicking lookup reported the caller as being at the console")
}
}
// The guard must not swallow a real answer on its way out.
func TestGuardConsoleLookupPassesTheAnswerThrough(t *testing.T) {
id := KnownForTest(Identity{UID: 1000})
if !guardConsoleLookup(id, func(got Identity) bool {
if got.UID != id.UID {
t.Fatalf("lookup received uid %d, want %d", got.UID, id.UID)
}
return true
}) {
t.Fatal("a lookup that found the caller at the console reported false")
}
if guardConsoleLookup(id, func(Identity) bool { return false }) {
t.Fatal("a lookup that found nobody reported true")
}
}
@@ -0,0 +1,40 @@
package ipcauth
import (
"golang.org/x/sys/windows"
)
// isConsoleUser reports whether id is the user logged into the active Windows
// console session.
//
// Returns false when there is no active console session, the session has no
// logged-in user, or any lookup fails.
func isConsoleUser(id Identity) bool {
// A caller with no SID is not a Windows principal and has nothing to
// compare against the console session's token.
if !id.IsWindows() {
return false
}
sessionID := windows.WTSGetActiveConsoleSessionId()
if sessionID == 0xFFFFFFFF {
return false
}
var token windows.Token
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
return false
}
defer token.Close()
console, err := identityFromToken(token)
if err != nil {
return false
}
// The console session's token and the caller's token carry the same SID for
// the same account, so the SID is what the two identities share. Elevation
// is deliberately not compared: whether the caller's shell is elevated says
// nothing about who is sitting at the console.
return console.SID != "" && console.SID == id.SID
}