Compare commits

..

26 Commits

Author SHA1 Message Date
9d9a165379
release: 0.2.16
All checks were successful
Release / Create release (push) Successful in 35s
Test / Create distribution (push) Successful in 22s
Test / Run NixOS test (push) Successful in 2m41s
Mostly refactor and cleanup, but also contains major fix to process lifecycle management.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 23:39:16 +09:00
d0dff1cac9
wl: check against null character
All checks were successful
Test / Create distribution (push) Successful in 23s
Test / Run NixOS test (push) Successful in 54s
Wayland library takes null terminated strings.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 23:35:49 +09:00
3c80fd2b0f
app: defer system.I revert
All checks were successful
Test / Create distribution (push) Successful in 19s
Test / Run NixOS test (push) Successful in 49s
Just returning an error after a successful call of commit will leave garbage behind with no way for the caller to clean them. This change ensures revert is always called after successful commit with at least per-process state enabled.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 21:12:11 +09:00
ef81828e0c
app: remove share method
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 2m3s
This is yet another implementation detail from before system.I, getting rid of this vastly cuts down on redundant seal state.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 16:20:25 +09:00
2978a6f046
app: separate appSeal finalise method
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m27s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 12:33:51 +09:00
dfd9467523
app: merge seal with sys
All checks were successful
Test / Create distribution (push) Successful in 27s
Test / Run NixOS test (push) Successful in 3m20s
The existence of the appSealSys struct was an implementation detail obsolete since system.I was integrated in 084cd84f36.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 01:36:29 +09:00
53571f030e
app: embed appSeal in app struct
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m22s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 01:10:37 +09:00
aa164081e1
app/seal: improve documentation
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m22s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 01:04:14 +09:00
9a10eeab90
app/seal: embed enablements
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 3m28s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 00:41:51 +09:00
d1f83f40d6
helper/bwrap: rename Write to WriteFile
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m25s
In case this might want to be an io.Writer.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 00:34:19 +09:00
a748d40745
app: store values with string representation
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m26s
Improves code readability without changing memory layout.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-19 00:25:00 +09:00
648e1d641a
app: separate interface from implementation
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 3m31s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-18 23:07:28 +09:00
3c327084d3
fst: declare wrappers for sandbox config
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 3m30s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-18 23:04:13 +09:00
ffaa12b9d8
sys: wrap log methods
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 3m31s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-18 22:52:09 +09:00
bf95127332
fst: move App interface declaration
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 3m24s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-18 22:36:45 +09:00
e0f321b2c4
sys: rename from linux
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 3m28s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-18 18:47:48 +09:00
2c9c7fee5b
linux: wrap fsu lookup error
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Run NixOS test (push) Successful in 5m58s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-18 17:39:53 +09:00
d0400f3c81
fmsg: PrintBaseError skip empty message
All checks were successful
Test / Create distribution (push) Successful in 24s
Test / Run NixOS test (push) Successful in 3m22s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-18 17:01:26 +09:00
e9b0f9faef
fmsg: export logBaseError function
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m16s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-18 13:02:51 +09:00
e85be67fd9
acl: implement Update in C
All checks were successful
Test / Create distribution (push) Successful in 18s
Test / Run NixOS test (push) Successful in 46s
The original implementation was effectively just writing C in Go.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-17 21:39:14 +09:00
7e69893264
acl: rename UpdatePerms to Update
All checks were successful
Test / Create distribution (push) Successful in 24s
Test / Run NixOS test (push) Successful in 3m21s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-17 20:33:18 +09:00
38a3e6af03
system: make xcb internal
All checks were successful
Test / Create distribution (push) Successful in 27s
Test / Run NixOS test (push) Successful in 3m29s
This package is hauntingly ugly. Move this to internal until it is removed or replaced.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-17 19:07:53 +09:00
90cb01b274
system: move out of internal
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m17s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-17 19:00:43 +09:00
b1e1d5627e
system: wrap console output functions
All checks were successful
Test / Create distribution (push) Successful in 24s
Test / Run NixOS test (push) Successful in 3m13s
This eliminates all fmsg imports from internal/system.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-17 18:17:55 +09:00
3ae2ab652e
system/wayland: sync file at caller specified address
All checks were successful
Test / Create distribution (push) Successful in 24s
Test / Run NixOS test (push) Successful in 3m14s
Storing this in sys is incredibly ugly: sys should be stateless and Ops must keep track of their state.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-17 13:24:17 +09:00
db71fbe22b
system/tmpfiles: fail gracefully in API misuse
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m25s
Panicking here leaves garbage behind. Not ideal if this package is going to be exported.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-17 12:17:01 +09:00
53 changed files with 1269 additions and 1358 deletions

69
acl/acl-update.c Normal file
View File

@ -0,0 +1,69 @@
#include "acl-update.h"
#include <stdlib.h>
#include <stdbool.h>
#include <sys/acl.h>
#include <acl/libacl.h>
int f_acl_update_file_by_uid(const char *path_p, uid_t uid, acl_perm_t *perms, size_t plen) {
int ret = -1;
bool v;
int i;
acl_t acl;
acl_entry_t entry;
acl_tag_t tag_type;
void *qualifier_p;
acl_permset_t permset;
acl = acl_get_file(path_p, ACL_TYPE_ACCESS);
if (acl == NULL)
goto out;
// prune entries by uid
for (i = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry); i == 1; i = acl_get_entry(acl, ACL_NEXT_ENTRY, &entry)) {
if (acl_get_tag_type(entry, &tag_type) != 0)
return -1;
if (tag_type != ACL_USER)
continue;
qualifier_p = acl_get_qualifier(entry);
if (qualifier_p == NULL)
return -1;
v = *(uid_t *)qualifier_p == uid;
acl_free(qualifier_p);
if (!v)
continue;
acl_delete_entry(acl, entry);
}
if (plen == 0)
goto set;
if (acl_create_entry(&acl, &entry) != 0)
goto out;
if (acl_get_permset(entry, &permset) != 0)
goto out;
for (i = 0; i < plen; i++) {
if (acl_add_perm(permset, perms[i]) != 0)
goto out;
}
if (acl_set_tag_type(entry, ACL_USER) != 0)
goto out;
if (acl_set_qualifier(entry, (void *)&uid) != 0)
goto out;
set:
if (acl_calc_mask(&acl) != 0)
goto out;
if (acl_valid(acl) != 0)
goto out;
if (acl_set_file(path_p, ACL_TYPE_ACCESS, acl) == 0)
ret = 0;
out:
free((void *)path_p);
if (acl != NULL)
acl_free((void *)acl);
return ret;
}

3
acl/acl-update.h Normal file
View File

@ -0,0 +1,3 @@
#include <sys/acl.h>
int f_acl_update_file_by_uid(const char *path_p, uid_t uid, acl_perm_t *perms, size_t plen);

View File

@ -1,19 +1,36 @@
// Package acl implements simple ACL manipulation via libacl. // Package acl implements simple ACL manipulation via libacl.
package acl package acl
type Perms []Perm /*
#cgo linux pkg-config: --static libacl
func (ps Perms) String() string { #include "acl-update.h"
var s = []byte("---") */
for _, p := range ps { import "C"
switch p {
case Read: type Perm C.acl_perm_t
s[0] = 'r'
case Write: const (
s[1] = 'w' Read Perm = C.ACL_READ
case Execute: Write Perm = C.ACL_WRITE
s[2] = 'x' Execute Perm = C.ACL_EXECUTE
} )
// Update replaces ACL_USER entry with qualifier uid.
func Update(name string, uid int, perms ...Perm) error {
var p *Perm
if len(perms) > 0 {
p = &perms[0]
} }
return string(s)
r, err := C.f_acl_update_file_by_uid(
C.CString(name),
C.uid_t(uid),
(*C.acl_perm_t)(p),
C.size_t(len(perms)),
)
if r == 0 {
return nil
}
return err
} }

View File

