Compare commits

..

No commits in common. "master" and "fpkg" have entirely different histories.
master ... fpkg

169 changed files with 4510 additions and 5951 deletions

View File

@ -20,5 +20,5 @@ jobs:
uses: https://gitea.com/actions/release-action@main uses: https://gitea.com/actions/release-action@main
with: with:
files: |- files: |-
result/hakurei-** result/fortify-**
api_key: '${{secrets.RELEASE_TOKEN}}' api_key: '${{secrets.RELEASE_TOKEN}}'

View File

@ -5,25 +5,42 @@ on:
- pull_request - pull_request
jobs: jobs:
hakurei: fortify:
name: Hakurei name: Fortify
runs-on: nix runs-on: nix
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Run NixOS test - name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.hakurei run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.fortify
- name: Upload test output - name: Upload test output
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: "hakurei-vm-output" name: "fortify-vm-output"
path: result/*
retention-days: 1
fpkg:
name: Fpkg
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.fpkg
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "fpkg-vm-output"
path: result/* path: result/*
retention-days: 1 retention-days: 1
race: race:
name: Hakurei (race detector) name: Data race detector
runs-on: nix runs-on: nix
steps: steps:
- name: Checkout - name: Checkout
@ -35,69 +52,16 @@ jobs:
- name: Upload test output - name: Upload test output
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: "hakurei-race-vm-output" name: "fortify-race-vm-output"
path: result/*
retention-days: 1
sandbox:
name: Sandbox
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.sandbox
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "sandbox-vm-output"
path: result/*
retention-days: 1
sandbox-race:
name: Sandbox (race detector)
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.sandbox-race
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "sandbox-race-vm-output"
path: result/*
retention-days: 1
planterette:
name: Planterette
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.planterette
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "planterette-vm-output"
path: result/* path: result/*
retention-days: 1 retention-days: 1
check: check:
name: Flake checks name: Flake checks
needs: needs:
- hakurei - fortify
- fpkg
- race - race
- sandbox
- sandbox-race
- planterette
runs-on: nix runs-on: nix
steps: steps:
- name: Checkout - name: Checkout
@ -116,15 +80,15 @@ jobs:
- name: Build for test - name: Build for test
id: build-test id: build-test
run: >- run: >-
export HAKUREI_REV="$(git rev-parse --short HEAD)" && export FORTIFY_REV="$(git rev-parse --short HEAD)" &&
sed -i.old 's/version = /version = "0.0.0-'$HAKUREI_REV'"; # version = /' package.nix && sed -i.old 's/version = /version = "0.0.0-'$FORTIFY_REV'"; # version = /' package.nix &&
nix build --print-out-paths --print-build-logs .#dist && nix build --print-out-paths --print-build-logs .#dist &&
mv package.nix.old package.nix && mv package.nix.old package.nix &&
echo "rev=$HAKUREI_REV" >> $GITHUB_OUTPUT echo "rev=$FORTIFY_REV" >> $GITHUB_OUTPUT
- name: Upload test build - name: Upload test build
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: "hakurei-${{ steps.build-test.outputs.rev }}" name: "fortify-${{ steps.build-test.outputs.rev }}"
path: result/* path: result/*
retention-days: 1 retention-days: 1

View File

@ -1 +0,0 @@
This port is solely for releasing to the github mirror and serves no purpose during development.

View File

@ -1,46 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Create release
runs-on: ubuntu-latest
permissions:
packages: write
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: nixbuild/nix-quick-install-action@v32
with:
nix_conf: |
keep-env-derivations = true
keep-outputs = true
- name: Restore and cache Nix store
uses: nix-community/cache-nix-action@v6
with:
primary-key: build-${{ runner.os }}-${{ hashFiles('**/*.nix') }}
restore-prefixes-first-match: build-${{ runner.os }}-
gc-max-store-size-linux: 1G
purge: true
purge-prefixes: build-${{ runner.os }}-
purge-created: 60
purge-primary-key: never
- name: Build for release
run: nix build --print-out-paths --print-build-logs .#dist
- name: Release
uses: softprops/action-gh-release@v2
with:
files: |-
result/hakurei-**

View File

@ -1,48 +0,0 @@
name: Test
on:
- push
jobs:
dist:
name: Create distribution
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: nixbuild/nix-quick-install-action@v32
with:
nix_conf: |
keep-env-derivations = true
keep-outputs = true
- name: Restore and cache Nix store
uses: nix-community/cache-nix-action@v6
with:
primary-key: build-${{ runner.os }}-${{ hashFiles('**/*.nix') }}
restore-prefixes-first-match: build-${{ runner.os }}-
gc-max-store-size-linux: 1G
purge: true
purge-prefixes: build-${{ runner.os }}-
purge-created: 60
purge-primary-key: never
- name: Build for test
id: build-test
run: >-
export HAKUREI_REV="$(git rev-parse --short HEAD)" &&
sed -i.old 's/version = /version = "0.0.0-'$HAKUREI_REV'"; # version = /' package.nix &&
nix build --print-out-paths --print-build-logs .#dist &&
mv package.nix.old package.nix &&
echo "rev=$HAKUREI_REV" >> $GITHUB_OUTPUT
- name: Upload test build
uses: actions/upload-artifact@v4
with:
name: "hakurei-${{ steps.build-test.outputs.rev }}"
path: result/*
retention-days: 1

4
.gitignore vendored
View File

@ -5,7 +5,7 @@
*.so *.so
*.dylib *.dylib
*.pkg *.pkg
/hakurei /fortify
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
@ -29,4 +29,4 @@ go.work.sum
security-context-v1-protocol.* security-context-v1-protocol.*
# release # release
/dist/hakurei-* /dist/fortify-*

105
README.md
View File

@ -1,79 +1,77 @@
<p align="center"> Fortify
<a href="https://git.gensokyo.uk/security/hakurei"> =======
<picture>
<img src="https://basement.gensokyo.uk/images/yukari1.png" width="200px" alt="Yukari">
</picture>
</a>
</p>
<p align="center"> [![Go Reference](https://pkg.go.dev/badge/git.gensokyo.uk/security/fortify.svg)](https://pkg.go.dev/git.gensokyo.uk/security/fortify)
<a href="https://pkg.go.dev/git.gensokyo.uk/security/hakurei"><img src="https://pkg.go.dev/badge/git.gensokyo.uk/security/hakurei.svg" alt="Go Reference" /></a> [![Go Report Card](https://goreportcard.com/badge/git.gensokyo.uk/security/fortify)](https://goreportcard.com/report/git.gensokyo.uk/security/fortify)
<a href="https://goreportcard.com/report/git.gensokyo.uk/security/hakurei"><img src="https://goreportcard.com/badge/git.gensokyo.uk/security/hakurei" alt="Go Report Card" /></a>
</p>
Hakurei is a tool for running sandboxed graphical applications as dedicated subordinate users on the Linux kernel. Lets you run graphical applications as another user in a confined environment with a nice NixOS
It also implements [planterette (WIP)](cmd/planterette), a self-contained Android-like package manager with modern security features. module to configure target users and provide launchers and desktop files for your privileged user.
## NixOS Module usage Why would you want this?
The NixOS module currently requires home-manager to configure subordinate users. Full module documentation can be found [here](options.md). - It protects the desktop environment from applications.
- It protects applications from each other.
- It provides UID isolation on top of the standard application sandbox.
If you have a flakes-enabled nix environment, you can try out the tool by running:
```shell
nix run git+https://git.gensokyo.uk/security/fortify -- help
```
## Module usage
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
```nix ```nix
{ {
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
hakurei = { fortify = {
url = "git+https://git.gensokyo.uk/security/hakurei"; url = "git+https://git.gensokyo.uk/security/fortify";
# Optional but recommended to limit the size of your system closure. # Optional but recommended to limit the size of your system closure.
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
}; };
outputs = { self, nixpkgs, hakurei, ... }: outputs = { self, nixpkgs, fortify, ... }:
{ {
nixosConfigurations.hakurei = nixpkgs.lib.nixosSystem { nixosConfigurations.fortify = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; system = "x86_64-linux";
modules = [ modules = [
hakurei.nixosModules.hakurei fortify.nixosModules.fortify
]; ];
}; };
}; };
} }
``` ```
This adds the `environment.hakurei` option: This adds the `environment.fortify` option:
```nix ```nix
{ pkgs, ... }: { pkgs, ... }:
{ {
environment.hakurei = { environment.fortify = {
enable = true; enable = true;
stateDir = "/var/lib/hakurei"; stateDir = "/var/lib/persist/module/fortify";
users = { users = {
alice = 0; alice = 0;
nixos = 10; nixos = 10;
}; };
commonPaths = [ apps = [
{ {
src = "/sdcard";
write = true;
}
];
extraHomeConfig = {
home.stateVersion = "23.05";
};
apps = {
"org.chromium.Chromium" = {
name = "chromium"; name = "chromium";
identity = 1; id = "org.chromium.Chromium";
packages = [ pkgs.chromium ]; packages = [ pkgs.chromium ];
userns = true; userns = true;
mapRealUid = true; mapRealUid = true;
@ -106,20 +104,16 @@ This adds the `environment.hakurei` option:
broadcast = { }; broadcast = { };
}; };
}; };
}; }
{
"org.claws_mail.Claws-Mail" = {
name = "claws-mail"; name = "claws-mail";
identity = 2; id = "org.claws_mail.Claws-Mail";
packages = [ pkgs.claws-mail ]; packages = [ pkgs.claws-mail ];
gpu = false; gpu = false;
capability.pulse = false; capability.pulse = false;
}; }
{
"org.weechat" = {
name = "weechat"; name = "weechat";
identity = 3;
shareUid = true;
packages = [ pkgs.weechat ]; packages = [ pkgs.weechat ];
capability = { capability = {
wayland = false; wayland = false;
@ -127,12 +121,10 @@ This adds the `environment.hakurei` option:
dbus = true; dbus = true;
pulse = false; pulse = false;
}; };
}; }
{
"dev.vencord.Vesktop" = {
name = "discord"; name = "discord";
identity = 3; id = "dev.vencord.Vesktop";
shareUid = true;
packages = [ pkgs.vesktop ]; packages = [ pkgs.vesktop ];
share = pkgs.vesktop; share = pkgs.vesktop;
command = "vesktop --ozone-platform-hint=wayland"; command = "vesktop --ozone-platform-hint=wayland";
@ -150,12 +142,9 @@ This adds the `environment.hakurei` option:
}; };
system.filter = true; system.filter = true;
}; };
}; }
{
"io.looking-glass" = {
name = "looking-glass-client"; name = "looking-glass-client";
identity = 4;
useCommonPaths = false;
groups = [ "plugdev" ]; groups = [ "plugdev" ];
extraPaths = [ extraPaths = [
{ {
@ -166,8 +155,8 @@ This adds the `environment.hakurei` option:
extraConfig = { extraConfig = {
programs.looking-glass-client.enable = true; programs.looking-glass-client.enable = true;
}; };
}; }
}; ];
}; };
} }
``` ```

View File

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

View File

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

View File

@ -23,7 +23,7 @@ func Update(name string, uid int, perms ...Perm) error {
p = &perms[0] p = &perms[0]
} }
r, err := C.hakurei_acl_update_file_by_uid( r, err := C.f_acl_update_file_by_uid(
C.CString(name), C.CString(name),
C.uid_t(uid), C.uid_t(uid),
(*C.acl_perm_t)(p), (*C.acl_perm_t)(p),

View File

@ -7,7 +7,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"git.gensokyo.uk/security/hakurei/acl" "git.gensokyo.uk/security/fortify/acl"
) )
const testFileName = "acl.test" const testFileName = "acl.test"

149
cmd/fpkg/app.go Normal file
View File

@ -0,0 +1,149 @@
package main
import (
"encoding/json"
"log"
"os"
"path"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
"git.gensokyo.uk/security/fortify/system"
)
type appInfo struct {
Name string `json:"name"`
Version string `json:"version"`
// passed through to [fst.Config]
ID string `json:"id"`
// passed through to [fst.Config]
AppID int `json:"app_id"`
// passed through to [fst.Config]
Groups []string `json:"groups,omitempty"`
// passed through to [fst.Config]
Devel bool `json:"devel,omitempty"`
// passed through to [fst.Config]
Userns bool `json:"userns,omitempty"`
// passed through to [fst.Config]
Net bool `json:"net,omitempty"`
// passed through to [fst.Config]
Dev bool `json:"dev,omitempty"`
// passed through to [fst.Config]
Tty bool `json:"tty,omitempty"`
// passed through to [fst.Config]
MapRealUID bool `json:"map_real_uid,omitempty"`
// passed through to [fst.Config]
DirectWayland bool `json:"direct_wayland,omitempty"`
// passed through to [fst.Config]
SystemBus *dbus.Config `json:"system_bus,omitempty"`
// passed through to [fst.Config]
SessionBus *dbus.Config `json:"session_bus,omitempty"`
// passed through to [fst.Config]
Enablements system.Enablement `json:"enablements"`
// passed through to [fst.Config]
Multiarch bool `json:"multiarch,omitempty"`
// passed through to [fst.Config]
Bluetooth bool `json:"bluetooth,omitempty"`
// allow gpu access within sandbox
GPU bool `json:"gpu"`
// store path to nixGL mesa wrappers
Mesa string `json:"mesa,omitempty"`
// store path to nixGL source
NixGL string `json:"nix_gl,omitempty"`
// store path to activate-and-exec script
Launcher string `json:"launcher"`
// store path to /run/current-system
CurrentSystem string `json:"current_system"`
// store path to home-manager activation package
ActivationPackage string `json:"activation_package"`
}
func (app *appInfo) toFst(pathSet *appPathSet, argv []string, flagDropShell bool) *fst.Config {
config := &fst.Config{
ID: app.ID,
Path: argv[0],
Args: argv,
Confinement: fst.ConfinementConfig{
AppID: app.AppID,
Groups: app.Groups,
Username: "fortify",
Inner: path.Join("/data/data", app.ID),
Outer: pathSet.homeDir,
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name),
Devel: app.Devel,
Userns: app.Userns,
Net: app.Net,
Dev: app.Dev,
Tty: app.Tty || flagDropShell,
MapRealUID: app.MapRealUID,
DirectWayland: app.DirectWayland,
Filesystem: []*fst.FilesystemConfig{
{Src: path.Join(pathSet.nixPath, "store"), Dst: "/nix/store", Must: true},
{Src: pathSet.metaPath, Dst: path.Join(fst.Tmp, "app"), Must: true},
{Src: "/etc/resolv.conf"},
{Src: "/sys/block"},
{Src: "/sys/bus"},
{Src: "/sys/class"},
{Src: "/sys/dev"},
{Src: "/sys/devices"},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(pathSet.cacheDir, "etc"),
AutoEtc: true,
},
ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
},
SystemBus: app.SystemBus,
SessionBus: app.SessionBus,
Enablements: app.Enablements,
},
}
if app.Multiarch {
config.Confinement.Sandbox.Seccomp |= seccomp.FlagMultiarch
}
if app.Bluetooth {
config.Confinement.Sandbox.Seccomp |= seccomp.FlagBluetooth
}
return config
}
func loadAppInfo(name string, beforeFail func()) *appInfo {
bundle := new(appInfo)
if f, err := os.Open(name); err != nil {
beforeFail()
log.Fatalf("cannot open bundle: %v", err)
} else if err = json.NewDecoder(f).Decode(&bundle); err != nil {
beforeFail()
log.Fatalf("cannot parse bundle metadata: %v", err)
} else if err = f.Close(); err != nil {
log.Printf("cannot close bundle metadata: %v", err)
// not fatal
}
if bundle.ID == "" {
beforeFail()
log.Fatal("application identifier must not be empty")
}
return bundle
}
func formatHostname(name string) string {
if h, err := os.Hostname(); err != nil {
log.Printf("cannot get hostname: %v", err)
return "fortify-" + name
} else {
return h + "-" + name
}
}

View File

@ -31,7 +31,7 @@
'', '',
id ? name, id ? name,
identity ? throw "identity is required", app_id ? throw "app_id is required",
groups ? [ ], groups ? [ ],
userns ? false, userns ? false,
net ? true, net ? true,
@ -57,7 +57,7 @@ let
modules = modules ++ [ modules = modules ++ [
{ {
home = { home = {
username = "hakurei"; username = "fortify";
homeDirectory = "/data/data/${id}"; homeDirectory = "/data/data/${id}";
stateVersion = "22.11"; stateVersion = "22.11";
}; };
@ -65,7 +65,7 @@ let
]; ];
}; };
launcher = writeScript "hakurei-${pname}" '' launcher = writeScript "fortify-${pname}" ''
#!${runtimeShell} -el #!${runtimeShell} -el
${script} ${script}
''; '';
@ -147,7 +147,7 @@ let
name name
version version
id id
identity app_id
launcher launcher
groups groups
userns userns

View File

@ -10,26 +10,39 @@ import (
"path" "path"
"syscall" "syscall"
"git.gensokyo.uk/security/hakurei/command" "git.gensokyo.uk/security/fortify/command"
"git.gensokyo.uk/security/hakurei/hst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/hakurei/internal" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/sandbox"
) )
const shellPath = "/run/current-system/sw/bin/bash" const shellPath = "/run/current-system/sw/bin/bash"
var ( var (
errSuccess = errors.New("success") errSuccess = errors.New("success")
std sys.State = new(sys.Std)
) )
func init() { func init() {
hlog.Prepare("planterette") fmsg.Prepare("fpkg")
if err := os.Setenv("SHELL", shellPath); err != nil { if err := os.Setenv("SHELL", shellPath); err != nil {
log.Fatalf("cannot set $SHELL: %v", err) log.Fatalf("cannot set $SHELL: %v", err)
} }
} }
func main() { func main() {
// early init path, skips root check and duplicate PR_SET_DUMPABLE
sandbox.TryArgv0(fmsg.Output{}, fmsg.Prepare, internal.InstallFmsg)
if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil {
log.Printf("cannot set SUID_DUMP_DISABLE: %s", err)
// not fatal: this program runs as the privileged user
}
if os.Geteuid() == 0 { if os.Geteuid() == 0 {
log.Fatal("this program must not run as root") log.Fatal("this program must not run as root")
} }
@ -42,9 +55,14 @@ func main() {
flagVerbose bool flagVerbose bool
flagDropShell bool flagDropShell bool
) )
c := command.New(os.Stderr, log.Printf, "planterette", func([]string) error { internal.InstallFmsg(flagVerbose); return nil }). c := command.New(os.Stderr, log.Printf, "fpkg", func([]string) error {
internal.InstallFmsg(flagVerbose)
return nil
}).
Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console"). Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console").
Flag(&flagDropShell, "s", command.BoolFlag(false), "Drop to a shell in place of next hakurei action") Flag(&flagDropShell, "s", command.BoolFlag(false), "Drop to a shell in place of next fortify action")
c.Command("shim", command.UsageInternal, func([]string) error { app.ShimMain(); return errSuccess })
{ {
var ( var (
@ -66,7 +84,7 @@ func main() {
} }
/* /*
Look up paths to programs started by planterette. Look up paths to programs started by fpkg.
This is done here to ease error handling as cleanup is not yet required. This is done here to ease error handling as cleanup is not yet required.
*/ */
@ -82,7 +100,7 @@ func main() {
*/ */
var workDir string var workDir string
if p, err := os.MkdirTemp("", "planterette.*"); err != nil { if p, err := os.MkdirTemp("", "fpkg.*"); err != nil {
log.Printf("cannot create temporary directory: %v", err) log.Printf("cannot create temporary directory: %v", err)
return err return err
} else { } else {
@ -139,19 +157,19 @@ func main() {
return errSuccess return errSuccess
} }
// identity determines uid // AppID determines uid
if a.Identity != bundle.Identity { if a.AppID != bundle.AppID {
cleanup() cleanup()
log.Printf("package %q identity %d differs from installed %d", log.Printf("package %q app id %d differs from installed %d",
pkgPath, bundle.Identity, a.Identity) pkgPath, bundle.AppID, a.AppID)
return syscall.EBADE return syscall.EBADE
} }
// sec: should compare version string // sec: should compare version string
hlog.Verbosef("installing application %q version %q over local %q", fmsg.Verbosef("installing application %q version %q over local %q",
bundle.ID, bundle.Version, a.Version) bundle.ID, bundle.Version, a.Version)
} else { } else {
hlog.Verbosef("application %q clean installation", bundle.ID) fmsg.Verbosef("application %q clean installation", bundle.ID)
// sec: should install credentials // sec: should install credentials
} }
@ -161,7 +179,7 @@ func main() {
withCacheDir(ctx, "install", []string{ withCacheDir(ctx, "install", []string{
// export inner bundle path in the environment // export inner bundle path in the environment
"export BUNDLE=" + hst.Tmp + "/bundle", "export BUNDLE=" + fst.Tmp + "/bundle",
// replace inner /etc // replace inner /etc
"mkdir -p etc", "mkdir -p etc",
"chmod -R +w etc", "chmod -R +w etc",
@ -200,7 +218,7 @@ func main() {
"rm -rf .local/state/{nix,home-manager}", "rm -rf .local/state/{nix,home-manager}",
// run activation script // run activation script
bundle.ActivationPackage + "/activate", bundle.ActivationPackage + "/activate",
}, false, func(config *hst.Config) *hst.Config { return config }, }, false, func(config *fst.Config) *fst.Config { return config },
bundle, pathSet, flagDropShellActivate, cleanup) bundle, pathSet, flagDropShellActivate, cleanup)
/* /*
@ -273,8 +291,8 @@ func main() {
"--out-link /nix/.nixGL/auto/vulkan " + "--out-link /nix/.nixGL/auto/vulkan " +
"--override-input nixpkgs path:/etc/nixpkgs " + "--override-input nixpkgs path:/etc/nixpkgs " +
"path:" + a.NixGL + "#nixVulkanNvidia", "path:" + a.NixGL + "#nixVulkanNvidia",
}, true, func(config *hst.Config) *hst.Config { }, true, func(config *fst.Config) *fst.Config {
config.Container.Filesystem = append(config.Container.Filesystem, []*hst.FilesystemConfig{ config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem, []*fst.FilesystemConfig{
{Src: "/etc/resolv.conf"}, {Src: "/etc/resolv.conf"},
{Src: "/sys/block"}, {Src: "/sys/block"},
{Src: "/sys/bus"}, {Src: "/sys/bus"},
@ -306,8 +324,8 @@ func main() {
*/ */
if a.GPU { if a.GPU {
config.Container.Filesystem = append(config.Container.Filesystem, config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem,
&hst.FilesystemConfig{Src: path.Join(pathSet.nixPath, ".nixGL"), Dst: path.Join(hst.Tmp, "nixGL")}) &fst.FilesystemConfig{Src: path.Join(pathSet.nixPath, ".nixGL"), Dst: path.Join(fst.Tmp, "nixGL")})
appendGPUFilesystem(config) appendGPUFilesystem(config)
} }
@ -323,9 +341,9 @@ func main() {
} }
c.MustParse(os.Args[1:], func(err error) { c.MustParse(os.Args[1:], func(err error) {
hlog.Verbosef("command returned %v", err) fmsg.Verbosef("command returned %v", err)
if errors.Is(err, errSuccess) { if errors.Is(err, errSuccess) {
hlog.BeforeExit() fmsg.BeforeExit()
os.Exit(0) os.Exit(0)
} }
}) })

View File

