Compare commits

...

26 Commits

Author SHA1 Message Date
Ophestra Umiker de0d78daae
release: 0.2.1
release / release (push) Successful in 1m4s Details
test / test (push) Successful in 20s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-19 21:03:50 +09:00
Ophestra Umiker 6bf33ce507
fortify: use resolved username
test / test (push) Successful in 21s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-19 21:03:09 +09:00
Ophestra Umiker 9faf3b3596
app: validate username
test / test (push) Successful in 23s Details
This value is used for passwd generation. Bad input can cause very confusing issues. This is not a security issue, however validation will improve user experience.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-19 21:01:41 +09:00
Ophestra Umiker d99c8b1fb4
release: 0.2.0
release / release (push) Successful in 44s Details
test / test (push) Successful in 22s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-19 18:15:09 +09:00
Ophestra Umiker 6e4870775f
update README document
test / test (push) Successful in 20s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-19 18:14:06 +09:00
Ophestra Umiker 0a546885e3
nix: update options doc
test / test (push) Successful in 22s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-19 18:12:35 +09:00
Ophestra Umiker 653d69da0a
nix: module descriptions
test / test (push) Successful in 24s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-19 18:10:57 +09:00
Ophestra Umiker f8256137ae
nix: separate module options from implementation
test / test (push) Successful in 25s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-19 17:08:22 +09:00
Ophestra Umiker 54b47b0315
nix: copy pixmaps directory to share package
test / test (push) Successful in 21s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-18 14:46:08 +09:00
Ophestra Umiker ae2628e57a
cmd/fshim/ipc: install signal handler on shim start
test / test (push) Successful in 20s Details
Getting killed at this point will result in inconsistent state.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-18 13:33:46 +09:00
Ophestra Umiker c026a4b5dc
fortify: permissive defaults resolve home directory from os
test / test (push) Successful in 21s Details
When starting with the permissive defaults "run" command, attempt to resolve home directory from os by default and fall back to /var/empty.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-18 13:01:07 +09:00
Ophestra Umiker 748a0ae2c8
nix: wrap program from libexec
test / test (push) Successful in 24s Details
This avoids renaming the fortify binary.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-18 12:58:47 +09:00
Ophestra Umiker 8f3f0c7bbf
nix: integrate dynamic users
test / test (push) Successful in 21s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-18 02:49:48 +09:00
Ophestra Umiker 05b7dbf066
app: alternative inner home path
test / test (push) Successful in 24s Details
Support binding home to an alternative path in the mount namespace.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-18 00:18:21 +09:00
Ophestra Umiker 866270ff05
fmsg: add to wg prior to enqueue
test / test (push) Successful in 27s Details
Adding after channel write is racy.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-17 23:50:02 +09:00
Ophestra Umiker c1fad649e8
app/start: check for cleanup and abort condition
test / test (push) Successful in 21s Details
Dirty fix. Will rewrite after fsu integration complete.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-17 23:41:52 +09:00
Ophestra Umiker b5f01ef20b
app: append # for ChangeHosts message with numerical uid
test / test (push) Successful in 21s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-17 23:40:37 +09:00
Ophestra Umiker 2e23cef7bb
cmd/fuserdb: generate group entries
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-17 23:31:06 +09:00
Ophestra Umiker 6a6d30af1f
cmd/fuserdb: systemd userdb drop-in entries generator
test / test (push) Successful in 20s Details
This provides user records via nss-systemd. Static drop-in entries are generated to reduce complexity and attack surface.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-17 02:16:02 +09:00
Ophestra Umiker df33123bd7
app: integrate fsu
test / test (push) Successful in 21s Details
This removes the dependency on external user switchers like sudo/machinectl and decouples fortify user ids from the passwd database.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-16 21:19:45 +09:00
Ophestra Umiker 1a09b55bd4
nix: remove portal paths from default
test / test (push) Successful in 27s Details
Despite presenting itself as a generic desktop integration interface, xdg-desktop portal is highly flatpak-centric and only supports flatpak and snap in practice. It is a significant attack surface to begin with as it is a privileged process which accepts input from unprivileged processes, and the lack of support for anything other than fortify also introduces various information leaks when exposed to fortify as it treats fortified programs as unsandboxed, privileged programs in many cases.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-10 22:24:17 +09:00
Ophestra Umiker 9a13b311ac
app/config: rename map_real_uid from use_real_uid
test / test (push) Successful in 19s Details
This option only changes mapped uid in the user namespace.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-09 12:01:34 +09:00
Ophestra Umiker 45fead18c3
cmd/fshim: set no_new_privs flag
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-09 11:50:56 +09:00
Ophestra Umiker 431aa32291
nix: remove absolute Exec paths
test / test (push) Successful in 26s Details
Absolute paths set for Exec causes the program to be launched as the privileged user.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-08 02:05:47 +09:00
Ophestra Umiker 3962705126
nix: keep fshim and finit names
test / test (push) Successful in 22s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-06 14:59:28 +09:00
Ophestra Umiker ad80be721b
nix: improve start script
test / test (push) Successful in 23s Details
Zsh store path in shebang. Replace writeShellScript with writeScript since runtimeShell is not overridable.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-11-06 14:09:41 +09:00
32 changed files with 1656 additions and 837 deletions

View File

@ -33,9 +33,9 @@ jobs:
go build -v -ldflags '-s -w go build -v -ldflags '-s -w
-X git.ophivana.moe/security/fortify/internal.Version=${{ github.ref_name }} -X git.ophivana.moe/security/fortify/internal.Version=${{ github.ref_name }}
-X git.ophivana.moe/security/fortify/internal.Fsu=/usr/bin/fsu -X git.ophivana.moe/security/fortify/internal.Fsu=/usr/bin/fsu
-X git.ophivana.moe/security/fortify/internal.Fshim=/usr/libexec/fortify/fshim
-X git.ophivana.moe/security/fortify/internal.Finit=/usr/libexec/fortify/finit -X git.ophivana.moe/security/fortify/internal.Finit=/usr/libexec/fortify/finit
-X main.Fmain=/usr/bin/fortify' -X main.Fmain=/usr/bin/fortify
-X main.Fshim=/usr/libexec/fortify/fshim'
-o bin/ ./... && -o bin/ ./... &&
(cd bin && sha512sum --tag -b * > sha512sums) (cd bin && sha512sum --tag -b * > sha512sums)

View File

@ -36,8 +36,8 @@ jobs:
go build -v -ldflags '-s -w go build -v -ldflags '-s -w
-X git.ophivana.moe/security/fortify/internal.Version=${{ github.ref_name }} -X git.ophivana.moe/security/fortify/internal.Version=${{ github.ref_name }}
-X git.ophivana.moe/security/fortify/internal.Fsu=/usr/bin/fsu -X git.ophivana.moe/security/fortify/internal.Fsu=/usr/bin/fsu
-X git.ophivana.moe/security/fortify/internal.Fshim=/usr/libexec/fortify/fshim
-X git.ophivana.moe/security/fortify/internal.Finit=/usr/libexec/fortify/finit -X git.ophivana.moe/security/fortify/internal.Finit=/usr/libexec/fortify/finit
-X main.Fmain=/usr/bin/fortify' -X main.Fmain=/usr/bin/fortify
-X main.Fshim=/usr/libexec/fortify/fshim'
-o bin/ ./... && -o bin/ ./... &&
(cd bin && sha512sum --tag -b * > sha512sums) (cd bin && sha512sum --tag -b * > sha512sums)

160
README.md
View File

@ -32,7 +32,9 @@ nix run git+https://git.ophivana.moe/security/fortify -- -h
## Module usage ## Module usage
The NixOS module currently requires home-manager and impermanence to function correctly. The NixOS module currently requires home-manager to function correctly.
Full module documentation can be found [here](options.md).
To use the module, import it into your configuration with To use the module, import it into your configuration with
@ -69,37 +71,19 @@ This adds the `environment.fortify` option:
{ {
environment.fortify = { environment.fortify = {
enable = true; enable = true;
user = "nixos"; stateDir = "/var/lib/persist/module/fortify";
stateDir = "/var/lib/persist/module"; users = {
target = { alice = 0;
chronos = { nixos = 10;
launchers = {
weechat.method = "sudo";
claws-mail.capability.pulse = false;
discord = {
id = "dev.vencord.Vesktop";
command = "vesktop --ozone-platform-hint=wayland";
userns = true;
useRealUid = true;
dbus = {
session =
f:
f {
talk = [ "org.kde.StatusNotifierWatcher" ];
own = [ ];
call = { };
broadcast = { };
};
system.filter = true;
};
share = pkgs.vesktop;
}; };
chromium = { apps = [
{
name = "chromium";
id = "org.chromium.Chromium"; id = "org.chromium.Chromium";
packages = [ pkgs.chromium ];
userns = true; userns = true;
useRealUid = true; mapRealUid = true;
dbus = { dbus = {
system = { system = {
filter = true; filter = true;
@ -109,7 +93,9 @@ This adds the `environment.fortify` option:
"org.freedesktop.UPower" "org.freedesktop.UPower"
]; ];
}; };
session = f: f { session =
f:
f {
talk = [ talk = [
"org.freedesktop.DBus" "org.freedesktop.DBus"
"org.freedesktop.FileManager1" "org.freedesktop.FileManager1"
@ -128,81 +114,59 @@ This adds the `environment.fortify` option:
broadcast = { }; broadcast = { };
}; };
}; };
}
{
name = "claws-mail";
id = "org.claws_mail.Claws-Mail";
packages = [ pkgs.claws-mail ];
gpu = false;
capability.pulse = false;
}
{
name = "weechat";
packages = [ pkgs.weechat ];
capability = {
wayland = false;
x11 = false;
dbus = true;
pulse = false;
}; };
}
{
name = "discord";
id = "dev.vencord.Vesktop";
packages = [ pkgs.vesktop ];
share = pkgs.vesktop;
command = "vesktop --ozone-platform-hint=wayland";
userns = true;
mapRealUid = true;
capability.x11 = true;
dbus = {
session =
f:
f {
talk = [ "org.kde.StatusNotifierWatcher" ];
own = [ ];
call = { };
broadcast = { };
}; };
packages = with pkgs; [ system.filter = true;
weechat };
claws-mail }
vesktop {
chromium name = "looking-glass-client";
]; groups = [ "plugdev" ];
persistence.directories = [ extraPaths = [
".config/weechat" {
".claws-mail" src = "/dev/shm/looking-glass";
".config/vesktop" write = true;
}
]; ];
extraConfig = { extraConfig = {
programs.looking-glass-client.enable = true; programs.looking-glass-client.enable = true;
}; };
}; }
}; ];
}; };
} }
``` ```
* `enable` determines whether the module should be enabled or not. Useful when sharing configurations between graphical
and headless systems. Defaults to `false`.
* `user` specifies the privileged user with access to fortified applications.
* `stateDir` is the path to your persistent storage location. It is directly passed through to the impermanence module.
* `target` is an attribute set of submodules, where the attribute name is the username of the unprivileged target user.
The available options are:
* `packages`, the list of packages to make available in the target user's environment.
* `persistence`, user persistence attribute set passed to impermanence.
* `extraConfig`, extra home-manager configuration for the target user.
* `launchers`, attribute set where the attribute name is the name of the launcher.
The available options are:
* `id`, the freedesktop application ID, primarily used by dbus, null to disable.
* `command`, the command to run as the target user. Defaults to launcher name.
* `dbus.session`, D-Bus session proxy custom configuration.
* `dbus.configSystem`, D-Bus system proxy custom configuration, null to disable.
* `env`, attrset of environment variables to set for the initial process in the sandbox.
* `nix`, whether to allow nix daemon connections from within the sandbox.
* `userns`, whether to allow userns within the sandbox.
* `useRealUid`, whether to map to the real UID within the sandbox.
* `net`, whether to allow network access within the sandbox.
* `gpu`, target process GPU and driver access, null to follow Wayland or X capability.
* `dev`, whether to allow full device access within the sandbox.
* `extraPaths`, a list of extra paths to make available inside the sandbox.
* `capability.wayland`, whether to share the Wayland socket.
* `capability.x11`, whether to share the X11 socket and allow connection.
* `capability.dbus`, whether to proxy D-Bus.
* `capability.pulse`, whether to share the PulseAudio socket and cookie.
* `share`, package containing desktop/icon files. Defaults to launcher name.
* `method`, the launch method for the sandboxed program, can be `"sudo"`, `"systemd"`, `"simple"`.

