package app import ( "errors" "fmt" "io/fs" "os" "os/user" "git.ophivana.moe/cat/fortify/acl" "git.ophivana.moe/cat/fortify/dbus" "git.ophivana.moe/cat/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/internal" "git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/cat/fortify/internal/verbose" "git.ophivana.moe/cat/fortify/xcb" ) // appSeal seals the application with child-related information type appSeal struct { // application unique identifier id *appID // wayland socket path if mediated wayland is enabled wl string // wait for wayland client to exit if mediated wayland is enabled, // (wlDone == nil) determines whether mediated wayland setup is performed wlDone chan struct{} // freedesktop application ID fid string // argv to start process with in the final confined environment command []string // persistent process state store store state.Store // uint8 representation of launch method sealed from config launchOption uint8 // process-specific share directory path share string // process-specific share directory path local to XDG_RUNTIME_DIR shareLocal string // path to launcher program toolPath string // pass-through enablement tracking from config et state.Enablements // prevents sharing from happening twice shared bool // seal system-level component sys *appSealTx // used in various sealing operations internal.SystemConstants // protected by upstream mutex } // appSealTx contains the system-level component of the app seal type appSealTx struct { bwrap *bwrap.Config // reference to D-Bus proxy instance, nil if disabled dbus *dbus.Proxy // notification from goroutine waiting for dbus.Proxy dbusWait chan struct{} // upstream address/downstream path used to initialise dbus.Proxy dbusAddr *[2][2]string // whether system bus proxy is enabled dbusSystem bool // paths to append/strip ACLs (of target user) from acl []*appACLEntry // X11 ChangeHosts commands to perform xhost []string // paths of directories to ensure mkdir []appEnsureEntry // dst, src pairs of temporarily shared files tmpfiles [][2]string // dst, src pairs of temporarily hard linked files hardlinks [][2]string // default formatted XDG_RUNTIME_DIR of User runtime string // sealed path to fortify executable, used by shim executable string // target user UID as an integer uid int // target user sealed from config *user.User // prevents commit from happening twice complete bool // prevents cleanup from happening twice closed bool // protected by upstream mutex } type appEnsureEntry struct { path string perm os.FileMode remove bool } // setEnv sets an environment variable for the child process func (tx *appSealTx) setEnv(k, v string) { tx.bwrap.SetEnv[k] = v } // bind mounts a directory within the sandbox func (tx *appSealTx) bind(src, dest string, ro bool) { if !ro { tx.bwrap.Bind = append(tx.bwrap.Bind, [2]string{src, dest}) } else { tx.bwrap.ROBind = append(tx.bwrap.ROBind, [2]string{src, dest}) } } // ensure appends a directory ensure action func (tx *appSealTx) ensure(path string, perm os.FileMode) { tx.mkdir = append(tx.mkdir, appEnsureEntry{path, perm, false}) } // ensureEphemeral appends a directory ensure action with removal in rollback func (tx *appSealTx) ensureEphemeral(path string, perm os.FileMode) { tx.mkdir = append(tx.mkdir, appEnsureEntry{path, perm, true}) } // appACLEntry contains information for applying/reverting an ACL entry type appACLEntry struct { tag state.Enablement path string perms []acl.Perm } func (e *appACLEntry) ts() string { switch e.tag { case state.EnableLength: return "Global" case state.EnableLength + 1: return "Process" default: return e.tag.String() } } func (e *appACLEntry) String() string { var s = []byte("---") for _, p := range e.perms { switch p { case acl.Read: s[0] = 'r' case acl.Write: s[1] = 'w' case acl.Execute: s[2] = 'x' } } return string(s) } // updatePerm appends an untagged acl update action func (tx *appSealTx) updatePerm(path string, perms ...acl.Perm) { tx.updatePermTag(state.EnableLength+1, path, perms...) } // updatePermTag appends an acl update action // Tagging with state.EnableLength sets cleanup to happen at final active launcher exit, // while tagging with state.EnableLength+1 will unconditionally clean up on exit. func (tx *appSealTx) updatePermTag(tag state.Enablement, path string, perms ...acl.Perm) { tx.acl = append(tx.acl, &appACLEntry{tag, path, perms}) } // changeHosts appends target username of an X11 ChangeHosts action func (tx *appSealTx) changeHosts(username string) { tx.xhost = append(tx.xhost, username) } // copyFile appends a tmpfiles action func (tx *appSealTx) copyFile(dst, src string) { tx.tmpfiles = append(tx.tmpfiles, [2]string{dst, src}) tx.updatePerm(dst, acl.Read) tx.bind(dst, dst, true) } // link appends a hardlink action func (tx *appSealTx) link(oldname, newname string) { tx.hardlinks = append(tx.hardlinks, [2]string{oldname, newname}) } type ( ChangeHostsError BaseError EnsureDirError BaseError TmpfileError BaseError DBusStartError BaseError ACLUpdateError BaseError ) // commit applies recorded actions // order: xhost, mkdir, tmpfiles, hardlinks, dbus, acl func (tx *appSealTx) commit() error { if tx.complete { panic("seal transaction committed twice") } tx.complete = true txp := &appSealTx{User: tx.User, bwrap: &bwrap.Config{SetEnv: make(map[string]string)}} defer func() { // rollback partial commit if txp != nil { // global changes (x11, ACLs) are always repeated and check for other launchers cannot happen here // attempting cleanup here will cause other fortified processes to lose access to them // a better (and more secure) fix is to proxy access to these resources and eliminate the ACLs altogether tags := new(state.Enablements) for e := state.Enablement(0); e < state.EnableLength+2; e++ { tags.Set(e) } if err := txp.revert(tags); err != nil { fmt.Println("fortify: errors returned reverting partial commit:", err) } } }() // insert xhost entries for _, username := range tx.xhost { verbose.Printf("inserting XHost entry SI:localuser:%s\n", username) if err := xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+username); err != nil { return (*ChangeHostsError)(wrapError(err, fmt.Sprintf("cannot insert XHost entry SI:localuser:%s, %s", username, err))) } else { // register partial commit txp.changeHosts(username) } } // ensure directories for _, dir := range tx.mkdir { verbose.Println("ensuring directory mode:", dir.perm.String(), "path:", dir.path) if err := os.Mkdir(dir.path, dir.perm); err != nil && !errors.Is(err, fs.ErrExist) { return (*EnsureDirError)(wrapError(err, fmt.Sprintf("cannot create directory '%s': %s", dir.path, err))) } else { // only ephemeral dirs require rollback if dir.remove { // register partial commit txp.ensureEphemeral(dir.path, dir.perm) } } } // publish tmpfiles for _, tmpfile := range tx.tmpfiles { verbose.Println("publishing tmpfile", tmpfile[0], "from", tmpfile[1]) if err := copyFile(tmpfile[0], tmpfile[1]); err != nil { return (*TmpfileError)(wrapError(err, fmt.Sprintf("cannot publish tmpfile '%s' from '%s': %s", tmpfile[0], tmpfile[1], err))) } else { // register partial commit txp.copyFile(tmpfile[0], tmpfile[1]) } } // create hardlinks for _, link := range tx.hardlinks { verbose.Println("creating hardlink", link[1], "from", link[0]) if err := os.Link(link[0], link[1]); err != nil { return (*TmpfileError)(wrapError(err, fmt.Sprintf("cannot create hardlink '%s' from '%s': %s", link[1], link[0], err))) } else { // register partial commit txp.link(link[0], link[1]) } } if tx.dbus != nil { // start dbus proxy verbose.Printf("session bus proxy on '%s' for upstream '%s'\n", tx.dbusAddr[0][1], tx.dbusAddr[0][0]) if tx.dbusSystem { verbose.Printf("system bus proxy on '%s' for upstream '%s'\n", tx.dbusAddr[1][1], tx.dbusAddr[1][0]) } if err := tx.startDBus(); err != nil { return (*DBusStartError)(wrapError(err, "cannot start message bus proxy:", err)) } else { txp.dbus = tx.dbus txp.dbusAddr = tx.dbusAddr txp.dbusSystem = tx.dbusSystem txp.dbusWait = tx.dbusWait } } // apply ACLs for _, e := range tx.acl { verbose.Println("applying ACL", e, "uid:", tx.Uid, "tag:", e.ts(), "path:", e.path) if err := acl.UpdatePerm(e.path, tx.uid, e.perms...); err != nil { return (*ACLUpdateError)(wrapError(err, fmt.Sprintf("cannot apply ACL to '%s': %s", e.path, err))) } else { // register partial commit txp.updatePermTag(e.tag, e.path, e.perms...) } } // disarm partial commit rollback txp = nil return nil } // revert rolls back recorded actions // order: acl, dbus, hardlinks, tmpfiles, mkdir, xhost // errors are printed but not treated as fatal func (tx *appSealTx) revert(tags *state.Enablements) error { if tx.closed { panic("seal transaction reverted twice") } tx.closed = true // will be slightly over-sized with ephemeral dirs errs := make([]error, 0, len(tx.acl)+1+len(tx.tmpfiles)+len(tx.mkdir)+len(tx.xhost)) joinError := func(err error, a ...any) { var e error if err != nil { e = wrapError(err, a...) } errs = append(errs, e) } // revert ACLs for _, e := range tx.acl { if tags.Has(e.tag) { verbose.Println("stripping ACL", e, "uid:", tx.Uid, "tag:", e.ts(), "path:", e.path) err := acl.UpdatePerm(e.path, tx.uid) joinError(err, fmt.Sprintf("cannot strip ACL entry from '%s': %s", e.path, err)) } else { verbose.Println("skipping ACL", e, "uid:", tx.Uid, "tag:", e.ts(), "path:", e.path) } } if tx.dbus != nil { // stop dbus proxy verbose.Println("terminating message bus proxy") err := tx.stopDBus() joinError(err, "cannot stop message bus proxy:", err) } // remove hardlinks for _, link := range tx.hardlinks { verbose.Println("removing hardlink", link[1]) err := os.Remove(link[1]) joinError(err, fmt.Sprintf("cannot remove hardlink '%s': %s", link[1], err)) } // remove tmpfiles for _, tmpfile := range tx.tmpfiles { verbose.Println("removing tmpfile", tmpfile[0]) err := os.Remove(tmpfile[0]) joinError(err, fmt.Sprintf("cannot remove tmpfile '%s': %s", tmpfile[0], err)) } // remove (empty) ephemeral directories for i := len(tx.mkdir); i > 0; i-- { dir := tx.mkdir[i-1] if !dir.remove { continue } verbose.Println("destroying ephemeral directory mode:", dir.perm.String(), "path:", dir.path) err := os.Remove(dir.path) joinError(err, fmt.Sprintf("cannot remove ephemeral directory '%s': %s", dir.path, err)) } if tags.Has(state.EnableX) { // rollback xhost insertions for _, username := range tx.xhost { verbose.Printf("deleting XHost entry SI:localuser:%s\n", username) err := xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+username) joinError(err, "cannot remove XHost entry:", err) } } return errors.Join(errs...) } // shareAll calls all share methods in sequence func (seal *appSeal) shareAll(bus [2]*dbus.Config) error { if seal.shared { panic("seal shared twice") } seal.shared = true seal.shareRuntime() targetRuntime := seal.shareRuntimeChild() verbose.Printf("child runtime data dir '%s' configured\n", targetRuntime) if err := seal.shareDisplay(); err != nil { return err } if err := seal.sharePulse(); err != nil { return err } // ensure dbus session bus defaults if bus[0] == nil { bus[0] = dbus.NewConfig(seal.fid, true, true) } if err := seal.shareDBus(bus); err != nil { return err } else if seal.sys.dbusAddr != nil { // set if D-Bus enabled and share successful verbose.Println("sealed session proxy", bus[0].Args(seal.sys.dbusAddr[0])) if bus[1] != nil { verbose.Println("sealed system proxy", bus[1].Args(seal.sys.dbusAddr[1])) } verbose.Println("message bus proxy final args:", seal.sys.dbus) } return nil }