@ -8,8 +8,8 @@ import (
"strconv" "strconv"
"sync/atomic" "sync/atomic"
"git.gensokyo.uk/security/hakurei/hst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
var ( var (
@ -18,10 +18,10 @@ var (
func init() { func init() {
// dataHome // dataHome
if p, ok := os.LookupEnv("HAKUREI_DATA_HOME"); ok { if p, ok := os.LookupEnv("FORTIFY_DATA_HOME"); ok {
dataHome = p dataHome = p
} else { } else {
dataHome = "/var/lib/hakurei/" + strconv.Itoa(os.Getuid()) dataHome = "/var/lib/fortify/" + strconv.Itoa(os.Getuid())
} }
} }
@ -37,7 +37,7 @@ func lookPath(file string) string {
var beforeRunFail = new(atomic.Pointer[func()]) var beforeRunFail = new(atomic.Pointer[func()])
func mustRun(name string, arg ...string) { func mustRun(name string, arg ...string) {
hlog.Verbosef("spawning process: %q %q", name, arg) fmsg.Verbosef("spawning process: %q %q", name, arg)
cmd := exec.Command(name, arg...) cmd := exec.Command(name, arg...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
@ -71,8 +71,8 @@ func pathSetByApp(id string) *appPathSet {
return pathSet return pathSet
} }
func appendGPUFilesystem(config *hst.Config) { func appendGPUFilesystem(config *fst.Config) {
config.Container.Filesystem = append(config.Container.Filesystem, []*hst.FilesystemConfig{ config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem, []*fst.FilesystemConfig{
// flatpak commit 763a686d874dd668f0236f911de00b80766ffe79 // flatpak commit 763a686d874dd668f0236f911de00b80766ffe79
{Src: "/dev/dri", Device: true}, {Src: "/dev/dri", Device: true},
// mali // mali

28
cmd/fpkg/proc.go Normal file
View File

@ -0,0 +1,28 @@
package main
import (
"context"
"os"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
func mustRunApp(ctx context.Context, config *fst.Config, beforeFail func()) {
rs := new(fst.RunState)
a := app.MustNew(ctx, std)
if sa, err := a.Seal(config); err != nil {
fmsg.PrintBaseError(err, "cannot seal app:")
rs.ExitCode = 1
} else {
// this updates ExitCode
app.PrintRunStateErr(rs, sa.Run(rs))
}
if rs.ExitCode != 0 {
beforeFail()
os.Exit(rs.ExitCode)
}
}

View File

@ -50,13 +50,11 @@
]; ];
}; };
environment.hakurei = { environment.fortify = {
enable = true; enable = true;
stateDir = "/var/lib/hakurei"; stateDir = "/var/lib/fortify";
users.alice = 0; users.alice = 0;
extraHomeConfig = { home-manager = _: _: { home.stateVersion = "23.05"; };
home.stateVersion = "23.05";
};
}; };
} }

View File

@ -9,7 +9,7 @@ let
buildPackage = self.buildPackage.${system}; buildPackage = self.buildPackage.${system};
in in
nixosTest { nixosTest {
name = "planterette"; name = "fpkg";
nodes.machine = { nodes.machine = {
environment.etc = { environment.etc = {
"foot.pkg".source = callPackage ./foot.nix { inherit buildPackage; }; "foot.pkg".source = callPackage ./foot.nix { inherit buildPackage; };
@ -18,7 +18,7 @@ nixosTest {
imports = [ imports = [
./configuration.nix ./configuration.nix
self.nixosModules.hakurei self.nixosModules.fortify
self.inputs.home-manager.nixosModules.home-manager self.inputs.home-manager.nixosModules.home-manager
]; ];
}; };

View File

@ -10,7 +10,7 @@ buildPackage {
name = "foot"; name = "foot";
inherit (foot) version; inherit (foot) version;
identity = 2; app_id = 2;
id = "org.codeberg.dnkl.foot"; id = "org.codeberg.dnkl.foot";
modules = [ modules = [

View File

@ -47,50 +47,50 @@ def wait_for_window(pattern):
def collect_state_ui(name): def collect_state_ui(name):
swaymsg(f"exec hakurei ps > '/tmp/{name}.ps'") swaymsg(f"exec fortify ps > '/tmp/{name}.ps'")
machine.copy_from_vm(f"/tmp/{name}.ps", "") machine.copy_from_vm(f"/tmp/{name}.ps", "")
swaymsg(f"exec hakurei --json ps > '/tmp/{name}.json'") swaymsg(f"exec fortify --json ps > '/tmp/{name}.json'")
machine.copy_from_vm(f"/tmp/{name}.json", "") machine.copy_from_vm(f"/tmp/{name}.json", "")
machine.screenshot(name) machine.screenshot(name)
def check_state(name, enablements): def check_state(name, enablements):
instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei --json ps")) instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 fortify --json ps"))
if len(instances) != 1: if len(instances) != 1:
raise Exception(f"unexpected state length {len(instances)}") raise Exception(f"unexpected state length {len(instances)}")
instance = next(iter(instances.values())) instance = next(iter(instances.values()))
config = instance['config'] config = instance['config']
if len(config['args']) != 1 or not (config['args'][0].startswith("/nix/store/")) or f"hakurei-{name}-" not in (config['args'][0]): if len(config['args']) != 1 or not (config['args'][0].startswith("/nix/store/")) or f"fortify-{name}-" not in (config['args'][0]):
raise Exception(f"unexpected args {instance['config']['args']}") raise Exception(f"unexpected args {instance['config']['args']}")
if config['enablements'] != enablements: if config['confinement']['enablements'] != enablements:
raise Exception(f"unexpected enablements {instance['config']['enablements']}") raise Exception(f"unexpected enablements {instance['config']['confinement']['enablements']}")
start_all() start_all()
machine.wait_for_unit("multi-user.target") machine.wait_for_unit("multi-user.target")
# To check hakurei's version: # To check fortify's version:
print(machine.succeed("sudo -u alice -i hakurei version")) print(machine.succeed("sudo -u alice -i fortify version"))
# Wait for Sway to complete startup: # Wait for Sway to complete startup:
machine.wait_for_file("/run/user/1000/wayland-1") machine.wait_for_file("/run/user/1000/wayland-1")
machine.wait_for_file("/tmp/sway-ipc.sock") machine.wait_for_file("/tmp/sway-ipc.sock")
# Prepare planterette directory: # Prepare fpkg directory:
machine.succeed("install -dm 0700 -o alice -g users /var/lib/hakurei/1000") machine.succeed("install -dm 0700 -o alice -g users /var/lib/fortify/1000")
# Install planterette app: # Install fpkg app:
swaymsg("exec planterette -v install /etc/foot.pkg && touch /tmp/planterette-install-ok") swaymsg("exec fpkg -v install /etc/foot.pkg && touch /tmp/fpkg-install-done")
machine.wait_for_file("/tmp/planterette-install-ok") machine.wait_for_file("/tmp/fpkg-install-done")
# Start app (foot) with Wayland enablement: # Start app (foot) with Wayland enablement:
swaymsg("exec planterette -v start org.codeberg.dnkl.foot") swaymsg("exec fpkg -v start org.codeberg.dnkl.foot")
wait_for_window("hakurei@machine-foot") wait_for_window("fortify@machine-foot")
machine.send_chars("clear; wayland-info && touch /tmp/success-client\n") machine.send_chars("clear; wayland-info && touch /tmp/success-client\n")
machine.wait_for_file("/tmp/hakurei.1000/tmpdir/2/success-client") machine.wait_for_file("/tmp/fortify.1000/tmpdir/2/success-client")
collect_state_ui("app_wayland") collect_state_ui("app_wayland")
check_state("foot", 13) check_state("foot", 13)
# Verify acl on XDG_RUNTIME_DIR: # Verify acl on XDG_RUNTIME_DIR:
@ -104,5 +104,5 @@ machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/
swaymsg("exit", succeed=False) swaymsg("exit", succeed=False)
machine.wait_for_file("/tmp/sway-exit-ok") machine.wait_for_file("/tmp/sway-exit-ok")
# Print hakurei runDir contents: # Print fortify runDir contents:
print(machine.succeed("find /run/user/1000/hakurei")) print(machine.succeed("find /run/user/1000/fortify"))

108
cmd/fpkg/with.go Normal file
View File

@ -0,0 +1,108 @@
package main
import (
"context"
"path"
"strings"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
)
func withNixDaemon(
ctx context.Context,
action string, command []string, net bool, updateConfig func(config *fst.Config) *fst.Config,
app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func(),
) {
mustRunAppDropShell(ctx, updateConfig(&fst.Config{
ID: app.ID,
Path: shellPath,
Args: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " +
// start nix-daemon
"nix-daemon --store / & " +
// wait for socket to appear
"(while [ ! -S /nix/var/nix/daemon-socket/socket ]; do sleep 0.01; done) && " +
// create directory so nix stops complaining
"mkdir -p /nix/var/nix/profiles/per-user/root/channels && " +
strings.Join(command, " && ") +
// terminate nix-daemon
" && pkill nix-daemon",
},
Confinement: fst.ConfinementConfig{
AppID: app.AppID,
Username: "fortify",
Inner: path.Join("/data/data", app.ID),
Outer: pathSet.homeDir,
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name) + "-" + action,
Userns: true, // nix sandbox requires userns
Net: net,
Seccomp: seccomp.FlagMultiarch,
Tty: dropShell,
Filesystem: []*fst.FilesystemConfig{
{Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(pathSet.cacheDir, "etc"),
AutoEtc: true,
},
ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
},
},
}), dropShell, beforeFail)
}
func withCacheDir(
ctx context.Context,
action string, command []string, workDir string,
app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) {
mustRunAppDropShell(ctx, &fst.Config{
ID: app.ID,
Path: shellPath,
Args: []string{shellPath, "-lc", strings.Join(command, " && ")},
Confinement: fst.ConfinementConfig{
AppID: app.AppID,
Username: "nixos",
Inner: path.Join("/data/data", app.ID, "cache"),
Outer: pathSet.cacheDir, // this also ensures cacheDir via shim
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name) + "-" + action,
Seccomp: seccomp.FlagMultiarch,
Tty: dropShell,
Filesystem: []*fst.FilesystemConfig{
{Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true},
{Src: workDir, Dst: path.Join(fst.Tmp, "bundle"), Must: true},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(workDir, "etc"),
AutoEtc: true,
},
ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
{Path: workDir, Execute: true},
},
},
}, dropShell, beforeFail)
}
func mustRunAppDropShell(ctx context.Context, config *fst.Config, dropShell bool, beforeFail func()) {
if dropShell {
config.Args = []string{shellPath, "-l"}
mustRunApp(ctx, config, beforeFail)
beforeFail()
internal.Exit(0)
}
mustRunApp(ctx, config, beforeFail)
}

View File

@ -13,17 +13,17 @@ import (
) )
const ( const (
hsuConfFile = "/etc/hsurc" fsuConfFile = "/etc/fsurc"
envShim = "HAKUREI_SHIM" envShim = "FORTIFY_SHIM"
envAID = "HAKUREI_APP_ID" envAID = "FORTIFY_APP_ID"
envGroups = "HAKUREI_GROUPS" envGroups = "FORTIFY_GROUPS"
PR_SET_NO_NEW_PRIVS = 0x26 PR_SET_NO_NEW_PRIVS = 0x26
) )
func main() { func main() {
log.SetFlags(0) log.SetFlags(0)
log.SetPrefix("hsu: ") log.SetPrefix("fsu: ")
log.SetOutput(os.Stderr) log.SetOutput(os.Stderr)
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
@ -40,9 +40,9 @@ func main() {
if p, err := os.Readlink(pexe); err != nil { if p, err := os.Readlink(pexe); err != nil {
log.Fatalf("cannot read parent executable path: %v", err) log.Fatalf("cannot read parent executable path: %v", err)
} else if strings.HasSuffix(p, " (deleted)") { } else if strings.HasSuffix(p, " (deleted)") {
log.Fatal("hakurei executable has been deleted") log.Fatal("fortify executable has been deleted")
} else if p != mustCheckPath(hmain) { } else if p != mustCheckPath(fmain) && p != mustCheckPath(fpkg) {
log.Fatal("this program must be started by hakurei") log.Fatal("this program must be started by fortify")
} else { } else {
toolPath = p toolPath = p
} }
@ -52,27 +52,27 @@ func main() {
// aid // aid
uid := 1000000 uid := 1000000
// refuse to run if hsurc is not protected correctly // refuse to run if fsurc is not protected correctly
if s, err := os.Stat(hsuConfFile); err != nil { if s, err := os.Stat(fsuConfFile); err != nil {
log.Fatal(err) log.Fatal(err)
} else if s.Mode().Perm() != 0400 { } else if s.Mode().Perm() != 0400 {
log.Fatal("bad hsurc perm") log.Fatal("bad fsurc perm")
} else if st := s.Sys().(*syscall.Stat_t); st.Uid != 0 || st.Gid != 0 { } else if st := s.Sys().(*syscall.Stat_t); st.Uid != 0 || st.Gid != 0 {
log.Fatal("hsurc must be owned by uid 0") log.Fatal("fsurc must be owned by uid 0")
} }
// authenticate before accepting user input // authenticate before accepting user input
if f, err := os.Open(hsuConfFile); err != nil { if f, err := os.Open(fsuConfFile); err != nil {
log.Fatal(err) log.Fatal(err)
} else if fid, ok := mustParseConfig(f, puid); !ok { } else if fid, ok := mustParseConfig(f, puid); !ok {
log.Fatalf("uid %d is not in the hsurc file", puid) log.Fatalf("uid %d is not in the fsurc file", puid)
} else { } else {
uid += fid * 10000 uid += fid * 10000
} }
// allowed aid range 0 to 9999 // allowed aid range 0 to 9999
if as, ok := os.LookupEnv(envAID); !ok { if as, ok := os.LookupEnv(envAID); !ok {
log.Fatal("HAKUREI_APP_ID not set") log.Fatal("FORTIFY_APP_ID not set")
} else if aid, err := parseUint32Fast(as); err != nil || aid < 0 || aid > 9999 { } else if aid, err := parseUint32Fast(as); err != nil || aid < 0 || aid > 9999 {
log.Fatal("invalid aid") log.Fatal("invalid aid")
} else { } else {
@ -82,12 +82,12 @@ func main() {
// pass through setup fd to shim // pass through setup fd to shim
var shimSetupFd string var shimSetupFd string
if s, ok := os.LookupEnv(envShim); !ok { if s, ok := os.LookupEnv(envShim); !ok {
// hakurei requests target uid // fortify requests target uid
// print resolved uid and exit // print resolved uid and exit
fmt.Print(uid) fmt.Print(uid)
os.Exit(0) os.Exit(0)
} else if len(s) != 1 || s[0] > '9' || s[0] < '3' { } else if len(s) != 1 || s[0] > '9' || s[0] < '3' {
log.Fatal("HAKUREI_SHIM holds an invalid value") log.Fatal("FORTIFY_SHIM holds an invalid value")
} else { } else {
shimSetupFd = s shimSetupFd = s
} }
@ -124,7 +124,7 @@ func main() {
panic("uid out of bounds") panic("uid out of bounds")
} }
// careful! users in the allowlist is effectively allowed to drop groups via hsu // 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)
@ -138,7 +138,7 @@ func main() {
if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 { 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()) log.Fatalf("cannot set no_new_privs flag: %s", errno.Error())
} }
if err := syscall.Exec(toolPath, []string{"hakurei", "shim"}, []string{envShim + "=" + shimSetupFd}); err != nil { if err := syscall.Exec(toolPath, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupFd}); err != nil {
log.Fatalf("cannot start shim: %v", err) log.Fatalf("cannot start shim: %v", err)
} }

30
cmd/fsu/package.nix Normal file
View File

@ -0,0 +1,30 @@
{
lib,
buildGoModule,
fortify ? abort "fortify package required",
}:
buildGoModule {
pname = "${fortify.pname}-fsu";
inherit (fortify) version;
src = ./.;
inherit (fortify) vendorHash;
CGO_ENABLED = 0;
preBuild = ''
go mod init fsu >& /dev/null
'';
ldflags =
lib.attrsets.foldlAttrs
(
ldflags: name: value:
ldflags ++ [ "-X main.${name}=${value}" ]
)
[ "-s -w" ]
{
fmain = "${fortify}/libexec/fortify";
fpkg = "${fortify}/libexec/fpkg";
};
}

View File

@ -50,7 +50,7 @@ func parseConfig(r io.Reader, puid int) (fid int, ok bool, err error) {
if ok { if ok {
// allowed fid range 0 to 99 // allowed fid range 0 to 99
if fid, err = parseUint32Fast(lf[1]); err != nil || fid < 0 || fid > 99 { if fid, err = parseUint32Fast(lf[1]); err != nil || fid < 0 || fid > 99 {
return -1, false, fmt.Errorf("invalid identity on line %d", line) return -1, false, fmt.Errorf("invalid fortify uid on line %d", line)
} }
return return
} }

View File

@ -65,7 +65,7 @@ func Test_parseConfig(t *testing.T) {
{"empty", 0, -1, "", ``}, {"empty", 0, -1, "", ``},
{"invalid field", 0, -1, "invalid entry on line 1", `9`}, {"invalid field", 0, -1, "invalid entry on line 1", `9`},
{"invalid puid", 0, -1, "invalid parent uid on line 1", `f 9`}, {"invalid puid", 0, -1, "invalid parent uid on line 1", `f 9`},
{"invalid fid", 1000, -1, "invalid identity on line 1", `1000 f`}, {"invalid fid", 1000, -1, "invalid fortify uid on line 1", `1000 f`},
{"match", 1000, 0, "", `1000 0`}, {"match", 1000, 0, "", `1000 0`},
} }

View File

@ -8,7 +8,8 @@ import (
const compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID" const compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
var ( var (
hmain = compPoison fmain = compPoison
fpkg = compPoison
) )
func mustCheckPath(p string) string { func mustCheckPath(p string) string {

View File

@ -1,23 +0,0 @@
{
lib,
buildGoModule,
hakurei ? abort "hakurei package required",
}:
buildGoModule {
pname = "${hakurei.pname}-hsu";
inherit (hakurei) version;
src = ./.;
inherit (hakurei) vendorHash;
env.CGO_ENABLED = 0;
preBuild = ''
go mod init hsu >& /dev/null
'';
ldflags = lib.attrsets.foldlAttrs (
ldflags: name: value:
ldflags ++ [ "-X main.${name}=${value}" ]
) [ "-s -w" ] { hmain = "${hakurei}/libexec/hakurei"; };
}

View File

@ -1,154 +0,0 @@
package main
import (
"encoding/json"
"log"
"os"
"path"
"git.gensokyo.uk/security/hakurei/dbus"
"git.gensokyo.uk/security/hakurei/hst"
"git.gensokyo.uk/security/hakurei/sandbox/seccomp"
"git.gensokyo.uk/security/hakurei/system"
)
type appInfo struct {
Name string `json:"name"`
Version string `json:"version"`
// passed through to [hst.Config]
ID string `json:"id"`
// passed through to [hst.Config]
Identity int `json:"identity"`
// passed through to [hst.Config]
Groups []string `json:"groups,omitempty"`
// passed through to [hst.Config]
Devel bool `json:"devel,omitempty"`
// passed through to [hst.Config]
Userns bool `json:"userns,omitempty"`
// passed through to [hst.Config]
Net bool `json:"net,omitempty"`
// passed through to [hst.Config]
Device bool `json:"dev,omitempty"`
// passed through to [hst.Config]
Tty bool `json:"tty,omitempty"`
// passed through to [hst.Config]
MapRealUID bool `json:"map_real_uid,omitempty"`
// passed through to [hst.Config]
DirectWayland bool `json:"direct_wayland,omitempty"`
// passed through to [hst.Config]
SystemBus *dbus.Config `json:"system_bus,omitempty"`
// passed through to [hst.Config]
SessionBus *dbus.Config `json:"session_bus,omitempty"`
// passed through to [hst.Config]
Enablements system.Enablement `json:"enablements"`
// passed through to [hst.Config]
Multiarch bool `json:"multiarch,omitempty"`
// passed through to [hst.Config]
Bluetooth bool `json:"bluetooth,omitempty"`
// allow gpu access within sandbox
GPU bool `json:"gpu"`
// store path to nixGL mesa wrappers
Mesa string `json:"mesa,omitempty"`
// store path to nixGL source
NixGL string `json:"nix_gl,omitempty"`
// store path to activate-and-exec script
Launcher string `json:"launcher"`
// store path to /run/current-system
CurrentSystem string `json:"current_system"`
// store path to home-manager activation package
ActivationPackage string `json:"activation_package"`
}
func (app *appInfo) toFst(pathSet *appPathSet, argv []string, flagDropShell bool) *hst.Config {
config := &hst.Config{
ID: app.ID,
Path: argv[0],
Args: argv,
Enablements: app.Enablements,
SystemBus: app.SystemBus,
SessionBus: app.SessionBus,
DirectWayland: app.DirectWayland,
Username: "hakurei",
Shell: shellPath,
Data: pathSet.homeDir,
Dir: path.Join("/data/data", app.ID),
Identity: app.Identity,
Groups: app.Groups,
Container: &hst.ContainerConfig{
Hostname: formatHostname(app.Name),
Devel: app.Devel,
Userns: app.Userns,
Net: app.Net,
Device: app.Device,
Tty: app.Tty || flagDropShell,
MapRealUID: app.MapRealUID,
Filesystem: []*hst.FilesystemConfig{
{Src: path.Join(pathSet.nixPath, "store"), Dst: "/nix/store", Must: true},
{Src: pathSet.metaPath, Dst: path.Join(hst.Tmp, "app"), Must: true},
{Src: "/etc/resolv.conf"},
{Src: "/sys/block"},
{Src: "/sys/bus"},
{Src: "/sys/class"},
{Src: "/sys/dev"},
{Src: "/sys/devices"},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(pathSet.cacheDir, "etc"),
AutoEtc: true,
},
ExtraPerms: []*hst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
},
}
if app.Multiarch {
config.Container.Seccomp |= seccomp.FilterMultiarch
}
if app.Bluetooth {
config.Container.Seccomp |= seccomp.FilterBluetooth
}
return config
}
func loadAppInfo(name string, beforeFail func()) *appInfo {
bundle := new(appInfo)
if f, err := os.Open(name); err != nil {
beforeFail()
log.Fatalf("cannot open bundle: %v", err)
} else if err = json.NewDecoder(f).Decode(&bundle); err != nil {
beforeFail()
log.Fatalf("cannot parse bundle metadata: %v", err)
} else if err = f.Close(); err != nil {
log.Printf("cannot close bundle metadata: %v", err)
// not fatal
}
if bundle.ID == "" {
beforeFail()
log.Fatal("application identifier must not be empty")
}
return bundle
}
func formatHostname(name string) string {
if h, err := os.Hostname(); err != nil {
log.Printf("cannot get hostname: %v", err)
return "hakurei-" + name
} else {
return h + "-" + name
}
}

View File

@ -1,60 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"os"
"os/exec"
"git.gensokyo.uk/security/hakurei/hst"
"git.gensokyo.uk/security/hakurei/internal"
"git.gensokyo.uk/security/hakurei/internal/hlog"
)
var hakureiPath = internal.MustHakureiPath()
func mustRunApp(ctx context.Context, config *hst.Config, beforeFail func()) {
var (
cmd *exec.Cmd
st io.WriteCloser
)
if r, w, err := os.Pipe(); err != nil {
beforeFail()
log.Fatalf("cannot pipe: %v", err)
} else {
if hlog.Load() {
cmd = exec.CommandContext(ctx, hakureiPath, "-v", "app", "3")
} else {
cmd = exec.CommandContext(ctx, hakureiPath, "app", "3")
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.ExtraFiles = []*os.File{r}
st = w
}
go func() {
if err := json.NewEncoder(st).Encode(config); err != nil {
beforeFail()
log.Fatalf("cannot send configuration: %v", err)
}
}()
if err := cmd.Start(); err != nil {
beforeFail()
log.Fatalf("cannot start hakurei: %v", err)
}
if err := cmd.Wait(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
beforeFail()
internal.Exit(exitError.ExitCode())
} else {
beforeFail()
log.Fatalf("cannot wait: %v", err)
}
}
}

View File

@ -1,114 +0,0 @@
package main
import (
"context"
"path"
"strings"
"git.gensokyo.uk/security/hakurei/hst"
"git.gensokyo.uk/security/hakurei/internal"
"git.gensokyo.uk/security/hakurei/sandbox/seccomp"
)
func withNixDaemon(
ctx context.Context,
action string, command []string, net bool, updateConfig func(config *hst.Config) *hst.Config,
app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func(),
) {
mustRunAppDropShell(ctx, updateConfig(&hst.Config{
ID: app.ID,
Path: shellPath,
Args: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " +
// start nix-daemon
"nix-daemon --store / & " +
// wait for socket to appear
"(while [ ! -S /nix/var/nix/daemon-socket/socket ]; do sleep 0.01; done) && " +
// create directory so nix stops complaining
"mkdir -p /nix/var/nix/profiles/per-user/root/channels && " +
strings.Join(command, " && ") +
// terminate nix-daemon
" && pkill nix-daemon",
},
Username: "hakurei",
Shell: shellPath,
Data: pathSet.homeDir,
Dir: path.Join("/data/data", app.ID),
ExtraPerms: []*hst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
},
Identity: app.Identity,
Container: &hst.ContainerConfig{
Hostname: formatHostname(app.Name) + "-" + action,
Userns: true, // nix sandbox requires userns
Net: net,
Seccomp: seccomp.FilterMultiarch,
Tty: dropShell,
Filesystem: []*hst.FilesystemConfig{
{Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(pathSet.cacheDir, "etc"),
AutoEtc: true,
},
}), dropShell, beforeFail)
}
func withCacheDir(
ctx context.Context,
action string, command []string, workDir string,
app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) {
mustRunAppDropShell(ctx, &hst.Config{
ID: app.ID,
Path: shellPath,
Args: []string{shellPath, "-lc", strings.Join(command, " && ")},
Username: "nixos",
Shell: shellPath,
Data: pathSet.cacheDir, // this also ensures cacheDir via shim
Dir: path.Join("/data/data", app.ID, "cache"),
ExtraPerms: []*hst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
{Path: workDir, Execute: true},
},
Identity: app.Identity,
Container: &hst.ContainerConfig{
Hostname: formatHostname(app.Name) + "-" + action,
Seccomp: seccomp.FilterMultiarch,
Tty: dropShell,
Filesystem: []*hst.FilesystemConfig{
{Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true},
{Src: workDir, Dst: path.Join(hst.Tmp, "bundle"), Must: true},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(workDir, "etc"),
AutoEtc: true,
},
}, dropShell, beforeFail)
}
func mustRunAppDropShell(ctx context.Context, config *hst.Config, dropShell bool, beforeFail func()) {
if dropShell {
config.Args = []string{shellPath, "-l"}
mustRunApp(ctx, config, beforeFail)
beforeFail()
internal.Exit(0)
}
mustRunApp(ctx, config, beforeFail)
}

View File

@ -3,7 +3,7 @@ package command_test
import ( import (
"testing" "testing"
"git.gensokyo.uk/security/hakurei/command" "git.gensokyo.uk/security/fortify/command"
) )
func TestBuild(t *testing.T) { func TestBuild(t *testing.T) {

View File

@ -10,7 +10,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.gensokyo.uk/security/hakurei/command" "git.gensokyo.uk/security/fortify/command"
) )
func TestParse(t *testing.T) { func TestParse(t *testing.T) {

82
comp/_fortify Normal file
View File

@ -0,0 +1,82 @@
#compdef fortify
_fortify_app() {
__fortify_files
return $?
}
_fortify_run() {
_arguments \
'--id[App ID, leave empty to disable security context app_id]:id' \
'-a[Fortify application ID]: :_numbers' \
'-g[Groups inherited by the app process]: :_groups' \
'-d[Application home directory]: :_files -/' \
'-u[Passwd name within sandbox]: :_users' \
'--wayland[Share Wayland socket]' \
'-X[Share X11 socket and allow connection]' \
'--dbus[Proxy D-Bus connection]' \
'--pulse[Share PulseAudio socket and cookie]' \
'--dbus-config[Path to D-Bus proxy config file]: :_files -g "*.json"' \
'--dbus-system[Path to system D-Bus proxy config file]: :_files -g "*.json"' \
'--mpris[Allow owning MPRIS D-Bus path]' \
'--dbus-log[Force logging in the D-Bus proxy]'
}
_fortify_ps() {
_arguments \
'--short[Print instance id]'
}
_fortify_show() {
_alternative \
'instances:domains:__fortify_instances' \
'files:files:__fortify_files'
}
__fortify_files() {
_files -g "*.(json|ftfy)"
return $?
}
__fortify_instances() {
local -a out
shift -p
out=( ${(f)"$(_call_program commands fortify ps --short 2>&1)"} )
if (( $#out == 0 )); then
_message "No active instances"
else
_describe "active instances" out
fi
return $?
}
(( $+functions[_fortify_commands] )) || _fortify_commands()
{
local -a _fortify_cmds
_fortify_cmds=(
"app:Launch app defined by the specified config file"
"run:Configure and start a permissive default sandbox"
"show:Show the contents of an app configuration"
"ps:List active apps and their state"
"version:Show fortify version"
"license:Show full license text"
"template:Produce a config template"
"help:Show help message"
)
if (( CURRENT == 1 )); then
_describe -t commands 'action' _fortify_cmds || compadd "$@"
else
local curcontext="$curcontext"
cmd="${${_fortify_cmds[(r)$words[1]:*]%%:*}}"
if (( $+functions[_fortify_$cmd] )); then
_fortify_$cmd
else
_message "no more options"
fi
fi
}
_arguments -C \
'-v[Verbose output]' \
'--json[Format output in JSON when applicable]' \
'*::fortify command:_fortify_commands'

View File

@ -5,7 +5,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"git.gensokyo.uk/security/hakurei/dbus" "git.gensokyo.uk/security/fortify/dbus"
) )
func TestParse(t *testing.T) { func TestParse(t *testing.T) {

View File

@ -5,12 +5,8 @@ import (
"errors" "errors"
"io" "io"
"os" "os"
"strings"
) )
// ProxyPair is an upstream dbus address and a downstream socket path.
type ProxyPair [2]string
type Config struct { type Config struct {
// See set 'see' policy for NAME (--see=NAME) // See set 'see' policy for NAME (--see=NAME)
See []string `json:"see"` See []string `json:"see"`
@ -28,62 +24,7 @@ type Config struct {
Filter bool `json:"filter"` Filter bool `json:"filter"`
} }
func (c *Config) interfaces(yield func(string) bool) { func (c *Config) Args(bus [2]string) (args []string) {
for _, iface := range c.See {
if !yield(iface) {
return
}
}
for _, iface := range c.Talk {
if !yield(iface) {
return
}
}
for _, iface := range c.Own {
if !yield(iface) {
return
}
}
for iface := range c.Call {
if !yield(iface) {
return
}
}
for iface := range c.Broadcast {
if !yield(iface) {
return
}
}
}
func (c *Config) checkInterfaces(segment string) error {
for iface := range c.interfaces {
/*
xdg-dbus-proxy fails without output when this condition is not met:
char *dot = strrchr (filter->interface, '.');
if (dot != NULL)
{
*dot = 0;
if (strcmp (dot + 1, "*") != 0)
filter->member = g_strdup (dot + 1);
}
trim ".*" since they are removed before searching for '.':
if (g_str_has_suffix (name, ".*"))
{
name[strlen (name) - 2] = 0;
wildcard = TRUE;
}
*/
if strings.IndexByte(strings.TrimSuffix(iface, ".*"), '.') == -1 {
return &BadInterfaceError{iface, segment}
}
}
return nil
}
func (c *Config) Args(bus ProxyPair) (args []string) {
argc := 2 + len(c.See) + len(c.Talk) + len(c.Own) + len(c.Call) + len(c.Broadcast) argc := 2 + len(c.See) + len(c.Talk) + len(c.Own) + len(c.Call) + len(c.Broadcast)
if c.Log { if c.Log {
argc++ argc++
@ -119,7 +60,9 @@ func (c *Config) Args(bus ProxyPair) (args []string) {
return return
} }
func (c *Config) Load(r io.Reader) error { return json.NewDecoder(r).Decode(&c) } func (c *Config) Load(r io.Reader) error {
return json.NewDecoder(r).Decode(&c)
}
// NewConfigFromFile opens the target config file at path and parses its contents into *Config. // NewConfigFromFile opens the target config file at path and parses its contents into *Config.
func NewConfigFromFile(path string) (*Config, error) { func NewConfigFromFile(path string) (*Config, error) {

View File

@ -9,7 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.gensokyo.uk/security/hakurei/dbus" "git.gensokyo.uk/security/fortify/dbus"
) )
func TestConfig_Args(t *testing.T) { func TestConfig_Args(t *testing.T) {

View File

@ -5,36 +5,77 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"syscall"
"testing" "testing"
"time" "time"
"git.gensokyo.uk/security/hakurei/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/hakurei/internal" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/hakurei/sandbox" "git.gensokyo.uk/security/fortify/sandbox"
) )
func TestFinalise(t *testing.T) { func TestNew(t *testing.T) {
if _, err := dbus.Finalise(dbus.ProxyPair{}, dbus.ProxyPair{}, nil, nil); !errors.Is(err, syscall.EBADE) { for _, tc := range [][2][2]string{
t.Errorf("Finalise: error = %v, want %v", {
err, syscall.EBADE) {"unix:path=/run/user/1971/bus", "/tmp/fortify.1971/1ca5d183ef4c99e74c3e544715f32702/bus"},
{"unix:path=/run/dbus/system_bus_socket", "/tmp/fortify.1971/1ca5d183ef4c99e74c3e544715f32702/system_bus_socket"},
},
{
{"unix:path=/run/user/1971/bus", "/tmp/fortify.1971/881ac3796ff3f3bf0a773824383187a0/bus"},
{"unix:path=/run/dbus/system_bus_socket", "/tmp/fortify.1971/881ac3796ff3f3bf0a773824383187a0/system_bus_socket"},
},
{
{"unix:path=/run/user/1971/bus", "/tmp/fortify.1971/3d1a5084520ef79c0c6a49a675bac701/bus"},
{"unix:path=/run/dbus/system_bus_socket", "/tmp/fortify.1971/3d1a5084520ef79c0c6a49a675bac701/system_bus_socket"},
},
{
{"unix:path=/run/user/1971/bus", "/tmp/fortify.1971/2a1639bab712799788ea0ff7aa280c35/bus"},
{"unix:path=/run/dbus/system_bus_socket", "/tmp/fortify.1971/2a1639bab712799788ea0ff7aa280c35/system_bus_socket"},
},
} {
t.Run("create instance for "+tc[0][0]+" and "+tc[1][0], func(t *testing.T) {
if got := dbus.New(tc[0], tc[1]); !got.CompareTestNew(tc[0], tc[1]) {
t.Errorf("New(%q, %q) = %v",
tc[0], tc[1],
got)
}
})
}
}
func TestProxy_Seal(t *testing.T) {
t.Run("double seal panic", func(t *testing.T) {
defer func() {
want := "dbus proxy sealed twice"
if r := recover(); r != want {
t.Errorf("Seal: panic = %q, want %q",
r, want)
}
}()
p := dbus.New([2]string{}, [2]string{})
_ = p.Seal(dbus.NewConfig("", true, false), nil)
_ = p.Seal(dbus.NewConfig("", true, false), nil)
})
ep := dbus.New([2]string{}, [2]string{})
if err := ep.Seal(nil, nil); !errors.Is(err, dbus.ErrConfig) {
t.Errorf("Seal(nil, nil) error = %v, want %v",
err, dbus.ErrConfig)
} }
for id, tc := range testCasePairs() { for id, tc := range testCasePairs() {
t.Run("create final for "+id, func(t *testing.T) { t.Run("create seal for "+id, func(t *testing.T) {
var wt io.WriterTo p := dbus.New(tc[0].bus, tc[1].bus)
if v, err := dbus.Finalise(tc[0].bus, tc[1].bus, tc[0].c, tc[1].c); (errors.Is(err, syscall.EINVAL)) != tc[0].wantErr { if err := p.Seal(tc[0].c, tc[1].c); (errors.Is(err, helper.ErrContainsNull)) != tc[0].wantErr {
t.Errorf("Finalise: error = %v, wantErr %v", t.Errorf("Seal(%p, %p) error = %v, wantErr %v",
tc[0].c, tc[1].c,
err, tc[0].wantErr) err, tc[0].wantErr)
return return
} else {
wt = v
} }
// rest of the tests happen for sealed instances // rest of the tests happen for sealed instances
@ -47,23 +88,25 @@ func TestFinalise(t *testing.T) {
args := append(tc[0].want, tc[1].want...) args := append(tc[0].want, tc[1].want...)
for _, arg := range args { for _, arg := range args {
want.WriteString(arg) want.WriteString(arg)
want.WriteByte(0) want.WriteByte('\x00')
} }
wt := p.AccessTestProxySeal()
got := new(strings.Builder) got := new(strings.Builder)
if _, err := wt.WriteTo(got); err != nil { if _, err := wt.WriteTo(got); err != nil {
t.Errorf("WriteTo: error = %v", err) t.Errorf("p.seal.WriteTo(): %v", err)
} }
if want.String() != got.String() { if want.String() != got.String() {
t.Errorf("Seal: %q, want %q", t.Errorf("Seal(%p, %p) seal = %v, want %v",
tc[0].c, tc[1].c,
got.String(), want.String()) got.String(), want.String())
} }
}) })
} }
} }
func TestProxyStartWaitCloseString(t *testing.T) { func TestProxy_Start_Wait_Close_String(t *testing.T) {
oldWaitDelay := helper.WaitDelay oldWaitDelay := helper.WaitDelay
helper.WaitDelay = 16 * time.Second helper.WaitDelay = 16 * time.Second
t.Cleanup(func() { helper.WaitDelay = oldWaitDelay }) t.Cleanup(func() { helper.WaitDelay = oldWaitDelay })
@ -72,62 +115,29 @@ func TestProxyStartWaitCloseString(t *testing.T) {
proxyName := dbus.ProxyName proxyName := dbus.ProxyName
dbus.ProxyName = os.Args[0] dbus.ProxyName = os.Args[0]
t.Cleanup(func() { dbus.ProxyName = proxyName }) t.Cleanup(func() { dbus.ProxyName = proxyName })
testProxyFinaliseStartWaitCloseString(t, true) testProxyStartWaitCloseString(t, true)
}) })
t.Run("direct", func(t *testing.T) { testProxyFinaliseStartWaitCloseString(t, false) }) t.Run("direct", func(t *testing.T) { testProxyStartWaitCloseString(t, false) })
} }
func testProxyFinaliseStartWaitCloseString(t *testing.T, useSandbox bool) { func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
var p *dbus.Proxy
t.Run("string for nil proxy", func(t *testing.T) {
want := "(invalid dbus proxy)"
if got := p.String(); got != want {
t.Errorf("String: %q, want %q",
got, want)
}
})
t.Run("invalid start", func(t *testing.T) {
if !useSandbox {
p = dbus.NewDirect(t.Context(), nil, nil)
} else {
p = dbus.New(t.Context(), nil, nil)
}
if err := p.Start(); !errors.Is(err, syscall.ENOTRECOVERABLE) {
t.Errorf("Start: error = %q, wantErr %q",
err, syscall.ENOTRECOVERABLE)
return
}
})
for id, tc := range testCasePairs() { for id, tc := range testCasePairs() {
// this test does not test errors // this test does not test errors
if tc[0].wantErr { if tc[0].wantErr {
continue continue
} }
t.Run("proxy for "+id, func(t *testing.T) { t.Run("string for nil proxy", func(t *testing.T) {
var final *dbus.Final var p *dbus.Proxy
t.Run("finalise", func(t *testing.T) { want := "(invalid dbus proxy)"
if v, err := dbus.Finalise(tc[0].bus, tc[1].bus, tc[0].c, tc[1].c); err != nil { if got := p.String(); got != want {
t.Errorf("Finalise: error = %v, wantErr %v", t.Errorf("String() = %v, want %v",
err, tc[0].wantErr) got, want)
return
} else {
final = v
}
})
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
if !useSandbox {
p = dbus.NewDirect(ctx, final, nil)
} else {
p = dbus.New(ctx, final, nil)
} }
})
t.Run("proxy for "+id, func(t *testing.T) {
p := dbus.New(tc[0].bus, tc[1].bus)
p.CommandContext = func(ctx context.Context) (cmd *exec.Cmd) { p.CommandContext = func(ctx context.Context) (cmd *exec.Cmd) {
return exec.CommandContext(ctx, os.Args[0], "-test.v", return exec.CommandContext(ctx, os.Args[0], "-test.v",
"-test.run=TestHelperInit", "--", "init") "-test.run=TestHelperInit", "--", "init")
@ -152,52 +162,79 @@ func testProxyFinaliseStartWaitCloseString(t *testing.T, useSandbox bool) {
p.FilterF = func(v []byte) []byte { return bytes.SplitN(v, []byte("TestHelperInit\n"), 2)[1] } p.FilterF = func(v []byte) []byte { return bytes.SplitN(v, []byte("TestHelperInit\n"), 2)[1] }
output := new(strings.Builder) output := new(strings.Builder)
t.Run("invalid wait", func(t *testing.T) { t.Run("unsealed", func(t *testing.T) {
wantErr := "dbus: not started"
if err := p.Wait(); err == nil || err.Error() != wantErr {
t.Errorf("Wait: error = %v, wantErr %v",
err, wantErr)
}
})
t.Run("string", func(t *testing.T) {
want := "(unused dbus proxy)"
if got := p.String(); got != want {
t.Errorf("String: %q, want %q",
got, want)
return
}
})
t.Run("start", func(t *testing.T) {
if err := p.Start(); err != nil {
t.Fatalf("Start: error = %v",
err)
}
t.Run("string", func(t *testing.T) { t.Run("string", func(t *testing.T) {
wantSubstr := fmt.Sprintf("%s -test.run=TestHelperStub -- --args=3 --fd=4", os.Args[0]) want := "(unsealed dbus proxy)"
if useSandbox { if got := p.String(); got != want {
wantSubstr = fmt.Sprintf(`argv: ["%s" "-test.run=TestHelperStub" "--" "--args=3" "--fd=4"], flags: 0x0, seccomp: 0x3e`, os.Args[0]) t.Errorf("String() = %v, want %v",
got, want)
return
} }
if got := p.String(); !strings.Contains(got, wantSubstr) { })
t.Errorf("String: %q, want %q",
got, wantSubstr) t.Run("start", func(t *testing.T) {
want := "proxy not sealed"
if err := p.Start(context.Background(), nil, useSandbox); err == nil || err.Error() != want {
t.Errorf("Start() error = %v, wantErr %q",
err, errors.New(want))
return return
} }
}) })
t.Run("wait", func(t *testing.T) { t.Run("wait", func(t *testing.T) {
done := make(chan struct{}) wantErr := "dbus: not started"
go func() { if err := p.Wait(); err == nil || err.Error() != wantErr {
t.Errorf("Wait() error = %v, wantErr %v",
err, wantErr)
}
})
})
t.Run("seal with "+id, func(t *testing.T) {
if err := p.Seal(tc[0].c, tc[1].c); err != nil {
t.Errorf("Seal(%p, %p) error = %v, wantErr %v",
tc[0].c, tc[1].c,
err, tc[0].wantErr)
return
}
})
t.Run("sealed", func(t *testing.T) {
want := strings.Join(append(tc[0].want, tc[1].want...), " ")
if got := p.String(); got != want {
t.Errorf("String() = %v, want %v",
got, want)
return
}
t.Run("start", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := p.Start(ctx, output, useSandbox); err != nil {
t.Fatalf("Start(nil, nil) error = %v",
err)
}
t.Run("string", func(t *testing.T) {
wantSubstr := fmt.Sprintf("%s -test.run=TestHelperStub -- --args=3 --fd=4", os.Args[0])
if useSandbox {
wantSubstr = fmt.Sprintf(`argv: ["%s" "-test.run=TestHelperStub" "--" "--args=3" "--fd=4"], flags: 0x0, seccomp: 0x3e`, os.Args[0])
}
if got := p.String(); !strings.Contains(got, wantSubstr) {
t.Errorf("String() = %v, want %v",
p.String(), wantSubstr)
return
}
})
t.Run("wait", func(t *testing.T) {
p.Close()
if err := p.Wait(); err != nil { if err := p.Wait(); err != nil {
t.Errorf("Wait: error = %v\noutput: %s", t.Errorf("Wait() error = %v\noutput: %s",
err, output.String()) err, output.String())
} }
close(done) })
}()
p.Close()
<-done
}) })
}) })
}) })
@ -208,6 +245,6 @@ func TestHelperInit(t *testing.T) {
if len(os.Args) != 5 || os.Args[4] != "init" { if len(os.Args) != 5 || os.Args[4] != "init" {
return return
} }
sandbox.SetOutput(hlog.Output{}) sandbox.SetOutput(fmsg.Output{})
sandbox.Init(hlog.Prepare, internal.InstallFmsg) sandbox.Init(fmsg.Prepare, internal.InstallFmsg)
} }

View File

@ -1,13 +1,13 @@
package dbus package dbus
import ( import "io"
"context"
"io"
)
// NewDirect returns a new instance of [Proxy] with its sandbox disabled. // CompareTestNew provides TestNew with comparison access to unexported Proxy fields.
func NewDirect(ctx context.Context, final *Final, output io.Writer) *Proxy { func (p *Proxy) CompareTestNew(session, system [2]string) bool {
p := New(ctx, final, output) return session == p.session && system == p.system
p.useSandbox = false }
return p
// AccessTestProxySeal provides TestProxy_Seal with access to unexported Proxy seal field.
func (p *Proxy) AccessTestProxySeal() io.WriterTo {
return p.seal
} }

View File

@ -3,44 +3,41 @@ package dbus
import ( import (
"context" "context"
"errors" "errors"
"io"
"os" "os"
"os/exec" "os/exec"
"path" "path"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/hakurei/ldd" "git.gensokyo.uk/security/fortify/ldd"
"git.gensokyo.uk/security/hakurei/sandbox" "git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/hakurei/sandbox/seccomp" "git.gensokyo.uk/security/fortify/sandbox/seccomp"
) )
// Start starts and configures a D-Bus proxy process. // Start launches the D-Bus proxy.
func (p *Proxy) Start() error { func (p *Proxy) Start(ctx context.Context, output io.Writer, useSandbox bool) error {
if p.final == nil || p.final.WriterTo == nil { p.lock.Lock()
return syscall.ENOTRECOVERABLE defer p.lock.Unlock()
if p.seal == nil {
return errors.New("proxy not sealed")
} }
p.mu.Lock() var h helper.Helper
defer p.mu.Unlock()
p.pmu.Lock()
defer p.pmu.Unlock()
if p.cancel != nil || p.cause != nil { c, cancel := context.WithCancelCause(ctx)
return errors.New("dbus: already started") if !useSandbox {
} h = helper.NewDirect(c, p.name, p.seal, true, argF, func(cmd *exec.Cmd) {
ctx, cancel := context.WithCancelCause(p.ctx)
if !p.useSandbox {
p.helper = helper.NewDirect(ctx, p.name, p.final, true, argF, func(cmd *exec.Cmd) {
if p.CmdF != nil { if p.CmdF != nil {
p.CmdF(cmd) p.CmdF(cmd)
} }
if p.output != nil { if output != nil {
cmd.Stdout, cmd.Stderr = p.output, p.output cmd.Stdout, cmd.Stderr = output, output
} }
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Env = make([]string, 0) cmd.Env = make([]string, 0)
@ -62,15 +59,15 @@ func (p *Proxy) Start() error {
libPaths = ldd.Path(entries) libPaths = ldd.Path(entries)
} }
p.helper = helper.New( h = helper.New(
ctx, toolPath, c, toolPath,
p.final, true, p.seal, true,
argF, func(container *sandbox.Container) { argF, func(container *sandbox.Container) {
container.Seccomp |= seccomp.FilterMultiarch container.Seccomp |= seccomp.FlagMultiarch
container.Hostname = "hakurei-dbus" container.Hostname = "fortify-dbus"
container.CommandContext = p.CommandContext container.CommandContext = p.CommandContext
if p.output != nil { if output != nil {
container.Stdout, container.Stderr = p.output, p.output container.Stdout, container.Stderr = output, output
} }
if p.CmdF != nil { if p.CmdF != nil {
@ -84,17 +81,10 @@ func (p *Proxy) Start() error {
// upstream bus directories // upstream bus directories
upstreamPaths := make([]string, 0, 2) upstreamPaths := make([]string, 0, 2)
for _, addr := range [][]AddrEntry{p.final.SessionUpstream, p.final.SystemUpstream} { for _, as := range []string{p.session[0], p.system[0]} {
for _, ent := range addr { if len(as) > 0 && strings.HasPrefix(as, "unix:path=/") {
if ent.Method != "unix" { // leave / intact
continue upstreamPaths = append(upstreamPaths, path.Dir(as[10:]))
}
for _, pair := range ent.Values {
if pair[0] != "path" || !path.IsAbs(pair[1]) {
continue
}
upstreamPaths = append(upstreamPaths, path.Dir(pair[1]))
}
} }
} }
slices.Sort(upstreamPaths) slices.Sort(upstreamPaths)
@ -105,10 +95,10 @@ func (p *Proxy) Start() error {
// parent directories of bind paths // parent directories of bind paths
sockDirPaths := make([]string, 0, 2) sockDirPaths := make([]string, 0, 2)
if d := path.Dir(p.final.Session[1]); path.IsAbs(d) { if d := path.Dir(p.session[1]); path.IsAbs(d) {
sockDirPaths = append(sockDirPaths, d) sockDirPaths = append(sockDirPaths, d)
} }
if d := path.Dir(p.final.System[1]); path.IsAbs(d) { if d := path.Dir(p.system[1]); path.IsAbs(d) {
sockDirPaths = append(sockDirPaths, d) sockDirPaths = append(sockDirPaths, d)
} }
slices.Sort(sockDirPaths) slices.Sort(sockDirPaths)
@ -123,13 +113,14 @@ func (p *Proxy) Start() error {
}, nil) }, nil)
} }
if err := p.helper.Start(); err != nil { if err := h.Start(); err != nil {
cancel(err) cancel(err)
p.helper = nil
return err return err
} }
p.cancel, p.cause = cancel, func() error { return context.Cause(ctx) } p.helper = h
p.ctx = c
p.cancel = cancel
return nil return nil
} }
@ -137,30 +128,28 @@ var proxyClosed = errors.New("proxy closed")
// Wait blocks until xdg-dbus-proxy exits and releases resources. // Wait blocks until xdg-dbus-proxy exits and releases resources.
func (p *Proxy) Wait() error { func (p *Proxy) Wait() error {
p.mu.RLock() p.lock.RLock()
defer p.mu.RUnlock() defer p.lock.RUnlock()
p.pmu.RLock() if p.helper == nil {
if p.helper == nil || p.cancel == nil || p.cause == nil {
p.pmu.RUnlock()
return errors.New("dbus: not started") return errors.New("dbus: not started")
} }
errs := make([]error, 3) errs := make([]error, 3)
errs[0] = p.helper.Wait() errs[0] = p.helper.Wait()
if errors.Is(errs[0], context.Canceled) && if p.cancel == nil &&
errors.Is(p.cause(), proxyClosed) { errors.Is(errs[0], context.Canceled) &&
errors.Is(context.Cause(p.ctx), proxyClosed) {
errs[0] = nil errs[0] = nil
} }
p.pmu.RUnlock()
// ensure socket removal so ephemeral directory is empty at revert // ensure socket removal so ephemeral directory is empty at revert
if err := os.Remove(p.final.Session[1]); err != nil && !errors.Is(err, os.ErrNotExist) { if err := os.Remove(p.session[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
errs[1] = err errs[1] = err
} }
if p.final.System[1] != "" { if p.sysP {
if err := os.Remove(p.final.System[1]); err != nil && !errors.Is(err, os.ErrNotExist) { if err := os.Remove(p.system[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
errs[2] = err errs[2] = err
} }
} }
@ -170,13 +159,14 @@ func (p *Proxy) Wait() error {
// Close cancels the context passed to the helper instance attached to xdg-dbus-proxy. // Close cancels the context passed to the helper instance attached to xdg-dbus-proxy.
func (p *Proxy) Close() { func (p *Proxy) Close() {
p.pmu.Lock() p.lock.Lock()
defer p.pmu.Unlock() defer p.lock.Unlock()
if p.cancel == nil { if p.cancel == nil {
panic("dbus: not started") panic("dbus: not started")
} }
p.cancel(proxyClosed) p.cancel(proxyClosed)
p.cancel = nil
} }
func argF(argsFd, statFd int) []string { func argF(argsFd, statFd int) []string {

View File

@ -2,116 +2,97 @@ package dbus
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
"sync" "sync"
"syscall"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
) )
// ProxyName is the file name or path to the proxy program. // ProxyName is the file name or path to the proxy program.
// Overriding ProxyName will only affect Proxy instance created after the change. // Overriding ProxyName will only affect Proxy instance created after the change.
var ProxyName = "xdg-dbus-proxy" var ProxyName = "xdg-dbus-proxy"
type BadInterfaceError struct { // Proxy holds references to a xdg-dbus-proxy process, and should never be copied.
Interface string // Once sealed, configuration changes will no longer be possible and attempting to do so will result in a panic.
Segment string
}
func (e *BadInterfaceError) Error() string {
return fmt.Sprintf("bad interface string %q in %s bus configuration", e.Interface, e.Segment)
}
// Proxy holds the state of a xdg-dbus-proxy process, and should never be copied.
type Proxy struct { type Proxy struct {
helper helper.Helper helper helper.Helper
ctx context.Context ctx context.Context
cancel context.CancelCauseFunc cancel context.CancelCauseFunc
cause func() error
final *Final name string
output io.Writer session [2]string
useSandbox bool system [2]string
CmdF func(any)
name string sysP bool
CmdF func(any)
CommandContext func(ctx context.Context) (cmd *exec.Cmd) CommandContext func(ctx context.Context) (cmd *exec.Cmd)
FilterF func([]byte) []byte FilterF func([]byte) []byte
mu, pmu sync.RWMutex seal io.WriterTo
lock sync.RWMutex
} }
func (p *Proxy) Session() [2]string { return p.session }
func (p *Proxy) System() [2]string { return p.system }
func (p *Proxy) Sealed() bool { p.lock.RLock(); defer p.lock.RUnlock(); return p.seal != nil }
var (
ErrConfig = errors.New("no configuration to seal")
)
func (p *Proxy) String() string { func (p *Proxy) String() string {
if p == nil { if p == nil {
return "(invalid dbus proxy)" return "(invalid dbus proxy)"
} }
p.mu.RLock() p.lock.RLock()
defer p.mu.RUnlock() defer p.lock.RUnlock()
if p.helper != nil { if p.helper != nil {
return p.helper.String() return p.helper.String()
} }
return "(unused dbus proxy)" if p.seal != nil {
return p.seal.(fmt.Stringer).String()
}
return "(unsealed dbus proxy)"
} }
// Final describes the outcome of a proxy configuration. // Seal seals the Proxy instance.
type Final struct { func (p *Proxy) Seal(session, system *Config) error {
Session, System ProxyPair p.lock.Lock()
// parsed upstream address defer p.lock.Unlock()
SessionUpstream, SystemUpstream []AddrEntry
io.WriterTo if p.seal != nil {
} panic("dbus proxy sealed twice")
}
// Finalise creates a checked argument writer for [Proxy].
func Finalise(sessionBus, systemBus ProxyPair, session, system *Config) (final *Final, err error) {
if session == nil && system == nil { if session == nil && system == nil {
return nil, syscall.EBADE return ErrConfig
} }
var args []string var args []string
if session != nil { if session != nil {
if err = session.checkInterfaces("session"); err != nil { args = append(args, session.Args(p.session)...)
return
}
args = append(args, session.Args(sessionBus)...)
} }
if system != nil { if system != nil {
if err = system.checkInterfaces("system"); err != nil { args = append(args, system.Args(p.system)...)
return p.sysP = true
} }
args = append(args, system.Args(systemBus)...) if seal, err := helper.NewCheckedArgs(args); err != nil {
return err
} else {
p.seal = seal
} }
final = &Final{Session: sessionBus, System: systemBus} return nil
final.WriterTo, err = helper.NewCheckedArgs(args)
if err != nil {
return
}
if session != nil {
final.SessionUpstream, err = Parse([]byte(final.Session[0]))
if err != nil {
return
}
}
if system != nil {
final.SystemUpstream, err = Parse([]byte(final.System[0]))
if err != nil {
return
}
}
return
} }
// New returns a new instance of [Proxy]. // New returns a reference to a new unsealed Proxy.
func New(ctx context.Context, final *Final, output io.Writer) *Proxy { func New(session, system [2]string) *Proxy {
return &Proxy{name: ProxyName, ctx: ctx, final: final, output: output, useSandbox: true} return &Proxy{name: ProxyName, session: session, system: system}
} }

View File

@ -3,7 +3,7 @@ package dbus_test
import ( import (
"sync" "sync"
"git.gensokyo.uk/security/hakurei/dbus" "git.gensokyo.uk/security/fortify/dbus"
) )
const ( const (

View File

@ -3,7 +3,7 @@ package dbus_test
import ( import (
"testing" "testing"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestHelperStub(t *testing.T) { helper.InternalHelperStub() } func TestHelperStub(t *testing.T) { helper.InternalHelperStub() }

82
dist/comp/_hakurei vendored
View File

@ -1,82 +0,0 @@
#compdef hakurei
_hakurei_app() {
__hakurei_files
return $?
}
_hakurei_run() {
_arguments \
'--id[Reverse-DNS style Application identifier, leave empty to inherit instance identifier]:id' \
'-a[Application identity]: :_numbers' \
'-g[Groups inherited by all container processes]: :_groups' \
'-d[Container home directory]: :_files -/' \
'-u[Passwd user name within sandbox]: :_users' \
'--wayland[Enable connection to Wayland via security-context-v1]' \
'-X[Enable direct connection to X11]' \
'--dbus[Enable proxied connection to D-Bus]' \
'--pulse[Enable direct connection to PulseAudio]' \
'--dbus-config[Path to session bus proxy config file]: :_files -g "*.json"' \
'--dbus-system[Path to system bus proxy config file]: :_files -g "*.json"' \
'--mpris[Allow owning MPRIS D-Bus path]' \
'--dbus-log[Force buffered logging in the D-Bus proxy]'
}
_hakurei_ps() {
_arguments \
'--short[List instances only]'
}
_hakurei_show() {
_alternative \
'instances:domains:__hakurei_instances' \
'files:files:__hakurei_files'
}
__hakurei_files() {
_files -g "*.(json|hakurei)"
return $?
}
__hakurei_instances() {
local -a out
shift -p
out=( ${(f)"$(_call_program commands hakurei ps --short 2>&1)"} )
if (( $#out == 0 )); then
_message "No active instances"
else
_describe "active instances" out
fi
return $?
}
(( $+functions[_hakurei_commands] )) || _hakurei_commands()
{
local -a _hakurei_cmds
_hakurei_cmds=(
"app:Load app from configuration file"
"run:Configure and start a permissive default sandbox"
"show:Show live or local app configuration"
"ps:List active instances"
"version:Display version information"
"license:Show full license text"
"template:Produce a config template"
"help:Show help message"
)
if (( CURRENT == 1 )); then
_describe -t commands 'action' _hakurei_cmds || compadd "$@"
else
local curcontext="$curcontext"
cmd="${${_hakurei_cmds[(r)$words[1]:*]%%:*}}"
if (( $+functions[_hakurei_$cmd] )); then
_hakurei_$cmd
else
_message "no more options"
fi
fi
}
_arguments -C \
'-v[Increase log verbosity]' \
'--json[Serialise output in JSON when applicable]' \
'*::hakurei command:_hakurei_commands'

12
dist/install.sh vendored
View File

@ -1,12 +1,12 @@
#!/bin/sh #!/bin/sh
cd "$(dirname -- "$0")" || exit 1 cd "$(dirname -- "$0")" || exit 1
install -vDm0755 "bin/hakurei" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hakurei" install -vDm0755 "bin/fortify" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fortify"
install -vDm0755 "bin/planterette" "${HAKUREI_INSTALL_PREFIX}/usr/bin/planterette" install -vDm0755 "bin/fpkg" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fpkg"
install -vDm6511 "bin/hsu" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hsu" install -vDm6511 "bin/fsu" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fsu"
if [ ! -f "${HAKUREI_INSTALL_PREFIX}/etc/hsurc" ]; then if [ ! -f "${FORTIFY_INSTALL_PREFIX}/etc/fsurc" ]; then
install -vDm0400 "hsurc.default" "${HAKUREI_INSTALL_PREFIX}/etc/hsurc" install -vDm0400 "fsurc.default" "${FORTIFY_INSTALL_PREFIX}/etc/fsurc"
fi fi
install -vDm0644 "comp/_hakurei" "${HAKUREI_INSTALL_PREFIX}/usr/share/zsh/site-functions/_hakurei" install -vDm0644 "comp/_fortify" "${FORTIFY_INSTALL_PREFIX}/usr/share/zsh/site-functions/_fortify"

16
dist/release.sh vendored
View File

@ -1,19 +1,19 @@
#!/bin/sh -e #!/bin/sh -e
cd "$(dirname -- "$0")/.." cd "$(dirname -- "$0")/.."
VERSION="${HAKUREI_VERSION:-untagged}" VERSION="${FORTIFY_VERSION:-untagged}"
pname="hakurei-${VERSION}" pname="fortify-${VERSION}"
out="dist/${pname}" out="dist/${pname}"
mkdir -p "${out}" mkdir -p "${out}"
cp -v "README.md" "dist/hsurc.default" "dist/install.sh" "${out}" cp -v "README.md" "dist/fsurc.default" "dist/install.sh" "${out}"
cp -rv "dist/comp" "${out}" cp -rv "comp" "${out}"
go generate ./... go generate ./...
go build -trimpath -v -o "${out}/bin/" -ldflags "-s -w -buildid= -extldflags '-static' go build -trimpath -v -o "${out}/bin/" -ldflags "-s -w -buildid= -extldflags '-static'
-X git.gensokyo.uk/security/hakurei/internal.version=${VERSION} -X git.gensokyo.uk/security/fortify/internal.version=${VERSION}
-X git.gensokyo.uk/security/hakurei/internal.hakurei=/usr/bin/hakurei -X git.gensokyo.uk/security/fortify/internal.fsu=/usr/bin/fsu
-X git.gensokyo.uk/security/hakurei/internal.hsu=/usr/bin/hsu -X main.fmain=/usr/bin/fortify
-X main.hmain=/usr/bin/hakurei" ./... -X main.fpkg=/usr/bin/fpkg" ./...
rm -f "./${out}.tar.gz" && tar -C dist -czf "${out}.tar.gz" "${pname}" rm -f "./${out}.tar.gz" && tar -C dist -czf "${out}.tar.gz" "${pname}"
rm -rf "./${out}" rm -rf "./${out}"

16
flake.lock generated
View File

@ -7,32 +7,32 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1748665073, "lastModified": 1742234739,
"narHash": "sha256-RMhjnPKWtCoIIHiuR9QKD7xfsKb3agxzMfJY8V9MOew=", "narHash": "sha256-zFL6zsf/5OztR1NSNQF33dvS1fL/BzVUjabZq4qrtY4=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "282e1e029cb6ab4811114fc85110613d72771dea", "rev": "f6af7280a3390e65c2ad8fd059cdc303426cbd59",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nix-community", "owner": "nix-community",
"ref": "release-25.05", "ref": "release-24.11",
"repo": "home-manager", "repo": "home-manager",
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1749024892, "lastModified": 1742512142,
"narHash": "sha256-OGcDEz60TXQC+gVz5sdtgGJdKVYr6rwdzQKuZAJQpCA=", "narHash": "sha256-8XfURTDxOm6+33swQJu/hx6xw1Tznl8vJJN5HwVqckg=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "8f1b52b04f2cb6e5ead50bd28d76528a2f0380ef", "rev": "7105ae3957700a9646cc4b766f5815b23ed0c682",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-25.05", "ref": "nixos-24.11",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

View File

@ -1,11 +1,11 @@
{ {
description = "hakurei container tool and nixos module"; description = "fortify sandbox tool and nixos module";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
home-manager = { home-manager = {
url = "github:nix-community/home-manager/release-25.05"; url = "github:nix-community/home-manager/release-24.11";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
}; };
@ -27,12 +27,12 @@
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
in in
{ {
nixosModules.hakurei = import ./nixos.nix self.packages; nixosModules.fortify = import ./nixos.nix self.packages;
buildPackage = forAllSystems ( buildPackage = forAllSystems (
system: system:
nixpkgsFor.${system}.callPackage ( nixpkgsFor.${system}.callPackage (
import ./cmd/planterette/build.nix { import ./cmd/fpkg/build.nix {
inherit inherit
nixpkgsFor nixpkgsFor
system system
@ -57,20 +57,13 @@
; ;
in in
{ {
hakurei = callPackage ./test { inherit system self; }; fortify = callPackage ./test { inherit system self; };
fpkg = callPackage ./cmd/fpkg/test { inherit system self; };
race = callPackage ./test { race = callPackage ./test {
inherit system self; inherit system self;
withRace = true; withRace = true;
}; };
sandbox = callPackage ./test/sandbox { inherit self; };
sandbox-race = callPackage ./test/sandbox {
inherit self;
withRace = true;
};
planterette = callPackage ./cmd/planterette/test { inherit system self; };
formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; } '' formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; } ''
cd ${./.} cd ${./.}
@ -105,12 +98,12 @@
packages = forAllSystems ( packages = forAllSystems (
system: system:
let let
inherit (self.packages.${system}) hakurei hsu; inherit (self.packages.${system}) fortify fsu;
pkgs = nixpkgsFor.${system}; pkgs = nixpkgsFor.${system};
in in
{ {
default = hakurei; default = fortify;
hakurei = pkgs.pkgsStatic.callPackage ./package.nix { fortify = pkgs.pkgsStatic.callPackage ./package.nix {
inherit (pkgs) inherit (pkgs)
# passthru.buildInputs # passthru.buildInputs
go go
@ -125,26 +118,26 @@
glibc glibc
xdg-dbus-proxy xdg-dbus-proxy
# planterette # fpkg
zstd zstd
gnutar gnutar
coreutils coreutils
; ;
}; };
hsu = pkgs.callPackage ./cmd/hsu/package.nix { inherit (self.packages.${system}) hakurei; }; fsu = pkgs.callPackage ./cmd/fsu/package.nix { inherit (self.packages.${system}) fortify; };
dist = pkgs.runCommand "${hakurei.name}-dist" { buildInputs = hakurei.targetPkgs ++ [ pkgs.pkgsStatic.musl ]; } '' dist = pkgs.runCommand "${fortify.name}-dist" { buildInputs = fortify.targetPkgs ++ [ pkgs.pkgsStatic.musl ]; } ''
# go requires XDG_CACHE_HOME for the build cache # go requires XDG_CACHE_HOME for the build cache
export XDG_CACHE_HOME="$(mktemp -d)" export XDG_CACHE_HOME="$(mktemp -d)"
# get a different workdir as go does not like /build # get a different workdir as go does not like /build
cd $(mktemp -d) \ cd $(mktemp -d) \
&& cp -r ${hakurei.src}/. . \ && cp -r ${fortify.src}/. . \
&& chmod +w cmd && cp -r ${hsu.src}/. cmd/hsu/ \ && chmod +w cmd && cp -r ${fsu.src}/. cmd/fsu/ \
&& chmod -R +w . && chmod -R +w .
export HAKUREI_VERSION="v${hakurei.version}" export FORTIFY_VERSION="v${fortify.version}"
./dist/release.sh && mkdir $out && cp -v "dist/hakurei-$HAKUREI_VERSION.tar.gz"* $out ./dist/release.sh && mkdir $out && cp -v "dist/fortify-$FORTIFY_VERSION.tar.gz"* $out
''; '';
} }
); );
@ -152,12 +145,12 @@
devShells = forAllSystems ( devShells = forAllSystems (
system: system:
let let
inherit (self.packages.${system}) hakurei; inherit (self.packages.${system}) fortify;
pkgs = nixpkgsFor.${system}; pkgs = nixpkgsFor.${system};
in in
{ {
default = pkgs.mkShell { buildInputs = hakurei.targetPkgs; }; default = pkgs.mkShell { buildInputs = fortify.targetPkgs; };
withPackage = pkgs.mkShell { buildInputs = [ hakurei ] ++ hakurei.targetPkgs; }; withPackage = pkgs.mkShell { buildInputs = [ fortify ] ++ fortify.targetPkgs; };
generateDoc = generateDoc =
let let
@ -174,7 +167,7 @@
cleanEval = lib.filterAttrsRecursive (n: _: n != "_module") eval; cleanEval = lib.filterAttrsRecursive (n: _: n != "_module") eval;
in in
pkgs.nixosOptionsDoc { inherit (cleanEval) options; }; pkgs.nixosOptionsDoc { inherit (cleanEval) options; };
docText = pkgs.runCommand "hakurei-module-docs.md" { } '' docText = pkgs.runCommand "fortify-module-docs.md" { } ''
cat ${doc.optionsCommonMark} > $out cat ${doc.optionsCommonMark} > $out
sed -i '/*Declared by:*/,+1 d' $out sed -i '/*Declared by:*/,+1 d' $out
''; '';

47
fst/app.go Normal file
View File

@ -0,0 +1,47 @@
// Package fst exports shared fortify types.
package fst
import (
"time"
)
type App interface {
// ID returns a copy of [fst.ID] held by App.
ID() ID
// Seal determines the outcome of config as a [SealedApp].
// The value of config might be overwritten and must not be used again.
Seal(config *Config) (SealedApp, error)
String() string
}
type SealedApp interface {
// Run commits sealed system setup and starts the app process.
Run(rs *RunState) error
}
// RunState stores the outcome of a call to [SealedApp.Run].
type RunState struct {
// Time is the exact point in time where the process was created.
// Location must be set to UTC.
//
// Time is nil if no process was ever created.
Time *time.Time
// ExitCode is the value returned by shim.
ExitCode int
// RevertErr is stored by the deferred revert call.
RevertErr error
// WaitErr is error returned by the underlying wait syscall.
WaitErr error
}
// Paths contains environment-dependent paths used by fortify.
type Paths struct {
// path to shared directory (usually `/tmp/fortify.%d`)
SharePath string `json:"share_path"`
// XDG_RUNTIME_DIR value (usually `/run/user/%d`)
RuntimePath string `json:"runtime_path"`
// application runtime directory (usually `/run/user/%d/fortify`)
RunDirPath string `json:"run_dir_path"`
}

159
fst/config.go Normal file
View File

@ -0,0 +1,159 @@
package fst
import (
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
"git.gensokyo.uk/security/fortify/system"
)
const Tmp = "/.fortify"
// Config is used to seal an app
type Config struct {
// reverse-DNS style arbitrary identifier string from config;
// passed to wayland security-context-v1 as application ID
// and used as part of defaults in dbus session proxy
ID string `json:"id"`
// absolute path to executable file
Path string `json:"path,omitempty"`
// final args passed to container init
Args []string `json:"args"`
Confinement ConfinementConfig `json:"confinement"`
}
// ConfinementConfig defines fortified child's confinement
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 container, defaults to passwd name of target uid or chronos
Username string `json:"username,omitempty"`
// home directory in container, empty for outer
Inner string `json:"home_inner"`
// home directory in init namespace
Outer string `json:"home"`
// abstract sandbox configuration
Sandbox *SandboxConfig `json:"sandbox"`
// extra acl ops, runs after everything else
ExtraPerms []*ExtraPermConfig `json:"extra_perms,omitempty"`
// reference to a system D-Bus proxy configuration,
// nil value disables system bus proxy
SystemBus *dbus.Config `json:"system_bus,omitempty"`
// reference to a session D-Bus proxy configuration,
// nil value makes session bus proxy assume built-in defaults
SessionBus *dbus.Config `json:"session_bus,omitempty"`
// system resources to expose to the container
Enablements system.Enablement `json:"enablements"`
}
type ExtraPermConfig struct {
Ensure bool `json:"ensure,omitempty"`
Path string `json:"path"`
Read bool `json:"r,omitempty"`
Write bool `json:"w,omitempty"`
Execute bool `json:"x,omitempty"`
}
func (e *ExtraPermConfig) String() string {
buf := make([]byte, 0, 5+len(e.Path))
buf = append(buf, '-', '-', '-')
if e.Ensure {
buf = append(buf, '+')
}
buf = append(buf, ':')
buf = append(buf, []byte(e.Path)...)
if e.Read {
buf[0] = 'r'
}
if e.Write {
buf[1] = 'w'
}
if e.Execute {
buf[2] = 'x'
}
return string(buf)
}
// Template returns a fully populated instance of Config.
func Template() *Config {
return &Config{
ID: "org.chromium.Chromium",
Path: "/run/current-system/sw/bin/chromium",
Args: []string{
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
},
Confinement: ConfinementConfig{
AppID: 9,
Groups: []string{"video"},
Username: "chronos",
Outer: "/var/lib/persist/home/org.chromium.Chromium",
Inner: "/var/lib/fortify",
Sandbox: &SandboxConfig{
Hostname: "localhost",
Devel: true,
Userns: true,
Net: true,
Dev: true,
Seccomp: seccomp.FlagMultiarch,
Tty: true,
Multiarch: true,
MapRealUID: true,
DirectWayland: false,
// example API credentials pulled from Google Chrome
// DO NOT USE THESE IN A REAL BROWSER
Env: map[string]string{
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT",
},
Filesystem: []*FilesystemConfig{
{Src: "/nix/store"},
{Src: "/run/current-system"},
{Src: "/run/opengl-driver"},
{Src: "/var/db/nix-channels"},
{Src: "/var/lib/fortify/u0/org.chromium.Chromium",
Dst: "/data/data/org.chromium.Chromium", Write: true, Must: true},
{Src: "/dev/dri", Device: true},
},
Link: [][2]string{{"/run/user/65534", "/run/user/150"}},
Etc: "/etc",
AutoEtc: true,
Cover: []string{"/var/run/nscd"},
},
ExtraPerms: []*ExtraPermConfig{
{Path: "/var/lib/fortify/u0", Ensure: true, Execute: true},
{Path: "/var/lib/fortify/u0/org.chromium.Chromium", Read: true, Write: true, Execute: true},
},
SystemBus: &dbus.Config{
See: nil,
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Own: nil,
Call: nil,
Broadcast: nil,
Log: false,
Filter: true,
},
SessionBus: &dbus.Config{
See: nil,
Talk: []string{"org.freedesktop.Notifications", "org.freedesktop.FileManager1", "org.freedesktop.ScreenSaver",
"org.freedesktop.secrets", "org.kde.kwalletd5", "org.kde.kwalletd6", "org.gnome.SessionManager"},
Own: []string{"org.chromium.Chromium.*", "org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"},
Call: map[string]string{"org.freedesktop.portal.*": "*"},
Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
Log: false,
Filter: true,
},
Enablements: system.EWayland | system.EDBus | system.EPulse,
},
}
}

View File

@ -1,4 +1,4 @@
package app package fst
import ( import (
"crypto/rand" "crypto/rand"

View File

@ -1,22 +1,22 @@
package app_test package fst_test
import ( import (
"errors" "errors"
"testing" "testing"
. "git.gensokyo.uk/security/hakurei/internal/app" "git.gensokyo.uk/security/fortify/fst"
) )
func TestParseAppID(t *testing.T) { func TestParseAppID(t *testing.T) {
t.Run("bad length", func(t *testing.T) { t.Run("bad length", func(t *testing.T) {
if err := ParseAppID(new(ID), "meow"); !errors.Is(err, ErrInvalidLength) { if err := fst.ParseAppID(new(fst.ID), "meow"); !errors.Is(err, fst.ErrInvalidLength) {
t.Errorf("ParseAppID: error = %v, wantErr = %v", err, ErrInvalidLength) t.Errorf("ParseAppID: error = %v, wantErr = %v", err, fst.ErrInvalidLength)
} }
}) })
t.Run("bad byte", func(t *testing.T) { t.Run("bad byte", func(t *testing.T) {
wantErr := "invalid char '\\n' at byte 15" wantErr := "invalid char '\\n' at byte 15"
if err := ParseAppID(new(ID), "02bc7f8936b2af6\n\ne2535cd71ef0bb7"); err == nil || err.Error() != wantErr { if err := fst.ParseAppID(new(fst.ID), "02bc7f8936b2af6\n\ne2535cd71ef0bb7"); err == nil || err.Error() != wantErr {
t.Errorf("ParseAppID: error = %v, wantErr = %v", err, wantErr) t.Errorf("ParseAppID: error = %v, wantErr = %v", err, wantErr)
} }
}) })
@ -30,30 +30,30 @@ func TestParseAppID(t *testing.T) {
func FuzzParseAppID(f *testing.F) { func FuzzParseAppID(f *testing.F) {
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
id := new(ID) id := new(fst.ID)
if err := NewAppID(id); err != nil { if err := fst.NewAppID(id); err != nil {
panic(err.Error()) panic(err.Error())
} }
f.Add(id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], id[9], id[10], id[11], id[12], id[13], id[14], id[15]) f.Add(id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], id[9], id[10], id[11], id[12], id[13], id[14], id[15])
} }
f.Fuzz(func(t *testing.T, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 byte) { f.Fuzz(func(t *testing.T, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 byte) {
testParseAppID(t, &ID{b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15}) testParseAppID(t, &fst.ID{b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15})
}) })
} }
func testParseAppIDWithRandom(t *testing.T) { func testParseAppIDWithRandom(t *testing.T) {
id := new(ID) id := new(fst.ID)
if err := NewAppID(id); err != nil { if err := fst.NewAppID(id); err != nil {
t.Fatalf("cannot generate app ID: %v", err) t.Fatalf("cannot generate app ID: %v", err)
} }
testParseAppID(t, id) testParseAppID(t, id)
} }
func testParseAppID(t *testing.T, id *ID) { func testParseAppID(t *testing.T, id *fst.ID) {
s := id.String() s := id.String()
got := new(ID) got := new(fst.ID)
if err := ParseAppID(got, s); err != nil { if err := fst.ParseAppID(got, s); err != nil {
t.Fatalf("cannot parse app ID: %v", err) t.Fatalf("cannot parse app ID: %v", err)
} }

View File

@ -1,4 +1,4 @@
package hst package fst
type Info struct { type Info struct {
User int `json:"user"` User int `json:"user"`

View File

@ -1,4 +1,4 @@
package common package fst
import ( import (
"path/filepath" "path/filepath"

View File

@ -1,4 +1,4 @@
package common package fst
import ( import (
"testing" "testing"

282
fst/sandbox.go Normal file
View File

@ -0,0 +1,282 @@
package fst
import (
"errors"
"fmt"
"io/fs"
"maps"
"path"
"slices"
"syscall"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
)
// SandboxConfig describes resources made available to the sandbox.
type (
SandboxConfig struct {
// container hostname
Hostname string `json:"hostname,omitempty"`
// extra seccomp flags
Seccomp seccomp.SyscallOpts `json:"seccomp"`
// allow ptrace and friends
Devel bool `json:"devel,omitempty"`
// allow userns creation in container
Userns bool `json:"userns,omitempty"`
// share host net namespace
Net bool `json:"net,omitempty"`
// expose main process tty
Tty bool `json:"tty,omitempty"`
// allow multiarch
Multiarch bool `json:"multiarch,omitempty"`
// initial process environment variables
Env map[string]string `json:"env"`
// map target user uid to privileged user uid in the user namespace
MapRealUID bool `json:"map_real_uid"`
// expose all devices
Dev bool `json:"dev,omitempty"`
// container host filesystem bind mounts
Filesystem []*FilesystemConfig `json:"filesystem"`
// create symlinks inside container filesystem
Link [][2]string `json:"symlink"`
// direct access to wayland socket; when this gets set no attempt is made to attach security-context-v1
// and the bare socket is mounted to the sandbox
DirectWayland bool `json:"direct_wayland,omitempty"`
// read-only /etc directory
Etc string `json:"etc,omitempty"`
// automatically set up /etc symlinks
AutoEtc bool `json:"auto_etc"`
// cover these paths or create them if they do not already exist
Cover []string `json:"cover"`
}
// SandboxSys encapsulates system functions used during [sandbox.Container] initialisation.
SandboxSys interface {
Getuid() int
Getgid() int
Paths() Paths
ReadDir(name string) ([]fs.DirEntry, error)
EvalSymlinks(path string) (string, error)
Println(v ...any)
Printf(format string, v ...any)
}
// FilesystemConfig is a representation of [sandbox.BindMount].
FilesystemConfig struct {
// mount point in container, same as src if empty
Dst string `json:"dst,omitempty"`
// host filesystem path to make available to the container
Src string `json:"src"`
// do not mount filesystem read-only
Write bool `json:"write,omitempty"`
// do not disable device files
Device bool `json:"dev,omitempty"`
// fail if the bind mount cannot be established for any reason
Must bool `json:"require,omitempty"`
}
)
// ToContainer initialises [sandbox.Params] via [SandboxConfig].
// Note that remaining container setup must be queued by the [App] implementation.
func (s *SandboxConfig) ToContainer(sys SandboxSys, uid, gid *int) (*sandbox.Params, map[string]string, error) {
if s == nil {
return nil, nil, syscall.EBADE
}
container := &sandbox.Params{
Hostname: s.Hostname,
Ops: new(sandbox.Ops),
Seccomp: s.Seccomp,
}
/* this is only 4 KiB of memory on a 64-bit system,
permissive defaults on NixOS results in around 100 entries
so this capacity should eliminate copies for most setups */
*container.Ops = slices.Grow(*container.Ops, 1<<8)
if s.Devel {
container.Flags |= sandbox.FAllowDevel
}
if s.Userns {
container.Flags |= sandbox.FAllowUserns
}
if s.Net {
container.Flags |= sandbox.FAllowNet
}
if s.Tty {
container.Flags |= sandbox.FAllowTTY
}
if s.MapRealUID {
/* some programs fail to connect to dbus session running as a different uid
so this workaround is introduced to map priv-side caller uid in container */
container.Uid = sys.Getuid()
*uid = container.Uid
container.Gid = sys.Getgid()
*gid = container.Gid
} else {
*uid = sandbox.OverflowUid()
*gid = sandbox.OverflowGid()
}
container.
Proc("/proc").
Tmpfs(Tmp, 1<<12, 0755)
if !s.Dev {
container.Dev("/dev").Mqueue("/dev/mqueue")
} else {
container.Bind("/dev", "/dev", sandbox.BindDevice)
}
/* retrieve paths and hide them if they're made available in the sandbox;
this feature tries to improve user experience of permissive defaults, and
to warn about issues in custom configuration; it is NOT a security feature
and should not be treated as such, ALWAYS be careful with what you bind */
var hidePaths []string
sc := sys.Paths()
hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath)
_, systemBusAddr := dbus.Address()
if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil {
return nil, nil, err
} else {
// there is usually only one, do not preallocate
for _, entry := range entries {
if entry.Method != "unix" {
continue
}
for _, pair := range entry.Values {
if pair[0] == "path" {
if path.IsAbs(pair[1]) {
// get parent dir of socket
dir := path.Dir(pair[1])
if dir == "." || dir == "/" {
sys.Printf("dbus socket %q is in an unusual location", pair[1])
}
hidePaths = append(hidePaths, dir)
} else {
sys.Printf("dbus socket %q is not absolute", pair[1])
}
}
}
}
}
hidePathMatch := make([]bool, len(hidePaths))
for i := range hidePaths {
if err := evalSymlinks(sys, &hidePaths[i]); err != nil {
return nil, nil, err
}
}
for _, c := range s.Filesystem {
if c == nil {
continue
}
if !path.IsAbs(c.Src) {
return nil, nil, fmt.Errorf("src path %q is not absolute", c.Src)
}
dest := c.Dst
if c.Dst == "" {
dest = c.Src
} else if !path.IsAbs(dest) {
return nil, nil, fmt.Errorf("dst path %q is not absolute", dest)
}
srcH := c.Src
if err := evalSymlinks(sys, &srcH); err != nil {
return nil, nil, err
}
for i := range hidePaths {
// skip matched entries
if hidePathMatch[i] {
continue
}
if ok, err := deepContainsH(srcH, hidePaths[i]); err != nil {
return nil, nil, err
} else if ok {
hidePathMatch[i] = true
sys.Printf("hiding paths from %q", c.Src)
}
}
var flags int
if c.Write {
flags |= sandbox.BindWritable
}
if c.Device {
flags |= sandbox.BindDevice | sandbox.BindWritable
}
if !c.Must {
flags |= sandbox.BindOptional
}
container.Bind(c.Src, dest, flags)
}
// cover matched paths
for i, ok := range hidePathMatch {
if ok {
container.Tmpfs(hidePaths[i], 1<<13, 0755)
}
}
for _, l := range s.Link {
container.Link(l[0], l[1])
}
// perf: this might work better if implemented as a setup op in container init
if !s.AutoEtc {
if s.Etc != "" {
container.Bind(s.Etc, "/etc", 0)
}
} else {
etcPath := s.Etc
if etcPath == "" {
etcPath = "/etc"
}
container.Bind(etcPath, Tmp+"/etc", 0)
// link host /etc contents to prevent dropping passwd/group bind mounts
if d, err := sys.ReadDir(etcPath); err != nil {
return nil, nil, err
} else {
for _, ent := range d {
n := ent.Name()
switch n {
case "passwd":
case "group":
case "mtab":
container.Link("/proc/mounts", "/etc/"+n)
default:
container.Link(Tmp+"/etc/"+n, "/etc/"+n)
}
}
}
}
return container, maps.Clone(s.Env), nil
}
func evalSymlinks(sys SandboxSys, v *string) error {
if p, err := sys.EvalSymlinks(*v); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
sys.Printf("path %q does not yet exist", *v)
} else {
*v = p
}
return nil
}

4
go.mod
View File

@ -1,3 +1,3 @@
module git.gensokyo.uk/security/hakurei module git.gensokyo.uk/security/fortify
go 1.24 go 1.23

View File

@ -1,17 +1,38 @@
package helper package helper
import ( import (
"bytes" "errors"
"io" "io"
"syscall" "strings"
) )
type argsWt [][]byte var (
ErrContainsNull = errors.New("argument contains null character")
)
type argsWt []string
// checks whether any element contains the null character
// must be called before args use and args must not be modified after call
func (a argsWt) check() error {
for _, arg := range a {
for _, b := range arg {
if b == '\x00' {
return ErrContainsNull
}
}
}
return nil
}
func (a argsWt) WriteTo(w io.Writer) (int64, error) { func (a argsWt) WriteTo(w io.Writer) (int64, error) {
// assuming already checked
nt := 0 nt := 0
// write null terminated arguments
for _, arg := range a { for _, arg := range a {
n, err := w.Write(arg) n, err := w.Write([]byte(arg + "\x00"))
nt += n nt += n
if err != nil { if err != nil {
@ -23,32 +44,18 @@ func (a argsWt) WriteTo(w io.Writer) (int64, error) {
} }
func (a argsWt) String() string { func (a argsWt) String() string {
return string( return strings.Join(a, " ")
bytes.TrimSuffix(
bytes.ReplaceAll(
bytes.Join(a, nil),
[]byte{0}, []byte{' '},
),
[]byte{' '},
),
)
} }
// NewCheckedArgs returns a checked null-terminated argument writer for a copy of args. // NewCheckedArgs returns a checked argument writer for args.
func NewCheckedArgs(args []string) (wt io.WriterTo, err error) { // Callers must not retain any references to args.
a := make(argsWt, len(args)) func NewCheckedArgs(args []string) (io.WriterTo, error) {
for i, arg := range args { a := argsWt(args)
a[i], err = syscall.ByteSliceFromString(arg) return a, a.check()
if err != nil {
return
}
}
wt = a
return
} }
// MustNewCheckedArgs returns a checked null-terminated argument writer for a copy of args. // MustNewCheckedArgs returns a checked argument writer for args and panics if check fails.
// If s contains a NUL byte this function panics instead of returning an error. // Callers must not retain any references to args.
func MustNewCheckedArgs(args []string) io.WriterTo { func MustNewCheckedArgs(args []string) io.WriterTo {
a, err := NewCheckedArgs(args) a, err := NewCheckedArgs(args)
if err != nil { if err != nil {

View File

@ -4,33 +4,34 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"syscall"
"testing" "testing"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestArgsString(t *testing.T) { func Test_argsFd_String(t *testing.T) {
wantString := strings.Join(wantArgs, " ") wantString := strings.Join(wantArgs, " ")
if got := argsWt.(fmt.Stringer).String(); got != wantString { if got := argsWt.(fmt.Stringer).String(); got != wantString {
t.Errorf("String: %q, want %q", t.Errorf("String(): got %v; want %v",
got, wantString) got, wantString)
} }
} }
func TestNewCheckedArgs(t *testing.T) { func TestNewCheckedArgs(t *testing.T) {
args := []string{"\x00"} args := []string{"\x00"}
if _, err := helper.NewCheckedArgs(args); !errors.Is(err, syscall.EINVAL) { if _, err := helper.NewCheckedArgs(args); !errors.Is(err, helper.ErrContainsNull) {
t.Errorf("NewCheckedArgs: error = %v, wantErr %v", t.Errorf("NewCheckedArgs(%q) error = %v, wantErr %v",
err, syscall.EINVAL) args,
err, helper.ErrContainsNull)
} }
t.Run("must panic", func(t *testing.T) { t.Run("must panic", func(t *testing.T) {
badPayload := []string{"\x00"} badPayload := []string{"\x00"}
defer func() { defer func() {
wantPanic := "invalid argument" wantPanic := "argument contains null character"
if r := recover(); r != wantPanic { if r := recover(); r != wantPanic {
t.Errorf("MustNewCheckedArgs: panic = %v, wantPanic %v", t.Errorf("MustNewCheckedArgs(%q) panic = %v, wantPanic %v",
badPayload,
r, wantPanic) r, wantPanic)
} }
}() }()

View File

@ -10,7 +10,7 @@ import (
"sync" "sync"
"syscall" "syscall"
"git.gensokyo.uk/security/hakurei/helper/proc" "git.gensokyo.uk/security/fortify/helper/proc"
) )
// NewDirect initialises a new direct Helper instance with wt as the null-terminated argument writer. // NewDirect initialises a new direct Helper instance with wt as the null-terminated argument writer.
@ -67,17 +67,17 @@ func (h *helperCmd) Start() error {
h.Env = slices.Grow(h.Env, 2) h.Env = slices.Grow(h.Env, 2)
if h.useArgsFd { if h.useArgsFd {
h.Env = append(h.Env, HakureiHelper+"=1") h.Env = append(h.Env, FortifyHelper+"=1")
} else { } else {
h.Env = append(h.Env, HakureiHelper+"=0") h.Env = append(h.Env, FortifyHelper+"=0")
} }
if h.useStatFd { if h.useStatFd {
h.Env = append(h.Env, HakureiStatus+"=1") h.Env = append(h.Env, FortifyStatus+"=1")
// stat is populated on fulfill // stat is populated on fulfill
h.Cancel = func() error { return h.stat.Close() } h.Cancel = func() error { return h.stat.Close() }
} else { } else {
h.Env = append(h.Env, HakureiStatus+"=0") h.Env = append(h.Env, FortifyStatus+"=0")
} }
return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, h.Cmd.Start, h.files, h.extraFiles) return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, h.Cmd.Start, h.files, h.extraFiles)

View File

@ -8,12 +8,12 @@ import (
"os/exec" "os/exec"
"testing" "testing"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestCmd(t *testing.T) { func TestCmd(t *testing.T) {
t.Run("start non-existent helper path", func(t *testing.T) { t.Run("start non-existent helper path", func(t *testing.T) {
h := helper.NewDirect(t.Context(), "/proc/nonexistent", argsWt, false, argF, nil, nil) h := helper.NewDirect(context.Background(), "/proc/nonexistent", argsWt, false, argF, nil, nil)
if err := h.Start(); !errors.Is(err, os.ErrNotExist) { if err := h.Start(); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start: error = %v, wantErr %v", t.Errorf("Start: error = %v, wantErr %v",
@ -22,9 +22,9 @@ func TestCmd(t *testing.T) {
}) })
t.Run("valid new helper nil check", func(t *testing.T) { t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.NewDirect(t.Context(), "hakurei", argsWt, false, argF, nil, nil); got == nil { if got := helper.NewDirect(context.TODO(), "fortify", argsWt, false, argF, nil, nil); got == nil {
t.Errorf("NewDirect(%q, %q) got nil", t.Errorf("NewDirect(%q, %q) got nil",
argsWt, "hakurei") argsWt, "fortify")
return return
} }
}) })

View File

@ -9,8 +9,8 @@ import (
"slices" "slices"
"sync" "sync"
"git.gensokyo.uk/security/hakurei/helper/proc" "git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/hakurei/sandbox" "git.gensokyo.uk/security/fortify/sandbox"
) )
// New initialises a Helper instance with wt as the null-terminated argument writer. // New initialises a Helper instance with wt as the null-terminated argument writer.
@ -54,17 +54,17 @@ func (h *helperContainer) Start() error {
h.Env = slices.Grow(h.Env, 2) h.Env = slices.Grow(h.Env, 2)
if h.useArgsFd { if h.useArgsFd {
h.Env = append(h.Env, HakureiHelper+"=1") h.Env = append(h.Env, FortifyHelper+"=1")
} else { } else {
h.Env = append(h.Env, HakureiHelper+"=0") h.Env = append(h.Env, FortifyHelper+"=0")
} }
if h.useStatFd { if h.useStatFd {
h.Env = append(h.Env, HakureiStatus+"=1") h.Env = append(h.Env, FortifyStatus+"=1")
// stat is populated on fulfill // stat is populated on fulfill
h.Cancel = func(*exec.Cmd) error { return h.stat.Close() } h.Cancel = func(*exec.Cmd) error { return h.stat.Close() }
} else { } else {
h.Env = append(h.Env, HakureiStatus+"=0") h.Env = append(h.Env, FortifyStatus+"=0")
} }
return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, func() error { return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, func() error {

View File

@ -7,15 +7,15 @@ import (
"os/exec" "os/exec"
"testing" "testing"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/hakurei/internal" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/hakurei/sandbox" "git.gensokyo.uk/security/fortify/sandbox"
) )
func TestContainer(t *testing.T) { func TestContainer(t *testing.T) {
t.Run("start empty container", func(t *testing.T) { t.Run("start empty container", func(t *testing.T) {
h := helper.New(t.Context(), "/nonexistent", argsWt, false, argF, nil, nil) h := helper.New(context.Background(), "/nonexistent", argsWt, false, argF, nil, nil)
wantErr := "sandbox: starting an empty container" wantErr := "sandbox: starting an empty container"
if err := h.Start(); err == nil || err.Error() != wantErr { if err := h.Start(); err == nil || err.Error() != wantErr {
@ -25,9 +25,9 @@ func TestContainer(t *testing.T) {
}) })
t.Run("valid new helper nil check", func(t *testing.T) { t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.New(t.Context(), "hakurei", argsWt, false, argF, nil, nil); got == nil { if got := helper.New(context.TODO(), "fortify", argsWt, false, argF, nil, nil); got == nil {
t.Errorf("New(%q, %q) got nil", t.Errorf("New(%q, %q) got nil",
argsWt, "hakurei") argsWt, "fortify")
return return
} }
}) })
@ -52,6 +52,6 @@ func TestHelperInit(t *testing.T) {
if len(os.Args) != 5 || os.Args[4] != "init" { if len(os.Args) != 5 || os.Args[4] != "init" {
return return
} }
sandbox.SetOutput(hlog.Output{}) sandbox.SetOutput(fmsg.Output{})
sandbox.Init(hlog.Prepare, func(bool) { internal.InstallFmsg(false) }) sandbox.Init(fmsg.Prepare, func(bool) { internal.InstallFmsg(false) })
} }

View File

@ -8,16 +8,16 @@ import (
"os" "os"
"time" "time"
"git.gensokyo.uk/security/hakurei/helper/proc" "git.gensokyo.uk/security/fortify/helper/proc"
) )
var WaitDelay = 2 * time.Second var WaitDelay = 2 * time.Second
const ( const (
// HakureiHelper is set to 1 when args fd is enabled and 0 otherwise. // FortifyHelper is set to 1 when args fd is enabled and 0 otherwise.
HakureiHelper = "HAKUREI_HELPER" FortifyHelper = "FORTIFY_HELPER"
// HakureiStatus is set to 1 when stat fd is enabled and 0 otherwise. // FortifyStatus is set to 1 when stat fd is enabled and 0 otherwise.
HakureiStatus = "HAKUREI_STATUS" FortifyStatus = "FORTIFY_STATUS"
) )
type Helper interface { type Helper interface {

View File

@ -5,19 +5,18 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
) )
var ( var (
wantArgs = []string{ wantArgs = []string{
"unix:path=/run/dbus/system_bus_socket", "unix:path=/run/dbus/system_bus_socket",
"/tmp/hakurei.1971/12622d846cc3fe7b4c10359d01f0eb47/system_bus_socket", "/tmp/fortify.1971/12622d846cc3fe7b4c10359d01f0eb47/system_bus_socket",
"--filter", "--filter",
"--talk=org.bluez", "--talk=org.bluez",
"--talk=org.freedesktop.Avahi", "--talk=org.freedesktop.Avahi",
@ -55,9 +54,9 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
t.Cleanup(func() { helper.WaitDelay = oldWaitDelay }) t.Cleanup(func() { helper.WaitDelay = oldWaitDelay })
t.Run("start helper with status channel and wait", func(t *testing.T) { t.Run("start helper with status channel and wait", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
stdout := new(strings.Builder) stdout, stderr := new(strings.Builder), new(strings.Builder)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, true) h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, stderr }, true)
t.Run("wait not yet started helper", func(t *testing.T) { t.Run("wait not yet started helper", func(t *testing.T) {
defer func() { defer func() {
@ -89,8 +88,8 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
t.Log("waiting on helper") t.Log("waiting on helper")
if err := h.Wait(); !errors.Is(err, context.Canceled) { if err := h.Wait(); !errors.Is(err, context.Canceled) {
t.Errorf("Wait: error = %v", t.Errorf("Wait() err = %v stderr = %s",
err) err, stderr)
} }
t.Run("wait already finalised helper", func(t *testing.T) { t.Run("wait already finalised helper", func(t *testing.T) {
@ -102,36 +101,32 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
} }
}) })
if got := trimStdout(stdout); got != wantPayload { if got := stderr.String(); got != wantPayload {
t.Errorf("Start: stdout = %q, want %q", t.Errorf("Start: stderr = %v, want %v",
got, wantPayload) got, wantPayload)
} }
}) })
t.Run("start helper and wait", func(t *testing.T) { t.Run("start helper and wait", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
stdout := new(strings.Builder) stdout, stderr := new(strings.Builder), new(strings.Builder)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, false) h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, stderr }, false)
if err := h.Start(); err != nil { if err := h.Start(); err != nil {
t.Errorf("Start: error = %v", t.Errorf("Start() error = %v",
err) err)
return return
} }
if err := h.Wait(); err != nil { if err := h.Wait(); err != nil {
t.Errorf("Wait: error = %v stdout = %q", t.Errorf("Wait() err = %v stdout = %s stderr = %s",
err, stdout) err, stdout, stderr)
} }
if got := trimStdout(stdout); got != wantPayload { if got := stderr.String(); got != wantPayload {
t.Errorf("Start: stdout = %q, want %q", t.Errorf("Start() stderr = %v, want %v",
got, wantPayload) got, wantPayload)
} }
}) })
} }
func trimStdout(stdout fmt.Stringer) string {
return strings.TrimPrefix(stdout.String(), "=== RUN TestHelperInit\n")
}

View File

@ -14,13 +14,13 @@ import (
func InternalHelperStub() { func InternalHelperStub() {
// this test mocks the helper process // this test mocks the helper process
var ap, sp string var ap, sp string
if v, ok := os.LookupEnv(HakureiHelper); !ok { if v, ok := os.LookupEnv(FortifyHelper); !ok {
return return
} else { } else {
ap = v ap = v
} }
if v, ok := os.LookupEnv(HakureiStatus); !ok { if v, ok := os.LookupEnv(FortifyStatus); !ok {
panic(HakureiStatus) panic(FortifyStatus)
} else { } else {
sp = v sp = v
} }
@ -63,7 +63,7 @@ func flagRestoreFiles(offset int, ap, sp string) (argsFile, statFile *os.File) {
func genericStub(argsFile, statFile *os.File) { func genericStub(argsFile, statFile *os.File) {
if argsFile != nil { if argsFile != nil {
// this output is checked by parent // this output is checked by parent
if _, err := io.Copy(os.Stdout, argsFile); err != nil { if _, err := io.Copy(os.Stderr, argsFile); err != nil {
panic("cannot read args: " + err.Error()) panic("cannot read args: " + err.Error())
} }
} }

View File

@ -3,7 +3,7 @@ package helper_test
import ( import (
"testing" "testing"
"git.gensokyo.uk/security/hakurei/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestHelperStub(t *testing.T) { helper.InternalHelperStub() } func TestHelperStub(t *testing.T) { helper.InternalHelperStub() }

View File

@ -1,83 +0,0 @@
// Package hst exports shared types for invoking hakurei.
package hst
import (
"git.gensokyo.uk/security/hakurei/dbus"
"git.gensokyo.uk/security/hakurei/system"
)
const Tmp = "/.hakurei"
// Config is used to seal an app implementation.
type Config struct {
// reverse-DNS style arbitrary identifier string from config;
// passed to wayland security-context-v1 as application ID
// and used as part of defaults in dbus session proxy
ID string `json:"id"`
// absolute path to executable file
Path string `json:"path,omitempty"`
// final args passed to container init
Args []string `json:"args"`
// system services to make available in the container
Enablements system.Enablement `json:"enablements"`
// session D-Bus proxy configuration;
// nil makes session bus proxy assume built-in defaults
SessionBus *dbus.Config `json:"session_bus,omitempty"`
// system D-Bus proxy configuration;
// nil disables system bus proxy
SystemBus *dbus.Config `json:"system_bus,omitempty"`
// direct access to wayland socket; when this gets set no attempt is made to attach security-context-v1
// and the bare socket is mounted to the sandbox
DirectWayland bool `json:"direct_wayland,omitempty"`
// passwd username in container, defaults to passwd name of target uid or chronos
Username string `json:"username,omitempty"`
// absolute path to shell, empty for host shell
Shell string `json:"shell,omitempty"`
// absolute path to home directory in the init mount namespace
Data string `json:"data"`
// directory to enter and use as home in the container mount namespace, empty for Data
Dir string `json:"dir"`
// extra acl ops, dispatches before container init
ExtraPerms []*ExtraPermConfig `json:"extra_perms,omitempty"`
// numerical application id, used for init user namespace credentials
Identity int `json:"identity"`
// list of supplementary groups inherited by container processes
Groups []string `json:"groups"`
// abstract container configuration baseline
Container *ContainerConfig `json:"container"`
}
// ExtraPermConfig describes an acl update op.
type ExtraPermConfig struct {
Ensure bool `json:"ensure,omitempty"`
Path string `json:"path"`
Read bool `json:"r,omitempty"`
Write bool `json:"w,omitempty"`
Execute bool `json:"x,omitempty"`
}
func (e *ExtraPermConfig) String() string {
buf := make([]byte, 0, 5+len(e.Path))
buf = append(buf, '-', '-', '-')
if e.Ensure {
buf = append(buf, '+')
}
buf = append(buf, ':')
buf = append(buf, []byte(e.Path)...)
if e.Read {
buf[0] = 'r'
}
if e.Write {
buf[1] = 'w'
}
if e.Execute {
buf[2] = 'x'
}
return string(buf)
}

View File

@ -1,59 +0,0 @@
package hst
import (
"git.gensokyo.uk/security/hakurei/sandbox/seccomp"
)
type (
// ContainerConfig describes the container configuration baseline to which the app implementation adds upon.
ContainerConfig struct {
// container hostname
Hostname string `json:"hostname,omitempty"`
// extra seccomp flags
Seccomp seccomp.FilterOpts `json:"seccomp"`
// allow ptrace and friends
Devel bool `json:"devel,omitempty"`
// allow userns creation in container
Userns bool `json:"userns,omitempty"`
// share host net namespace
Net bool `json:"net,omitempty"`
// allow dangerous terminal I/O
Tty bool `json:"tty,omitempty"`
// allow multiarch
Multiarch bool `json:"multiarch,omitempty"`
// initial process environment variables
Env map[string]string `json:"env"`
// map target user uid to privileged user uid in the user namespace
MapRealUID bool `json:"map_real_uid"`
// pass through all devices
Device bool `json:"device,omitempty"`
// container host filesystem bind mounts
Filesystem []*FilesystemConfig `json:"filesystem"`
// create symlinks inside container filesystem
Link [][2]string `json:"symlink"`
// read-only /etc directory
Etc string `json:"etc,omitempty"`
// automatically set up /etc symlinks
AutoEtc bool `json:"auto_etc"`
// cover these paths or create them if they do not already exist
Cover []string `json:"cover"`
}
// FilesystemConfig is an abstract representation of a bind mount.
FilesystemConfig struct {
// mount point in container, same as src if empty
Dst string `json:"dst,omitempty"`
// host filesystem path to make available to the container
Src string `json:"src"`
// do not mount filesystem read-only
Write bool `json:"write,omitempty"`
// do not disable device files
Device bool `json:"dev,omitempty"`
// fail if the bind mount cannot be established for any reason
Must bool `json:"require,omitempty"`
}
)

View File

@ -1,91 +0,0 @@
package hst
import (
"git.gensokyo.uk/security/hakurei/dbus"
"git.gensokyo.uk/security/hakurei/sandbox/seccomp"
"git.gensokyo.uk/security/hakurei/system"
)
// Template returns a fully populated instance of Config.
func Template() *Config {
return &Config{
ID: "org.chromium.Chromium",
Path: "/run/current-system/sw/bin/chromium",
Args: []string{
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
},
Enablements: system.EWayland | system.EDBus | system.EPulse,
SessionBus: &dbus.Config{
See: nil,
Talk: []string{"org.freedesktop.Notifications", "org.freedesktop.FileManager1", "org.freedesktop.ScreenSaver",
"org.freedesktop.secrets", "org.kde.kwalletd5", "org.kde.kwalletd6", "org.gnome.SessionManager"},
Own: []string{"org.chromium.Chromium.*", "org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"},
Call: map[string]string{"org.freedesktop.portal.*": "*"},
Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
Log: false,
Filter: true,
},
SystemBus: &dbus.Config{
See: nil,
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Own: nil,
Call: nil,
Broadcast: nil,
Log: false,
Filter: true,
},
DirectWayland: false,
Username: "chronos",
Shell: "/run/current-system/sw/bin/zsh",
Data: "/var/lib/hakurei/u0/org.chromium.Chromium",
Dir: "/data/data/org.chromium.Chromium",
ExtraPerms: []*ExtraPermConfig{
{Path: "/var/lib/hakurei/u0", Ensure: true, Execute: true},
{Path: "/var/lib/hakurei/u0/org.chromium.Chromium", Read: true, Write: true, Execute: true},
},
Identity: 9,
Groups: []string{"video", "dialout", "plugdev"},
Container: &ContainerConfig{
Hostname: "localhost",
Devel: true,
Userns: true,
Net: true,
Device: true,
Seccomp: seccomp.FilterMultiarch,
Tty: true,
Multiarch: true,
MapRealUID: true,
// example API credentials pulled from Google Chrome
// DO NOT USE THESE IN A REAL BROWSER
Env: map[string]string{
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT",
},
Filesystem: []*FilesystemConfig{
{Src: "/nix/store"},
{Src: "/run/current-system"},
{Src: "/run/opengl-driver"},
{Src: "/var/db/nix-channels"},
{Src: "/var/lib/hakurei/u0/org.chromium.Chromium",
Dst: "/data/data/org.chromium.Chromium", Write: true, Must: true},
{Src: "/dev/dri", Device: true},
},
Link: [][2]string{{"/run/user/65534", "/run/user/150"}},
Etc: "/etc",
AutoEtc: true,
Cover: []string{"/var/run/nscd"},
},
}
}

View File

@ -1,140 +0,0 @@
package hst_test
import (
"encoding/json"
"testing"
"git.gensokyo.uk/security/hakurei/hst"
)
func TestTemplate(t *testing.T) {
const want = `{
"id": "org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium",
"args": [
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland"
],
"enablements": 13,
"session_bus": {
"see": null,
"talk": [
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager"
],
"own": [
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"
],
"call": {
"org.freedesktop.portal.*": "*"
},
"broadcast": {
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"
},
"filter": true
},
"system_bus": {
"see": null,
"talk": [
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower"
],
"own": null,
"call": null,
"broadcast": null,
"filter": true
},
"username": "chronos",
"shell": "/run/current-system/sw/bin/zsh",
"data": "/var/lib/hakurei/u0/org.chromium.Chromium",
"dir": "/data/data/org.chromium.Chromium",
"extra_perms": [
{
"ensure": true,
"path": "/var/lib/hakurei/u0",
"x": true
},
{
"path": "/var/lib/hakurei/u0/org.chromium.Chromium",
"r": true,
"w": true,
"x": true
}
],
"identity": 9,
"groups": [
"video",
"dialout",
"plugdev"
],
"container": {
"hostname": "localhost",
"seccomp": 32,
"devel": true,
"userns": true,
"net": true,
"tty": true,
"multiarch": true,
"env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
},
"map_real_uid": true,
"device": true,
"filesystem": [
{
"src": "/nix/store"
},
{
"src": "/run/current-system"
},
{
"src": "/run/opengl-driver"
},
{
"src": "/var/db/nix-channels"
},
{
"dst": "/data/data/org.chromium.Chromium",
"src": "/var/lib/hakurei/u0/org.chromium.Chromium",
"write": true,
"require": true
},
{
"src": "/dev/dri",
"dev": true
}
],
"symlink": [
[
"/run/user/65534",
"/run/user/150"
]
],
"etc": "/etc",
"auto_etc": true,
"cover": [
"/var/run/nscd"
]
}
}`
if p, err := json.MarshalIndent(hst.Template(), "", "\t"); err != nil {
t.Fatalf("cannot marshal: %v", err)
} else if s := string(p); s != want {
t.Fatalf("Template:\n%s\nwant:\n%s",
s, want)
}
}

View File

@ -1,59 +1,82 @@
// Package app defines the generic [App] interface.
package app package app
import ( import (
"syscall" "context"
"time" "fmt"
"log"
"sync"
"git.gensokyo.uk/security/hakurei/hst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/sys"
) )
type App interface { func New(ctx context.Context, os sys.State) (fst.App, error) {
// ID returns a copy of [ID] held by App. a := new(app)
ID() ID a.sys = os
a.ctx = ctx
// Seal determines the outcome of config as a [SealedApp]. id := new(fst.ID)
// The value of config might be overwritten and must not be used again. err := fst.NewAppID(id)
Seal(config *hst.Config) (SealedApp, error) a.id = newID(id)
String() string return a, err
} }
type SealedApp interface { func MustNew(ctx context.Context, os sys.State) fst.App {
// Run commits sealed system setup and starts the app process. a, err := New(ctx, os)
Run(rs *RunState) error if err != nil {
} log.Fatalf("cannot create app: %v", err)
// RunState stores the outcome of a call to [SealedApp.Run].
type RunState struct {
// Time is the exact point in time where the process was created.
// Location must be set to UTC.
//
// Time is nil if no process was ever created.
Time *time.Time
// RevertErr is stored by the deferred revert call.
RevertErr error
// WaitErr is the generic error value created by the standard library.
WaitErr error
syscall.WaitStatus
}
// SetStart stores the current time in [RunState] once.
func (rs *RunState) SetStart() {
if rs.Time != nil {
panic("attempted to store time twice")
} }
now := time.Now().UTC() return a
rs.Time = &now
} }
// Paths contains environment-dependent paths used by hakurei. type app struct {
type Paths struct { id *stringPair[fst.ID]
// path to shared directory (usually `/tmp/hakurei.%d`) sys sys.State
SharePath string `json:"share_path"` ctx context.Context
// XDG_RUNTIME_DIR value (usually `/run/user/%d`)
RuntimePath string `json:"runtime_path"` *outcome
// application runtime directory (usually `/run/user/%d/hakurei`) mu sync.RWMutex
RunDirPath string `json:"run_dir_path"` }
func (a *app) ID() fst.ID { a.mu.RLock(); defer a.mu.RUnlock(); return a.id.unwrap() }
func (a *app) String() string {
if a == nil {
return "(invalid app)"
}
a.mu.RLock()
defer a.mu.RUnlock()
if a.outcome != nil {
if a.outcome.user.uid == nil {
return fmt.Sprintf("(sealed app %s with invalid uid)", a.id)
}
return fmt.Sprintf("(sealed app %s as uid %s)", a.id, a.outcome.user.uid)
}
return fmt.Sprintf("(unsealed app %s)", a.id)
}
func (a *app) Seal(config *fst.Config) (fst.SealedApp, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.outcome != nil {
panic("app sealed twice")
}
if config == nil {
return nil, fmsg.WrapError(ErrConfig,
"attempted to seal app with nil config")
}
seal := new(outcome)
seal.id = a.id
err := seal.finalise(a.ctx, a.sys, config)
if err == nil {
a.outcome = seal
}
return seal, err
} }

View File

@ -0,0 +1,219 @@
package app_test
import (
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/system"
)
var testCasesNixos = []sealTestCase{
{
"nixos chromium direct wayland", new(stubNixOS),
&fst.Config{
ID: "org.chromium.Chromium",
Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start",
Confinement: fst.ConfinementConfig{
AppID: 1, Groups: []string{}, Username: "u0_a1",
Outer: "/var/lib/persist/module/fortify/0/1",
Sandbox: &fst.SandboxConfig{
Userns: true, Net: true, MapRealUID: true, DirectWayland: true, Env: nil, AutoEtc: true,
Filesystem: []*fst.FilesystemConfig{
{Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true},
{Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true},
{Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"},
{Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true},
},
Cover: []string{"/var/run/nscd"},
},
SystemBus: &dbus.Config{
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Filter: true,
},
SessionBus: &dbus.Config{
Talk: []string{
"org.freedesktop.FileManager1", "org.freedesktop.Notifications",
"org.freedesktop.ScreenSaver", "org.freedesktop.secrets",
"org.kde.kwalletd5", "org.kde.kwalletd6",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{}, Broadcast: map[string]string{},
Filter: true,
},
Enablements: system.EWayland | system.EDBus | system.EPulse,
},
},
fst.ID{
0x8e, 0x2c, 0x76, 0xb0,
0x66, 0xda, 0xbe, 0x57,
0x4c, 0xf0, 0x73, 0xbd,
0xb4, 0x6e, 0xb5, 0xc1,
},
system.New(1000001).
Ensure("/tmp/fortify.1971", 0711).
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
Ephemeral(system.Process, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/1", acl.Read, acl.Write, acl.Execute).
UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute).
Link("/run/user/1971/pulse/native", "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/pulse").
CopyFile(nil, "/home/ophestra/xdg/config/pulse/cookie", 256, 256).
MustProxyDBus("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", &dbus.Config{
Talk: []string{
"org.freedesktop.FileManager1", "org.freedesktop.Notifications",
"org.freedesktop.ScreenSaver", "org.freedesktop.secrets",
"org.kde.kwalletd5", "org.kde.kwalletd6",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{}, Broadcast: map[string]string{},
Filter: true,
}, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
}).
UpdatePerm("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write),
&sandbox.Params{
Uid: 1971,
Gid: 100,
Flags: sandbox.FAllowNet | sandbox.FAllowUserns,
Dir: "/var/lib/persist/module/fortify/0/1",
Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start",
Args: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"},
Env: []string{
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus",
"DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket",
"HOME=/var/lib/persist/module/fortify/0/1",
"PULSE_COOKIE=" + fst.Tmp + "/pulse-cookie",
"PULSE_SERVER=unix:/run/user/1971/pulse/native",
"TERM=xterm-256color",
"USER=u0_a1",
"WAYLAND_DISPLAY=wayland-0",
"XDG_RUNTIME_DIR=/run/user/1971",
"XDG_SESSION_CLASS=user",
"XDG_SESSION_TYPE=tty",
},
Ops: new(sandbox.Ops).
Proc("/proc").
Tmpfs(fst.Tmp, 4096, 0755).
Dev("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", 0).
Bind("/usr/bin", "/usr/bin", 0).
Bind("/nix/store", "/nix/store", 0).
Bind("/run/current-system", "/run/current-system", 0).
Bind("/sys/block", "/sys/block", sandbox.BindOptional).
Bind("/sys/bus", "/sys/bus", sandbox.BindOptional).
Bind("/sys/class", "/sys/class", sandbox.BindOptional).
Bind("/sys/dev", "/sys/dev", sandbox.BindOptional).
Bind("/sys/devices", "/sys/devices", sandbox.BindOptional).
Bind("/run/opengl-driver", "/run/opengl-driver", 0).
Bind("/dev/dri", "/dev/dri", sandbox.BindDevice|sandbox.BindWritable|sandbox.BindOptional).
Bind("/etc", fst.Tmp+"/etc", 0).
Link(fst.Tmp+"/etc/alsa", "/etc/alsa").
Link(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Link(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Link(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Link(fst.Tmp+"/etc/default", "/etc/default").
Link(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Link(fst.Tmp+"/etc/fonts", "/etc/fonts").
Link(fst.Tmp+"/etc/fstab", "/etc/fstab").
Link(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Link(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Link(fst.Tmp+"/etc/hostid", "/etc/hostid").
Link(fst.Tmp+"/etc/hostname", "/etc/hostname").
Link(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Link(fst.Tmp+"/etc/hosts", "/etc/hosts").
Link(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Link(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Link(fst.Tmp+"/etc/issue", "/etc/issue").
Link(fst.Tmp+"/etc/kbd", "/etc/kbd").
Link(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Link(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Link(fst.Tmp+"/etc/localtime", "/etc/localtime").
Link(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Link(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Link(fst.Tmp+"/etc/lvm", "/etc/lvm").
Link(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Link(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Link(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Link(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Link("/proc/mounts", "/etc/mtab").
Link(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Link(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Link(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Link(fst.Tmp+"/etc/nix", "/etc/nix").
Link(fst.Tmp+"/etc/nixos", "/etc/nixos").
Link(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Link(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Link(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Link(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Link(fst.Tmp+"/etc/os-release", "/etc/os-release").
Link(fst.Tmp+"/etc/pam", "/etc/pam").
Link(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Link(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Link(fst.Tmp+"/etc/pki", "/etc/pki").
Link(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Link(fst.Tmp+"/etc/profile", "/etc/profile").
Link(fst.Tmp+"/etc/protocols", "/etc/protocols").
Link(fst.Tmp+"/etc/qemu", "/etc/qemu").
Link(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Link(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Link(fst.Tmp+"/etc/rpc", "/etc/rpc").
Link(fst.Tmp+"/etc/samba", "/etc/samba").
Link(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Link(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Link(fst.Tmp+"/etc/services", "/etc/services").
Link(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Link(fst.Tmp+"/etc/shadow", "/etc/shadow").
Link(fst.Tmp+"/etc/shells", "/etc/shells").
Link(fst.Tmp+"/etc/ssh", "/etc/ssh").
Link(fst.Tmp+"/etc/ssl", "/etc/ssl").
Link(fst.Tmp+"/etc/static", "/etc/static").
Link(fst.Tmp+"/etc/subgid", "/etc/subgid").
Link(fst.Tmp+"/etc/subuid", "/etc/subuid").
Link(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Link(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Link(fst.Tmp+"/etc/systemd", "/etc/systemd").
Link(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Link(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Link(fst.Tmp+"/etc/udev", "/etc/udev").
Link(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Link(fst.Tmp+"/etc/UPower", "/etc/UPower").
Link(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Link(fst.Tmp+"/etc/X11", "/etc/X11").
Link(fst.Tmp+"/etc/zfs", "/etc/zfs").
Link(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Link(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Link(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 4096, 0755).
Tmpfs("/run/user/1971", 8388608, 0700).
Bind("/tmp/fortify.1971/tmpdir/1", "/tmp", sandbox.BindWritable).
Bind("/var/lib/persist/module/fortify/0/1", "/var/lib/persist/module/fortify/0/1", sandbox.BindWritable).
Place("/etc/passwd", []byte("u0_a1:x:1971:100:Fortify:/var/lib/persist/module/fortify/0/1:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("fortify:x:100:\n")).
Bind("/run/user/1971/wayland-0", "/run/user/1971/wayland-0", 0).
Bind("/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/pulse", "/run/user/1971/pulse/native", 0).
Place(fst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", "/run/user/1971/bus", 0).
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", "/run/dbus/system_bus_socket", 0).
Tmpfs("/var/run/nscd", 8192, 0755),
},
},
}

382
internal/app/app_pd_test.go Normal file
View File

@ -0,0 +1,382 @@
package app_test
import (
"os"
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/system"
)
var testCasesPd = []sealTestCase{
{
"nixos permissive defaults no enablements", new(stubNixOS),
&fst.Config{
Confinement: fst.ConfinementConfig{
AppID: 0,
Username: "chronos",
Outer: "/home/chronos",
},
},
fst.ID{
0x4a, 0x45, 0x0b, 0x65,
0x96, 0xd7, 0xbc, 0x15,
0xbd, 0x01, 0x78, 0x0e,
0xb9, 0xa6, 0x07, 0xac,
},
system.New(1000000).
Ensure("/tmp/fortify.1971", 0711).
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
Ephemeral(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute),
&sandbox.Params{
Flags: sandbox.FAllowNet | sandbox.FAllowUserns | sandbox.FAllowTTY,
Dir: "/home/chronos",
Path: "/run/current-system/sw/bin/zsh",
Args: []string{"/run/current-system/sw/bin/zsh"},
Env: []string{
"HOME=/home/chronos",
"TERM=xterm-256color",
"USER=chronos",
"XDG_RUNTIME_DIR=/run/user/65534",
"XDG_SESSION_CLASS=user",
"XDG_SESSION_TYPE=tty",
},
Ops: new(sandbox.Ops).
Proc("/proc").
Tmpfs(fst.Tmp, 4096, 0755).
Dev("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", sandbox.BindWritable).
Bind("/boot", "/boot", sandbox.BindWritable).
Bind("/home", "/home", sandbox.BindWritable).
Bind("/lib", "/lib", sandbox.BindWritable).
Bind("/lib64", "/lib64", sandbox.BindWritable).
Bind("/nix", "/nix", sandbox.BindWritable).
Bind("/root", "/root", sandbox.BindWritable).
Bind("/run", "/run", sandbox.BindWritable).
Bind("/srv", "/srv", sandbox.BindWritable).
Bind("/sys", "/sys", sandbox.BindWritable).
Bind("/usr", "/usr", sandbox.BindWritable).
Bind("/var", "/var", sandbox.BindWritable).
Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional).
Tmpfs("/run/user/1971", 8192, 0755).
Tmpfs("/run/dbus", 8192, 0755).
Bind("/etc", fst.Tmp+"/etc", 0).
Link(fst.Tmp+"/etc/alsa", "/etc/alsa").
Link(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Link(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Link(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Link(fst.Tmp+"/etc/default", "/etc/default").
Link(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Link(fst.Tmp+"/etc/fonts", "/etc/fonts").
Link(fst.Tmp+"/etc/fstab", "/etc/fstab").
Link(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Link(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Link(fst.Tmp+"/etc/hostid", "/etc/hostid").
Link(fst.Tmp+"/etc/hostname", "/etc/hostname").
Link(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Link(fst.Tmp+"/etc/hosts", "/etc/hosts").
Link(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Link(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Link(fst.Tmp+"/etc/issue", "/etc/issue").
Link(fst.Tmp+"/etc/kbd", "/etc/kbd").
Link(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Link(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Link(fst.Tmp+"/etc/localtime", "/etc/localtime").
Link(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Link(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Link(fst.Tmp+"/etc/lvm", "/etc/lvm").
Link(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Link(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Link(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Link(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Link("/proc/mounts", "/etc/mtab").
Link(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Link(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Link(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Link(fst.Tmp+"/etc/nix", "/etc/nix").
Link(fst.Tmp+"/etc/nixos", "/etc/nixos").
Link(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Link(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Link(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Link(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Link(fst.Tmp+"/etc/os-release", "/etc/os-release").
Link(fst.Tmp+"/etc/pam", "/etc/pam").
Link(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Link(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Link(fst.Tmp+"/etc/pki", "/etc/pki").
Link(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Link(fst.Tmp+"/etc/profile", "/etc/profile").
Link(fst.Tmp+"/etc/protocols", "/etc/protocols").
Link(fst.Tmp+"/etc/qemu", "/etc/qemu").
Link(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Link(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Link(fst.Tmp+"/etc/rpc", "/etc/rpc").
Link(fst.Tmp+"/etc/samba", "/etc/samba").
Link(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Link(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Link(fst.Tmp+"/etc/services", "/etc/services").
Link(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Link(fst.Tmp+"/etc/shadow", "/etc/shadow").
Link(fst.Tmp+"/etc/shells", "/etc/shells").
Link(fst.Tmp+"/etc/ssh", "/etc/ssh").
Link(fst.Tmp+"/etc/ssl", "/etc/ssl").
Link(fst.Tmp+"/etc/static", "/etc/static").
Link(fst.Tmp+"/etc/subgid", "/etc/subgid").
Link(fst.Tmp+"/etc/subuid", "/etc/subuid").
Link(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Link(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Link(fst.Tmp+"/etc/systemd", "/etc/systemd").
Link(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Link(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Link(fst.Tmp+"/etc/udev", "/etc/udev").
Link(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Link(fst.Tmp+"/etc/UPower", "/etc/UPower").
Link(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Link(fst.Tmp+"/etc/X11", "/etc/X11").
Link(fst.Tmp+"/etc/zfs", "/etc/zfs").
Link(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Link(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Link(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 4096, 0755).
Tmpfs("/run/user/65534", 8388608, 0700).
Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", sandbox.BindWritable).
Bind("/home/chronos", "/home/chronos", sandbox.BindWritable).
Place("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("fortify:x:65534:\n")).
Tmpfs("/var/run/nscd", 8192, 0755),
},
},
{
"nixos permissive defaults chromium", new(stubNixOS),
&fst.Config{
ID: "org.chromium.Chromium",
Args: []string{"zsh", "-c", "exec chromium "},
Confinement: fst.ConfinementConfig{
AppID: 9,
Groups: []string{"video"},
Username: "chronos",
Outer: "/home/chronos",
SessionBus: &dbus.Config{
Talk: []string{
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{
"org.freedesktop.portal.*": "*",
},
Broadcast: map[string]string{
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*",
},
Filter: true,
},
SystemBus: &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
},
Enablements: system.EWayland | system.EDBus | system.EPulse,
},
},
fst.ID{
0xeb, 0xf0, 0x83, 0xd1,
0xb1, 0x75, 0x91, 0x17,
0x82, 0xd4, 0x13, 0x36,
0x9b, 0x64, 0xce, 0x7c,
},
system.New(1000009).
Ensure("/tmp/fortify.1971", 0711).
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
Ephemeral(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/9", acl.Read, acl.Write, acl.Execute).
Ensure("/tmp/fortify.1971/wayland", 0711).
Wayland(new(*os.File), "/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c").
Link("/run/user/1971/pulse/native", "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse").
CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 256, 256).
MustProxyDBus("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{
Talk: []string{
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{
"org.freedesktop.portal.*": "*",
},
Broadcast: map[string]string{
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*",
},
Filter: true,
}, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
}).
UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write),
&sandbox.Params{
Flags: sandbox.FAllowNet | sandbox.FAllowUserns | sandbox.FAllowTTY,
Dir: "/home/chronos",
Path: "/run/current-system/sw/bin/zsh",
Args: []string{"zsh", "-c", "exec chromium "},
Env: []string{
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
"DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket",
"HOME=/home/chronos",
"PULSE_COOKIE=" + fst.Tmp + "/pulse-cookie",
"PULSE_SERVER=unix:/run/user/65534/pulse/native",
"TERM=xterm-256color",
"USER=chronos",
"WAYLAND_DISPLAY=wayland-0",
"XDG_RUNTIME_DIR=/run/user/65534",
"XDG_SESSION_CLASS=user",
"XDG_SESSION_TYPE=tty",
},
Ops: new(sandbox.Ops).
Proc("/proc").
Tmpfs(fst.Tmp, 4096, 0755).
Dev("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", sandbox.BindWritable).
Bind("/boot", "/boot", sandbox.BindWritable).
Bind("/home", "/home", sandbox.BindWritable).
Bind("/lib", "/lib", sandbox.BindWritable).
Bind("/lib64", "/lib64", sandbox.BindWritable).
Bind("/nix", "/nix", sandbox.BindWritable).
Bind("/root", "/root", sandbox.BindWritable).
Bind("/run", "/run", sandbox.BindWritable).
Bind("/srv", "/srv", sandbox.BindWritable).
Bind("/sys", "/sys", sandbox.BindWritable).
Bind("/usr", "/usr", sandbox.BindWritable).
Bind("/var", "/var", sandbox.BindWritable).
Bind("/dev/dri", "/dev/dri", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional).
Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional).
Tmpfs("/run/user/1971", 8192, 0755).
Tmpfs("/run/dbus", 8192, 0755).
Bind("/etc", fst.Tmp+"/etc", 0).
Link(fst.Tmp+"/etc/alsa", "/etc/alsa").
Link(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Link(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Link(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Link(fst.Tmp+"/etc/default", "/etc/default").
Link(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Link(fst.Tmp+"/etc/fonts", "/etc/fonts").
Link(fst.Tmp+"/etc/fstab", "/etc/fstab").
Link(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Link(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Link(fst.Tmp+"/etc/hostid", "/etc/hostid").
Link(fst.Tmp+"/etc/hostname", "/etc/hostname").
Link(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Link(fst.Tmp+"/etc/hosts", "/etc/hosts").
Link(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Link(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Link(fst.Tmp+"/etc/issue", "/etc/issue").
Link(fst.Tmp+"/etc/kbd", "/etc/kbd").
Link(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Link(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Link(fst.Tmp+"/etc/localtime", "/etc/localtime").
Link(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Link(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Link(fst.Tmp+"/etc/lvm", "/etc/lvm").
Link(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Link(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Link(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Link(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Link("/proc/mounts", "/etc/mtab").
Link(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Link(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Link(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Link(fst.Tmp+"/etc/nix", "/etc/nix").
Link(fst.Tmp+"/etc/nixos", "/etc/nixos").
Link(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Link(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Link(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Link(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Link(fst.Tmp+"/etc/os-release", "/etc/os-release").
Link(fst.Tmp+"/etc/pam", "/etc/pam").
Link(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Link(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Link(fst.Tmp+"/etc/pki", "/etc/pki").
Link(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Link(fst.Tmp+"/etc/profile", "/etc/profile").
Link(fst.Tmp+"/etc/protocols", "/etc/protocols").
Link(fst.Tmp+"/etc/qemu", "/etc/qemu").
Link(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Link(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Link(fst.Tmp+"/etc/rpc", "/etc/rpc").
Link(fst.Tmp+"/etc/samba", "/etc/samba").
Link(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Link(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Link(fst.Tmp+"/etc/services", "/etc/services").
Link(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Link(fst.Tmp+"/etc/shadow", "/etc/shadow").
Link(fst.Tmp+"/etc/shells", "/etc/shells").
Link(fst.Tmp+"/etc/ssh", "/etc/ssh").
Link(fst.Tmp+"/etc/ssl", "/etc/ssl").
Link(fst.Tmp+"/etc/static", "/etc/static").
Link(fst.Tmp+"/etc/subgid", "/etc/subgid").
Link(fst.Tmp+"/etc/subuid", "/etc/subuid").
Link(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Link(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Link(fst.Tmp+"/etc/systemd", "/etc/systemd").
Link(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Link(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Link(fst.Tmp+"/etc/udev", "/etc/udev").
Link(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Link(fst.Tmp+"/etc/UPower", "/etc/UPower").
Link(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Link(fst.Tmp+"/etc/X11", "/etc/X11").
Link(fst.Tmp+"/etc/zfs", "/etc/zfs").
Link(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Link(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Link(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 4096, 0755).
Tmpfs("/run/user/65534", 8388608, 0700).
Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", sandbox.BindWritable).
Bind("/home/chronos", "/home/chronos", sandbox.BindWritable).
Place("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("fortify:x:65534:\n")).
Bind("/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/65534/wayland-0", 0).
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/65534/pulse/native", 0).
Place(fst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/65534/bus", 0).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket", 0).
Tmpfs("/var/run/nscd", 8192, 0755),
},
},
}

View File

@ -1,4 +1,4 @@
package setuid_test package app_test
import ( import (
"fmt" "fmt"
@ -7,7 +7,7 @@ import (
"os/user" "os/user"
"strconv" "strconv"
"git.gensokyo.uk/security/hakurei/internal/app" "git.gensokyo.uk/security/fortify/fst"
) )
// fs methods are not implemented using a real FS // fs methods are not implemented using a real FS
@ -20,7 +20,7 @@ type stubNixOS struct {
func (s *stubNixOS) Getuid() int { return 1971 } func (s *stubNixOS) Getuid() int { return 1971 }
func (s *stubNixOS) Getgid() int { return 100 } func (s *stubNixOS) Getgid() int { return 100 }
func (s *stubNixOS) TempDir() string { return "/tmp" } func (s *stubNixOS) TempDir() string { return "/tmp" }
func (s *stubNixOS) MustExecutable() string { return "/run/wrappers/bin/hakurei" } func (s *stubNixOS) MustExecutable() string { return "/run/wrappers/bin/fortify" }
func (s *stubNixOS) Exit(code int) { panic("called exit on stub with code " + strconv.Itoa(code)) } func (s *stubNixOS) Exit(code int) { panic("called exit on stub with code " + strconv.Itoa(code)) }
func (s *stubNixOS) EvalSymlinks(path string) (string, error) { return path, nil } func (s *stubNixOS) EvalSymlinks(path string) (string, error) { return path, nil }
func (s *stubNixOS) Uid(aid int) (int, error) { return 1000000 + 0*10000 + aid, nil } func (s *stubNixOS) Uid(aid int) (int, error) { return 1000000 + 0*10000 + aid, nil }
@ -125,10 +125,10 @@ func (s *stubNixOS) Open(name string) (fs.File, error) {
} }
} }
func (s *stubNixOS) Paths() app.Paths { func (s *stubNixOS) Paths() fst.Paths {
return app.Paths{ return fst.Paths{
SharePath: "/tmp/hakurei.1971", SharePath: "/tmp/fortify.1971",
RuntimePath: "/run/user/1971", RuntimePath: "/run/user/1971",
RunDirPath: "/run/user/1971/hakurei", RunDirPath: "/run/user/1971/fortify",
} }
} }

148
internal/app/app_test.go Normal file
View File

@ -0,0 +1,148 @@
package app_test
import (
"encoding/json"
"io/fs"
"reflect"
"testing"
"time"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/system"
)
type sealTestCase struct {
name string
os sys.State
config *fst.Config
id fst.ID
wantSys *system.I
wantContainer *sandbox.Params
}
func TestApp(t *testing.T) {
testCases := append(testCasesPd, testCasesNixos...)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a := app.NewWithID(tc.id, tc.os)
var (
gotSys *system.I
gotContainer *sandbox.Params
)
if !t.Run("seal", func(t *testing.T) {
if sa, err := a.Seal(tc.config); err != nil {
t.Errorf("Seal: error = %v", err)
return
} else {
gotSys, gotContainer = app.AppIParams(a, sa)
}
}) {
return
}
t.Run("compare sys", func(t *testing.T) {
if !gotSys.Equal(tc.wantSys) {
t.Errorf("Seal: sys = %#v, want %#v",
gotSys, tc.wantSys)
}
})
t.Run("compare params", func(t *testing.T) {
if !reflect.DeepEqual(gotContainer, tc.wantContainer) {
t.Errorf("seal: params =\n%s\n, want\n%s",
mustMarshal(gotContainer), mustMarshal(tc.wantContainer))
}
})
})
}
}
func mustMarshal(v any) string {
if b, err := json.Marshal(v); err != nil {
panic(err.Error())
} else {
return string(b)
}
}
func stubDirEntries(names ...string) (e []fs.DirEntry, err error) {
e = make([]fs.DirEntry, len(names))
for i, name := range names {
e[i] = stubDirEntryPath(name)
}
return
}
type stubDirEntryPath string
func (p stubDirEntryPath) Name() string {
return string(p)
}
func (p stubDirEntryPath) IsDir() bool {
panic("attempted to call IsDir")
}
func (p stubDirEntryPath) Type() fs.FileMode {
panic("attempted to call Type")
}
func (p stubDirEntryPath) Info() (fs.FileInfo, error) {
panic("attempted to call Info")
}
type stubFileInfoMode fs.FileMode
func (s stubFileInfoMode) Name() string {
panic("attempted to call Name")
}
func (s stubFileInfoMode) Size() int64 {
panic("attempted to call Size")
}
func (s stubFileInfoMode) Mode() fs.FileMode {
return fs.FileMode(s)
}
func (s stubFileInfoMode) ModTime() time.Time {
panic("attempted to call ModTime")
}
func (s stubFileInfoMode) IsDir() bool {
panic("attempted to call IsDir")
}
func (s stubFileInfoMode) Sys() any {
panic("attempted to call Sys")
}
type stubFileInfoIsDir bool
func (s stubFileInfoIsDir) Name() string {
panic("attempted to call Name")
}
func (s stubFileInfoIsDir) Size() int64 {
panic("attempted to call Size")
}
func (s stubFileInfoIsDir) Mode() fs.FileMode {
panic("attempted to call Mode")
}
func (s stubFileInfoIsDir) ModTime() time.Time {
panic("attempted to call ModTime")
}
func (s stubFileInfoIsDir) IsDir() bool {
return bool(s)
}
func (s stubFileInfoIsDir) Sys() any {
panic("attempted to call Sys")
}

View File

@ -1,22 +1,20 @@
package setuid package app
import ( import (
"errors" "errors"
"log" "log"
. "git.gensokyo.uk/security/hakurei/internal/app" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
func PrintRunStateErr(rs *RunState, runErr error) (code int) { func PrintRunStateErr(rs *fst.RunState, runErr error) {
code = rs.ExitStatus()
if runErr != nil { if runErr != nil {
if rs.Time == nil { if rs.Time == nil {
hlog.PrintBaseError(runErr, "cannot start app:") fmsg.PrintBaseError(runErr, "cannot start app:")
} else { } else {
var e *hlog.BaseError var e *fmsg.BaseError
if !hlog.AsBaseError(runErr, &e) { if !fmsg.AsBaseError(runErr, &e) {
log.Println("wait failed:", runErr) log.Println("wait failed:", runErr)
} else { } else {
// Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError // Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError
@ -37,7 +35,7 @@ func PrintRunStateErr(rs *RunState, runErr error) (code int) {
// every error here is wrapped in *app.BaseError // every error here is wrapped in *app.BaseError
for _, ei := range errs { for _, ei := range errs {
var eb *hlog.BaseError var eb *fmsg.BaseError
if !errors.As(ei, &eb) { if !errors.As(ei, &eb) {
// unreachable // unreachable
log.Println("invalid error type returned by revert:", ei) log.Println("invalid error type returned by revert:", ei)
@ -51,15 +49,15 @@ func PrintRunStateErr(rs *RunState, runErr error) (code int) {
} }
} }
if code == 0 { if rs.ExitCode == 0 {
code = 126 rs.ExitCode = 126
} }
} }
if rs.RevertErr != nil { if rs.RevertErr != nil {
var stateStoreError *StateStoreError var stateStoreError *StateStoreError
if !errors.As(rs.RevertErr, &stateStoreError) || stateStoreError == nil { if !errors.As(rs.RevertErr, &stateStoreError) || stateStoreError == nil {
hlog.PrintBaseError(rs.RevertErr, "generic fault during cleanup:") fmsg.PrintBaseError(rs.RevertErr, "generic fault during cleanup:")
goto out goto out
} }
@ -67,11 +65,11 @@ func PrintRunStateErr(rs *RunState, runErr error) (code int) {
if len(stateStoreError.Err) == 2 { if len(stateStoreError.Err) == 2 {
if stateStoreError.Err[0] != nil { if stateStoreError.Err[0] != nil {
if joinedErrs, ok := stateStoreError.Err[0].(interface{ Unwrap() []error }); !ok { if joinedErrs, ok := stateStoreError.Err[0].(interface{ Unwrap() []error }); !ok {
hlog.PrintBaseError(stateStoreError.Err[0], "generic fault during revert:") fmsg.PrintBaseError(stateStoreError.Err[0], "generic fault during revert:")
} else { } else {
for _, err := range joinedErrs.Unwrap() { for _, err := range joinedErrs.Unwrap() {
if err != nil { if err != nil {
hlog.PrintBaseError(err, "fault during revert:") fmsg.PrintBaseError(err, "fault during revert:")
} }
} }
} }
@ -91,22 +89,21 @@ func PrintRunStateErr(rs *RunState, runErr error) (code int) {
} }
if stateStoreError.DoErr != nil { if stateStoreError.DoErr != nil {
hlog.PrintBaseError(stateStoreError.DoErr, "state store operation unsuccessful:") fmsg.PrintBaseError(stateStoreError.DoErr, "state store operation unsuccessful:")
} }
if stateStoreError.Inner && stateStoreError.InnerErr != nil { if stateStoreError.Inner && stateStoreError.InnerErr != nil {
hlog.PrintBaseError(stateStoreError.InnerErr, "cannot destroy state entry:") fmsg.PrintBaseError(stateStoreError.InnerErr, "cannot destroy state entry:")
} }
out: out:
if code == 0 { if rs.ExitCode == 0 {
code = 128 rs.ExitCode = 128
} }
} }
if rs.WaitErr != nil { if rs.WaitErr != nil {
hlog.Verbosef("wait: %v", rs.WaitErr) log.Println("inner wait failed:", rs.WaitErr)
} }
return
} }
// StateStoreError is returned for a failed state save // StateStoreError is returned for a failed state save
@ -124,7 +121,7 @@ type StateStoreError struct {
} }
// save saves arbitrary errors in [StateStoreError] once. // save saves arbitrary errors in [StateStoreError] once.
func (e *StateStoreError) save(errs ...error) { func (e *StateStoreError) save(errs []error) {
if len(errs) == 0 || e.Err != nil { if len(errs) == 0 || e.Err != nil {
panic("invalid call to save") panic("invalid call to save")
} }
@ -135,7 +132,7 @@ func (e *StateStoreError) equiv(a ...any) error {
if e.Inner && e.InnerErr == nil && e.DoErr == nil && e.OpErr == nil && errors.Join(e.Err...) == nil { if e.Inner && e.InnerErr == nil && e.DoErr == nil && e.OpErr == nil && errors.Join(e.Err...) == nil {
return nil return nil
} else { } else {
return hlog.WrapErrSuffix(e, a...) return fmsg.WrapErrorSuffix(e, a...)
} }
} }

View File

@ -0,0 +1,24 @@
package app
import (
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/system"
)
func NewWithID(id fst.ID, os sys.State) fst.App {
a := new(app)
a.id = newID(&id)
a.sys = os
return a
}
func AppIParams(a fst.App, sa fst.SealedApp) (*system.I, *sandbox.Params) {
v := a.(*app)
seal := sa.(*outcome)
if v.outcome != seal || v.id != seal.id {
panic("broken app/outcome link")
}
return seal.sys, seal.container
}

View File

@ -1,189 +0,0 @@
package common
import (
"errors"
"fmt"
"io/fs"
"maps"
"path"
"syscall"
"git.gensokyo.uk/security/hakurei/dbus"
"git.gensokyo.uk/security/hakurei/hst"
"git.gensokyo.uk/security/hakurei/internal/sys"
"git.gensokyo.uk/security/hakurei/sandbox"
"git.gensokyo.uk/security/hakurei/sandbox/seccomp"
)
// in practice there should be less than 30 entries added by the runtime;
// allocating slightly more as a margin for future expansion
const preallocateOpsCount = 1 << 5
// NewContainer initialises [sandbox.Params] via [hst.ContainerConfig].
// Note that remaining container setup must be queued by the caller.
func NewContainer(s *hst.ContainerConfig, os sys.State, uid, gid *int) (*sandbox.Params, map[string]string, error) {
if s == nil {
return nil, nil, syscall.EBADE
}
container := &sandbox.Params{
Hostname: s.Hostname,
Seccomp: s.Seccomp,
}
{
ops := make(sandbox.Ops, 0, preallocateOpsCount+len(s.Filesystem)+len(s.Link)+len(s.Cover))
container.Ops = &ops
}
if s.Multiarch {
container.Seccomp |= seccomp.FilterMultiarch
}
if s.Devel {
container.Flags |= sandbox.FAllowDevel
}
if s.Userns {
container.Flags |= sandbox.FAllowUserns
}
if s.Net {
container.Flags |= sandbox.FAllowNet
}
if s.Tty {
container.Flags |= sandbox.FAllowTTY
}
if s.MapRealUID {
/* some programs fail to connect to dbus session running as a different uid
so this workaround is introduced to map priv-side caller uid in container */
container.Uid = os.Getuid()
*uid = container.Uid
container.Gid = os.Getgid()
*gid = container.Gid
} else {
*uid = sandbox.OverflowUid()
*gid = sandbox.OverflowGid()
}
container.
Proc("/proc").
Tmpfs(hst.Tmp, 1<<12, 0755)
if !s.Device {
container.Dev("/dev").Mqueue("/dev/mqueue")
} else {
container.Bind("/dev", "/dev", sandbox.BindWritable|sandbox.BindDevice)
}
/* retrieve paths and hide them if they're made available in the sandbox;
this feature tries to improve user experience of permissive defaults, and
to warn about issues in custom configuration; it is NOT a security feature
and should not be treated as such, ALWAYS be careful with what you bind */
var hidePaths []string
sc := os.Paths()
hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath)
_, systemBusAddr := dbus.Address()
if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil {
return nil, nil, err
} else {
// there is usually only one, do not preallocate
for _, entry := range entries {
if entry.Method != "unix" {
continue
}
for _, pair := range entry.Values {
if pair[0] == "path" {
if path.IsAbs(pair[1]) {
// get parent dir of socket
dir := path.Dir(pair[1])
if dir == "." || dir == "/" {
os.Printf("dbus socket %q is in an unusual location", pair[1])
}
hidePaths = append(hidePaths, dir)
} else {
os.Printf("dbus socket %q is not absolute", pair[1])
}
}
}
}
}
hidePathMatch := make([]bool, len(hidePaths))
for i := range hidePaths {
if err := evalSymlinks(os, &hidePaths[i]); err != nil {
return nil, nil, err
}
}
for _, c := range s.Filesystem {
if c == nil {
continue
}
if !path.IsAbs(c.Src) {
return nil, nil, fmt.Errorf("src path %q is not absolute", c.Src)
}
dest := c.Dst
if c.Dst == "" {
dest = c.Src
} else if !path.IsAbs(dest) {
return nil, nil, fmt.Errorf("dst path %q is not absolute", dest)
}
srcH := c.Src
if err := evalSymlinks(os, &srcH); err != nil {
return nil, nil, err
}
for i := range hidePaths {
// skip matched entries
if hidePathMatch[i] {
continue
}
if ok, err := deepContainsH(srcH, hidePaths[i]); err != nil {
return nil, nil, err
} else if ok {
hidePathMatch[i] = true
os.Printf("hiding paths from %q", c.Src)
}
}
var flags int
if c.Write {
flags |= sandbox.BindWritable
}
if c.Device {
flags |= sandbox.BindDevice | sandbox.BindWritable
}
if !c.Must {
flags |= sandbox.BindOptional
}
container.Bind(c.Src, dest, flags)
}
// cover matched paths
for i, ok := range hidePathMatch {
if ok {
container.Tmpfs(hidePaths[i], 1<<13, 0755)
}
}
for _, l := range s.Link {
container.Link(l[0], l[1])
}
return container, maps.Clone(s.Env), nil
}
func evalSymlinks(os sys.State, v *string) error {
if p, err := os.EvalSymlinks(*v); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
os.Printf("path %q does not yet exist", *v)
} else {
*v = p
}
return nil
}

View File

@ -1,17 +0,0 @@
package instance
import (
"syscall"
"git.gensokyo.uk/security/hakurei/internal/app"
"git.gensokyo.uk/security/hakurei/internal/app/internal/setuid"
)
func PrintRunStateErr(whence int, rs *app.RunState, runErr error) (code int) {
switch whence {
case ISetuid:
return setuid.PrintRunStateErr(rs, runErr)
default:
panic(syscall.EINVAL)
}
}

View File

@ -1,33 +0,0 @@
// Package instance exposes cross-package implementation details and provides constructors for builtin implementations.
package instance
import (
"context"
"log"
"syscall"
"git.gensokyo.uk/security/hakurei/internal/app"
"git.gensokyo.uk/security/hakurei/internal/app/internal/setuid"
"git.gensokyo.uk/security/hakurei/internal/sys"
)
const (
ISetuid = iota
)
func New(whence int, ctx context.Context, os sys.State) (app.App, error) {
switch whence {
case ISetuid:
return setuid.New(ctx, os)
default:
return nil, syscall.EINVAL
}
}
func MustNew(whence int, ctx context.Context, os sys.State) app.App {
a, err := New(whence, ctx, os)
if err != nil {
log.Fatalf("cannot create app: %v", err)
}
return a
}

View File

@ -1,6 +0,0 @@
package instance
import "git.gensokyo.uk/security/hakurei/internal/app/internal/setuid"
// ShimMain is the main function of the shim process and runs as the unconstrained target user.
func ShimMain() { setuid.ShimMain() }

View File

@ -1,74 +0,0 @@
package setuid
import (
"context"
"fmt"
"sync"
"git.gensokyo.uk/security/hakurei/hst"
. "git.gensokyo.uk/security/hakurei/internal/app"
"git.gensokyo.uk/security/hakurei/internal/hlog"
"git.gensokyo.uk/security/hakurei/internal/sys"
)
func New(ctx context.Context, os sys.State) (App, error) {
a := new(app)
a.sys = os
a.ctx = ctx
id := new(ID)
err := NewAppID(id)
a.id = newID(id)
return a, err
}
type app struct {
id *stringPair[ID]
sys sys.State
ctx context.Context
*outcome
mu sync.RWMutex
}
func (a *app) ID() ID { a.mu.RLock(); defer a.mu.RUnlock(); return a.id.unwrap() }
func (a *app) String() string {
if a == nil {
return "(invalid app)"
}
a.mu.RLock()
defer a.mu.RUnlock()
if a.outcome != nil {
if a.outcome.user.uid == nil {
return fmt.Sprintf("(sealed app %s with invalid uid)", a.id)
}
return fmt.Sprintf("(sealed app %s as uid %s)", a.id, a.outcome.user.uid)
}
return fmt.Sprintf("(unsealed app %s)", a.id)
}
func (a *app) Seal(config *hst.Config) (SealedApp, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.outcome != nil {
panic("app sealed twice")
}
if config == nil {
return nil, hlog.WrapErr(ErrConfig,
"attempted to seal app with nil config")
}
seal := new(outcome)
seal.id = a.id
err := seal.finalise(a.ctx, a.sys, config)
if err == nil {
a.outcome = seal
}
return seal, err
}

View File

@ -1,147 +0,0 @@
package setuid_test
import (
"git.gensokyo.uk/security/hakurei/acl"
"git.gensokyo.uk/security/hakurei/dbus"
"git.gensokyo.uk/security/hakurei/hst"
"git.gensokyo.uk/security/hakurei/internal/app"
"git.gensokyo.uk/security/hakurei/sandbox"
"git.gensokyo.uk/security/hakurei/system"
)
var testCasesNixos = []sealTestCase{
{
"nixos chromium direct wayland", new(stubNixOS),
&hst.Config{
ID: "org.chromium.Chromium",
Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start",
Enablements: system.EWayland | system.EDBus | system.EPulse,
Container: &hst.ContainerConfig{
Userns: true, Net: true, MapRealUID: true, Env: nil, AutoEtc: true,
Filesystem: []*hst.FilesystemConfig{
{Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true},
{Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true},
{Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"},
{Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true},
},
Cover: []string{"/var/run/nscd"},
},
SystemBus: &dbus.Config{
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Filter: true,
},
SessionBus: &dbus.Config{
Talk: []string{
"org.freedesktop.FileManager1", "org.freedesktop.Notifications",
"org.freedesktop.ScreenSaver", "org.freedesktop.secrets",
"org.kde.kwalletd5", "org.kde.kwalletd6",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{}, Broadcast: map[string]string{},
Filter: true,
},
DirectWayland: true,
Username: "u0_a1",
Data: "/var/lib/persist/module/hakurei/0/1",
Identity: 1, Groups: []string{},
},
app.ID{
0x8e, 0x2c, 0x76, 0xb0,
0x66, 0xda, 0xbe, 0x57,
0x4c, 0xf0, 0x73, 0xbd,
0xb4, 0x6e, 0xb5, 0xc1,
},
system.New(1000001).
Ensure("/tmp/hakurei.1971", 0711).
Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute).
Ensure("/tmp/hakurei.1971/runtime/1", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/1", acl.Read, acl.Write, acl.Execute).
Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute).
Ensure("/tmp/hakurei.1971/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/1", acl.Read, acl.Write, acl.Execute).
Ensure("/run/user/1971/hakurei", 0700).UpdatePermType(system.User, "/run/user/1971/hakurei", 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
UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute).
Ephemeral(system.Process, "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute).
Link("/run/user/1971/pulse/native", "/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse").
CopyFile(nil, "/home/ophestra/xdg/config/pulse/cookie", 256, 256).
Ephemeral(system.Process, "/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711).
MustProxyDBus("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", &dbus.Config{
Talk: []string{
"org.freedesktop.FileManager1", "org.freedesktop.Notifications",
"org.freedesktop.ScreenSaver", "org.freedesktop.secrets",
"org.kde.kwalletd5", "org.kde.kwalletd6",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{}, Broadcast: map[string]string{},
Filter: true,
}, "/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
}).
UpdatePerm("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write),
&sandbox.Params{
Uid: 1971,
Gid: 100,
Flags: sandbox.FAllowNet | sandbox.FAllowUserns,
Dir: "/var/lib/persist/module/hakurei/0/1",
Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start",
Args: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"},
Env: []string{
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus",
"DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket",
"HOME=/var/lib/persist/module/hakurei/0/1",
"PULSE_COOKIE=" + hst.Tmp + "/pulse-cookie",
"PULSE_SERVER=unix:/run/user/1971/pulse/native",
"SHELL=/run/current-system/sw/bin/zsh",
"TERM=xterm-256color",
"USER=u0_a1",
"WAYLAND_DISPLAY=wayland-0",
"XDG_RUNTIME_DIR=/run/user/1971",
"XDG_SESSION_CLASS=user",
"XDG_SESSION_TYPE=tty",
},
Ops: new(sandbox.Ops).
Proc("/proc").
Tmpfs(hst.Tmp, 4096, 0755).
Dev("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", 0).
Bind("/usr/bin", "/usr/bin", 0).
Bind("/nix/store", "/nix/store", 0).
Bind("/run/current-system", "/run/current-system", 0).
Bind("/sys/block", "/sys/block", sandbox.BindOptional).
Bind("/sys/bus", "/sys/bus", sandbox.BindOptional).
Bind("/sys/class", "/sys/class", sandbox.BindOptional).
Bind("/sys/dev", "/sys/dev", sandbox.BindOptional).
Bind("/sys/devices", "/sys/devices", sandbox.BindOptional).
Bind("/run/opengl-driver", "/run/opengl-driver", 0).
Bind("/dev/dri", "/dev/dri", sandbox.BindDevice|sandbox.BindWritable|sandbox.BindOptional).
Etc("/etc", "8e2c76b066dabe574cf073bdb46eb5c1").
Tmpfs("/run/user", 4096, 0755).
Bind("/tmp/hakurei.1971/runtime/1", "/run/user/1971", sandbox.BindWritable).
Bind("/tmp/hakurei.1971/tmpdir/1", "/tmp", sandbox.BindWritable).
Bind("/var/lib/persist/module/hakurei/0/1", "/var/lib/persist/module/hakurei/0/1", sandbox.BindWritable).
Place("/etc/passwd", []byte("u0_a1:x:1971:100:Hakurei:/var/lib/persist/module/hakurei/0/1:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("hakurei:x:100:\n")).
Bind("/run/user/1971/wayland-0", "/run/user/1971/wayland-0", 0).
Bind("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse", "/run/user/1971/pulse/native", 0).
Place(hst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", "/run/user/1971/bus", 0).
Bind("/tmp/hakurei.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", "/run/dbus/system_bus_socket", 0).
Tmpfs("/var/run/nscd", 8192, 0755),
},
},
}

View File

@ -1,220 +0,0 @@
package setuid_test
import (
"os"
"git.gensokyo.uk/security/hakurei/acl"
"git.gensokyo.uk/security/hakurei/dbus"
"git.gensokyo.uk/security/hakurei/hst"
"git.gensokyo.uk/security/hakurei/internal/app"
"git.gensokyo.uk/security/hakurei/sandbox"
"git.gensokyo.uk/security/hakurei/system"
)
var testCasesPd = []sealTestCase{
{
"nixos permissive defaults no enablements", new(stubNixOS),
&hst.Config{Username: "chronos", Data: "/home/chronos"},
app.ID{
0x4a, 0x45, 0x0b, 0x65,
0x96, 0xd7, 0xbc, 0x15,
0xbd, 0x01, 0x78, 0x0e,
0xb9, 0xa6, 0x07, 0xac,
},
system.New(1000000).
Ensure("/tmp/hakurei.1971", 0711).
Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute).
Ensure("/tmp/hakurei.1971/runtime/0", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/0", acl.Read, acl.Write, acl.Execute).
Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute).
Ensure("/tmp/hakurei.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute),
&sandbox.Params{
Flags: sandbox.FAllowNet | sandbox.FAllowUserns | sandbox.FAllowTTY,
Dir: "/home/chronos",
Path: "/run/current-system/sw/bin/zsh",
Args: []string{"/run/current-system/sw/bin/zsh"},
Env: []string{
"HOME=/home/chronos",
"SHELL=/run/current-system/sw/bin/zsh",
"TERM=xterm-256color",
"USER=chronos",
"XDG_RUNTIME_DIR=/run/user/65534",
"XDG_SESSION_CLASS=user",
"XDG_SESSION_TYPE=tty",
},
Ops: new(sandbox.Ops).
Proc("/proc").
Tmpfs(hst.Tmp, 4096, 0755).
Dev("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", sandbox.BindWritable).
Bind("/boot", "/boot", sandbox.BindWritable).
Bind("/home", "/home", sandbox.BindWritable).
Bind("/lib", "/lib", sandbox.BindWritable).
Bind("/lib64", "/lib64", sandbox.BindWritable).
Bind("/nix", "/nix", sandbox.BindWritable).
Bind("/root", "/root", sandbox.BindWritable).
Bind("/run", "/run", sandbox.BindWritable).
Bind("/srv", "/srv", sandbox.BindWritable).
Bind("/sys", "/sys", sandbox.BindWritable).
Bind("/usr", "/usr", sandbox.BindWritable).
Bind("/var", "/var", sandbox.BindWritable).
Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional).
Tmpfs("/run/user/1971", 8192, 0755).
Tmpfs("/run/dbus", 8192, 0755).
Etc("/etc", "4a450b6596d7bc15bd01780eb9a607ac").
Tmpfs("/run/user", 4096, 0755).
Bind("/tmp/hakurei.1971/runtime/0", "/run/user/65534", sandbox.BindWritable).
Bind("/tmp/hakurei.1971/tmpdir/0", "/tmp", sandbox.BindWritable).
Bind("/home/chronos", "/home/chronos", sandbox.BindWritable).
Place("/etc/passwd", []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("hakurei:x:65534:\n")).
Tmpfs("/var/run/nscd", 8192, 0755),
},
},
{
"nixos permissive defaults chromium", new(stubNixOS),
&hst.Config{
ID: "org.chromium.Chromium",
Args: []string{"zsh", "-c", "exec chromium "},
Identity: 9,
Groups: []string{"video"},
Username: "chronos",
Data: "/home/chronos",
SessionBus: &dbus.Config{
Talk: []string{
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{
"org.freedesktop.portal.*": "*",
},
Broadcast: map[string]string{
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*",
},
Filter: true,
},
SystemBus: &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
},
Enablements: system.EWayland | system.EDBus | system.EPulse,
},
app.ID{
0xeb, 0xf0, 0x83, 0xd1,
0xb1, 0x75, 0x91, 0x17,
0x82, 0xd4, 0x13, 0x36,
0x9b, 0x64, 0xce, 0x7c,
},
system.New(1000009).
Ensure("/tmp/hakurei.1971", 0711).
Ensure("/tmp/hakurei.1971/runtime", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime", acl.Execute).
Ensure("/tmp/hakurei.1971/runtime/9", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/runtime/9", acl.Read, acl.Write, acl.Execute).
Ensure("/tmp/hakurei.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir", acl.Execute).
Ensure("/tmp/hakurei.1971/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/hakurei.1971/tmpdir/9", acl.Read, acl.Write, acl.Execute).
Ephemeral(system.Process, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c", 0711).
Wayland(new(*os.File), "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c").
Ensure("/run/user/1971/hakurei", 0700).UpdatePermType(system.User, "/run/user/1971/hakurei", 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
Ephemeral(system.Process, "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c", acl.Execute).
Link("/run/user/1971/pulse/native", "/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse").
CopyFile(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 256, 256).
MustProxyDBus("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{
Talk: []string{
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{
"org.freedesktop.portal.*": "*",
},
Broadcast: map[string]string{
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*",
},
Filter: true,
}, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
}).
UpdatePerm("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write),
&sandbox.Params{
Flags: sandbox.FAllowNet | sandbox.FAllowUserns | sandbox.FAllowTTY,
Dir: "/home/chronos",
Path: "/run/current-system/sw/bin/zsh",
Args: []string{"zsh", "-c", "exec chromium "},
Env: []string{
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
"DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket",
"HOME=/home/chronos",
"PULSE_COOKIE=" + hst.Tmp + "/pulse-cookie",
"PULSE_SERVER=unix:/run/user/65534/pulse/native",
"SHELL=/run/current-system/sw/bin/zsh",
"TERM=xterm-256color",
"USER=chronos",
"WAYLAND_DISPLAY=wayland-0",
"XDG_RUNTIME_DIR=/run/user/65534",
"XDG_SESSION_CLASS=user",
"XDG_SESSION_TYPE=tty",
},
Ops: new(sandbox.Ops).
Proc("/proc").
Tmpfs(hst.Tmp, 4096, 0755).
Dev("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", sandbox.BindWritable).
Bind("/boot", "/boot", sandbox.BindWritable).
Bind("/home", "/home", sandbox.BindWritable).
Bind("/lib", "/lib", sandbox.BindWritable).
Bind("/lib64", "/lib64", sandbox.BindWritable).
Bind("/nix", "/nix", sandbox.BindWritable).
Bind("/root", "/root", sandbox.BindWritable).
Bind("/run", "/run", sandbox.BindWritable).
Bind("/srv", "/srv", sandbox.BindWritable).
Bind("/sys", "/sys", sandbox.BindWritable).
Bind("/usr", "/usr", sandbox.BindWritable).
Bind("/var", "/var", sandbox.BindWritable).
Bind("/dev/dri", "/dev/dri", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional).
Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional).
Tmpfs("/run/user/1971", 8192, 0755).
Tmpfs("/run/dbus", 8192, 0755).
Etc("/etc", "ebf083d1b175911782d413369b64ce7c").
Tmpfs("/run/user", 4096, 0755).
Bind("/tmp/hakurei.1971/runtime/9", "/run/user/65534", sandbox.BindWritable).
Bind("/tmp/hakurei.1971/tmpdir/9", "/tmp", sandbox.BindWritable).
Bind("/home/chronos", "/home/chronos", sandbox.BindWritable).
Place("/etc/passwd", []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("hakurei:x:65534:\n")).
Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/65534/wayland-0", 0).
Bind("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/65534/pulse/native", 0).
Place(hst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/65534/bus", 0).
Bind("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket", 0).
Tmpfs("/var/run/nscd", 8192, 0755),
},
},
}

View File

@ -1,104 +0,0 @@
package setuid_test
import (
"encoding/json"
"io/fs"
"reflect"
"testing"
"time"
"git.gensokyo.uk/security/hakurei/hst"
"git.gensokyo.uk/security/hakurei/internal/app"
"git.gensokyo.uk/security/hakurei/internal/app/internal/setuid"
"git.gensokyo.uk/security/hakurei/internal/sys"
"git.gensokyo.uk/security/hakurei/sandbox"
"git.gensokyo.uk/security/hakurei/system"
)
type sealTestCase struct {
name string
os sys.State
config *hst.Config
id app.ID
wantSys *system.I
wantContainer *sandbox.Params
}
func TestApp(t *testing.T) {
testCases := append(testCasesPd, testCasesNixos...)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a := setuid.NewWithID(tc.id, tc.os)
var (
gotSys *system.I
gotContainer *sandbox.Params
)
if !t.Run("seal", func(t *testing.T) {
if sa, err := a.Seal(tc.config); err != nil {
t.Errorf("Seal: error = %v", err)
return
} else {
gotSys, gotContainer = setuid.AppIParams(a, sa)
}
}) {
return
}
t.Run("compare sys", func(t *testing.T) {
if !gotSys.Equal(tc.wantSys) {
t.Errorf("Seal: sys = %#v, want %#v",
gotSys, tc.wantSys)
}
})
t.Run("compare params", func(t *testing.T) {
if !reflect.DeepEqual(gotContainer, tc.wantContainer) {
t.Errorf("seal: params =\n%s\n, want\n%s",
mustMarshal(gotContainer), mustMarshal(tc.wantContainer))
}
})
})
}
}
func mustMarshal(v any) string {
if b, err := json.Marshal(v); err != nil {
panic(err.Error())
} else {
return string(b)
}
}
func stubDirEntries(names ...string) (e []fs.DirEntry, err error) {
e = make([]fs.DirEntry, len(names))
for i, name := range names {
e[i] = stubDirEntryPath(name)
}
return
}
type stubDirEntryPath string
func (p stubDirEntryPath) Name() string { return string(p) }
func (p stubDirEntryPath) IsDir() bool { panic("attempted to call IsDir") }
func (p stubDirEntryPath) Type() fs.FileMode { panic("attempted to call Type") }
func (p stubDirEntryPath) Info() (fs.FileInfo, error) { panic("attempted to call Info") }
type stubFileInfoMode fs.FileMode
func (s stubFileInfoMode) Name() string { panic("attempted to call Name") }
func (s stubFileInfoMode) Size() int64 { panic("attempted to call Size") }
func (s stubFileInfoMode) Mode() fs.FileMode { return fs.FileMode(s) }
func (s stubFileInfoMode) ModTime() time.Time { panic("attempted to call ModTime") }
func (s stubFileInfoMode) IsDir() bool { panic("attempted to call IsDir") }
func (s stubFileInfoMode) Sys() any { panic("attempted to call Sys") }
type stubFileInfoIsDir bool
func (s stubFileInfoIsDir) Name() string { panic("attempted to call Name") }
func (s stubFileInfoIsDir) Size() int64 { panic("attempted to call Size") }
func (s stubFileInfoIsDir) Mode() fs.FileMode { panic("attempted to call Mode") }
func (s stubFileInfoIsDir) ModTime() time.Time { panic("attempted to call ModTime") }
func (s stubFileInfoIsDir) IsDir() bool { return bool(s) }
func (s stubFileInfoIsDir) Sys() any { panic("attempted to call Sys") }

View File

@ -1,24 +0,0 @@
package setuid
import (
. "git.gensokyo.uk/security/hakurei/internal/app"
"git.gensokyo.uk/security/hakurei/internal/sys"
"git.gensokyo.uk/security/hakurei/sandbox"
"git.gensokyo.uk/security/hakurei/system"
)
func NewWithID(id ID, os sys.State) App {
a := new(app)
a.id = newID(&id)
a.sys = os
return a
}
func AppIParams(a App, sa SealedApp) (*system.I, *sandbox.Params) {
v := a.(*app)
seal := sa.(*outcome)
if v.outcome != seal || v.id != seal.id {
panic("broken app/outcome link")
}
return seal.sys, seal.container
}

View File

@ -1,195 +0,0 @@
package setuid
import (
"context"
"encoding/gob"
"errors"
"log"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"time"
"git.gensokyo.uk/security/hakurei/internal"
. "git.gensokyo.uk/security/hakurei/internal/app"
"git.gensokyo.uk/security/hakurei/internal/hlog"
"git.gensokyo.uk/security/hakurei/internal/state"
"git.gensokyo.uk/security/hakurei/sandbox"
"git.gensokyo.uk/security/hakurei/system"
)
const shimWaitTimeout = 5 * time.Second
func (seal *outcome) Run(rs *RunState) error {
if !seal.f.CompareAndSwap(false, true) {
// run does much more than just starting a process; calling it twice, even if the first call fails, will result
// in inconsistent state that is impossible to clean up; return here to limit damage and hopefully give the
// other Run a chance to return
return errors.New("outcome: attempted to run twice")
}
if rs == nil {
panic("invalid state")
}
// read comp value early to allow for early failure
hsuPath := internal.MustHsuPath()
if err := seal.sys.Commit(seal.ctx); err != nil {
return err
}
store := state.NewMulti(seal.runDirPath)
deferredStoreFunc := func(c state.Cursor) error { return nil } // noop until state in store
defer func() {
var revertErr error
storeErr := new(StateStoreError)
storeErr.Inner, storeErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) {
revertErr = func() error {
storeErr.InnerErr = deferredStoreFunc(c)
var rt system.Enablement
ec := system.Process
if states, err := c.Load(); err != nil {
// revert per-process state here to limit damage
storeErr.OpErr = err
return seal.sys.Revert((*system.Criteria)(&ec))
} else {
if l := len(states); l == 0 {
ec |= system.User
} else {
hlog.Verbosef("found %d instances, cleaning up without user-scoped operations", l)
}
// accumulate enablements of remaining launchers
for i, s := range states {
if s.Config != nil {
rt |= s.Config.Enablements
} else {
log.Printf("state entry %d does not contain config", i)
}
}
}
ec |= rt ^ (system.EWayland | system.EX11 | system.EDBus | system.EPulse)
if hlog.Load() {
if ec > 0 {
hlog.Verbose("reverting operations scope", system.TypeString(ec))
}
}
return seal.sys.Revert((*system.Criteria)(&ec))
}()
})
storeErr.save(revertErr, store.Close())
rs.RevertErr = storeErr.equiv("error during cleanup:")
}()
ctx, cancel := context.WithCancel(seal.ctx)
defer cancel()
cmd := exec.CommandContext(ctx, hsuPath)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Dir = "/" // container init enters final working directory
// shim runs in the same session as monitor; see shim.go for behaviour
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGCONT) }
var e *gob.Encoder
if fd, encoder, err := sandbox.Setup(&cmd.ExtraFiles); err != nil {
return hlog.WrapErrSuffix(err,
"cannot create shim setup pipe:")
} else {
e = encoder
cmd.Env = []string{
// passed through to shim by hsu
shimEnv + "=" + strconv.Itoa(fd),
// interpreted by hsu
"HAKUREI_APP_ID=" + seal.user.aid.String(),
}
}
if len(seal.user.supp) > 0 {
hlog.Verbosef("attaching supplementary group ids %s", seal.user.supp)
// interpreted by hsu
cmd.Env = append(cmd.Env, "HAKUREI_GROUPS="+strings.Join(seal.user.supp, " "))
}
hlog.Verbosef("setuid helper at %s", hsuPath)
hlog.Suspend()
if err := cmd.Start(); err != nil {
return hlog.WrapErrSuffix(err,
"cannot start setuid wrapper:")
}
rs.SetStart()
// this prevents blocking forever on an early failure
waitErr, setupErr := make(chan error, 1), make(chan error, 1)
go func() { waitErr <- cmd.Wait(); cancel() }()
go func() { setupErr <- e.Encode(&shimParams{os.Getpid(), seal.container, seal.user.data, hlog.Load()}) }()
select {
case err := <-setupErr:
if err != nil {
hlog.Resume()
return hlog.WrapErrSuffix(err,
"cannot transmit shim config:")
}
case <-ctx.Done():
hlog.Resume()
return hlog.WrapErr(syscall.ECANCELED,
"shim setup canceled")
}
// returned after blocking on waitErr
var earlyStoreErr = new(StateStoreError)
{
// shim accepted setup payload, create process state
sd := state.State{
ID: seal.id.unwrap(),
PID: cmd.Process.Pid,
Time: *rs.Time,
}
earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) {
earlyStoreErr.InnerErr = c.Save(&sd, seal.ct)
})
}
// state in store at this point, destroy defunct state entry on return
deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(seal.id.unwrap()) }
waitTimeout := make(chan struct{})
go func() { <-seal.ctx.Done(); time.Sleep(shimWaitTimeout); close(waitTimeout) }()
select {
case rs.WaitErr = <-waitErr:
rs.WaitStatus = cmd.ProcessState.Sys().(syscall.WaitStatus)
if hlog.Load() {
switch {
case rs.Exited():
hlog.Verbosef("process %d exited with code %d", cmd.Process.Pid, rs.ExitStatus())
case rs.CoreDump():
hlog.Verbosef("process %d dumped core", cmd.Process.Pid)
case rs.Signaled():
hlog.Verbosef("process %d got %s", cmd.Process.Pid, rs.Signal())
default:
hlog.Verbosef("process %d exited with status %#x", cmd.Process.Pid, rs.WaitStatus)
}
}
case <-waitTimeout:
rs.WaitErr = syscall.ETIMEDOUT
hlog.Resume()
log.Printf("process %d did not terminate", cmd.Process.Pid)
}
hlog.Resume()
if seal.sync != nil {
if err := seal.sync.Close(); err != nil {
log.Printf("cannot close wayland security context: %v", err)
}
}
if seal.dbusMsg != nil {
seal.dbusMsg()
}
return earlyStoreErr.equiv("cannot save process state:")
}

View File

@ -1,181 +0,0 @@
package setuid
import (
"context"
"errors"
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
"git.gensokyo.uk/security/hakurei/internal"
"git.gensokyo.uk/security/hakurei/internal/hlog"
"git.gensokyo.uk/security/hakurei/sandbox"
"git.gensokyo.uk/security/hakurei/sandbox/seccomp"
)
/*
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <signal.h>
static pid_t hakurei_shim_param_ppid = -1;
// this cannot unblock hlog since Go code is not async-signal-safe
static void hakurei_shim_sigaction(int sig, siginfo_t *si, void *ucontext) {
if (sig != SIGCONT || si == NULL) {
// unreachable
fprintf(stderr, "sigaction: sa_sigaction got invalid siginfo\n");
return;
}
// monitor requests shim exit
if (si->si_pid == hakurei_shim_param_ppid)
exit(254);
fprintf(stderr, "sigaction: got SIGCONT from process %d\n", si->si_pid);
// shim orphaned before monitor delivers a signal
if (getppid() != hakurei_shim_param_ppid)
exit(3);
}
void hakurei_shim_setup_cont_signal(pid_t ppid) {
struct sigaction new_action = {0}, old_action = {0};
if (sigaction(SIGCONT, NULL, &old_action) != 0)
return;
if (old_action.sa_handler != SIG_DFL) {
errno = ENOTRECOVERABLE;
return;
}
new_action.sa_sigaction = hakurei_shim_sigaction;
if (sigemptyset(&new_action.sa_mask) != 0)
return;
new_action.sa_flags = SA_ONSTACK | SA_SIGINFO;
if (sigaction(SIGCONT, &new_action, NULL) != 0)
return;
errno = 0;
hakurei_shim_param_ppid = ppid;
}
*/
import "C"
const shimEnv = "HAKUREI_SHIM"
type shimParams struct {
// monitor pid, checked against ppid in signal handler
Monitor int
// finalised container params
Container *sandbox.Params
// path to outer home directory
Home string
// verbosity pass through
Verbose bool
}
// ShimMain is the main function of the shim process and runs as the unconstrained target user.
func ShimMain() {
hlog.Prepare("shim")
if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil {
log.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err)
}
var (
params shimParams
closeSetup func() error
)
if f, err := sandbox.Receive(shimEnv, &params, nil); err != nil {
if errors.Is(err, sandbox.ErrInvalid) {
log.Fatal("invalid config descriptor")
}
if errors.Is(err, sandbox.ErrNotSet) {
log.Fatal("HAKUREI_SHIM not set")
}
log.Fatalf("cannot receive shim setup params: %v", err)
} else {
internal.InstallFmsg(params.Verbose)
closeSetup = f
// the Go runtime does not expose siginfo_t so SIGCONT is handled in C to check si_pid
if _, err = C.hakurei_shim_setup_cont_signal(C.pid_t(params.Monitor)); err != nil {
log.Fatalf("cannot install SIGCONT handler: %v", err)
}
// pdeath_signal delivery is checked as if the dying process called kill(2), see kernel/exit.c
if _, _, errno := syscall.Syscall(syscall.SYS_PRCTL, syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGCONT), 0); errno != 0 {
log.Fatalf("cannot set parent-death signal: %v", errno)
}
}
if params.Container == nil || params.Container.Ops == nil {
log.Fatal("invalid container params")
}
// close setup socket
if err := closeSetup(); err != nil {
log.Printf("cannot close setup pipe: %v", err)
// not fatal
}
// ensure home directory as target user
if s, err := os.Stat(params.Home); err != nil {
if os.IsNotExist(err) {
if err = os.Mkdir(params.Home, 0700); err != nil {
log.Fatalf("cannot create home directory: %v", err)
}
} else {
log.Fatalf("cannot access home directory: %v", err)
}
// home directory is created, proceed
} else if !s.IsDir() {
log.Fatalf("path %q is not a directory", params.Home)
}
var name string
if len(params.Container.Args) > 0 {
name = params.Container.Args[0]
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() // unreachable
container := sandbox.New(ctx, name)
container.Params = *params.Container
container.Stdin, container.Stdout, container.Stderr = os.Stdin, os.Stdout, os.Stderr
container.Cancel = func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }
container.WaitDelay = 2 * time.Second
if err := container.Start(); err != nil {
hlog.PrintBaseError(err, "cannot start container:")
os.Exit(1)
}
if err := container.Serve(); err != nil {
hlog.PrintBaseError(err, "cannot configure container:")
}
if err := seccomp.Load(seccomp.PresetCommon); err != nil {
log.Fatalf("cannot load syscall filter: %v", err)
}
if err := container.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
if errors.Is(err, context.Canceled) {
os.Exit(2)
}
log.Printf("wait: %v", err)
os.Exit(127)
}
os.Exit(exitError.ExitCode())
}
}

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

@ -0,0 +1,180 @@
package app
import (
"context"
"errors"
"log"
"os/exec"
"time"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/system"
)
const shimSetupTimeout = 5 * time.Second
func (seal *outcome) Run(rs *fst.RunState) error {
if !seal.f.CompareAndSwap(false, true) {
// run does much more than just starting a process; calling it twice, even if the first call fails, will result
// in inconsistent state that is impossible to clean up; return here to limit damage and hopefully give the
// other Run a chance to return
panic("attempted to run twice")
}
if rs == nil {
panic("invalid state")
}
// read comp values early to allow for early failure
fmsg.Verbosef("version %s", internal.Version())
fmsg.Verbosef("setuid helper at %s", internal.MustFsuPath())
/*
prepare/revert os state
*/
if err := seal.sys.Commit(seal.ctx); err != nil {
return err
}
store := state.NewMulti(seal.runDirPath)
deferredStoreFunc := func(c state.Cursor) error { return nil }
defer func() {
var revertErr error
storeErr := new(StateStoreError)
storeErr.Inner, storeErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) {
revertErr = func() error {
storeErr.InnerErr = deferredStoreFunc(c)
/*
revert app setup transaction
*/
var rt system.Enablement
ec := system.Process
if states, err := c.Load(); err != nil {
// revert per-process state here to limit damage
storeErr.OpErr = err
return seal.sys.Revert((*system.Criteria)(&ec))
} else {
if l := len(states); l == 0 {
fmsg.Verbose("no other launchers active, will clean up globals")
ec |= system.User
} else {
fmsg.Verbosef("found %d active launchers, cleaning up without globals", l)
}
// accumulate enablements of remaining launchers
for i, s := range states {
if s.Config != nil {
rt |= s.Config.Confinement.Enablements
} else {
log.Printf("state entry %d does not contain config", i)
}
}
}
ec |= rt ^ (system.EWayland | system.EX11 | system.EDBus | system.EPulse)
if fmsg.Load() {
if ec > 0 {
fmsg.Verbose("reverting operations type", system.TypeString(ec))
}
}
return seal.sys.Revert((*system.Criteria)(&ec))
}()
})
storeErr.save([]error{revertErr, store.Close()})
rs.RevertErr = storeErr.equiv("error returned during cleanup:")
}()
/*
shim process lifecycle
*/
waitErr := make(chan error, 1)
cmd := new(shimProcess)
if startTime, err := cmd.Start(
seal.user.aid.String(),
seal.user.supp,
); err != nil {
return err
} else {
// whether/when the fsu process was created
rs.Time = startTime
}
ctx, cancel := context.WithTimeout(seal.ctx, shimSetupTimeout)
defer cancel()
go func() {
waitErr <- cmd.Unwrap().Wait()
// cancel shim setup in case shim died before receiving payload
cancel()
}()
if err := cmd.Serve(ctx, &shimParams{
Container: seal.container,
Home: seal.user.data,
Verbose: fmsg.Load(),
}); err != nil {
return err
}
// shim accepted setup payload, create process state
sd := state.State{
ID: seal.id.unwrap(),
PID: cmd.Unwrap().Process.Pid,
Time: *rs.Time,
}
var earlyStoreErr = new(StateStoreError) // returned after blocking on waitErr
earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) {
earlyStoreErr.InnerErr = c.Save(&sd, seal.ct)
})
// destroy defunct state entry
deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(seal.id.unwrap()) }
select {
case err := <-waitErr: // block until fsu/shim returns
if err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
// should be unreachable
rs.WaitErr = err
}
// store non-zero return code
rs.ExitCode = exitError.ExitCode()
} else {
rs.ExitCode = cmd.Unwrap().ProcessState.ExitCode()
}
if fmsg.Load() {
fmsg.Verbosef("process %d exited with exit code %d", cmd.Unwrap().Process.Pid, rs.ExitCode)
}
// this is reached when a fault makes an already running shim impossible to continue execution
// however a kill signal could not be delivered (should actually always happen like that since fsu)
// the effects of this is similar to the alternative exit path and ensures shim death
case err := <-cmd.Fallback():
rs.ExitCode = 255
log.Printf("cannot terminate shim on faulted setup: %v", err)
// alternative exit path relying on shim behaviour on monitor process exit
case <-seal.ctx.Done():
fmsg.Verbose("alternative exit path selected")
}
fmsg.Resume()
if seal.sync != nil {
if err := seal.sync.Close(); err != nil {
log.Printf("cannot close wayland security context: %v", err)
}
}
if seal.dbusMsg != nil {
seal.dbusMsg()
}
return earlyStoreErr.equiv("cannot save process state:")
}

View File

@ -1,4 +1,4 @@
package setuid package app
import ( import (
"bytes" "bytes"
@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"maps"
"os" "os"
"path" "path"
"regexp" "regexp"
@ -16,17 +17,15 @@ import (
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"git.gensokyo.uk/security/hakurei/acl" "git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/hakurei/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/hakurei/hst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/hakurei/internal" "git.gensokyo.uk/security/fortify/internal"
. "git.gensokyo.uk/security/hakurei/internal/app" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/hakurei/internal/app/instance/common" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/hakurei/internal/sys" "git.gensokyo.uk/security/fortify/sandbox/wl"
"git.gensokyo.uk/security/hakurei/sandbox" "git.gensokyo.uk/security/fortify/system"
"git.gensokyo.uk/security/hakurei/sandbox/wl"
"git.gensokyo.uk/security/hakurei/system"
) )
const ( const (
@ -63,20 +62,20 @@ var (
var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$") var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$")
// outcome stores copies of various parts of [hst.Config] // outcome stores copies of various parts of [fst.Config]
type outcome struct { type outcome struct {
// copied from initialising [app] // copied from initialising [app]
id *stringPair[ID] id *stringPair[fst.ID]
// copied from [sys.State] response // copied from [sys.State] response
runDirPath string runDirPath string
// initial [hst.Config] gob stream for state data; // initial [fst.Config] gob stream for state data;
// this is prepared ahead of time as config is clobbered during seal creation // this is prepared ahead of time as config is clobbered during seal creation
ct io.WriterTo ct io.WriterTo
// dump dbus proxy message buffer // dump dbus proxy message buffer
dbusMsg func() dbusMsg func()
user hsuUser user fsuUser
sys *system.I sys *system.I
ctx context.Context ctx context.Context
@ -87,55 +86,8 @@ type outcome struct {
f atomic.Bool f atomic.Bool
} }
// shareHost holds optional share directory state that must not be accessed directly // fsuUser stores post-fsu credentials and metadata
type shareHost struct { type fsuUser struct {
// whether XDG_RUNTIME_DIR is used post hsu
useRuntimeDir bool
// process-specific directory in tmpdir, empty if unused
sharePath string
// process-specific directory in XDG_RUNTIME_DIR, empty if unused
runtimeSharePath string
seal *outcome
sc Paths
}
// ensureRuntimeDir must be called if direct access to paths within XDG_RUNTIME_DIR is required
func (share *shareHost) ensureRuntimeDir() {
if share.useRuntimeDir {
return
}
share.useRuntimeDir = true
share.seal.sys.Ensure(share.sc.RunDirPath, 0700)
share.seal.sys.UpdatePermType(system.User, share.sc.RunDirPath, acl.Execute)
share.seal.sys.Ensure(share.sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset
share.seal.sys.UpdatePermType(system.User, share.sc.RuntimePath, acl.Execute)
}
// instance returns a process-specific share path within tmpdir
func (share *shareHost) instance() string {
if share.sharePath != "" {
return share.sharePath
}
share.sharePath = path.Join(share.sc.SharePath, share.seal.id.String())
share.seal.sys.Ephemeral(system.Process, share.sharePath, 0711)
return share.sharePath
}
// runtime returns a process-specific share path within XDG_RUNTIME_DIR
func (share *shareHost) runtime() string {
if share.runtimeSharePath != "" {
return share.runtimeSharePath
}
share.ensureRuntimeDir()
share.runtimeSharePath = path.Join(share.sc.RunDirPath, share.seal.id.String())
share.seal.sys.Ephemeral(system.Process, share.runtimeSharePath, 0700)
share.seal.sys.UpdatePerm(share.runtimeSharePath, acl.Execute)
return share.runtimeSharePath
}
// hsuUser stores post-hsu credentials and metadata
type hsuUser struct {
// application id // application id
aid *stringPair[int] aid *stringPair[int]
// target uid resolved by fid:aid // target uid resolved by fid:aid
@ -152,43 +104,52 @@ type hsuUser struct {
username string username string
} }
func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Config) error { func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Config) error {
if seal.ctx != nil { if seal.ctx != nil {
panic("finalise called twice") panic("finalise called twice")
} }
seal.ctx = ctx seal.ctx = ctx
shellPath := "/bin/sh"
if s, ok := sys.LookupEnv(shell); ok && path.IsAbs(s) {
shellPath = s
}
{ {
// encode initial configuration for state tracking // encode initial configuration for state tracking
ct := new(bytes.Buffer) ct := new(bytes.Buffer)
if err := gob.NewEncoder(ct).Encode(config); err != nil { if err := gob.NewEncoder(ct).Encode(config); err != nil {
return hlog.WrapErrSuffix(err, return fmsg.WrapErrorSuffix(err,
"cannot encode initial config:") "cannot encode initial config:")
} }
seal.ct = ct seal.ct = ct
} }
// allowed aid range 0 to 9999, this is checked again in hsu // allowed aid range 0 to 9999, this is checked again in fsu
if config.Identity < 0 || config.Identity > 9999 { if config.Confinement.AppID < 0 || config.Confinement.AppID > 9999 {
return hlog.WrapErr(ErrUser, return fmsg.WrapError(ErrUser,
fmt.Sprintf("identity %d out of range", config.Identity)) fmt.Sprintf("aid %d out of range", config.Confinement.AppID))
} }
seal.user = hsuUser{ /*
aid: newInt(config.Identity), Resolve post-fsu user state
data: config.Data, */
home: config.Dir,
username: config.Username, seal.user = fsuUser{
aid: newInt(config.Confinement.AppID),
data: config.Confinement.Outer,
home: config.Confinement.Inner,
username: config.Confinement.Username,
} }
if seal.user.username == "" { if seal.user.username == "" {
seal.user.username = "chronos" seal.user.username = "chronos"
} else if !posixUsername.MatchString(seal.user.username) || } else if !posixUsername.MatchString(seal.user.username) ||
len(seal.user.username) >= internal.Sysconf_SC_LOGIN_NAME_MAX() { len(seal.user.username) >= internal.Sysconf_SC_LOGIN_NAME_MAX() {
return hlog.WrapErr(ErrName, return fmsg.WrapError(ErrName,
fmt.Sprintf("invalid user name %q", seal.user.username)) fmt.Sprintf("invalid user name %q", seal.user.username))
} }
if seal.user.data == "" || !path.IsAbs(seal.user.data) { if seal.user.data == "" || !path.IsAbs(seal.user.data) {
return hlog.WrapErr(ErrHome, return fmsg.WrapError(ErrHome,
fmt.Sprintf("invalid home directory %q", seal.user.data)) fmt.Sprintf("invalid home directory %q", seal.user.data))
} }
if seal.user.home == "" { if seal.user.home == "" {
@ -199,43 +160,38 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
} else { } else {
seal.user.uid = newInt(u) seal.user.uid = newInt(u)
} }
seal.user.supp = make([]string, len(config.Groups)) seal.user.supp = make([]string, len(config.Confinement.Groups))
for i, name := range config.Groups { for i, name := range config.Confinement.Groups {
if g, err := sys.LookupGroup(name); err != nil { if g, err := sys.LookupGroup(name); err != nil {
return hlog.WrapErr(err, return fmsg.WrapError(err,
fmt.Sprintf("unknown group %q", name)) fmt.Sprintf("unknown group %q", name))
} else { } else {
seal.user.supp[i] = g.Gid seal.user.supp[i] = g.Gid
} }
} }
// this also falls back to host path if encountering an invalid path /*
if !path.IsAbs(config.Shell) { Resolve initial container state
config.Shell = "/bin/sh" */
if s, ok := sys.LookupEnv(shell); ok && path.IsAbs(s) {
config.Shell = s
}
}
// do not use the value of shell before this point
// permissive defaults // permissive defaults
if config.Container == nil { if config.Confinement.Sandbox == nil {
hlog.Verbose("container configuration not supplied, PROCEED WITH CAUTION") fmsg.Verbose("sandbox configuration not supplied, PROCEED WITH CAUTION")
// hsu clears the environment so resolve paths early // fsu clears the environment so resolve paths early
if !path.IsAbs(config.Path) { if !path.IsAbs(config.Path) {
if len(config.Args) > 0 { if len(config.Args) > 0 {
if p, err := sys.LookPath(config.Args[0]); err != nil { if p, err := sys.LookPath(config.Args[0]); err != nil {
return hlog.WrapErr(err, err.Error()) return fmsg.WrapError(err, err.Error())
} else { } else {
config.Path = p config.Path = p
} }
} else { } else {
config.Path = config.Shell config.Path = shellPath
} }
} }
conf := &hst.ContainerConfig{ conf := &fst.SandboxConfig{
Userns: true, Userns: true,
Net: true, Net: true,
Tty: true, Tty: true,
@ -245,7 +201,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
if d, err := sys.ReadDir("/"); err != nil { if d, err := sys.ReadDir("/"); err != nil {
return err return err
} else { } else {
b := make([]*hst.FilesystemConfig, 0, len(d)) b := make([]*fst.FilesystemConfig, 0, len(d))
for _, ent := range d { for _, ent := range d {
p := "/" + ent.Name() p := "/" + ent.Name()
switch p { switch p {
@ -256,7 +212,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
case "/etc": case "/etc":
default: default:
b = append(b, &hst.FilesystemConfig{Src: p, Write: true, Must: true}) b = append(b, &fst.FilesystemConfig{Src: p, Write: true, Must: true})
} }
} }
conf.Filesystem = append(conf.Filesystem, b...) conf.Filesystem = append(conf.Filesystem, b...)
@ -268,26 +224,26 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
conf.Cover = append(conf.Cover, nscd) conf.Cover = append(conf.Cover, nscd)
} }
// bind GPU stuff // bind GPU stuff
if config.Enablements&(system.EX11|system.EWayland) != 0 { if config.Confinement.Enablements&(system.EX11|system.EWayland) != 0 {
conf.Filesystem = append(conf.Filesystem, &hst.FilesystemConfig{Src: "/dev/dri", Device: true}) conf.Filesystem = append(conf.Filesystem, &fst.FilesystemConfig{Src: "/dev/dri", Device: true})
} }
// opportunistically bind kvm // opportunistically bind kvm
conf.Filesystem = append(conf.Filesystem, &hst.FilesystemConfig{Src: "/dev/kvm", Device: true}) conf.Filesystem = append(conf.Filesystem, &fst.FilesystemConfig{Src: "/dev/kvm", Device: true})
config.Container = conf config.Confinement.Sandbox = conf
} }
var mapuid, mapgid *stringPair[int] var mapuid, mapgid *stringPair[int]
{ {
var uid, gid int var uid, gid int
var err error var err error
seal.container, seal.env, err = common.NewContainer(config.Container, sys, &uid, &gid) seal.container, seal.env, err = config.Confinement.Sandbox.ToContainer(sys, &uid, &gid)
if err != nil { if err != nil {
return hlog.WrapErrSuffix(err, return fmsg.WrapErrorSuffix(err,
"cannot initialise container configuration:") "cannot initialise container configuration:")
} }
if !path.IsAbs(config.Path) { if !path.IsAbs(config.Path) {
return hlog.WrapErr(syscall.EINVAL, return fmsg.WrapError(syscall.EINVAL,
"invalid program path") "invalid program path")
} }
if len(config.Args) == 0 { if len(config.Args) == 0 {
@ -299,89 +255,97 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
mapuid = newInt(uid) mapuid = newInt(uid)
mapgid = newInt(gid) mapgid = newInt(gid)
if seal.env == nil { if seal.env == nil {
seal.env = make(map[string]string, 1<<6) seal.env = make(map[string]string)
} }
} }
if !config.Container.AutoEtc { /*
if config.Container.Etc != "" { Initialise externals
seal.container.Bind(config.Container.Etc, "/etc", 0) */
}
} else {
etcPath := config.Container.Etc
if etcPath == "" {
etcPath = "/etc"
}
seal.container.Etc(etcPath, seal.id.String())
}
// inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as mapped uid sc := sys.Paths()
seal.runDirPath = sc.RunDirPath
seal.sys = system.New(seal.user.uid.unwrap())
/*
Work directories
*/
// base fortify share path
seal.sys.Ensure(sc.SharePath, 0711)
// outer paths used by the main process
seal.sys.Ensure(sc.RunDirPath, 0700)
seal.sys.UpdatePermType(system.User, sc.RunDirPath, acl.Execute)
seal.sys.Ensure(sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset
seal.sys.UpdatePermType(system.User, sc.RuntimePath, acl.Execute)
// outer process-specific share directory
sharePath := path.Join(sc.SharePath, seal.id.String())
seal.sys.Ephemeral(system.Process, sharePath, 0711)
// similar to share but within XDG_RUNTIME_DIR
sharePathLocal := path.Join(sc.RunDirPath, seal.id.String())
seal.sys.Ephemeral(system.Process, sharePathLocal, 0700)
seal.sys.UpdatePerm(sharePathLocal, acl.Execute)
// inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as post-fsu user
innerRuntimeDir := path.Join("/run/user", mapuid.String()) innerRuntimeDir := path.Join("/run/user", mapuid.String())
seal.container.Tmpfs("/run/user", 1<<12, 0755)
seal.container.Tmpfs(innerRuntimeDir, 1<<23, 0700)
seal.env[xdgRuntimeDir] = innerRuntimeDir seal.env[xdgRuntimeDir] = innerRuntimeDir
seal.env[xdgSessionClass] = "user" seal.env[xdgSessionClass] = "user"
seal.env[xdgSessionType] = "tty" seal.env[xdgSessionType] = "tty"
share := &shareHost{seal: seal, sc: sys.Paths()} // outer path for inner /tmp
seal.runDirPath = share.sc.RunDirPath
seal.sys = system.New(seal.user.uid.unwrap())
seal.sys.Ensure(share.sc.SharePath, 0711)
{ {
runtimeDir := path.Join(share.sc.SharePath, "runtime") tmpdir := path.Join(sc.SharePath, "tmpdir")
seal.sys.Ensure(runtimeDir, 0700)
seal.sys.UpdatePermType(system.User, runtimeDir, acl.Execute)
runtimeDirInst := path.Join(runtimeDir, seal.user.aid.String())
seal.sys.Ensure(runtimeDirInst, 0700)
seal.sys.UpdatePermType(system.User, runtimeDirInst, acl.Read, acl.Write, acl.Execute)
seal.container.Tmpfs("/run/user", 1<<12, 0755)
seal.container.Bind(runtimeDirInst, innerRuntimeDir, sandbox.BindWritable)
}
{
tmpdir := path.Join(share.sc.SharePath, "tmpdir")
seal.sys.Ensure(tmpdir, 0700) seal.sys.Ensure(tmpdir, 0700)
seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute) seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute)
tmpdirInst := path.Join(tmpdir, seal.user.aid.String()) tmpdirInst := path.Join(tmpdir, seal.user.aid.String())
seal.sys.Ensure(tmpdirInst, 01700) seal.sys.Ensure(tmpdirInst, 01700)
seal.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute) seal.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute)
// mount inner /tmp from share so it shares persistence and storage behaviour of host /tmp
seal.container.Bind(tmpdirInst, "/tmp", sandbox.BindWritable) seal.container.Bind(tmpdirInst, "/tmp", sandbox.BindWritable)
} }
{ /*
homeDir := "/var/empty" Passwd database
if seal.user.home != "" { */
homeDir = seal.user.home
}
username := "chronos"
if seal.user.username != "" {
username = seal.user.username
}
seal.container.Bind(seal.user.data, homeDir, sandbox.BindWritable)
seal.container.Dir = homeDir
seal.env["HOME"] = homeDir
seal.env["USER"] = username
seal.env[shell] = config.Shell
seal.container.Place("/etc/passwd", homeDir := "/var/empty"
[]byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Hakurei:"+homeDir+":"+config.Shell+"\n")) if seal.user.home != "" {
seal.container.Place("/etc/group", homeDir = seal.user.home
[]byte("hakurei:x:"+mapgid.String()+":\n"))
} }
username := "chronos"
if seal.user.username != "" {
username = seal.user.username
}
seal.container.Bind(seal.user.data, homeDir, sandbox.BindWritable)
seal.container.Dir = homeDir
seal.env["HOME"] = homeDir
seal.env["USER"] = username
// pass TERM for proper terminal I/O in initial process seal.container.Place("/etc/passwd",
[]byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Fortify:"+homeDir+":"+shellPath+"\n"))
seal.container.Place("/etc/group",
[]byte("fortify:x:"+mapgid.String()+":\n"))
/*
Display servers
*/
// pass $TERM for proper terminal I/O in shell
if t, ok := sys.LookupEnv(term); ok { if t, ok := sys.LookupEnv(term); ok {
seal.env[term] = t seal.env[term] = t
} }
if config.Enablements&system.EWayland != 0 { if config.Confinement.Enablements&system.EWayland != 0 {
// outer wayland socket (usually `/run/user/%d/wayland-%d`) // outer wayland socket (usually `/run/user/%d/wayland-%d`)
var socketPath string var socketPath string
if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok { if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok {
hlog.Verbose(wl.WaylandDisplay + " is not set, assuming " + wl.FallbackName) fmsg.Verbose(wl.WaylandDisplay + " is not set, assuming " + wl.FallbackName)
socketPath = path.Join(share.sc.RuntimePath, wl.FallbackName) socketPath = path.Join(sc.RuntimePath, wl.FallbackName)
} else if !path.IsAbs(name) { } else if !path.IsAbs(name) {
socketPath = path.Join(share.sc.RuntimePath, name) socketPath = path.Join(sc.RuntimePath, name)
} else { } else {
socketPath = name socketPath = name
} }
@ -389,27 +353,27 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
innerPath := path.Join(innerRuntimeDir, wl.FallbackName) innerPath := path.Join(innerRuntimeDir, wl.FallbackName)
seal.env[wl.WaylandDisplay] = wl.FallbackName seal.env[wl.WaylandDisplay] = wl.FallbackName
if !config.DirectWayland { // set up security-context-v1 if !config.Confinement.Sandbox.DirectWayland { // set up security-context-v1
socketDir := path.Join(sc.SharePath, "wayland")
outerPath := path.Join(socketDir, seal.id.String())
seal.sys.Ensure(socketDir, 0711)
appID := config.ID appID := config.ID
if appID == "" { if appID == "" {
// use instance ID in case app id is not set // use instance ID in case app id is not set
appID = "uk.gensokyo.hakurei." + seal.id.String() appID = "uk.gensokyo.fortify." + seal.id.String()
} }
// downstream socket paths
outerPath := path.Join(share.instance(), "wayland")
seal.sys.Wayland(&seal.sync, outerPath, socketPath, appID, seal.id.String()) seal.sys.Wayland(&seal.sync, outerPath, socketPath, appID, seal.id.String())
seal.container.Bind(outerPath, innerPath, 0) seal.container.Bind(outerPath, innerPath, 0)
} else { // bind mount wayland socket (insecure) } else { // bind mount wayland socket (insecure)
hlog.Verbose("direct wayland access, PROCEED WITH CAUTION") fmsg.Verbose("direct wayland access, PROCEED WITH CAUTION")
share.ensureRuntimeDir()
seal.container.Bind(socketPath, innerPath, 0) seal.container.Bind(socketPath, innerPath, 0)
seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute) seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute)
} }
} }
if config.Enablements&system.EX11 != 0 { if config.Confinement.Enablements&system.EX11 != 0 {
if d, ok := sys.LookupEnv(display); !ok { if d, ok := sys.LookupEnv(display); !ok {
return hlog.WrapErr(ErrXDisplay, return fmsg.WrapError(ErrXDisplay,
"DISPLAY is not set") "DISPLAY is not set")
} else { } else {
seal.sys.ChangeHosts("#" + seal.user.uid.String()) seal.sys.ChangeHosts("#" + seal.user.uid.String())
@ -418,37 +382,41 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
} }
} }
if config.Enablements&system.EPulse != 0 { /*
PulseAudio server and authentication
*/
if config.Confinement.Enablements&system.EPulse != 0 {
// PulseAudio runtime directory (usually `/run/user/%d/pulse`) // PulseAudio runtime directory (usually `/run/user/%d/pulse`)
pulseRuntimeDir := path.Join(share.sc.RuntimePath, "pulse") pulseRuntimeDir := path.Join(sc.RuntimePath, "pulse")
// PulseAudio socket (usually `/run/user/%d/pulse/native`) // PulseAudio socket (usually `/run/user/%d/pulse/native`)
pulseSocket := path.Join(pulseRuntimeDir, "native") pulseSocket := path.Join(pulseRuntimeDir, "native")
if _, err := sys.Stat(pulseRuntimeDir); err != nil { if _, err := sys.Stat(pulseRuntimeDir); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return hlog.WrapErrSuffix(err, return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio directory %q:", pulseRuntimeDir)) fmt.Sprintf("cannot access PulseAudio directory %q:", pulseRuntimeDir))
} }
return hlog.WrapErr(ErrPulseSocket, return fmsg.WrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir)) fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir))
} }
if s, err := sys.Stat(pulseSocket); err != nil { if s, err := sys.Stat(pulseSocket); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return hlog.WrapErrSuffix(err, return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio socket %q:", pulseSocket)) fmt.Sprintf("cannot access PulseAudio socket %q:", pulseSocket))
} }
return hlog.WrapErr(ErrPulseSocket, return fmsg.WrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir)) fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir))
} else { } else {
if m := s.Mode(); m&0o006 != 0o006 { if m := s.Mode(); m&0o006 != 0o006 {
return hlog.WrapErr(ErrPulseMode, return fmsg.WrapError(ErrPulseMode,
fmt.Sprintf("unexpected permissions on %q:", pulseSocket), m) fmt.Sprintf("unexpected permissions on %q:", pulseSocket), m)
} }
} }
// hard link pulse socket into target-executable share // hard link pulse socket into target-executable share
innerPulseRuntimeDir := path.Join(share.runtime(), "pulse") innerPulseRuntimeDir := path.Join(sharePathLocal, "pulse")
innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native") innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native")
seal.sys.Link(pulseSocket, innerPulseRuntimeDir) seal.sys.Link(pulseSocket, innerPulseRuntimeDir)
seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0) seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0)
@ -457,9 +425,9 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
// publish current user's pulse cookie for target user // publish current user's pulse cookie for target user
if src, err := discoverPulseCookie(sys); err != nil { if src, err := discoverPulseCookie(sys); err != nil {
// not fatal // not fatal
hlog.Verbose(strings.TrimSpace(err.(*hlog.BaseError).Message())) fmsg.Verbose(strings.TrimSpace(err.(*fmsg.BaseError).Message()))
} else { } else {
innerDst := hst.Tmp + "/pulse-cookie" innerDst := fst.Tmp + "/pulse-cookie"
seal.env[pulseCookie] = innerDst seal.env[pulseCookie] = innerDst
var payload *[]byte var payload *[]byte
seal.container.PlaceP(innerDst, &payload) seal.container.PlaceP(innerDst, &payload)
@ -467,19 +435,22 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
} }
} }
if config.Enablements&system.EDBus != 0 { /*
D-Bus proxy
*/
if config.Confinement.Enablements&system.EDBus != 0 {
// ensure dbus session bus defaults // ensure dbus session bus defaults
if config.SessionBus == nil { if config.Confinement.SessionBus == nil {
config.SessionBus = dbus.NewConfig(config.ID, true, true) config.Confinement.SessionBus = dbus.NewConfig(config.ID, true, true)
} }
// downstream socket paths // downstream socket paths
sharePath := share.instance()
sessionPath, systemPath := path.Join(sharePath, "bus"), path.Join(sharePath, "system_bus_socket") sessionPath, systemPath := path.Join(sharePath, "bus"), path.Join(sharePath, "system_bus_socket")
// configure dbus proxy // configure dbus proxy
if f, err := seal.sys.ProxyDBus( if f, err := seal.sys.ProxyDBus(
config.SessionBus, config.SystemBus, config.Confinement.SessionBus, config.Confinement.SystemBus,
sessionPath, systemPath, sessionPath, systemPath,
); err != nil { ); err != nil {
return err return err
@ -492,7 +463,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
seal.env[dbusSessionBusAddress] = "unix:path=" + sessionInner seal.env[dbusSessionBusAddress] = "unix:path=" + sessionInner
seal.container.Bind(sessionPath, sessionInner, 0) seal.container.Bind(sessionPath, sessionInner, 0)
seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write)
if config.SystemBus != nil { if config.Confinement.SystemBus != nil {
systemInner := "/run/dbus/system_bus_socket" systemInner := "/run/dbus/system_bus_socket"
seal.env[dbusSystemBusAddress] = "unix:path=" + systemInner seal.env[dbusSystemBusAddress] = "unix:path=" + systemInner
seal.container.Bind(systemPath, systemInner, 0) seal.container.Bind(systemPath, systemInner, 0)
@ -500,12 +471,16 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
} }
} }
for _, dest := range config.Container.Cover { /*
Miscellaneous
*/
for _, dest := range config.Confinement.Sandbox.Cover {
seal.container.Tmpfs(dest, 1<<13, 0755) seal.container.Tmpfs(dest, 1<<13, 0755)
} }
// append ExtraPerms last // append ExtraPerms last
for _, p := range config.ExtraPerms { for _, p := range config.Confinement.ExtraPerms {
if p == nil { if p == nil {
continue continue
} }
@ -529,19 +504,11 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *hst.Co
// flatten and sort env for deterministic behaviour // flatten and sort env for deterministic behaviour
seal.container.Env = make([]string, 0, len(seal.env)) seal.container.Env = make([]string, 0, len(seal.env))
for k, v := range seal.env { maps.All(seal.env)(func(k string, v string) bool { seal.container.Env = append(seal.container.Env, k+"="+v); return true })
if strings.IndexByte(k, '=') != -1 {
return hlog.WrapErr(syscall.EINVAL,
fmt.Sprintf("invalid environment variable %s", k))
}
seal.container.Env = append(seal.container.Env, k+"="+v)
}
slices.Sort(seal.container.Env) slices.Sort(seal.container.Env)
if hlog.Load() { fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s",
hlog.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s, ops: %d", seal.user.uid, seal.user.username, config.Confinement.Groups, seal.container.Args)
seal.user.uid, seal.user.username, config.Groups, seal.container.Args, len(*seal.container.Ops))
}
return nil return nil
} }
@ -557,7 +524,7 @@ func discoverPulseCookie(sys sys.State) (string, error) {
p = path.Join(p, ".pulse-cookie") p = path.Join(p, ".pulse-cookie")
if s, err := sys.Stat(p); err != nil { if s, err := sys.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return p, hlog.WrapErrSuffix(err, return p, fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio cookie %q:", p)) fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
} }
// not found, try next method // not found, try next method
@ -571,7 +538,7 @@ func discoverPulseCookie(sys sys.State) (string, error) {
p = path.Join(p, "pulse", "cookie") p = path.Join(p, "pulse", "cookie")
if s, err := sys.Stat(p); err != nil { if s, err := sys.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return p, hlog.WrapErrSuffix(err, return p, fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio cookie %q:", p)) fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
} }
// not found, try next method // not found, try next method
@ -580,7 +547,7 @@ func discoverPulseCookie(sys sys.State) (string, error) {
} }
} }
return "", hlog.WrapErr(ErrPulseCookie, return "", fmsg.WrapError(ErrPulseCookie,
fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)", fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
pulseCookie, xdgConfigHome, home)) pulseCookie, xdgConfigHome, home))
} }

212
internal/app/shim.go Normal file
View File

@ -0,0 +1,212 @@
package app
import (
"context"
"encoding/gob"
"errors"
"log"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/sandbox"
)
const shimEnv = "FORTIFY_SHIM"
type shimParams struct {
// finalised container params
Container *sandbox.Params
// path to outer home directory
Home string
// verbosity pass through
Verbose bool
}
// ShimMain is the main function of the shim process and runs as the unconstrained target user.
func ShimMain() {
fmsg.Prepare("shim")
if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil {
log.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err)
}
var (
params shimParams
closeSetup func() error
)
if f, err := sandbox.Receive(shimEnv, &params, nil); err != nil {
if errors.Is(err, sandbox.ErrInvalid) {
log.Fatal("invalid config descriptor")
}
if errors.Is(err, sandbox.ErrNotSet) {
log.Fatal("FORTIFY_SHIM not set")
}
log.Fatalf("cannot receive shim setup params: %v", err)
} else {
internal.InstallFmsg(params.Verbose)
closeSetup = f
}
if params.Container == nil || params.Container.Ops == nil {
log.Fatal("invalid container params")
}
// close setup socket
if err := closeSetup(); err != nil {
log.Printf("cannot close setup pipe: %v", err)
// not fatal
}
// ensure home directory as target user
if s, err := os.Stat(params.Home); err != nil {
if os.IsNotExist(err) {
if err = os.Mkdir(params.Home, 0700); err != nil {
log.Fatalf("cannot create home directory: %v", err)
}
} else {
log.Fatalf("cannot access home directory: %v", err)
}
// home directory is created, proceed
} else if !s.IsDir() {
log.Fatalf("path %q is not a directory", params.Home)
}
var name string
if len(params.Container.Args) > 0 {
name = params.Container.Args[0]
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() // unreachable
container := sandbox.New(ctx, name)
container.Params = *params.Container
container.Stdin, container.Stdout, container.Stderr = os.Stdin, os.Stdout, os.Stderr
container.Cancel = func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }
container.WaitDelay = 2 * time.Second
if err := container.Start(); err != nil {
fmsg.PrintBaseError(err, "cannot start container:")
os.Exit(1)
}
if err := container.Serve(); err != nil {
fmsg.PrintBaseError(err, "cannot configure container:")
}
if err := container.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
if errors.Is(err, context.Canceled) {
os.Exit(2)
}
log.Printf("wait: %v", err)
os.Exit(127)
}
os.Exit(exitError.ExitCode())
}
}
type shimProcess struct {
// user switcher process
cmd *exec.Cmd
// fallback exit notifier with error returned killing the process
killFallback chan error
// monitor to shim encoder
encoder *gob.Encoder
}
func (s *shimProcess) Unwrap() *exec.Cmd { return s.cmd }
func (s *shimProcess) Fallback() chan error { return s.killFallback }
func (s *shimProcess) String() string {
if s.cmd == nil {
return "(unused shim manager)"
}
return s.cmd.String()
}
func (s *shimProcess) Start(
aid string,
supp []string,
) (*time.Time, error) {
// prepare user switcher invocation
fsuPath := internal.MustFsuPath()
s.cmd = exec.Command(fsuPath)
// pass shim setup pipe
if fd, e, err := sandbox.Setup(&s.cmd.ExtraFiles); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot create shim setup pipe:")
} else {
s.encoder = e
s.cmd.Env = []string{
shimEnv + "=" + strconv.Itoa(fd),
"FORTIFY_APP_ID=" + aid,
}
}
// format fsu supplementary groups
if len(supp) > 0 {
fmsg.Verbosef("attaching supplementary group ids %s", supp)
s.cmd.Env = append(s.cmd.Env, "FORTIFY_GROUPS="+strings.Join(supp, " "))
}
s.cmd.Stdin, s.cmd.Stdout, s.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
s.cmd.Dir = "/"
fmsg.Verbose("starting shim via fsu:", s.cmd)
// withhold messages to stderr
fmsg.Suspend()
if err := s.cmd.Start(); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot start fsu:")
}
startTime := time.Now().UTC()
return &startTime, nil
}
func (s *shimProcess) Serve(ctx context.Context, params *shimParams) error {
// kill shim if something goes wrong and an error is returned
s.killFallback = make(chan error, 1)
killShim := func() {
if err := s.cmd.Process.Signal(os.Interrupt); err != nil {
s.killFallback <- err
}
}
defer func() { killShim() }()
encodeErr := make(chan error)
go func() { encodeErr <- s.encoder.Encode(params) }()
select {
// encode return indicates setup completion
case err := <-encodeErr:
if err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot transmit shim config:")
}
killShim = func() {}
return nil
// setup canceled before payload was accepted
case <-ctx.Done():
err := ctx.Err()
if errors.Is(err, context.Canceled) {
return fmsg.WrapError(syscall.ECANCELED,
"shim setup canceled")
}
if errors.Is(err, context.DeadlineExceeded) {
return fmsg.WrapError(syscall.ETIMEDOUT,
"deadline exceeded waiting for shim")
}
// unreachable
return err
}
}

View File

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

View File

@ -3,7 +3,7 @@ package internal
import ( import (
"os" "os"
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
func Exit(code int) { hlog.BeforeExit(); os.Exit(code) } func Exit(code int) { fmsg.BeforeExit(); os.Exit(code) }

View File

@ -1,4 +1,4 @@
package hlog package fmsg
import ( import (
"fmt" "fmt"
@ -12,8 +12,13 @@ type baseError struct {
Err error Err error
} }
func (e *baseError) Error() string { return e.Err.Error() } func (e *baseError) Error() string {
func (e *baseError) Unwrap() error { return e.Err } return e.Err.Error()
}
func (e *baseError) Unwrap() error {
return e.Err
}
// BaseError implements an error container with a user-facing message // BaseError implements an error container with a user-facing message
type BaseError struct { type BaseError struct {
@ -22,33 +27,35 @@ type BaseError struct {
} }
// Message returns a user-facing error message // Message returns a user-facing error message
func (e *BaseError) Message() string { return e.message } func (e *BaseError) Message() string {
return e.message
}
// WrapErr wraps an error with a corresponding message. // WrapError wraps an error with a corresponding message.
func WrapErr(err error, a ...any) error { func WrapError(err error, a ...any) error {
if err == nil { if err == nil {
return nil return nil
} }
return wrapErr(err, fmt.Sprintln(a...)) return wrapError(err, fmt.Sprintln(a...))
} }
// WrapErrSuffix wraps an error with a corresponding message with err at the end of the message. // WrapErrorSuffix wraps an error with a corresponding message with err at the end of the message.
func WrapErrSuffix(err error, a ...any) error { func WrapErrorSuffix(err error, a ...any) error {
if err == nil { if err == nil {
return nil return nil
} }
return wrapErr(err, fmt.Sprintln(append(a, err)...)) return wrapError(err, fmt.Sprintln(append(a, err)...))
} }
// WrapErrFunc wraps an error with a corresponding message returned by f. // WrapErrorFunc wraps an error with a corresponding message returned by f.
func WrapErrFunc(err error, f func(err error) string) error { func WrapErrorFunc(err error, f func(err error) string) error {
if err == nil { if err == nil {
return nil return nil
} }
return wrapErr(err, f(err)) return wrapError(err, f(err))
} }
func wrapErr(err error, message string) *BaseError { func wrapError(err error, message string) *BaseError {
return &BaseError{message, baseError{err}} return &BaseError{message, baseError{err}}
} }

View File

@ -1,5 +1,5 @@
// Package hlog provides various functions for output messages. // Package fmsg provides various functions for output messages.
package hlog package fmsg
import ( import (
"bytes" "bytes"

View File

@ -1,11 +1,11 @@
package hlog package fmsg
type Output struct{} type Output struct{}
func (Output) IsVerbose() bool { return Load() } func (Output) IsVerbose() bool { return Load() }
func (Output) Verbose(v ...any) { Verbose(v...) } func (Output) Verbose(v ...any) { Verbose(v...) }
func (Output) Verbosef(format string, v ...any) { Verbosef(format, v...) } func (Output) Verbosef(format string, v ...any) { Verbosef(format, v...) }
func (Output) WrapErr(err error, a ...any) error { return WrapErr(err, a...) } func (Output) WrapErr(err error, a ...any) error { return WrapError(err, a...) }
func (Output) PrintBaseErr(err error, fallback string) { PrintBaseError(err, fallback) } func (Output) PrintBaseErr(err error, fallback string) { PrintBaseError(err, fallback) }
func (Output) Suspend() { Suspend() } func (Output) Suspend() { Suspend() }
func (Output) Resume() bool { return Resume() } func (Output) Resume() bool { return Resume() }

View File

@ -1,4 +1,4 @@
package hlog package fmsg
import ( import (
"log" "log"

View File

@ -1,17 +1,17 @@
package internal package internal
import ( import (
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/hakurei/sandbox" "git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/hakurei/sandbox/seccomp" "git.gensokyo.uk/security/fortify/sandbox/seccomp"
"git.gensokyo.uk/security/hakurei/system" "git.gensokyo.uk/security/fortify/system"
) )
func InstallFmsg(verbose bool) { func InstallFmsg(verbose bool) {
hlog.Store(verbose) fmsg.Store(verbose)
sandbox.SetOutput(hlog.Output{}) sandbox.SetOutput(fmsg.Output{})
system.SetOutput(hlog.Output{}) system.SetOutput(fmsg.Output{})
if verbose { if verbose {
seccomp.SetOutput(hlog.Verbose) seccomp.SetOutput(fmsg.Verbose)
} }
} }

View File

@ -4,30 +4,20 @@ import (
"log" "log"
"path" "path"
"git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
var ( var (
hakurei = compPoison fsu = compPoison
hsu = compPoison
) )
func MustHakureiPath() string { func MustFsuPath() string {
if name, ok := checkPath(hakurei); ok { if name, ok := checkPath(fsu); ok {
return name return name
} }
hlog.BeforeExit() fmsg.BeforeExit()
log.Fatal("invalid hakurei path, this program is compiled incorrectly") log.Fatal("invalid fsu path, this program is compiled incorrectly")
return compPoison // unreachable return compPoison
}
func MustHsuPath() string {
if name, ok := checkPath(hsu); ok {
return name
}
hlog.BeforeExit()
log.Fatal("invalid hsu path, this program is compiled incorrectly")
return compPoison // unreachable
} }
func checkPath(p string) (string, bool) { return p, p != compPoison && p != "" && path.IsAbs(p) } func checkPath(p string) (string, bool) { return p, p != compPoison && p != "" && path.IsAbs(p) }

Some files were not shown because too many files have changed in this diff Show More