View File

@ -103,7 +103,7 @@ func main() {
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
fmsg.Fatalf("cannot start %q: %v", payload.Argv0, err) fmsg.Fatalf("cannot start %q: %v", payload.Argv0, err)
} }
fmsg.Withhold() fmsg.Suspend()
// close setup pipe as setup is now complete // close setup pipe as setup is now complete
if err := setup.Close(); err != nil { if err := setup.Close(); err != nil {

View File

@ -5,6 +5,8 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"os/signal"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
@ -12,6 +14,7 @@ import (
"git.ophivana.moe/security/fortify/acl" "git.ophivana.moe/security/fortify/acl"
shim0 "git.ophivana.moe/security/fortify/cmd/fshim/ipc" shim0 "git.ophivana.moe/security/fortify/cmd/fshim/ipc"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.ophivana.moe/security/fortify/internal/fmsg"
) )
@ -24,24 +27,26 @@ type Shim struct {
cmd *exec.Cmd cmd *exec.Cmd
// uid of shim target user // uid of shim target user
uid uint32 uid uint32
// whether to check shim pid // string representation of application id
checkPid bool aid string
// user switcher executable path // string representation of supplementary group ids
executable string supp []string
// path to setup socket // path to setup socket
socket string socket string
// shim setup abort reason and completion // shim setup abort reason and completion
abort chan error abort chan error
abortErr atomic.Pointer[error] abortErr atomic.Pointer[error]
abortOnce sync.Once abortOnce sync.Once
// fallback exit notifier with error returned killing the process
killFallback chan error
// wayland mediation, nil if disabled // wayland mediation, nil if disabled
wl *shim0.Wayland wl *shim0.Wayland
// shim setup payload // shim setup payload
payload *shim0.Payload payload *shim0.Payload
} }
func New(executable string, uid uint32, socket string, wl *shim0.Wayland, payload *shim0.Payload, checkPid bool) *Shim { func New(uid uint32, aid string, supp []string, socket string, wl *shim0.Wayland, payload *shim0.Payload) *Shim {
return &Shim{uid: uid, executable: executable, socket: socket, wl: wl, payload: payload, checkPid: checkPid} return &Shim{uid: uid, aid: aid, supp: supp, socket: socket, wl: wl, payload: payload}
} }
func (s *Shim) String() string { func (s *Shim) String() string {
@ -68,9 +73,11 @@ func (s *Shim) AbortWait(err error) {
<-s.abort <-s.abort
} }
type CommandBuilder func(shimEnv string) (args []string) func (s *Shim) WaitFallback() chan error {
return s.killFallback
}
func (s *Shim) Start(f CommandBuilder) (*time.Time, error) { func (s *Shim) Start() (*time.Time, error) {
var ( var (
cf chan *net.UnixConn cf chan *net.UnixConn
accept func() accept func()
@ -87,26 +94,51 @@ func (s *Shim) Start(f CommandBuilder) (*time.Time, error) {
} }
// start user switcher process and save time // start user switcher process and save time
s.cmd = exec.Command(s.executable, f(shim0.Env+"="+s.socket)...) var fsu string
s.cmd.Env = []string{} if p, ok := internal.Check(internal.Fsu); !ok {
fmsg.Fatal("invalid fsu path, this copy of fshim is not compiled correctly")
panic("unreachable")
} else {
fsu = p
}
s.cmd = exec.Command(fsu)
s.cmd.Env = []string{
shim0.Env + "=" + s.socket,
"FORTIFY_APP_ID=" + s.aid,
}
if len(s.supp) > 0 {
fmsg.VPrintf("attaching supplementary group ids %s", s.supp)
s.cmd.Env = append(s.cmd.Env, "FORTIFY_GROUPS="+strings.Join(s.supp, " "))
}
s.cmd.Stdin, s.cmd.Stdout, s.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr s.cmd.Stdin, s.cmd.Stdout, s.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
s.cmd.Dir = "/" s.cmd.Dir = "/"
fmsg.VPrintln("starting shim via user switcher:", s.cmd) fmsg.VPrintln("starting shim via fsu:", s.cmd)
fmsg.Withhold() // withhold messages to stderr fmsg.Suspend() // withhold messages to stderr
if err := s.cmd.Start(); err != nil { if err := s.cmd.Start(); err != nil {
return nil, fmsg.WrapErrorSuffix(err, return nil, fmsg.WrapErrorSuffix(err,
"cannot start user switcher:") "cannot start fsu:")
} }
startTime := time.Now().UTC() startTime := time.Now().UTC()
// kill shim if something goes wrong and an error is returned // kill shim if something goes wrong and an error is returned
s.killFallback = make(chan error, 1)
killShim := func() { killShim := func() {
if err := s.cmd.Process.Signal(os.Interrupt); err != nil { if err := s.cmd.Process.Signal(os.Interrupt); err != nil {
fmsg.Println("cannot terminate shim on faulted setup:", err) s.killFallback <- err
} }
} }
defer func() { killShim() }() defer func() { killShim() }()
// take alternative exit path on signal
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() {
v := <-sig
fmsg.Printf("got %s after program start", v)
s.killFallback <- nil
signal.Ignore(syscall.SIGINT, syscall.SIGTERM)
}()
accept() accept()
var conn *net.UnixConn var conn *net.UnixConn
select { select {
@ -132,7 +164,7 @@ func (s *Shim) Start(f CommandBuilder) (*time.Time, error) {
err = errors.New("compromised fortify build") err = errors.New("compromised fortify build")
s.Abort(err) s.Abort(err)
return &startTime, err return &startTime, err
} else if s.checkPid && cred.Pid != int32(s.cmd.Process.Pid) { } else if cred.Pid != int32(s.cmd.Process.Pid) {
fmsg.Printf("process %d tried to connect to shim setup socket, expecting shim %d", fmsg.Printf("process %d tried to connect to shim setup socket, expecting shim %d",
cred.Pid, s.cmd.Process.Pid) cred.Pid, s.cmd.Process.Pid)
err = errors.New("compromised target user") err = errors.New("compromised target user")

View File

@ -58,7 +58,7 @@ func main() {
// dial setup socket // dial setup socket
var conn *net.UnixConn var conn *net.UnixConn
if c, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"}); err != nil { if c, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"}); err != nil {
fmsg.Fatal("cannot dial setup socket:", err) fmsg.Fatal(err.Error())
panic("unreachable") panic("unreachable")
} else { } else {
conn = c conn = c
@ -67,7 +67,7 @@ func main() {
// decode payload gob stream // decode payload gob stream
var payload shim.Payload var payload shim.Payload
if err := gob.NewDecoder(conn).Decode(&payload); err != nil { if err := gob.NewDecoder(conn).Decode(&payload); err != nil {
fmsg.Fatal("cannot decode shim payload:", err) fmsg.Fatalf("cannot decode shim payload: %v", err)
} else { } else {
fmsg.SetVerbose(payload.Verbose) fmsg.SetVerbose(payload.Verbose)
} }
@ -80,7 +80,7 @@ func main() {
wfd := -1 wfd := -1
if payload.WL { if payload.WL {
if fd, err := receiveWLfd(conn); err != nil { if fd, err := receiveWLfd(conn); err != nil {
fmsg.Fatal("cannot receive wayland fd:", err) fmsg.Fatalf("cannot receive wayland fd: %v", err)
} else { } else {
wfd = fd wfd = fd
} }
@ -102,7 +102,10 @@ func main() {
} else { } else {
// no argv, look up shell instead // no argv, look up shell instead
var ok bool var ok bool
if ic.Argv0, ok = os.LookupEnv("SHELL"); !ok { if payload.Bwrap.SetEnv == nil {
fmsg.Fatal("no command was specified and environment is unset")
}
if ic.Argv0, ok = payload.Bwrap.SetEnv["SHELL"]; !ok {
fmsg.Fatal("no command was specified and $SHELL was unset") fmsg.Fatal("no command was specified and $SHELL was unset")
} }
@ -125,7 +128,7 @@ func main() {
// share config pipe // share config pipe
if r, w, err := os.Pipe(); err != nil { if r, w, err := os.Pipe(); err != nil {
fmsg.Fatal("cannot pipe:", err) fmsg.Fatalf("cannot pipe: %v", err)
} else { } else {
conf.SetEnv[init0.Env] = strconv.Itoa(3 + len(extraFiles)) conf.SetEnv[init0.Env] = strconv.Itoa(3 + len(extraFiles))
extraFiles = append(extraFiles, r) extraFiles = append(extraFiles, r)
@ -134,7 +137,7 @@ func main() {
go func() { go func() {
// stream config to pipe // stream config to pipe
if err = gob.NewEncoder(w).Encode(&ic); err != nil { if err = gob.NewEncoder(w).Encode(&ic); err != nil {
fmsg.Fatal("cannot transmit init config:", err) fmsg.Fatalf("cannot transmit init config: %v", err)
} }
}() }()
} }
@ -142,7 +145,7 @@ func main() {
helper.BubblewrapName = payload.Exec[0] // resolved bwrap path by parent helper.BubblewrapName = payload.Exec[0] // resolved bwrap path by parent
if b, err := helper.NewBwrap(conf, nil, finitPath, if b, err := helper.NewBwrap(conf, nil, finitPath,
func(int, int) []string { return make([]string, 0) }); err != nil { func(int, int) []string { return make([]string, 0) }); err != nil {
fmsg.Fatal("malformed sandbox config:", err) fmsg.Fatalf("malformed sandbox config: %v", err)
} else { } else {
cmd := b.Unwrap() cmd := b.Unwrap()
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
@ -154,7 +157,7 @@ func main() {
// run and pass through exit code // run and pass through exit code
if err = b.Start(); err != nil { if err = b.Start(); err != nil {
fmsg.Fatal("cannot start target process:", err) fmsg.Fatalf("cannot start target process: %v", err)
} else if err = b.Wait(); err != nil { } else if err = b.Wait(); err != nil {
fmsg.VPrintln("wait:", err) fmsg.VPrintln("wait:", err)
} }

View File

@ -1,10 +1,12 @@
package main package main
import ( import (
"bufio" "bytes"
"fmt"
"log" "log"
"os" "os"
"path" "path"
"slices"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@ -15,9 +17,15 @@ const (
fsuConfFile = "/etc/fsurc" fsuConfFile = "/etc/fsurc"
envShim = "FORTIFY_SHIM" envShim = "FORTIFY_SHIM"
envAID = "FORTIFY_APP_ID" envAID = "FORTIFY_APP_ID"
envGroups = "FORTIFY_GROUPS"
PR_SET_NO_NEW_PRIVS = 0x26
) )
var Fmain = compPoison var (
Fmain = compPoison
Fshim = compPoison
)
func main() { func main() {
log.SetFlags(0) log.SetFlags(0)
@ -33,12 +41,17 @@ func main() {
log.Fatal("this program must not be started by root") log.Fatal("this program must not be started by root")
} }
var fmain string var fmain, fshim string
if p, ok := checkPath(Fmain); !ok { if p, ok := checkPath(Fmain); !ok {
log.Fatal("invalid fortify path, this copy of fsu is not compiled correctly") log.Fatal("invalid fortify path, this copy of fsu is not compiled correctly")
} else { } else {
fmain = p fmain = p
} }
if p, ok := checkPath(Fshim); !ok {
log.Fatal("invalid fshim path, this copy of fsu is not compiled correctly")
} else {
fshim = p
}
pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe") pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
if p, err := os.Readlink(pexe); err != nil { if p, err := os.Readlink(pexe); err != nil {
@ -61,84 +74,76 @@ func main() {
uid += fid * 10000 uid += fid * 10000
} }
// allowed aid range 0 to 9999
if as, ok := os.LookupEnv(envAID); !ok {
log.Fatal("FORTIFY_APP_ID not set")
} else if aid, err := parseUint32Fast(as); err != nil || aid < 0 || aid > 9999 {
log.Fatal("invalid aid")
} else {
uid += aid
}
// pass through setup path to shim // pass through setup path to shim
var shimSetupPath string var shimSetupPath string
if s, ok := os.LookupEnv(envShim); !ok { if s, ok := os.LookupEnv(envShim); !ok {
log.Fatal("FORTIFY_SHIM not set") // fortify requests target uid
// print resolved uid and exit
fmt.Print(uid)
os.Exit(0)
} else if !path.IsAbs(s) { } else if !path.IsAbs(s) {
log.Fatal("FORTIFY_SHIM is not absolute") log.Fatal("FORTIFY_SHIM is not absolute")
} else { } else {
shimSetupPath = s shimSetupPath = s
} }
// allowed aid range 0 to 9999 // supplementary groups
if as, ok := os.LookupEnv(envAID); !ok { var suppGroups, suppCurrent []int
log.Fatal("FORTIFY_APP_ID not set")
} else if aid, err := strconv.Atoi(as); err != nil || aid < 0 || aid > 9999 { if gs, ok := os.LookupEnv(envGroups); ok {
log.Fatal("invalid aid") if cur, err := os.Getgroups(); err != nil {
log.Fatalf("cannot get groups: %v", err)
} else { } else {
uid += aid suppCurrent = cur
} }
// parse space-separated list of group ids
gss := bytes.Split([]byte(gs), []byte{' '})
suppGroups = make([]int, len(gss)+1)
for i, s := range gss {
if gid, err := strconv.Atoi(string(s)); err != nil {
log.Fatalf("cannot parse %q: %v", string(s), err)
} else if gid > 0 && gid != uid && gid != os.Getgid() && slices.Contains(suppCurrent, gid) {
suppGroups[i] = gid
} else {
log.Fatalf("invalid gid %d", gid)
}
}
suppGroups[len(suppGroups)-1] = uid
} else {
suppGroups = []int{uid}
}
// careful! users in the allowlist is effectively allowed to drop groups via fsu
if err := syscall.Setresgid(uid, uid, uid); err != nil { if err := syscall.Setresgid(uid, uid, uid); err != nil {
log.Fatalf("cannot set gid: %v", err) log.Fatalf("cannot set gid: %v", err)
} }
if err := syscall.Setgroups(suppGroups); err != nil {
log.Fatalf("cannot set supplementary groups: %v", err)
}
if err := syscall.Setresuid(uid, uid, uid); err != nil { if err := syscall.Setresuid(uid, uid, uid); err != nil {
log.Fatalf("cannot set uid: %v", err) log.Fatalf("cannot set uid: %v", err)
} }
if err := syscall.Exec(fmain, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupPath}); err != nil { if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 {
log.Fatalf("cannot set no_new_privs flag: %s", errno.Error())
}
if err := syscall.Exec(fshim, []string{"fshim"}, []string{envShim + "=" + shimSetupPath}); err != nil {
log.Fatalf("cannot start shim: %v", err) log.Fatalf("cannot start shim: %v", err)
} }
panic("unreachable") panic("unreachable")
} }
func parseConfig(p string, puid int) (fid int, ok bool) {
// refuse to run if fsurc is not protected correctly
if s, err := os.Stat(p); err != nil {
log.Fatal(err)
} else if s.Mode().Perm() != 0400 {
log.Fatal("bad fsurc perm")
} else if st := s.Sys().(*syscall.Stat_t); st.Uid != 0 || st.Gid != 0 {
log.Fatal("fsurc must be owned by uid 0")
}
if r, err := os.Open(p); err != nil {
log.Fatal(err)
return -1, false
} else {
s := bufio.NewScanner(r)
var line int
for s.Scan() {
line++
// <puid> <fid>
lf := strings.SplitN(s.Text(), " ", 2)
if len(lf) != 2 {
log.Fatalf("invalid entry on line %d", line)
}
var puid0 int
if puid0, err = strconv.Atoi(lf[0]); err != nil || puid0 < 1 {
log.Fatalf("invalid parent uid on line %d", line)
}
ok = puid0 == puid
if ok {
// allowed fid range 0 to 99
if fid, err = strconv.Atoi(lf[1]); err != nil || fid < 0 || fid > 99 {
log.Fatalf("invalid fortify uid on line %d", line)
}
return
}
}
if err = s.Err(); err != nil {
log.Fatalf("cannot read fsurc: %v", err)
}
return -1, false
}
}
func checkPath(p string) (string, bool) { func checkPath(p string) (string, bool) {
return p, p != compPoison && p != "" && path.IsAbs(p) return p, p != compPoison && p != "" && path.IsAbs(p)
} }

77
cmd/fsu/parse.go Normal file
View File

@ -0,0 +1,77 @@
package main
import (
"bufio"
"errors"
"fmt"
"log"
"os"
"strings"
"syscall"
)
func parseUint32Fast(s string) (int, error) {
sLen := len(s)
if sLen < 1 {
return -1, errors.New("zero length string")
}
if sLen > 10 {
return -1, errors.New("string too long")
}
n := 0
for i, ch := range []byte(s) {
ch -= '0'
if ch > 9 {
return -1, fmt.Errorf("invalid character '%s' at index %d", string([]byte{ch}), i)
}
n = n*10 + int(ch)
}
return n, nil
}
func parseConfig(p string, puid int) (fid int, ok bool) {
// refuse to run if fsurc is not protected correctly
if s, err := os.Stat(p); err != nil {
log.Fatal(err)
} else if s.Mode().Perm() != 0400 {
log.Fatal("bad fsurc perm")
} else if st := s.Sys().(*syscall.Stat_t); st.Uid != 0 || st.Gid != 0 {
log.Fatal("fsurc must be owned by uid 0")
}
if r, err := os.Open(p); err != nil {
log.Fatal(err)
return -1, false
} else {
s := bufio.NewScanner(r)
var line int
for s.Scan() {
line++
// <puid> <fid>
lf := strings.SplitN(s.Text(), " ", 2)
if len(lf) != 2 {
log.Fatalf("invalid entry on line %d", line)
}
var puid0 int
if puid0, err = parseUint32Fast(lf[0]); err != nil || puid0 < 1 {
log.Fatalf("invalid parent uid on line %d", line)
}
ok = puid0 == puid
if ok {
// allowed fid range 0 to 99
if fid, err = parseUint32Fast(lf[1]); err != nil || fid < 0 || fid > 99 {
log.Fatalf("invalid fortify uid on line %d", line)
}
return
}
}
if err = s.Err(); err != nil {
log.Fatalf("cannot read fsurc: %v", err)
}
return -1, false
}
}

69
cmd/fuserdb/main.go Normal file
View File

@ -0,0 +1,69 @@
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"os"
"path"
"strconv"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
func main() {
fmsg.SetPrefix("fuserdb")
const varEmpty = "/var/empty"
out := flag.String("o", "userdb", "output directory")
homeDir := flag.String("d", varEmpty, "parent of home directories")
shell := flag.String("s", "/sbin/nologin", "absolute path to subordinate user shell")
flag.Parse()
type user struct {
name string
fid int
}
users := make([]user, len(flag.Args()))
for i, s := range flag.Args() {
f := bytes.SplitN([]byte(s), []byte{':'}, 2)
if len(f) != 2 {
fmsg.Fatalf("invalid entry at index %d", i)
}
users[i].name = string(f[0])
if fid, err := strconv.Atoi(string(f[1])); err != nil {
fmsg.Fatal(err.Error())
} else {
users[i].fid = fid
}
}
if err := os.MkdirAll(*out, 0755); err != nil && !errors.Is(err, os.ErrExist) {
fmsg.Fatalf("cannot create output: %v", err)
}
for _, u := range users {
fidString := strconv.Itoa(u.fid)
for aid := 0; aid < 10000; aid++ {
userName := fmt.Sprintf("u%d_a%d", u.fid, aid)
uid := 1000000 + u.fid*10000 + aid
us := strconv.Itoa(uid)
realName := fmt.Sprintf("Fortify subordinate user %d (%s)", aid, u.name)
var homeDirectory string
if *homeDir != varEmpty {
homeDirectory = path.Join(*homeDir, fidString, strconv.Itoa(aid))
} else {
homeDirectory = varEmpty
}
writeUser(userName, uid, us, realName, homeDirectory, *shell, *out)
writeGroup(userName, uid, us, nil, *out)
}
}
fmsg.Printf("created %d entries", len(users)*2*10000)
fmsg.Exit(0)
}

64
cmd/fuserdb/payload.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
type payloadU struct {
UserName string `json:"userName"`
Uid int `json:"uid"`
Gid int `json:"gid"`
MemberOf []string `json:"memberOf,omitempty"`
RealName string `json:"realName"`
HomeDirectory string `json:"homeDirectory"`
Shell string `json:"shell"`
}
func writeUser(userName string, uid int, us string, realName, homeDirectory, shell string, out string) {
userFileName := userName + ".user"
if f, err := os.OpenFile(path.Join(out, userFileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644); err != nil {
fmsg.Fatalf("cannot create %s: %v", userName, err)
} else if err = json.NewEncoder(f).Encode(&payloadU{
UserName: userName,
Uid: uid,
Gid: uid,
RealName: realName,
HomeDirectory: homeDirectory,
Shell: shell,
}); err != nil {
fmsg.Fatalf("cannot serialise %s: %v", userName, err)
} else if err = f.Close(); err != nil {
fmsg.Printf("cannot close %s: %v", userName, err)
}
if err := os.Symlink(userFileName, path.Join(out, us+".user")); err != nil {
fmsg.Fatalf("cannot link %s: %v", userName, err)
}
}
type payloadG struct {
GroupName string `json:"groupName"`
Gid int `json:"gid"`
Members []string `json:"members,omitempty"`
}
func writeGroup(groupName string, gid int, gs string, members []string, out string) {
groupFileName := groupName + ".group"
if f, err := os.OpenFile(path.Join(out, groupFileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644); err != nil {
fmsg.Fatalf("cannot create %s: %v", groupName, err)
} else if err = json.NewEncoder(f).Encode(&payloadG{
GroupName: groupName,
Gid: gid,
Members: members,
}); err != nil {
fmsg.Fatalf("cannot serialise %s: %v", groupName, err)
} else if err = f.Close(); err != nil {
fmsg.Printf("cannot close %s: %v", groupName, err)
}
if err := os.Symlink(groupFileName, path.Join(out, gs+".group")); err != nil {
fmsg.Fatalf("cannot link %s: %v", groupName, err)
}
}

View File

@ -42,6 +42,33 @@
with nixpkgsFor.${system}; with nixpkgsFor.${system};
self.packages.${system}.fortify.buildInputs ++ [ self.packages.${system}.fortify ]; self.packages.${system}.fortify.buildInputs ++ [ self.packages.${system}.fortify ];
}; };
generateDoc =
let
pkgs = nixpkgsFor.${system};
inherit (pkgs) lib;
doc =
let
eval = lib.evalModules {
specialArgs = {
inherit pkgs;
};
modules = [ ./options.nix ];
};
cleanEval = lib.filterAttrsRecursive (n: v: n != "_module") eval;
in
pkgs.nixosOptionsDoc { inherit (cleanEval) options; };
docText = pkgs.runCommand "fortify-module-docs.md" { } ''
cat ${doc.optionsCommonMark} > $out
sed -i '/*Declared by:*/,+1 d' $out
'';
in
nixpkgsFor.${system}.mkShell {
shellHook = ''
exec cat ${docText} > options.md
'';
};
}); });
}; };
} }

View File

@ -53,7 +53,7 @@ func (a *app) String() string {
} }
if a.seal != nil { if a.seal != nil {
return "(sealed fortified app as uid " + a.seal.sys.user.Uid + ")" return "(sealed fortified app as uid " + a.seal.sys.user.us + ")"
} }
return "(unsealed fortified app)" return "(unsealed fortified app)"

View File

@ -19,9 +19,12 @@ var testCasesNixos = []sealTestCase{
{ {
"nixos permissive defaults no enablements", new(stubNixOS), "nixos permissive defaults no enablements", new(stubNixOS),
&app.Config{ &app.Config{
User: "chronos",
Command: make([]string, 0), Command: make([]string, 0),
Method: "sudo", Confinement: app.ConfinementConfig{
AppID: 0,
Username: "chronos",
Outer: "/home/chronos",
},
}, },
app.ID{ app.ID{
0x4a, 0x45, 0x0b, 0x65, 0x4a, 0x45, 0x0b, 0x65,
@ -29,11 +32,11 @@ var testCasesNixos = []sealTestCase{
0xbd, 0x01, 0x78, 0x0e, 0xbd, 0x01, 0x78, 0x0e,
0xb9, 0xa6, 0x07, 0xac, 0xb9, 0xa6, 0x07, 0xac,
}, },
system.New(150). system.New(1000000).
Ensure("/tmp/fortify.1971", 0701). Ensure("/tmp/fortify.1971", 0711).
Ephemeral(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac", 0701). 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", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/150", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/150", acl.Read, acl.Write, 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, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute).
@ -43,6 +46,7 @@ var testCasesNixos = []sealTestCase{
Net: true, Net: true,
UserNS: true, UserNS: true,
Clearenv: true, Clearenv: true,
Chdir: "/home/chronos",
SetEnv: map[string]string{ SetEnv: map[string]string{
"HOME": "/home/chronos", "HOME": "/home/chronos",
"SHELL": "/run/current-system/sw/bin/zsh", "SHELL": "/run/current-system/sw/bin/zsh",
@ -182,10 +186,11 @@ var testCasesNixos = []sealTestCase{
Symlink("/fortify/etc/zprofile", "/etc/zprofile"). Symlink("/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/fortify/etc/zshenv", "/etc/zshenv"). Symlink("/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/fortify/etc/zshrc", "/etc/zshrc"). Symlink("/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/150", "/tmp", false, true). Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576). Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576). Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/65534", 8388608). Tmpfs("/run/user/65534", 8388608).
Bind("/home/chronos", "/home/chronos", false, true).
Bind("/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/passwd", "/etc/passwd"). Bind("/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/group", "/etc/group"). Bind("/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/group", "/etc/group").
Tmpfs("/var/run/nscd", 8192), Tmpfs("/var/run/nscd", 8192),
@ -194,9 +199,12 @@ var testCasesNixos = []sealTestCase{
"nixos permissive defaults chromium", new(stubNixOS), "nixos permissive defaults chromium", new(stubNixOS),
&app.Config{ &app.Config{
ID: "org.chromium.Chromium", ID: "org.chromium.Chromium",
User: "chronos",
Command: []string{"/run/current-system/sw/bin/zsh", "-c", "exec chromium "}, Command: []string{"/run/current-system/sw/bin/zsh", "-c", "exec chromium "},
Confinement: app.ConfinementConfig{ Confinement: app.ConfinementConfig{
AppID: 9,
Groups: []string{"video"},
Username: "chronos",
Outer: "/home/chronos",
SessionBus: &dbus.Config{ SessionBus: &dbus.Config{
Talk: []string{ Talk: []string{
"org.freedesktop.Notifications", "org.freedesktop.Notifications",
@ -230,7 +238,6 @@ var testCasesNixos = []sealTestCase{
}, },
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(), Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
}, },
Method: "systemd",
}, },
app.ID{ app.ID{
0xeb, 0xf0, 0x83, 0xd1, 0xeb, 0xf0, 0x83, 0xd1,
@ -238,11 +245,11 @@ var testCasesNixos = []sealTestCase{
0x82, 0xd4, 0x13, 0x36, 0x82, 0xd4, 0x13, 0x36,
0x9b, 0x64, 0xce, 0x7c, 0x9b, 0x64, 0xce, 0x7c,
}, },
system.New(150). system.New(1000009).
Ensure("/tmp/fortify.1971", 0701). Ensure("/tmp/fortify.1971", 0711).
Ephemeral(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0701). 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", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/150", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/150", acl.Read, acl.Write, 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, "/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).
@ -287,6 +294,7 @@ var testCasesNixos = []sealTestCase{
(&bwrap.Config{ (&bwrap.Config{
Net: true, Net: true,
UserNS: true, UserNS: true,
Chdir: "/home/chronos",
Clearenv: true, Clearenv: true,
SetEnv: map[string]string{ SetEnv: map[string]string{
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/65534/bus", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/65534/bus",
@ -434,10 +442,11 @@ var testCasesNixos = []sealTestCase{
Symlink("/fortify/etc/zprofile", "/etc/zprofile"). Symlink("/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/fortify/etc/zshenv", "/etc/zshenv"). Symlink("/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/fortify/etc/zshrc", "/etc/zshrc"). Symlink("/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/150", "/tmp", false, true). Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576). Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576). Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/65534", 8388608). Tmpfs("/run/user/65534", 8388608).
Bind("/home/chronos", "/home/chronos", false, true).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/passwd", "/etc/passwd"). Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/group", "/etc/group"). Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/group", "/etc/group").
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/65534/wayland-0"). Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/65534/wayland-0").
@ -504,23 +513,12 @@ func (s *stubNixOS) Executable() (string, error) {
return "/home/ophestra/.nix-profile/bin/fortify", nil return "/home/ophestra/.nix-profile/bin/fortify", nil
} }
func (s *stubNixOS) Lookup(username string) (*user.User, error) { func (s *stubNixOS) LookupGroup(name string) (*user.Group, error) {
if s.usernameErr != nil { switch name {
if err, ok := s.usernameErr[username]; ok { case "video":
return nil, err return &user.Group{Gid: "26", Name: "video"}, nil
}
}
switch username {
case "chronos":
return &user.User{
Uid: "150",
Gid: "101",
Username: "chronos",
HomeDir: "/home/chronos",
}, nil
default: default:
return nil, user.UnknownUserError(username) return nil, user.UnknownGroupError(name)
} }
} }
@ -586,10 +584,6 @@ func (s *stubNixOS) Stdout() io.Writer {
panic("requested stdout") panic("requested stdout")
} }
func (s *stubNixOS) FshimPath() string {
return "/nix/store/00000000000000000000000000000000-fortify-0.0.10/bin/.fshim"
}
func (s *stubNixOS) Paths() linux.Paths { func (s *stubNixOS) Paths() linux.Paths {
return linux.Paths{ return linux.Paths{
SharePath: "/tmp/fortify.1971", SharePath: "/tmp/fortify.1971",
@ -598,6 +592,10 @@ func (s *stubNixOS) Paths() linux.Paths {
} }
} }
func (s *stubNixOS) Uid(aid int) (int, error) {
return 1000000 + 0*10000 + aid, nil
}
func (s *stubNixOS) SdBooted() bool { func (s *stubNixOS) SdBooted() bool {
return true return true
} }

View File

@ -15,12 +15,8 @@ const fTmp = "/fortify"
type Config struct { type Config struct {
// D-Bus application ID // D-Bus application ID
ID string `json:"id"` ID string `json:"id"`
// username of the target user to switch to
User string `json:"user"`
// value passed through to the child process as its argv // value passed through to the child process as its argv
Command []string `json:"command"` Command []string `json:"command"`
// string representation of the child's launch method
Method string `json:"method"`
// child confinement configuration // child confinement configuration
Confinement ConfinementConfig `json:"confinement"` Confinement ConfinementConfig `json:"confinement"`
@ -28,6 +24,16 @@ type Config struct {
// ConfinementConfig defines fortified child's confinement // ConfinementConfig defines fortified child's confinement
type ConfinementConfig struct { type ConfinementConfig struct {
// numerical application id, determines uid in the init namespace
AppID int `json:"app_id"`
// list of supplementary groups to inherit
Groups []string `json:"groups"`
// passwd username in the sandbox, defaults to chronos
Username string `json:"username,omitempty"`
// home directory in sandbox, empty for outer
Inner string `json:"home_inner"`
// home directory in init namespace
Outer string `json:"home"`
// bwrap sandbox confinement configuration // bwrap sandbox confinement configuration
Sandbox *SandboxConfig `json:"sandbox"` Sandbox *SandboxConfig `json:"sandbox"`
@ -55,7 +61,7 @@ type SandboxConfig struct {
// do not run in new session // do not run in new session
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
UseRealUID bool `json:"use_real_uid"` MapRealUID bool `json:"map_real_uid"`
// mediated access to wayland socket // mediated access to wayland socket
Wayland bool `json:"wayland,omitempty"` Wayland bool `json:"wayland,omitempty"`
@ -92,7 +98,7 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
} }
var uid int var uid int
if !s.UseRealUID { if !s.MapRealUID {
uid = 65534 uid = 65534
} else { } else {
uid = os.Geteuid() uid = os.Geteuid()
@ -170,7 +176,6 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
func Template() *Config { func Template() *Config {
return &Config{ return &Config{
ID: "org.chromium.Chromium", ID: "org.chromium.Chromium",
User: "chronos",
Command: []string{ Command: []string{
"chromium", "chromium",
"--ignore-gpu-blocklist", "--ignore-gpu-blocklist",
@ -178,14 +183,18 @@ func Template() *Config {
"--enable-features=UseOzonePlatform", "--enable-features=UseOzonePlatform",
"--ozone-platform=wayland", "--ozone-platform=wayland",
}, },
Method: "sudo",
Confinement: ConfinementConfig{ Confinement: ConfinementConfig{
AppID: 9,
Groups: []string{"video"},
Username: "chronos",
Outer: "/var/lib/persist/home/org.chromium.Chromium",
Inner: "/var/lib/fortify",
Sandbox: &SandboxConfig{ Sandbox: &SandboxConfig{
Hostname: "localhost", Hostname: "localhost",
UserNS: true, UserNS: true,
Net: true, Net: true,
NoNewSession: true, NoNewSession: true,
UseRealUID: true, MapRealUID: true,
Dev: true, Dev: true,
Wayland: false, Wayland: false,
// example API credentials pulled from Google Chrome // example API credentials pulled from Google Chrome

View File

@ -1,57 +0,0 @@
package app
import (
"strings"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
func (a *app) commandBuilderMachineCtl(shimEnv string) (args []string) {
args = make([]string, 0, 9+len(a.seal.sys.bwrap.SetEnv))
// shell --uid=$USER
args = append(args, "shell", "--uid="+a.seal.sys.user.Username)
// --quiet
if !fmsg.Verbose() {
args = append(args, "--quiet")
}
// environ
envQ := make([]string, 0, len(a.seal.sys.bwrap.SetEnv)+1)
for k, v := range a.seal.sys.bwrap.SetEnv {
envQ = append(envQ, "-E"+k+"="+v)
}
// add shim payload to environment for shim path
envQ = append(envQ, "-E"+shimEnv)
args = append(args, envQ...)
// -- .host
args = append(args, "--", ".host")
// /bin/sh -c
if sh, err := a.os.LookPath("sh"); err != nil {
// hardcode /bin/sh path since it exists more often than not
args = append(args, "/bin/sh", "-c")
} else {
args = append(args, sh, "-c")
}
// build inner command expression ran as target user
innerCommand := strings.Builder{}
// apply custom environment variables to activation environment
innerCommand.WriteString("dbus-update-activation-environment --systemd")
for k := range a.seal.sys.bwrap.SetEnv {
innerCommand.WriteString(" " + k)
}
innerCommand.WriteString("; ")
// launch fortify shim
innerCommand.WriteString("exec " + a.os.FshimPath())
// append inner command
args = append(args, innerCommand.String())
return
}

View File

@ -1,30 +0,0 @@
package app
import (
"git.ophivana.moe/security/fortify/internal/fmsg"
)
const (
sudoAskPass = "SUDO_ASKPASS"
)
func (a *app) commandBuilderSudo(shimEnv string) (args []string) {
args = make([]string, 0, 8)
// -Hiu $USER
args = append(args, "-Hiu", a.seal.sys.user.Username)
// -A?
if _, ok := a.os.LookupEnv(sudoAskPass); ok {
fmsg.VPrintln(sudoAskPass, "set, adding askpass flag")
args = append(args, "-A")
}
// shim payload
args = append(args, shimEnv)
// -- $@
args = append(args, "--", a.os.FshimPath())
return
}

View File

@ -2,9 +2,10 @@ package app
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"os/user"
"path" "path"
"regexp"
"strconv" "strconv"
shim "git.ophivana.moe/security/fortify/cmd/fshim/ipc" shim "git.ophivana.moe/security/fortify/cmd/fshim/ipc"
@ -15,26 +16,15 @@ import (
"git.ophivana.moe/security/fortify/internal/system" "git.ophivana.moe/security/fortify/internal/system"
) )
const (
LaunchMethodSudo uint8 = iota
LaunchMethodMachineCtl
)
var method = [...]string{
LaunchMethodSudo: "sudo",
LaunchMethodMachineCtl: "systemd",
}
var ( var (
ErrConfig = errors.New("no configuration to seal") ErrConfig = errors.New("no configuration to seal")
ErrUser = errors.New("unknown user") ErrUser = errors.New("invalid aid")
ErrLaunch = errors.New("invalid launch method") ErrHome = errors.New("invalid home directory")
ErrName = errors.New("invalid username")
ErrSudo = errors.New("sudo not available")
ErrSystemd = errors.New("systemd not available")
ErrMachineCtl = errors.New("machinectl not available")
) )
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 seals the application with child-related information
type appSeal struct { type appSeal struct {
// app unique ID string representation // app unique ID string representation
@ -51,15 +41,11 @@ type appSeal struct {
// persistent process state store // persistent process state store
store state.Store store state.Store
// uint8 representation of launch method sealed from config
launchOption uint8
// process-specific share directory path // process-specific share directory path
share string share string
// process-specific share directory path local to XDG_RUNTIME_DIR // process-specific share directory path local to XDG_RUNTIME_DIR
shareLocal string shareLocal string
// path to launcher program
toolPath string
// pass-through enablement tracking from config // pass-through enablement tracking from config
et system.Enablements et system.Enablements
@ -98,39 +84,11 @@ func (a *app) Seal(config *Config) error {
seal.fid = config.ID seal.fid = config.ID
seal.command = config.Command seal.command = config.Command
// parses launch method text and looks up tool path
switch config.Method {
case method[LaunchMethodSudo]:
seal.launchOption = LaunchMethodSudo
if sudoPath, err := a.os.LookPath("sudo"); err != nil {
return fmsg.WrapError(ErrSudo,
"sudo not found")
} else {
seal.toolPath = sudoPath
}
case method[LaunchMethodMachineCtl]:
seal.launchOption = LaunchMethodMachineCtl
if !a.os.SdBooted() {
return fmsg.WrapError(ErrSystemd,
"system has not been booted with systemd as init system")
}
if machineCtlPath, err := a.os.LookPath("machinectl"); err != nil {
return fmsg.WrapError(ErrMachineCtl,
"machinectl not found")
} else {
seal.toolPath = machineCtlPath
}
default:
return fmsg.WrapError(ErrLaunch,
"invalid launch method")
}
// create seal system component // create seal system component
seal.sys = new(appSealSys) seal.sys = new(appSealSys)
// mapped uid // mapped uid
if config.Confinement.Sandbox != nil && config.Confinement.Sandbox.UseRealUID { if config.Confinement.Sandbox != nil && config.Confinement.Sandbox.MapRealUID {
seal.sys.mappedID = a.os.Geteuid() seal.sys.mappedID = a.os.Geteuid()
} else { } else {
seal.sys.mappedID = 65534 seal.sys.mappedID = 65534
@ -138,16 +96,51 @@ func (a *app) Seal(config *Config) error {
seal.sys.mappedIDString = strconv.Itoa(seal.sys.mappedID) seal.sys.mappedIDString = strconv.Itoa(seal.sys.mappedID)
seal.sys.runtime = path.Join("/run/user", seal.sys.mappedIDString) seal.sys.runtime = path.Join("/run/user", seal.sys.mappedIDString)
// look up user from system // validate uid and set user info
if u, err := a.os.Lookup(config.User); err != nil { if config.Confinement.AppID < 0 || config.Confinement.AppID > 9999 {
if errors.As(err, new(user.UnknownUserError)) { return fmsg.WrapError(ErrUser,
return fmsg.WrapError(ErrUser, "unknown user", config.User) fmt.Sprintf("aid %d out of range", config.Confinement.AppID))
} else { } else {
// unreachable seal.sys.user = appUser{
panic(err) aid: config.Confinement.AppID,
as: strconv.Itoa(config.Confinement.AppID),
data: config.Confinement.Outer,
home: config.Confinement.Inner,
username: config.Confinement.Username,
} }
if seal.sys.user.username == "" {
seal.sys.user.username = "chronos"
} else if !posixUsername.MatchString(seal.sys.user.username) {
return fmsg.WrapError(ErrName,
fmt.Sprintf("invalid user name %q", seal.sys.user.username))
}
if seal.sys.user.data == "" || !path.IsAbs(seal.sys.user.data) {
return fmsg.WrapError(ErrHome,
fmt.Sprintf("invalid home directory %q", seal.sys.user.data))
}
if seal.sys.user.home == "" {
seal.sys.user.home = seal.sys.user.data
}
// invoke fsu for full uid
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 = u seal.sys.user.uid = u
seal.sys.user.us = strconv.Itoa(u)
}
// resolve supplementary group ids from names
seal.sys.user.supp = make([]string, len(config.Confinement.Groups))
for i, name := range config.Confinement.Groups {
if g, err := a.os.LookupGroup(name); err != nil {
return fmsg.WrapError(err,
fmt.Sprintf("unknown group %q", name))
} else {
seal.sys.user.supp[i] = g.Gid
}
}
} }
// map sandbox config to bwrap // map sandbox config to bwrap
@ -230,15 +223,10 @@ func (a *app) Seal(config *Config) error {
// open process state store // open process state store
// the simple store only starts holding an open file after first action // the simple store only starts holding an open file after first action
// store activity begins after Start is called and must end before Wait // store activity begins after Start is called and must end before Wait
seal.store = state.NewSimple(seal.RunDirPath, seal.sys.user.Uid) seal.store = state.NewSimple(seal.RunDirPath, seal.sys.user.as)
// parse string UID // initialise system interface with full uid
if u, err := strconv.Atoi(seal.sys.user.Uid); err != nil { seal.sys.I = system.New(seal.sys.user.uid)
// unreachable unless kernel bug
panic("uid parse")
} else {
seal.sys.I = system.New(u)
}
// pass through enablements // pass through enablements
seal.et = config.Confinement.Enablements seal.et = config.Confinement.Enablements
@ -249,11 +237,8 @@ func (a *app) Seal(config *Config) error {
} }
// verbose log seal information // verbose log seal information
fmsg.VPrintln("created application seal as user", fmsg.VPrintf("created application seal for uid %s (%s) groups: %v, command: %s",
seal.sys.user.Username, "("+seal.sys.user.Uid+"),", seal.sys.user.us, seal.sys.user.username, config.Confinement.Groups, config.Command)
"method:", config.Method+",",
"launcher:", seal.toolPath+",",
"command:", config.Command)
// seal app and release lock // seal app and release lock
a.seal = seal a.seal = seal

View File

@ -58,7 +58,7 @@ func (seal *appSeal) shareDisplay(os linux.System) error {
return fmsg.WrapError(ErrXDisplay, return fmsg.WrapError(ErrXDisplay,
"DISPLAY is not set") "DISPLAY is not set")
} else { } else {
seal.sys.ChangeHosts(seal.sys.user.Username) seal.sys.ChangeHosts("#" + seal.sys.user.us)
seal.sys.bwrap.SetEnv[display] = d seal.sys.bwrap.SetEnv[display] = d
seal.sys.bwrap.Bind("/tmp/.X11-unix", "/tmp/.X11-unix") seal.sys.bwrap.Bind("/tmp/.X11-unix", "/tmp/.X11-unix")
} }

View File

@ -16,12 +16,12 @@ const (
func (seal *appSeal) shareSystem() { func (seal *appSeal) shareSystem() {
// ensure Share (e.g. `/tmp/fortify.%d`) // ensure Share (e.g. `/tmp/fortify.%d`)
// acl is unnecessary as this directory is world executable // acl is unnecessary as this directory is world executable
seal.sys.Ensure(seal.SharePath, 0701) seal.sys.Ensure(seal.SharePath, 0711)
// ensure process-specific share (e.g. `/tmp/fortify.%d/%s`) // ensure process-specific share (e.g. `/tmp/fortify.%d/%s`)
// acl is unnecessary as this directory is world executable // acl is unnecessary as this directory is world executable
seal.share = path.Join(seal.SharePath, seal.id) seal.share = path.Join(seal.SharePath, seal.id)
seal.sys.Ephemeral(system.Process, seal.share, 0701) seal.sys.Ephemeral(system.Process, seal.share, 0711)
// ensure child tmpdir parent directory (e.g. `/tmp/fortify.%d/tmpdir`) // ensure child tmpdir parent directory (e.g. `/tmp/fortify.%d/tmpdir`)
targetTmpdirParent := path.Join(seal.SharePath, "tmpdir") targetTmpdirParent := path.Join(seal.SharePath, "tmpdir")
@ -29,7 +29,7 @@ func (seal *appSeal) shareSystem() {
seal.sys.UpdatePermType(system.User, targetTmpdirParent, acl.Execute) seal.sys.UpdatePermType(system.User, targetTmpdirParent, acl.Execute)
// ensure child tmpdir (e.g. `/tmp/fortify.%d/tmpdir/%d`) // ensure child tmpdir (e.g. `/tmp/fortify.%d/tmpdir/%d`)
targetTmpdir := path.Join(targetTmpdirParent, seal.sys.user.Uid) targetTmpdir := path.Join(targetTmpdirParent, seal.sys.user.as)
seal.sys.Ensure(targetTmpdir, 01700) seal.sys.Ensure(targetTmpdir, 01700)
seal.sys.UpdatePermType(system.User, targetTmpdir, acl.Read, acl.Write, acl.Execute) seal.sys.UpdatePermType(system.User, targetTmpdir, acl.Read, acl.Write, acl.Execute)
seal.sys.bwrap.Bind(targetTmpdir, "/tmp", false, true) seal.sys.bwrap.Bind(targetTmpdir, "/tmp", false, true)
@ -49,15 +49,21 @@ func (seal *appSeal) sharePasswd(os linux.System) {
// generate /etc/passwd // generate /etc/passwd
passwdPath := path.Join(seal.share, "passwd") passwdPath := path.Join(seal.share, "passwd")
username := "chronos" username := "chronos"
if seal.sys.user.Username != "" { if seal.sys.user.username != "" {
username = seal.sys.user.Username username = seal.sys.user.username
seal.sys.bwrap.SetEnv["USER"] = seal.sys.user.Username
} }
homeDir := "/var/empty" homeDir := "/var/empty"
if seal.sys.user.HomeDir != "" { if seal.sys.user.home != "" {
homeDir = seal.sys.user.HomeDir homeDir = seal.sys.user.home
seal.sys.bwrap.SetEnv["HOME"] = seal.sys.user.HomeDir
} }
// bind home directory
seal.sys.bwrap.Bind(seal.sys.user.data, homeDir, false, true)
seal.sys.bwrap.Chdir = homeDir
seal.sys.bwrap.SetEnv["USER"] = username
seal.sys.bwrap.SetEnv["HOME"] = homeDir
passwd := username + ":x:" + seal.sys.mappedIDString + ":" + seal.sys.mappedIDString + ":Fortify:" + homeDir + ":" + sh + "\n" passwd := username + ":x:" + seal.sys.mappedIDString + ":" + seal.sys.mappedIDString + ":Fortify:" + homeDir + ":" + sh + "\n"
seal.sys.Write(passwdPath, passwd) seal.sys.Write(passwdPath, passwd)

View File

@ -41,19 +41,13 @@ func (a *app) Start() error {
} }
} }
// select command builder
var commandBuilder shim.CommandBuilder
switch a.seal.launchOption {
case LaunchMethodSudo:
commandBuilder = a.commandBuilderSudo
case LaunchMethodMachineCtl:
commandBuilder = a.commandBuilderMachineCtl
default:
panic("unreachable")
}
// construct shim manager // construct shim manager
a.shim = shim.New(a.seal.toolPath, uint32(a.seal.sys.UID()), path.Join(a.seal.share, "shim"), a.seal.wl, a.shim = shim.New(
uint32(a.seal.sys.UID()),
a.seal.sys.user.as,
a.seal.sys.user.supp,
path.Join(a.seal.share, "shim"),
a.seal.wl,
&shim0.Payload{ &shim0.Payload{
Argv: a.seal.command, Argv: a.seal.command,
Exec: shimExec, Exec: shimExec,
@ -62,9 +56,6 @@ func (a *app) Start() error {
Verbose: fmsg.Verbose(), Verbose: fmsg.Verbose(),
}, },
// checkPid is impossible at the moment since there is no reliable way to obtain shim's pid
// this feature is disabled here until sudo is replaced by fortify suid wrapper
false,
) )
// startup will go ahead, commit system setup // startup will go ahead, commit system setup
@ -73,7 +64,7 @@ func (a *app) Start() error {
} }
a.seal.sys.needRevert = true a.seal.sys.needRevert = true
if startTime, err := a.shim.Start(commandBuilder); err != nil { if startTime, err := a.shim.Start(); err != nil {
return err return err
} else { } else {
// shim start and setup success, create process state // shim start and setup success, create process state
@ -81,7 +72,6 @@ func (a *app) Start() error {
PID: a.shim.Unwrap().Process.Pid, PID: a.shim.Unwrap().Process.Pid,
Command: a.seal.command, Command: a.seal.command,
Capability: a.seal.et, Capability: a.seal.et,
Method: method[a.seal.launchOption],
Argv: a.shim.Unwrap().Args, Argv: a.shim.Unwrap().Args,
Time: *startTime, Time: *startTime,
} }
@ -166,8 +156,13 @@ func (a *app) Wait() (int, error) {
// failure prior to process start // failure prior to process start
r = 255 r = 255
} else { } else {
wait := make(chan error, 1)
go func() { wait <- cmd.Wait() }()
select {
// wait for process and resolve exit code // wait for process and resolve exit code
if err := cmd.Wait(); err != nil { case err := <-wait:
if err != nil {
var exitError *exec.ExitError var exitError *exec.ExitError
if !errors.As(err, &exitError) { if !errors.As(err, &exitError) {
// should be unreachable // should be unreachable
@ -180,6 +175,16 @@ func (a *app) Wait() (int, error) {
r = cmd.ProcessState.ExitCode() r = cmd.ProcessState.ExitCode()
} }
fmsg.VPrintf("process %d exited with exit code %d", cmd.Process.Pid, r) fmsg.VPrintf("process %d exited with exit code %d", cmd.Process.Pid, r)
// alternative exit path when kill was unsuccessful
case err := <-a.shim.WaitFallback():
r = 255
if err != nil {
fmsg.Printf("cannot terminate shim on faulted setup: %v", err)
} else {
fmsg.VPrintln("alternative exit path selected")
}
}
} }
// child process exited, resume output // child process exited, resume output
@ -250,10 +255,17 @@ func (a *app) Wait() (int, error) {
} }
} }
if a.shim.Unwrap() == nil {
fmsg.VPrintln("fault before shim start")
} else {
a.shim.AbortWait(errors.New("shim exited")) a.shim.AbortWait(errors.New("shim exited"))
}
if a.seal.sys.needRevert {
if err := a.seal.sys.Revert(ec); err != nil { if err := a.seal.sys.Revert(ec); err != nil {
return err.(RevertCompoundError) return err.(RevertCompoundError)
} }
}
return nil return nil
}() }()

View File

@ -1,8 +1,6 @@
package app package app
import ( import (
"os/user"
"git.ophivana.moe/security/fortify/dbus" "git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/linux" "git.ophivana.moe/security/fortify/internal/linux"
@ -18,7 +16,7 @@ type appSealSys struct {
// default formatted XDG_RUNTIME_DIR of User // default formatted XDG_RUNTIME_DIR of User
runtime string runtime string
// target user sealed from config // target user sealed from config
user *user.User user appUser
// mapped uid and gid in user namespace // mapped uid and gid in user namespace
mappedID int mappedID int
@ -32,6 +30,28 @@ type appSealSys struct {
// protected by upstream mutex // 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
}
// shareAll calls all share methods in sequence // shareAll calls all share methods in sequence
func (seal *appSeal) shareAll(bus [2]*dbus.Config, os linux.System) error { func (seal *appSeal) shareAll(bus [2]*dbus.Config, os linux.System) error {
if seal.shared { if seal.shared {

View File

@ -33,16 +33,17 @@ func dequeue() {
// queue submits ops to msgbuf but drops messages // queue submits ops to msgbuf but drops messages
// when the buffer is full and dequeue is withholding // when the buffer is full and dequeue is withholding
func queue(op dOp) { func queue(op dOp) {
queueSync.Add(1)
select { select {
case msgbuf <- op: case msgbuf <- op:
queueSync.Add(1)
default: default:
// send the op anyway if not withholding // send the op anyway if not withholding
// as dequeue will get to it eventually // as dequeue will get to it eventually
if !wstate.Load() { if !wstate.Load() {
queueSync.Add(1)
msgbuf <- op msgbuf <- op
} else { } else {
queueSync.Done()
// increment dropped message count // increment dropped message count
dropped.Add(1) dropped.Add(1)
} }
@ -56,9 +57,10 @@ func Exit(code int) {
os.Exit(code) os.Exit(code)
} }
func Withhold() { func Suspend() {
dequeueOnce.Do(dequeue) dequeueOnce.Do(dequeue)
if wstate.CompareAndSwap(false, true) { if wstate.CompareAndSwap(false, true) {
queueSync.Wait()
withhold <- struct{}{} withhold <- struct{}{}
} }
} }

View File

@ -22,8 +22,8 @@ type System interface {
LookPath(file string) (string, error) LookPath(file string) (string, error)
// Executable provides [os.Executable]. // Executable provides [os.Executable].
Executable() (string, error) Executable() (string, error)
// Lookup provides [user.Lookup]. // LookupGroup provides [user.LookupGroup].
Lookup(username string) (*user.User, error) LookupGroup(name string) (*user.Group, error)
// ReadDir provides [os.ReadDir]. // ReadDir provides [os.ReadDir].
ReadDir(name string) ([]fs.DirEntry, error) ReadDir(name string) ([]fs.DirEntry, error)
// Stat provides [os.Stat]. // Stat provides [os.Stat].
@ -35,10 +35,10 @@ type System interface {
// Stdout provides [os.Stdout]. // Stdout provides [os.Stdout].
Stdout() io.Writer Stdout() io.Writer
// FshimPath returns an absolute path to the fshim binary.
FshimPath() string
// Paths returns a populated [Paths] struct. // Paths returns a populated [Paths] struct.
Paths() Paths Paths() Paths
// Uid invokes fsu and returns target uid.
Uid(aid int) (int, error)
// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html // SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html
SdBooted() bool SdBooted() bool
} }

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"os/user" "os/user"
"strconv"
"sync" "sync"
"git.ophivana.moe/security/fortify/internal" "git.ophivana.moe/security/fortify/internal"
@ -21,8 +22,12 @@ type Std struct {
sdBooted bool sdBooted bool
sdBootedOnce sync.Once sdBootedOnce sync.Once
fshim string uidOnce sync.Once
fshimOnce sync.Once uidCopy map[int]struct {
uid int
err error
}
uidMu sync.RWMutex
} }
func (s *Std) Geteuid() int { return os.Geteuid() } func (s *Std) Geteuid() int { return os.Geteuid() }
@ -30,7 +35,7 @@ func (s *Std) LookupEnv(key string) (string, bool) { return os.LookupEnv(
func (s *Std) TempDir() string { return os.TempDir() } func (s *Std) TempDir() string { return os.TempDir() }
func (s *Std) LookPath(file string) (string, error) { return exec.LookPath(file) } func (s *Std) LookPath(file string) (string, error) { return exec.LookPath(file) }
func (s *Std) Executable() (string, error) { return os.Executable() } func (s *Std) Executable() (string, error) { return os.Executable() }
func (s *Std) Lookup(username string) (*user.User, error) { return user.Lookup(username) } func (s *Std) LookupGroup(name string) (*user.Group, error) { return user.LookupGroup(name) }
func (s *Std) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) } func (s *Std) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
func (s *Std) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) } func (s *Std) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
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) }
@ -39,23 +44,53 @@ func (s *Std) Stdout() io.Writer { return os.Stdout }
const xdgRuntimeDir = "XDG_RUNTIME_DIR" const xdgRuntimeDir = "XDG_RUNTIME_DIR"
func (s *Std) FshimPath() string {
s.fshimOnce.Do(func() {
p, ok := internal.Path(internal.Fshim)
if !ok {
fmsg.Fatal("invalid fshim path, this copy of fortify is not compiled correctly")
}
s.fshim = p
})
return s.fshim
}
func (s *Std) Paths() Paths { func (s *Std) Paths() Paths {
s.pathsOnce.Do(func() { CopyPaths(s, &s.paths) }) s.pathsOnce.Do(func() { CopyPaths(s, &s.paths) })
return s.paths return s.paths
} }
func (s *Std) Uid(aid int) (int, error) {
s.uidOnce.Do(func() {
s.uidCopy = make(map[int]struct {
uid int
err error
})
})
s.uidMu.RLock()
if u, ok := s.uidCopy[aid]; ok {
s.uidMu.RUnlock()
return u.uid, u.err
}
s.uidMu.RUnlock()
s.uidMu.Lock()
defer s.uidMu.Unlock()
u := struct {
uid int
err error
}{}
defer func() { s.uidCopy[aid] = u }()
u.uid = -1
if fsu, ok := internal.Check(internal.Fsu); !ok {
fmsg.Fatal("invalid fsu path, this copy of fshim is not compiled correctly")
panic("unreachable")
} else {
cmd := exec.Command(fsu)
cmd.Path = fsu
cmd.Stderr = os.Stderr // pass through fatal messages
cmd.Env = []string{"FORTIFY_APP_ID=" + strconv.Itoa(aid)}
cmd.Dir = "/"
var p []byte
if p, u.err = cmd.Output(); u.err == nil {
u.uid, u.err = strconv.Atoi(string(p))
}
return u.uid, u.err
}
}
func (s *Std) SdBooted() bool { func (s *Std) SdBooted() bool {
s.sdBootedOnce.Do(func() { s.sdBooted = copySdBooted() }) s.sdBootedOnce.Do(func() { s.sdBooted = copySdBooted() })
return s.sdBooted return s.sdBooted

View File

@ -4,7 +4,6 @@ import "path"
var ( var (
Fsu = compPoison Fsu = compPoison
Fshim = compPoison
Finit = compPoison Finit = compPoison
) )

View File

@ -67,10 +67,10 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
// write header when initialising // write header when initialising
if !fmsg.Verbose() { if !fmsg.Verbose() {
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tUptime\tEnablements\tMethod\tCommand") _, _ = fmt.Fprintln(*w, "\tPID\tApp\tUptime\tEnablements\tCommand")
} else { } else {
// argv is emitted in body when verbose // argv is emitted in body when verbose
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv") _, _ = fmt.Fprintln(*w, "\tPID\tApp\tArgv")
} }
} }
@ -96,13 +96,13 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
} }
if !fmsg.Verbose() { if !fmsg.Verbose() {
_, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\t%s\t%s\t%s\n", _, _ = fmt.Fprintf(*w, "\t%d\t%s\t%s\t%s\t%s\n",
s.path[len(s.path)-1], state.PID, now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), state.Method, state.PID, s.path[len(s.path)-1], now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "),
state.Command) state.Command)
} else { } else {
// emit argv instead when verbose // emit argv instead when verbose
_, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\n", _, _ = fmt.Fprintf(*w, "\t%d\t%s\t%s\n",
s.path[len(s.path)-1], state.PID, state.Argv) state.PID, s.path[len(s.path)-1], state.Argv)
} }
} }

View File

@ -33,8 +33,6 @@ type State struct {
// capability enablements applied to child // capability enablements applied to child
Capability system.Enablements Capability system.Enablements
// user switch method
Method string
// full argv whe launching // full argv whe launching
Argv []string Argv []string
// process start time // process start time

83
main.go
View File

@ -5,6 +5,10 @@ import (
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"os/user"
"strconv"
"strings"
"sync"
"text/tabwriter" "text/tabwriter"
"git.ophivana.moe/security/fortify/dbus" "git.ophivana.moe/security/fortify/dbus"
@ -29,6 +33,20 @@ func init() {
var os = new(linux.Std) var os = new(linux.Std)
type gl []string
func (g *gl) String() string {
if g == nil {
return "<nil>"
}
return strings.Join(*g, " ")
}
func (g *gl) Set(v string) error {
*g = append(*g, v)
return nil
}
func main() { func main() {
if err := internal.PR_SET_DUMPABLE__SUID_DUMP_DISABLE(); err != nil { if err := internal.PR_SET_DUMPABLE__SUID_DUMP_DISABLE(); err != nil {
fmsg.Printf("cannot set SUID_DUMP_DISABLE: %s", err) fmsg.Printf("cannot set SUID_DUMP_DISABLE: %s", err)
@ -135,10 +153,11 @@ func main() {
mpris bool mpris bool
dbusVerbose bool dbusVerbose bool
aid int
groups gl
homeDir string
userName string userName string
enablements [system.ELen]bool enablements [system.ELen]bool
launchMethodText string
) )
set.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults") set.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults")
@ -147,29 +166,71 @@ func main() {
set.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available") set.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available")
set.BoolVar(&dbusVerbose, "dbus-log", false, "Force logging in the D-Bus proxy") set.BoolVar(&dbusVerbose, "dbus-log", false, "Force logging in the D-Bus proxy")
set.StringVar(&userName, "u", "chronos", "Passwd name of user to run as") set.IntVar(&aid, "a", 0, "Fortify application ID")
set.Var(&groups, "g", "Groups inherited by the app process")
set.StringVar(&homeDir, "d", "os", "Application home directory")
set.StringVar(&userName, "u", "chronos", "Passwd name within sandbox")
set.BoolVar(&enablements[system.EWayland], "wayland", false, "Share Wayland socket") set.BoolVar(&enablements[system.EWayland], "wayland", false, "Share Wayland socket")
set.BoolVar(&enablements[system.EX11], "X", false, "Share X11 socket and allow connection") set.BoolVar(&enablements[system.EX11], "X", false, "Share X11 socket and allow connection")
set.BoolVar(&enablements[system.EDBus], "dbus", false, "Proxy D-Bus connection") set.BoolVar(&enablements[system.EDBus], "dbus", false, "Proxy D-Bus connection")
set.BoolVar(&enablements[system.EPulse], "pulse", false, "Share PulseAudio socket and cookie") set.BoolVar(&enablements[system.EPulse], "pulse", false, "Share PulseAudio socket and cookie")
methodHelpString := "Method of launching the child process, can be one of \"sudo\""
if os.SdBooted() {
methodHelpString += ", \"systemd\""
}
set.StringVar(&launchMethodText, "method", "sudo", methodHelpString)
// Ignore errors; set is set for ExitOnError. // Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:]) _ = set.Parse(args[1:])
// initialise config from flags // initialise config from flags
config := &app.Config{ config := &app.Config{
ID: dbusID, ID: dbusID,
User: userName,
Command: set.Args(), Command: set.Args(),
Method: launchMethodText,
} }
if aid < 0 || aid > 9999 {
fmsg.Fatalf("aid %d out of range", aid)
panic("unreachable")
}
// resolve home/username from os when flag is unset
var (
passwd *user.User
passwdOnce sync.Once
passwdFunc = func() {
var us string
if uid, err := os.Uid(aid); err != nil {
fmsg.Fatalf("cannot obtain uid from fsu: %v", err)
} else {
us = strconv.Itoa(uid)
}
if u, err := user.LookupId(us); err != nil {
fmsg.VPrintf("cannot look up uid %s", us)
passwd = &user.User{
Uid: us,
Gid: us,
Username: "chronos",
Name: "Fortify",
HomeDir: "/var/empty",
}
} else {
passwd = u
}
}
)
if homeDir == "os" {
passwdOnce.Do(passwdFunc)
homeDir = passwd.HomeDir
}
if userName == "chronos" {
passwdOnce.Do(passwdFunc)
userName = passwd.Username
}
config.Confinement.AppID = aid
config.Confinement.Groups = groups
config.Confinement.Outer = homeDir
config.Confinement.Username = userName
// enablements from flags // enablements from flags
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ { for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if enablements[i] { if enablements[i] {

406
nixos.nix
View File

@ -7,12 +7,13 @@
let let
inherit (lib) inherit (lib)
types
mkOption
mkEnableOption
mkIf mkIf
mkDefault
mapAttrs mapAttrs
mapAttrsToList mapAttrsToList
mergeAttrsList
imap1
foldr
foldlAttrs foldlAttrs
optional optional
optionals optionals
@ -22,263 +23,59 @@ let
in in
{ {
options = { imports = [ ./options.nix ];
environment.fortify = {
enable = mkEnableOption "fortify";
target = mkOption {
default = { };
type =
let
inherit (types)
str
enum
bool
package
anything
submodule
listOf
attrsOf
nullOr
functionTo
;
in
attrsOf (submodule {
options = {
packages = mkOption {
type = listOf package;
default = [ ];
description = ''
List of extra packages to install via home-manager.
'';
};
launchers = mkOption {
type = attrsOf (submodule {
options = {
id = mkOption {
type = nullOr str;
default = null;
description = ''
Freedesktop application ID.
'';
};
command = mkOption {
type = nullOr str;
default = null;
description = ''
Command to run as the target user.
Setting this to null will default command to wrapper name.
'';
};
method = mkOption {
type = enum [
"simple"
"sudo"
"systemd"
];
default = "systemd";
description = ''
Launch method for the sandboxed program.
'';
};
dbus = {
session = mkOption {
type = nullOr (functionTo anything);
default = null;
description = ''
D-Bus session bus custom configuration.
Setting this to null will enable built-in defaults.
'';
};
system = mkOption {
type = nullOr anything;
default = null;
description = ''
D-Bus system bus custom configuration.
Setting this to null will disable the system bus proxy.
'';
};
};
env = mkOption {
type = nullOr (attrsOf str);
default = null;
description = ''
Environment variables to set for the initial process in the sandbox.
'';
};
nix = mkEnableOption ''
Whether to allow nix daemon connections from within sandbox.
'';
userns = mkEnableOption ''
Whether to allow userns within sandbox.
'';
useRealUid = mkEnableOption ''
Whether to map to fortify's real UID within the sandbox.
'';
net =
mkEnableOption ''
Whether to allow network access within sandbox.
''
// {
default = true;
};
gpu = mkOption {
type = nullOr bool;
default = null;
description = ''
Target process GPU and driver access.
Setting this to null will enable GPU whenever X or Wayland is enabled.
'';
};
dev = mkEnableOption ''
Whether to allow access to all devices within sandbox.
'';
extraPaths = mkOption {
type = listOf anything;
default = [ ];
description = ''
Extra paths to make available inside the sandbox.
'';
};
capability = {
wayland = mkOption {
type = bool;
default = true;
description = ''
Whether to share the Wayland socket.
'';
};
x11 = mkOption {
type = bool;
default = false;
description = ''
Whether to share the X11 socket and allow connection.
'';
};
dbus = mkOption {
type = bool;
default = true;
description = ''
Whether to proxy D-Bus.
'';
};
pulse = mkOption {
type = bool;
default = true;
description = ''
Whether to share the PulseAudio socket and cookie.
'';
};
};
share = mkOption {
type = nullOr package;
default = null;
description = ''
Package containing share files.
Setting this to null will default package name to wrapper name.
'';
};
};
});
default = { };
};
persistence = mkOption {
type = submodule {
options = {
directories = mkOption {
type = listOf anything;
default = [ ];
};
files = mkOption {
type = listOf anything;
default = [ ];
};
};
};
description = ''
Per-user state passed to github:nix-community/impermanence.
'';
};
extraConfig = mkOption {
type = anything;
default = { };
description = "Extra home-manager configuration.";
};
};
});
};
package = mkOption {
type = types.package;
default = pkgs.callPackage ./package.nix { };
description = "Package providing fortify.";
};
user = mkOption {
type = types.str;
description = "Privileged user account.";
};
stateDir = mkOption {
type = types.str;
description = ''
The path to persistent storage where per-user state should be stored.
'';
};
};
};
config = mkIf cfg.enable { config = mkIf cfg.enable {
environment.persistence.${cfg.stateDir}.users = mapAttrs (_: target: target.persistence) cfg.target; security.wrappers.fsu = {
source = "${cfg.package}/libexec/fsu";
setuid = true;
owner = "root";
setgid = true;
group = "root";
};
home-manager.users = environment.etc = {
mapAttrs (_: target: target.extraConfig // { home.packages = target.packages; }) cfg.target fsurc = {
// { mode = "0400";
${cfg.user}.home.packages = text = foldlAttrs (
acc: username: fid:
"${toString config.users.users.${username}.uid} ${toString fid}\n" + acc
) "" cfg.users;
};
userdb.source = pkgs.runCommand "fortify-userdb" { } ''
${cfg.package}/libexec/fuserdb -o $out ${
foldlAttrs (
acc: username: fid:
acc + " ${username}:${toString fid}"
) "-s /run/current-system/sw/bin/nologin -d ${cfg.stateDir}" cfg.users
}
'';
};
services.userdbd.enable = mkDefault true;
home-manager =
let let
wrap = privPackages = mapAttrs (username: fid: {
user: launchers: home.packages =
mapAttrsToList ( let
name: launcher: # aid 0 is reserved
with launcher.capability; wrappers = imap1 (
aid: app:
let let
extendDBusDefault = id: ext: { extendDBusDefault = id: ext: {
filter = true; filter = true;
talk = [ "org.freedesktop.Notifications" ] ++ ext.talk; talk = [ "org.freedesktop.Notifications" ] ++ ext.talk;
own = own =
(optionals (launcher.id != null) [ (optionals (app.id != null) [
"${id}.*" "${id}.*"
"org.mpris.MediaPlayer2.${id}.*" "org.mpris.MediaPlayer2.${id}.*"
]) ])
++ ext.own; ++ ext.own;
call = {
"org.freedesktop.portal.*" = "*"; inherit (ext) call broadcast;
} // ext.call;
broadcast = {
"org.freedesktop.portal.*" = "@/org/freedesktop/portal/*";
} // ext.broadcast;
}; };
dbusConfig = dbusConfig =
let let
@ -291,34 +88,41 @@ in
in in
{ {
session_bus = session_bus =
if launcher.dbus.session != null then if app.dbus.session != null then
(launcher.dbus.session (extendDBusDefault launcher.id)) (app.dbus.session (extendDBusDefault app.id))
else else
(extendDBusDefault launcher.id default); (extendDBusDefault app.id default);
system_bus = launcher.dbus.system; system_bus = app.dbus.system;
}; };
command = if launcher.command == null then name else launcher.command; command = if app.command == null then app.name else app.command;
script = if app.script == null then ("exec " + command + " $@") else app.script;
enablements = enablements =
with app.capability;
(if wayland then 1 else 0) (if wayland then 1 else 0)
+ (if x11 then 2 else 0) + (if x11 then 2 else 0)
+ (if dbus then 4 else 0) + (if dbus then 4 else 0)
+ (if pulse then 8 else 0); + (if pulse then 8 else 0);
conf = { conf = {
inherit (launcher) id method; inherit (app) id;
inherit user;
command = [ command = [
"/run/current-system/sw/bin/zsh" (pkgs.writeScript "${app.name}-start" ''
(pkgs.writeShellScript "${name}-start" ("exec " + command + " $@")) #!${pkgs.zsh}${pkgs.zsh.shellPath}
${script}
'')
]; ];
confinement = { confinement = {
app_id = aid;
inherit (app) groups;
username = "u${toString fid}_a${toString aid}";
home = "${cfg.stateDir}/${toString fid}/${toString aid}";
sandbox = { sandbox = {
inherit (launcher) inherit (app)
userns userns
net net
dev dev
env env
; ;
use_real_uid = launcher.useRealUid; map_real_uid = app.mapRealUid;
filesystem = filesystem =
[ [
{ src = "/bin"; } { src = "/bin"; }
@ -345,24 +149,19 @@ in
src = "/sys/devices"; src = "/sys/devices";
require = false; require = false;
} }
{
src = "/home/${user}";
write = true;
require = true;
}
] ]
++ optionals launcher.nix [ ++ optionals app.nix [
{ src = "/nix/var"; } { src = "/nix/var"; }
{ src = "/var/db/nix-channels"; } { src = "/var/db/nix-channels"; }
] ]
++ optionals (if launcher.gpu != null then launcher.gpu else wayland || x11) [ ++ optionals (if app.gpu != null then app.gpu else app.capability.wayland || app.capability.x11) [
{ src = "/run/opengl-driver"; } { src = "/run/opengl-driver"; }
{ {
src = "/dev/dri"; src = "/dev/dri";
dev = true; dev = true;
} }
] ]
++ launcher.extraPaths; ++ app.extraPaths;
auto_etc = true; auto_etc = true;
override = [ "/var/run/nscd" ]; override = [ "/var/run/nscd" ];
}; };
@ -371,54 +170,49 @@ in
}; };
}; };
in in
pkgs.writeShellScriptBin name ( pkgs.writeShellScriptBin app.name ''
if launcher.method == "simple" then exec fortify app ${pkgs.writeText "fortify-${app.name}.json" (builtins.toJSON conf)} $@
'' ''
exec sudo -u ${user} -i ${command} $@ ) cfg.apps;
''
else
''
exec fortify app ${pkgs.writeText "fortify-${name}.json" (builtins.toJSON conf)} $@
''
)
) launchers;
in in
foldlAttrs ( foldr (
acc: user: target: app: acc:
acc
++ (foldlAttrs (
shares: name: launcher:
let let
pkg = if launcher.share != null then launcher.share else pkgs.${name}; pkg = if app.share != null then app.share else pkgs.${app.name};
link = source: "[ -d '${source}' ] && ln -sv '${source}' $out/share || true"; copy = source: "[ -d '${source}' ] && cp -Lrv '${source}' $out/share || true";
in in
shares optional (app.capability.wayland || app.capability.x11) (
++ pkgs.runCommand "${app.name}-share" { } ''
optional (launcher.method != "simple" && (launcher.capability.wayland || launcher.capability.x11))
(
pkgs.runCommand "${name}-share" { } ''
mkdir -p $out/share mkdir -p $out/share
${link "${pkg}/share/applications"} ${copy "${pkg}/share/applications"}
${link "${pkg}/share/icons"} ${copy "${pkg}/share/pixmaps"}
${link "${pkg}/share/man"} ${copy "${pkg}/share/icons"}
${copy "${pkg}/share/man"}
substituteInPlace $out/share/applications/* \
--replace-warn '${pkg}/bin/' "" \
--replace-warn '${pkg}/libexec/' ""
'' ''
) )
) (wrap user target.launchers) target.launchers) ++ acc
) [ cfg.package ] cfg.target; ) (wrappers ++ [ cfg.package ]) cfg.apps;
}; }) cfg.users;
security.polkit.extraConfig =
let
allowList = builtins.toJSON (mapAttrsToList (name: _: name) cfg.target);
in in
'' {
polkit.addRule(function(action, subject) { useUserPackages = false; # prevent users.users entries from being added
if (action.id == "org.freedesktop.machine1.host-shell" &&
${allowList}.indexOf(action.lookup("user")) > -1 && users = foldlAttrs (
subject.user == "${cfg.user}") { acc: _: fid:
return polkit.Result.YES; mergeAttrsList (
} # aid 0 is reserved
}); imap1 (aid: app: {
''; "u${toString fid}_a${toString aid}" = app.extraConfig // {
home.packages = app.packages;
};
}) cfg.apps
)
// acc
) privPackages cfg.users;
};
}; };
} }

531
options.md Normal file
View File

@ -0,0 +1,531 @@
## environment\.fortify\.enable
Whether to enable fortify\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.package
The fortify package to use\.
*Type:*
package
*Default:*
` <derivation fortify-0.1.0> `
## environment\.fortify\.apps
Declarative fortify apps\.
*Type:*
list of (submodule)
*Default:*
` [ ] `
## environment\.fortify\.apps\.\*\.packages
List of extra packages to install via home-manager\.
*Type:*
list of package
*Default:*
` [ ] `
## environment\.fortify\.apps\.\*\.capability\.dbus
Whether to proxy D-Bus\.
*Type:*
boolean
*Default:*
` true `
## environment\.fortify\.apps\.\*\.capability\.pulse
Whether to share the PulseAudio socket and cookie\.
*Type:*
boolean
*Default:*
` true `
## environment\.fortify\.apps\.\*\.capability\.wayland
Whether to share the Wayland socket\.
*Type:*
boolean
*Default:*
` true `
## environment\.fortify\.apps\.\*\.capability\.x11
Whether to share the X11 socket and allow connection\.
*Type:*
boolean
*Default:*
` false `
## environment\.fortify\.apps\.\*\.command
Command to run as the target user\.
Setting this to null will default command to launcher name\.
Has no effect when script is set\.
*Type:*
null or string
*Default:*
` null `
## environment\.fortify\.apps\.\*\.dbus\.session
D-Bus session bus custom configuration\.
Setting this to null will enable built-in defaults\.
*Type:*
null or (function that evaluates to a(n) anything)
*Default:*
` null `
## environment\.fortify\.apps\.\*\.dbus\.system
D-Bus system bus custom configuration\.
Setting this to null will disable the system bus proxy\.
*Type:*
null or anything
*Default:*
` null `
## environment\.fortify\.apps\.\*\.dev
Whether to enable access to all devices within the sandbox\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.apps\.\*\.env
Environment variables to set for the initial process in the sandbox\.
*Type:*
null or (attribute set of string)
*Default:*
` null `
## environment\.fortify\.apps\.\*\.extraConfig
Extra home-manager configuration\.
*Type:*
anything
*Default:*
` { } `
## environment\.fortify\.apps\.\*\.extraPaths
Extra paths to make available to the sandbox\.
*Type:*
list of anything
*Default:*
` [ ] `
## environment\.fortify\.apps\.\*\.gpu
Target process GPU and driver access\.
Setting this to null will enable GPU whenever X or Wayland is enabled\.
*Type:*
null or boolean
*Default:*
` null `
## environment\.fortify\.apps\.\*\.groups
List of groups to inherit from the privileged user\.
*Type:*
list of string
*Default:*
` [ ] `
## environment\.fortify\.apps\.\*\.id
Freedesktop application ID\.
*Type:*
null or string
*Default:*
` null `
## environment\.fortify\.apps\.\*\.mapRealUid
Whether to enable mapping to fortifys real UID within the sandbox\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.apps\.\*\.name
Name of the apps launcher script\.
*Type:*
string
## environment\.fortify\.apps\.\*\.net
Whether to enable network access within the sandbox\.
*Type:*
boolean
*Default:*
` true `
*Example:*
` true `
## environment\.fortify\.apps\.\*\.nix
Whether to enable nix daemon access within the sandbox\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.apps\.\*\.script
Application launch script\.
*Type:*
null or string
*Default:*
` null `
## environment\.fortify\.apps\.\*\.share
Package containing share files\.
Setting this to null will default package name to wrapper name\.
*Type:*
null or package
*Default:*
` null `
## environment\.fortify\.apps\.\*\.userns
Whether to enable userns within the sandbox\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.stateDir
The state directory where app home directories are stored\.
*Type:*
string
## environment\.fortify\.users
Users allowed to spawn fortify apps and their corresponding fortify fid\.
*Type:*
attribute set of integer between 0 and 99 (both inclusive)

214
options.nix Normal file
View File

@ -0,0 +1,214 @@
{ lib, pkgs, ... }:
let
inherit (lib) types mkOption mkEnableOption;
in
{
options = {
environment.fortify = {
enable = mkEnableOption "fortify";
package = mkOption {
type = types.package;
default = pkgs.callPackage ./package.nix { };
description = "The fortify package to use.";
};
users = mkOption {
type =
let
inherit (types) attrsOf ints;
in
attrsOf (ints.between 0 99);
description = ''
Users allowed to spawn fortify apps and their corresponding fortify fid.
'';
};
apps = mkOption {
type =
let
inherit (types)
str
enum
bool
package
anything
submodule
listOf
attrsOf
nullOr
functionTo
;
in
listOf (submodule {
options = {
name = mkOption {
type = str;
description = ''
Name of the app's launcher script.
'';
};
id = mkOption {
type = nullOr str;
default = null;
description = ''
Freedesktop application ID.
'';
};
packages = mkOption {
type = listOf package;
default = [ ];
description = ''
List of extra packages to install via home-manager.
'';
};
extraConfig = mkOption {
type = anything;
default = { };
description = ''
Extra home-manager configuration.
'';
};
script = mkOption {
type = nullOr str;
default = null;
description = ''
Application launch script.
'';
};
command = mkOption {
type = nullOr str;
default = null;
description = ''
Command to run as the target user.
Setting this to null will default command to launcher name.
Has no effect when script is set.
'';
};
groups = mkOption {
type = listOf str;
default = [ ];
description = ''
List of groups to inherit from the privileged user.
'';
};
dbus = {
session = mkOption {
type = nullOr (functionTo anything);
default = null;
description = ''
D-Bus session bus custom configuration.
Setting this to null will enable built-in defaults.
'';
};
system = mkOption {
type = nullOr anything;
default = null;
description = ''
D-Bus system bus custom configuration.
Setting this to null will disable the system bus proxy.
'';
};
};
env = mkOption {
type = nullOr (attrsOf str);
default = null;
description = ''
Environment variables to set for the initial process in the sandbox.
'';
};
nix = mkEnableOption "nix daemon access within the sandbox";
userns = mkEnableOption "userns within the sandbox";
mapRealUid = mkEnableOption "mapping to fortify's real UID within the sandbox";
dev = mkEnableOption "access to all devices within the sandbox";
net = mkEnableOption "network access within the sandbox" // {
default = true;
};
gpu = mkOption {
type = nullOr bool;
default = null;
description = ''
Target process GPU and driver access.
Setting this to null will enable GPU whenever X or Wayland is enabled.
'';
};
extraPaths = mkOption {
type = listOf anything;
default = [ ];
description = ''
Extra paths to make available to the sandbox.
'';
};
capability = {
wayland = mkOption {
type = bool;
default = true;
description = ''
Whether to share the Wayland socket.
'';
};
x11 = mkOption {
type = bool;
default = false;
description = ''
Whether to share the X11 socket and allow connection.
'';
};
dbus = mkOption {
type = bool;
default = true;
description = ''
Whether to proxy D-Bus.
'';
};
pulse = mkOption {
type = bool;
default = true;
description = ''
Whether to share the PulseAudio socket and cookie.
'';
};
};
share = mkOption {
type = nullOr package;
default = null;
description = ''
Package containing share files.
Setting this to null will default package name to wrapper name.
'';
};
};
});
default = [ ];
description = "Declarative fortify apps.";
};
stateDir = mkOption {
type = types.str;
description = ''
The state directory where app home directories are stored.
'';
};
};
};
}

View File

@ -10,7 +10,7 @@
buildGoModule rec { buildGoModule rec {
pname = "fortify"; pname = "fortify";
version = "0.1.0"; version = "0.2.1";
src = ./.; src = ./.;
vendorHash = null; vendorHash = null;
@ -29,13 +29,14 @@ buildGoModule rec {
"-s" "-s"
"-w" "-w"
"-X" "-X"
"main.Fmain=${placeholder "out"}/bin/.fortify-wrapped" "main.Fmain=${placeholder "out"}/libexec/fortify"
"-X"
"main.Fshim=${placeholder "out"}/libexec/fshim"
] ]
{ {
Version = "v${version}"; Version = "v${version}";
Fsu = "/run/wrappers/bin/fsu"; Fsu = "/run/wrappers/bin/fsu";
Fshim = "${placeholder "out"}/bin/.fshim"; Finit = "${placeholder "out"}/libexec/finit";
Finit = "${placeholder "out"}/bin/.finit";
}; };
buildInputs = [ buildInputs = [
@ -46,15 +47,15 @@ buildGoModule rec {
nativeBuildInputs = [ makeBinaryWrapper ]; nativeBuildInputs = [ makeBinaryWrapper ];
postInstall = '' postInstall = ''
wrapProgram $out/bin/${pname} --prefix PATH : ${ mkdir "$out/libexec"
mv "$out"/bin/* "$out/libexec/"
makeBinaryWrapper "$out/libexec/fortify" "$out/bin/fortify" \
--inherit-argv0 --prefix PATH : ${
lib.makeBinPath [ lib.makeBinPath [
bubblewrap bubblewrap
xdg-dbus-proxy xdg-dbus-proxy
] ]
} }
mv $out/bin/fsu $out/bin/.fsu
mv $out/bin/fshim $out/bin/.fshim
mv $out/bin/finit $out/bin/.finit
''; '';
} }