Compare commits

...

37 Commits

Author SHA1 Message Date
195b717e01
release: 0.2.5
All checks were successful
Tests / Go tests (push) Successful in 49s
Create distribution / Release (push) Successful in 1m6s
Nix / NixOS tests (push) Successful in 1m23s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-20 00:28:48 +09:00
df6fc298f6
migrate to git.gensokyo.uk/security/fortify
All checks were successful
Tests / Go tests (push) Successful in 2m55s
Nix / NixOS tests (push) Successful in 5m10s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-20 00:20:02 +09:00
eae3034260
state: expose aids and use instance id as key
All checks were successful
Tests / Go tests (push) Successful in 39s
Nix / NixOS tests (push) Successful in 3m26s
Fortify state store instances was specific to aids due to outdated design decisions carried over from the ego rewrite. That no longer makes sense in the current application, so the interface now enables a single store object to manage all transient state.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-19 21:36:17 +09:00
5ea7333431
fst: implement app id parser
All checks were successful
Tests / Go tests (push) Successful in 40s
Nix / NixOS tests (push) Successful in 3m8s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-19 18:19:47 +09:00
f796622c35
state: rename simple store implementation
All checks were successful
Tests / Go tests (push) Successful in 42s
Nix / NixOS tests (push) Successful in 3m4s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-19 11:48:48 +09:00
5d25bee786
fortify: remove systemd check
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 3m3s
This is no longer necessary as fortify no longer integrates with external user switchers.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-19 11:14:31 +09:00
b48ece3bb0
acl: use test-managed tmpdir
All checks were successful
Tests / Go tests (push) Successful in 44s
Nix / NixOS tests (push) Successful in 3m7s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-19 11:08:13 +09:00
9f95f60400
release: 0.2.4
All checks were successful
Tests / Go tests (push) Successful in 52s
Create distribution / Release (push) Successful in 1m9s
Nix / NixOS tests (push) Successful in 1m23s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 23:52:52 +09:00
90dd57f75d
workflows: cache nix store
All checks were successful
Tests / Go tests (push) Successful in 45s
Nix / NixOS tests (push) Successful in 1m11s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 23:38:39 +09:00
141f2e3685
workflows: cache apt packages
All checks were successful
Tests / Go tests (push) Successful in 36s
Nix / NixOS tests (push) Successful in 5m43s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 23:05:28 +09:00
73aa285e8f
workflows: upload nixos test output
All checks were successful
Tests / Go tests (push) Successful in 44s
Nix / NixOS tests (push) Successful in 5m45s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 20:32:40 +09:00
6e87fc02dd
workflows: build and upload test distribution
All checks were successful
Tests / Go tests (push) Successful in 43s
Nix / NixOS tests (push) Successful in 5m33s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 20:28:35 +09:00
52f21a19f3
cmd/fshim: switch to setup pipe
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 5m43s
The socket-based approach is no longer necessary as fsu allows extra files and sudo compatibility is no longer relevant.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 19:39:25 +09:00
7be53a2438
cmd/fshim: switch to generic setup func
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 5m47s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 17:20:31 +09:00
7f29b37a32
proc: setup payload send
Generic setup payload encoder adapted from fshim.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 17:20:01 +09:00
f69e8e753e
cmd/finit: switch to generic receive func
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 5m40s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 16:49:19 +09:00
ef8fd37e9d
proc: setup payload receive
Generic implementation of setup payload receiver adapted from finit.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 16:48:41 +09:00
2f676c9d6e
fst: rename from fipc
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 5m48s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 15:50:46 +09:00
bbace8f84b
nix: increase cpu count
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 5m41s
This improves performance, especially when kvm is inaccessible.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 15:32:52 +09:00
2efedf56c0
nix: collect fortify ps output
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 10m38s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 13:48:39 +09:00
b752ec4468
fipc: export config struct
All checks were successful
Tests / Go tests (push) Successful in 1m12s
Nix / NixOS tests (push) Successful in 10m51s
Also store full config as part of state.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 13:45:55 +09:00
5d00805a7c
nix: check acl rollback
All checks were successful
Tests / Go tests (push) Successful in 1m1s
Nix / NixOS tests (push) Successful in 10m32s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-18 12:49:32 +09:00
7b6052a473
nix: run Go tests in nixos
All checks were successful
Tests / Go tests (push) Successful in 41s
Nix / NixOS tests (push) Successful in 9m56s
Nix build environment does not support ACLs in any filesystem. This allows acl tests to run.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 21:16:55 +09:00
38653c6ab5
release: 0.2.3
All checks were successful
Tests / Go tests (push) Successful in 55s
Create distribution / Release (push) Successful in 1m1s
Nix / NixOS tests (push) Successful in 5m5s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 14:06:17 +09:00
b5cbbeab90
dist: generate distribution tarball
All checks were successful
Tests / Go tests (push) Successful in 46s
Create distribution / Release (push) Successful in 49s
Nix / NixOS tests (push) Successful in 5m9s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 14:02:54 +09:00
c3ba0c3cce
nix: rename nixos test
All checks were successful
Tests / Go tests (push) Successful in 39s
Nix / NixOS tests (push) Successful in 5m0s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 13:02:12 +09:00
b453f70ca2
cmd/fsu: check uid range before syscall
All checks were successful
Tests / Go tests (push) Successful in 43s
Nix / NixOS tests (push) Successful in 5m0s
This limits potential exploits to the fortify uid range.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 13:01:36 +09:00
c2b178e626
xcb: refactor and clean up
All checks were successful
Tests / Go tests (push) Successful in 45s
Nix / NixOS tests (push) Successful in 5m2s
No clean way to write Go tests for this package. Will rely on NixOS tests for now.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 12:46:36 +09:00
aeda40fc92
nix: test x11 permissive defaults
All checks were successful
Tests / Go tests (push) Successful in 40s
Nix / NixOS tests (push) Successful in 4m51s
Also invoke glinfo/wayland-info as part of tests.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 12:40:29 +09:00
65dc39956f
workflows: set action names
All checks were successful
Tests / Go tests (push) Successful in 42s
Nix / NixOS tests (push) Successful in 4m33s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 11:12:39 +09:00
35505c8a26
workflows: invoke nix flake checks
All checks were successful
check / nix-flake-check (push) Successful in 4m32s
test / test (push) Successful in 37s
Integration tests are implemented as nix flake checks.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-17 10:49:43 +09:00
3f993021f8
nix: permissive defaults nixos test
All checks were successful
test / test (push) Successful in 37s
Adapted from nixos sway integration tests.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-16 22:56:10 +09:00
4d3bd5338f
nix: implement flake checks
All checks were successful
test / test (push) Successful in 36s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-16 20:54:28 +09:00
138666d753
nix: skip acl test
All checks were successful
test / test (push) Successful in 39s
The nix build environment does not support ACLs.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-16 19:29:01 +09:00
f4628e181b
acl: create test file in tmpdir
All checks were successful
test / test (push) Successful in 37s
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-16 18:58:09 +09:00
c8a90666c5
acl: refactor and clean up
All checks were successful
test / test (push) Successful in 37s
Move all C code to c.go, switch to pkg-config, set up finalizer for acl.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-16 18:27:19 +09:00
ee41b37606
acl: add tests
All checks were successful
test / test (push) Successful in 37s
These tests test UpdatePerm correctness by parsing getfacl output.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-12-16 16:00:31 +09:00
86 changed files with 1928 additions and 996 deletions

46
.gitea/workflows/nix.yml Normal file
View File