@ -47,7 +47,7 @@ func TestUpdatePerm(t *testing.T) {
}) })
t.Run("default clear mask", func(t *testing.T) { t.Run("default clear mask", func(t *testing.T) {
if err := acl.UpdatePerm(testFilePath, uid); err != nil { if err := acl.Update(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err) t.Fatalf("UpdatePerm: error = %v", err)
} }
if cur = getfacl(t, testFilePath); len(cur) != 4 { if cur = getfacl(t, testFilePath); len(cur) != 4 {
@ -56,7 +56,7 @@ func TestUpdatePerm(t *testing.T) {
}) })
t.Run("default clear consistency", func(t *testing.T) { t.Run("default clear consistency", func(t *testing.T) {
if err := acl.UpdatePerm(testFilePath, uid); err != nil { if err := acl.Update(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err) t.Fatalf("UpdatePerm: error = %v", err)
} }
if val := getfacl(t, testFilePath); !reflect.DeepEqual(val, cur) { if val := getfacl(t, testFilePath); !reflect.DeepEqual(val, cur) {
@ -76,7 +76,7 @@ func TestUpdatePerm(t *testing.T) {
func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val fAclPerm, perms ...acl.Perm) { func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val fAclPerm, perms ...acl.Perm) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Cleanup(func() { t.Cleanup(func() {
if err := acl.UpdatePerm(testFilePath, uid); err != nil { if err := acl.Update(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err) t.Fatalf("UpdatePerm: error = %v", err)
} }
if v := getfacl(t, testFilePath); !reflect.DeepEqual(v, cur) { if v := getfacl(t, testFilePath); !reflect.DeepEqual(v, cur) {
@ -84,7 +84,7 @@ func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val
} }
}) })
if err := acl.UpdatePerm(testFilePath, uid, perms...); err != nil { if err := acl.Update(testFilePath, uid, perms...); err != nil {
t.Fatalf("UpdatePerm: error = %v", err) t.Fatalf("UpdatePerm: error = %v", err)
} }
r := respByCred(getfacl(t, testFilePath), fAclTypeUser, cred) r := respByCred(getfacl(t, testFilePath), fAclTypeUser, cred)

196
acl/c.go
View File

@ -1,196 +0,0 @@
package acl
import "C"
import (
"errors"
"runtime"
"syscall"
"unsafe"
)
/*
#cgo linux pkg-config: --static libacl
#include <stdlib.h>
#include <sys/acl.h>
#include <acl/libacl.h>
static acl_t _go_acl_get_file(const char *path_p, acl_type_t type) {
acl_t acl = acl_get_file(path_p, type);
free((void *)path_p);
return acl;
}
static int _go_acl_set_file(const char *path_p, acl_type_t type, acl_t acl) {
if (acl_valid(acl) != 0) {
return -1;
}
int ret = acl_set_file(path_p, type, acl);
free((void *)path_p);
return ret;
}
*/
import "C"
func getFile(name string, t C.acl_type_t) (*ACL, error) {
a, err := C._go_acl_get_file(C.CString(name), t)
if errors.Is(err, syscall.ENODATA) {
err = nil
}
return newACL(a), err
}
func (acl *ACL) setFile(name string, t C.acl_type_t) error {
_, err := C._go_acl_set_file(C.CString(name), t, acl.acl)
return err
}
func newACL(a C.acl_t) *ACL {
acl := &ACL{a}
runtime.SetFinalizer(acl, (*ACL).free)
return acl
}
type ACL struct {
acl C.acl_t
}
func (acl *ACL) free() {
C.acl_free(unsafe.Pointer(acl.acl))
// no need for a finalizer anymore
runtime.SetFinalizer(acl, nil)
}
const (
Read = C.ACL_READ
Write = C.ACL_WRITE
Execute = C.ACL_EXECUTE
TypeDefault = C.ACL_TYPE_DEFAULT
TypeAccess = C.ACL_TYPE_ACCESS
UndefinedTag = C.ACL_UNDEFINED_TAG
UserObj = C.ACL_USER_OBJ
User = C.ACL_USER
GroupObj = C.ACL_GROUP_OBJ
Group = C.ACL_GROUP
Mask = C.ACL_MASK
Other = C.ACL_OTHER
)
type (
Perm C.acl_perm_t
)
func (acl *ACL) removeEntry(tt C.acl_tag_t, tq int) error {
var e C.acl_entry_t
// get first entry
if r, err := C.acl_get_entry(acl.acl, C.ACL_FIRST_ENTRY, &e); err != nil {
return err
} else if r == 0 {
// return on acl with no entries
return nil
}
for {
if r, err := C.acl_get_entry(acl.acl, C.ACL_NEXT_ENTRY, &e); err != nil {
return err
} else if r == 0 {
// return on drained acl
return nil
}
var (
q int
t C.acl_tag_t
)
// get current entry tag type
if _, err := C.acl_get_tag_type(e, &t); err != nil {
return err
}
// get current entry qualifier
if rq, err := C.acl_get_qualifier(e); err != nil {
// neither ACL_USER nor ACL_GROUP
if errors.Is(err, syscall.EINVAL) {
continue
}
return err
} else {
q = *(*int)(rq)
C.acl_free(rq)
}
// delete on match
if t == tt && q == tq {
_, err := C.acl_delete_entry(acl.acl, e)
return err
}
}
}
func UpdatePerm(name string, uid int, perms ...Perm) error {
// read acl from file
a, err := getFile(name, TypeAccess)
if err != nil {
return err
}
// free acl on return if get is successful
defer a.free()
// remove existing entry
if err = a.removeEntry(User, uid); err != nil {
return err
}
// create new entry if perms are passed
if len(perms) > 0 {
// create new acl entry
var e C.acl_entry_t
if _, err = C.acl_create_entry(&a.acl, &e); err != nil {
return err
}
// get perm set of new entry
var p C.acl_permset_t
if _, err = C.acl_get_permset(e, &p); err != nil {
return err
}
// add target perms
for _, perm := range perms {
if _, err = C.acl_add_perm(p, C.acl_perm_t(perm)); err != nil {
return err
}
}
// set perm set to new entry
if _, err = C.acl_set_permset(e, p); err != nil {
return err
}
// set user tag to new entry
if _, err = C.acl_set_tag_type(e, User); err != nil {
return err
}
// set qualifier (uid) to new entry
if _, err = C.acl_set_qualifier(e, unsafe.Pointer(&uid)); err != nil {
return err
}
}
// calculate mask after update
if _, err = C.acl_calc_mask(&a.acl); err != nil {
return err
}
// write acl to file
return a.setFile(name, TypeAccess)
}

18
acl/perms.go Normal file
View File

@ -0,0 +1,18 @@
package acl
type Perms []Perm
func (ps Perms) String() string {
var s = []byte("---")
for _, p := range ps {
switch p {
case Read:
s[0] = 'r'
case Write:
s[1] = 'w'
case Execute:
s[2] = 'x'
}
}
return string(s)
}

View File

@ -6,7 +6,7 @@ import (
"os" "os"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/system"
) )
type bundleInfo struct { type bundleInfo struct {

View File

@ -44,13 +44,3 @@ func logWaitError(err error) {
} }
} }
} }
func logBaseError(err error, message string) {
var e *fmsg.BaseError
if fmsg.AsBaseError(err, &e) {
log.Print(e.Message())
} else {
log.Println(message, err)
}
}

41
fst/app.go Normal file
View File

@ -0,0 +1,41 @@
package fst
import (
"context"
"time"
)
type App interface {
// ID returns a copy of App's unique ID.
ID() ID
// Run sets up the system and runs the App.
Run(ctx context.Context, rs *RunState) error
Seal(config *Config) error
String() string
}
// RunState stores the outcome of a call to [App.Run].
type RunState struct {
// Time is the exact point in time where the process was created.
// Location must be set to UTC.
//
// Time is nil if no process was ever created.
Time *time.Time
// ExitCode is the value returned by shim.
ExitCode int
// RevertErr is stored by the deferred revert call.
RevertErr error
// WaitErr is error returned by the underlying wait syscall.
WaitErr error
}
// Paths contains environment-dependent paths used by fortify.
type Paths struct {
// path to shared directory (usually `/tmp/fortify.%d`)
SharePath string `json:"share_path"`
// XDG_RUNTIME_DIR value (usually `/run/user/%d`)
RuntimePath string `json:"runtime_path"`
// application runtime directory (usually `/run/user/%d/fortify`)
RunDirPath string `json:"run_dir_path"`
}

View File

@ -3,16 +3,18 @@ package fst
import ( import (
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/system"
) )
const Tmp = "/.fortify" const Tmp = "/.fortify"
// Config is used to seal an app // Config is used to seal an app
type Config struct { type Config struct {
// application ID // reverse-DNS style arbitrary identifier string from config;
// passed to wayland security-context-v1 as application ID
// and used as part of defaults in dbus session proxy
ID string `json:"id"` ID string `json:"id"`
// value passed through to the child process as its argv // final argv, passed to init
Command []string `json:"command"` Command []string `json:"command"`
Confinement ConfinementConfig `json:"confinement"` Confinement ConfinementConfig `json:"confinement"`
@ -32,7 +34,7 @@ type ConfinementConfig struct {
Outer string `json:"home"` Outer string `json:"home"`
// bwrap sandbox confinement configuration // bwrap sandbox confinement configuration
Sandbox *SandboxConfig `json:"sandbox"` Sandbox *SandboxConfig `json:"sandbox"`
// extra acl entries to append // extra acl ops, runs after everything else
ExtraPerms []*ExtraPermConfig `json:"extra_perms,omitempty"` ExtraPerms []*ExtraPermConfig `json:"extra_perms,omitempty"`
// reference to a system D-Bus proxy configuration, // reference to a system D-Bus proxy configuration,

View File

@ -8,8 +8,6 @@ import (
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/linux"
) )
// SandboxConfig describes resources made available to the sandbox. // SandboxConfig describes resources made available to the sandbox.
@ -28,7 +26,8 @@ type SandboxConfig struct {
NoNewSession bool `json:"no_new_session,omitempty"` NoNewSession bool `json:"no_new_session,omitempty"`
// map target user uid to privileged user uid in the user namespace // map target user uid to privileged user uid in the user namespace
MapRealUID bool `json:"map_real_uid"` MapRealUID bool `json:"map_real_uid"`
// direct access to wayland socket // direct access to wayland socket; when this gets set no attempt is made to attach security-context-v1
// and the bare socket is mounted to the sandbox
DirectWayland bool `json:"direct_wayland,omitempty"` DirectWayland bool `json:"direct_wayland,omitempty"`
// final environment variables // final environment variables
@ -41,31 +40,47 @@ type SandboxConfig struct {
Etc string `json:"etc,omitempty"` Etc string `json:"etc,omitempty"`
// automatically set up /etc symlinks // automatically set up /etc symlinks
AutoEtc bool `json:"auto_etc"` AutoEtc bool `json:"auto_etc"`
// paths to override by mounting tmpfs over them // mount tmpfs over these paths,
// runs right before [ConfinementConfig.ExtraPerms]
Override []string `json:"override"` Override []string `json:"override"`
} }
// SandboxSys encapsulates system functions used during the creation of [bwrap.Config].
type SandboxSys interface {
Geteuid() int
Paths() Paths
ReadDir(name string) ([]fs.DirEntry, error)
EvalSymlinks(path string) (string, error)
Println(v ...any)
Printf(format string, v ...any)
}
// Bwrap returns the address of the corresponding bwrap.Config to s. // Bwrap returns the address of the corresponding bwrap.Config to s.
// Note that remaining tmpfs entries must be queued by the caller prior to launch. // Note that remaining tmpfs entries must be queued by the caller prior to launch.
func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) { func (s *SandboxConfig) Bwrap(sys SandboxSys, uid *int) (*bwrap.Config, error) {
if s == nil { if s == nil {
return nil, errors.New("nil sandbox config") return nil, errors.New("nil sandbox config")
} }
if s.Syscall == nil { if s.Syscall == nil {
fmsg.Verbose("syscall filter not configured, PROCEED WITH CAUTION") sys.Println("syscall filter not configured, PROCEED WITH CAUTION")
} }
var uid int
if !s.MapRealUID { if !s.MapRealUID {
uid = 65534 // mapped uid defaults to 65534 to work around file ownership checks due to a bwrap limitation
*uid = 65534
} else { } else {
uid = os.Geteuid() // some programs fail to connect to dbus session running as a different uid, so a separate workaround
// is introduced to map priv-side caller uid in namespace
*uid = sys.Geteuid()
} }
conf := (&bwrap.Config{ conf := (&bwrap.Config{
Net: s.Net, Net: s.Net,
UserNS: s.UserNS, UserNS: s.UserNS,
UID: uid,
GID: uid,
Hostname: s.Hostname, Hostname: s.Hostname,
Clearenv: true, Clearenv: true,
SetEnv: s.Env, SetEnv: s.Env,
@ -84,7 +99,6 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
// for saving such a miniscule amount of memory // for saving such a miniscule amount of memory
Chmod: make(bwrap.ChmodConfig), Chmod: make(bwrap.ChmodConfig),
}). }).
SetUID(uid).SetGID(uid).
Procfs("/proc"). Procfs("/proc").
Tmpfs(Tmp, 4*1024) Tmpfs(Tmp, 4*1024)
@ -104,7 +118,7 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
// retrieve paths and hide them if they're made available in the sandbox // retrieve paths and hide them if they're made available in the sandbox
var hidePaths []string var hidePaths []string
sc := os.Paths() sc := sys.Paths()
hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath) hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath)
_, systemBusAddr := dbus.Address() _, systemBusAddr := dbus.Address()
if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil { if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil {
@ -121,11 +135,11 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
// get parent dir of socket // get parent dir of socket
dir := path.Dir(pair[1]) dir := path.Dir(pair[1])
if dir == "." || dir == "/" { if dir == "." || dir == "/" {
fmsg.Verbosef("dbus socket %q is in an unusual location", pair[1]) sys.Printf("dbus socket %q is in an unusual location", pair[1])
} }
hidePaths = append(hidePaths, dir) hidePaths = append(hidePaths, dir)
} else { } else {
fmsg.Verbosef("dbus socket %q is not absolute", pair[1]) sys.Printf("dbus socket %q is not absolute", pair[1])
} }
} }
} }
@ -133,7 +147,7 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
} }
hidePathMatch := make([]bool, len(hidePaths)) hidePathMatch := make([]bool, len(hidePaths))
for i := range hidePaths { for i := range hidePaths {
if err := evalSymlinks(os, &hidePaths[i]); err != nil { if err := evalSymlinks(sys, &hidePaths[i]); err != nil {
return nil, err return nil, err
} }
} }
@ -155,7 +169,7 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
} }
srcH := c.Src srcH := c.Src
if err := evalSymlinks(os, &srcH); err != nil { if err := evalSymlinks(sys, &srcH); err != nil {
return nil, err return nil, err
} }
@ -169,7 +183,7 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
return nil, err return nil, err
} else if ok { } else if ok {
hidePathMatch[i] = true hidePathMatch[i] = true
fmsg.Verbosef("hiding paths from %q", c.Src) sys.Printf("hiding paths from %q", c.Src)
} }
} }
@ -195,7 +209,7 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
conf.Bind(etc, Tmp+"/etc") conf.Bind(etc, Tmp+"/etc")
// link host /etc contents to prevent passwd/group from being overwritten // link host /etc contents to prevent passwd/group from being overwritten
if d, err := os.ReadDir(etc); err != nil { if d, err := sys.ReadDir(etc); err != nil {
return nil, err return nil, err
} else { } else {
for _, ent := range d { for _, ent := range d {
@ -216,12 +230,12 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
return conf, nil return conf, nil
} }
func evalSymlinks(os linux.System, v *string) error { func evalSymlinks(sys SandboxSys, v *string) error {
if p, err := os.EvalSymlinks(*v); err != nil { if p, err := sys.EvalSymlinks(*v); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return err return err
} }
fmsg.Verbosef("path %q does not yet exist", *v) sys.Printf("path %q does not yet exist", *v)
} else { } else {
*v = p *v = p
} }

View File

@ -63,10 +63,10 @@ func (c *Config) Bind(src, dest string, opts ...bool) *Config {
} }
} }
// Write copy from FD to destination DEST // WriteFile copy from FD to destination DEST
// (--file FD DEST) // (--file FD DEST)
func (c *Config) Write(dest string, payload []byte) *Config { func (c *Config) WriteFile(name string, data []byte) *Config {
c.Filesystem = append(c.Filesystem, &DataConfig{Dest: dest, Data: payload, Type: DataWrite}) c.Filesystem = append(c.Filesystem, &DataConfig{Dest: name, Data: data, Type: DataWrite})
return c return c
} }

View File

