diff options
Diffstat (limited to 'system')
| -rw-r--r-- | system/acl.go | 65 | ||||
| -rw-r--r-- | system/acl_test.go | 90 | ||||
| -rw-r--r-- | system/dbus.go | 148 | ||||
| -rw-r--r-- | system/enablement.go | 68 | ||||
| -rw-r--r-- | system/link.go | 47 | ||||
| -rw-r--r-- | system/mkdir.go | 86 | ||||
| -rw-r--r-- | system/mkdir_test.go | 73 | ||||
| -rw-r--r-- | system/op.go | 162 | ||||
| -rw-r--r-- | system/op_internal_test.go | 79 | ||||
| -rw-r--r-- | system/op_test.go | 129 | ||||
| -rw-r--r-- | system/tmpfiles.go | 76 | ||||
| -rw-r--r-- | system/tmpfiles_test.go | 81 | ||||
| -rw-r--r-- | system/wayland.go | 89 | ||||
| -rw-r--r-- | system/xhost.go | 53 | ||||
| -rw-r--r-- | system/xhost_test.go | 34 |
15 files changed, 1280 insertions, 0 deletions
diff --git a/system/acl.go b/system/acl.go new file mode 100644 index 00000000..840c3971 --- /dev/null +++ b/system/acl.go @@ -0,0 +1,65 @@ +package system + +import ( + "fmt" + "slices" + + "git.gensokyo.uk/security/fortify/acl" +) + +// UpdatePerm appends an ephemeral acl update Op. +func (sys *I) UpdatePerm(path string, perms ...acl.Perm) *I { + sys.UpdatePermType(Process, path, perms...) + + return sys +} + +// UpdatePermType appends an acl update Op. +func (sys *I) UpdatePermType(et Enablement, path string, perms ...acl.Perm) *I { + sys.lock.Lock() + defer sys.lock.Unlock() + + sys.ops = append(sys.ops, &ACL{et, path, perms}) + + return sys +} + +type ACL struct { + et Enablement + path string + perms acl.Perms +} + +func (a *ACL) Type() Enablement { return a.et } + +func (a *ACL) apply(sys *I) error { + sys.println("applying ACL", a) + return sys.wrapErrSuffix(acl.UpdatePerm(a.path, sys.uid, a.perms...), + fmt.Sprintf("cannot apply ACL entry to %q:", a.path)) +} + +func (a *ACL) revert(sys *I, ec *Criteria) error { + if ec.hasType(a) { + sys.println("stripping ACL", a) + return sys.wrapErrSuffix(acl.UpdatePerm(a.path, sys.uid), + fmt.Sprintf("cannot strip ACL entry from %q:", a.path)) + } else { + sys.println("skipping ACL", a) + return nil + } +} + +func (a *ACL) Is(o Op) bool { + a0, ok := o.(*ACL) + return ok && a0 != nil && + a.et == a0.et && + a.path == a0.path && + slices.Equal(a.perms, a0.perms) +} + +func (a *ACL) Path() string { return a.path } + +func (a *ACL) String() string { + return fmt.Sprintf("%s type: %s path: %q", + a.perms, TypeString(a.et), a.path) +} diff --git a/system/acl_test.go b/system/acl_test.go new file mode 100644 index 00000000..ed154ad1 --- /dev/null +++ b/system/acl_test.go @@ -0,0 +1,90 @@ +package system + +import ( + "testing" + + "git.gensokyo.uk/security/fortify/acl" +) + +func TestUpdatePerm(t *testing.T) { + testCases := []struct { + path string + perms []acl.Perm + }{ + {"/run/user/1971/fortify", []acl.Perm{acl.Execute}}, + {"/tmp/fortify.1971/tmpdir/150", []acl.Perm{acl.Read, acl.Write, acl.Execute}}, + } + + for _, tc := range testCases { + t.Run(tc.path+permSubTestSuffix(tc.perms), func(t *testing.T) { + sys := New(150) + sys.UpdatePerm(tc.path, tc.perms...) + (&tcOp{Process, tc.path}).test(t, sys.ops, []Op{&ACL{Process, tc.path, tc.perms}}, "UpdatePerm") + }) + } +} + +func TestUpdatePermType(t *testing.T) { + testCases := []struct { + perms []acl.Perm + tcOp + }{ + {[]acl.Perm{acl.Execute}, tcOp{User, "/tmp/fortify.1971/tmpdir"}}, + {[]acl.Perm{acl.Read, acl.Write, acl.Execute}, tcOp{User, "/tmp/fortify.1971/tmpdir/150"}}, + {[]acl.Perm{acl.Execute}, tcOp{Process, "/run/user/1971/fortify/fcb8a12f7c482d183ade8288c3de78b5"}}, + {[]acl.Perm{acl.Read}, tcOp{Process, "/tmp/fortify.1971/fcb8a12f7c482d183ade8288c3de78b5/passwd"}}, + {[]acl.Perm{acl.Read}, tcOp{Process, "/tmp/fortify.1971/fcb8a12f7c482d183ade8288c3de78b5/group"}}, + {[]acl.Perm{acl.Read, acl.Write, acl.Execute}, tcOp{EWayland, "/run/user/1971/wayland-0"}}, + } + + for _, tc := range testCases { + t.Run(tc.path+"_"+TypeString(tc.et)+permSubTestSuffix(tc.perms), func(t *testing.T) { + sys := New(150) + sys.UpdatePermType(tc.et, tc.path, tc.perms...) + tc.test(t, sys.ops, []Op{&ACL{tc.et, tc.path, tc.perms}}, "UpdatePermType") + }) + } +} + +func TestACL_String(t *testing.T) { + testCases := []struct { + want string + et Enablement + perms []acl.Perm + }{ + {`--- type: Process path: "/nonexistent"`, Process, []acl.Perm{}}, + {`r-- type: User path: "/nonexistent"`, User, []acl.Perm{acl.Read}}, + {`-w- type: Wayland path: "/nonexistent"`, EWayland, []acl.Perm{acl.Write}}, + {`--x type: X11 path: "/nonexistent"`, EX11, []acl.Perm{acl.Execute}}, + {`rw- type: D-Bus path: "/nonexistent"`, EDBus, []acl.Perm{acl.Read, acl.Write}}, + {`r-x type: PulseAudio path: "/nonexistent"`, EPulse, []acl.Perm{acl.Read, acl.Execute}}, + {`rwx type: User path: "/nonexistent"`, User, []acl.Perm{acl.Read, acl.Write, acl.Execute}}, + {`rwx type: Process path: "/nonexistent"`, Process, []acl.Perm{acl.Read, acl.Write, acl.Write, acl.Execute}}, + } + + for _, tc := range testCases { + t.Run(tc.want, func(t *testing.T) { + a := &ACL{et: tc.et, perms: tc.perms, path: "/nonexistent"} + if got := a.String(); got != tc.want { + t.Errorf("String() = %v, want %v", + got, tc.want) + } + }) + } +} + +func permSubTestSuffix(perms []acl.Perm) (suffix string) { + for _, perm := range perms { + switch perm { + case acl.Read: + suffix += "_read" + case acl.Write: + suffix += "_write" + case acl.Execute: + suffix += "_execute" + default: + panic("unreachable") + } + } + return +} diff --git a/system/dbus.go b/system/dbus.go new file mode 100644 index 00000000..cbb127ed --- /dev/null +++ b/system/dbus.go @@ -0,0 +1,148 @@ +package system + +import ( + "bytes" + "errors" + "log" + "strings" + "sync" + + "git.gensokyo.uk/security/fortify/dbus" +) + +var ( + ErrDBusConfig = errors.New("dbus config not supplied") +) + +func (sys *I) MustProxyDBus(sessionPath string, session *dbus.Config, systemPath string, system *dbus.Config) *I { + if _, err := sys.ProxyDBus(session, system, sessionPath, systemPath); err != nil { + panic(err.Error()) + } else { + return sys + } +} + +func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath string) (func(), error) { + d := new(DBus) + + // session bus is mandatory + if session == nil { + return nil, sys.wrapErr(ErrDBusConfig, + "attempted to seal message bus proxy without session bus config") + } + + // system bus is optional + d.system = system != nil + + // upstream address, downstream socket path + var sessionBus, systemBus [2]string + + // resolve upstream bus addresses + sessionBus[0], systemBus[0] = dbus.Address() + + // set paths from caller + sessionBus[1], systemBus[1] = sessionPath, systemPath + + // create proxy instance + d.proxy = dbus.New(sessionBus, systemBus) + + defer func() { + if sys.IsVerbose() && d.proxy.Sealed() { + sys.println("sealed session proxy", session.Args(sessionBus)) + if system != nil { + sys.println("sealed system proxy", system.Args(systemBus)) + } + sys.println("message bus proxy final args:", d.proxy) + } + }() + + // queue operation + sys.ops = append(sys.ops, d) + + // seal dbus proxy + d.out = &scanToFmsg{msg: new(strings.Builder)} + return d.out.Dump, sys.wrapErrSuffix(d.proxy.Seal(session, system), + "cannot seal message bus proxy:") +} + +type DBus struct { + proxy *dbus.Proxy + + out *scanToFmsg + // whether system bus proxy is enabled + system bool +} + +func (d *DBus) Type() Enablement { return Process } + +func (d *DBus) apply(sys *I) error { + sys.printf("session bus proxy on %q for upstream %q", d.proxy.Session()[1], d.proxy.Session()[0]) + if d.system { + sys.printf("system bus proxy on %q for upstream %q", d.proxy.System()[1], d.proxy.System()[0]) + } + + // this starts the process and blocks until ready + if err := d.proxy.Start(sys.ctx, d.out, true); err != nil { + d.out.Dump() + return sys.wrapErrSuffix(err, + "cannot start message bus proxy:") + } + sys.println("starting message bus proxy:", d.proxy) + return nil +} + +func (d *DBus) revert(sys *I, _ *Criteria) error { + // criteria ignored here since dbus is always process-scoped + sys.println("terminating message bus proxy") + d.proxy.Close() + defer sys.println("message bus proxy exit") + return sys.wrapErrSuffix(d.proxy.Wait(), "message bus proxy error:") +} + +func (d *DBus) Is(o Op) bool { + d0, ok := o.(*DBus) + return ok && d0 != nil && + ((d.proxy == nil && d0.proxy == nil) || + (d.proxy != nil && d0.proxy != nil && d.proxy.String() == d0.proxy.String())) +} + +func (d *DBus) Path() string { + return "(dbus proxy)" +} + +func (d *DBus) String() string { + return d.proxy.String() +} + +type scanToFmsg struct { + msg *strings.Builder + msgbuf []string + + mu sync.RWMutex +} + +func (s *scanToFmsg) Write(p []byte) (n int, err error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.write(p, 0) +} + +func (s *scanToFmsg) write(p []byte, a int) (int, error) { + if i := bytes.IndexByte(p, '\n'); i == -1 { + n, _ := s.msg.Write(p) + return a + n, nil + } else { + n, _ := s.msg.Write(p[:i]) + s.msgbuf = append(s.msgbuf, s.msg.String()) + s.msg.Reset() + return s.write(p[i+1:], a+n+1) + } +} + +func (s *scanToFmsg) Dump() { + s.mu.RLock() + for _, msg := range s.msgbuf { + log.Println(msg) + } + s.mu.RUnlock() +} diff --git a/system/enablement.go b/system/enablement.go new file mode 100644 index 00000000..f0911d54 --- /dev/null +++ b/system/enablement.go @@ -0,0 +1,68 @@ +package system + +import ( + "strings" +) + +type ( + // Enablement represents an optional system resource + Enablement uint8 + // Enablements represents optional system resources to share + Enablements uint64 +) + +const ( + EWayland Enablement = iota + EX11 + EDBus + EPulse +) + +var enablementString = [...]string{ + EWayland: "Wayland", + EX11: "X11", + EDBus: "D-Bus", + EPulse: "PulseAudio", +} + +const ELen = len(enablementString) + +func (e Enablement) String() string { + if int(e) >= ELen { + return "<invalid enablement>" + } + return enablementString[e] +} + +func (e Enablement) Mask() Enablements { + return 1 << e +} + +// Has returns whether a feature is enabled +func (es *Enablements) Has(e Enablement) bool { + return *es&e.Mask() != 0 +} + +// Set enables a feature +func (es *Enablements) Set(e Enablement) { + if es.Has(e) { + panic("enablement " + e.String() + " set twice") + } + + *es |= e.Mask() +} + +func (es *Enablements) String() string { + buf := new(strings.Builder) + for i := Enablement(0); i < Enablement(ELen); i++ { + if es.Has(i) { + buf.WriteString(", " + i.String()) + } + } + + if buf.Len() == 0 { + buf.WriteString("(No enablements)") + } + + return strings.TrimPrefix(buf.String(), ", ") +} diff --git a/system/link.go b/system/link.go new file mode 100644 index 00000000..4182d560 --- /dev/null +++ b/system/link.go @@ -0,0 +1,47 @@ +package system + +import ( + "fmt" + "os" +) + +// Link registers an Op that links dst to src. +func (sys *I) Link(oldname, newname string) *I { return sys.LinkFileType(Process, oldname, newname) } + +// LinkFileType registers a file linking Op labelled with type et. +func (sys *I) LinkFileType(et Enablement, oldname, newname string) *I { + sys.lock.Lock() + defer sys.lock.Unlock() + + sys.ops = append(sys.ops, &Hardlink{et, newname, oldname}) + + return sys +} + +type Hardlink struct { + et Enablement + dst, src string +} + +func (l *Hardlink) Type() Enablement { return l.et } + +func (l *Hardlink) apply(sys *I) error { + sys.println("linking", l) + return sys.wrapErrSuffix(os.Link(l.src, l.dst), + fmt.Sprintf("cannot link %q:", l.dst)) +} + +func (l *Hardlink) revert(sys *I, ec *Criteria) error { + if ec.hasType(l) { + sys.printf("removing hard link %q", l.dst) + return sys.wrapErrSuffix(os.Remove(l.dst), + fmt.Sprintf("cannot remove hard link %q:", l.dst)) + } else { + sys.printf("skipping hard link %q", l.dst) + return nil + } +} + +func (l *Hardlink) Is(o Op) bool { l0, ok := o.(*Hardlink); return ok && l0 != nil && *l == *l0 } +func (l *Hardlink) Path() string { return l.src } +func (l *Hardlink) String() string { return fmt.Sprintf("%q from %q", l.dst, l.src) } diff --git a/system/mkdir.go b/system/mkdir.go new file mode 100644 index 00000000..524289e5 --- /dev/null +++ b/system/mkdir.go @@ -0,0 +1,86 @@ +package system + +import ( + "errors" + "fmt" + "os" +) + +// Ensure the existence and mode of a directory. +func (sys *I) Ensure(name string, perm os.FileMode) *I { + sys.lock.Lock() + defer sys.lock.Unlock() + + sys.ops = append(sys.ops, &Mkdir{User, name, perm, false}) + + return sys +} + +// Ephemeral ensures the temporary existence and mode of a directory through the life of et. +func (sys *I) Ephemeral(et Enablement, name string, perm os.FileMode) *I { + sys.lock.Lock() + defer sys.lock.Unlock() + + sys.ops = append(sys.ops, &Mkdir{et, name, perm, true}) + + return sys +} + +type Mkdir struct { + et Enablement + path string + perm os.FileMode + ephemeral bool +} + +func (m *Mkdir) Type() Enablement { + return m.et +} + +func (m *Mkdir) apply(sys *I) error { + sys.println("ensuring directory", m) + + // create directory + err := os.Mkdir(m.path, m.perm) + if !errors.Is(err, os.ErrExist) { + return sys.wrapErrSuffix(err, + fmt.Sprintf("cannot create directory %q:", m.path)) + } + + // directory exists, ensure mode + return sys.wrapErrSuffix(os.Chmod(m.path, m.perm), + fmt.Sprintf("cannot change mode of %q to %s:", m.path, m.perm)) +} + +func (m *Mkdir) revert(sys *I, ec *Criteria) error { + if !m.ephemeral { + // skip non-ephemeral dir and do not log anything + return nil + } + + if ec.hasType(m) { + sys.println("destroying ephemeral directory", m) + return sys.wrapErrSuffix(os.Remove(m.path), + fmt.Sprintf("cannot remove ephemeral directory %q:", m.path)) + } else { + sys.println("skipping ephemeral directory", m) + return nil + } +} + +func (m *Mkdir) Is(o Op) bool { + m0, ok := o.(*Mkdir) + return ok && m0 != nil && *m == *m0 +} + +func (m *Mkdir) Path() string { + return m.path +} + +func (m *Mkdir) String() string { + t := "Ensure" + if m.ephemeral { + t = TypeString(m.Type()) + } + return fmt.Sprintf("mode: %s type: %s path: %q", m.perm.String(), t, m.path) +} diff --git a/system/mkdir_test.go b/system/mkdir_test.go new file mode 100644 index 00000000..20826940 --- /dev/null +++ b/system/mkdir_test.go @@ -0,0 +1,73 @@ +package system + +import ( + "os" + "testing" +) + +func TestEnsure(t *testing.T) { + testCases := []struct { + name string + perm os.FileMode + }{ + {"/tmp/fortify.1971", 0701}, + {"/tmp/fortify.1971/tmpdir", 0700}, + {"/tmp/fortify.1971/tmpdir/150", 0700}, + {"/run/user/1971/fortify", 0700}, + } + for _, tc := range testCases { + t.Run(tc.name+"_"+tc.perm.String(), func(t *testing.T) { + sys := New(150) + sys.Ensure(tc.name, tc.perm) + (&tcOp{User, tc.name}).test(t, sys.ops, []Op{&Mkdir{User, tc.name, tc.perm, false}}, "Ensure") + }) + } +} + +func TestEphemeral(t *testing.T) { + testCases := []struct { + perm os.FileMode + tcOp + }{ + {0700, tcOp{Process, "/run/user/1971/fortify/ec07546a772a07cde87389afc84ffd13"}}, + {0701, tcOp{Process, "/tmp/fortify.1971/ec07546a772a07cde87389afc84ffd13"}}, + } + for _, tc := range testCases { + t.Run(tc.path+"_"+tc.perm.String()+"_"+TypeString(tc.et), func(t *testing.T) { + sys := New(150) + sys.Ephemeral(tc.et, tc.path, tc.perm) + tc.test(t, sys.ops, []Op{&Mkdir{tc.et, tc.path, tc.perm, true}}, "Ephemeral") + }) + } +} + +func TestMkdir_String(t *testing.T) { + testCases := []struct { + want string + ephemeral bool + et Enablement + }{ + {"Ensure", false, User}, + {"Ensure", false, Process}, + {"Ensure", false, EWayland}, + + {"Wayland", true, EWayland}, + {"X11", true, EX11}, + {"D-Bus", true, EDBus}, + {"PulseAudio", true, EPulse}, + } + for _, tc := range testCases { + t.Run(tc.want, func(t *testing.T) { + m := &Mkdir{ + et: tc.et, + path: "/nonexistent", + perm: 0701, + ephemeral: tc.ephemeral, + } + want := "mode: " + os.FileMode(0701).String() + " type: " + tc.want + " path: \"/nonexistent\"" + if got := m.String(); got != want { + t.Errorf("String() = %v, want %v", got, want) + } + }) + } +} diff --git a/system/op.go b/system/op.go new file mode 100644 index 00000000..0e421aec --- /dev/null +++ b/system/op.go @@ -0,0 +1,162 @@ +package system + +import ( + "context" + "errors" + "log" + "sync" +) + +const ( + // User type is reverted at final launcher exit. + User = Enablement(ELen) + // Process type is unconditionally reverted on exit. + Process = Enablement(ELen + 1) +) + +type Criteria struct { + *Enablements +} + +func (ec *Criteria) hasType(o Op) bool { + // nil criteria: revert everything except User + if ec.Enablements == nil { + return o.Type() != User + } + + return ec.Has(o.Type()) +} + +// Op is a reversible system operation. +type Op interface { + // Type returns Op's enablement type. + Type() Enablement + + // apply the Op + apply(sys *I) error + // revert reverses the Op if criteria is met + revert(sys *I, ec *Criteria) error + + Is(o Op) bool + Path() string + String() string +} + +func TypeString(e Enablement) string { + switch e { + case User: + return "User" + case Process: + return "Process" + default: + return e.String() + } +} + +// New initialises sys with no-op verbose functions. +func New(uid int) (sys *I) { + sys = new(I) + sys.uid = uid + sys.IsVerbose = func() bool { return false } + sys.Verbose = func(...any) {} + sys.Verbosef = func(string, ...any) {} + sys.WrapErr = func(err error, _ ...any) error { return err } + return +} + +type I struct { + uid int + ops []Op + ctx context.Context + + IsVerbose func() bool + Verbose func(v ...any) + Verbosef func(format string, v ...any) + WrapErr func(err error, a ...any) error + + // whether sys has been reverted + state bool + + lock sync.Mutex +} + +func (sys *I) UID() int { return sys.uid } +func (sys *I) println(v ...any) { sys.Verbose(v...) } +func (sys *I) printf(format string, v ...any) { sys.Verbosef(format, v...) } +func (sys *I) wrapErr(err error, a ...any) error { return sys.WrapErr(err, a...) } +func (sys *I) wrapErrSuffix(err error, a ...any) error { + if err == nil { + return nil + } + return sys.wrapErr(err, append(a, err)...) +} + +func (sys *I) Equal(v *I) bool { + if v == nil || sys.uid != v.uid || len(sys.ops) != len(v.ops) { + return false + } + + for i, o := range sys.ops { + if !o.Is(v.ops[i]) { + return false + } + } + + return true +} + +func (sys *I) Commit(ctx context.Context) error { + sys.lock.Lock() + defer sys.lock.Unlock() + + if sys.ctx != nil { + panic("sys instance committed twice") + } + sys.ctx = ctx + + sp := New(sys.uid) + sp.ops = make([]Op, 0, len(sys.ops)) // prevent copies during commits + defer func() { + // sp is set to nil when all ops are applied + if sp != nil { + // rollback partial commit + sys.printf("commit faulted after %d ops, rolling back partial commit", len(sp.ops)) + if err := sp.Revert(&Criteria{nil}); err != nil { + log.Println("errors returned reverting partial commit:", err) + } + } + }() + + for _, o := range sys.ops { + if err := o.apply(sys); err != nil { + return err + } else { + // register partial commit + sp.ops = append(sp.ops, o) + } + } + + // disarm partial commit rollback + sp = nil + return nil +} + +func (sys *I) Revert(ec *Criteria) error { + sys.lock.Lock() + defer sys.lock.Unlock() + + if sys.state { + panic("sys instance reverted twice") + } + sys.state = true + + // collect errors + errs := make([]error, len(sys.ops)) + + for i := range sys.ops { + errs[i] = sys.ops[len(sys.ops)-i-1].revert(sys, ec) + } + + // errors.Join filters nils + return errors.Join(errs...) +} diff --git a/system/op_internal_test.go b/system/op_internal_test.go new file mode 100644 index 00000000..7ec4f174 --- /dev/null +++ b/system/op_internal_test.go @@ -0,0 +1,79 @@ +package system + +import "testing" + +type tcOp struct { + et Enablement + path string +} + +// test an instance of the Op interface +func (ptc tcOp) test(t *testing.T, gotOps []Op, wantOps []Op, fn string) { + if len(gotOps) != len(wantOps) { + t.Errorf("%s: inserted %v Ops, want %v", fn, + len(gotOps), len(wantOps)) + return + } + + t.Run("path", func(t *testing.T) { + if len(gotOps) > 0 { + if got := gotOps[0].Path(); got != ptc.path { + t.Errorf("Path() = %q, want %q", + got, ptc.path) + return + } + } + }) + + for i := range gotOps { + o := gotOps[i] + + t.Run("is", func(t *testing.T) { + if !o.Is(o) { + t.Errorf("Is returned false on self") + return + } + if !o.Is(wantOps[i]) { + t.Errorf("%s: inserted %#v, want %#v", + fn, + o, wantOps[i]) + return + } + }) + + t.Run("criteria", func(t *testing.T) { + testCases := []struct { + name string + ec *Criteria + want bool + }{ + {"nil", newCriteria(), ptc.et != User}, + {"self", newCriteria(ptc.et), true}, + {"all", newCriteria(EWayland, EX11, EDBus, EPulse, User, Process), true}, + {"enablements", newCriteria(EWayland, EX11, EDBus, EPulse), ptc.et != User && ptc.et != Process}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.ec.hasType(o); got != tc.want { + t.Errorf("hasType: got %v, want %v", + got, tc.want) + } + }) + } + }) + } +} + +func newCriteria(labels ...Enablement) *Criteria { + ec := new(Criteria) + if len(labels) == 0 { + return ec + } + + ec.Enablements = new(Enablements) + for _, e := range labels { + ec.Set(e) + } + return ec +} diff --git a/system/op_test.go b/system/op_test.go new file mode 100644 index 00000000..9d179699 --- /dev/null +++ b/system/op_test.go @@ -0,0 +1,129 @@ +package system_test + +import ( + "strconv" + "testing" + + "git.gensokyo.uk/security/fortify/system" +) + +func TestNew(t *testing.T) { + testCases := []struct { + uid int + }{ + {150}, + {149}, + {148}, + {147}, + } + + for _, tc := range testCases { + t.Run("sys initialised with uid "+strconv.Itoa(tc.uid), func(t *testing.T) { + if got := system.New(tc.uid); got.UID() != tc.uid { + t.Errorf("New(%d) uid = %d, want %d", + tc.uid, + got.UID(), tc.uid) + } + }) + } +} + +func TestTypeString(t *testing.T) { + testCases := []struct { + e system.Enablement + want string + }{ + {system.EWayland, system.EWayland.String()}, + {system.EX11, system.EX11.String()}, + {system.EDBus, system.EDBus.String()}, + {system.EPulse, system.EPulse.String()}, + {system.User, "User"}, + {system.Process, "Process"}, + } + + for _, tc := range testCases { + t.Run("label type string "+tc.want, func(t *testing.T) { + if got := system.TypeString(tc.e); got != tc.want { + t.Errorf("TypeString(%d) = %v, want %v", + tc.e, + got, tc.want) + } + }) + } +} + +func TestI_Equal(t *testing.T) { + testCases := []struct { + name string + sys *system.I + v *system.I + want bool + }{ + { + "simple UID", + system.New(150), + system.New(150), + true, + }, + { + "simple UID differ", + system.New(150), + system.New(151), + false, + }, + { + "simple UID nil", + system.New(150), + nil, + false, + }, + { + "op length mismatch", + system.New(150). + ChangeHosts("chronos"), + system.New(150). + ChangeHosts("chronos"). + Ensure("/run", 0755), + false, + }, + { + "op value mismatch", + system.New(150). + ChangeHosts("chronos"). + Ensure("/run", 0644), + system.New(150). + ChangeHosts("chronos"). + Ensure("/run", 0755), + false, + }, + { + "op type mismatch", + system.New(150). + ChangeHosts("chronos"). + CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 0, 256), + system.New(150). + ChangeHosts("chronos"). + Ensure("/run", 0755), + false, + }, + { + "op equals", + system.New(150). + ChangeHosts("chronos"). + Ensure("/run", 0755), + system.New(150). + ChangeHosts("chronos"). + Ensure("/run", 0755), + true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.sys.Equal(tc.v) != tc.want { + t.Errorf("Equal: got %v; want %v", + !tc.want, tc.want) + } + }) + } +} diff --git a/system/tmpfiles.go b/system/tmpfiles.go new file mode 100644 index 00000000..636943bc --- /dev/null +++ b/system/tmpfiles.go @@ -0,0 +1,76 @@ +package system + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "syscall" +) + +// CopyFile registers an Op that copies from src. +// A buffer is initialised with size cap and the Op faults if bytes read exceed n. +func (sys *I) CopyFile(payload *[]byte, src string, cap int, n int64) *I { + buf := new(bytes.Buffer) + buf.Grow(cap) + + sys.lock.Lock() + sys.ops = append(sys.ops, &Tmpfile{payload, src, n, buf}) + sys.lock.Unlock() + + return sys +} + +type Tmpfile struct { + payload *[]byte + src string + + n int64 + buf *bytes.Buffer +} + +func (t *Tmpfile) Type() Enablement { return Process } +func (t *Tmpfile) apply(sys *I) error { + sys.println("copying", t) + + if t.payload == nil { + // this is a misuse of the API; do not return an error message + return errors.New("invalid payload") + } + + if b, err := os.Stat(t.src); err != nil { + return sys.wrapErrSuffix(err, + fmt.Sprintf("cannot stat %q:", t.src)) + } else { + if b.IsDir() { + return sys.wrapErrSuffix(syscall.EISDIR, + fmt.Sprintf("%q is a directory", t.src)) + } + if s := b.Size(); s > t.n { + return sys.wrapErrSuffix(syscall.ENOMEM, + fmt.Sprintf("file %q is too long: %d > %d", + t.src, s, t.n)) + } + } + + if f, err := os.Open(t.src); err != nil { + return sys.wrapErrSuffix(err, + fmt.Sprintf("cannot open %q:", t.src)) + } else if _, err = io.CopyN(t.buf, f, t.n); err != nil { + return sys.wrapErrSuffix(err, + fmt.Sprintf("cannot read from %q:", t.src)) + } + + *t.payload = t.buf.Bytes() + return nil +} +func (t *Tmpfile) revert(*I, *Criteria) error { t.buf.Reset(); return nil } + +func (t *Tmpfile) Is(o Op) bool { + t0, ok := o.(*Tmpfile) + return ok && t0 != nil && + t.src == t0.src && t.n == t0.n +} +func (t *Tmpfile) Path() string { return t.src } +func (t *Tmpfile) String() string { return fmt.Sprintf("up to %d bytes from %q", t.n, t.src) } diff --git a/system/tmpfiles_test.go b/system/tmpfiles_test.go new file mode 100644 index 00000000..c7402f59 --- /dev/null +++ b/system/tmpfiles_test.go @@ -0,0 +1,81 @@ +package system + +import ( + "strconv" + "testing" +) + +func TestCopyFile(t *testing.T) { + testCases := []struct { + tcOp + cap int + n int64 + }{ + {tcOp{Process, "/home/ophestra/xdg/config/pulse/cookie"}, 256, 256}, + } + for _, tc := range testCases { + t.Run("copy file "+tc.path+" with cap = "+strconv.Itoa(tc.cap)+" n = "+strconv.Itoa(int(tc.n)), func(t *testing.T) { + sys := New(150) + sys.CopyFile(new([]byte), tc.path, tc.cap, tc.n) + tc.test(t, sys.ops, []Op{ + &Tmpfile{nil, tc.path, tc.n, nil}, + }, "CopyFile") + }) + } +} + +func TestLink(t *testing.T) { + testCases := []struct { + dst, src string + }{ + {"/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"}, + {"/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"}, + } + for _, tc := range testCases { + t.Run("link file "+tc.dst+" from "+tc.src, func(t *testing.T) { + sys := New(150) + sys.Link(tc.src, tc.dst) + (&tcOp{Process, tc.src}).test(t, sys.ops, []Op{ + &Hardlink{Process, tc.dst, tc.src}, + }, "Link") + }) + } +} + +func TestLinkFileType(t *testing.T) { + testCases := []struct { + tcOp + dst string + }{ + {tcOp{User, "/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"}, + {tcOp{Process, "/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"}, + } + for _, tc := range testCases { + t.Run("link file "+tc.dst+" from "+tc.path+" with type "+TypeString(tc.et), func(t *testing.T) { + sys := New(150) + sys.LinkFileType(tc.et, tc.path, tc.dst) + tc.test(t, sys.ops, []Op{ + &Hardlink{tc.et, tc.dst, tc.path}, + }, "LinkFileType") + }) + } +} + +func TestTmpfile_String(t *testing.T) { + testCases := []struct { + src string + n int64 + want string + }{ + {"/home/ophestra/xdg/config/pulse/cookie", 256, + `up to 256 bytes from "/home/ophestra/xdg/config/pulse/cookie"`}, + } + + for _, tc := range testCases { + t.Run(tc.want, func(t *testing.T) { + if got := (&Tmpfile{src: tc.src, n: tc.n}).String(); got != tc.want { + t.Errorf("String() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/system/wayland.go b/system/wayland.go new file mode 100644 index 00000000..11f54d80 --- /dev/null +++ b/system/wayland.go @@ -0,0 +1,89 @@ +package system + +import ( + "errors" + "fmt" + "os" + + "git.gensokyo.uk/security/fortify/acl" + "git.gensokyo.uk/security/fortify/wl" +) + +// Wayland sets up a wayland socket with a security context attached. +func (sys *I) Wayland(syncFd **os.File, dst, src, appID, instanceID string) *I { + sys.lock.Lock() + defer sys.lock.Unlock() + + sys.ops = append(sys.ops, &Wayland{syncFd, dst, src, appID, instanceID, wl.Conn{}}) + + return sys +} + +type Wayland struct { + sync **os.File + dst, src string + appID, instanceID string + + conn wl.Conn +} + +func (w *Wayland) Type() Enablement { return Process } + +func (w *Wayland) apply(sys *I) error { + if w.sync == nil { + // this is a misuse of the API; do not return an error message + return errors.New("invalid sync") + } + + // the Wayland op is not repeatable + if *w.sync != nil { + // this is a misuse of the API; do not return an error message + return errors.New("attempted to attach multiple wayland sockets") + } + + if err := w.conn.Attach(w.src); err != nil { + // make console output less nasty + if errors.Is(err, os.ErrNotExist) { + err = os.ErrNotExist + } + return sys.wrapErrSuffix(err, + fmt.Sprintf("cannot attach to wayland on %q:", w.src)) + } else { + sys.printf("wayland attached on %q", w.src) + } + + if sp, err := w.conn.Bind(w.dst, w.appID, w.instanceID); err != nil { + return sys.wrapErrSuffix(err, + fmt.Sprintf("cannot bind to socket on %q:", w.dst)) + } else { + *w.sync = sp + sys.printf("wayland listening on %q", w.dst) + return sys.wrapErrSuffix(errors.Join(os.Chmod(w.dst, 0), acl.UpdatePerm(w.dst, sys.uid, acl.Read, acl.Write, acl.Execute)), + fmt.Sprintf("cannot chmod socket on %q:", w.dst)) + } +} + +func (w *Wayland) revert(sys *I, ec *Criteria) error { + if ec.hasType(w) { + sys.printf("removing wayland socket on %q", w.dst) + if err := os.Remove(w.dst); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + sys.printf("detaching from wayland on %q", w.src) + return sys.wrapErrSuffix(w.conn.Close(), + fmt.Sprintf("cannot detach from wayland on %q:", w.src)) + } else { + sys.printf("skipping wayland cleanup on %q", w.dst) + return nil + } +} + +func (w *Wayland) Is(o Op) bool { + w0, ok := o.(*Wayland) + return ok && w.dst == w0.dst && w.src == w0.src && + w.appID == w0.appID && w.instanceID == w0.instanceID +} + +func (w *Wayland) Path() string { return w.dst } +func (w *Wayland) String() string { return fmt.Sprintf("wayland socket at %q", w.dst) } diff --git a/system/xhost.go b/system/xhost.go new file mode 100644 index 00000000..bbc0a914 --- /dev/null +++ b/system/xhost.go @@ -0,0 +1,53 @@ +package system + +import ( + "fmt" + + "git.gensokyo.uk/security/fortify/xcb" +) + +// ChangeHosts appends an X11 ChangeHosts command Op. +func (sys *I) ChangeHosts(username string) *I { + sys.lock.Lock() + defer sys.lock.Unlock() + + sys.ops = append(sys.ops, XHost(username)) + + return sys +} + +type XHost string + +func (x XHost) Type() Enablement { + return EX11 +} + +func (x XHost) apply(sys *I) error { + sys.printf("inserting entry %s to X11", x) + return sys.wrapErrSuffix(xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)), + fmt.Sprintf("cannot insert entry %s to X11:", x)) +} + +func (x XHost) revert(sys *I, ec *Criteria) error { + if ec.hasType(x) { + sys.printf("deleting entry %s from X11", x) + return sys.wrapErrSuffix(xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)), + fmt.Sprintf("cannot delete entry %s from X11:", x)) + } else { + sys.printf("skipping entry %s in X11", x) + return nil + } +} + +func (x XHost) Is(o Op) bool { + x0, ok := o.(XHost) + return ok && x == x0 +} + +func (x XHost) Path() string { + return string(x) +} + +func (x XHost) String() string { + return string("SI:localuser:" + x) +} diff --git a/system/xhost_test.go b/system/xhost_test.go new file mode 100644 index 00000000..4b364432 --- /dev/null +++ b/system/xhost_test.go @@ -0,0 +1,34 @@ +package system + +import ( + "testing" +) + +func TestChangeHosts(t *testing.T) { + testCases := []string{"chronos", "keyring", "cat", "kbd", "yonah"} + for _, tc := range testCases { + t.Run("append ChangeHosts operation for "+tc, func(t *testing.T) { + sys := New(150) + sys.ChangeHosts(tc) + (&tcOp{EX11, tc}).test(t, sys.ops, []Op{ + XHost(tc), + }, "ChangeHosts") + }) + } +} + +func TestXHost_String(t *testing.T) { + testCases := []struct { + username string + want string + }{ + {"chronos", "SI:localuser:chronos"}, + } + for _, tc := range testCases { + t.Run(tc.want, func(t *testing.T) { + if got := XHost(tc.username).String(); got != tc.want { + t.Errorf("String() = %v, want %v", got, tc.want) + } + }) + } +} |