@ -0,0 +1,46 @@
name: Nix
on:
- push
- pull_request
jobs:
tests:
name: NixOS tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Nix
uses: cachix/install-nix-action@08dcb3a5e62fa31e2da3d490afc4176ef55ecd72 # v30
with:
# explicitly enable sandbox
install_options: --daemon
extra_nix_config: |
sandbox = true
system-features = nixos-test benchmark big-parallel kvm
enable_kvm: true
- name: Ensure environment
run: >-
apt-get update && apt-get install -y sqlite3
if: ${{ runner.os == 'Linux' }}
- name: Restore Nix store
uses: nix-community/cache-nix-action@v5
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
- name: Run tests
run: |
nix --print-build-logs --experimental-features 'nix-command flakes' flake check --all-systems
nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.nixos-tests
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "result"
path: result/*
retention-days: 1

View File

@ -1,4 +1,4 @@
name: release name: Create distribution
on: on:
push: push:
@ -7,6 +7,7 @@ on:
jobs: jobs:
release: release:
name: Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: node:16-bookworm-slim image: node:16-bookworm-slim
@ -16,6 +17,7 @@ jobs:
echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list.d/backports.list && echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list.d/backports.list &&
apt-get update && apt-get update &&
apt-get install -y apt-get install -y
acl
git git
gcc gcc
pkg-config pkg-config
@ -39,21 +41,13 @@ jobs:
run: >- run: >-
go generate ./... go generate ./...
- name: Build for Linux - name: Build for release
run: >- run: FORTIFY_VERSION='${{ github.ref_name }}' ./dist/release.sh
go build -v -ldflags '-s -w
-X git.ophivana.moe/security/fortify/internal.Version=${{ github.ref_name }}
-X git.ophivana.moe/security/fortify/internal.Fsu=/usr/bin/fsu
-X git.ophivana.moe/security/fortify/internal.Finit=/usr/libexec/fortify/finit
-X main.Fmain=/usr/bin/fortify
-X main.Fshim=/usr/libexec/fortify/fshim'
-o bin/ ./... &&
(cd bin && sha512sum --tag -b * > sha512sums)
- name: Release - name: Release
id: use-go-action id: use-go-action
uses: https://gitea.com/actions/release-action@main uses: https://gitea.com/actions/release-action@main
with: with:
files: |- files: |-
bin/** dist/fortify-**
api_key: '${{secrets.RELEASE_TOKEN}}' api_key: '${{secrets.RELEASE_TOKEN}}'

View File

@ -1,4 +1,4 @@
name: test name: Tests
on: on:
- push - push
@ -6,22 +6,27 @@ on:
jobs: jobs:
test: test:
name: Go tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: node:16-bookworm-slim image: node:16-bookworm-slim
steps: steps:
- name: Get dependencies - name: Enable backports
run: >- run: >-
echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list.d/backports.list && echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list.d/backports.list
apt-get update && if: ${{ runner.os == 'Linux' }}
apt-get install -y
git - name: Ensure environment
gcc run: >-
pkg-config apt-get update && apt-get install -y curl wget sudo libxml2
libwayland-dev if: ${{ runner.os == 'Linux' }}
wayland-protocols/bookworm-backports
libxcb1-dev - name: Get dependencies
libacl1-dev uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: acl git gcc pkg-config libwayland-dev wayland-protocols/bookworm-backports libxcb1-dev libacl1-dev
version: 1.0
#execute_install_scripts: true
if: ${{ runner.os == 'Linux' }} if: ${{ runner.os == 'Linux' }}
- name: Checkout - name: Checkout
@ -42,13 +47,16 @@ jobs:
run: >- run: >-
go test ./... go test ./...
- name: Build for Linux - name: Build for test
id: build-test
run: >- run: >-
go build -v -ldflags '-s -w FORTIFY_VERSION="$(git rev-parse --short HEAD)"
-X git.ophivana.moe/security/fortify/internal.Version=${{ github.ref_name }} bash -c './dist/release.sh &&
-X git.ophivana.moe/security/fortify/internal.Fsu=/usr/bin/fsu echo "rev=$FORTIFY_VERSION" >> $GITHUB_OUTPUT'
-X git.ophivana.moe/security/fortify/internal.Finit=/usr/libexec/fortify/finit
-X main.Fmain=/usr/bin/fortify - name: Upload test build
-X main.Fshim=/usr/libexec/fortify/fshim' uses: actions/upload-artifact@v3
-o bin/ ./... && with:
(cd bin && sha512sum --tag -b * > sha512sums) name: "fortify-${{ steps.build-test.outputs.rev }}"
path: dist/fortify-*
retention-days: 1

5
.gitignore vendored
View File

@ -25,4 +25,7 @@ go.work.sum
.vscode .vscode
# go generate # go generate
security-context-v1-protocol.* security-context-v1-protocol.*
# release
/dist/fortify-*

View File

@ -1,8 +1,8 @@
Fortify Fortify
======= =======
[![Go Reference](https://pkg.go.dev/badge/git.ophivana.moe/security/fortify.svg)](https://pkg.go.dev/git.ophivana.moe/security/fortify) [![Go Reference](https://pkg.go.dev/badge/git.gensokyo.uk/security/fortify.svg)](https://pkg.go.dev/git.gensokyo.uk/security/fortify)
[![Go Report Card](https://goreportcard.com/badge/git.ophivana.moe/security/fortify)](https://goreportcard.com/report/git.ophivana.moe/security/fortify) [![Go Report Card](https://goreportcard.com/badge/git.gensokyo.uk/security/fortify)](https://goreportcard.com/report/git.gensokyo.uk/security/fortify)
Lets you run graphical applications as another user in a confined environment with a nice NixOS Lets you run graphical applications as another user in a confined environment with a nice NixOS
module to configure target users and provide launchers and desktop files for your privileged user. module to configure target users and provide launchers and desktop files for your privileged user.
@ -18,7 +18,7 @@ Why would you want this?
If you have a flakes-enabled nix environment, you can try out the tool by running: If you have a flakes-enabled nix environment, you can try out the tool by running:
```shell ```shell
nix run git+https://git.ophivana.moe/security/fortify -- help nix run git+https://git.gensokyo.uk/security/fortify -- help
``` ```
## Module usage ## Module usage
@ -35,7 +35,7 @@ To use the module, import it into your configuration with
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
fortify = { fortify = {
url = "git+https://git.ophivana.moe/security/fortify"; 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";

19
acl/acl.go Normal file
View File

@ -0,0 +1,19 @@
// Package acl implements simple ACL manipulation via libacl.
package acl
type Perms []Perm
func (ps Perms) String() string {
var s = []byte("---")
for _, p := range ps {
switch p {
case Read:
s[0] = 'r'
case Write:
s[1] = 'w'
case Execute:
s[2] = 'x'
}
}
return string(s)
}

156
acl/acl_getfacl_test.go Normal file
View File

@ -0,0 +1,156 @@
package acl_test
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
)
type (
getFAclInvocation struct {
cmd *exec.Cmd
val []*getFAclResp
pe []error
}
getFAclResp struct {
typ fAclType
cred int32
val fAclPerm
raw []byte
}
fAclPerm uintptr
fAclType uint8
)
const fAclBufSize = 16
const (
fAclPermRead fAclPerm = 1 << iota
fAclPermWrite
fAclPermExecute
)
const (
fAclTypeUser fAclType = iota
fAclTypeGroup
fAclTypeMask
fAclTypeOther
)
func (c *getFAclInvocation) run(name string) error {
if c.cmd != nil {
panic("attempted to run twice")
}
c.cmd = exec.Command("getfacl", "--omit-header", "--absolute-names", "--numeric", name)
scanErr := make(chan error, 1)
if p, err := c.cmd.StdoutPipe(); err != nil {
return err
} else {
go c.parse(p, scanErr)
}
if err := c.cmd.Start(); err != nil {
return err
}
return errors.Join(<-scanErr, c.cmd.Wait())
}
func (c *getFAclInvocation) parse(pipe io.Reader, scanErr chan error) {
c.val = make([]*getFAclResp, 0, 4+fAclBufSize)
s := bufio.NewScanner(pipe)
for s.Scan() {
fields := bytes.SplitN(s.Bytes(), []byte{':'}, 3)
if len(fields) != 3 {
continue
}
resp := getFAclResp{}
switch string(fields[0]) {
case "user":
resp.typ = fAclTypeUser
case "group":
resp.typ = fAclTypeGroup
case "mask":
resp.typ = fAclTypeMask
case "other":
resp.typ = fAclTypeOther
default:
c.pe = append(c.pe, fmt.Errorf("unknown type %s", string(fields[0])))
continue
}
if len(fields[1]) == 0 {
resp.cred = -1
} else {
if cred, err := strconv.Atoi(string(fields[1])); err != nil {
c.pe = append(c.pe, err)
continue
} else {
resp.cred = int32(cred)
if resp.cred < 0 {
c.pe = append(c.pe, fmt.Errorf("credential %d out of range", resp.cred))
continue
}
}
}
if len(fields[2]) != 3 {
c.pe = append(c.pe, fmt.Errorf("invalid perm length %d", len(fields[2])))
continue
} else {
switch fields[2][0] {
case 'r':
resp.val |= fAclPermRead
case '-':
default:
c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][0]))
continue
}
switch fields[2][1] {
case 'w':
resp.val |= fAclPermWrite
case '-':
default:
c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][1]))
continue
}
switch fields[2][2] {
case 'x':
resp.val |= fAclPermExecute
case '-':
default:
c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][2]))
continue
}
}
resp.raw = make([]byte, len(s.Bytes()))
copy(resp.raw, s.Bytes())
c.val = append(c.val, &resp)
}
scanErr <- s.Err()
}
func (r *getFAclResp) String() string {
if r.raw != nil && len(r.raw) > 0 {
return string(r.raw)
}
return "(user-initialised resp value)"
}
func (r *getFAclResp) equals(typ fAclType, cred int32, val fAclPerm) bool {
return r.typ == typ && r.cred == cred && r.val == val
}

125
acl/acl_test.go Normal file
View File

@ -0,0 +1,125 @@
package acl_test
import (
"errors"
"os"
"path"
"reflect"
"testing"
"git.gensokyo.uk/security/fortify/acl"
)
const testFileName = "acl.test"
var (
uid = os.Geteuid()
cred = int32(os.Geteuid())
)
func TestUpdatePerm(t *testing.T) {
if os.Getenv("GO_TEST_SKIP_ACL") == "1" {
t.Log("acl test skipped")
t.SkipNow()
}
testFilePath := path.Join(t.TempDir(), testFileName)
if f, err := os.Create(testFilePath); err != nil {
t.Fatalf("Create: error = %v", err)
} else {
if err = f.Close(); err != nil {
t.Fatalf("Close: error = %v", err)
}
}
defer func() {
if err := os.Remove(testFilePath); err != nil {
t.Fatalf("Remove: error = %v", err)
}
}()
cur := getfacl(t, testFilePath)
t.Run("default entry count", func(t *testing.T) {
if len(cur) != 3 {
t.Fatalf("unexpected test file acl length %d", len(cur))
}
})
t.Run("default clear mask", func(t *testing.T) {
if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err)
}
if cur = getfacl(t, testFilePath); len(cur) != 4 {
t.Fatalf("UpdatePerm: %v", cur)
}
})
t.Run("default clear consistency", func(t *testing.T) {
if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err)
}
if val := getfacl(t, testFilePath); !reflect.DeepEqual(val, cur) {
t.Fatalf("UpdatePerm: %v, want %v", val, cur)
}
})
testUpdate(t, testFilePath, "r--", cur, fAclPermRead, acl.Read)
testUpdate(t, testFilePath, "-w-", cur, fAclPermWrite, acl.Write)
testUpdate(t, testFilePath, "--x", cur, fAclPermExecute, acl.Execute)
testUpdate(t, testFilePath, "-wx", cur, fAclPermWrite|fAclPermExecute, acl.Write, acl.Execute)
testUpdate(t, testFilePath, "r-x", cur, fAclPermRead|fAclPermExecute, acl.Read, acl.Execute)
testUpdate(t, testFilePath, "rw-", cur, fAclPermRead|fAclPermWrite, acl.Read, acl.Write)
testUpdate(t, testFilePath, "rwx", cur, fAclPermRead|fAclPermWrite|fAclPermExecute, acl.Read, acl.Write, acl.Execute)
}
func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val fAclPerm, perms ...acl.Perm) {
t.Run(name, func(t *testing.T) {
t.Cleanup(func() {
if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err)
}
if v := getfacl(t, testFilePath); !reflect.DeepEqual(v, cur) {
t.Fatalf("UpdatePerm: %v, want %v", v, cur)
}
})
if err := acl.UpdatePerm(testFilePath, uid, perms...); err != nil {
t.Fatalf("UpdatePerm: error = %v", err)
}
r := respByCred(getfacl(t, testFilePath), fAclTypeUser, cred)
if r == nil {
t.Fatalf("UpdatePerm did not add an ACL entry")
}
if !r.equals(fAclTypeUser, cred, val) {
t.Fatalf("UpdatePerm(%s) = %s", name, r)
}
})
}
func getfacl(t *testing.T, name string) []*getFAclResp {
c := new(getFAclInvocation)
if err := c.run(name); err != nil {
t.Fatalf("getfacl: error = %v", err)
}
if len(c.pe) != 0 {
t.Errorf("errors encountered parsing getfacl output\n%s", errors.Join(c.pe...).Error())
}
return c.val
}
func respByCred(v []*getFAclResp, typ fAclType, cred int32) *getFAclResp {
j := -1
for i, r := range v {
if r.typ == typ && r.cred == cred {
if j != -1 {
panic("invalid acl")
}
j = i
}
}
if j == -1 {
return nil
}
return v[j]
}

159
acl/c.go
View File

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

View File

@ -1,107 +0,0 @@
// Package acl implements simple ACL manipulation via libacl.
package acl
import "unsafe"
//#include <stdlib.h>
//#include <sys/acl.h>
//#include <acl/libacl.h>
//#cgo linux LDFLAGS: -lacl
import "C"
const (
Read = C.ACL_READ
Write = C.ACL_WRITE
Execute = C.ACL_EXECUTE
TypeDefault = C.ACL_TYPE_DEFAULT
TypeAccess = C.ACL_TYPE_ACCESS
UndefinedTag = C.ACL_UNDEFINED_TAG
UserObj = C.ACL_USER_OBJ
User = C.ACL_USER
GroupObj = C.ACL_GROUP_OBJ
Group = C.ACL_GROUP
Mask = C.ACL_MASK
Other = C.ACL_OTHER
)
type (
Perm C.acl_perm_t
Perms []Perm
)
func (ps Perms) String() string {
var s = []byte("---")
for _, p := range ps {
switch p {
case Read:
s[0] = 'r'
case Write:
s[1] = 'w'
case Execute:
s[2] = 'x'
}
}
return string(s)
}
func UpdatePerm(path string, uid int, perms ...Perm) error {
// read acl from file
a, err := aclGetFile(path, TypeAccess)
if err != nil {
return err
}
// free acl on return if get is successful
defer a.free()
// remove existing entry
if err = a.removeEntry(User, uid); err != nil {
return err
}
// create new entry if perms are passed
if len(perms) > 0 {
// create new acl entry
var e C.acl_entry_t
if _, err = C.acl_create_entry(&a.val, &e); err != nil {
return err
}
// get perm set of new entry
var p C.acl_permset_t
if _, err = C.acl_get_permset(e, &p); err != nil {
return err
}
// add target perms
for _, perm := range perms {
if _, err = C.acl_add_perm(p, C.acl_perm_t(perm)); err != nil {
return err
}
}
// set perm set to new entry
if _, err = C.acl_set_permset(e, p); err != nil {
return err
}
// set user tag to new entry
if _, err = C.acl_set_tag_type(e, User); err != nil {
return err
}
// set qualifier (uid) to new entry
if _, err = C.acl_set_qualifier(e, unsafe.Pointer(&uid)); err != nil {
return err
}
}
// calculate mask after update
if _, err = C.acl_calc_mask(&a.val); err != nil {
return err
}
// write acl to file
return a.setFile(path, TypeAccess)
}

View File

@ -1,19 +1,18 @@
package main package main
import ( import (
"encoding/gob"
"errors" "errors"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path" "path"
"strconv"
"syscall" "syscall"
"time" "time"
init0 "git.ophivana.moe/security/fortify/cmd/finit/ipc" init0 "git.gensokyo.uk/security/fortify/cmd/finit/ipc"
"git.ophivana.moe/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/proc"
) )
const ( const (
@ -48,30 +47,24 @@ func main() {
} }
} }
// setup pipe fd from environment // receive setup payload
var setup *os.File var (
if s, ok := os.LookupEnv(init0.Env); !ok { payload init0.Payload
fmsg.Fatal("FORTIFY_INIT not set") closeSetup func() error
panic("unreachable") )
} else { if f, err := proc.Receive(init0.Env, &payload); err != nil {
if fd, err := strconv.Atoi(s); err != nil { if errors.Is(err, proc.ErrInvalid) {
fmsg.Fatalf("cannot parse %q: %v", s, err) fmsg.Fatal("invalid config descriptor")
panic("unreachable") }
} else { if errors.Is(err, proc.ErrNotSet) {
setup = os.NewFile(uintptr(fd), "setup") fmsg.Fatal("FORTIFY_INIT not set")
if setup == nil {
fmsg.Fatal("invalid config descriptor")
panic("unreachable")
}
} }
}
var payload init0.Payload fmsg.Fatalf("cannot decode init setup payload: %v", err)
if err := gob.NewDecoder(setup).Decode(&payload); err != nil {
fmsg.Fatal("cannot decode init setup payload:", err)
panic("unreachable") panic("unreachable")
} else { } else {
fmsg.SetVerbose(payload.Verbose) fmsg.SetVerbose(payload.Verbose)
closeSetup = f
// child does not need to see this // child does not need to see this
if err = os.Unsetenv(init0.Env); err != nil { if err = os.Unsetenv(init0.Env); err != nil {
@ -98,7 +91,7 @@ func main() {
fmsg.Suspend() fmsg.Suspend()
// close setup pipe as setup is now complete // close setup pipe as setup is now complete
if err := setup.Close(); err != nil { if err := closeSetup(); err != nil {
fmsg.Println("cannot close setup pipe:", err) fmsg.Println("cannot close setup pipe:", err)
// not fatal // not fatal
} }

View File

@ -1,11 +1,7 @@
package shim0 package shim0
import ( import (
"encoding/gob" "git.gensokyo.uk/security/fortify/helper/bwrap"
"net"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/fmsg"
) )
const Env = "FORTIFY_SHIM" const Env = "FORTIFY_SHIM"
@ -23,13 +19,3 @@ type Payload struct {
// verbosity pass through // verbosity pass through
Verbose bool Verbose bool
} }
func (p *Payload) Serve(conn *net.UnixConn) error {
if err := gob.NewEncoder(conn).Encode(*p); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot stream shim payload:")
}
return fmsg.WrapErrorSuffix(conn.Close(),
"cannot close setup connection:")
}

View File

@ -1,22 +1,20 @@
package shim package shim
import ( import (
"encoding/gob"
"errors" "errors"
"net"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"sync"
"sync/atomic"
"syscall" "syscall"
"time" "time"
"git.ophivana.moe/security/fortify/acl" shim0 "git.gensokyo.uk/security/fortify/cmd/fshim/ipc"
shim0 "git.ophivana.moe/security/fortify/cmd/fshim/ipc" "git.gensokyo.uk/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/proc"
"git.ophivana.moe/security/fortify/internal/proc"
) )
const shimSetupTimeout = 5 * time.Second const shimSetupTimeout = 5 * time.Second
@ -32,20 +30,14 @@ type Shim struct {
aid string aid string
// string representation of supplementary group ids // string representation of supplementary group ids
supp []string supp []string
// path to setup socket
socket string
// shim setup abort reason and completion
abort chan error
abortErr atomic.Pointer[error]
abortOnce sync.Once
// fallback exit notifier with error returned killing the process // fallback exit notifier with error returned killing the process
killFallback chan error killFallback chan error
// shim setup payload // shim setup payload
payload *shim0.Payload payload *shim0.Payload
} }
func New(uid uint32, aid string, supp []string, socket string, payload *shim0.Payload) *Shim { func New(uid uint32, aid string, supp []string, payload *shim0.Payload) *Shim {
return &Shim{uid: uid, aid: aid, supp: supp, socket: socket, payload: payload} return &Shim{uid: uid, aid: aid, supp: supp, payload: payload}
} }
func (s *Shim) String() string { func (s *Shim) String() string {
@ -59,39 +51,11 @@ func (s *Shim) Unwrap() *exec.Cmd {
return s.cmd return s.cmd
} }
func (s *Shim) Abort(err error) {
s.abortOnce.Do(func() {
s.abortErr.Store(&err)
// s.abort is buffered so this will never block
s.abort <- err
})
}
func (s *Shim) AbortWait(err error) {
s.Abort(err)
<-s.abort
}
func (s *Shim) WaitFallback() chan error { func (s *Shim) WaitFallback() chan error {
return s.killFallback return s.killFallback
} }
func (s *Shim) Start() (*time.Time, error) { func (s *Shim) Start() (*time.Time, error) {
var (
cf chan *net.UnixConn
accept func()
)
// listen on setup socket
if c, a, err := s.serve(); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot listen on shim setup socket:")
} else {
// accepts a connection after each call to accept
// connections are sent to the channel cf
cf, accept = c, a
}
// start user switcher process and save time // start user switcher process and save time
var fsu string var fsu string
if p, ok := internal.Check(internal.Fsu); !ok { if p, ok := internal.Check(internal.Fsu); !ok {
@ -101,10 +65,19 @@ func (s *Shim) Start() (*time.Time, error) {
fsu = p fsu = p
} }
s.cmd = exec.Command(fsu) s.cmd = exec.Command(fsu)
s.cmd.Env = []string{
shim0.Env + "=" + s.socket, var encoder *gob.Encoder
"FORTIFY_APP_ID=" + s.aid, if fd, e, err := proc.Setup(&s.cmd.ExtraFiles); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot create shim setup pipe:")
} else {
encoder = e
s.cmd.Env = []string{
shim0.Env + "=" + strconv.Itoa(fd),
"FORTIFY_APP_ID=" + s.aid,
}
} }
if len(s.supp) > 0 { if len(s.supp) > 0 {
fmsg.VPrintf("attaching supplementary group ids %s", s.supp) fmsg.VPrintf("attaching supplementary group ids %s", s.supp)
s.cmd.Env = append(s.cmd.Env, "FORTIFY_GROUPS="+strings.Join(s.supp, " ")) s.cmd.Env = append(s.cmd.Env, "FORTIFY_GROUPS="+strings.Join(s.supp, " "))
@ -145,117 +118,20 @@ func (s *Shim) Start() (*time.Time, error) {
signal.Ignore(syscall.SIGINT, syscall.SIGTERM) signal.Ignore(syscall.SIGINT, syscall.SIGTERM)
}() }()
accept() shimErr := make(chan error)
var conn *net.UnixConn go func() { shimErr <- encoder.Encode(s.payload) }()
select { select {
case c := <-cf: case err := <-shimErr:
if c == nil { if err != nil {
return &startTime, fmsg.WrapErrorSuffix(*s.abortErr.Load(), "cannot accept call on setup socket:") return &startTime, fmsg.WrapErrorSuffix(err,
} else { "cannot transmit shim config:")
conn = c
} }
case <-time.After(shimSetupTimeout):
err := fmsg.WrapError(errors.New("timed out waiting for shim"),
"timed out waiting for shim to connect")
s.AbortWait(err)
return &startTime, err
}
// authenticate against called provided uid and shim pid
if cred, err := peerCred(conn); err != nil {
return &startTime, fmsg.WrapErrorSuffix(*s.abortErr.Load(), "cannot retrieve shim credentials:")
} else if cred.Uid != s.uid {
fmsg.Printf("process %d owned by user %d tried to connect, expecting %d",
cred.Pid, cred.Uid, s.uid)
err = errors.New("compromised fortify build")
s.Abort(err)
return &startTime, err
} else if cred.Pid != int32(s.cmd.Process.Pid) {
fmsg.Printf("process %d tried to connect to shim setup socket, expecting shim %d",
cred.Pid, s.cmd.Process.Pid)
err = errors.New("compromised target user")
s.Abort(err)
return &startTime, err
}
// serve payload
// this also closes the connection
err := s.payload.Serve(conn)
if err == nil {
killShim = func() {} killShim = func() {}
case <-time.After(shimSetupTimeout):
return &startTime, fmsg.WrapError(errors.New("timed out waiting for shim"),
"timed out waiting for shim")
} }
s.Abort(err) // aborting with nil indicates success
return &startTime, err return &startTime, nil
}
func (s *Shim) serve() (chan *net.UnixConn, func(), error) {
if s.abort != nil {
panic("attempted to serve shim setup twice")
}
s.abort = make(chan error, 1)
cf := make(chan *net.UnixConn)
accept := make(chan struct{}, 1)
if l, err := net.ListenUnix("unix", &net.UnixAddr{Name: s.socket, Net: "unix"}); err != nil {
return nil, nil, err
} else {
l.SetUnlinkOnClose(true)
fmsg.VPrintf("listening on shim setup socket %q", s.socket)
if err = acl.UpdatePerm(s.socket, int(s.uid), acl.Read, acl.Write, acl.Execute); err != nil {
fmsg.Println("cannot append ACL entry to shim setup socket:", err)
s.Abort(err) // ensures setup socket cleanup
}
go func() {
cfWg := new(sync.WaitGroup)
for {
select {
case err = <-s.abort:
if err != nil {
fmsg.VPrintln("aborting shim setup, reason:", err)
}
if err = l.Close(); err != nil {
fmsg.Println("cannot close setup socket:", err)
}
close(s.abort)
go func() {
cfWg.Wait()
close(cf)
}()
return
case <-accept:
cfWg.Add(1)
go func() {
defer cfWg.Done()
if conn, err0 := l.AcceptUnix(); err0 != nil {
// breaks loop
s.Abort(err0)
// receiver sees nil value and loads err0 stored during abort
cf <- nil
} else {
cf <- conn
}
}()
}
}
}()
}
return cf, func() { accept <- struct{}{} }, nil
}
// peerCred fetches peer credentials of conn
func peerCred(conn *net.UnixConn) (ucred *syscall.Ucred, err error) {
var raw syscall.RawConn
if raw, err = conn.SyscallConn(); err != nil {
return
}
err0 := raw.Control(func(fd uintptr) {
ucred, err = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
})
err = errors.Join(err, err0)
return
} }

View File

@ -1,18 +1,18 @@
package main package main
import ( import (
"encoding/gob" "errors"
"net"
"os" "os"
"path" "path"
"strconv" "strconv"
"syscall" "syscall"
init0 "git.ophivana.moe/security/fortify/cmd/finit/ipc" init0 "git.gensokyo.uk/security/fortify/cmd/finit/ipc"
shim "git.ophivana.moe/security/fortify/cmd/fshim/ipc" shim "git.gensokyo.uk/security/fortify/cmd/fshim/ipc"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.ophivana.moe/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/proc"
) )
// everything beyond this point runs as unconstrained target user // everything beyond this point runs as unconstrained target user
@ -37,15 +37,6 @@ func main() {
} }
} }
// lookup socket path from environment
var socketPath string
if s, ok := os.LookupEnv(shim.Env); !ok {
fmsg.Fatal("FORTIFY_SHIM not set")
panic("unreachable")
} else {
socketPath = s
}
// check path to finit // check path to finit
var finitPath string var finitPath string
if p, ok := internal.Path(internal.Finit); !ok { if p, ok := internal.Path(internal.Finit); !ok {
@ -54,21 +45,24 @@ func main() {
finitPath = p finitPath = p
} }
// dial setup socket // receive setup payload
var conn *net.UnixConn var (
if c, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"}); err != nil { payload shim.Payload
fmsg.Fatal(err.Error()) closeSetup func() error
)
if f, err := proc.Receive(shim.Env, &payload); err != nil {
if errors.Is(err, proc.ErrInvalid) {
fmsg.Fatal("invalid config descriptor")
}
if errors.Is(err, proc.ErrNotSet) {
fmsg.Fatal("FORTIFY_SHIM not set")
}
fmsg.Fatalf("cannot decode shim setup payload: %v", err)
panic("unreachable") panic("unreachable")
} else {
conn = c
}
// decode payload gob stream
var payload shim.Payload
if err := gob.NewDecoder(conn).Decode(&payload); err != nil {
fmsg.Fatalf("cannot decode shim payload: %v", err)
} else { } else {
fmsg.SetVerbose(payload.Verbose) fmsg.SetVerbose(payload.Verbose)
closeSetup = f
} }
if payload.Bwrap == nil { if payload.Bwrap == nil {
@ -81,8 +75,8 @@ func main() {
} }
// close setup socket // close setup socket
if err := conn.Close(); err != nil { if err := closeSetup(); err != nil {
fmsg.Println("cannot close setup socket:", err) fmsg.Println("cannot close setup pipe:", err)
// not fatal // not fatal
} }
@ -110,17 +104,14 @@ func main() {
var extraFiles []*os.File var extraFiles []*os.File
// share config pipe // serve setup payload
if r, w, err := os.Pipe(); err != nil { if fd, encoder, err := proc.Setup(&extraFiles); err != nil {
fmsg.Fatalf("cannot pipe: %v", err) fmsg.Fatalf("cannot pipe: %v", err)
} else { } else {
conf.SetEnv[init0.Env] = strconv.Itoa(3 + len(extraFiles)) conf.SetEnv[init0.Env] = strconv.Itoa(fd)
extraFiles = append(extraFiles, r)
fmsg.VPrintln("transmitting config to init")
go func() { go func() {
// stream config to pipe fmsg.VPrintln("transmitting config to init")
if err = gob.NewEncoder(w).Encode(&ic); err != nil { if err = encoder.Encode(&ic); err != nil {
fmsg.Fatalf("cannot transmit init config: %v", err) fmsg.Fatalf("cannot transmit init config: %v", err)
} }
}() }()

View File

@ -83,17 +83,17 @@ func main() {
uid += aid uid += aid
} }
// pass through setup path to shim // pass through setup fd to shim
var shimSetupPath string var shimSetupFd string
if s, ok := os.LookupEnv(envShim); !ok { if s, ok := os.LookupEnv(envShim); !ok {
// fortify 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 !path.IsAbs(s) { } else if len(s) != 1 || s[0] > '9' || s[0] < '3' {
log.Fatal("FORTIFY_SHIM is not absolute") log.Fatal("FORTIFY_SHIM holds an invalid value")
} else { } else {
shimSetupPath = s shimSetupFd = s
} }
// supplementary groups // supplementary groups
@ -123,6 +123,11 @@ func main() {
suppGroups = []int{uid} suppGroups = []int{uid}
} }
// final bounds check to catch any bugs
if uid < 1000000 || uid >= 2000000 {
panic("uid out of bounds")
}
// careful! users in the allowlist is effectively allowed to drop groups via fsu // 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 {
@ -137,7 +142,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(fshim, []string{"fshim"}, []string{envShim + "=" + shimSetupPath}); err != nil { if err := syscall.Exec(fshim, []string{"fshim"}, []string{envShim + "=" + shimSetupFd}); err != nil {
log.Fatalf("cannot start shim: %v", err) log.Fatalf("cannot start shim: %v", err)
} }

View File

@ -9,7 +9,7 @@ import (
"path" "path"
"strconv" "strconv"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
func main() { func main() {

View File

@ -5,7 +5,7 @@ import (
"os" "os"
"path" "path"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
type payloadU struct { type payloadU struct {

View File

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

View File

@ -5,8 +5,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {

View File

@ -6,8 +6,8 @@ import (
"io" "io"
"sync" "sync"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
) )
// ProxyName is the file name or path to the proxy program. // ProxyName is the file name or path to the proxy program.

View File

@ -9,9 +9,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/ldd" "git.gensokyo.uk/security/fortify/ldd"
) )
// Start launches the D-Bus proxy and sets up the Wait method. // Start launches the D-Bus proxy and sets up the Wait method.

View File

@ -3,7 +3,7 @@ package dbus_test
import ( import (
"sync" "sync"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
) )
var samples = []dbusTestCase{ var samples = []dbusTestCase{

View File

@ -3,7 +3,7 @@ package dbus_test
import ( import (
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestHelperChildStub(t *testing.T) { func TestHelperChildStub(t *testing.T) {

1
dist/fsurc.default vendored Normal file
View File

@ -0,0 +1 @@
1000 0

10
dist/install.sh vendored Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
cd "$(dirname -- "$0")" || exit 1
install -vDm0755 "bin/fortify" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fortify"
install -vDm0755 "bin/fshim" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/fshim"
install -vDm0755 "bin/finit" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/finit"
install -vDm0755 "bin/fuserdb" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/fuserdb"
install -vDm6511 "bin/fsu" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fsu"
install -vDm0400 "fsurc.default" "${FORTIFY_INSTALL_PREFIX}/etc/fsurc"

19
dist/release.sh vendored Executable file
View File

@ -0,0 +1,19 @@
#!/bin/sh -e
cd "$(dirname -- "$0")/.."
VERSION="${FORTIFY_VERSION:-untagged}"
pname="fortify-${VERSION}"
out="dist/${pname}"
mkdir -p "${out}"
cp "README.md" "dist/fsurc.default" "dist/install.sh" "${out}"
go build -v -o "${out}/bin/" -ldflags "-s -w
-X git.gensokyo.uk/security/fortify/internal.Version=${VERSION}
-X git.gensokyo.uk/security/fortify/internal.Fsu=/usr/bin/fsu
-X git.gensokyo.uk/security/fortify/internal.Finit=/usr/libexec/fortify/finit
-X main.Fmain=/usr/bin/fortify
-X main.Fshim=/usr/libexec/fortify/fshim" ./...
rm -f "./${out}.tar.gz" && tar -C dist -czf "${out}.tar.gz" "${pname}"
rm -rf "./${out}"
sha512sum "${out}.tar.gz" > "${out}.tar.gz.sha512"

View File

@ -3,8 +3,8 @@ package main
import ( import (
"errors" "errors"
"git.ophivana.moe/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
func logWaitError(err error) { func logWaitError(err error) {

28
flake.lock generated
View File

@ -1,12 +1,33 @@
{ {
"nodes": { "nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1733951536,
"narHash": "sha256-Zb5ZCa7Xj+0gy5XVXINTSr71fCfAv+IKtmIXNrykT54=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "1318c3f3b068cdcea922fa7c1a0a1f0c96c22f5f",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-24.11",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1733348545, "lastModified": 1734298236,
"narHash": "sha256-b4JrUmqT0vFNx42aEN9LTWOHomkTKL/ayLopflVf81U=", "narHash": "sha256-aWhhqY44xBjMoO9r5fyPp5u8tqUNWRZ/m/P+abMSs5c=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9ecb50d2fae8680be74c08bb0a995c5383747f89", "rev": "eb919d9300b6a18f8583f58aef16db458fbd7bec",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -18,6 +39,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
} }

View File

@ -3,10 +3,19 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11-small"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11-small";
home-manager = {
url = "github:nix-community/home-manager/release-24.11";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = outputs =
{ self, nixpkgs }: {
self,
nixpkgs,
home-manager,
}:
let let
supportedSystems = [ supportedSystems = [
"aarch64-linux" "aarch64-linux"
@ -20,6 +29,55 @@
{ {
nixosModules.fortify = import ./nixos.nix; nixosModules.fortify = import ./nixos.nix;
checks = forAllSystems (
system:
let
pkgs = nixpkgsFor.${system};
inherit (pkgs)
runCommandLocal
callPackage
nixfmt-rfc-style
deadnix
statix
;
in
{
check-formatting =
runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; }
''
cd ${./.}
echo "running nixfmt..."
nixfmt --check .
touch $out
'';
check-lint =
runCommandLocal "check-lint"
{
nativeBuildInputs = [
deadnix
statix
];
}
''
cd ${./.}
echo "running deadnix..."
deadnix --fail
echo "running statix..."
statix check .
touch $out
'';
nixos-tests = callPackage ./test.nix { inherit system self home-manager; };
}
);
packages = forAllSystems ( packages = forAllSystems (
system: system:
let let
@ -37,6 +95,26 @@
buildInputs = with nixpkgsFor.${system}; self.packages.${system}.fortify.buildInputs; buildInputs = with nixpkgsFor.${system}; self.packages.${system}.fortify.buildInputs;
}; };
fhs = nixpkgsFor.${system}.buildFHSEnv {
pname = "fortify-fhs";
inherit (self.packages.${system}.fortify) version;
targetPkgs =
pkgs: with pkgs; [
go
gcc
pkg-config
acl
wayland
wayland-scanner
wayland-protocols
xorg.libxcb
];
extraOutputsToInstall = [ "dev" ];
profile = ''
export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH"
'';
};
withPackage = nixpkgsFor.${system}.mkShell { withPackage = nixpkgsFor.${system}.mkShell {
buildInputs = buildInputs =
with nixpkgsFor.${system}; with nixpkgsFor.${system};
@ -56,7 +134,7 @@
}; };
modules = [ ./options.nix ]; modules = [ ./options.nix ];
}; };
cleanEval = lib.filterAttrsRecursive (n: v: 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 "fortify-module-docs.md" { } '' docText = pkgs.runCommand "fortify-module-docs.md" { } ''

View File

@ -1,12 +1,12 @@
package app package fst
import ( import (
"errors" "errors"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/linux"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
const fTmp = "/fortify" const fTmp = "/fortify"

48
fst/id.go Normal file
View File

@ -0,0 +1,48 @@
package fst
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
)
type ID [16]byte
var (
ErrInvalidLength = errors.New("string representation must have a length of 32")
)
func (a *ID) String() string {
return hex.EncodeToString(a[:])
}
func NewAppID(id *ID) error {
_, err := rand.Read(id[:])
return err
}
func ParseAppID(id *ID, s string) error {
if len(s) != 32 {
return ErrInvalidLength
}
for i, b := range s {
if b < '0' || b > 'f' {
return fmt.Errorf("invalid char %q at byte %d", b, i)
}
v := uint8(b)
if v > '9' {
v = 10 + v - 'a'
} else {
v -= '0'
}
if i%2 == 0 {
v <<= 4
}
id[i/2] += v
}
return nil
}

63
fst/id_test.go Normal file
View File

@ -0,0 +1,63 @@
package fst_test
import (
"errors"
"testing"
"git.gensokyo.uk/security/fortify/fst"
)
func TestParseAppID(t *testing.T) {
t.Run("bad length", func(t *testing.T) {
if err := fst.ParseAppID(new(fst.ID), "meow"); !errors.Is(err, fst.ErrInvalidLength) {
t.Errorf("ParseAppID: error = %v, wantErr = %v", err, fst.ErrInvalidLength)
}
})
t.Run("bad byte", func(t *testing.T) {
wantErr := "invalid char '\\n' at byte 15"
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.Run("fuzz 16 iterations", func(t *testing.T) {
for i := 0; i < 16; i++ {
testParseAppIDWithRandom(t)
}
})
}
func FuzzParseAppID(f *testing.F) {
for i := 0; i < 16; i++ {
id := new(fst.ID)
if err := fst.NewAppID(id); err != nil {
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.Fuzz(func(t *testing.T, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 byte) {
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) {
id := new(fst.ID)
if err := fst.NewAppID(id); err != nil {
t.Fatalf("cannot generate app ID: %v", err)
}
testParseAppID(t, id)
}
func testParseAppID(t *testing.T, id *fst.ID) {
s := id.String()
got := new(fst.ID)
if err := fst.ParseAppID(got, s); err != nil {
t.Fatalf("cannot parse app ID: %v", err)
}
if *got != *id {
t.Fatalf("ParseAppID(%#v) = \n%#v, want \n%#v", s, got, id)
}
}

2
fst/shared.go Normal file
View File

@ -0,0 +1,2 @@
// Package fst exports shared fortify types.
package fst

2
go.mod
View File

@ -1,3 +1,3 @@
module git.ophivana.moe/security/fortify module git.gensokyo.uk/security/fortify
go 1.22 go 1.22

View File

@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func Test_argsFD_String(t *testing.T) { func Test_argsFD_String(t *testing.T) {

View File

@ -8,8 +8,8 @@ import (
"strconv" "strconv"
"sync" "sync"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/proc" "git.gensokyo.uk/security/fortify/internal/proc"
) )
// BubblewrapName is the file name or path to bubblewrap. // BubblewrapName is the file name or path to bubblewrap.

View File

@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
) )
func TestBwrap(t *testing.T) { func TestBwrap(t *testing.T) {

View File

@ -5,7 +5,7 @@ import (
"os" "os"
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestDirect(t *testing.T) { func TestDirect(t *testing.T) {

View File

@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
var ( var (

View File

@ -6,7 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"git.ophivana.moe/security/fortify/internal/proc" "git.gensokyo.uk/security/fortify/internal/proc"
) )
type pipes struct { type pipes struct {

View File

@ -10,8 +10,8 @@ import (
"syscall" "syscall"
"testing" "testing"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// InternalChildStub is an internal function but exported because it is cross-package; // InternalChildStub is an internal function but exported because it is cross-package;

View File

@ -3,7 +3,7 @@ package helper_test
import ( import (
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestHelperChildStub(t *testing.T) { func TestHelperChildStub(t *testing.T) {

View File

@ -2,14 +2,16 @@ package app
import ( import (
"sync" "sync"
"sync/atomic"
"git.ophivana.moe/security/fortify/cmd/fshim/ipc/shim" "git.gensokyo.uk/security/fortify/cmd/fshim/ipc/shim"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/linux"
) )
type App interface { type App interface {
// ID returns a copy of App's unique ID. // ID returns a copy of App's unique ID.
ID() ID ID() fst.ID
// Start sets up the system and starts the App. // Start sets up the system and starts the App.
Start() error Start() error
// Wait waits for App's process to exit and reverts system setup. // Wait waits for App's process to exit and reverts system setup.
@ -17,13 +19,16 @@ type App interface {
// WaitErr returns error returned by the underlying wait syscall. // WaitErr returns error returned by the underlying wait syscall.
WaitErr() error WaitErr() error
Seal(config *Config) error Seal(config *fst.Config) error
String() string String() string
} }
type app struct { type app struct {
// single-use config reference
ct *appCt
// application unique identifier // application unique identifier
id *ID id *fst.ID
// operating system interface // operating system interface
os linux.System os linux.System
// shim process manager // shim process manager
@ -36,7 +41,7 @@ type app struct {
lock sync.RWMutex lock sync.RWMutex
} }
func (a *app) ID() ID { func (a *app) ID() fst.ID {
return *a.id return *a.id
} }
@ -65,7 +70,28 @@ func (a *app) WaitErr() error {
func New(os linux.System) (App, error) { func New(os linux.System) (App, error) {
a := new(app) a := new(app)
a.id = new(ID) a.id = new(fst.ID)
a.os = os a.os = os
return a, newAppID(a.id) return a, fst.NewAppID(a.id)
}
// appCt ensures its wrapped val is only accessed once
type appCt struct {
val *fst.Config
done *atomic.Bool
}
func (a *appCt) Unwrap() *fst.Config {
if !a.done.Load() {
defer a.done.Store(true)
return a.val
}
panic("attempted to access config reference twice")
}
func newAppCt(config *fst.Config) (ct *appCt) {
ct = new(appCt)
ct.done = new(atomic.Bool)
ct.val = config
return ct
} }

View File

@ -1,25 +1,25 @@
package app_test package app_test
import ( import (
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
var testCasesNixos = []sealTestCase{ var testCasesNixos = []sealTestCase{
{ {
"nixos chromium direct wayland", new(stubNixOS), "nixos chromium direct wayland", new(stubNixOS),
&app.Config{ &fst.Config{
ID: "org.chromium.Chromium", ID: "org.chromium.Chromium",
Command: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"}, Command: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"},
Confinement: app.ConfinementConfig{ Confinement: fst.ConfinementConfig{
AppID: 1, Groups: []string{}, Username: "u0_a1", AppID: 1, Groups: []string{}, Username: "u0_a1",
Outer: "/var/lib/persist/module/fortify/0/1", Outer: "/var/lib/persist/module/fortify/0/1",
Sandbox: &app.SandboxConfig{ Sandbox: &fst.SandboxConfig{
UserNS: true, Net: true, MapRealUID: true, DirectWayland: true, Env: nil, UserNS: true, Net: true, MapRealUID: true, DirectWayland: true, Env: nil,
Filesystem: []*app.FilesystemConfig{ Filesystem: []*fst.FilesystemConfig{
{Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true}, {Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true},
{Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true}, {Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true},
{Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"}, {Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"},
@ -48,7 +48,7 @@ var testCasesNixos = []sealTestCase{
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(), Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
}, },
}, },
app.ID{ fst.ID{
0x8e, 0x2c, 0x76, 0xb0, 0x8e, 0x2c, 0x76, 0xb0,
0x66, 0xda, 0xbe, 0x57, 0x66, 0xda, 0xbe, 0x57,
0x4c, 0xf0, 0x73, 0xbd, 0x4c, 0xf0, 0x73, 0xbd,

View File

@ -1,25 +1,25 @@
package app_test package app_test
import ( import (
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
var testCasesPd = []sealTestCase{ var testCasesPd = []sealTestCase{
{ {
"nixos permissive defaults no enablements", new(stubNixOS), "nixos permissive defaults no enablements", new(stubNixOS),
&app.Config{ &fst.Config{
Command: make([]string, 0), Command: make([]string, 0),
Confinement: app.ConfinementConfig{ Confinement: fst.ConfinementConfig{
AppID: 0, AppID: 0,
Username: "chronos", Username: "chronos",
Outer: "/home/chronos", Outer: "/home/chronos",
}, },
}, },
app.ID{ fst.ID{
0x4a, 0x45, 0x0b, 0x65, 0x4a, 0x45, 0x0b, 0x65,
0x96, 0xd7, 0xbc, 0x15, 0x96, 0xd7, 0xbc, 0x15,
0xbd, 0x01, 0x78, 0x0e, 0xbd, 0x01, 0x78, 0x0e,
@ -190,10 +190,10 @@ var testCasesPd = []sealTestCase{
}, },
{ {
"nixos permissive defaults chromium", new(stubNixOS), "nixos permissive defaults chromium", new(stubNixOS),
&app.Config{ &fst.Config{
ID: "org.chromium.Chromium", ID: "org.chromium.Chromium",
Command: []string{"/run/current-system/sw/bin/zsh", "-c", "exec chromium "}, Command: []string{"/run/current-system/sw/bin/zsh", "-c", "exec chromium "},
Confinement: app.ConfinementConfig{ Confinement: fst.ConfinementConfig{
AppID: 9, AppID: 9,
Groups: []string{"video"}, Groups: []string{"video"},
Username: "chronos", Username: "chronos",
@ -232,7 +232,7 @@ var testCasesPd = []sealTestCase{
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(), Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
}, },
}, },
app.ID{ fst.ID{
0xeb, 0xf0, 0x83, 0xd1, 0xeb, 0xf0, 0x83, 0xd1,
0xb1, 0x75, 0x91, 0x17, 0xb1, 0x75, 0x91, 0x17,
0x82, 0xd4, 0x13, 0x36, 0x82, 0xd4, 0x13, 0x36,

View File

@ -7,7 +7,7 @@ import (
"os/user" "os/user"
"strconv" "strconv"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/linux"
) )
// fs methods are not implemented using a real FS // fs methods are not implemented using a real FS

View File

@ -6,17 +6,18 @@ import (
"testing" "testing"
"time" "time"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
) )
type sealTestCase struct { type sealTestCase struct {
name string name string
os linux.System os linux.System
config *app.Config config *fst.Config
id app.ID id fst.ID
wantSys *system.I wantSys *system.I
wantBwrap *bwrap.Config wantBwrap *bwrap.Config
} }

View File

@ -1,12 +1,13 @@
package app package app
import ( import (
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
) )
func NewWithID(id ID, os linux.System) App { func NewWithID(id fst.ID, os linux.System) App {
a := new(app) a := new(app)
a.id = &id a.id = &id
a.os = os a.os = os

View File

@ -1,17 +0,0 @@
package app
import (
"crypto/rand"
"encoding/hex"
)
type ID [16]byte
func (a *ID) String() string {
return hex.EncodeToString(a[:])
}
func newAppID(id *ID) error {
_, err := rand.Read(id[:])
return err
}

View File

@ -8,11 +8,12 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/linux"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/system"
) )
var ( var (
@ -59,7 +60,7 @@ type appSeal struct {
} }
// Seal seals the app launch context // Seal seals the app launch context
func (a *app) Seal(config *Config) error { func (a *app) Seal(config *fst.Config) error {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
@ -147,7 +148,7 @@ func (a *app) Seal(config *Config) error {
fmsg.VPrintln("sandbox configuration not supplied, PROCEED WITH CAUTION") fmsg.VPrintln("sandbox configuration not supplied, PROCEED WITH CAUTION")
// permissive defaults // permissive defaults
conf := &SandboxConfig{ conf := &fst.SandboxConfig{
UserNS: true, UserNS: true,
Net: true, Net: true,
NoNewSession: true, NoNewSession: true,
@ -157,7 +158,7 @@ func (a *app) Seal(config *Config) error {
if d, err := a.os.ReadDir("/"); err != nil { if d, err := a.os.ReadDir("/"); err != nil {
return err return err
} else { } else {
b := make([]*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 {
@ -169,7 +170,7 @@ func (a *app) Seal(config *Config) error {
case "/etc": case "/etc":
default: default:
b = append(b, &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...)
@ -178,7 +179,7 @@ func (a *app) Seal(config *Config) error {
if d, err := a.os.ReadDir("/run"); err != nil { if d, err := a.os.ReadDir("/run"); err != nil {
return err return err
} else { } else {
b := make([]*FilesystemConfig, 0, len(d)) b := make([]*fst.FilesystemConfig, 0, len(d))
for _, ent := range d { for _, ent := range d {
name := ent.Name() name := ent.Name()
switch name { switch name {
@ -186,7 +187,7 @@ func (a *app) Seal(config *Config) error {
case "dbus": case "dbus":
default: default:
p := "/run/" + name p := "/run/" + name
b = append(b, &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...)
@ -198,7 +199,7 @@ func (a *app) Seal(config *Config) error {
} }
// bind GPU stuff // bind GPU stuff
if config.Confinement.Enablements.Has(system.EX11) || config.Confinement.Enablements.Has(system.EWayland) { if config.Confinement.Enablements.Has(system.EX11) || config.Confinement.Enablements.Has(system.EWayland) {
conf.Filesystem = append(conf.Filesystem, &FilesystemConfig{Src: "/dev/dri", Device: true}) conf.Filesystem = append(conf.Filesystem, &fst.FilesystemConfig{Src: "/dev/dri", Device: true})
} }
config.Confinement.Sandbox = conf config.Confinement.Sandbox = conf
@ -217,7 +218,7 @@ func (a *app) Seal(config *Config) error {
// open process state store // open process state store
// the simple store only starts holding an open file after first action // the simple store only starts holding an open file after first action
// store activity begins after Start is called and must end before Wait // store activity begins after Start is called and must end before Wait
seal.store = state.NewSimple(seal.RunDirPath, seal.sys.user.as) seal.store = state.NewMulti(seal.RunDirPath)
// initialise system interface with full uid // initialise system interface with full uid
seal.sys.I = system.New(seal.sys.user.uid) seal.sys.I = system.New(seal.sys.user.uid)
@ -236,5 +237,6 @@ func (a *app) Seal(config *Config) error {
// seal app and release lock // seal app and release lock
a.seal = seal a.seal = seal
a.ct = newAppCt(config)
return nil return nil
} }

View File

@ -3,9 +3,9 @@ package app
import ( import (
"path" "path"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
const ( const (

View File

@ -4,10 +4,10 @@ import (
"errors" "errors"
"path" "path"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/linux"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
const ( const (

View File

@ -6,9 +6,9 @@ import (
"io/fs" "io/fs"
"path" "path"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/linux"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
const ( const (

View File

@ -3,8 +3,8 @@ package app
import ( import (
"path" "path"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
const ( const (

View File

@ -3,9 +3,9 @@ package app
import ( import (
"path" "path"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/linux"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
const ( const (

View File

@ -4,16 +4,15 @@ import (
"errors" "errors"
"fmt" "fmt"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
shim0 "git.ophivana.moe/security/fortify/cmd/fshim/ipc" shim0 "git.gensokyo.uk/security/fortify/cmd/fshim/ipc"
"git.ophivana.moe/security/fortify/cmd/fshim/ipc/shim" "git.gensokyo.uk/security/fortify/cmd/fshim/ipc/shim"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
// Start selects a user switcher and starts shim. // Start selects a user switcher and starts shim.
@ -46,7 +45,6 @@ func (a *app) Start() error {
uint32(a.seal.sys.UID()), uint32(a.seal.sys.UID()),
a.seal.sys.user.as, a.seal.sys.user.as,
a.seal.sys.user.supp, a.seal.sys.user.supp,
path.Join(a.seal.share, "shim"),
&shim0.Payload{ &shim0.Payload{
Argv: a.seal.command, Argv: a.seal.command,
Exec: shimExec, Exec: shimExec,
@ -70,17 +68,16 @@ func (a *app) Start() error {
} else { } else {
// shim start and setup success, create process state // shim start and setup success, create process state
sd := state.State{ sd := state.State{
PID: a.shim.Unwrap().Process.Pid, ID: *a.id,
Command: a.seal.command, PID: a.shim.Unwrap().Process.Pid,
Capability: a.seal.et, Config: a.ct.Unwrap(),
Argv: a.shim.Unwrap().Args, Time: *startTime,
Time: *startTime,
} }
// register process state // register process state
var err0 = new(StateStoreError) var err0 = new(StateStoreError)
err0.Inner, err0.DoErr = a.seal.store.Do(func(b state.Backend) { err0.Inner, err0.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(c state.Cursor) {
err0.InnerErr = b.Save(&sd) err0.InnerErr = c.Save(&sd)
}) })
a.seal.sys.saveState = true a.seal.sys.saveState = true
return err0.equiv("cannot save process state:") return err0.equiv("cannot save process state:")
@ -202,11 +199,11 @@ func (a *app) Wait() (int, error) {
// update store and revert app setup transaction // update store and revert app setup transaction
e := new(StateStoreError) e := new(StateStoreError)
e.Inner, e.DoErr = a.seal.store.Do(func(b state.Backend) { e.Inner, e.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(b state.Cursor) {
e.InnerErr = func() error { e.InnerErr = func() error {
// destroy defunct state entry // destroy defunct state entry
if cmd := a.shim.Unwrap(); cmd != nil && a.seal.sys.saveState { if cmd := a.shim.Unwrap(); cmd != nil && a.seal.sys.saveState {
if err := b.Destroy(cmd.Process.Pid); err != nil { if err := b.Destroy(*a.id); err != nil {
return err return err
} }
} }
@ -227,8 +224,12 @@ func (a *app) Wait() (int, error) {
} }
// accumulate capabilities of other launchers // accumulate capabilities of other launchers
for _, s := range states { for i, s := range states {
*rt |= s.Capability if s.Config != nil {
*rt |= s.Config.Confinement.Enablements
} else {
fmsg.Printf("state entry %d does not contain config", i)
}
} }
} }
// invert accumulated enablements for cleanup // invert accumulated enablements for cleanup
@ -249,12 +250,6 @@ func (a *app) Wait() (int, error) {
} }
} }
if a.shim.Unwrap() == nil {
fmsg.VPrintln("fault before shim start")
} else {
a.shim.AbortWait(errors.New("shim exited"))
}
if a.seal.sys.needRevert { if a.seal.sys.needRevert {
if err := a.seal.sys.Revert(ec); err != nil { if err := a.seal.sys.Revert(ec); err != nil {
return err.(RevertCompoundError) return err.(RevertCompoundError)

View File

@ -1,10 +1,10 @@
package app package app
import ( import (
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/linux"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
// appSealSys encapsulates app seal behaviour with OS interactions // appSealSys encapsulates app seal behaviour with OS interactions

View File

@ -7,7 +7,7 @@ import (
"path" "path"
"strconv" "strconv"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// System provides safe access to operating system resources. // System provides safe access to operating system resources.
@ -39,8 +39,6 @@ type System interface {
Paths() Paths Paths() Paths
// Uid invokes fsu and returns target uid. // Uid invokes fsu and returns target uid.
Uid(aid int) (int, error) Uid(aid int) (int, error)
// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html
SdBooted() bool
} }
// Paths contains environment dependent paths used by fortify. // Paths contains environment dependent paths used by fortify.

View File

@ -1,7 +1,6 @@
package linux package linux
import ( import (
"errors"
"io" "io"
"io/fs" "io/fs"
"os" "os"
@ -10,8 +9,8 @@ import (
"strconv" "strconv"
"sync" "sync"
"git.ophivana.moe/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// Std implements System using the standard library. // Std implements System using the standard library.
@ -19,9 +18,6 @@ type Std struct {
paths Paths paths Paths
pathsOnce sync.Once pathsOnce sync.Once
sdBooted bool
sdBootedOnce sync.Once
uidOnce sync.Once uidOnce sync.Once
uidCopy map[int]struct { uidCopy map[int]struct {
uid int uid int
@ -90,31 +86,3 @@ func (s *Std) Uid(aid int) (int, error) {
return u.uid, u.err return u.uid, u.err
} }
} }
func (s *Std) SdBooted() bool {
s.sdBootedOnce.Do(func() { s.sdBooted = copySdBooted() })
return s.sdBooted
}
const systemdCheckPath = "/run/systemd/system"
func copySdBooted() bool {
if v, err := sdBooted(); err != nil {
fmsg.Println("cannot read systemd marker:", err)
return false
} else {
return v
}
}
func sdBooted() (bool, error) {
_, err := os.Stat(systemdCheckPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
err = nil
}
return false, err
}
return true, nil
}

42
internal/proc/fd.go Normal file
View File

@ -0,0 +1,42 @@
package proc
import (
"encoding/gob"
"errors"
"os"
"strconv"
)
var (
ErrNotSet = errors.New("environment variable not set")
ErrInvalid = errors.New("bad file descriptor")
)
func Setup(extraFiles *[]*os.File) (int, *gob.Encoder, error) {
if r, w, err := os.Pipe(); err != nil {
return -1, nil, err
} else {
fd := 3 + len(*extraFiles)
*extraFiles = append(*extraFiles, r)
return fd, gob.NewEncoder(w), nil
}
}
func Receive(key string, e any) (func() error, error) {
var setup *os.File
if s, ok := os.LookupEnv(key); !ok {
return nil, ErrNotSet
} else {
if fd, err := strconv.Atoi(s); err != nil {
return nil, err
} else {
setup = os.NewFile(uintptr(fd), "setup")
if setup == nil {
return nil, ErrInvalid
}
}
}
return func() error { return setup.Close() }, gob.NewDecoder(setup).Decode(e)
}

292
internal/state/multi.go Normal file
View File

@ -0,0 +1,292 @@
package state
import (
"encoding/gob"
"errors"
"fmt"
"io/fs"
"os"
"path"
"strconv"
"sync"
"syscall"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
// fine-grained locking and access
type multiStore struct {
base string
// initialised backends
backends *sync.Map
lock sync.RWMutex
}
func (s *multiStore) Do(aid int, f func(c Cursor)) (bool, error) {
s.lock.RLock()
defer s.lock.RUnlock()
// load or initialise new backend
b := new(multiBackend)
if v, ok := s.backends.LoadOrStore(aid, b); ok {
b = v.(*multiBackend)
} else {
b.lock.Lock()
b.path = path.Join(s.base, strconv.Itoa(aid))
// ensure directory
if err := os.MkdirAll(b.path, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
s.backends.CompareAndDelete(aid, b)
return false, err
}
// open locker file
if l, err := os.OpenFile(b.path+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil {
s.backends.CompareAndDelete(aid, b)
return false, err
} else {
b.lockfile = l
}
b.lock.Unlock()
}
// lock backend
if err := b.lockFile(); err != nil {
return false, err
}
// expose backend methods without exporting the pointer
c := new(struct{ *multiBackend })
c.multiBackend = b
f(b)
// disable access to the backend on a best-effort basis
c.multiBackend = nil
// unlock backend
return true, b.unlockFile()
}
func (s *multiStore) List() ([]int, error) {
var entries []os.DirEntry
// read base directory to get all aids
if v, err := os.ReadDir(s.base); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
} else {
entries = v
}
aidsBuf := make([]int, 0, len(entries))
for _, e := range entries {
// skip non-directories
if !e.IsDir() {
fmsg.VPrintf("skipped non-directory entry %q", e.Name())
continue
}
// skip non-numerical names
if v, err := strconv.Atoi(e.Name()); err != nil {
fmsg.VPrintf("skipped non-aid entry %q", e.Name())
continue
} else {
if v < 0 || v > 9999 {
fmsg.VPrintf("skipped out of bounds entry %q", e.Name())
continue
}
aidsBuf = append(aidsBuf, v)
}
}
return append([]int(nil), aidsBuf...), nil
}
func (s *multiStore) Close() error {
s.lock.Lock()
defer s.lock.Unlock()
var errs []error
s.backends.Range(func(_, value any) bool {
b := value.(*multiBackend)
errs = append(errs, b.close())
return true
})
return errors.Join(errs...)
}
type multiBackend struct {
path string
// created/opened by prepare
lockfile *os.File
lock sync.RWMutex
}
func (b *multiBackend) filename(id *fst.ID) string {
return path.Join(b.path, id.String())
}
func (b *multiBackend) lockFileAct(lt int) (err error) {
op := "LockAct"
switch lt {
case syscall.LOCK_EX:
op = "Lock"
case syscall.LOCK_UN:
op = "Unlock"
}
for {
err = syscall.Flock(int(b.lockfile.Fd()), lt)
if !errors.Is(err, syscall.EINTR) {
break
}
}
if err != nil {
return &fs.PathError{
Op: op,
Path: b.lockfile.Name(),
Err: err,
}
}
return nil
}
func (b *multiBackend) lockFile() error {
return b.lockFileAct(syscall.LOCK_EX)
}
func (b *multiBackend) unlockFile() error {
return b.lockFileAct(syscall.LOCK_UN)
}
// reads all launchers in simpleBackend
// file contents are ignored if decode is false
func (b *multiBackend) load(decode bool) (Entries, error) {
b.lock.RLock()
defer b.lock.RUnlock()
// read directory contents, should only contain files named after ids
var entries []os.DirEntry
if pl, err := os.ReadDir(b.path); err != nil {
return nil, err
} else {
entries = pl
}
// allocate as if every entry is valid
// since that should be the case assuming no external interference happens
r := make(Entries, len(entries))
for _, e := range entries {
if e.IsDir() {
return nil, fmt.Errorf("unexpected directory %q in store", e.Name())
}
id := new(fst.ID)
if err := fst.ParseAppID(id, e.Name()); err != nil {
return nil, err
}
// run in a function to better handle file closing
if err := func() error {
// open state file for reading
if f, err := os.Open(path.Join(b.path, e.Name())); err != nil {
return err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("foreign state file closed prematurely")
}
}()
s := new(State)
r[*id] = s
// append regardless, but only parse if required, used to implement Len
if decode {
if err = gob.NewDecoder(f).Decode(s); err != nil {
return err
}
if s.ID != *id {
return fmt.Errorf("state entry %s has unexpected id %s", id, &s.ID)
}
}
return nil
}
}(); err != nil {
return nil, err
}
}
return r, nil
}
// Save writes process state to filesystem
func (b *multiBackend) Save(state *State) error {
b.lock.Lock()
defer b.lock.Unlock()
if state.Config == nil {
return errors.New("state does not contain config")
}
statePath := b.filename(&state.ID)
// create and open state data file
if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil {
return err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("state file closed prematurely")
}
}()
// encode into state file
return gob.NewEncoder(f).Encode(state)
}
}
func (b *multiBackend) Destroy(id fst.ID) error {
b.lock.Lock()
defer b.lock.Unlock()
return os.Remove(b.filename(&id))
}
func (b *multiBackend) Load() (Entries, error) {
return b.load(true)
}
func (b *multiBackend) Len() (int, error) {
// rn consists of only nil entries but has the correct length
rn, err := b.load(false)
return len(rn), err
}
func (b *multiBackend) close() error {
b.lock.Lock()
defer b.lock.Unlock()
err := b.lockfile.Close()
if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) {
return nil
}
return err
}
// NewMulti returns an instance of the multi-file store.
func NewMulti(runDir string) Store {
b := new(multiStore)
b.base = path.Join(runDir, "state")
b.backends = new(sync.Map)
return b
}

View File

@ -0,0 +1,11 @@
package state_test
import (
"testing"
"git.gensokyo.uk/security/fortify/internal/state"
)
func TestMulti(t *testing.T) {
testStore(t, state.NewMulti(t.TempDir()))
}

View File

@ -1,62 +1,45 @@
package state package state
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path"
"strconv"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
// MustPrintLauncherStateSimpleGlobal prints active launcher states of all simple stores // MustPrintLauncherStateSimpleGlobal prints active launcher states of all simple stores
// in an implementation-specific way. // in an implementation-specific way.
func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer, runDir string) { func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer, runDir string) {
now := time.Now().UTC() now := time.Now().UTC()
s := NewMulti(runDir)
// read runtime directory to get all UIDs // read runtime directory to get all UIDs
if dirs, err := os.ReadDir(path.Join(runDir, "state")); err != nil && !errors.Is(err, os.ErrNotExist) { if aids, err := s.List(); err != nil {
fmsg.Fatal("cannot read runtime directory:", err) fmsg.Fatal("cannot list store:", err)
} else { } else {
for _, e := range dirs { for _, aid := range aids {
// skip non-directories
if !e.IsDir() {
fmsg.VPrintf("skipped non-directory entry %q", e.Name())
continue
}
// skip non-numerical names
if _, err = strconv.Atoi(e.Name()); err != nil {
fmsg.VPrintf("skipped non-uid entry %q", e.Name())
continue
}
// obtain temporary store
s := NewSimple(runDir, e.Name()).(*simpleStore)
// print states belonging to this store // print states belonging to this store
s.mustPrintLauncherState(w, now) s.(*multiStore).mustPrintLauncherState(aid, w, now)
// mustPrintLauncherState causes store activity so store needs to be closed
if err = s.Close(); err != nil {
fmsg.Printf("cannot close store for user %q: %s", e.Name(), err)
}
} }
} }
// mustPrintLauncherState causes store activity so store needs to be closed
if err := s.Close(); err != nil {
fmsg.Printf("cannot close store: %v", err)
}
} }
func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time) { func (s *multiStore) mustPrintLauncherState(aid int, w **tabwriter.Writer, now time.Time) {
var innerErr error var innerErr error
if ok, err := s.Do(func(b Backend) { if ok, err := s.Do(aid, func(c Cursor) {
innerErr = func() error { innerErr = func() error {
// read launcher states // read launcher states
states, err := b.Load() states, err := c.Load()
if err != nil { if err != nil {
return err return err
} }
@ -82,40 +65,54 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
continue continue
} }
// build enablements string // build enablements and command string
ets := strings.Builder{} var (
// append enablement strings in order ets *strings.Builder
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ { cs = "(No command information)"
if state.Capability.Has(i) { )
ets.WriteString(", " + i.String())
// check if enablements are provided
if state.Config != nil {
ets = new(strings.Builder)
// append enablement strings in order
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if state.Config.Confinement.Enablements.Has(i) {
ets.WriteString(", " + i.String())
}
} }
cs = fmt.Sprintf("%q", state.Config.Command)
} }
// prevent an empty string when if ets != nil {
if ets.Len() == 0 { // prevent an empty string
ets.WriteString("(No enablements)") if ets.Len() == 0 {
ets.WriteString("(No enablements)")
}
} else {
ets = new(strings.Builder)
ets.WriteString("(No confinement information)")
} }
if !fmsg.Verbose() { if !fmsg.Verbose() {
_, _ = fmt.Fprintf(*w, "\t%d\t%s\t%s\t%s\t%s\n", _, _ = fmt.Fprintf(*w, "\t%d\t%d\t%s\t%s\t%s\n",
state.PID, s.path[len(s.path)-1], now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), state.PID, aid, now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), cs)
state.Command)
} else { } else {
// emit argv instead when verbose // emit argv instead when verbose
_, _ = fmt.Fprintf(*w, "\t%d\t%s\t%s\n", _, _ = fmt.Fprintf(*w, "\t%d\t%d\t%s\n",
state.PID, s.path[len(s.path)-1], state.Argv) state.PID, aid, state.ID)
} }
} }
return nil return nil
}() }()
}); err != nil { }); err != nil {
fmsg.Printf("cannot perform action on store %q: %s", path.Join(s.path...), err) fmsg.Printf("cannot perform action on app %d: %v", aid, err)
if !ok { if !ok {
fmsg.Fatal("store faulted before printing") fmsg.Fatal("store faulted before printing")
} }
} }
if innerErr != nil { if innerErr != nil {
fmsg.Fatalf("cannot print launcher state for store %q: %s", path.Join(s.path...), innerErr) fmsg.Fatalf("cannot print launcher state of app %d: %s", aid, innerErr)
} }
} }

View File

@ -1,218 +0,0 @@
package state
import (
"encoding/gob"
"errors"
"io/fs"
"os"
"path"
"strconv"
"sync"
"syscall"
)
// file-based locking
type simpleStore struct {
path []string
// created/opened by prepare
lockfile *os.File
// enforce prepare method
init sync.Once
// error returned by prepare
initErr error
lock sync.Mutex
}
func (s *simpleStore) Do(f func(b Backend)) (bool, error) {
s.init.Do(s.prepare)
if s.initErr != nil {
return false, s.initErr
}
s.lock.Lock()
defer s.lock.Unlock()
// lock store
if err := s.lockFile(); err != nil {
return false, err
}
// initialise new backend for caller
b := new(simpleBackend)
b.path = path.Join(s.path...)
f(b)
// disable backend
b.lock.Lock()
// unlock store
return true, s.unlockFile()
}
func (s *simpleStore) lockFileAct(lt int) (err error) {
op := "LockAct"
switch lt {
case syscall.LOCK_EX:
op = "Lock"
case syscall.LOCK_UN:
op = "Unlock"
}
for {
err = syscall.Flock(int(s.lockfile.Fd()), lt)
if !errors.Is(err, syscall.EINTR) {
break
}
}
if err != nil {
return &fs.PathError{
Op: op,
Path: s.lockfile.Name(),
Err: err,
}
}
return nil
}
func (s *simpleStore) lockFile() error {
return s.lockFileAct(syscall.LOCK_EX)
}
func (s *simpleStore) unlockFile() error {
return s.lockFileAct(syscall.LOCK_UN)
}
func (s *simpleStore) prepare() {
s.initErr = func() error {
prefix := path.Join(s.path...)
// ensure directory
if err := os.MkdirAll(prefix, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
return err
}
// open locker file
if f, err := os.OpenFile(prefix+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil {
return err
} else {
s.lockfile = f
}
return nil
}()
}
func (s *simpleStore) Close() error {
s.lock.Lock()
defer s.lock.Unlock()
err := s.lockfile.Close()
if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) {
return nil
}
return err
}
type simpleBackend struct {
path string
lock sync.RWMutex
}
func (b *simpleBackend) filename(pid int) string {
return path.Join(b.path, strconv.Itoa(pid))
}
// reads all launchers in simpleBackend
// file contents are ignored if decode is false
func (b *simpleBackend) load(decode bool) ([]*State, error) {
b.lock.RLock()
defer b.lock.RUnlock()
var (
r []*State
f *os.File
)
// read directory contents, should only contain files named after PIDs
if pl, err := os.ReadDir(b.path); err != nil {
return nil, err
} else {
for _, e := range pl {
// run in a function to better handle file closing
if err = func() error {
// open state file for reading
if f, err = os.Open(path.Join(b.path, e.Name())); err != nil {
return err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("foreign state file closed prematurely")
}
}()
var s State
r = append(r, &s)
// append regardless, but only parse if required, used to implement Len
if decode {
return gob.NewDecoder(f).Decode(&s)
} else {
return nil
}
}
}(); err != nil {
return nil, err
}
}
}
return r, nil
}
// Save writes process state to filesystem
func (b *simpleBackend) Save(state *State) error {
b.lock.Lock()
defer b.lock.Unlock()
statePath := b.filename(state.PID)
// create and open state data file
if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil {
return err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("state file closed prematurely")
}
}()
// encode into state file
return gob.NewEncoder(f).Encode(state)
}
}
func (b *simpleBackend) Destroy(pid int) error {
b.lock.Lock()
defer b.lock.Unlock()
return os.Remove(b.filename(pid))
}
func (b *simpleBackend) Load() ([]*State, error) {
return b.load(true)
}
func (b *simpleBackend) Len() (int, error) {
// rn consists of only nil entries but has the correct length
rn, err := b.load(false)
return len(rn), err
}
// NewSimple returns an instance of a file-based store.
func NewSimple(runDir string, prefix ...string) Store {
b := new(simpleStore)
b.path = append([]string{runDir, "state"}, prefix...)
return b
}

View File

@ -3,38 +3,42 @@ package state
import ( import (
"time" "time"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/fst"
) )
type Entries map[fst.ID]*State
type Store interface { type Store interface {
// Do calls f exactly once and ensures store exclusivity until f returns. // Do calls f exactly once and ensures store exclusivity until f returns.
// Returns whether f is called and any errors during the locking process. // Returns whether f is called and any errors during the locking process.
// Backend provided to f becomes invalid as soon as f returns. // Cursor provided to f becomes invalid as soon as f returns.
Do(f func(b Backend)) (bool, error) Do(aid int, f func(c Cursor)) (ok bool, err error)
// List queries the store and returns a list of aids known to the store.
// Note that some or all returned aids might not have any active apps.
List() (aids []int, err error)
// Close releases any resources held by Store. // Close releases any resources held by Store.
Close() error Close() error
} }
// Backend provides access to the store // Cursor provides access to the store
type Backend interface { type Cursor interface {
Save(state *State) error Save(state *State) error
Destroy(pid int) error Destroy(id fst.ID) error
Load() ([]*State, error) Load() (Entries, error)
Len() (int, error) Len() (int, error)
} }
// State is the on-disk format for a fortified process's state information // State is the on-disk format for a fortified process's state information
type State struct { type State struct {
// fortify instance id
ID fst.ID `json:"instance"`
// child process PID value // child process PID value
PID int PID int `json:"pid"`
// command used to seal the app // sealed app configuration
Command []string Config *fst.Config `json:"config"`
// capability enablements applied to child
Capability system.Enablements
// full argv whe launching
Argv []string
// process start time // process start time
Time time.Time Time time.Time
} }

View File

@ -0,0 +1,126 @@
package state_test
import (
"math/rand/v2"
"reflect"
"slices"
"testing"
"time"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/state"
)
func testStore(t *testing.T, s state.Store) {
t.Run("list empty store", func(t *testing.T) {
if aids, err := s.List(); err != nil {
t.Fatalf("List: error = %v", err)
} else if len(aids) != 0 {
t.Fatalf("List: aids = %#v", aids)
}
})
const (
insertEntryChecked = iota
insertEntryNoCheck
insertEntryOtherApp
tl
)
var tc [tl]state.State
for i := 0; i < tl; i++ {
makeState(t, &tc[i])
}
do := func(aid int, f func(c state.Cursor)) {
if ok, err := s.Do(aid, f); err != nil {
t.Fatalf("Do: ok = %v, error = %v", ok, err)
}
}
insert := func(i, aid int) {
do(aid, func(c state.Cursor) {
if err := c.Save(&tc[i]); err != nil {
t.Fatalf("Save(&tc[%v]): error = %v", i, err)
}
})
}
check := func(i, aid int) {
do(aid, func(c state.Cursor) {
if entries, err := c.Load(); err != nil {
t.Fatalf("Load: error = %v", err)
} else if got, ok := entries[tc[i].ID]; !ok {
t.Fatalf("Load: entry %s missing",
&tc[i].ID)
} else {
got.Time = tc[i].Time
if !reflect.DeepEqual(got, &tc[i]) {
t.Fatalf("Load: entry %s got %#v, want %#v",
&tc[i].ID, got, &tc[i])
}
}
})
}
t.Run("insert entry checked", func(t *testing.T) {
insert(insertEntryChecked, 0)
check(insertEntryChecked, 0)
})
t.Run("insert entry unchecked", func(t *testing.T) {
insert(insertEntryNoCheck, 0)
})
t.Run("insert entry different aid", func(t *testing.T) {
insert(insertEntryOtherApp, 1)
check(insertEntryOtherApp, 1)
})
t.Run("check previous insertion", func(t *testing.T) {
check(insertEntryNoCheck, 0)
})
t.Run("list aids", func(t *testing.T) {
if aids, err := s.List(); err != nil {
t.Fatalf("List: error = %v", err)
} else {
slices.Sort(aids)
want := []int{0, 1}
if slices.Compare(aids, want) != 0 {
t.Fatalf("List() = %#v, want %#v", aids, want)
}
}
})
t.Run("clear aid 1", func(t *testing.T) {
do(1, func(c state.Cursor) {
if err := c.Destroy(tc[insertEntryOtherApp].ID); err != nil {
t.Fatalf("Destroy: error = %v", err)
}
})
do(1, func(c state.Cursor) {
if l, err := c.Len(); err != nil {
t.Fatalf("Len: error = %v", err)
} else if l != 0 {
t.Fatalf("Len() = %d, want 0", l)
}
})
})
t.Run("close store", func(t *testing.T) {
if err := s.Close(); err != nil {
t.Fatalf("Close: error = %v", err)
}
})
}
func makeState(t *testing.T, s *state.State) {
if err := fst.NewAppID(&s.ID); err != nil {
t.Fatalf("cannot create dummy state: %v", err)
}
s.Config = fst.Template()
s.PID = rand.Int()
s.Time = time.Now()
}

View File

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"slices" "slices"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// UpdatePerm appends an ephemeral acl update Op. // UpdatePerm appends an ephemeral acl update Op.

View File

@ -3,7 +3,7 @@ package system
import ( import (
"testing" "testing"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
) )
func TestUpdatePerm(t *testing.T) { func TestUpdatePerm(t *testing.T) {

View File

@ -7,8 +7,8 @@ import (
"strings" "strings"
"sync" "sync"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
var ( var (

View File

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// Ensure the existence and mode of a directory. // Ensure the existence and mode of a directory.

View File

@ -5,7 +5,7 @@ import (
"os" "os"
"sync" "sync"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
const ( const (

View File

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

View File

@ -7,8 +7,8 @@ import (
"os" "os"
"strconv" "strconv"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// CopyFile registers an Op that copies path dst from src. // CopyFile registers an Op that copies path dst from src.

View File

@ -4,7 +4,7 @@ import (
"strconv" "strconv"
"testing" "testing"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
) )
func TestCopyFile(t *testing.T) { func TestCopyFile(t *testing.T) {

View File

@ -5,9 +5,9 @@ import (
"fmt" "fmt"
"os" "os"
"git.ophivana.moe/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/wl" "git.gensokyo.uk/security/fortify/wl"
) )
// Wayland sets up a wayland socket with a security context attached. // Wayland sets up a wayland socket with a security context attached.

View File

@ -3,8 +3,8 @@ package system
import ( import (
"fmt" "fmt"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/xcb" "git.gensokyo.uk/security/fortify/xcb"
) )
// ChangeHosts appends an X11 ChangeHosts command Op. // ChangeHosts appends an X11 ChangeHosts command Op.

View File

@ -6,8 +6,8 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"git.ophivana.moe/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
) )
func Exec(p string) ([]*Entry, error) { func Exec(p string) ([]*Entry, error) {

View File

@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/security/fortify/ldd" "git.gensokyo.uk/security/fortify/ldd"
) )
func TestParseError(t *testing.T) { func TestParseError(t *testing.T) {

27
main.go
View File

@ -11,13 +11,14 @@ import (
"sync" "sync"
"text/tabwriter" "text/tabwriter"
"git.ophivana.moe/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.ophivana.moe/security/fortify/internal" "git.gensokyo.uk/security/fortify/fst"
"git.ophivana.moe/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/linux"
"git.ophivana.moe/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/system"
) )
var ( var (
@ -102,7 +103,7 @@ func main() {
fmt.Println(license) fmt.Println(license)
fmsg.Exit(0) fmsg.Exit(0)
case "template": // print full template configuration case "template": // print full template configuration
if s, err := json.MarshalIndent(app.Template(), "", " "); err != nil { if s, err := json.MarshalIndent(fst.Template(), "", " "); err != nil {
fmsg.Fatalf("cannot generate template: %v", err) fmsg.Fatalf("cannot generate template: %v", err)
panic("unreachable") panic("unreachable")
} else { } else {
@ -129,7 +130,7 @@ func main() {
fmsg.Fatal("app requires at least 1 argument") fmsg.Fatal("app requires at least 1 argument")
} }
config := new(app.Config) config := new(fst.Config)
if f, err := os.Open(args[1]); err != nil { if f, err := os.Open(args[1]); err != nil {
fmsg.Fatalf("cannot access config file %q: %s", args[1], err) fmsg.Fatalf("cannot access config file %q: %s", args[1], err)
panic("unreachable") panic("unreachable")
@ -179,7 +180,7 @@ func main() {
_ = set.Parse(args[1:]) _ = set.Parse(args[1:])
// initialise config from flags // initialise config from flags
config := &app.Config{ config := &fst.Config{
ID: fid, ID: fid,
Command: set.Args(), Command: set.Args(),
} }
@ -275,11 +276,7 @@ func main() {
panic("unreachable") panic("unreachable")
} }
func runApp(config *app.Config) { func runApp(config *fst.Config) {
if os.SdBooted() {
fmsg.VPrintln("system booted with systemd as init system")
}
a, err := app.New(os) a, err := app.New(os)
if err != nil { if err != nil {
fmsg.Fatalf("cannot create app: %s\n", err) fmsg.Fatalf("cannot create app: %s\n", err)

View File

@ -10,7 +10,6 @@ let
mkIf mkIf
mkDefault mkDefault
mapAttrs mapAttrs
mapAttrsToList
mergeAttrsList mergeAttrsList
imap1 imap1
foldr foldr

View File

@ -36,7 +36,7 @@ package
*Default:* *Default:*
` <derivation fortify-0.2.1> ` ` <derivation fortify-0.2.5> `

View File

@ -31,7 +31,6 @@ in
let let
inherit (types) inherit (types)
str str
enum
bool bool
package package
anything anything

View File

@ -14,7 +14,7 @@
buildGoModule rec { buildGoModule rec {
pname = "fortify"; pname = "fortify";
version = "0.2.2"; version = "0.2.5";
src = ./.; src = ./.;
vendorHash = null; vendorHash = null;
@ -26,7 +26,7 @@ buildGoModule rec {
ldflags ldflags
++ [ ++ [
"-X" "-X"
"git.ophivana.moe/security/fortify/internal.${name}=${value}" "git.gensokyo.uk/security/fortify/internal.${name}=${value}"
] ]
) )
[ [
@ -43,6 +43,9 @@ buildGoModule rec {
Finit = "${placeholder "out"}/libexec/finit"; Finit = "${placeholder "out"}/libexec/finit";
}; };
# nix build environment does not allow acls
GO_TEST_SKIP_ACL = 1;
buildInputs = [ buildInputs = [
acl acl
wayland wayland

221
test.nix Normal file
View File

@ -0,0 +1,221 @@
{
system,
self,
home-manager,
nixosTest,
}:
nixosTest {
name = "fortify";
# adapted from nixos sway integration tests
# testScriptWithTypes:49: error: Cannot call function of unknown type
# (machine.succeed if succeed else machine.execute)(
# ^
# Found 1 error in 1 file (checked 1 source file)
skipTypeCheck = true;
nodes.machine =
{ lib, pkgs, ... }:
{
users.users.alice = {
isNormalUser = true;
description = "Alice Foobar";
password = "foobar";
uid = 1000;
};
home-manager.users.alice.home.stateVersion = "24.11";
# Automatically login on tty1 as a normal user:
services.getty.autologinUser = "alice";
environment = {
systemPackages = with pkgs; [
# For glinfo and wayland-info:
mesa-demos
wayland-utils
alacritty
# For go tests:
self.devShells.${system}.fhs
];
variables = {
SWAYSOCK = "/tmp/sway-ipc.sock";
WLR_RENDERER = "pixman";
};
# To help with OCR:
etc."xdg/foot/foot.ini".text = lib.generators.toINI { } {
main = {
font = "inconsolata:size=14";
};
colors = rec {
foreground = "000000";
background = "ffffff";
regular2 = foreground;
};
};
};
fonts.packages = [ pkgs.inconsolata ];
# Automatically configure and start Sway when logging in on tty1:
programs.bash.loginShellInit = ''
if [ "$(tty)" = "/dev/tty1" ]; then
set -e
mkdir -p ~/.config/sway
sed s/Mod4/Mod1/ /etc/sway/config > ~/.config/sway/config
sway --validate
sway && touch /tmp/sway-exit-ok
fi
'';
programs.sway.enable = true;
virtualisation.qemu.options = [
# Need to switch to a different GPU driver than the default one (-vga std) so that Sway can launch:
"-vga none -device virtio-gpu-pci"
# Increase Go test compiler performance:
"-smp 8"
];
environment.fortify = {
enable = true;
stateDir = "/var/lib/fortify";
users.alice = 0;
};
imports = [
self.nixosModules.fortify
home-manager.nixosModules.home-manager
];
};
testScript = ''
import shlex
import json
q = shlex.quote
NODE_GROUPS = ["nodes", "floating_nodes"]
def swaymsg(command: str = "", succeed=True, type="command"):
assert command != "" or type != "command", "Must specify command or type"
shell = q(f"swaymsg -t {q(type)} -- {q(command)}")
with machine.nested(
f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed)
):
ret = (machine.succeed if succeed else machine.execute)(
f"su - alice -c {shell}"
)
# execute also returns a status code, but disregard.
if not succeed:
_, ret = ret
if not succeed and not ret:
return None
parsed = json.loads(ret)
return parsed
def walk(tree):
yield tree
for group in NODE_GROUPS:
for node in tree.get(group, []):
yield from walk(node)
def wait_for_window(pattern):
def func(last_chance):
nodes = (node["name"] for node in walk(swaymsg(type="get_tree")))
if last_chance:
nodes = list(nodes)
machine.log(f"Last call! Current list of windows: {nodes}")
return any(pattern in name for name in nodes)
retry(func)
def collect_state_ui(name):
swaymsg(f"exec fortify ps > '/tmp/{name}.ps'")
machine.copy_from_vm(f"/tmp/{name}.ps", "")
machine.screenshot(name)
start_all()
machine.wait_for_unit("multi-user.target")
# Run fortify Go tests outside of nix build:
machine.succeed("rm -rf /tmp/src && cp -a '${self.packages.${system}.fortify.src}' /tmp/src")
print(machine.succeed("fortify-fhs -c '(cd /tmp/src && go generate ./... && go test ./...)'"))
# To check sway's version:
print(machine.succeed("sway --version"))
# Wait for Sway to complete startup:
machine.wait_for_file("/run/user/1000/wayland-1")
machine.wait_for_file("/tmp/sway-ipc.sock")
# Create fortify aid 0 home directory:
machine.succeed("install -dm 0700 -o 1000000 -g 1000000 /var/lib/fortify/u0/a0")
# Start fortify outside Wayland session:
print(machine.succeed("sudo -u alice -i fortify -v run -a 0 touch /tmp/success-bare"))
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-bare")
# Start fortify within Wayland session:
swaymsg("exec fortify -v run --wayland touch /tmp/success-session")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-session")
# Start a terminal (foot) within fortify on workspace 3:
machine.send_key("alt-3")
machine.sleep(3)
swaymsg("exec fortify run --wayland foot")
wait_for_window("u0_a0@machine")
machine.send_chars("wayland-info && touch /tmp/success-client\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-client")
collect_state_ui("foot_wayland_permissive")
# Verify acl on XDG_RUNTIME_DIR:
print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000000"))
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Verify acl cleanup on XDG_RUNTIME_DIR:
machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000000")
# Start a terminal (foot) within fortify from a terminal on workspace 4:
machine.send_key("alt-4")
machine.sleep(3)
swaymsg("exec foot fortify run --wayland foot")
wait_for_window("u0_a0@machine")
machine.send_chars("wayland-info && touch /tmp/success-client-term\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-client-term")
collect_state_ui("foot_wayland_permissive_term")
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Test XWayland (foot does not support X):
swaymsg("exec fortify run -X alacritty")
wait_for_window("u0_a0@machine")
machine.send_chars("glinfo && touch /tmp/success-client-x11\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-client-x11")
collect_state_ui("alacritty_x11_permissive")
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep alacritty")
# Exit Sway and verify process exit status 0:
swaymsg("exit", succeed=False)
machine.wait_until_fails("pgrep -x sway")
machine.wait_for_file("/tmp/sway-exit-ok")
# Print fortify runDir contents:
print(machine.succeed("find /run/user/1000/fortify"))
'';
}

137
xcb/c.go
View File

@ -1,33 +1,124 @@
package xcb package xcb
import ( import (
"errors" "runtime"
"unsafe"
) )
//#include <stdlib.h> /*
//#include <xcb/xcb.h> #cgo linux pkg-config: xcb
//#cgo linux LDFLAGS: -lxcb
#include <stdlib.h>
#include <xcb/xcb.h>
static int _go_xcb_change_hosts_checked(xcb_connection_t *c, uint8_t mode, uint8_t family, uint16_t address_len, const uint8_t *address) {
xcb_void_cookie_t cookie = xcb_change_hosts_checked(c, mode, family, address_len, address);
free((void *)address);
int errno = xcb_connection_has_error(c);
if (errno != 0)
return errno;
xcb_generic_error_t *e = xcb_request_check(c, cookie);
if (e != NULL) {
// don't want to deal with xcb errors
free((void *)e);
return -1;
}
return 0;
}
*/
import "C" import "C"
func xcbHandleConnectionError(c *C.xcb_connection_t) error { const (
if errno := C.xcb_connection_has_error(c); errno != 0 { HostModeInsert = C.XCB_HOST_MODE_INSERT
switch errno { HostModeDelete = C.XCB_HOST_MODE_DELETE
case C.XCB_CONN_ERROR:
return errors.New("connection error") FamilyInternet = C.XCB_FAMILY_INTERNET
case C.XCB_CONN_CLOSED_EXT_NOTSUPPORTED: FamilyDecnet = C.XCB_FAMILY_DECNET
return errors.New("extension not supported") FamilyChaos = C.XCB_FAMILY_CHAOS
case C.XCB_CONN_CLOSED_MEM_INSUFFICIENT: FamilyServerInterpreted = C.XCB_FAMILY_SERVER_INTERPRETED
return errors.New("memory not available") FamilyInternet6 = C.XCB_FAMILY_INTERNET_6
case C.XCB_CONN_CLOSED_REQ_LEN_EXCEED: )
return errors.New("request length exceeded")
case C.XCB_CONN_CLOSED_PARSE_ERR: type (
return errors.New("invalid display string") HostMode = C.xcb_host_mode_t
case C.XCB_CONN_CLOSED_INVALID_SCREEN: Family = C.xcb_family_t
return errors.New("server has no screen matching display") )
default:
return errors.New("generic X11 failure") func (conn *connection) changeHostsChecked(mode HostMode, family Family, address string) error {
} errno := C._go_xcb_change_hosts_checked(
} else { conn.c,
C.uint8_t(mode),
C.uint8_t(family),
C.uint16_t(len(address)),
(*C.uint8_t)(unsafe.Pointer(C.CString(address))),
)
switch errno {
case 0:
return nil return nil
case -1:
return ErrChangeHosts
default:
return &ConnectionError{errno}
} }
} }
type connection struct{ c *C.xcb_connection_t }
func connect() (*connection, error) {
conn := newConnection(C.xcb_connect(nil, nil))
return conn, conn.hasError()
}
func newConnection(c *C.xcb_connection_t) *connection {
conn := &connection{c}
runtime.SetFinalizer(conn, (*connection).disconnect)
return conn
}
const (
ConnError = C.XCB_CONN_ERROR
ConnClosedExtNotSupported = C.XCB_CONN_CLOSED_EXT_NOTSUPPORTED
ConnClosedMemInsufficient = C.XCB_CONN_CLOSED_MEM_INSUFFICIENT
ConnClosedReqLenExceed = C.XCB_CONN_CLOSED_REQ_LEN_EXCEED
ConnClosedParseErr = C.XCB_CONN_CLOSED_PARSE_ERR
ConnClosedInvalidScreen = C.XCB_CONN_CLOSED_INVALID_SCREEN
)
type ConnectionError struct{ errno C.int }
func (ce *ConnectionError) Error() string {
switch ce.errno {
case ConnError:
return "connection error"
case ConnClosedExtNotSupported:
return "extension not supported"
case ConnClosedMemInsufficient:
return "memory not available"
case ConnClosedReqLenExceed:
return "request length exceeded"
case ConnClosedParseErr:
return "invalid display string"
case ConnClosedInvalidScreen:
return "server has no screen matching display"
default:
return "generic X11 failure"
}
}
func (conn *connection) hasError() error {
errno := C.xcb_connection_has_error(conn.c)
if errno == 0 {
return nil
}
return &ConnectionError{errno}
}
func (conn *connection) disconnect() {
C.xcb_disconnect(conn.c)
// no need for a finalizer anymore
runtime.SetFinalizer(conn, nil)
}