@ -113,7 +113,7 @@ func TestConfig_Args(t *testing.T) {
}, },
{ {
"copy", (new(bwrap.Config)). "copy", (new(bwrap.Config)).
Write("/.fortify/version", make([]byte, 8)). WriteFile("/.fortify/version", make([]byte, 8)).
CopyBind("/etc/group", make([]byte, 8)). CopyBind("/etc/group", make([]byte, 8)).
CopyBind("/etc/passwd", make([]byte, 8), true), CopyBind("/etc/passwd", make([]byte, 8), true),
[]string{ []string{

View File

@ -1,72 +1,69 @@
package app package app
import ( import (
"context" "fmt"
"sync" "sync"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/app/shim" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/sys"
) )
type App interface { func New(os sys.State) (fst.App, error) {
// ID returns a copy of App's unique ID. a := new(app)
ID() fst.ID a.sys = os
// Run sets up the system and runs the App.
Run(ctx context.Context, rs *RunState) error
Seal(config *fst.Config) error id := new(fst.ID)
String() string err := fst.NewAppID(id)
} a.id = newID(id)
type RunState struct { return a, err
// Start is true if fsu is successfully started.
Start bool
// ExitCode is the value returned by shim.
ExitCode int
// WaitErr is error returned by the underlying wait syscall.
WaitErr error
} }
type app struct { type app struct {
// application unique identifier id *stringPair[fst.ID]
id *fst.ID sys sys.State
// operating system interface
os linux.System
// shim process manager
shim *shim.Shim
// child process related information
seal *appSeal
lock sync.RWMutex *appSeal
mu sync.RWMutex
} }
func (a *app) ID() fst.ID { func (a *app) ID() fst.ID { return a.id.unwrap() }
return *a.id
}
func (a *app) String() string { func (a *app) String() string {
if a == nil { if a == nil {
return "(invalid fortified app)" return "(invalid app)"
} }
a.lock.RLock() a.mu.RLock()
defer a.lock.RUnlock() defer a.mu.RUnlock()
if a.shim != nil { if a.appSeal != nil {
return a.shim.String() if a.appSeal.user.uid == nil {
return fmt.Sprintf("(sealed app %s with invalid uid)", a.id)
}
return fmt.Sprintf("(sealed app %s as uid %s)", a.id, a.appSeal.user.uid)
} }
if a.seal != nil { return fmt.Sprintf("(unsealed app %s)", a.id)
return "(sealed fortified app as uid " + a.seal.sys.user.us + ")"
}
return "(unsealed fortified app)"
} }
func New(os linux.System) (App, error) { func (a *app) Seal(config *fst.Config) (err error) {
a := new(app) a.mu.Lock()
a.id = new(fst.ID) defer a.mu.Unlock()
a.os = os
return a, fst.NewAppID(a.id) if a.appSeal != nil {
panic("app sealed twice")
}
if config == nil {
return fmsg.WrapError(ErrConfig,
"attempted to seal app with nil config")
}
seal := new(appSeal)
err = seal.finalise(a.sys, config, a.id.String())
if err == nil {
a.appSeal = seal
}
return
} }

View File

@ -5,7 +5,7 @@ import (
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/system"
) )
var testCasesNixos = []sealTestCase{ var testCasesNixos = []sealTestCase{
@ -18,13 +18,13 @@ var testCasesNixos = []sealTestCase{
AppID: 1, Groups: []string{}, Username: "u0_a1", AppID: 1, Groups: []string{}, Username: "u0_a1",
Outer: "/var/lib/persist/module/fortify/0/1", Outer: "/var/lib/persist/module/fortify/0/1",
Sandbox: &fst.SandboxConfig{ Sandbox: &fst.SandboxConfig{
UserNS: true, Net: true, MapRealUID: true, DirectWayland: true, Env: nil, UserNS: true, Net: true, MapRealUID: true, DirectWayland: true, Env: nil, AutoEtc: true,
Filesystem: []*fst.FilesystemConfig{ Filesystem: []*fst.FilesystemConfig{
{Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true}, {Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true},
{Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true}, {Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true},
{Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"}, {Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"},
{Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true}, {Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true},
}, AutoEtc: true, },
Override: []string{"/var/run/nscd"}, Override: []string{"/var/run/nscd"},
}, },
SystemBus: &dbus.Config{ SystemBus: &dbus.Config{
@ -56,12 +56,12 @@ var testCasesNixos = []sealTestCase{
}, },
system.New(1000001). system.New(1000001).
Ensure("/tmp/fortify.1971", 0711). Ensure("/tmp/fortify.1971", 0711).
Ephemeral(system.Process, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/1", acl.Read, acl.Write, acl.Execute).
Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute). Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute).
Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset
Ephemeral(system.Process, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute). Ephemeral(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/1", acl.Read, acl.Write, acl.Execute).
UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute). UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute).
Link("/run/user/1971/pulse/native", "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/pulse"). Link("/run/user/1971/pulse/native", "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/pulse").
CopyFile(nil, "/home/ophestra/xdg/config/pulse/cookie", 256, 256). CopyFile(nil, "/home/ophestra/xdg/config/pulse/cookie", 256, 256).
@ -205,9 +205,9 @@ var testCasesNixos = []sealTestCase{
Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile"). Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv"). Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc"). Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/1", "/tmp", false, true).
Tmpfs("/run/user", 1048576). Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/1971", 8388608). Tmpfs("/run/user/1971", 8388608).
Bind("/tmp/fortify.1971/tmpdir/1", "/tmp", false, true).
Bind("/var/lib/persist/module/fortify/0/1", "/var/lib/persist/module/fortify/0/1", false, true). Bind("/var/lib/persist/module/fortify/0/1", "/var/lib/persist/module/fortify/0/1", false, true).
CopyBind("/etc/passwd", []byte("u0_a1:x:1971:1971:Fortify:/var/lib/persist/module/fortify/0/1:/run/current-system/sw/bin/zsh\n")). CopyBind("/etc/passwd", []byte("u0_a1:x:1971:1971:Fortify:/var/lib/persist/module/fortify/0/1:/run/current-system/sw/bin/zsh\n")).
CopyBind("/etc/group", []byte("fortify:x:1971:\n")). CopyBind("/etc/group", []byte("fortify:x:1971:\n")).

View File

@ -1,11 +1,13 @@
package app_test package app_test
import ( import (
"os"
"git.gensokyo.uk/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/system"
) )
var testCasesPd = []sealTestCase{ var testCasesPd = []sealTestCase{
@ -27,12 +29,12 @@ var testCasesPd = []sealTestCase{
}, },
system.New(1000000). system.New(1000000).
Ensure("/tmp/fortify.1971", 0711). Ensure("/tmp/fortify.1971", 0711).
Ephemeral(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac", 0711).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute).
Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute). Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute).
Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset
Ephemeral(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute), Ephemeral(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute),
(&bwrap.Config{ (&bwrap.Config{
Net: true, Net: true,
UserNS: true, UserNS: true,
@ -148,9 +150,9 @@ var testCasesPd = []sealTestCase{
Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile"). Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv"). Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc"). Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", false, true).
Tmpfs("/run/user", 1048576). Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/65534", 8388608). Tmpfs("/run/user/65534", 8388608).
Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", false, true).
Bind("/home/chronos", "/home/chronos", false, true). Bind("/home/chronos", "/home/chronos", false, true).
CopyBind("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")). CopyBind("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
CopyBind("/etc/group", []byte("fortify:x:65534:\n")). CopyBind("/etc/group", []byte("fortify:x:65534:\n")).
@ -210,14 +212,14 @@ var testCasesPd = []sealTestCase{
}, },
system.New(1000009). system.New(1000009).
Ensure("/tmp/fortify.1971", 0711). Ensure("/tmp/fortify.1971", 0711).
Ephemeral(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0711).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/9", acl.Read, acl.Write, acl.Execute).
Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute). Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute).
Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset
Ephemeral(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", acl.Execute). Ephemeral(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/9", acl.Read, acl.Write, acl.Execute).
Ensure("/tmp/fortify.1971/wayland", 0711). Ensure("/tmp/fortify.1971/wayland", 0711).
Wayland("/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c"). Wayland(new(*os.File), "/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c").
Link("/run/user/1971/pulse/native", "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse"). Link("/run/user/1971/pulse/native", "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse").
CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 256, 256). CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 256, 256).
MustProxyDBus("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{ MustProxyDBus("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{
@ -374,9 +376,9 @@ var testCasesPd = []sealTestCase{
Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile"). Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv"). Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc"). Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", false, true).
Tmpfs("/run/user", 1048576). Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/65534", 8388608). Tmpfs("/run/user/65534", 8388608).
Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", false, true).
Bind("/home/chronos", "/home/chronos", false, true). Bind("/home/chronos", "/home/chronos", false, true).
CopyBind("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")). CopyBind("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
CopyBind("/etc/group", []byte("fortify:x:65534:\n")). CopyBind("/etc/group", []byte("fortify:x:65534:\n")).

View File

@ -3,10 +3,11 @@ package app_test
import ( import (
"fmt" "fmt"
"io/fs" "io/fs"
"log"
"os/user" "os/user"
"strconv" "strconv"
"git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/fst"
) )
// fs methods are not implemented using a real FS // fs methods are not implemented using a real FS
@ -23,6 +24,9 @@ func (s *stubNixOS) Exit(code int) { panic("called ex
func (s *stubNixOS) EvalSymlinks(path string) (string, error) { return path, nil } func (s *stubNixOS) EvalSymlinks(path string) (string, error) { return path, nil }
func (s *stubNixOS) Uid(aid int) (int, error) { return 1000000 + 0*10000 + aid, nil } func (s *stubNixOS) Uid(aid int) (int, error) { return 1000000 + 0*10000 + aid, nil }
func (s *stubNixOS) Println(v ...any) { log.Println(v...) }
func (s *stubNixOS) Printf(format string, v ...any) { log.Printf(format, v...) }
func (s *stubNixOS) LookupEnv(key string) (string, bool) { func (s *stubNixOS) LookupEnv(key string) (string, bool) {
switch key { switch key {
case "SHELL": case "SHELL":
@ -122,8 +126,8 @@ func (s *stubNixOS) Open(name string) (fs.File, error) {
} }
} }
func (s *stubNixOS) Paths() linux.Paths { func (s *stubNixOS) Paths() fst.Paths {
return linux.Paths{ return fst.Paths{
SharePath: "/tmp/fortify.1971", SharePath: "/tmp/fortify.1971",
RuntimePath: "/run/user/1971", RuntimePath: "/run/user/1971",
RunDirPath: "/run/user/1971/fortify", RunDirPath: "/run/user/1971/fortify",

View File

@ -10,13 +10,13 @@ import (
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/system"
) )
type sealTestCase struct { type sealTestCase struct {
name string name string
os linux.System os sys.State
config *fst.Config config *fst.Config
id fst.ID id fst.ID
wantSys *system.I wantSys *system.I

View File

@ -3,18 +3,18 @@ package app
import ( import (
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/system"
) )
func NewWithID(id fst.ID, os linux.System) App { func NewWithID(id fst.ID, os sys.State) fst.App {
a := new(app) a := new(app)
a.id = &id a.id = newID(&id)
a.os = os a.sys = os
return a return a
} }
func AppSystemBwrap(a App) (*system.I, *bwrap.Config) { func AppSystemBwrap(a fst.App) (*system.I, *bwrap.Config) {
v := a.(*app) v := a.(*app)
return v.seal.sys.I, v.seal.sys.bwrap return v.appSeal.sys, v.appSeal.container
} }

276
internal/app/process.go Normal file
View File

@ -0,0 +1,276 @@
package app
import (
"context"
"errors"
"fmt"
"log"
"os/exec"
"path/filepath"
"strings"
"time"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/internal/app/shim"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/system"
)
const shimSetupTimeout = 5 * time.Second
func (a *app) Run(ctx context.Context, rs *fst.RunState) error {
a.mu.Lock()
defer a.mu.Unlock()
if rs == nil {
panic("attempted to pass nil state to run")
}
/*
resolve exec paths
*/
shimExec := [2]string{helper.BubblewrapName}
if len(a.appSeal.command) > 0 {
shimExec[1] = a.appSeal.command[0]
}
for i, n := range shimExec {
if len(n) == 0 {
continue
}
if filepath.Base(n) == n {
if s, err := exec.LookPath(n); err == nil {
shimExec[i] = s
} else {
return fmsg.WrapError(err,
fmt.Sprintf("executable file %q not found in $PATH", n))
}
}
}
/*
prepare/revert os state
*/
if err := a.appSeal.sys.Commit(ctx); err != nil {
return err
}
store := state.NewMulti(a.sys.Paths().RunDirPath)
deferredStoreFunc := func(c state.Cursor) error { return nil }
defer func() {
var revertErr error
storeErr := new(StateStoreError)
storeErr.Inner, storeErr.DoErr = store.Do(a.appSeal.user.aid.unwrap(), func(c state.Cursor) {
revertErr = func() error {
storeErr.InnerErr = deferredStoreFunc(c)
/*
revert app setup transaction
*/
rt, ec := new(system.Enablements), new(system.Criteria)
ec.Enablements = new(system.Enablements)
ec.Set(system.Process)
if states, err := c.Load(); err != nil {
// revert per-process state here to limit damage
return errors.Join(err, a.appSeal.sys.Revert(ec))
} else {
if l := len(states); l == 0 {
fmsg.Verbose("no other launchers active, will clean up globals")
ec.Set(system.User)
} else {
fmsg.Verbosef("found %d active launchers, cleaning up without globals", l)
}
// accumulate enablements of remaining launchers
for i, s := range states {
if s.Config != nil {
*rt |= s.Config.Confinement.Enablements
} else {
log.Printf("state entry %d does not contain config", i)
}
}
}
// invert accumulated enablements for cleanup
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if !rt.Has(i) {
ec.Set(i)
}
}
if fmsg.Load() {
labels := make([]string, 0, system.ELen+1)
for i := system.Enablement(0); i < system.Enablement(system.ELen+2); i++ {
if ec.Has(i) {
labels = append(labels, system.TypeString(i))
}
}
if len(labels) > 0 {
fmsg.Verbose("reverting operations type", strings.Join(labels, ", "))
}
}
err := a.appSeal.sys.Revert(ec)
if err != nil {
err = err.(RevertCompoundError)
}
return err
}()
})
storeErr.Err = errors.Join(revertErr, store.Close())
rs.RevertErr = storeErr.equiv("error returned during cleanup:")
}()
/*
shim process lifecycle
*/
waitErr := make(chan error, 1)
cmd := new(shim.Shim)
if startTime, err := cmd.Start(
a.appSeal.user.aid.String(),
a.appSeal.user.supp,
a.appSeal.bwrapSync,
); err != nil {
return err
} else {
// whether/when the fsu process was created
rs.Time = startTime
}
shimSetupCtx, shimSetupCancel := context.WithDeadline(ctx, time.Now().Add(shimSetupTimeout))
defer shimSetupCancel()
go func() {
waitErr <- cmd.Unwrap().Wait()
// cancel shim setup in case shim died before receiving payload
shimSetupCancel()
}()
if err := cmd.Serve(shimSetupCtx, &shim.Payload{
Argv: a.appSeal.command,
Exec: shimExec,
Bwrap: a.appSeal.container,
Home: a.appSeal.user.data,
Verbose: fmsg.Load(),
}); err != nil {
return err
}
// shim accepted setup payload, create process state
sd := state.State{
ID: a.id.unwrap(),
PID: cmd.Unwrap().Process.Pid,
Time: *rs.Time,
}
var earlyStoreErr = new(StateStoreError) // returned after blocking on waitErr
earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(a.appSeal.user.aid.unwrap(), func(c state.Cursor) { earlyStoreErr.InnerErr = c.Save(&sd, a.appSeal.ct) })
// destroy defunct state entry
deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(a.id.unwrap()) }
select {
case err := <-waitErr: // block until fsu/shim returns
if err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
// should be unreachable
rs.WaitErr = err
}
// store non-zero return code
rs.ExitCode = exitError.ExitCode()
} else {
rs.ExitCode = cmd.Unwrap().ProcessState.ExitCode()
}
if fmsg.Load() {
fmsg.Verbosef("process %d exited with exit code %d", cmd.Unwrap().Process.Pid, rs.ExitCode)
}
// this is reached when a fault makes an already running shim impossible to continue execution
// however a kill signal could not be delivered (should actually always happen like that since fsu)
// the effects of this is similar to the alternative exit path and ensures shim death
case err := <-cmd.WaitFallback():
rs.ExitCode = 255
log.Printf("cannot terminate shim on faulted setup: %v", err)
// alternative exit path relying on shim behaviour on monitor process exit
case <-ctx.Done():
fmsg.Verbose("alternative exit path selected")
}
fmsg.Resume()
if a.appSeal.dbusMsg != nil {
// dump dbus message buffer
a.appSeal.dbusMsg()
}
return earlyStoreErr.equiv("cannot save process state:")
}
// StateStoreError is returned for a failed state save
type StateStoreError struct {
// whether inner function was called
Inner bool
// returned by the Do method of [state.Store]
DoErr error
// returned by the Save/Destroy method of [state.Cursor]
InnerErr error
// stores an arbitrary error
Err error
}
// save saves exactly one arbitrary error in [StateStoreError].
func (e *StateStoreError) save(err error) {
if err == nil || e.Err != nil {
panic("invalid call to save")
}
e.Err = err
}
func (e *StateStoreError) equiv(a ...any) error {
if e.Inner && e.DoErr == nil && e.InnerErr == nil && e.Err == nil {
return nil
} else {
return fmsg.WrapErrorSuffix(e, a...)
}
}
func (e *StateStoreError) Error() string {
if e.Inner && e.InnerErr != nil {
return e.InnerErr.Error()
}
if e.DoErr != nil {
return e.DoErr.Error()
}
if e.Err != nil {
return e.Err.Error()
}
// equiv nullifies e for values where this is reached
panic("unreachable")
}
func (e *StateStoreError) Unwrap() (errs []error) {
errs = make([]error, 0, 3)
if e.DoErr != nil {
errs = append(errs, e.DoErr)
}
if e.InnerErr != nil {
errs = append(errs, e.InnerErr)
}
if e.Err != nil {
errs = append(errs, e.Err)
}
return
}
// A RevertCompoundError encapsulates errors returned by
// the Revert method of [system.I].
type RevertCompoundError interface {
Error() string
Unwrap() []error
}

View File

@ -7,9 +7,10 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"os"
"path" "path"
"regexp" "regexp"
"strconv" "strings"
"git.gensokyo.uk/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
@ -17,9 +18,28 @@ import (
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/system"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/wl"
)
const (
home = "HOME"
shell = "SHELL"
xdgConfigHome = "XDG_CONFIG_HOME"
xdgRuntimeDir = "XDG_RUNTIME_DIR"
xdgSessionClass = "XDG_SESSION_CLASS"
xdgSessionType = "XDG_SESSION_TYPE"
term = "TERM"
display = "DISPLAY"
pulseServer = "PULSE_SERVER"
pulseCookie = "PULSE_COOKIE"
dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS"
dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS"
) )
var ( var (
@ -27,173 +47,120 @@ var (
ErrUser = errors.New("invalid aid") ErrUser = errors.New("invalid aid")
ErrHome = errors.New("invalid home directory") ErrHome = errors.New("invalid home directory")
ErrName = errors.New("invalid username") ErrName = errors.New("invalid username")
ErrXDisplay = errors.New(display + " unset")
ErrPulseCookie = errors.New("pulse cookie not present")
ErrPulseSocket = errors.New("pulse socket not present")
ErrPulseMode = errors.New("unexpected pulse socket mode")
) )
var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$") var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$")
// appSeal seals the application with child-related information // appSeal stores copies of various parts of [fst.Config]
type appSeal struct { type appSeal struct {
// app unique ID string representation // passed through from [fst.Config]
id string command []string
// initial [fst.Config] gob stream for state data;
// this is prepared ahead of time as config is mutated during seal creation
ct io.WriterTo
// dump dbus proxy message buffer // dump dbus proxy message buffer
dbusMsg func() dbusMsg func()
// freedesktop application ID user appUser
fid string sys *system.I
// argv to start process with in the final confined environment container *bwrap.Config
command []string bwrapSync *os.File
// persistent process state store
store state.Store
// process-specific share directory path
share string
// process-specific share directory path local to XDG_RUNTIME_DIR
shareLocal string
// pass-through enablement tracking from config
et system.Enablements
// initial config gob encoding buffer
ct io.WriterTo
// wayland socket direct access
directWayland bool
// extra UpdatePerm ops
extraPerms []*sealedExtraPerm
// prevents sharing from happening twice
shared bool
// seal system-level component
sys *appSealSys
linux.Paths
// protected by upstream mutex // protected by upstream mutex
} }
type sealedExtraPerm struct { // appUser stores post-fsu credentials and metadata
name string type appUser struct {
perms acl.Perms // application id
ensure bool aid *stringPair[int]
// target uid resolved by fid:aid
uid *stringPair[int]
// supplementary group ids
supp []string
// home directory host path
data string
// app user home directory
home string
// passwd database username
username string
} }
// Seal seals the app launch context func (seal *appSeal) finalise(sys sys.State, config *fst.Config, id string) error {
func (a *app) Seal(config *fst.Config) error { {
a.lock.Lock() // encode initial configuration for state tracking
defer a.lock.Unlock() ct := new(bytes.Buffer)
if err := gob.NewEncoder(ct).Encode(config); err != nil {
if a.seal != nil { return fmsg.WrapErrorSuffix(err,
panic("app sealed twice") "cannot encode initial config:")
}
seal.ct = ct
} }
if config == nil { // pass through command slice; this value is never touched in the main process
return fmsg.WrapError(ErrConfig,
"attempted to seal app with nil config")
}
// create seal
seal := new(appSeal)
// encode initial configuration for state tracking
ct := new(bytes.Buffer)
if err := gob.NewEncoder(ct).Encode(config); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot encode initial config:")
}
seal.ct = ct
// fetch system constants
seal.Paths = a.os.Paths()
// pass through config values
seal.id = a.id.String()
seal.fid = config.ID
seal.command = config.Command seal.command = config.Command
// create seal system component // allowed aid range 0 to 9999, this is checked again in fsu
seal.sys = new(appSealSys)
// mapped uid
if config.Confinement.Sandbox != nil && config.Confinement.Sandbox.MapRealUID {
seal.sys.mappedID = a.os.Geteuid()
} else {
seal.sys.mappedID = 65534
}
seal.sys.mappedIDString = strconv.Itoa(seal.sys.mappedID)
seal.sys.runtime = path.Join("/run/user", seal.sys.mappedIDString)
// validate uid and set user info
if config.Confinement.AppID < 0 || config.Confinement.AppID > 9999 { if config.Confinement.AppID < 0 || config.Confinement.AppID > 9999 {
return fmsg.WrapError(ErrUser, return fmsg.WrapError(ErrUser,
fmt.Sprintf("aid %d out of range", config.Confinement.AppID)) fmt.Sprintf("aid %d out of range", config.Confinement.AppID))
} }
seal.sys.user = appUser{
aid: config.Confinement.AppID, /*
as: strconv.Itoa(config.Confinement.AppID), Resolve post-fsu user state
*/
seal.user = appUser{
aid: newInt(config.Confinement.AppID),
data: config.Confinement.Outer, data: config.Confinement.Outer,
home: config.Confinement.Inner, home: config.Confinement.Inner,
username: config.Confinement.Username, username: config.Confinement.Username,
} }
if seal.sys.user.username == "" { if seal.user.username == "" {
seal.sys.user.username = "chronos" seal.user.username = "chronos"
} else if !posixUsername.MatchString(seal.sys.user.username) || } else if !posixUsername.MatchString(seal.user.username) ||
len(seal.sys.user.username) >= internal.Sysconf_SC_LOGIN_NAME_MAX() { len(seal.user.username) >= internal.Sysconf_SC_LOGIN_NAME_MAX() {
return fmsg.WrapError(ErrName, return fmsg.WrapError(ErrName,
fmt.Sprintf("invalid user name %q", seal.sys.user.username)) fmt.Sprintf("invalid user name %q", seal.user.username))
} }
if seal.sys.user.data == "" || !path.IsAbs(seal.sys.user.data) { if seal.user.data == "" || !path.IsAbs(seal.user.data) {
return fmsg.WrapError(ErrHome, return fmsg.WrapError(ErrHome,
fmt.Sprintf("invalid home directory %q", seal.sys.user.data)) fmt.Sprintf("invalid home directory %q", seal.user.data))
} }
if seal.sys.user.home == "" { if seal.user.home == "" {
seal.sys.user.home = seal.sys.user.data seal.user.home = seal.user.data
} }
if u, err := sys.Uid(seal.user.aid.unwrap()); err != nil {
// invoke fsu for full uid return err
if u, err := a.os.Uid(seal.sys.user.aid); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot obtain uid from fsu:")
} else { } else {
seal.sys.user.uid = u seal.user.uid = newInt(u)
seal.sys.user.us = strconv.Itoa(u)
} }
seal.user.supp = make([]string, len(config.Confinement.Groups))
// resolve supplementary group ids from names
seal.sys.user.supp = make([]string, len(config.Confinement.Groups))
for i, name := range config.Confinement.Groups { for i, name := range config.Confinement.Groups {
if g, err := a.os.LookupGroup(name); err != nil { if g, err := sys.LookupGroup(name); err != nil {
return fmsg.WrapError(err, return fmsg.WrapError(err,
fmt.Sprintf("unknown group %q", name)) fmt.Sprintf("unknown group %q", name))
} else { } else {
seal.sys.user.supp[i] = g.Gid seal.user.supp[i] = g.Gid
} }
} }
// build extra perms /*
seal.extraPerms = make([]*sealedExtraPerm, len(config.Confinement.ExtraPerms)) Resolve initial container state
for i, p := range config.Confinement.ExtraPerms { */
if p == nil {
continue
}
seal.extraPerms[i] = new(sealedExtraPerm) // permissive defaults
seal.extraPerms[i].name = p.Path
seal.extraPerms[i].perms = make(acl.Perms, 0, 3)
if p.Read {
seal.extraPerms[i].perms = append(seal.extraPerms[i].perms, acl.Read)
}
if p.Write {
seal.extraPerms[i].perms = append(seal.extraPerms[i].perms, acl.Write)
}
if p.Execute {
seal.extraPerms[i].perms = append(seal.extraPerms[i].perms, acl.Execute)
}
seal.extraPerms[i].ensure = p.Ensure
}
// map sandbox config to bwrap
if config.Confinement.Sandbox == nil { if config.Confinement.Sandbox == nil {
fmsg.Verbose("sandbox configuration not supplied, PROCEED WITH CAUTION") fmsg.Verbose("sandbox configuration not supplied, PROCEED WITH CAUTION")
// permissive defaults
conf := &fst.SandboxConfig{ conf := &fst.SandboxConfig{
UserNS: true, UserNS: true,
Net: true, Net: true,
@ -202,7 +169,7 @@ func (a *app) Seal(config *fst.Config) error {
AutoEtc: true, AutoEtc: true,
} }
// bind entries in / // bind entries in /
if d, err := a.os.ReadDir("/"); err != nil { if d, err := sys.ReadDir("/"); err != nil {
return err return err
} else { } else {
b := make([]*fst.FilesystemConfig, 0, len(d)) b := make([]*fst.FilesystemConfig, 0, len(d))
@ -224,7 +191,7 @@ func (a *app) Seal(config *fst.Config) error {
// hide nscd from sandbox if present // hide nscd from sandbox if present
nscd := "/var/run/nscd" nscd := "/var/run/nscd"
if _, err := a.os.Stat(nscd); !errors.Is(err, fs.ErrNotExist) { if _, err := sys.Stat(nscd); !errors.Is(err, fs.ErrNotExist) {
conf.Override = append(conf.Override, nscd) conf.Override = append(conf.Override, nscd)
} }
// bind GPU stuff // bind GPU stuff
@ -236,38 +203,325 @@ func (a *app) Seal(config *fst.Config) error {
config.Confinement.Sandbox = conf config.Confinement.Sandbox = conf
} }
seal.directWayland = config.Confinement.Sandbox.DirectWayland
if b, err := config.Confinement.Sandbox.Bwrap(a.os); err != nil { var mapuid *stringPair[int]
return err {
} else { var uid int
seal.sys.bwrap = b var err error
} seal.container, err = config.Confinement.Sandbox.Bwrap(sys, &uid)
seal.sys.override = config.Confinement.Sandbox.Override if err != nil {
if seal.sys.bwrap.SetEnv == nil { return err
seal.sys.bwrap.SetEnv = make(map[string]string) }
mapuid = newInt(uid)
if seal.container.SetEnv == nil {
seal.container.SetEnv = make(map[string]string)
}
} }
// open process state store /*
// the simple store only starts holding an open file after first action Initialise externals
// store activity begins after Start is called and must end before Wait */
seal.store = state.NewMulti(seal.RunDirPath)
// initialise system interface with full uid sc := sys.Paths()
seal.sys.I = system.New(seal.sys.user.uid) seal.sys = system.New(seal.user.uid.unwrap())
seal.sys.IsVerbose = fmsg.Load
seal.sys.Verbose = fmsg.Verbose
seal.sys.Verbosef = fmsg.Verbosef
seal.sys.WrapErr = fmsg.WrapError
// pass through enablements /*
seal.et = config.Confinement.Enablements Work directories
*/
// this method calls all share methods in sequence // base fortify share path
if err := seal.setupShares([2]*dbus.Config{config.Confinement.SessionBus, config.Confinement.SystemBus}, a.os); err != nil { seal.sys.Ensure(sc.SharePath, 0711)
return err
// outer paths used by the main process
seal.sys.Ensure(sc.RunDirPath, 0700)
seal.sys.UpdatePermType(system.User, sc.RunDirPath, acl.Execute)
seal.sys.Ensure(sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset
seal.sys.UpdatePermType(system.User, sc.RuntimePath, acl.Execute)
// outer process-specific share directory
sharePath := path.Join(sc.SharePath, id)
seal.sys.Ephemeral(system.Process, sharePath, 0711)
// similar to share but within XDG_RUNTIME_DIR
sharePathLocal := path.Join(sc.RunDirPath, id)
seal.sys.Ephemeral(system.Process, sharePathLocal, 0700)
seal.sys.UpdatePerm(sharePathLocal, acl.Execute)
// inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as post-fsu user
innerRuntimeDir := path.Join("/run/user", mapuid.String())
seal.container.Tmpfs("/run/user", 1*1024*1024)
seal.container.Tmpfs(innerRuntimeDir, 8*1024*1024)
seal.container.SetEnv[xdgRuntimeDir] = innerRuntimeDir
seal.container.SetEnv[xdgSessionClass] = "user"
seal.container.SetEnv[xdgSessionType] = "tty"
// outer path for inner /tmp
{
tmpdir := path.Join(sc.SharePath, "tmpdir")
seal.sys.Ensure(tmpdir, 0700)
seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute)
tmpdirProc := path.Join(tmpdir, seal.user.aid.String())
seal.sys.Ensure(tmpdirProc, 01700)
seal.sys.UpdatePermType(system.User, tmpdirProc, acl.Read, acl.Write, acl.Execute)
seal.container.Bind(tmpdirProc, "/tmp", false, true)
} }
// verbose log seal information /*
Passwd database
*/
// look up shell
sh := "/bin/sh"
if s, ok := sys.LookupEnv(shell); ok {
seal.container.SetEnv[shell] = s
sh = s
}
// bind home directory
homeDir := "/var/empty"
if seal.user.home != "" {
homeDir = seal.user.home
}
username := "chronos"
if seal.user.username != "" {
username = seal.user.username
}
seal.container.Bind(seal.user.data, homeDir, false, true)
seal.container.Chdir = homeDir
seal.container.SetEnv["HOME"] = homeDir
seal.container.SetEnv["USER"] = username
// generate /etc/passwd and /etc/group
seal.container.CopyBind("/etc/passwd",
[]byte(username+":x:"+mapuid.String()+":"+mapuid.String()+":Fortify:"+homeDir+":"+sh+"\n"))
seal.container.CopyBind("/etc/group",
[]byte("fortify:x:"+mapuid.String()+":\n"))
/*
Display servers
*/
// pass $TERM to launcher
if t, ok := sys.LookupEnv(term); ok {
seal.container.SetEnv[term] = t
}
// set up wayland
if config.Confinement.Enablements.Has(system.EWayland) {
// outer wayland socket (usually `/run/user/%d/wayland-%d`)
var socketPath string
if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok {
fmsg.Verbose(wl.WaylandDisplay + " is not set, assuming " + wl.FallbackName)
socketPath = path.Join(sc.RuntimePath, wl.FallbackName)
} else if !path.IsAbs(name) {
socketPath = path.Join(sc.RuntimePath, name)
} else {
socketPath = name
}
innerPath := path.Join(innerRuntimeDir, wl.FallbackName)
seal.container.SetEnv[wl.WaylandDisplay] = wl.FallbackName
if !config.Confinement.Sandbox.DirectWayland { // set up security-context-v1
socketDir := path.Join(sc.SharePath, "wayland")
outerPath := path.Join(socketDir, id)
seal.sys.Ensure(socketDir, 0711)
appID := config.ID
if appID == "" {
// use instance ID in case app id is not set
appID = "uk.gensokyo.fortify." + id
}
seal.sys.Wayland(&seal.bwrapSync, outerPath, socketPath, appID, id)
seal.container.Bind(outerPath, innerPath)
} else { // bind mount wayland socket (insecure)
fmsg.Verbose("direct wayland access, PROCEED WITH CAUTION")
seal.container.Bind(socketPath, innerPath)
seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute)
}
}
// set up X11
if config.Confinement.Enablements.Has(system.EX11) {
// discover X11 and grant user permission via the `ChangeHosts` command
if d, ok := sys.LookupEnv(display); !ok {
return fmsg.WrapError(ErrXDisplay,
"DISPLAY is not set")
} else {
seal.sys.ChangeHosts("#" + seal.user.uid.String())
seal.container.SetEnv[display] = d
seal.container.Bind("/tmp/.X11-unix", "/tmp/.X11-unix")
}
}
/*
PulseAudio server and authentication
*/
if config.Confinement.Enablements.Has(system.EPulse) {
// PulseAudio runtime directory (usually `/run/user/%d/pulse`)
pulseRuntimeDir := path.Join(sc.RuntimePath, "pulse")
// PulseAudio socket (usually `/run/user/%d/pulse/native`)
pulseSocket := path.Join(pulseRuntimeDir, "native")
if _, err := sys.Stat(pulseRuntimeDir); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio directory %q:", pulseRuntimeDir))
}
return fmsg.WrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir))
}
if s, err := sys.Stat(pulseSocket); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio socket %q:", pulseSocket))
}
return fmsg.WrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir))
} else {
if m := s.Mode(); m&0o006 != 0o006 {
return fmsg.WrapError(ErrPulseMode,
fmt.Sprintf("unexpected permissions on %q:", pulseSocket), m)
}
}
// hard link pulse socket into target-executable share
innerPulseRuntimeDir := path.Join(sharePathLocal, "pulse")
innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native")
seal.sys.Link(pulseSocket, innerPulseRuntimeDir)
seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket)
seal.container.SetEnv[pulseServer] = "unix:" + innerPulseSocket
// publish current user's pulse cookie for target user
if src, err := discoverPulseCookie(sys); err != nil {
// not fatal
fmsg.Verbose(strings.TrimSpace(err.(*fmsg.BaseError).Message()))
} else {
innerDst := fst.Tmp + "/pulse-cookie"
seal.container.SetEnv[pulseCookie] = innerDst
payload := new([]byte)
seal.container.CopyBindRef(innerDst, &payload)
seal.sys.CopyFile(payload, src, 256, 256)
}
}
/*
D-Bus proxy
*/
if config.Confinement.Enablements.Has(system.EDBus) {
// ensure dbus session bus defaults
if config.Confinement.SessionBus == nil {
config.Confinement.SessionBus = dbus.NewConfig(config.ID, true, true)
}
// downstream socket paths
sessionPath, systemPath := path.Join(sharePath, "bus"), path.Join(sharePath, "system_bus_socket")
// configure dbus proxy
if f, err := seal.sys.ProxyDBus(
config.Confinement.SessionBus, config.Confinement.SystemBus,
sessionPath, systemPath,
); err != nil {
return err
} else {
seal.dbusMsg = f
}
// share proxy sockets
sessionInner := path.Join(innerRuntimeDir, "bus")
seal.container.SetEnv[dbusSessionBusAddress] = "unix:path=" + sessionInner
seal.container.Bind(sessionPath, sessionInner)
seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write)
if config.Confinement.SystemBus != nil {
systemInner := "/run/dbus/system_bus_socket"
seal.container.SetEnv[dbusSystemBusAddress] = "unix:path=" + systemInner
seal.container.Bind(systemPath, systemInner)
seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write)
}
}
/*
Miscellaneous
*/
// queue overriding tmpfs at the end of seal.container.Filesystem
for _, dest := range config.Confinement.Sandbox.Override {
seal.container.Tmpfs(dest, 8*1024)
}
// append ExtraPerms last
for _, p := range config.Confinement.ExtraPerms {
if p == nil {
continue
}
if p.Ensure {
seal.sys.Ensure(p.Path, 0700)
}
perms := make(acl.Perms, 0, 3)
if p.Read {
perms = append(perms, acl.Read)
}
if p.Write {
perms = append(perms, acl.Write)
}
if p.Execute {
perms = append(perms, acl.Execute)
}
seal.sys.UpdatePermType(system.User, p.Path, perms...)
}
// mount fortify in sandbox for init
seal.container.Bind(sys.MustExecutable(), path.Join(fst.Tmp, "sbin/fortify"))
seal.container.Symlink("fortify", path.Join(fst.Tmp, "sbin/init"))
fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, command: %s", fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, command: %s",
seal.sys.user.us, seal.sys.user.username, config.Confinement.Groups, config.Command) seal.user.uid, seal.user.username, config.Confinement.Groups, config.Command)
// seal app and release lock
a.seal = seal
return nil return nil
} }
// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie
func discoverPulseCookie(sys sys.State) (string, error) {
if p, ok := sys.LookupEnv(pulseCookie); ok {
return p, nil
}
// dotfile $HOME/.pulse-cookie
if p, ok := sys.LookupEnv(home); ok {
p = path.Join(p, ".pulse-cookie")
if s, err := sys.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return p, fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
}
// not found, try next method
} else if !s.IsDir() {
return p, nil
}
}
// $XDG_CONFIG_HOME/pulse/cookie
if p, ok := sys.LookupEnv(xdgConfigHome); ok {
p = path.Join(p, "pulse", "cookie")
if s, err := sys.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return p, fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
}
// not found, try next method
} else if !s.IsDir() {
return p, nil
}
}
return "", fmsg.WrapError(ErrPulseCookie,
fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
pulseCookie, xdgConfigHome, home))
}

View File

@ -1,339 +0,0 @@
package app
import (
"errors"
"fmt"
"io/fs"
"path"
"strings"
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
"git.gensokyo.uk/security/fortify/wl"
)
const (
home = "HOME"
shell = "SHELL"
xdgConfigHome = "XDG_CONFIG_HOME"
xdgRuntimeDir = "XDG_RUNTIME_DIR"
xdgSessionClass = "XDG_SESSION_CLASS"
xdgSessionType = "XDG_SESSION_TYPE"
term = "TERM"
display = "DISPLAY"
pulseServer = "PULSE_SERVER"
pulseCookie = "PULSE_COOKIE"
dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS"
dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS"
)
var (
ErrXDisplay = errors.New(display + " unset")
ErrPulseCookie = errors.New("pulse cookie not present")
ErrPulseSocket = errors.New("pulse socket not present")
ErrPulseMode = errors.New("unexpected pulse socket mode")
)
func (seal *appSeal) setupShares(bus [2]*dbus.Config, os linux.System) error {
if seal.shared {
panic("seal shared twice")
}
seal.shared = true
/*
Tmpdir-based share directory
*/
// ensure Share (e.g. `/tmp/fortify.%d`)
// acl is unnecessary as this directory is world executable
seal.sys.Ensure(seal.SharePath, 0711)
// ensure process-specific share (e.g. `/tmp/fortify.%d/%s`)
// acl is unnecessary as this directory is world executable
seal.share = path.Join(seal.SharePath, seal.id)
seal.sys.Ephemeral(system.Process, seal.share, 0711)
// ensure child tmpdir parent directory (e.g. `/tmp/fortify.%d/tmpdir`)
targetTmpdirParent := path.Join(seal.SharePath, "tmpdir")
seal.sys.Ensure(targetTmpdirParent, 0700)
seal.sys.UpdatePermType(system.User, targetTmpdirParent, acl.Execute)
// ensure child tmpdir (e.g. `/tmp/fortify.%d/tmpdir/%d`)
targetTmpdir := path.Join(targetTmpdirParent, seal.sys.user.as)
seal.sys.Ensure(targetTmpdir, 01700)
seal.sys.UpdatePermType(system.User, targetTmpdir, acl.Read, acl.Write, acl.Execute)
seal.sys.bwrap.Bind(targetTmpdir, "/tmp", false, true)
/*
XDG runtime directory
*/
// mount tmpfs on inner runtime (e.g. `/run/user/%d`)
seal.sys.bwrap.Tmpfs("/run/user", 1*1024*1024)
seal.sys.bwrap.Tmpfs(seal.sys.runtime, 8*1024*1024)
// point to inner runtime path `/run/user/%d`
seal.sys.bwrap.SetEnv[xdgRuntimeDir] = seal.sys.runtime
seal.sys.bwrap.SetEnv[xdgSessionClass] = "user"
seal.sys.bwrap.SetEnv[xdgSessionType] = "tty"
// ensure RunDir (e.g. `/run/user/%d/fortify`)
seal.sys.Ensure(seal.RunDirPath, 0700)
seal.sys.UpdatePermType(system.User, seal.RunDirPath, acl.Execute)
// ensure runtime directory ACL (e.g. `/run/user/%d`)
seal.sys.Ensure(seal.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset
seal.sys.UpdatePermType(system.User, seal.RuntimePath, acl.Execute)
// ensure process-specific share local to XDG_RUNTIME_DIR (e.g. `/run/user/%d/fortify/%s`)
seal.shareLocal = path.Join(seal.RunDirPath, seal.id)
seal.sys.Ephemeral(system.Process, seal.shareLocal, 0700)
seal.sys.UpdatePerm(seal.shareLocal, acl.Execute)
/*
Inner passwd database
*/
// look up shell
sh := "/bin/sh"
if s, ok := os.LookupEnv(shell); ok {
seal.sys.bwrap.SetEnv[shell] = s
sh = s
}
// bind home directory
homeDir := "/var/empty"
if seal.sys.user.home != "" {
homeDir = seal.sys.user.home
}
username := "chronos"
if seal.sys.user.username != "" {
username = seal.sys.user.username
}
seal.sys.bwrap.Bind(seal.sys.user.data, homeDir, false, true)
seal.sys.bwrap.Chdir = homeDir
seal.sys.bwrap.SetEnv["HOME"] = homeDir
seal.sys.bwrap.SetEnv["USER"] = username
// generate /etc/passwd and /etc/group
seal.sys.bwrap.CopyBind("/etc/passwd",
[]byte(username+":x:"+seal.sys.mappedIDString+":"+seal.sys.mappedIDString+":Fortify:"+homeDir+":"+sh+"\n"))
seal.sys.bwrap.CopyBind("/etc/group",
[]byte("fortify:x:"+seal.sys.mappedIDString+":\n"))
/*
Display servers
*/
// pass $TERM to launcher
if t, ok := os.LookupEnv(term); ok {
seal.sys.bwrap.SetEnv[term] = t
}
// set up wayland
if seal.et.Has(system.EWayland) {
var socketPath string
if name, ok := os.LookupEnv(wl.WaylandDisplay); !ok {
fmsg.Verbose(wl.WaylandDisplay + " is not set, assuming " + wl.FallbackName)
socketPath = path.Join(seal.RuntimePath, wl.FallbackName)
} else if !path.IsAbs(name) {
socketPath = path.Join(seal.RuntimePath, name)
} else {
socketPath = name
}
innerPath := path.Join(seal.sys.runtime, wl.FallbackName)
seal.sys.bwrap.SetEnv[wl.WaylandDisplay] = wl.FallbackName
if !seal.directWayland { // set up security-context-v1
socketDir := path.Join(seal.SharePath, "wayland")
outerPath := path.Join(socketDir, seal.id)
seal.sys.Ensure(socketDir, 0711)
appID := seal.fid
if appID == "" {
// use instance ID in case app id is not set
appID = "uk.gensokyo.fortify." + seal.id
}
seal.sys.Wayland(outerPath, socketPath, appID, seal.id)
seal.sys.bwrap.Bind(outerPath, innerPath)
} else { // bind mount wayland socket (insecure)
fmsg.Verbose("direct wayland access, PROCEED WITH CAUTION")
seal.sys.bwrap.Bind(socketPath, innerPath)
// ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`)
seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute)
}
}
// set up X11
if seal.et.Has(system.EX11) {
// discover X11 and grant user permission via the `ChangeHosts` command
if d, ok := os.LookupEnv(display); !ok {
return fmsg.WrapError(ErrXDisplay,
"DISPLAY is not set")
} else {
seal.sys.ChangeHosts("#" + seal.sys.user.us)
seal.sys.bwrap.SetEnv[display] = d
seal.sys.bwrap.Bind("/tmp/.X11-unix", "/tmp/.X11-unix")
}
}
/*
PulseAudio server and authentication
*/
if seal.et.Has(system.EPulse) {
// check PulseAudio directory presence (e.g. `/run/user/%d/pulse`)
pd := path.Join(seal.RuntimePath, "pulse")
ps := path.Join(pd, "native")
if _, err := os.Stat(pd); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio directory %q:", pd))
}
return fmsg.WrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory %q not found", pd))
}
// check PulseAudio socket permission (e.g. `/run/user/%d/pulse/native`)
if s, err := os.Stat(ps); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio socket %q:", ps))
}
return fmsg.WrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pd))
} else {
if m := s.Mode(); m&0o006 != 0o006 {
return fmsg.WrapError(ErrPulseMode,
fmt.Sprintf("unexpected permissions on %q:", ps), m)
}
}
// hard link pulse socket into target-executable share
psi := path.Join(seal.shareLocal, "pulse")
p := path.Join(seal.sys.runtime, "pulse", "native")
seal.sys.Link(ps, psi)
seal.sys.bwrap.Bind(psi, p)
seal.sys.bwrap.SetEnv[pulseServer] = "unix:" + p
// publish current user's pulse cookie for target user
if src, err := discoverPulseCookie(os); err != nil {
// not fatal
fmsg.Verbose(strings.TrimSpace(err.(*fmsg.BaseError).Message()))
} else {
innerDst := fst.Tmp + "/pulse-cookie"
seal.sys.bwrap.SetEnv[pulseCookie] = innerDst
payload := new([]byte)
seal.sys.bwrap.CopyBindRef(innerDst, &payload)
seal.sys.CopyFile(payload, src, 256, 256)
}
}
/*
D-Bus proxy
*/
if seal.et.Has(system.EDBus) {
// ensure dbus session bus defaults
if bus[0] == nil {
bus[0] = dbus.NewConfig(seal.fid, true, true)
}
// downstream socket paths
sessionPath, systemPath := path.Join(seal.share, "bus"), path.Join(seal.share, "system_bus_socket")
// configure dbus proxy
if f, err := seal.sys.ProxyDBus(bus[0], bus[1], sessionPath, systemPath); err != nil {
return err
} else {
seal.dbusMsg = f
}
// share proxy sockets
sessionInner := path.Join(seal.sys.runtime, "bus")
seal.sys.bwrap.SetEnv[dbusSessionBusAddress] = "unix:path=" + sessionInner
seal.sys.bwrap.Bind(sessionPath, sessionInner)
seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write)
if bus[1] != nil {
systemInner := "/run/dbus/system_bus_socket"
seal.sys.bwrap.SetEnv[dbusSystemBusAddress] = "unix:path=" + systemInner
seal.sys.bwrap.Bind(systemPath, systemInner)
seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write)
}
}
/*
Miscellaneous
*/
// queue overriding tmpfs at the end of seal.sys.bwrap.Filesystem
for _, dest := range seal.sys.override {
seal.sys.bwrap.Tmpfs(dest, 8*1024)
}
// mount fortify in sandbox for init
seal.sys.bwrap.Bind(os.MustExecutable(), path.Join(fst.Tmp, "sbin/fortify"))
seal.sys.bwrap.Symlink("fortify", path.Join(fst.Tmp, "sbin/init"))
// append extra perms
for _, p := range seal.extraPerms {
if p == nil {
continue
}
if p.ensure {
seal.sys.Ensure(p.name, 0700)
}
seal.sys.UpdatePermType(system.User, p.name, p.perms...)
}
return nil
}
// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie
func discoverPulseCookie(os linux.System) (string, error) {
if p, ok := os.LookupEnv(pulseCookie); ok {
return p, nil
}
// dotfile $HOME/.pulse-cookie
if p, ok := os.LookupEnv(home); ok {
p = path.Join(p, ".pulse-cookie")
if s, err := os.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return p, fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
}
// not found, try next method
} else if !s.IsDir() {
return p, nil
}
}
// $XDG_CONFIG_HOME/pulse/cookie
if p, ok := os.LookupEnv(xdgConfigHome); ok {
p = path.Join(p, "pulse", "cookie")
if s, err := os.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return p, fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
}
// not found, try next method
} else if !s.IsDir() {
return p, nil
}
}
return "", fmsg.WrapError(ErrPulseCookie,
fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
pulseCookie, xdgConfigHome, home))
}