View File

@ -1,63 +1,22 @@
// Package xcb implements X11 ChangeHosts via libxcb. // Package xcb implements X11 ChangeHosts via libxcb.
package xcb package xcb
//#include <stdlib.h>
//#include <xcb/xcb.h>
//#cgo linux LDFLAGS: -lxcb
import "C"
import ( import (
"errors" "errors"
"unsafe"
) )
const ( var ErrChangeHosts = errors.New("xcb_change_hosts() failed")
HostModeInsert = C.XCB_HOST_MODE_INSERT
HostModeDelete = C.XCB_HOST_MODE_DELETE
FamilyInternet = C.XCB_FAMILY_INTERNET func ChangeHosts(mode HostMode, family Family, address string) error {
FamilyDecnet = C.XCB_FAMILY_DECNET var conn *connection
FamilyChaos = C.XCB_FAMILY_CHAOS
FamilyServerInterpreted = C.XCB_FAMILY_SERVER_INTERPRETED
FamilyInternet6 = C.XCB_FAMILY_INTERNET_6
)
type ConnectionError struct { if c, err := connect(); err != nil {
err error c.disconnect()
} return err
} else {
func (e *ConnectionError) Error() string { defer c.disconnect()
return e.err.Error() conn = c
}
func (e *ConnectionError) Unwrap() error {
return e.err
}
var (
ErrChangeHosts = errors.New("xcb_change_hosts() failed")
)
func ChangeHosts(mode, family C.uint8_t, address string) error {
c := C.xcb_connect(nil, nil)
defer C.xcb_disconnect(c)
if err := xcbHandleConnectionError(c); err != nil {
return &ConnectionError{err}
} }
addr := C.CString(address) return conn.changeHostsChecked(mode, family, address)
cookie := C.xcb_change_hosts_checked(c, mode, family, C.ushort(len(address)), (*C.uchar)(unsafe.Pointer(addr)))
C.free(unsafe.Pointer(addr))
if err := xcbHandleConnectionError(c); err != nil {
return &ConnectionError{err}
}
e := C.xcb_request_check(c, cookie)
if e != nil {
defer C.free(unsafe.Pointer(e))
return ErrChangeHosts
}
return nil
} }