View File

@ -1,267 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"log"
"os/exec"
"path/filepath"
"strings"
"time"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/internal/app/shim"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/system"
)
const shimSetupTimeout = 5 * time.Second
func (a *app) Run(ctx context.Context, rs *RunState) error {
a.lock.Lock()
defer a.lock.Unlock()
if rs == nil {
panic("attempted to pass nil state to run")
}
// resolve exec paths
shimExec := [2]string{helper.BubblewrapName}
if len(a.seal.command) > 0 {
shimExec[1] = a.seal.command[0]
}
for i, n := range shimExec {
if len(n) == 0 {
continue
}
if filepath.Base(n) == n {
if s, err := exec.LookPath(n); err == nil {
shimExec[i] = s
} else {
return fmsg.WrapError(err,
fmt.Sprintf("executable file %q not found in $PATH", n))
}
}
}
// startup will go ahead, commit system setup
if err := a.seal.sys.Commit(ctx); err != nil {
return err
}
a.seal.sys.needRevert = true
// start shim via manager
a.shim = new(shim.Shim)
waitErr := make(chan error, 1)
if startTime, err := a.shim.Start(
a.seal.sys.user.as,
a.seal.sys.user.supp,
a.seal.sys.Sync(),
); err != nil {
return err
} else {
// shim process created
rs.Start = true
shimSetupCtx, shimSetupCancel := context.WithDeadline(ctx, time.Now().Add(shimSetupTimeout))
defer shimSetupCancel()
// start waiting for shim
go func() {
waitErr <- a.shim.Unwrap().Wait()
// cancel shim setup in case shim died before receiving payload
shimSetupCancel()
}()
// send payload
if err = a.shim.Serve(shimSetupCtx, &shim.Payload{
Argv: a.seal.command,
Exec: shimExec,
Bwrap: a.seal.sys.bwrap,
Home: a.seal.sys.user.data,
Verbose: fmsg.Load(),
}); err != nil {
return err
}
// shim accepted setup payload, create process state
sd := state.State{
ID: *a.id,
PID: a.shim.Unwrap().Process.Pid,
Time: *startTime,
}
// register process state
var err0 = new(StateStoreError)
err0.Inner, err0.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(c state.Cursor) {
err0.InnerErr = c.Save(&sd, a.seal.ct)
})
a.seal.sys.saveState = true
if err = err0.equiv("cannot save process state:"); err != nil {
return err
}
}
select {
// wait for process and resolve exit code
case err := <-waitErr:
if err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
// should be unreachable
rs.WaitErr = err
}
// store non-zero return code
rs.ExitCode = exitError.ExitCode()
} else {
rs.ExitCode = a.shim.Unwrap().ProcessState.ExitCode()
}
if fmsg.Load() {
fmsg.Verbosef("process %d exited with exit code %d", a.shim.Unwrap().Process.Pid, rs.ExitCode)
}
// this is reached when a fault makes an already running shim impossible to continue execution
// however a kill signal could not be delivered (should actually always happen like that since fsu)
// the effects of this is similar to the alternative exit path and ensures shim death
case err := <-a.shim.WaitFallback():
rs.ExitCode = 255
log.Printf("cannot terminate shim on faulted setup: %v", err)
// alternative exit path relying on shim behaviour on monitor process exit
case <-ctx.Done():
fmsg.Verbose("alternative exit path selected")
}
// child process exited, resume output
fmsg.Resume()
// print queued up dbus messages
if a.seal.dbusMsg != nil {
a.seal.dbusMsg()
}
// update store and revert app setup transaction
e := new(StateStoreError)
e.Inner, e.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(b state.Cursor) {
e.InnerErr = func() error {
// destroy defunct state entry
if cmd := a.shim.Unwrap(); cmd != nil && a.seal.sys.saveState {
if err := b.Destroy(*a.id); err != nil {
return err
}
}
// enablements of remaining launchers
rt, ec := new(system.Enablements), new(system.Criteria)
ec.Enablements = new(system.Enablements)
ec.Set(system.Process)
if states, err := b.Load(); err != nil {
return err
} else {
if l := len(states); l == 0 {
// cleanup globals as the final launcher
fmsg.Verbose("no other launchers active, will clean up globals")
ec.Set(system.User)
} else {
fmsg.Verbosef("found %d active launchers, cleaning up without globals", l)
}
// accumulate capabilities of other launchers
for i, s := range states {
if s.Config != nil {
*rt |= s.Config.Confinement.Enablements
} else {
log.Printf("state entry %d does not contain config", i)
}
}
}
// invert accumulated enablements for cleanup
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if !rt.Has(i) {
ec.Set(i)
}
}
if fmsg.Load() {
labels := make([]string, 0, system.ELen+1)
for i := system.Enablement(0); i < system.Enablement(system.ELen+2); i++ {
if ec.Has(i) {
labels = append(labels, system.TypeString(i))
}
}
if len(labels) > 0 {
fmsg.Verbose("reverting operations labelled", strings.Join(labels, ", "))
}
}
if a.seal.sys.needRevert {
if err := a.seal.sys.Revert(ec); err != nil {
return err.(RevertCompoundError)
}
}
return nil
}()
})
e.Err = a.seal.store.Close()
return e.equiv("error returned during cleanup:", e)
}
// StateStoreError is returned for a failed state save
type StateStoreError struct {
// whether inner function was called
Inner bool
// error returned by state.Store Do method
DoErr error
// error returned by state.Backend Save method
InnerErr error
// any other errors needing to be tracked
Err error
}
func (e *StateStoreError) equiv(a ...any) error {
if e.Inner && e.DoErr == nil && e.InnerErr == nil && e.Err == nil {
return nil
} else {
return fmsg.WrapErrorSuffix(e, a...)
}
}
func (e *StateStoreError) Error() string {
if e.Inner && e.InnerErr != nil {
return e.InnerErr.Error()
}
if e.DoErr != nil {
return e.DoErr.Error()
}
if e.Err != nil {
return e.Err.Error()
}
return "(nil)"
}
func (e *StateStoreError) Unwrap() (errs []error) {
errs = make([]error, 0, 3)
if e.DoErr != nil {
errs = append(errs, e.DoErr)
}
if e.InnerErr != nil {
errs = append(errs, e.InnerErr)
}
if e.Err != nil {
errs = append(errs, e.Err)
}
return
}
type RevertCompoundError interface {
Error() string
Unwrap() []error
}

19
internal/app/strings.go Normal file
View File

@ -0,0 +1,19 @@
package app
import (
"strconv"
"git.gensokyo.uk/security/fortify/fst"
)
func newInt(v int) *stringPair[int] { return &stringPair[int]{v, strconv.Itoa(v)} }
func newID(id *fst.ID) *stringPair[fst.ID] { return &stringPair[fst.ID]{*id, id.String()} }
// stringPair stores a value and its string representation.
type stringPair[T comparable] struct {
v T
s string
}
func (s *stringPair[T]) unwrap() T { return s.v }
func (s *stringPair[T]) String() string { return s.s }

View File

@ -1,51 +0,0 @@
package app
import (
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/system"
)
// appSealSys encapsulates app seal behaviour with OS interactions
type appSealSys struct {
bwrap *bwrap.Config
// paths to override by mounting tmpfs over them
override []string
// default formatted XDG_RUNTIME_DIR of User
runtime string
// target user sealed from config
user appUser
// mapped uid and gid in user namespace
mappedID int
// string representation of mappedID
mappedIDString string
needRevert bool
saveState bool
*system.I
// protected by upstream mutex
}
type appUser struct {
// full uid resolved by fsu
uid int
// string representation of uid
us string
// supplementary group ids
supp []string
// application id
aid int
// string representation of aid
as string
// home directory host path
data string
// app user home directory
home string
// passwd database username
username string
}

View File

@ -2,7 +2,9 @@ package fmsg
import ( import (
"fmt" "fmt"
"log"
"reflect" "reflect"
"strings"
) )
// baseError implements a basic error container // baseError implements a basic error container
@ -70,3 +72,17 @@ func AsBaseError(err error, target **BaseError) bool {
*target = v.Convert(baseErrorType).Interface().(*BaseError) *target = v.Convert(baseErrorType).Interface().(*BaseError)
return true return true
} }
func PrintBaseError(err error, fallback string) {
var e *BaseError
if AsBaseError(err, &e) {
if msg := e.Message(); strings.TrimSpace(msg) != "" {
log.Print(msg)
return
}
Verbose("*"+fallback, err)
return
}
log.Println(fallback, err)
}

View File

@ -1,4 +1,4 @@
package linux package sys
import ( import (
"io/fs" "io/fs"
@ -6,11 +6,12 @@ import (
"path" "path"
"strconv" "strconv"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// System provides safe access to operating system resources. // State provides safe interaction with operating system state.
type System interface { type State interface {
// Geteuid provides [os.Geteuid]. // Geteuid provides [os.Geteuid].
Geteuid() int Geteuid() int
// LookupEnv provides [os.LookupEnv]. // LookupEnv provides [os.LookupEnv].
@ -34,24 +35,18 @@ type System interface {
// Exit provides [os.Exit]. // Exit provides [os.Exit].
Exit(code int) Exit(code int)
Println(v ...any)
Printf(format string, v ...any)
// Paths returns a populated [Paths] struct. // Paths returns a populated [Paths] struct.
Paths() Paths Paths() fst.Paths
// Uid invokes fsu and returns target uid. // Uid invokes fsu and returns target uid.
// Any errors returned by Uid is already wrapped [fmsg.BaseError].
Uid(aid int) (int, error) Uid(aid int) (int, error)
} }
// Paths contains environment dependent paths used by fortify.
type Paths struct {
// path to shared directory e.g. /tmp/fortify.%d
SharePath string `json:"share_path"`
// XDG_RUNTIME_DIR value e.g. /run/user/%d
RuntimePath string `json:"runtime_path"`
// application runtime directory e.g. /run/user/%d/fortify
RunDirPath string `json:"run_dir_path"`
}
// CopyPaths is a generic implementation of [System.Paths]. // CopyPaths is a generic implementation of [System.Paths].
func CopyPaths(os System, v *Paths) { func CopyPaths(os State, v *fst.Paths) {
v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid())) v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid()))
fmsg.Verbosef("process share directory at %q", v.SharePath) fmsg.Verbosef("process share directory at %q", v.SharePath)

View File

@ -1,7 +1,8 @@
package linux package sys
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"log" "log"
"os" "os"
@ -12,13 +13,14 @@ import (
"sync" "sync"
"syscall" "syscall"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// Std implements System using the standard library. // Std implements System using the standard library.
type Std struct { type Std struct {
paths Paths paths fst.Paths
pathsOnce sync.Once pathsOnce sync.Once
uidOnce sync.Once uidOnce sync.Once
@ -40,10 +42,12 @@ func (s *Std) Stat(name string) (fs.FileInfo, error) { return os.Stat(nam
func (s *Std) Open(name string) (fs.File, error) { return os.Open(name) } func (s *Std) Open(name string) (fs.File, error) { return os.Open(name) }
func (s *Std) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } func (s *Std) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) }
func (s *Std) Exit(code int) { internal.Exit(code) } func (s *Std) Exit(code int) { internal.Exit(code) }
func (s *Std) Println(v ...any) { fmsg.Verbose(v...) }
func (s *Std) Printf(format string, v ...any) { fmsg.Verbosef(format, v...) }
const xdgRuntimeDir = "XDG_RUNTIME_DIR" const xdgRuntimeDir = "XDG_RUNTIME_DIR"
func (s *Std) Paths() Paths { func (s *Std) Paths() fst.Paths {
s.pathsOnce.Do(func() { CopyPaths(s, &s.paths) }) s.pathsOnce.Do(func() { CopyPaths(s, &s.paths) })
return s.paths return s.paths
} }
@ -56,13 +60,15 @@ func (s *Std) Uid(aid int) (int, error) {
}) })
}) })
s.uidMu.RLock() {
if u, ok := s.uidCopy[aid]; ok { s.uidMu.RLock()
u, ok := s.uidCopy[aid]
s.uidMu.RUnlock() s.uidMu.RUnlock()
return u.uid, u.err if ok {
return u.uid, u.err
}
} }
s.uidMu.RUnlock()
s.uidMu.Lock() s.uidMu.Lock()
defer s.uidMu.Unlock() defer s.uidMu.Unlock()
@ -91,8 +97,13 @@ func (s *Std) Uid(aid int) (int, error) {
if p, u.err = cmd.Output(); u.err == nil { if p, u.err = cmd.Output(); u.err == nil {
u.uid, u.err = strconv.Atoi(string(p)) u.uid, u.err = strconv.Atoi(string(p))
if u.err != nil {
u.err = fmsg.WrapErrorSuffix(u.err, "cannot parse uid from fsu:")
}
} else if errors.As(u.err, &exitError) && exitError != nil && exitError.ExitCode() == 1 { } else if errors.As(u.err, &exitError) && exitError != nil && exitError.ExitCode() == 1 {
u.err = syscall.EACCES u.err = fmsg.WrapError(syscall.EACCES, "") // fsu prints to stderr in this case
} else if os.IsNotExist(u.err) {
u.err = fmsg.WrapError(os.ErrNotExist, fmt.Sprintf("the setuid helper is missing: %s", fsu))
} }
return u.uid, u.err return u.uid, u.err
} }

View File

@ -1,89 +0,0 @@
package system
import (
"errors"
"fmt"
"os"
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/wl"
)
// Wayland sets up a wayland socket with a security context attached.
func (sys *I) Wayland(dst, src, appID, instanceID string) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, Wayland{[2]string{dst, src}, new(wl.Conn), appID, instanceID})
return sys
}
type Wayland struct {
pair [2]string
conn *wl.Conn
appID, instanceID string
}
func (w Wayland) Type() Enablement {
return Process
}
func (w Wayland) apply(sys *I) error {
// the Wayland op is not repeatable
if sys.sp != nil {
return errors.New("attempted to attach multiple wayland sockets")
}
if err := w.conn.Attach(w.pair[1]); err != nil {
// make console output less nasty
if errors.Is(err, os.ErrNotExist) {
err = os.ErrNotExist
}
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot attach to wayland on %q:", w.pair[1]))
} else {
fmsg.Verbosef("wayland attached on %q", w.pair[1])
}
if sp, err := w.conn.Bind(w.pair[0], w.appID, w.instanceID); err != nil {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot bind to socket on %q:", w.pair[0]))
} else {
sys.sp = sp
fmsg.Verbosef("wayland listening on %q", w.pair[0])
return fmsg.WrapErrorSuffix(errors.Join(os.Chmod(w.pair[0], 0), acl.UpdatePerm(w.pair[0], sys.uid, acl.Read, acl.Write, acl.Execute)),
fmt.Sprintf("cannot chmod socket on %q:", w.pair[0]))
}
}
func (w Wayland) revert(_ *I, ec *Criteria) error {
if ec.hasType(w) {
fmsg.Verbosef("removing wayland socket on %q", w.pair[0])
if err := os.Remove(w.pair[0]); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
fmsg.Verbosef("detaching from wayland on %q", w.pair[1])
return fmsg.WrapErrorSuffix(w.conn.Close(),
fmt.Sprintf("cannot detach from wayland on %q:", w.pair[1]))
} else {
fmsg.Verbosef("skipping wayland cleanup on %q", w.pair[0])
return nil
}
}
func (w Wayland) Is(o Op) bool {
w0, ok := o.(Wayland)
return ok && w.pair == w0.pair
}
func (w Wayland) Path() string {
return w.pair[0]
}
func (w Wayland) String() string {
return fmt.Sprintf("wayland socket at %q", w.pair[0])
}

29
main.go
View File

@ -24,9 +24,9 @@ import (
init0 "git.gensokyo.uk/security/fortify/internal/app/init" init0 "git.gensokyo.uk/security/fortify/internal/app/init"
"git.gensokyo.uk/security/fortify/internal/app/shim" "git.gensokyo.uk/security/fortify/internal/app/shim"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/system"
) )
var ( var (
@ -44,7 +44,7 @@ func init() {
flag.BoolVar(&flagJSON, "json", false, "Format output in JSON when applicable") flag.BoolVar(&flagJSON, "json", false, "Format output in JSON when applicable")
} }
var sys linux.System = new(linux.Std) var std sys.State = new(sys.Std)
type gl []string type gl []string
@ -135,7 +135,7 @@ func main() {
// Ignore errors; set is set for ExitOnError. // Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:]) _ = set.Parse(args[1:])
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(sys.Paths().RunDirPath), short) printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), short)
internal.Exit(0) internal.Exit(0)
case "show": // pretty-print app info case "show": // pretty-print app info
@ -227,8 +227,9 @@ func main() {
passwdOnce sync.Once passwdOnce sync.Once
passwdFunc = func() { passwdFunc = func() {
var us string var us string
if uid, err := sys.Uid(aid); err != nil { if uid, err := std.Uid(aid); err != nil {
log.Fatalf("cannot obtain uid from fsu: %v", err) fmsg.PrintBaseError(err, "cannot obtain uid from fsu:")
os.Exit(1)
} else { } else {
us = strconv.Itoa(uid) us = strconv.Itoa(uid)
} }
@ -318,7 +319,7 @@ func main() {
} }
func runApp(config *fst.Config) { func runApp(config *fst.Config) {
rs := new(app.RunState) rs := new(fst.RunState)
ctx, stop := signal.NotifyContext(context.Background(), ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM) syscall.SIGINT, syscall.SIGTERM)
defer stop() // unreachable defer stop() // unreachable
@ -327,14 +328,14 @@ func runApp(config *fst.Config) {
seccomp.CPrintln = log.Println seccomp.CPrintln = log.Println
} }
if a, err := app.New(sys); err != nil { if a, err := app.New(std); err != nil {
log.Fatalf("cannot create app: %s", err) log.Fatalf("cannot create app: %s", err)
} else if err = a.Seal(config); err != nil { } else if err = a.Seal(config); err != nil {
logBaseError(err, "cannot seal app:") fmsg.PrintBaseError(err, "cannot seal app:")
internal.Exit(1) internal.Exit(1)
} else if err = a.Run(ctx, rs); err != nil { } else if err = a.Run(ctx, rs); err != nil {
if !rs.Start { if rs.Time == nil {
logBaseError(err, "cannot start app:") fmsg.PrintBaseError(err, "cannot start app:")
} else { } else {
logWaitError(err) logWaitError(err)
} }
@ -343,6 +344,12 @@ func runApp(config *fst.Config) {
rs.ExitCode = 126 rs.ExitCode = 126
} }
} }
if rs.RevertErr != nil {
fmsg.PrintBaseError(rs.RevertErr, "generic error returned during cleanup:")
if rs.ExitCode == 0 {
rs.ExitCode = 128
}
}
if rs.WaitErr != nil { if rs.WaitErr != nil {
log.Println("inner wait failed:", rs.WaitErr) log.Println("inner wait failed:", rs.WaitErr)
} }

View File

@ -36,7 +36,7 @@ package
*Default:* *Default:*
` <derivation fortify-0.2.15> ` ` <derivation fortify-0.2.16> `

View File

@ -16,7 +16,7 @@
buildGoModule rec { buildGoModule rec {
pname = "fortify"; pname = "fortify";
version = "0.2.15"; version = "0.2.16";
src = builtins.path { src = builtins.path {
name = "fortify-src"; name = "fortify-src";

View File

@ -84,7 +84,7 @@ func tryShort(name string) (config *fst.Config, instance *state.State) {
if likePrefix && len(name) >= 8 { if likePrefix && len(name) >= 8 {
fmsg.Verbose("argument looks like prefix") fmsg.Verbose("argument looks like prefix")
s := state.NewMulti(sys.Paths().RunDirPath) s := state.NewMulti(std.Paths().RunDirPath)
if entries, err := state.Join(s); err != nil { if entries, err := state.Join(s); err != nil {
log.Printf("cannot join store: %v", err) log.Printf("cannot join store: %v", err)
// drop to fetch from file // drop to fetch from file

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"os"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@ -13,6 +14,7 @@ import (
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
) )
@ -23,8 +25,9 @@ func printShowSystem(output io.Writer, short bool) {
info := new(fst.Info) info := new(fst.Info)
// get fid by querying uid of aid 0 // get fid by querying uid of aid 0
if uid, err := sys.Uid(0); err != nil { if uid, err := std.Uid(0); err != nil {
log.Fatalf("cannot obtain uid from fsu: %v", err) fmsg.PrintBaseError(err, "cannot obtain uid from fsu:")
os.Exit(1)
} else { } else {
info.User = (uid / 10000) - 100 info.User = (uid / 10000) - 100
} }

View File

@ -5,7 +5,6 @@ import (
"slices" "slices"
"git.gensokyo.uk/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// UpdatePerm appends an ephemeral acl update Op. // UpdatePerm appends an ephemeral acl update Op.
@ -31,23 +30,21 @@ type ACL struct {
perms acl.Perms perms acl.Perms
} }
func (a *ACL) Type() Enablement { func (a *ACL) Type() Enablement { return a.et }
return a.et
}
func (a *ACL) apply(sys *I) error { func (a *ACL) apply(sys *I) error {
fmsg.Verbose("applying ACL", a) sys.println("applying ACL", a)
return fmsg.WrapErrorSuffix(acl.UpdatePerm(a.path, sys.uid, a.perms...), return sys.wrapErrSuffix(acl.Update(a.path, sys.uid, a.perms...),
fmt.Sprintf("cannot apply ACL entry to %q:", a.path)) fmt.Sprintf("cannot apply ACL entry to %q:", a.path))
} }
func (a *ACL) revert(sys *I, ec *Criteria) error { func (a *ACL) revert(sys *I, ec *Criteria) error {
if ec.hasType(a) { if ec.hasType(a) {
fmsg.Verbose("stripping ACL", a) sys.println("stripping ACL", a)
return fmsg.WrapErrorSuffix(acl.UpdatePerm(a.path, sys.uid), return sys.wrapErrSuffix(acl.Update(a.path, sys.uid),
fmt.Sprintf("cannot strip ACL entry from %q:", a.path)) fmt.Sprintf("cannot strip ACL entry from %q:", a.path))
} else { } else {
fmsg.Verbose("skipping ACL", a) sys.println("skipping ACL", a)
return nil return nil
} }
} }
@ -60,9 +57,7 @@ func (a *ACL) Is(o Op) bool {
slices.Equal(a.perms, a0.perms) slices.Equal(a.perms, a0.perms)
} }
func (a *ACL) Path() string { func (a *ACL) Path() string { return a.path }
return a.path
}
func (a *ACL) String() string { func (a *ACL) String() string {
return fmt.Sprintf("%s type: %s path: %q", return fmt.Sprintf("%s type: %s path: %q",

View File

@ -8,7 +8,6 @@ import (
"sync" "sync"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
var ( var (
@ -28,7 +27,7 @@ func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath st
// session bus is mandatory // session bus is mandatory
if session == nil { if session == nil {
return nil, fmsg.WrapError(ErrDBusConfig, return nil, sys.wrapErr(ErrDBusConfig,
"attempted to seal message bus proxy without session bus config") "attempted to seal message bus proxy without session bus config")
} }
@ -48,12 +47,12 @@ func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath st
d.proxy = dbus.New(sessionBus, systemBus) d.proxy = dbus.New(sessionBus, systemBus)
defer func() { defer func() {
if fmsg.Load() && d.proxy.Sealed() { if sys.IsVerbose() && d.proxy.Sealed() {
fmsg.Verbose("sealed session proxy", session.Args(sessionBus)) sys.println("sealed session proxy", session.Args(sessionBus))
if system != nil { if system != nil {
fmsg.Verbose("sealed system proxy", system.Args(systemBus)) sys.println("sealed system proxy", system.Args(systemBus))
} }
fmsg.Verbose("message bus proxy final args:", d.proxy) sys.println("message bus proxy final args:", d.proxy)
} }
}() }()
@ -62,7 +61,7 @@ func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath st
// seal dbus proxy // seal dbus proxy
d.out = &scanToFmsg{msg: new(strings.Builder)} d.out = &scanToFmsg{msg: new(strings.Builder)}
return d.out.Dump, fmsg.WrapErrorSuffix(d.proxy.Seal(session, system), return d.out.Dump, sys.wrapErrSuffix(d.proxy.Seal(session, system),
"cannot seal message bus proxy:") "cannot seal message bus proxy:")
} }
@ -74,32 +73,30 @@ type DBus struct {
system bool system bool
} }
func (d *DBus) Type() Enablement { func (d *DBus) Type() Enablement { return Process }
return Process
}
func (d *DBus) apply(sys *I) error { func (d *DBus) apply(sys *I) error {
fmsg.Verbosef("session bus proxy on %q for upstream %q", d.proxy.Session()[1], d.proxy.Session()[0]) sys.printf("session bus proxy on %q for upstream %q", d.proxy.Session()[1], d.proxy.Session()[0])
if d.system { if d.system {
fmsg.Verbosef("system bus proxy on %q for upstream %q", d.proxy.System()[1], d.proxy.System()[0]) 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 // this starts the process and blocks until ready
if err := d.proxy.Start(sys.ctx, d.out, true); err != nil { if err := d.proxy.Start(sys.ctx, d.out, true); err != nil {
d.out.Dump() d.out.Dump()
return fmsg.WrapErrorSuffix(err, return sys.wrapErrSuffix(err,
"cannot start message bus proxy:") "cannot start message bus proxy:")
} }
fmsg.Verbose("starting message bus proxy:", d.proxy) sys.println("starting message bus proxy:", d.proxy)
return nil return nil
} }
func (d *DBus) revert(_ *I, _ *Criteria) error { func (d *DBus) revert(sys *I, _ *Criteria) error {
// criteria ignored here since dbus is always process-scoped // criteria ignored here since dbus is always process-scoped
fmsg.Verbose("terminating message bus proxy") sys.println("terminating message bus proxy")
d.proxy.Close() d.proxy.Close()
defer fmsg.Verbose("message bus proxy exit") defer sys.println("message bus proxy exit")
return fmsg.WrapErrorSuffix(d.proxy.Wait(), "message bus proxy error:") return sys.wrapErrSuffix(d.proxy.Wait(), "message bus proxy error:")
} }
func (d *DBus) Is(o Op) bool { func (d *DBus) Is(o Op) bool {

View File

@ -3,8 +3,6 @@ package system
import ( import (
"fmt" "fmt"
"os" "os"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// Link registers an Op that links dst to src. // Link registers an Op that links dst to src.
@ -27,27 +25,23 @@ type Hardlink struct {
func (l *Hardlink) Type() Enablement { return l.et } func (l *Hardlink) Type() Enablement { return l.et }
func (l *Hardlink) apply(_ *I) error { func (l *Hardlink) apply(sys *I) error {
fmsg.Verbose("linking", l) sys.println("linking", l)
return fmsg.WrapErrorSuffix(os.Link(l.src, l.dst), return sys.wrapErrSuffix(os.Link(l.src, l.dst),
fmt.Sprintf("cannot link %q:", l.dst)) fmt.Sprintf("cannot link %q:", l.dst))
} }
func (l *Hardlink) revert(_ *I, ec *Criteria) error { func (l *Hardlink) revert(sys *I, ec *Criteria) error {
if ec.hasType(l) { if ec.hasType(l) {
fmsg.Verbosef("removing hard link %q", l.dst) sys.printf("removing hard link %q", l.dst)
return fmsg.WrapErrorSuffix(os.Remove(l.dst), return sys.wrapErrSuffix(os.Remove(l.dst),
fmt.Sprintf("cannot remove hard link %q:", l.dst)) fmt.Sprintf("cannot remove hard link %q:", l.dst))
} else { } else {
fmsg.Verbosef("skipping hard link %q", l.dst) sys.printf("skipping hard link %q", l.dst)
return nil return nil
} }
} }
func (l *Hardlink) Is(o Op) bool { func (l *Hardlink) Is(o Op) bool { l0, ok := o.(*Hardlink); return ok && l0 != nil && *l == *l0 }
l0, ok := o.(*Hardlink)
return ok && l0 != nil && *l == *l0
}
func (l *Hardlink) Path() string { return l.src } func (l *Hardlink) Path() string { return l.src }
func (l *Hardlink) String() string { return fmt.Sprintf("%q from %q", l.dst, l.src) } func (l *Hardlink) String() string { return fmt.Sprintf("%q from %q", l.dst, l.src) }

View File

@ -4,8 +4,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// Ensure the existence and mode of a directory. // Ensure the existence and mode of a directory.
@ -39,33 +37,33 @@ func (m *Mkdir) Type() Enablement {
return m.et return m.et
} }
func (m *Mkdir) apply(_ *I) error { func (m *Mkdir) apply(sys *I) error {
fmsg.Verbose("ensuring directory", m) sys.println("ensuring directory", m)
// create directory // create directory
err := os.Mkdir(m.path, m.perm) err := os.Mkdir(m.path, m.perm)
if !errors.Is(err, os.ErrExist) { if !errors.Is(err, os.ErrExist) {
return fmsg.WrapErrorSuffix(err, return sys.wrapErrSuffix(err,
fmt.Sprintf("cannot create directory %q:", m.path)) fmt.Sprintf("cannot create directory %q:", m.path))
} }
// directory exists, ensure mode // directory exists, ensure mode
return fmsg.WrapErrorSuffix(os.Chmod(m.path, m.perm), return sys.wrapErrSuffix(os.Chmod(m.path, m.perm),
fmt.Sprintf("cannot change mode of %q to %s:", m.path, m.perm)) fmt.Sprintf("cannot change mode of %q to %s:", m.path, m.perm))
} }
func (m *Mkdir) revert(_ *I, ec *Criteria) error { func (m *Mkdir) revert(sys *I, ec *Criteria) error {
if !m.ephemeral { if !m.ephemeral {
// skip non-ephemeral dir and do not log anything // skip non-ephemeral dir and do not log anything
return nil return nil
} }
if ec.hasType(m) { if ec.hasType(m) {
fmsg.Verbose("destroying ephemeral directory", m) sys.println("destroying ephemeral directory", m)
return fmsg.WrapErrorSuffix(os.Remove(m.path), return sys.wrapErrSuffix(os.Remove(m.path),
fmt.Sprintf("cannot remove ephemeral directory %q:", m.path)) fmt.Sprintf("cannot remove ephemeral directory %q:", m.path))
} else { } else {
fmsg.Verbose("skipping ephemeral directory", m) sys.println("skipping ephemeral directory", m)
return nil return nil
} }
} }

View File

@ -4,10 +4,7 @@ import (
"context" "context"
"errors" "errors"
"log" "log"
"os"
"sync" "sync"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
const ( const (
@ -56,12 +53,26 @@ func TypeString(e Enablement) 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 { type I struct {
uid int uid int
ops []Op ops []Op
ctx context.Context ctx context.Context
// sync fd passed to bwrap
sp *os.File 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 // whether sys has been reverted
state bool state bool
@ -69,12 +80,15 @@ type I struct {
lock sync.Mutex lock sync.Mutex
} }
func (sys *I) UID() int { func (sys *I) UID() int { return sys.uid }
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) Sync() *os.File { func (sys *I) wrapErrSuffix(err error, a ...any) error {
return sys.sp if err == nil {
return nil
}
return sys.wrapErr(err, append(a, err)...)
} }
func (sys *I) Equal(v *I) bool { func (sys *I) Equal(v *I) bool {
@ -106,7 +120,7 @@ func (sys *I) Commit(ctx context.Context) error {
// sp is set to nil when all ops are applied // sp is set to nil when all ops are applied
if sp != nil { if sp != nil {
// rollback partial commit // rollback partial commit
fmsg.Verbosef("commit faulted after %d ops, rolling back partial commit", len(sp.ops)) sys.printf("commit faulted after %d ops, rolling back partial commit", len(sp.ops))
if err := sp.Revert(&Criteria{nil}); err != nil { if err := sp.Revert(&Criteria{nil}); err != nil {
log.Println("errors returned reverting partial commit:", err) log.Println("errors returned reverting partial commit:", err)
} }
@ -146,7 +160,3 @@ func (sys *I) Revert(ec *Criteria) error {
// errors.Join filters nils // errors.Join filters nils
return errors.Join(errs...) return errors.Join(errs...)
} }
func New(uid int) *I {
return &I{uid: uid}
}

View File

@ -4,7 +4,7 @@ import (
"strconv" "strconv"
"testing" "testing"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/system"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {

View File

@ -2,12 +2,11 @@ package system
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"syscall" "syscall"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// CopyFile registers an Op that copies from src. // CopyFile registers an Op that copies from src.
@ -32,29 +31,34 @@ type Tmpfile struct {
} }
func (t *Tmpfile) Type() Enablement { return Process } func (t *Tmpfile) Type() Enablement { return Process }
func (t *Tmpfile) apply(_ *I) error { func (t *Tmpfile) apply(sys *I) error {
fmsg.Verbose("copying", t) 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 { if b, err := os.Stat(t.src); err != nil {
return fmsg.WrapErrorSuffix(err, return sys.wrapErrSuffix(err,
fmt.Sprintf("cannot stat %q:", t.src)) fmt.Sprintf("cannot stat %q:", t.src))
} else { } else {
if b.IsDir() { if b.IsDir() {
return fmsg.WrapErrorSuffix(syscall.EISDIR, return sys.wrapErrSuffix(syscall.EISDIR,
fmt.Sprintf("%q is a directory", t.src)) fmt.Sprintf("%q is a directory", t.src))
} }
if s := b.Size(); s > t.n { if s := b.Size(); s > t.n {
return fmsg.WrapErrorSuffix(syscall.ENOMEM, return sys.wrapErrSuffix(syscall.ENOMEM,
fmt.Sprintf("file %q is too long: %d > %d", fmt.Sprintf("file %q is too long: %d > %d",
t.src, s, t.n)) t.src, s, t.n))
} }
} }
if f, err := os.Open(t.src); err != nil { if f, err := os.Open(t.src); err != nil {
return fmsg.WrapErrorSuffix(err, return sys.wrapErrSuffix(err,
fmt.Sprintf("cannot open %q:", t.src)) fmt.Sprintf("cannot open %q:", t.src))
} else if _, err = io.CopyN(t.buf, f, t.n); err != nil { } else if _, err = io.CopyN(t.buf, f, t.n); err != nil {
return fmsg.WrapErrorSuffix(err, return sys.wrapErrSuffix(err,
fmt.Sprintf("cannot read from %q:", t.src)) fmt.Sprintf("cannot read from %q:", t.src))
} }

89
system/wayland.go Normal file
View File

@ -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.Update(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) }

View File

@ -3,8 +3,7 @@ package system
import ( import (
"fmt" "fmt"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/system/internal/xcb"
"git.gensokyo.uk/security/fortify/xcb"
) )
// ChangeHosts appends an X11 ChangeHosts command Op. // ChangeHosts appends an X11 ChangeHosts command Op.
@ -23,19 +22,19 @@ func (x XHost) Type() Enablement {
return EX11 return EX11
} }
func (x XHost) apply(_ *I) error { func (x XHost) apply(sys *I) error {
fmsg.Verbosef("inserting entry %s to X11", x) sys.printf("inserting entry %s to X11", x)
return fmsg.WrapErrorSuffix(xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)), return sys.wrapErrSuffix(xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
fmt.Sprintf("cannot insert entry %s to X11:", x)) fmt.Sprintf("cannot insert entry %s to X11:", x))
} }
func (x XHost) revert(_ *I, ec *Criteria) error { func (x XHost) revert(sys *I, ec *Criteria) error {
if ec.hasType(x) { if ec.hasType(x) {
fmsg.Verbosef("deleting entry %s from X11", x) sys.printf("deleting entry %s from X11", x)
return fmsg.WrapErrorSuffix(xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)), return sys.wrapErrSuffix(xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
fmt.Sprintf("cannot delete entry %s from X11:", x)) fmt.Sprintf("cannot delete entry %s from X11:", x))
} else { } else {
fmsg.Verbosef("skipping entry %s in X11", x) sys.printf("skipping entry %s in X11", x)
return nil return nil
} }
} }

View File

@ -18,7 +18,12 @@ nixosTest {
skipTypeCheck = true; skipTypeCheck = true;
nodes.machine = nodes.machine =
{ lib, pkgs, ... }: {
lib,
pkgs,
config,
...
}:
{ {
users.users = { users.users = {
alice = { alice = {
@ -32,6 +37,9 @@ nixosTest {
description = "Untrusted user"; description = "Untrusted user";
password = "foobar"; password = "foobar";
uid = 1001; uid = 1001;
# For deny unmapped uid test:
packages = [ config.environment.fortify.package ];
}; };
}; };
@ -284,7 +292,16 @@ nixosTest {
machine.wait_for_file("/tmp/sway-ipc.sock") machine.wait_for_file("/tmp/sway-ipc.sock")
# Deny unmapped uid: # Deny unmapped uid:
print(machine.fail("sudo -u untrusted -i ${self.packages.${system}.fortify}/bin/fortify -v run")) denyOutput = machine.fail("sudo -u untrusted -i fortify run &>/dev/stdout")
print(denyOutput)
denyOutputVerbose = machine.fail("sudo -u untrusted -i fortify -v run &>/dev/stdout")
print(denyOutputVerbose)
# Verify PrintBaseError behaviour:
if denyOutput != "fsu: uid 1001 is not in the fsurc file\n":
raise Exception(f"unexpected deny output:\n{denyOutput}")
if denyOutputVerbose != "fsu: uid 1001 is not in the fsurc file\nfortify: *cannot obtain uid from fsu: permission denied\n":
raise Exception(f"unexpected deny verbose output:\n{denyOutputVerbose}")
# Start fortify permissive defaults outside Wayland session: # Start fortify permissive defaults outside Wayland session:
print(machine.succeed("sudo -u alice -i fortify -v run -a 0 touch /tmp/success-bare")) print(machine.succeed("sudo -u alice -i fortify -v run -a 0 touch /tmp/success-bare"))

View File

@ -10,7 +10,14 @@ package wl
#include "wayland-bind.h" #include "wayland-bind.h"
*/ */
import "C" import "C"
import "errors" import (
"errors"
"strings"
)
var (
ErrContainsNull = errors.New("string contains null character")
)
var resErr = [...]error{ var resErr = [...]error{
0: nil, 0: nil,
@ -19,6 +26,11 @@ var resErr = [...]error{
} }
func bindWaylandFd(socketPath string, fd uintptr, appID, instanceID string, syncFD uintptr) error { func bindWaylandFd(socketPath string, fd uintptr, appID, instanceID string, syncFD uintptr) error {
if hasNull(appID) || hasNull(instanceID) {
return ErrContainsNull
}
res := C.bind_wayland_fd(C.CString(socketPath), C.int(fd), C.CString(appID), C.CString(instanceID), C.int(syncFD)) res := C.bind_wayland_fd(C.CString(socketPath), C.int(fd), C.CString(appID), C.CString(instanceID), C.int(syncFD))
return resErr[int32(res)] return resErr[int32(res)]
} }
func hasNull(s string) bool { return strings.IndexByte(s, '\x00') > -1 }