Compare commits

...

93 Commits

Author SHA1 Message Date
Ophestra Umiker 563c39c2d9
release: 0.0.10
test / test (push) Successful in 19s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-28 20:38:10 +09:00
Ophestra Umiker aa1f96eeeb
fsu: check parent executable path
test / test (push) Successful in 19s Details
Only allow main program to launch fsu. This change and further checks in the main program reduces attack surface.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-28 18:52:23 +09:00
Ophestra Umiker 431dc095e5
app/start: skip cleanup if shim is nil
test / test (push) Successful in 19s Details
Shim is created before any system operation happens.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-28 14:21:15 +09:00
Ophestra Umiker 60e91b9b0f
shim: expose checkPid in constructor
test / test (push) Successful in 1m44s Details
This will be supported soon when launching via fsu.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-28 00:02:55 +09:00
Ophestra Umiker d9cb2a9f2b
fsu: implement simple setuid user switcher
Contains path to fortify, set at compile time, authenticates based on a simple uid range assignment file which also acts as the allow list.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-28 00:02:34 +09:00
Ophestra Umiker 09feda3783
fortify: exit if seal returns error
test / test (push) Successful in 20s Details
Wait should not be called on an unsealed App.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-27 23:18:16 +09:00
Ophestra Umiker 51e84ba8a5
system/dbus: compare sealed value by string
test / test (push) Successful in 19s Details
Stringer method of dbus.Proxy returns a string representation of its args stream when sealed.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-27 12:09:34 +09:00
Ophestra Umiker 7df9d8d01d
system: move sd_booted implementation to os abstraction
This implements lazy loading of the systemd marker (they are not accessed in init and shim) and ensures consistent behaviour when running with a stub.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-27 12:09:34 +09:00
Ophestra Umiker 6d8bcb63f2
release: 0.0.9
release / release (push) Successful in 27s Details
test / test (push) Successful in 22s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-27 01:25:24 +09:00
Ophestra Umiker c7b77d6e5e
fmsg: initialise dequeue prior to withhold/resume
test / test (push) Successful in 23s Details
This fixes the hang on resume when no messages were ever printed.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-27 01:24:30 +09:00
Ophestra Umiker 2f34627d37
release: 0.0.8
release / release (push) Successful in 31s Details
test / test (push) Successful in 20s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-27 00:49:50 +09:00
Ophestra Umiker 1d6ea81205
shim: user switcher process management struct
test / test (push) Successful in 19s Details
This change moves all user switcher and shim management to the shim package and withholds output while shim is alive. This also eliminated all exit scenarios where revert is skipped.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-27 00:46:15 +09:00
Ophestra Umiker ae1a102882
fmsg: support temporarily withholding output
test / test (push) Successful in 31s Details
Trying to print to a shared stdout is a terrible idea. This change makes it possible to withhold output for the lifetime of the sandbox.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-26 23:09:32 +09:00
Ophestra Umiker 093e99d062
app: separate nixos test cases from tests
test / test (push) Successful in 20s Details
Test cases are very long, separating them improves editor performance.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 17:44:29 +09:00
Ophestra Umiker ad7e389eee
app: test app permissive defaults sealing behaviour
test / test (push) Successful in 20s Details
This test seals App against a deterministic os stub and checks the resulting sys and bwrap values against known correct ones. The effects of sys and bwrap on the OS and sandbox is deterministic and tested in their own respective packages.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 17:12:13 +09:00
Ophestra Umiker 5b249e4a66
system: print number of ops completed at point of failure
test / test (push) Successful in 21s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 17:08:21 +09:00
Ophestra Umiker 2a348c7f91
system: include more info in ACL Stringer
test / test (push) Successful in 24s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 16:23:22 +09:00
Ophestra Umiker eb767e7642
app/start: cleaner command not found message
test / test (push) Successful in 27s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 16:12:18 +09:00
Ophestra Umiker 3bfe8dbf5d
internal: ReadDir wrapper return fs.DirEntry
test / test (push) Successful in 25s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 14:56:29 +09:00
Ophestra Umiker 8fa791a2f8
app/seal: symlink /etc entries in permissive default
test / test (push) Successful in 20s Details
Fortify overrides /etc/passwd and /etc/group in the sandbox. Bind mounting /etc results in them being replaced when the passwd database is updated on host.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 13:31:57 +09:00
Ophestra Umiker b932ac8260
app/config: support creating symlinks within sandbox
test / test (push) Successful in 21s Details
This is already supported by the underlying bwrap helper. This change exposes access to it in Config.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 13:29:01 +09:00
Ophestra Umiker 050ffceb27
helper/bwrap: register generic PermConfig types with gob
test / test (push) Successful in 21s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 13:26:01 +09:00
Ophestra Umiker 31350d74e5
shim: kill shim if setup becomes impossible
test / test (push) Successful in 23s Details
This prevents a hang when setup faults but the shim keeps waiting on the socket. Setup is automatically aborted when the shim is killed.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 13:19:37 +09:00
Ophestra Umiker 3b82cc55de
internal: use fallback paths when XDG_RUNTIME_DIR is not absolute
test / test (push) Successful in 24s Details
There are scenarios where XDG_RUNTIME_DIR is set to an empty string or garbage. This check tries to ensure reasonable behaviour in most of those cases.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-25 12:14:57 +09:00
Ophestra Umiker 6bc5be7e5a
internal: wrap calls to os standard library functions
test / test (push) Successful in 19s Details
This change helps tests stub out and simulate OS behaviour during the sealing process. This also removes dependency on XDG_RUNTIME_DIR as the internal.System implementation provided to App provides a compat directory inside the tmpdir-based share when XDG_RUNTIME_DIR is unavailable.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-23 21:46:21 +09:00
Ophestra Umiker e35c5fe3ed
system: sys comparison method
test / test (push) Successful in 24s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-23 14:15:13 +09:00
Ophestra Umiker 20195ece47
system: return sys in queueing methods
test / test (push) Successful in 54s Details
This enables building an instance in a single statement.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-23 12:34:16 +09:00
Ophestra Umiker cafed5f234
shim: abort setup on failed start and process exit
test / test (push) Successful in 25s Details
Shim setup listens on a socket in the process share, if shim setup hasn't happened on exit revert will fail. This change makes sure shim setup is aborted on a doomed launch.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-21 21:23:56 +09:00
Ophestra Umiker 42e0b168e3
fmsg: produce all output through fmsg
test / test (push) Successful in 17s Details
The behaviour of print functions from package fmt is not thread safe. Functions provided by fmsg wrap around Logger methods. This makes prefix much cleaner and makes it easy to deal with future changes to logging.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-21 20:47:02 +09:00
Ophestra Umiker 380d1f4585
app: move wayland mediation to shim package
test / test (push) Successful in 29s Details
Values used in the Wayland mediation implementation is stored in various struct fields strewn across multiple app structs and checks are messy and confusing. This commit unifies them into a single struct and access it using much better looking methods.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-21 18:46:06 +09:00
Ophestra Umiker 133f23e0de
release: 0.0.7
release / release (push) Successful in 21s Details
test / test (push) Successful in 11s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-20 19:50:59 +09:00
Ophestra Umiker 65af1684e3
migrate to git.ophivana.moe/security/fortify
test / test (push) Successful in 14s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-20 19:50:13 +09:00
Ophestra Umiker cdda33555c
update README document
We have a highly configurable sandbox now, just not really the Android sandbox.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-20 00:24:50 +09:00
Ophestra Umiker ad0034b09a
app: move app ID to app struct
App ID is inherent to App, and it makes no sense to generate it as part of the app sealing process.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-20 00:22:18 +09:00
Ophestra Umiker 1da845d78b
workflows: call apt-get without sudo
Workflow scripts run as root in act-runner containers, so calling sudo is redundant and pointless.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-18 22:56:49 +09:00
Ophestra Umiker 55bb348d5f
state: store launch method instead of launcher path
Launcher path is constant for each launch method on the same system.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-18 22:25:09 +09:00
Ophestra Umiker ecce832d93
release: 0.0.6
release / release (push) Successful in 1m46s Details
test / test (push) Successful in 1m39s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-18 01:26:42 +09:00
Ophestra Umiker 65bd7d18db
app/share: fix order to ensure SharePath before any of its subdirectories
shareTmpdirChild happened to request an ephemeral dir within SharePath and was called before shareRuntime which ensures that path. This commit moves SharePath initialisation to shareSystem and moves shareTmpdirChild into ShareSystem. Further cleanup and tests are desperately needed for the app package but for now this fix will have to do.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-18 01:21:58 +09:00
Ophestra Umiker 4ebb98649e
release: 0.0.5
release / release (push) Successful in 1m26s Details
test / test (push) Successful in 3m6s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 20:48:41 +09:00
Ophestra Umiker 919e5b5cd5
init: start timeout only if reaped PID is the initial process
Fix a very obvious bug introduced in 5401882ed0.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 20:46:48 +09:00
Ophestra Umiker 40161c5938
nix: remove fortify package from default devShell
This change makes it possible to start a devShell when tests aren't passing.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 20:35:10 +09:00
Ophestra Umiker 679e719f9e
system: tests for all Op implementations except DBus
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 20:28:55 +09:00
Ophestra Umiker 064db9f020
system/mkdir: type label in String method
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 16:37:23 +09:00
Ophestra Umiker 73a698c7cb
ldd: run ldd with read-only filesystem and unshared net
This is only called on trusted programs, however extra hardening is never a bad idea.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 15:37:27 +09:00
Ophestra Umiker 57c1b3eda6
system: handle invalid enablement in String method
Invalid enablement is only caused by bad API usage, however panicking on the spot leaves behind messy state that has to be manually cleaned up.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 14:31:13 +09:00
Ophestra Umiker 5401882ed0
init: post initial process death exit timeout
Wait for 5 seconds before printing a message and exiting after picking up the initial process's wait status. This also kills any lingering processes.This behaviour is helpful for applications launched without a terminal attached.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 02:38:24 +09:00
Ophestra Umiker dd78728fb3
workflows: test workflow to run tests every commit
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 00:18:20 +09:00
Ophestra Umiker 354c23dd28
workflows: add lines between steps
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-17 00:17:40 +09:00
Ophestra Umiker c21168a741
system: move enablements from state package
This removes the unnecessary import of the state package.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-16 14:38:57 +09:00
Ophestra Umiker 084cd84f36
app: port app to use the system package
This commit does away with almost all baggage left over from the Ego port. Error wrapping also got simplified. All API changes happens to be internal which means no changes to main except renaming of the BaseError type.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-16 01:38:59 +09:00
Ophestra Umiker 430f1a5b4e
system: isolate app/system into generic implementation
This improves maintainability and extensibility of system operations, makes writing tests for them possible, and operations now apply and revert in order, instead of being bunched up into their own categories.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-16 01:31:23 +09:00
Ophestra Umiker 0fd63e85e7
fmsg/errors: isolate app/error into a separate package
These functions are not in any way specific to the app package.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-16 01:29:44 +09:00
Ophestra Umiker 33cf0bed54
dbus: various accessors for dbus.Proxy internal fields
These values are useful during sandbox setup and exporting them makes more sense than storing them twice.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-16 01:27:49 +09:00
Ophestra Umiker 689f5bed57
release: 0.0.4
release / release (push) Successful in 1m32s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-15 02:56:49 +09:00
Ophestra Umiker 184a5f29fa
helper/bwrap: add fortify permissive default test case
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-15 02:56:13 +09:00
Ophestra Umiker 3015266e5a
helper/bwrap: sort SetEnv arguments
This guarantees consistency of resulting args.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-15 02:55:48 +09:00
Ophestra Umiker aa5dd2313c
app: filter /tmp from permissive default
Tmpdir is bind mounted over further along in execution so there is no point sharing it here.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-15 02:54:50 +09:00
Ophestra Umiker 2faf510146
helper/bwrap: ordered filesystem args
The argument builder was written based on the incorrect assumption that bwrap arguments are unordered. The argument builder is replaced in this commit to correct that mistake.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-15 02:15:55 +09:00
Ophestra Umiker a0db19b9ad
helper/bwrap: format mode in octal
Bubblewrap expects an octal representation of mode.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-14 13:47:50 +09:00
Ophestra Umiker aaed5080f4
fortify: move PR_SET_DUMPABLE to the beginning of main
This call does need flag values.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-14 02:48:37 +09:00
Ophestra Umiker 41a7eb567e
release: 0.0.3
release / release (push) Successful in 2m38s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-14 02:31:11 +09:00
Ophestra Umiker 1302bcede0
init: custom init process inside sandbox
Bubblewrap as init is a bit awkward and don't support a few setup actions fortify will need, such as starting/supervising nscd.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-14 02:27:02 +09:00
Ophestra Umiker 315c9b8849
fortify: refuse to run as root
There is no good reason to run fortify as root and desktop environments typically do not like that either. This check prevents confusion for new users who might mistakenly run it as root or set the setuid bit.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 20:06:47 +09:00
Ophestra Umiker 3739b56504
shim: update payload comment
Generating permissive default no longer happens in shim.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 17:19:50 +09:00
Ophestra Umiker 77f2c320a6
shim: re-exec self on startup
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 16:56:10 +09:00
Ophestra Umiker b470941911
shim: get rid of insane launch condition
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 12:09:38 +09:00
Ophestra Umiker e4536b87ad
app: generate and replace passwd and group files
This ensures libc functions get correct user information.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 02:43:00 +09:00
Ophestra Umiker 65a5f8fb08
app/config: map bwrap tmpfs in app config
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 02:39:27 +09:00
Ophestra Umiker aee96b0fdf
helper/bwrap: allow pushing generic arguments to the end of argument stream
Bwrap argument order determines the order their corresponding actions are performed. This allows generic arguments like tmpfs to the end of the stream to override bind mounts.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 02:26:01 +09:00
Ophestra Umiker 655020eb5d
app/config: always use nobody UID within sandbox
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 00:50:24 +09:00
Ophestra Umiker f320dfc2ee
fortify: set SUID_DUMP_DISABLE after flag parse
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 00:09:14 +09:00
Ophestra Umiker c818ea649a
app/seal: skip /mnt in permissive default
This directory usually contains temporarily mounted stuff and shouldn't get into the sandbox.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 00:07:48 +09:00
Ophestra Umiker b091260fd3
update README document
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-13 00:07:10 +09:00
Ophestra Umiker b9d5fe49cb
nix: pass $SHELL for shell interpreter
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 23:01:06 +09:00
Ophestra Umiker d37dcff2fc
app/seal: allow GPU access in permissive default when either X11/Wayland is enabled
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 22:55:53 +09:00
Ophestra Umiker 805ef99f9b
app: filesystem struct that maps to all bwrap bind options
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 22:33:04 +09:00
Ophestra Umiker 283bcba05b
fortify/config: flag to print template config serialised as JSON
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 19:46:40 +09:00
Ophestra Umiker 2e019e48c1
app: supply template config
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 19:46:07 +09:00
Ophestra Umiker d5c26ae593
fortify: move error handling to separate file
Error handling here is way too monstrous due to terrible design of the internal/app package. Since rewriting internal/app will take a while, error handling is moved out of main to improve readability.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 02:11:43 +09:00
Ophestra Umiker 61b473a06f
fortify: clean up config loading
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 01:51:06 +09:00
Ophestra Umiker d2575b6708
fortify: move flag handling to separate files
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 01:28:22 +09:00
Ophestra Umiker 8d82446d97
helper: remove unused bwrap config field
This configuration is not saved anywhere, and does not need to be saved. Bwrap configuration information is already saved into p.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 00:55:14 +09:00
Ophestra Umiker 0f421644be
dbus: improve unsealed behaviour coverage
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-12 00:53:08 +09:00
Ophestra Umiker 662f2a9d2c
app: integrate bwrap into environment setup
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-11 04:18:15 +09:00
Ophestra Umiker 3ddfd76cdf
shim: use bwrap config as it is
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-11 04:13:56 +09:00
Ophestra Umiker 713872a5cd
helper/bwrap: move interfaceArgs before stringArgs
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-11 04:12:47 +09:00
Ophestra Umiker 6220f7e197
app: migrate to new shim implementation
Both machinectl and sudo launch methods launch shim as shim is now responsible for setting up the sandbox. Various app structures are adapted to accommodate bwrap configuration and mediated wayland access.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-11 02:01:03 +09:00
Ophestra Umiker b86fa6b4c9
shim: new shim implementation
This implementation of shim accepts configuration as a gob stream over a unix socket, with support for mediating access to wayland via WAYLAND_SOCKET fd. All configuration is now included in the payload, and child is started inside bwrap configured with supplied bwrap.Config.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-11 01:55:33 +09:00
Ophestra Umiker 6eb712aec7
verbose: overridable prefix
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-11 01:49:11 +09:00
Ophestra Umiker 101e49a48b
helper/bwrap: proc, dev and mqueue as string arguments
These flags do not support --chmod.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-11 01:30:11 +09:00
Ophestra Umiker a3aadd4146
app: tag ACL operations for revert
ACL operations are now tagged with the enablement causing them. At the end of child process's life, enablements of all remaining launchers are resolved and inverted. This allows Wait to only revert operations targeting resources no longer required by other launchers.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-10 14:33:58 +09:00
Ophestra Umiker 86cb5ac1db
app: hardlink sockets to process-specific share local to XDG_RUNTIME_DIR
This avoids adding ACLs to the PulseAudio directory.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-10 12:44:08 +09:00
Ophestra Umiker 2220055e26
state/simple: prefix store path
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-10-10 11:03:31 +09:00
97 changed files with 5035 additions and 1728 deletions

View File

@ -13,22 +13,26 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up go
- name: Setup go
uses: https://github.com/actions/setup-go@v5 uses: https://github.com/actions/setup-go@v5
with: with:
go-version: '>=1.20.1' go-version: '>=1.23.0'
- name: Get dependencies - name: Get dependencies
run: >- run: >-
sudo apt-get update && apt-get update &&
sudo apt-get install -y apt-get install -y
gcc gcc
pkg-config pkg-config
libacl1-dev libacl1-dev
if: ${{ runner.os == 'Linux' }} if: ${{ runner.os == 'Linux' }}
- name: Build for Linux - name: Build for Linux
run: >- run: >-
sh -c "go build -v -ldflags '-s -w -X main.Version=${{ github.ref_name }}' -o bin/fortify && sh -c "go build -v -ldflags '-s -w -X main.Version=${{ github.ref_name }}' -o bin/fortify &&
sha256sum --tag -b bin/fortify > bin/fortify.sha256" sha256sum --tag -b bin/fortify > bin/fortify.sha256"
- 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

37
.gitea/workflows/test.yml Normal file
View File

@ -0,0 +1,37 @@
name: test
on:
- push
- pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup go
uses: https://github.com/actions/setup-go@v5
with:
go-version: '>=1.23.0'
- name: Get dependencies
run: >-
apt-get update &&
apt-get install -y
gcc
pkg-config
libacl1-dev
if: ${{ runner.os == 'Linux' }}
- name: Run tests
run: >-
go test ./...
- name: Build for Linux
run: >-
sh -c "go build -v -ldflags '-s -w -X main.Version=${{ github.ref_name }}' -o bin/fortify &&
sha256sum --tag -b bin/fortify > bin/fortify.sha256"

View File

@ -1,9 +1,9 @@
Fortify Fortify
======= =======
[![Go Reference](https://pkg.go.dev/badge/git.ophivana.moe/cat/fortify.svg)](https://pkg.go.dev/git.ophivana.moe/cat/fortify) [![Go Reference](https://pkg.go.dev/badge/git.ophivana.moe/security/fortify.svg)](https://pkg.go.dev/git.ophivana.moe/security/fortify)
Lets you run graphical applications as another user ~~in an Android-like sandbox environment~~ (WIP) 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.
Why would you want this? Why would you want this?
@ -12,7 +12,7 @@ Why would you want this?
- It protects applications from each other. - It protects applications from each other.
- It provides UID isolation on top of ~~the standard application sandbox~~ (WIP). - It provides UID isolation on top of the standard application sandbox.
There are a few different things to set up for this to work: There are a few different things to set up for this to work:
@ -26,7 +26,7 @@ There are a few different things to set up for this to work:
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/cat/fortify -- -h nix run git+https://git.ophivana.moe/security/fortify -- -h
``` ```
## Module usage ## Module usage
@ -41,7 +41,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/cat/fortify"; url = "git+https://git.ophivana.moe/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";
@ -69,7 +69,6 @@ This adds the `environment.fortify` option:
environment.fortify = { environment.fortify = {
enable = true; enable = true;
user = "nixos"; user = "nixos";
shell = "zsh";
stateDir = "/var/lib/persist/module"; stateDir = "/var/lib/persist/module";
target = { target = {
chronos = { chronos = {
@ -141,8 +140,6 @@ This adds the `environment.fortify` option:
* `user` specifies the privileged user with access to fortified applications. * `user` specifies the privileged user with access to fortified applications.
* `shell` is the shell used to run the launch command, required for sourcing the home-manager environment.
* `stateDir` is the path to your persistent storage location. It is directly passed through to the impermanence module. * `stateDir` is the path to your persistent storage location. It is directly passed through to the impermanence module.
* `target` is an attribute set of submodules, where the attribute name is the username of the unprivileged target user. * `target` is an attribute set of submodules, where the attribute name is the username of the unprivileged target user.

View File

@ -25,7 +25,25 @@ const (
Other = C.ACL_OTHER Other = C.ACL_OTHER
) )
type Perm C.acl_perm_t 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 { func UpdatePerm(path string, uid int, perms ...Perm) error {
// read acl from file // read acl from file

140
cmd/fsu/main.go Normal file
View File

@ -0,0 +1,140 @@
package main
import (
"bufio"
"log"
"os"
"path"
"strconv"
"strings"
"syscall"
)
const (
fsuConfFile = "/etc/fsurc"
envShim = "FORTIFY_SHIM"
envAID = "FORTIFY_APP_ID"
fpPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
)
// FortifyPath is the path to fortify, set at compile time.
var FortifyPath = fpPoison
func main() {
log.SetFlags(0)
log.SetPrefix("fsu: ")
log.SetOutput(os.Stderr)
if os.Geteuid() != 0 {
log.Fatal("this program must be owned by uid 0 and have the setuid bit set")
}
puid := os.Getuid()
if puid == 0 {
log.Fatal("this program must not be started by root")
}
// validate compiled in fortify path
if FortifyPath == fpPoison || !path.IsAbs(FortifyPath) {
log.Fatal("invalid fortify path, this copy of fsu is not compiled correctly")
}
pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
if p, err := os.Readlink(pexe); err != nil {
log.Fatalf("cannot read parent executable path: %v", err)
} else if strings.HasSuffix(p, " (deleted)") {
log.Fatal("fortify executable has been deleted")
} else if p != FortifyPath {
log.Fatal("this program must be started by fortify")
}
// uid = 1000000 +
// fid * 10000 +
// aid
uid := 1000000
// authenticate before accepting user input
if fid, ok := parseConfig(fsuConfFile, puid); !ok {
log.Fatalf("uid %d is not in the fsurc file", puid)
} else {
uid += fid * 10000
}
// pass through setup path to shim
var shimSetupPath string
if s, ok := os.LookupEnv(envShim); !ok {
log.Fatal("FORTIFY_SHIM not set")
} else if !path.IsAbs(s) {
log.Fatal("FORTIFY_SHIM is not absolute")
} else {
shimSetupPath = s
}
// allowed aid range 0 to 9999
if as, ok := os.LookupEnv(envAID); !ok {
log.Fatal("FORTIFY_APP_ID not set")
} else if aid, err := strconv.Atoi(as); err != nil || aid < 0 || aid > 9999 {
log.Fatal("invalid aid")
} else {
uid += aid
}
if err := syscall.Setresgid(uid, uid, uid); err != nil {
log.Fatalf("cannot set gid: %v", err)
}
if err := syscall.Setresuid(uid, uid, uid); err != nil {
log.Fatalf("cannot set uid: %v", err)
}
if err := syscall.Exec(FortifyPath, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupPath}); err != nil {
log.Fatalf("cannot start shim: %v", err)
}
panic("unreachable")
}
func parseConfig(p string, puid int) (fid int, ok bool) {
// refuse to run if fsurc is not protected correctly
if s, err := os.Stat(p); err != nil {
log.Fatal(err)
} else if s.Mode().Perm() != 0400 {
log.Fatal("bad fsurc perm")
} else if st := s.Sys().(*syscall.Stat_t); st.Uid != 0 || st.Gid != 0 {
log.Fatal("fsurc must be owned by uid 0")
}
if r, err := os.Open(p); err != nil {
log.Fatal(err)
return -1, false
} else {
s := bufio.NewScanner(r)
var line int
for s.Scan() {
line++
// <puid> <fid>
lf := strings.SplitN(s.Text(), " ", 2)
if len(lf) != 2 {
log.Fatalf("invalid entry on line %d", line)
}
var puid0 int
if puid0, err = strconv.Atoi(lf[0]); err != nil || puid0 < 1 {
log.Fatalf("invalid parent uid on line %d", line)
}
ok = puid0 == puid
if ok {
// allowed fid range 0 to 99
if fid, err = strconv.Atoi(lf[1]); err != nil || fid < 0 || fid > 99 {
log.Fatalf("invalid fortify uid on line %d", line)
}
return
}
}
if err = s.Err(); err != nil {
log.Fatalf("cannot read fsurc: %v", err)
}
return -1, false
}
}

135
config.go Normal file
View File

@ -0,0 +1,135 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/system"
)
var (
printTemplate bool
confPath string
dbusConfigSession string
dbusConfigSystem string
dbusID string
mpris bool
dbusVerbose bool
userName string
enablements [system.ELen]bool
launchMethodText string
)
func init() {
flag.BoolVar(&printTemplate, "template", false, "Print a full config template and exit")
// config file, disables every other flag here
flag.StringVar(&confPath, "c", "nil", "Path to full app configuration, or \"nil\" to configure from flags")
flag.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults")
flag.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable")
flag.StringVar(&dbusID, "dbus-id", "", "D-Bus ID of application, leave empty to disable own paths, has no effect if custom config is available")
flag.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available")
flag.BoolVar(&dbusVerbose, "dbus-log", false, "Force logging in the D-Bus proxy")
flag.StringVar(&userName, "u", "chronos", "Passwd name of user to run as")
flag.BoolVar(&enablements[system.EWayland], "wayland", false, "Share Wayland socket")
flag.BoolVar(&enablements[system.EX11], "X", false, "Share X11 socket and allow connection")
flag.BoolVar(&enablements[system.EDBus], "dbus", false, "Proxy D-Bus connection")
flag.BoolVar(&enablements[system.EPulse], "pulse", false, "Share PulseAudio socket and cookie")
}
func init() {
methodHelpString := "Method of launching the child process, can be one of \"sudo\""
if os.SdBooted() {
methodHelpString += ", \"systemd\""
}
flag.StringVar(&launchMethodText, "method", "sudo", methodHelpString)
}
func tryTemplate() {
if printTemplate {
if s, err := json.MarshalIndent(app.Template(), "", " "); err != nil {
fmsg.Fatalf("cannot generate template: %v", err)
panic("unreachable")
} else {
fmt.Println(string(s))
}
fmsg.Exit(0)
}
}
func loadConfig() *app.Config {
if confPath == "nil" {
// config from flags
return configFromFlags()
} else {
// config from file
c := new(app.Config)
if f, err := os.Open(confPath); err != nil {
fmsg.Fatalf("cannot access config file %q: %s", confPath, err)
panic("unreachable")
} else if err = json.NewDecoder(f).Decode(&c); err != nil {
fmsg.Fatalf("cannot parse config file %q: %s", confPath, err)
panic("unreachable")
} else {
return c
}
}
}
func configFromFlags() (config *app.Config) {
// initialise config from flags
config = &app.Config{
ID: dbusID,
User: userName,
Command: flag.Args(),
Method: launchMethodText,
}
// enablements from flags
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if enablements[i] {
config.Confinement.Enablements.Set(i)
}
}
// parse D-Bus config file from flags if applicable
if enablements[system.EDBus] {
if dbusConfigSession == "builtin" {
config.Confinement.SessionBus = dbus.NewConfig(dbusID, true, mpris)
} else {
if c, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil {
fmsg.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err)
} else {
config.Confinement.SessionBus = c
}
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if c, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil {
fmsg.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err)
} else {
config.Confinement.SystemBus = c
}
}
// override log from configuration
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
}
}
return
}

View File

@ -9,7 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/cat/fortify/dbus" "git.ophivana.moe/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/cat/fortify/dbus" "git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/cat/fortify/helper" "git.ophivana.moe/security/fortify/helper"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
@ -137,6 +137,15 @@ func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
} }
}) })
t.Run("unsealed start of "+id, func(t *testing.T) {
want := "proxy not sealed"
if err := p.Start(nil, nil, sandbox); err == nil || err.Error() != want {
t.Errorf("Start() error = %v, wantErr %q",
err, errors.New(want))
return
}
})
t.Run("unsealed wait of "+id, func(t *testing.T) { t.Run("unsealed wait of "+id, func(t *testing.T) {
wantErr := "proxy not started" wantErr := "proxy not started"
if err := p.Wait(); err == nil || err.Error() != wantErr { if err := p.Wait(); err == nil || err.Error() != wantErr {

View File

@ -6,8 +6,8 @@ import (
"io" "io"
"sync" "sync"
"git.ophivana.moe/cat/fortify/helper" "git.ophivana.moe/security/fortify/helper"
"git.ophivana.moe/cat/fortify/helper/bwrap" "git.ophivana.moe/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.
@ -28,6 +28,21 @@ type Proxy struct {
lock sync.RWMutex lock sync.RWMutex
} }
func (p *Proxy) Session() [2]string {
return p.session
}
func (p *Proxy) System() [2]string {
return p.system
}
func (p *Proxy) Sealed() bool {
p.lock.RLock()
defer p.lock.RUnlock()
return p.seal != nil
}
var ( var (
ErrConfig = errors.New("no configuration to seal") ErrConfig = errors.New("no configuration to seal")
) )

View File

@ -9,9 +9,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.ophivana.moe/cat/fortify/helper" "git.ophivana.moe/security/fortify/helper"
"git.ophivana.moe/cat/fortify/helper/bwrap" "git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/cat/fortify/ldd" "git.ophivana.moe/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.
@ -79,11 +79,9 @@ func (p *Proxy) Start(ready chan error, output io.Writer, sandbox bool) error {
} }
} }
} }
bindTargetDedup := make([][2]string, 0, len(bindTarget))
for k := range bindTarget { for k := range bindTarget {
bindTargetDedup = append(bindTargetDedup, [2]string{k, k}) bc.Bind(k, k, false, true)
} }
bc.Bind = append(bc.Bind, bindTargetDedup...)
roBindTarget := make(map[string]struct{}, 2+1+len(proxyDeps)) roBindTarget := make(map[string]struct{}, 2+1+len(proxyDeps))
@ -103,11 +101,9 @@ func (p *Proxy) Start(ready chan error, output io.Writer, sandbox bool) error {
} }
} }
roBindTargetDedup := make([][2]string, 0, len(roBindTarget))
for k := range roBindTarget { for k := range roBindTarget {
roBindTargetDedup = append(roBindTargetDedup, [2]string{k, k}) bc.Bind(k, k)
} }
bc.ROBind = append(bc.ROBind, roBindTargetDedup...)
h = helper.MustNewBwrap(bc, p.seal, toolPath, argF) h = helper.MustNewBwrap(bc, p.seal, toolPath, argF)
cmd = h.Unwrap() cmd = h.Unwrap()

View File

@ -3,7 +3,7 @@ package dbus_test
import ( import (
"sync" "sync"
"git.ophivana.moe/cat/fortify/dbus" "git.ophivana.moe/security/fortify/dbus"
) )
var samples = []dbusTestCase{ var samples = []dbusTestCase{
@ -175,7 +175,6 @@ func testCaseGenerate() {
// inject nulls // inject nulls
fi := &testCasesV[len(samples)+i] fi := &testCasesV[len(samples)+i]
fi.wantErr = true fi.wantErr = true
fi.c = &*fi.c
injectNulls(&fi.c.See) injectNulls(&fi.c.See)
injectNulls(&fi.c.Talk) injectNulls(&fi.c.Talk)

View File

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

55
error.go Normal file
View File

@ -0,0 +1,55 @@
package main
import (
"errors"
"git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
func logWaitError(err error) {
var e *fmsg.BaseError
if !fmsg.AsBaseError(err, &e) {
fmsg.Println("wait failed:", err)
} else {
// Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError
var se *app.StateStoreError
if !errors.As(err, &se) {
// does not need special handling
fmsg.Print(e.Message())
} else {
// inner error are either unwrapped store errors
// or joined errors returned by *appSealTx revert
// wrapped in *app.BaseError
var ej app.RevertCompoundError
if !errors.As(se.InnerErr, &ej) {
// does not require special handling
fmsg.Print(e.Message())
} else {
errs := ej.Unwrap()
// every error here is wrapped in *app.BaseError
for _, ei := range errs {
var eb *fmsg.BaseError
if !errors.As(ei, &eb) {
// unreachable
fmsg.Println("invalid error type returned by revert:", ei)
} else {
// print inner *app.BaseError message
fmsg.Print(eb.Message())
}
}
}
}
}
}
func logBaseError(err error, message string) {
var e *fmsg.BaseError
if fmsg.AsBaseError(err, &e) {
fmsg.Print(e.Message())
} else {
fmsg.Println(message, err)
}
}

56
flag.go
View File

@ -1,56 +0,0 @@
package main
import (
"flag"
"git.ophivana.moe/cat/fortify/internal"
)
var (
userName string
confPath string
dbusConfigSession string
dbusConfigSystem string
dbusVerbose bool
dbusID string
mpris bool
mustWayland bool
mustX bool
mustDBus bool
mustPulse bool
flagVerbose bool
printVersion bool
launchMethodText string
)
func init() {
flag.StringVar(&userName, "u", "chronos", "Passwd name of user to run as")
flag.StringVar(&confPath, "c", "nil", "Path to full app configuration, or \"nil\" to configure from flags")
flag.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults")
flag.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable")
flag.BoolVar(&dbusVerbose, "dbus-log", false, "Enable logging in the D-Bus proxy")
flag.StringVar(&dbusID, "dbus-id", "", "D-Bus ID of application, leave empty to disable own paths, has no effect if custom config is available")
flag.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available")
flag.BoolVar(&mustWayland, "wayland", false, "Share Wayland socket")
flag.BoolVar(&mustX, "X", false, "Share X11 socket and allow connection")
flag.BoolVar(&mustDBus, "dbus", false, "Proxy D-Bus connection")
flag.BoolVar(&mustPulse, "pulse", false, "Share PulseAudio socket and cookie")
flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
flag.BoolVar(&printVersion, "V", false, "Print version")
}
func init() {
methodHelpString := "Method of launching the child process, can be one of \"sudo\""
if internal.SdBootedV {
methodHelpString += ", \"systemd\""
}
flag.StringVar(&launchMethodText, "method", "sudo", methodHelpString)
}

View File

@ -34,6 +34,10 @@
devShells = forAllSystems (system: { devShells = forAllSystems (system: {
default = nixpkgsFor.${system}.mkShell { default = nixpkgsFor.${system}.mkShell {
buildInputs = with nixpkgsFor.${system}; self.packages.${system}.fortify.buildInputs;
};
withPackage = nixpkgsFor.${system}.mkShell {
buildInputs = buildInputs =
with nixpkgsFor.${system}; with nixpkgsFor.${system};
self.packages.${system}.fortify.buildInputs ++ [ self.packages.${system}.fortify ]; self.packages.${system}.fortify.buildInputs ++ [ self.packages.${system}.fortify ];

2
go.mod
View File

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

View File

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

View File

@ -7,7 +7,7 @@ import (
"strconv" "strconv"
"sync" "sync"
"git.ophivana.moe/cat/fortify/helper/bwrap" "git.ophivana.moe/security/fortify/helper/bwrap"
) )
// BubblewrapName is the file name or path to bubblewrap. // BubblewrapName is the file name or path to bubblewrap.
@ -19,8 +19,6 @@ type bubblewrap struct {
// bwrap pipes // bwrap pipes
p *pipes p *pipes
// sealed bwrap config
config *bwrap.Config
// returns an array of arguments passed directly // returns an array of arguments passed directly
// to the child process spawned by bwrap // to the child process spawned by bwrap
argF func(argsFD, statFD int) []string argF func(argsFD, statFD int) []string

77
helper/bwrap/arg.go Normal file
View File

@ -0,0 +1,77 @@
package bwrap
import "encoding/gob"
type Builder interface {
Len() int
Append(args *[]string)
}
type FSBuilder interface {
Path() string
Builder
}
func init() {
gob.Register(new(pairF))
gob.Register(new(stringF))
}
type pairF [3]string
func (p *pairF) Path() string {
return p[2]
}
func (p *pairF) Len() int {
return len(p) // compiler replaces this with 3
}
func (p *pairF) Append(args *[]string) {
*args = append(*args, p[0], p[1], p[2])
}
type stringF [2]string
func (s stringF) Path() string {
return s[1]
}
func (s stringF) Len() int {
return len(s) // compiler replaces this with 2
}
func (s stringF) Append(args *[]string) {
*args = append(*args, s[0], s[1])
}
// Args returns a slice of bwrap args corresponding to c.
func (c *Config) Args() (args []string) {
builders := []Builder{
c.boolArgs(),
c.intArgs(),
c.stringArgs(),
c.pairArgs(),
}
// copy FSBuilder slice to builder slice
fb := make([]Builder, len(c.Filesystem)+1)
for i, f := range c.Filesystem {
fb[i] = f
}
fb[len(fb)-1] = c.Chmod
builders = append(builders, fb...)
// accumulate arg count
argc := 0
for _, b := range builders {
argc += b.Len()
}
args = make([]string, 0, argc)
for _, b := range builders {
b.Append(&args)
}
return
}

View File

@ -0,0 +1,13 @@
package bwrap
const (
Tmpfs = iota
Dir
Symlink
)
var awkwardArgs = [...]string{
Tmpfs: "--tmpfs",
Dir: "--dir",
Symlink: "--symlink",
}

View File

@ -0,0 +1,81 @@
package bwrap
const (
UnshareAll = iota
UnshareUser
UnshareIPC
UnsharePID
UnshareNet
UnshareUTS
UnshareCGroup
ShareNet
UserNS
Clearenv
NewSession
DieWithParent
AsInit
)
var boolArgs = [...][]string{
UnshareAll: {"--unshare-all", "--unshare-user"},
UnshareUser: {"--unshare-user"},
UnshareIPC: {"--unshare-ipc"},
UnsharePID: {"--unshare-pid"},
UnshareNet: {"--unshare-net"},
UnshareUTS: {"--unshare-uts"},
UnshareCGroup: {"--unshare-cgroup"},
ShareNet: {"--share-net"},
UserNS: {"--disable-userns", "--assert-userns-disabled"},
Clearenv: {"--clearenv"},
NewSession: {"--new-session"},
DieWithParent: {"--die-with-parent"},
AsInit: {"--as-pid-1"},
}
func (c *Config) boolArgs() Builder {
b := boolArg{
UserNS: !c.UserNS,
Clearenv: c.Clearenv,
NewSession: c.NewSession,
DieWithParent: c.DieWithParent,
AsInit: c.AsInit,
}
if c.Unshare == nil {
b[UnshareAll] = true
b[ShareNet] = c.Net
} else {
b[UnshareUser] = c.Unshare.User
b[UnshareIPC] = c.Unshare.IPC
b[UnsharePID] = c.Unshare.PID
b[UnshareNet] = c.Unshare.Net
b[UnshareUTS] = c.Unshare.UTS
b[UnshareCGroup] = c.Unshare.CGroup
}
return &b
}
type boolArg [len(boolArgs)]bool
func (b *boolArg) Len() (l int) {
for i, v := range b {
if v {
l += len(boolArgs[i])
}
}
return
}
func (b *boolArg) Append(args *[]string) {
for i, v := range b {
if v {
*args = append(*args, boolArgs[i]...)
}
}
}

View File

@ -0,0 +1,47 @@
package bwrap
import "strconv"
const (
UID = iota
GID
Perms
Size
)
var intArgs = [...]string{
UID: "--uid",
GID: "--gid",
Perms: "--perms",
Size: "--size",
}
func (c *Config) intArgs() Builder {
// Arg types:
// Perms
// are handled by the sequential builder
return &intArg{
UID: c.UID,
GID: c.GID,
}
}
type intArg [len(intArgs)]*int
func (n *intArg) Len() (l int) {
for _, v := range n {
if v != nil {
l += 2
}
}
return
}
func (n *intArg) Append(args *[]string) {
for i, v := range n {
if v != nil {
*args = append(*args, intArgs[i], strconv.Itoa(*v))
}
}
}

View File

@ -0,0 +1,73 @@
package bwrap
import (
"slices"
)
const (
SetEnv = iota
Bind
BindTry
DevBind
DevBindTry
ROBind
ROBindTry
Chmod
)
var pairArgs = [...]string{
SetEnv: "--setenv",
Bind: "--bind",
BindTry: "--bind-try",
DevBind: "--dev-bind",
DevBindTry: "--dev-bind-try",
ROBind: "--ro-bind",
ROBindTry: "--ro-bind-try",
Chmod: "--chmod",
}
func (c *Config) pairArgs() Builder {
var n pairArg
n[SetEnv] = make([][2]string, len(c.SetEnv))
keys := make([]string, 0, len(c.SetEnv))
for k := range c.SetEnv {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
n[SetEnv][i] = [2]string{k, c.SetEnv[k]}
}
// Arg types:
// Bind
// BindTry
// DevBind
// DevBindTry
// ROBind
// ROBindTry
// Chmod
// are handled by the sequential builder
return &n
}
type pairArg [len(pairArgs)][][2]string
func (p *pairArg) Len() (l int) {
for _, v := range p {
l += len(v) * 3
}
return
}
func (p *pairArg) Append(args *[]string) {
for i, arg := range p {
for _, v := range arg {
*args = append(*args, pairArgs[i], v[0], v[1])
}
}
}

View File

@ -0,0 +1,65 @@
package bwrap
const (
Hostname = iota
Chdir
UnsetEnv
LockFile
RemountRO
Procfs
DevTmpfs
Mqueue
)
var stringArgs = [...]string{
Hostname: "--hostname",
Chdir: "--chdir",
UnsetEnv: "--unsetenv",
LockFile: "--lock-file",
RemountRO: "--remount-ro",
Procfs: "--proc",
DevTmpfs: "--dev",
Mqueue: "--mqueue",
}
func (c *Config) stringArgs() Builder {
n := stringArg{
UnsetEnv: c.UnsetEnv,
LockFile: c.LockFile,
}
if c.Hostname != "" {
n[Hostname] = []string{c.Hostname}
}
if c.Chdir != "" {
n[Chdir] = []string{c.Chdir}
}
// Arg types:
// RemountRO
// Procfs
// DevTmpfs
// Mqueue
// are handled by the sequential builder
return &n
}
type stringArg [len(stringArgs)][]string
func (s *stringArg) Len() (l int) {
for _, arg := range s {
l += len(arg) * 2
}
return
}
func (s *stringArg) Append(args *[]string) {
for i, arg := range s {
for _, v := range arg {
*args = append(*args, stringArgs[i], v)
}
}
}

View File

@ -1,64 +0,0 @@
package bwrap
const (
UnshareAll = iota
UnshareUser
UnshareIPC
UnsharePID
UnshareNet
UnshareUTS
UnshareCGroup
ShareNet
UserNS
Clearenv
NewSession
DieWithParent
AsInit
boolC
)
var boolArgs = func() (b [boolC][]string) {
b[UnshareAll] = []string{"--unshare-all", "--unshare-user"}
b[UnshareUser] = []string{"--unshare-user"}
b[UnshareIPC] = []string{"--unshare-ipc"}
b[UnsharePID] = []string{"--unshare-pid"}
b[UnshareNet] = []string{"--unshare-net"}
b[UnshareUTS] = []string{"--unshare-uts"}
b[UnshareCGroup] = []string{"--unshare-cgroup"}
b[ShareNet] = []string{"--share-net"}
b[UserNS] = []string{"--disable-userns", "--assert-userns-disabled"}
b[Clearenv] = []string{"--clearenv"}
b[NewSession] = []string{"--new-session"}
b[DieWithParent] = []string{"--die-with-parent"}
b[AsInit] = []string{"--as-pid-1"}
return
}()
func (c *Config) boolArgs() (b [boolC]bool) {
if c.Unshare == nil {
b[UnshareAll] = true
b[ShareNet] = c.Net
} else {
b[UnshareUser] = c.Unshare.User
b[UnshareIPC] = c.Unshare.IPC
b[UnsharePID] = c.Unshare.PID
b[UnshareNet] = c.Unshare.Net
b[UnshareUTS] = c.Unshare.UTS
b[UnshareCGroup] = c.Unshare.CGroup
}
b[UserNS] = !c.UserNS
b[Clearenv] = c.Clearenv
b[NewSession] = c.NewSession
b[DieWithParent] = c.DieWithParent
b[AsInit] = c.AsInit
return
}

View File

@ -1,63 +1,14 @@
package bwrap package bwrap
import ( import (
"encoding/gob"
"os" "os"
"strconv" "strconv"
) )
func (c *Config) Args() (args []string) { func init() {
b := c.boolArgs() gob.Register(new(PermConfig[SymlinkConfig]))
n := c.intArgs() gob.Register(new(PermConfig[*TmpfsConfig]))
s := c.stringArgs()
p := c.pairArgs()
g := c.interfaceArgs()
argc := 0
for i, arg := range b {
if arg {
argc += len(boolArgs[i])
}
}
for _, arg := range n {
if arg != nil {
argc += 2
}
}
for _, arg := range s {
argc += len(arg) * 2
}
for _, arg := range p {
argc += len(arg) * 3
}
args = make([]string, 0, argc)
for i, arg := range b {
if arg {
args = append(args, boolArgs[i]...)
}
}
for i, arg := range n {
if arg != nil {
args = append(args, intArgs[i], strconv.Itoa(*arg))
}
}
for i, arg := range s {
for _, v := range arg {
args = append(args, stringArgs[i], v)
}
}
for i, arg := range p {
for _, v := range arg {
args = append(args, pairArgs[i], v[0], v[1])
}
}
for i, arg := range g {
for _, v := range arg {
args = append(args, v.Value(interfaceArgs[i])...)
}
}
return
} }
type Config struct { type Config struct {
@ -100,53 +51,12 @@ type Config struct {
// (--lock-file DEST) // (--lock-file DEST)
LockFile []string `json:"lock_file,omitempty"` LockFile []string `json:"lock_file,omitempty"`
// bind mount host path on sandbox // ordered filesystem args
// (--bind SRC DEST) Filesystem []FSBuilder
Bind [][2]string `json:"bind,omitempty"`
// equal to Bind but ignores non-existent host path
// (--bind-try SRC DEST)
BindTry [][2]string `json:"bind_try,omitempty"`
// bind mount host path on sandbox, allowing device access
// (--dev-bind SRC DEST)
DevBind [][2]string `json:"dev_bind,omitempty"`
// equal to DevBind but ignores non-existent host path
// (--dev-bind-try SRC DEST)
DevBindTry [][2]string `json:"dev_bind_try,omitempty"`
// bind mount host path readonly on sandbox
// (--ro-bind SRC DEST)
ROBind [][2]string `json:"ro_bind,omitempty"`
// equal to ROBind but ignores non-existent host path
// (--ro-bind-try SRC DEST)
ROBindTry [][2]string `json:"ro_bind_try,omitempty"`
// remount path as readonly; does not recursively remount
// (--remount-ro DEST)
RemountRO []string `json:"remount_ro,omitempty"`
// mount new procfs in sandbox
// (--proc DEST)
Procfs []PermConfig[string] `json:"proc,omitempty"`
// mount new dev in sandbox
// (--dev DEST)
DevTmpfs []PermConfig[string] `json:"dev,omitempty"`
// mount new tmpfs in sandbox
// (--tmpfs DEST)
Tmpfs []PermConfig[TmpfsConfig] `json:"tmpfs,omitempty"`
// mount new mqueue in sandbox
// (--mqueue DEST)
Mqueue []PermConfig[string] `json:"mqueue,omitempty"`
// create dir in sandbox
// (--dir DEST)
Dir []PermConfig[string] `json:"dir,omitempty"`
// create symlink within sandbox
// (--symlink SRC DEST)
Symlink []PermConfig[[2]string] `json:"symlink,omitempty"`
// change permissions (must already exist) // change permissions (must already exist)
// (--chmod OCTAL PATH) // (--chmod OCTAL PATH)
Chmod map[string]os.FileMode `json:"chmod,omitempty"` Chmod ChmodConfig `json:"chmod,omitempty"`
// create a new terminal session // create a new terminal session
// (--new-session) // (--new-session)
@ -203,6 +113,34 @@ type UnshareConfig struct {
CGroup bool `json:"cgroup"` CGroup bool `json:"cgroup"`
} }
type PermConfig[T FSBuilder] struct {
// set permissions of next argument
// (--perms OCTAL)
Mode *os.FileMode `json:"mode,omitempty"`
// path to get the new permission
// (--bind-data, --file, etc.)
Inner T `json:"path"`
}
func (p *PermConfig[T]) Path() string {
return p.Inner.Path()
}
func (p *PermConfig[T]) Len() int {
if p.Mode != nil {
return p.Inner.Len() + 2
} else {
return p.Inner.Len()
}
}
func (p *PermConfig[T]) Append(args *[]string) {
if p.Mode != nil {
*args = append(*args, intArgs[Perms], strconv.FormatInt(int64(*p.Mode), 8))
}
p.Inner.Append(args)
}
type TmpfsConfig struct { type TmpfsConfig struct {
// set size of tmpfs // set size of tmpfs
// (--size BYTES) // (--size BYTES)
@ -212,54 +150,47 @@ type TmpfsConfig struct {
Dir string `json:"dir"` Dir string `json:"dir"`
} }
type argOf interface { func (t *TmpfsConfig) Path() string {
Value(arg string) (args []string) return t.Dir
} }
func copyToArgOfSlice[T [2]string | string | TmpfsConfig](src []PermConfig[T]) (dst []argOf) { func (t *TmpfsConfig) Len() int {
dst = make([]argOf, len(src)) if t.Size > 0 {
for i, arg := range src { return 4
dst[i] = arg
}
return
}
type PermConfig[T [2]string | string | TmpfsConfig] struct {
// set permissions of next argument
// (--perms OCTAL)
Mode *os.FileMode `json:"mode,omitempty"`
// path to get the new permission
// (--bind-data, --file, etc.)
Path T
}
func (p PermConfig[T]) Value(arg string) (args []string) {
// max possible size
if p.Mode != nil {
args = make([]string, 0, 6)
args = append(args, "--perms", strconv.Itoa(int(*p.Mode)))
} else { } else {
args = make([]string, 0, 4) return 2
} }
}
switch v := any(p.Path).(type) {
case string: func (t *TmpfsConfig) Append(args *[]string) {
args = append(args, arg, v) if t.Size > 0 {
return *args = append(*args, intArgs[Size], strconv.Itoa(t.Size))
case [2]string: }
args = append(args, arg, v[0], v[1]) *args = append(*args, awkwardArgs[Tmpfs], t.Dir)
return }
case TmpfsConfig:
if arg != "--tmpfs" { type SymlinkConfig [2]string
panic("unreachable")
} func (s SymlinkConfig) Path() string {
return s[1]
if v.Size > 0 { }
args = append(args, "--size", strconv.Itoa(v.Size))
} func (s SymlinkConfig) Len() int {
args = append(args, arg, v.Dir) return 3
return }
default:
panic("unreachable") func (s SymlinkConfig) Append(args *[]string) {
*args = append(*args, awkwardArgs[Symlink], s[0], s[1])
}
type ChmodConfig map[string]os.FileMode
func (c ChmodConfig) Len() int {
return len(c)
}
func (c ChmodConfig) Append(args *[]string) {
for path, mode := range c {
*args = append(*args, pairArgs[Chmod], strconv.FormatInt(int64(mode), 8), path)
} }
} }

View File

@ -1,22 +0,0 @@
package bwrap
const (
UID = iota
GID
intC
)
var intArgs = func() (n [intC]string) {
n[UID] = "--uid"
n[GID] = "--gid"
return
}()
func (c *Config) intArgs() (n [intC]*int) {
n[UID] = c.UID
n[GID] = c.GID
return
}

View File

@ -1,34 +0,0 @@
package bwrap
const (
Procfs = iota
DevTmpfs
Tmpfs
Mqueue
Dir
Symlink
interfaceC
)
var interfaceArgs = func() (g [interfaceC]string) {
g[Procfs] = "--proc"
g[DevTmpfs] = "--dev"
g[Tmpfs] = "--tmpfs"
g[Mqueue] = "--mqueue"
g[Dir] = "--dir"
g[Symlink] = "--symlink"
return
}()
func (c *Config) interfaceArgs() (g [interfaceC][]argOf) {
g[Procfs] = copyToArgOfSlice(c.Procfs)
g[DevTmpfs] = copyToArgOfSlice(c.DevTmpfs)
g[Tmpfs] = copyToArgOfSlice(c.Tmpfs)
g[Mqueue] = copyToArgOfSlice(c.Mqueue)
g[Dir] = copyToArgOfSlice(c.Dir)
g[Symlink] = copyToArgOfSlice(c.Symlink)
return
}

View File

@ -1,54 +0,0 @@
package bwrap
import "strconv"
const (
SetEnv = iota
Bind
BindTry
DevBind
DevBindTry
ROBind
ROBindTry
Chmod
pairC
)
var pairArgs = func() (n [pairC]string) {
n[SetEnv] = "--setenv"
n[Bind] = "--bind"
n[BindTry] = "--bind-try"
n[DevBind] = "--dev-bind"
n[DevBindTry] = "--dev-bind-try"
n[ROBind] = "--ro-bind"
n[ROBindTry] = "--ro-bind-try"
n[Chmod] = "--chmod"
return
}()
func (c *Config) pairArgs() (n [pairC][][2]string) {
n[SetEnv] = make([][2]string, 0, len(c.SetEnv))
for k, v := range c.SetEnv {
n[SetEnv] = append(n[SetEnv], [2]string{k, v})
}
n[Bind] = c.Bind
n[BindTry] = c.BindTry
n[DevBind] = c.DevBind
n[DevBindTry] = c.DevBindTry
n[ROBind] = c.ROBind
n[ROBindTry] = c.ROBindTry
n[Chmod] = make([][2]string, 0, len(c.Chmod))
for path, octal := range c.Chmod {
n[Chmod] = append(n[Chmod], [2]string{strconv.Itoa(int(octal)), path})
}
return
}

138
helper/bwrap/config.set.go Normal file
View File

@ -0,0 +1,138 @@
package bwrap
import "os"
/*
Bind binds mount src on host to dest in sandbox.
Bind(src, dest) bind mount host path readonly on sandbox
(--ro-bind SRC DEST).
Bind(src, dest, true) equal to ROBind but ignores non-existent host path
(--ro-bind-try SRC DEST).
Bind(src, dest, false, true) bind mount host path on sandbox.
(--bind SRC DEST).
Bind(src, dest, true, true) equal to Bind but ignores non-existent host path
(--bind-try SRC DEST).
Bind(src, dest, false, true, true) bind mount host path on sandbox, allowing device access
(--dev-bind SRC DEST).
Bind(src, dest, true, true, true) equal to DevBind but ignores non-existent host path
(--dev-bind-try SRC DEST).
*/
func (c *Config) Bind(src, dest string, opts ...bool) *Config {
var (
try bool
write bool
dev bool
)
if len(opts) > 0 {
try = opts[0]
}
if len(opts) > 1 {
write = opts[1]
}
if len(opts) > 2 {
dev = opts[2]
}
if dev {
if try {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[DevBindTry], src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[DevBind], src, dest})
}
return c
} else if write {
if try {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[BindTry], src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[Bind], src, dest})
}
return c
} else {
if try {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[ROBindTry], src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[ROBind], src, dest})
}
return c
}
}
// RemountRO remount path as readonly; does not recursively remount
// (--remount-ro DEST)
func (c *Config) RemountRO(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[RemountRO], dest})
return c
}
// Procfs mount new procfs in sandbox
// (--proc DEST)
func (c *Config) Procfs(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[Procfs], dest})
return c
}
// DevTmpfs mount new dev in sandbox
// (--dev DEST)
func (c *Config) DevTmpfs(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[DevTmpfs], dest})
return c
}
// Tmpfs mount new tmpfs in sandbox
// (--tmpfs DEST)
func (c *Config) Tmpfs(dest string, size int, perm ...os.FileMode) *Config {
tmpfs := &PermConfig[*TmpfsConfig]{Inner: &TmpfsConfig{Dir: dest}}
if size >= 0 {
tmpfs.Inner.Size = size
}
if len(perm) == 1 {
tmpfs.Mode = &perm[0]
}
c.Filesystem = append(c.Filesystem, tmpfs)
return c
}
// Mqueue mount new mqueue in sandbox
// (--mqueue DEST)
func (c *Config) Mqueue(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[Mqueue], dest})
return c
}
// Dir create dir in sandbox
// (--dir DEST)
func (c *Config) Dir(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[Dir], dest})
return c
}
// Symlink create symlink within sandbox
// (--symlink SRC DEST)
func (c *Config) Symlink(src, dest string, perm ...os.FileMode) *Config {
symlink := &PermConfig[SymlinkConfig]{Inner: SymlinkConfig{src, dest}}
if len(perm) == 1 {
symlink.Mode = &perm[0]
}
c.Filesystem = append(c.Filesystem, symlink)
return c
}
// SetUID sets custom uid in the sandbox, requires new user namespace (--uid UID).
func (c *Config) SetUID(uid int) *Config {
if uid >= 0 {
c.UID = &uid
}
return c
}
// SetGID sets custom gid in the sandbox, requires new user namespace (--gid GID).
func (c *Config) SetGID(gid int) *Config {
if gid >= 0 {
c.GID = &gid
}
return c
}

View File

@ -1,35 +0,0 @@
package bwrap
const (
Hostname = iota
Chdir
UnsetEnv
LockFile
RemountRO
stringC
)
var stringArgs = func() (n [stringC]string) {
n[Hostname] = "--hostname"
n[Chdir] = "--chdir"
n[UnsetEnv] = "--unsetenv"
n[LockFile] = "--lock-file"
n[RemountRO] = "--remount-ro"
return
}()
func (c *Config) stringArgs() (n [stringC][]string) {
if c.Hostname != "" {
n[Hostname] = []string{c.Hostname}
}
if c.Chdir != "" {
n[Chdir] = []string{c.Chdir}
}
n[UnsetEnv] = c.UnsetEnv
n[LockFile] = c.LockFile
n[RemountRO] = c.RemountRO
return
}

View File

@ -13,47 +13,49 @@ func TestConfig_Args(t *testing.T) {
}{ }{
{ {
name: "xdg-dbus-proxy constraint sample", name: "xdg-dbus-proxy constraint sample",
conf: &Config{ conf: (&Config{
Unshare: nil, Unshare: nil,
UserNS: false, UserNS: false,
Clearenv: true, Clearenv: true,
Symlink: []PermConfig[[2]string]{
{Path: [2]string{"usr/bin", "/bin"}},
{Path: [2]string{"var/home", "/home"}},
{Path: [2]string{"usr/lib", "/lib"}},
{Path: [2]string{"usr/lib64", "/lib64"}},
{Path: [2]string{"run/media", "/media"}},
{Path: [2]string{"var/mnt", "/mnt"}},
{Path: [2]string{"var/opt", "/opt"}},
{Path: [2]string{"sysroot/ostree", "/ostree"}},
{Path: [2]string{"var/roothome", "/root"}},
{Path: [2]string{"usr/sbin", "/sbin"}},
{Path: [2]string{"var/srv", "/srv"}},
},
Bind: [][2]string{
{"/run", "/run"},
{"/tmp", "/tmp"},
{"/var", "/var"},
{"/run/user/1971/.dbus-proxy/", "/run/user/1971/.dbus-proxy/"},
},
ROBind: [][2]string{
{"/boot", "/boot"},
{"/dev", "/dev"},
{"/proc", "/proc"},
{"/sys", "/sys"},
{"/sysroot", "/sysroot"},
{"/usr", "/usr"},
{"/etc", "/etc"},
},
DieWithParent: true, DieWithParent: true,
}, }).
Symlink("usr/bin", "/bin").
Symlink("var/home", "/home").
Symlink("usr/lib", "/lib").
Symlink("usr/lib64", "/lib64").
Symlink("run/media", "/media").
Symlink("var/mnt", "/mnt").
Symlink("var/opt", "/opt").
Symlink("sysroot/ostree", "/ostree").
Symlink("var/roothome", "/root").
Symlink("usr/sbin", "/sbin").
Symlink("var/srv", "/srv").
Bind("/run", "/run", false, true).
Bind("/tmp", "/tmp", false, true).
Bind("/var", "/var", false, true).
Bind("/run/user/1971/.dbus-proxy/", "/run/user/1971/.dbus-proxy/", false, true).
Bind("/boot", "/boot").
Bind("/dev", "/dev").
Bind("/proc", "/proc").
Bind("/sys", "/sys").
Bind("/sysroot", "/sysroot").
Bind("/usr", "/usr").
Bind("/etc", "/etc"),
want: []string{ want: []string{
"--unshare-all", "--unshare-all", "--unshare-user",
"--unshare-user", "--disable-userns", "--assert-userns-disabled",
"--disable-userns", "--clearenv", "--die-with-parent",
"--assert-userns-disabled", "--symlink", "usr/bin", "/bin",
"--clearenv", "--symlink", "var/home", "/home",
"--die-with-parent", "--symlink", "usr/lib", "/lib",
"--symlink", "usr/lib64", "/lib64",
"--symlink", "run/media", "/media",
"--symlink", "var/mnt", "/mnt",
"--symlink", "var/opt", "/opt",
"--symlink", "sysroot/ostree", "/ostree",
"--symlink", "var/roothome", "/root",
"--symlink", "usr/sbin", "/sbin",
"--symlink", "var/srv", "/srv",
"--bind", "/run", "/run", "--bind", "/run", "/run",
"--bind", "/tmp", "/tmp", "--bind", "/tmp", "/tmp",
"--bind", "/var", "/var", "--bind", "/var", "/var",
@ -65,17 +67,149 @@ func TestConfig_Args(t *testing.T) {
"--ro-bind", "/sysroot", "/sysroot", "--ro-bind", "/sysroot", "/sysroot",
"--ro-bind", "/usr", "/usr", "--ro-bind", "/usr", "/usr",
"--ro-bind", "/etc", "/etc", "--ro-bind", "/etc", "/etc",
"--symlink", "usr/bin", "/bin", },
"--symlink", "var/home", "/home", },
"--symlink", "usr/lib", "/lib", {
"--symlink", "usr/lib64", "/lib64", name: "fortify permissive default nixos",
"--symlink", "run/media", "/media", conf: (&Config{
"--symlink", "var/mnt", "/mnt", Unshare: nil,
"--symlink", "var/opt", "/opt", Net: true,
"--symlink", "sysroot/ostree", "/ostree", UserNS: true,
"--symlink", "var/roothome", "/root", Clearenv: true,
"--symlink", "usr/sbin", "/sbin", SetEnv: map[string]string{
"--symlink", "var/srv", "/srv"}, "HOME": "/home/chronos",
"TERM": "xterm-256color",
"FORTIFY_INIT": "3",
"XDG_RUNTIME_DIR": "/run/user/150",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty",
"SHELL": "/run/current-system/sw/bin/zsh",
"USER": "chronos",
},
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/etc", "/etc", false, true).
Bind("/home", "/home", false, true).
Bind("/lib", "/lib", false, true).
Bind("/lib64", "/lib64", false, true).
Bind("/nix", "/nix", false, true).
Bind("/root", "/root", false, true).
Bind("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/run/NetworkManager", "/run/NetworkManager", false, true).
Bind("/run/agetty.reload", "/run/agetty.reload", false, true).
Bind("/run/binfmt", "/run/binfmt", false, true).
Bind("/run/booted-system", "/run/booted-system", false, true).
Bind("/run/credentials", "/run/credentials", false, true).
Bind("/run/cryptsetup", "/run/cryptsetup", false, true).
Bind("/run/current-system", "/run/current-system", false, true).
Bind("/run/host", "/run/host", false, true).
Bind("/run/keys", "/run/keys", false, true).
Bind("/run/libvirt", "/run/libvirt", false, true).
Bind("/run/libvirtd.pid", "/run/libvirtd.pid", false, true).
Bind("/run/lock", "/run/lock", false, true).
Bind("/run/log", "/run/log", false, true).
Bind("/run/lvm", "/run/lvm", false, true).
Bind("/run/mount", "/run/mount", false, true).
Bind("/run/nginx", "/run/nginx", false, true).
Bind("/run/nscd", "/run/nscd", false, true).
Bind("/run/opengl-driver", "/run/opengl-driver", false, true).
Bind("/run/pppd", "/run/pppd", false, true).
Bind("/run/resolvconf", "/run/resolvconf", false, true).
Bind("/run/sddm", "/run/sddm", false, true).
Bind("/run/syncoid", "/run/syncoid", false, true).
Bind("/run/systemd", "/run/systemd", false, true).
Bind("/run/tmpfiles.d", "/run/tmpfiles.d", false, true).
Bind("/run/udev", "/run/udev", false, true).
Bind("/run/udisks2", "/run/udisks2", false, true).
Bind("/run/utmp", "/run/utmp", false, true).
Bind("/run/virtlogd.pid", "/run/virtlogd.pid", false, true).
Bind("/run/wrappers", "/run/wrappers", false, true).
Bind("/run/zed.pid", "/run/zed.pid", false, true).
Bind("/run/zed.state", "/run/zed.state", false, true).
Bind("/tmp/fortify.1971/tmpdir/150", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/150", 8388608).
Bind("/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd").
Bind("/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group").
Bind("/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group", "/etc/group").
Tmpfs("/var/run/nscd", 8192),
want: []string{
"--unshare-all", "--unshare-user", "--share-net",
"--clearenv", "--die-with-parent", "--as-pid-1",
"--uid", "65534",
"--gid", "65534",
"--setenv", "FORTIFY_INIT", "3",
"--setenv", "HOME", "/home/chronos",
"--setenv", "SHELL", "/run/current-system/sw/bin/zsh",
"--setenv", "TERM", "xterm-256color",
"--setenv", "USER", "chronos",
"--setenv", "XDG_RUNTIME_DIR", "/run/user/150",
"--setenv", "XDG_SESSION_CLASS", "user",
"--setenv", "XDG_SESSION_TYPE", "tty",
"--proc", "/proc", "--dev", "/dev",
"--mqueue", "/dev/mqueue",
"--bind", "/bin", "/bin",
"--bind", "/boot", "/boot",
"--bind", "/etc", "/etc",
"--bind", "/home", "/home",
"--bind", "/lib", "/lib",
"--bind", "/lib64", "/lib64",
"--bind", "/nix", "/nix",
"--bind", "/root", "/root",
"--bind", "/srv", "/srv",
"--bind", "/sys", "/sys",
"--bind", "/usr", "/usr",
"--bind", "/var", "/var",
"--bind", "/run/NetworkManager", "/run/NetworkManager",
"--bind", "/run/agetty.reload", "/run/agetty.reload",
"--bind", "/run/binfmt", "/run/binfmt",
"--bind", "/run/booted-system", "/run/booted-system",
"--bind", "/run/credentials", "/run/credentials",
"--bind", "/run/cryptsetup", "/run/cryptsetup",
"--bind", "/run/current-system", "/run/current-system",
"--bind", "/run/host", "/run/host",
"--bind", "/run/keys", "/run/keys",
"--bind", "/run/libvirt", "/run/libvirt",
"--bind", "/run/libvirtd.pid", "/run/libvirtd.pid",
"--bind", "/run/lock", "/run/lock",
"--bind", "/run/log", "/run/log",
"--bind", "/run/lvm", "/run/lvm",
"--bind", "/run/mount", "/run/mount",
"--bind", "/run/nginx", "/run/nginx",
"--bind", "/run/nscd", "/run/nscd",
"--bind", "/run/opengl-driver", "/run/opengl-driver",
"--bind", "/run/pppd", "/run/pppd",
"--bind", "/run/resolvconf", "/run/resolvconf",
"--bind", "/run/sddm", "/run/sddm",
"--bind", "/run/syncoid", "/run/syncoid",
"--bind", "/run/systemd", "/run/systemd",
"--bind", "/run/tmpfiles.d", "/run/tmpfiles.d",
"--bind", "/run/udev", "/run/udev",
"--bind", "/run/udisks2", "/run/udisks2",
"--bind", "/run/utmp", "/run/utmp",
"--bind", "/run/virtlogd.pid", "/run/virtlogd.pid",
"--bind", "/run/wrappers", "/run/wrappers",
"--bind", "/run/zed.pid", "/run/zed.pid",
"--bind", "/run/zed.state", "/run/zed.state",
"--bind", "/tmp/fortify.1971/tmpdir/150", "/tmp",
"--size", "1048576", "--tmpfs", "/tmp/fortify.1971",
"--size", "1048576", "--tmpfs", "/run/user",
"--size", "8388608", "--tmpfs", "/run/user/150",
"--ro-bind", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd",
"--ro-bind", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group",
"--ro-bind", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd", "/etc/passwd",
"--ro-bind", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group", "/etc/group",
"--size", "8192", "--tmpfs", "/var/run/nscd",
},
}, },
} }

View File

@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/cat/fortify/helper" "git.ophivana.moe/security/fortify/helper"
"git.ophivana.moe/cat/fortify/helper/bwrap" "git.ophivana.moe/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/cat/fortify/helper" "git.ophivana.moe/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/cat/fortify/helper" "git.ophivana.moe/security/fortify/helper"
) )
var ( var (

View File

@ -10,7 +10,8 @@ import (
"syscall" "syscall"
"testing" "testing"
"git.ophivana.moe/cat/fortify/helper/bwrap" "git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/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;
@ -33,7 +34,7 @@ func InternalChildStub() {
genericStub(argsFD, statFD) genericStub(argsFD, statFD)
} }
os.Exit(0) fmsg.Exit(0)
} }
// InternalReplaceExecCommand is an internal function but exported because it is cross-package; // InternalReplaceExecCommand 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/cat/fortify/helper" "git.ophivana.moe/security/fortify/helper"
) )
func TestHelperChildStub(t *testing.T) { func TestHelperChildStub(t *testing.T) {

View File

@ -1,29 +1,45 @@
package app package app
import ( import (
"os/exec"
"sync" "sync"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/shim"
) )
type App interface { type App interface {
Seal(config *Config) error // ID returns a copy of App's unique ID.
ID() ID
// 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() (int, error) Wait() (int, error)
// WaitErr returns error returned by the underlying wait syscall.
WaitErr() error WaitErr() error
Seal(config *Config) error
String() string String() string
} }
type app struct { type app struct {
// application unique identifier
id *ID
// operating system interface
os internal.System
// shim process manager
shim *shim.Shim
// child process related information // child process related information
seal *appSeal seal *appSeal
// underlying fortified child process
cmd *exec.Cmd
// error returned waiting for process // error returned waiting for process
wait error waitErr error
lock sync.RWMutex lock sync.RWMutex
} }
func (a *app) ID() ID {
return *a.id
}
func (a *app) String() string { func (a *app) String() string {
if a == nil { if a == nil {
return "(invalid fortified app)" return "(invalid fortified app)"
@ -32,21 +48,24 @@ func (a *app) String() string {
a.lock.RLock() a.lock.RLock()
defer a.lock.RUnlock() defer a.lock.RUnlock()
if a.cmd != nil { if a.shim != nil {
return a.cmd.String() return a.shim.String()
} }
if a.seal != nil { if a.seal != nil {
return "(sealed fortified app as uid " + a.seal.sys.Uid + ")" return "(sealed fortified app as uid " + a.seal.sys.user.Uid + ")"
} }
return "(unsealed fortified app)" return "(unsealed fortified app)"
} }
func (a *app) WaitErr() error { func (a *app) WaitErr() error {
return a.wait return a.waitErr
} }
func New() App { func New(os internal.System) (App, error) {
return new(app) a := new(app)
a.id = new(ID)
a.os = os
return a, newAppID(a.id)
} }

View File

@ -0,0 +1,592 @@
package app_test
import (
"fmt"
"io/fs"
"os/user"
"strconv"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/system"
)
var testCasesNixos = []sealTestCase{
{
"nixos permissive defaults no enablements", new(stubNixOS),
&app.Config{
User: "chronos",
Command: make([]string, 0),
Method: "sudo",
},
app.ID{
0x4a, 0x45, 0x0b, 0x65,
0x96, 0xd7, 0xbc, 0x15,
0xbd, 0x01, 0x78, 0x0e,
0xb9, 0xa6, 0x07, 0xac,
},
system.New(150).
Ensure("/tmp/fortify.1971", 0701).
Ephemeral(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac", 0701).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/150", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/150", acl.Read, acl.Write, acl.Execute).
Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute).
Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset
Ephemeral(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute).
WriteType(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n").
WriteType(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/group", "fortify:x:65534:\n"),
(&bwrap.Config{
Net: true,
UserNS: true,
Clearenv: true,
SetEnv: map[string]string{
"HOME": "/home/chronos",
"SHELL": "/run/current-system/sw/bin/zsh",
"TERM": "xterm-256color",
"USER": "chronos",
"XDG_RUNTIME_DIR": "/run/user/150",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty"},
Chmod: make(bwrap.ChmodConfig),
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").DevTmpfs("/dev").Mqueue("/dev/mqueue").
Tmpfs("/dev/fortify", 4096).
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/etc", "/dev/fortify/etc").
Bind("/home", "/home", false, true).
Bind("/lib", "/lib", false, true).
Bind("/lib64", "/lib64", false, true).
Bind("/nix", "/nix", false, true).
Bind("/root", "/root", false, true).
Bind("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/run/agetty.reload", "/run/agetty.reload", false, true).
Bind("/run/binfmt", "/run/binfmt", false, true).
Bind("/run/booted-system", "/run/booted-system", false, true).
Bind("/run/credentials", "/run/credentials", false, true).
Bind("/run/cryptsetup", "/run/cryptsetup", false, true).
Bind("/run/current-system", "/run/current-system", false, true).
Bind("/run/host", "/run/host", false, true).
Bind("/run/keys", "/run/keys", false, true).
Bind("/run/libvirt", "/run/libvirt", false, true).
Bind("/run/libvirtd.pid", "/run/libvirtd.pid", false, true).
Bind("/run/lock", "/run/lock", false, true).
Bind("/run/log", "/run/log", false, true).
Bind("/run/lvm", "/run/lvm", false, true).
Bind("/run/mount", "/run/mount", false, true).
Bind("/run/NetworkManager", "/run/NetworkManager", false, true).
Bind("/run/nginx", "/run/nginx", false, true).
Bind("/run/nixos", "/run/nixos", false, true).
Bind("/run/nscd", "/run/nscd", false, true).
Bind("/run/opengl-driver", "/run/opengl-driver", false, true).
Bind("/run/pppd", "/run/pppd", false, true).
Bind("/run/resolvconf", "/run/resolvconf", false, true).
Bind("/run/sddm", "/run/sddm", false, true).
Bind("/run/store", "/run/store", false, true).
Bind("/run/syncoid", "/run/syncoid", false, true).
Bind("/run/system", "/run/system", false, true).
Bind("/run/systemd", "/run/systemd", false, true).
Bind("/run/tmpfiles.d", "/run/tmpfiles.d", false, true).
Bind("/run/udev", "/run/udev", false, true).
Bind("/run/udisks2", "/run/udisks2", false, true).
Bind("/run/utmp", "/run/utmp", false, true).
Bind("/run/virtlogd.pid", "/run/virtlogd.pid", false, true).
Bind("/run/wrappers", "/run/wrappers", false, true).
Bind("/run/zed.pid", "/run/zed.pid", false, true).
Bind("/run/zed.state", "/run/zed.state", false, true).
Symlink("/dev/fortify/etc/alsa", "/etc/alsa").
Symlink("/dev/fortify/etc/bashrc", "/etc/bashrc").
Symlink("/dev/fortify/etc/binfmt.d", "/etc/binfmt.d").
Symlink("/dev/fortify/etc/dbus-1", "/etc/dbus-1").
Symlink("/dev/fortify/etc/default", "/etc/default").
Symlink("/dev/fortify/etc/ethertypes", "/etc/ethertypes").
Symlink("/dev/fortify/etc/fonts", "/etc/fonts").
Symlink("/dev/fortify/etc/fstab", "/etc/fstab").
Symlink("/dev/fortify/etc/fuse.conf", "/etc/fuse.conf").
Symlink("/dev/fortify/etc/host.conf", "/etc/host.conf").
Symlink("/dev/fortify/etc/hostid", "/etc/hostid").
Symlink("/dev/fortify/etc/hostname", "/etc/hostname").
Symlink("/dev/fortify/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink("/dev/fortify/etc/hosts", "/etc/hosts").
Symlink("/dev/fortify/etc/inputrc", "/etc/inputrc").
Symlink("/dev/fortify/etc/ipsec.d", "/etc/ipsec.d").
Symlink("/dev/fortify/etc/issue", "/etc/issue").
Symlink("/dev/fortify/etc/kbd", "/etc/kbd").
Symlink("/dev/fortify/etc/libblockdev", "/etc/libblockdev").
Symlink("/dev/fortify/etc/locale.conf", "/etc/locale.conf").
Symlink("/dev/fortify/etc/localtime", "/etc/localtime").
Symlink("/dev/fortify/etc/login.defs", "/etc/login.defs").
Symlink("/dev/fortify/etc/lsb-release", "/etc/lsb-release").
Symlink("/dev/fortify/etc/lvm", "/etc/lvm").
Symlink("/dev/fortify/etc/machine-id", "/etc/machine-id").
Symlink("/dev/fortify/etc/man_db.conf", "/etc/man_db.conf").
Symlink("/dev/fortify/etc/modprobe.d", "/etc/modprobe.d").
Symlink("/dev/fortify/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink("/dev/fortify/etc/nanorc", "/etc/nanorc").
Symlink("/dev/fortify/etc/netgroup", "/etc/netgroup").
Symlink("/dev/fortify/etc/NetworkManager", "/etc/NetworkManager").
Symlink("/dev/fortify/etc/nix", "/etc/nix").
Symlink("/dev/fortify/etc/nixos", "/etc/nixos").
Symlink("/dev/fortify/etc/NIXOS", "/etc/NIXOS").
Symlink("/dev/fortify/etc/nscd.conf", "/etc/nscd.conf").
Symlink("/dev/fortify/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink("/dev/fortify/etc/opensnitchd", "/etc/opensnitchd").
Symlink("/dev/fortify/etc/os-release", "/etc/os-release").
Symlink("/dev/fortify/etc/pam", "/etc/pam").
Symlink("/dev/fortify/etc/pam.d", "/etc/pam.d").
Symlink("/dev/fortify/etc/pipewire", "/etc/pipewire").
Symlink("/dev/fortify/etc/pki", "/etc/pki").
Symlink("/dev/fortify/etc/polkit-1", "/etc/polkit-1").
Symlink("/dev/fortify/etc/profile", "/etc/profile").
Symlink("/dev/fortify/etc/protocols", "/etc/protocols").
Symlink("/dev/fortify/etc/qemu", "/etc/qemu").
Symlink("/dev/fortify/etc/resolv.conf", "/etc/resolv.conf").
Symlink("/dev/fortify/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink("/dev/fortify/etc/rpc", "/etc/rpc").
Symlink("/dev/fortify/etc/samba", "/etc/samba").
Symlink("/dev/fortify/etc/sddm.conf", "/etc/sddm.conf").
Symlink("/dev/fortify/etc/secureboot", "/etc/secureboot").
Symlink("/dev/fortify/etc/services", "/etc/services").
Symlink("/dev/fortify/etc/set-environment", "/etc/set-environment").
Symlink("/dev/fortify/etc/shadow", "/etc/shadow").
Symlink("/dev/fortify/etc/shells", "/etc/shells").
Symlink("/dev/fortify/etc/ssh", "/etc/ssh").
Symlink("/dev/fortify/etc/ssl", "/etc/ssl").
Symlink("/dev/fortify/etc/static", "/etc/static").
Symlink("/dev/fortify/etc/subgid", "/etc/subgid").
Symlink("/dev/fortify/etc/subuid", "/etc/subuid").
Symlink("/dev/fortify/etc/sudoers", "/etc/sudoers").
Symlink("/dev/fortify/etc/sysctl.d", "/etc/sysctl.d").
Symlink("/dev/fortify/etc/systemd", "/etc/systemd").
Symlink("/dev/fortify/etc/terminfo", "/etc/terminfo").
Symlink("/dev/fortify/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink("/dev/fortify/etc/udev", "/etc/udev").
Symlink("/dev/fortify/etc/udisks2", "/etc/udisks2").
Symlink("/dev/fortify/etc/UPower", "/etc/UPower").
Symlink("/dev/fortify/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink("/dev/fortify/etc/X11", "/etc/X11").
Symlink("/dev/fortify/etc/zfs", "/etc/zfs").
Symlink("/dev/fortify/etc/zinputrc", "/etc/zinputrc").
Symlink("/dev/fortify/etc/zoneinfo", "/etc/zoneinfo").
Symlink("/dev/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/dev/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/dev/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/150", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/150", 8388608).
Bind("/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/group", "/etc/group").
Tmpfs("/var/run/nscd", 8192),
},
{
"nixos permissive defaults chromium", new(stubNixOS),
&app.Config{
ID: "org.chromium.Chromium",
User: "chronos",
Command: []string{"/run/current-system/sw/bin/zsh", "-c", "exec chromium "},
Confinement: app.ConfinementConfig{
SessionBus: &dbus.Config{
Talk: []string{
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{
"org.freedesktop.portal.*": "*",
},
Broadcast: map[string]string{
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*",
},
Filter: true,
},
SystemBus: &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
},
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
},
Method: "systemd",
},
app.ID{
0xeb, 0xf0, 0x83, 0xd1,
0xb1, 0x75, 0x91, 0x17,
0x82, 0xd4, 0x13, 0x36,
0x9b, 0x64, 0xce, 0x7c,
},
system.New(150).
Ensure("/tmp/fortify.1971", 0701).
Ephemeral(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0701).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/150", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/150", acl.Read, acl.Write, acl.Execute).
Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute).
Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset
Ephemeral(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", acl.Execute).
WriteType(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n").
WriteType(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/group", "fortify:x:65534:\n").
Link("/run/user/1971/wayland-0", "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/wayland").
UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute).
Link("/run/user/1971/pulse/native", "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse").
CopyFile("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie").
MustProxyDBus("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{
Talk: []string{
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{
"org.freedesktop.portal.*": "*",
},
Broadcast: map[string]string{
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*",
},
Filter: true,
}, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
}).
UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write),
(&bwrap.Config{
Net: true,
UserNS: true,
Clearenv: true,
SetEnv: map[string]string{
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/150/bus",
"DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/run/dbus/system_bus_socket",
"HOME": "/home/chronos",
"PULSE_COOKIE": "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie",
"PULSE_SERVER": "unix:/run/user/150/pulse/native",
"SHELL": "/run/current-system/sw/bin/zsh",
"TERM": "xterm-256color",
"USER": "chronos",
"WAYLAND_DISPLAY": "/run/user/150/wayland-0",
"XDG_RUNTIME_DIR": "/run/user/150",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty",
},
Chmod: make(bwrap.ChmodConfig),
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").DevTmpfs("/dev").Mqueue("/dev/mqueue").
Tmpfs("/dev/fortify", 4096).
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/etc", "/dev/fortify/etc").
Bind("/home", "/home", false, true).
Bind("/lib", "/lib", false, true).
Bind("/lib64", "/lib64", false, true).
Bind("/nix", "/nix", false, true).
Bind("/root", "/root", false, true).
Bind("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/run/agetty.reload", "/run/agetty.reload", false, true).
Bind("/run/binfmt", "/run/binfmt", false, true).
Bind("/run/booted-system", "/run/booted-system", false, true).
Bind("/run/credentials", "/run/credentials", false, true).
Bind("/run/cryptsetup", "/run/cryptsetup", false, true).
Bind("/run/current-system", "/run/current-system", false, true).
Bind("/run/host", "/run/host", false, true).
Bind("/run/keys", "/run/keys", false, true).
Bind("/run/libvirt", "/run/libvirt", false, true).
Bind("/run/libvirtd.pid", "/run/libvirtd.pid", false, true).
Bind("/run/lock", "/run/lock", false, true).
Bind("/run/log", "/run/log", false, true).
Bind("/run/lvm", "/run/lvm", false, true).
Bind("/run/mount", "/run/mount", false, true).
Bind("/run/NetworkManager", "/run/NetworkManager", false, true).
Bind("/run/nginx", "/run/nginx", false, true).
Bind("/run/nixos", "/run/nixos", false, true).
Bind("/run/nscd", "/run/nscd", false, true).
Bind("/run/opengl-driver", "/run/opengl-driver", false, true).
Bind("/run/pppd", "/run/pppd", false, true).
Bind("/run/resolvconf", "/run/resolvconf", false, true).
Bind("/run/sddm", "/run/sddm", false, true).
Bind("/run/store", "/run/store", false, true).
Bind("/run/syncoid", "/run/syncoid", false, true).
Bind("/run/system", "/run/system", false, true).
Bind("/run/systemd", "/run/systemd", false, true).
Bind("/run/tmpfiles.d", "/run/tmpfiles.d", false, true).
Bind("/run/udev", "/run/udev", false, true).
Bind("/run/udisks2", "/run/udisks2", false, true).
Bind("/run/utmp", "/run/utmp", false, true).
Bind("/run/virtlogd.pid", "/run/virtlogd.pid", false, true).
Bind("/run/wrappers", "/run/wrappers", false, true).
Bind("/run/zed.pid", "/run/zed.pid", false, true).
Bind("/run/zed.state", "/run/zed.state", false, true).
Bind("/dev/dri", "/dev/dri", true, true, true).
Symlink("/dev/fortify/etc/alsa", "/etc/alsa").
Symlink("/dev/fortify/etc/bashrc", "/etc/bashrc").
Symlink("/dev/fortify/etc/binfmt.d", "/etc/binfmt.d").
Symlink("/dev/fortify/etc/dbus-1", "/etc/dbus-1").
Symlink("/dev/fortify/etc/default", "/etc/default").
Symlink("/dev/fortify/etc/ethertypes", "/etc/ethertypes").
Symlink("/dev/fortify/etc/fonts", "/etc/fonts").
Symlink("/dev/fortify/etc/fstab", "/etc/fstab").
Symlink("/dev/fortify/etc/fuse.conf", "/etc/fuse.conf").
Symlink("/dev/fortify/etc/host.conf", "/etc/host.conf").
Symlink("/dev/fortify/etc/hostid", "/etc/hostid").
Symlink("/dev/fortify/etc/hostname", "/etc/hostname").
Symlink("/dev/fortify/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink("/dev/fortify/etc/hosts", "/etc/hosts").
Symlink("/dev/fortify/etc/inputrc", "/etc/inputrc").
Symlink("/dev/fortify/etc/ipsec.d", "/etc/ipsec.d").
Symlink("/dev/fortify/etc/issue", "/etc/issue").
Symlink("/dev/fortify/etc/kbd", "/etc/kbd").
Symlink("/dev/fortify/etc/libblockdev", "/etc/libblockdev").
Symlink("/dev/fortify/etc/locale.conf", "/etc/locale.conf").
Symlink("/dev/fortify/etc/localtime", "/etc/localtime").
Symlink("/dev/fortify/etc/login.defs", "/etc/login.defs").
Symlink("/dev/fortify/etc/lsb-release", "/etc/lsb-release").
Symlink("/dev/fortify/etc/lvm", "/etc/lvm").
Symlink("/dev/fortify/etc/machine-id", "/etc/machine-id").
Symlink("/dev/fortify/etc/man_db.conf", "/etc/man_db.conf").
Symlink("/dev/fortify/etc/modprobe.d", "/etc/modprobe.d").
Symlink("/dev/fortify/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink("/dev/fortify/etc/nanorc", "/etc/nanorc").
Symlink("/dev/fortify/etc/netgroup", "/etc/netgroup").
Symlink("/dev/fortify/etc/NetworkManager", "/etc/NetworkManager").
Symlink("/dev/fortify/etc/nix", "/etc/nix").
Symlink("/dev/fortify/etc/nixos", "/etc/nixos").
Symlink("/dev/fortify/etc/NIXOS", "/etc/NIXOS").
Symlink("/dev/fortify/etc/nscd.conf", "/etc/nscd.conf").
Symlink("/dev/fortify/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink("/dev/fortify/etc/opensnitchd", "/etc/opensnitchd").
Symlink("/dev/fortify/etc/os-release", "/etc/os-release").
Symlink("/dev/fortify/etc/pam", "/etc/pam").
Symlink("/dev/fortify/etc/pam.d", "/etc/pam.d").
Symlink("/dev/fortify/etc/pipewire", "/etc/pipewire").
Symlink("/dev/fortify/etc/pki", "/etc/pki").
Symlink("/dev/fortify/etc/polkit-1", "/etc/polkit-1").
Symlink("/dev/fortify/etc/profile", "/etc/profile").
Symlink("/dev/fortify/etc/protocols", "/etc/protocols").
Symlink("/dev/fortify/etc/qemu", "/etc/qemu").
Symlink("/dev/fortify/etc/resolv.conf", "/etc/resolv.conf").
Symlink("/dev/fortify/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink("/dev/fortify/etc/rpc", "/etc/rpc").
Symlink("/dev/fortify/etc/samba", "/etc/samba").
Symlink("/dev/fortify/etc/sddm.conf", "/etc/sddm.conf").
Symlink("/dev/fortify/etc/secureboot", "/etc/secureboot").
Symlink("/dev/fortify/etc/services", "/etc/services").
Symlink("/dev/fortify/etc/set-environment", "/etc/set-environment").
Symlink("/dev/fortify/etc/shadow", "/etc/shadow").
Symlink("/dev/fortify/etc/shells", "/etc/shells").
Symlink("/dev/fortify/etc/ssh", "/etc/ssh").
Symlink("/dev/fortify/etc/ssl", "/etc/ssl").
Symlink("/dev/fortify/etc/static", "/etc/static").
Symlink("/dev/fortify/etc/subgid", "/etc/subgid").
Symlink("/dev/fortify/etc/subuid", "/etc/subuid").
Symlink("/dev/fortify/etc/sudoers", "/etc/sudoers").
Symlink("/dev/fortify/etc/sysctl.d", "/etc/sysctl.d").
Symlink("/dev/fortify/etc/systemd", "/etc/systemd").
Symlink("/dev/fortify/etc/terminfo", "/etc/terminfo").
Symlink("/dev/fortify/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink("/dev/fortify/etc/udev", "/etc/udev").
Symlink("/dev/fortify/etc/udisks2", "/etc/udisks2").
Symlink("/dev/fortify/etc/UPower", "/etc/UPower").
Symlink("/dev/fortify/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink("/dev/fortify/etc/X11", "/etc/X11").
Symlink("/dev/fortify/etc/zfs", "/etc/zfs").
Symlink("/dev/fortify/etc/zinputrc", "/etc/zinputrc").
Symlink("/dev/fortify/etc/zoneinfo", "/etc/zoneinfo").
Symlink("/dev/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/dev/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/dev/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/150", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/150", 8388608).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/group", "/etc/group").
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/150/wayland-0").
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/150/pulse/native").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie", "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/150/bus").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket").
Tmpfs("/var/run/nscd", 8192),
},
}
// fs methods are not implemented using a real FS
// to help better understand filesystem access behaviour
type stubNixOS struct {
lookPathErr map[string]error
usernameErr map[string]error
}
func (s *stubNixOS) Geteuid() int {
return 1971
}
func (s *stubNixOS) LookupEnv(key string) (string, bool) {
switch key {
case "SHELL":
return "/run/current-system/sw/bin/zsh", true
case "TERM":
return "xterm-256color", true
case "WAYLAND_DISPLAY":
return "wayland-0", true
case "PULSE_COOKIE":
return "", false
case "HOME":
return "/home/ophestra", true
case "XDG_CONFIG_HOME":
return "/home/ophestra/xdg/config", true
default:
panic(fmt.Sprintf("attempted to access unexpected environment variable %q", key))
}
}
func (s *stubNixOS) TempDir() string {
return "/tmp"
}
func (s *stubNixOS) LookPath(file string) (string, error) {
if s.lookPathErr != nil {
if err, ok := s.lookPathErr[file]; ok {
return "", err
}
}
switch file {
case "sudo":
return "/run/wrappers/bin/sudo", nil
case "machinectl":
return "/home/ophestra/.nix-profile/bin/machinectl", nil
default:
panic(fmt.Sprintf("attempted to look up unexpected executable %q", file))
}
}
func (s *stubNixOS) Executable() (string, error) {
return "/home/ophestra/.nix-profile/bin/fortify", nil
}
func (s *stubNixOS) Lookup(username string) (*user.User, error) {
if s.usernameErr != nil {
if err, ok := s.usernameErr[username]; ok {
return nil, err
}
}
switch username {
case "chronos":
return &user.User{
Uid: "150",
Gid: "101",
Username: "chronos",
HomeDir: "/home/chronos",
}, nil
default:
return nil, user.UnknownUserError(username)
}
}
func (s *stubNixOS) ReadDir(name string) ([]fs.DirEntry, error) {
switch name {
case "/":
return stubDirEntries("bin", "boot", "dev", "etc", "home", "lib",
"lib64", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var")
case "/run":
return stubDirEntries("agetty.reload", "binfmt", "booted-system",
"credentials", "cryptsetup", "current-system", "dbus", "host", "keys",
"libvirt", "libvirtd.pid", "lock", "log", "lvm", "mount", "NetworkManager",
"nginx", "nixos", "nscd", "opengl-driver", "pppd", "resolvconf", "sddm",
"store", "syncoid", "system", "systemd", "tmpfiles.d", "udev", "udisks2",
"user", "utmp", "virtlogd.pid", "wrappers", "zed.pid", "zed.state")
case "/etc":
return stubDirEntries("alsa", "bashrc", "binfmt.d", "dbus-1", "default",
"ethertypes", "fonts", "fstab", "fuse.conf", "group", "host.conf", "hostid",
"hostname", "hostname.CHECKSUM", "hosts", "inputrc", "ipsec.d", "issue", "kbd",
"libblockdev", "locale.conf", "localtime", "login.defs", "lsb-release", "lvm",
"machine-id", "man_db.conf", "modprobe.d", "modules-load.d", "mtab", "nanorc",
"netgroup", "NetworkManager", "nix", "nixos", "NIXOS", "nscd.conf", "nsswitch.conf",
"opensnitchd", "os-release", "pam", "pam.d", "passwd", "pipewire", "pki", "polkit-1",
"profile", "protocols", "qemu", "resolv.conf", "resolvconf.conf", "rpc", "samba",
"sddm.conf", "secureboot", "services", "set-environment", "shadow", "shells", "ssh",
"ssl", "static", "subgid", "subuid", "sudoers", "sysctl.d", "systemd", "terminfo",
"tmpfiles.d", "udev", "udisks2", "UPower", "vconsole.conf", "X11", "zfs", "zinputrc",
"zoneinfo", "zprofile", "zshenv", "zshrc")
default:
panic(fmt.Sprintf("attempted to read unexpected directory %q", name))
}
}
func (s *stubNixOS) Stat(name string) (fs.FileInfo, error) {
switch name {
case "/var/run/nscd":
return nil, nil
case "/run/user/1971/pulse":
return nil, nil
case "/run/user/1971/pulse/native":
return stubFileInfoMode(0666), nil
case "/home/ophestra/.pulse-cookie":
return stubFileInfoIsDir(true), nil
case "/home/ophestra/xdg/config/pulse/cookie":
return stubFileInfoIsDir(false), nil
default:
panic(fmt.Sprintf("attempted to stat unexpected path %q", name))
}
}
func (s *stubNixOS) Open(name string) (fs.File, error) {
switch name {
default:
panic(fmt.Sprintf("attempted to open unexpected file %q", name))
}
}
func (s *stubNixOS) Exit(code int) {
panic("called exit on stub with code " + strconv.Itoa(code))
}
func (s *stubNixOS) Paths() internal.Paths {
return internal.Paths{
SharePath: "/tmp/fortify.1971",
RuntimePath: "/run/user/1971",
RunDirPath: "/run/user/1971/fortify",
}
}
func (s *stubNixOS) SdBooted() bool {
return true
}

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

@ -0,0 +1,134 @@
package app_test
import (
"io/fs"
"reflect"
"testing"
"time"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/system"
)
type sealTestCase struct {
name string
os internal.System
config *app.Config
id app.ID
wantSys *system.I
wantBwrap *bwrap.Config
}
func TestApp(t *testing.T) {
testCases := append(testCasesNixos)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a := app.NewWithID(tc.id, tc.os)
if !t.Run("seal", func(t *testing.T) {
if err := a.Seal(tc.config); err != nil {
t.Errorf("Seal: error = %v", err)
}
}) {
return
}
gotSys, gotBwrap := app.AppSystemBwrap(a)
t.Run("compare sys", func(t *testing.T) {
if !gotSys.Equal(tc.wantSys) {
t.Errorf("Seal: sys = %#v, want %#v",
gotSys, tc.wantSys)
}
})
t.Run("compare bwrap", func(t *testing.T) {
if !reflect.DeepEqual(gotBwrap, tc.wantBwrap) {
t.Errorf("seal: bwrap = %#v, want %#v",
gotBwrap, tc.wantBwrap)
}
})
})
}
}
func stubDirEntries(names ...string) (e []fs.DirEntry, err error) {
e = make([]fs.DirEntry, len(names))
for i, name := range names {
e[i] = stubDirEntryPath(name)
}
return
}
type stubDirEntryPath string
func (p stubDirEntryPath) Name() string {
return string(p)
}
func (p stubDirEntryPath) IsDir() bool {
panic("attempted to call IsDir")
}
func (p stubDirEntryPath) Type() fs.FileMode {
panic("attempted to call Type")
}
func (p stubDirEntryPath) Info() (fs.FileInfo, error) {
panic("attempted to call Info")
}
type stubFileInfoMode fs.FileMode
func (s stubFileInfoMode) Name() string {
panic("attempted to call Name")
}
func (s stubFileInfoMode) Size() int64 {
panic("attempted to call Size")
}
func (s stubFileInfoMode) Mode() fs.FileMode {
return fs.FileMode(s)
}
func (s stubFileInfoMode) ModTime() time.Time {
panic("attempted to call ModTime")
}
func (s stubFileInfoMode) IsDir() bool {
panic("attempted to call IsDir")
}
func (s stubFileInfoMode) Sys() any {
panic("attempted to call Sys")
}
type stubFileInfoIsDir bool
func (s stubFileInfoIsDir) Name() string {
panic("attempted to call Name")
}
func (s stubFileInfoIsDir) Size() int64 {
panic("attempted to call Size")
}
func (s stubFileInfoIsDir) Mode() fs.FileMode {
panic("attempted to call Mode")
}
func (s stubFileInfoIsDir) ModTime() time.Time {
panic("attempted to call ModTime")
}
func (s stubFileInfoIsDir) IsDir() bool {
return bool(s)
}
func (s stubFileInfoIsDir) Sys() any {
panic("attempted to call Sys")
}

View File

@ -1,8 +1,11 @@
package app package app
import ( import (
"git.ophivana.moe/cat/fortify/dbus" "os"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/system"
) )
// Config is used to seal an *App // Config is used to seal an *App
@ -22,6 +25,9 @@ type Config struct {
// ConfinementConfig defines fortified child's confinement // ConfinementConfig defines fortified child's confinement
type ConfinementConfig struct { type ConfinementConfig struct {
// bwrap sandbox confinement configuration
Sandbox *SandboxConfig `json:"sandbox"`
// reference to a system D-Bus proxy configuration, // reference to a system D-Bus proxy configuration,
// nil value disables system bus proxy // nil value disables system bus proxy
SystemBus *dbus.Config `json:"system_bus,omitempty"` SystemBus *dbus.Config `json:"system_bus,omitempty"`
@ -30,5 +36,145 @@ type ConfinementConfig struct {
SessionBus *dbus.Config `json:"session_bus,omitempty"` SessionBus *dbus.Config `json:"session_bus,omitempty"`
// child capability enablements // child capability enablements
Enablements state.Enablements `json:"enablements"` Enablements system.Enablements `json:"enablements"`
}
// SandboxConfig describes resources made available to the sandbox.
type SandboxConfig struct {
// unix hostname within sandbox
Hostname string `json:"hostname,omitempty"`
// userns availability within sandbox
UserNS bool `json:"userns,omitempty"`
// share net namespace
Net bool `json:"net,omitempty"`
// do not run in new session
NoNewSession bool `json:"no_new_session,omitempty"`
// mediated access to wayland socket
Wayland bool `json:"wayland,omitempty"`
// final environment variables
Env map[string]string `json:"env"`
// sandbox host filesystem access
Filesystem []*FilesystemConfig `json:"filesystem"`
// symlinks created inside the sandbox
Link [][2]string `json:"symlink"`
// paths to override by mounting tmpfs over them
Override []string `json:"override"`
}
type FilesystemConfig struct {
// mount point in sandbox, same as src if empty
Dst string `json:"dst,omitempty"`
// host filesystem path to make available to sandbox
Src string `json:"src"`
// write access
Write bool `json:"write,omitempty"`
// device access
Device bool `json:"dev,omitempty"`
// exit if unable to share
Must bool `json:"require,omitempty"`
}
// Bwrap returns the address of the corresponding bwrap.Config to s.
// Note that remaining tmpfs entries must be queued by the caller prior to launch.
func (s *SandboxConfig) Bwrap() *bwrap.Config {
if s == nil {
return nil
}
conf := (&bwrap.Config{
Net: s.Net,
UserNS: s.UserNS,
Hostname: s.Hostname,
Clearenv: true,
SetEnv: s.Env,
NewSession: !s.NoNewSession,
DieWithParent: true,
AsInit: true,
// initialise map
Chmod: make(map[string]os.FileMode),
}).
SetUID(65534).SetGID(65534).
Procfs("/proc").DevTmpfs("/dev").Mqueue("/dev/mqueue").
Tmpfs("/dev/fortify", 4*1024)
for _, c := range s.Filesystem {
if c == nil {
continue
}
src := c.Src
dest := c.Dst
if c.Dst == "" {
dest = c.Src
}
conf.Bind(src, dest, !c.Must, c.Write, c.Device)
}
for _, l := range s.Link {
conf.Symlink(l[0], l[1])
}
return conf
}
// Template returns a fully populated instance of Config.
func Template() *Config {
return &Config{
ID: "org.chromium.Chromium",
User: "chronos",
Command: []string{
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
},
Method: "sudo",
Confinement: ConfinementConfig{
Sandbox: &SandboxConfig{
Hostname: "localhost",
UserNS: true,
Net: true,
NoNewSession: true,
Wayland: false,
// example API credentials pulled from Google Chrome
// DO NOT USE THESE IN A REAL BROWSER
Env: map[string]string{
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT",
},
Filesystem: []*FilesystemConfig{
{Src: "/nix"},
{Src: "/storage/emulated/0", Write: true, Must: true},
{Src: "/data/user/0", Dst: "/data/data", Write: true, Must: true},
{Src: "/var/tmp", Write: true},
},
Link: [][2]string{{"/dev/fortify/etc", "/etc"}},
Override: []string{"/var/run/nscd"},
},
SystemBus: &dbus.Config{
See: nil,
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Own: nil,
Call: nil,
Broadcast: nil,
Log: false,
Filter: true,
},
SessionBus: &dbus.Config{
See: nil,
Talk: []string{"org.freedesktop.Notifications", "org.freedesktop.FileManager1", "org.freedesktop.ScreenSaver",
"org.freedesktop.secrets", "org.kde.kwalletd5", "org.kde.kwalletd6", "org.gnome.SessionManager"},
Own: []string{"org.chromium.Chromium.*", "org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"},
Call: map[string]string{"org.freedesktop.portal.*": "*"},
Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
Log: false,
Filter: true,
},
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
},
}
} }

View File

@ -1,33 +0,0 @@
package app
import (
"io"
"os"
)
func copyFile(dst, src string) error {
srcD, err := os.Open(src)
if err != nil {
return err
}
defer func() {
if srcD.Close() != nil {
// unreachable
panic("src file closed prematurely")
}
}()
dstD, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer func() {
if dstD.Close() != nil {
// unreachable
panic("dst file closed prematurely")
}
}()
_, err = io.Copy(dstD, srcD)
return err
}

View File

@ -0,0 +1,19 @@
package app
import (
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/system"
)
func NewWithID(id ID, os internal.System) App {
a := new(app)
a.id = &id
a.os = os
return a
}
func AppSystemBwrap(a App) (*system.I, *bwrap.Config) {
v := a.(*app)
return v.seal.sys.I, v.seal.sys.bwrap
}

View File

@ -5,14 +5,13 @@ import (
"encoding/hex" "encoding/hex"
) )
type appID [16]byte type ID [16]byte
func (a *appID) String() string { func (a *ID) String() string {
return hex.EncodeToString(a[:]) return hex.EncodeToString(a[:])
} }
func newAppID() (*appID, error) { func newAppID(id *ID) error {
a := &appID{} _, err := rand.Read(id[:])
_, err := rand.Read(a[:]) return err
return a, err
} }

View File

@ -1,38 +1,36 @@
package app package app
import ( import (
"os/exec"
"strings" "strings"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/verbose"
) )
func (a *app) commandBuilderMachineCtl() (args []string) { func (a *app) commandBuilderMachineCtl(shimEnv string) (args []string) {
args = make([]string, 0, 9+len(a.seal.env)) args = make([]string, 0, 9+len(a.seal.sys.bwrap.SetEnv))
// shell --uid=$USER // shell --uid=$USER
args = append(args, "shell", "--uid="+a.seal.sys.Username) args = append(args, "shell", "--uid="+a.seal.sys.user.Username)
// --quiet // --quiet
if !verbose.Get() { if !fmsg.Verbose() {
args = append(args, "--quiet") args = append(args, "--quiet")
} }
// environ // environ
envQ := make([]string, len(a.seal.env)+1) envQ := make([]string, 0, len(a.seal.sys.bwrap.SetEnv)+1)
for i, e := range a.seal.env { for k, v := range a.seal.sys.bwrap.SetEnv {
envQ[i] = "-E" + e envQ = append(envQ, "-E"+k+"="+v)
} }
// add shim payload to environment for shim path // add shim payload to environment for shim path
envQ[len(a.seal.env)] = "-E" + a.shimPayloadEnv() envQ = append(envQ, "-E"+shimEnv)
args = append(args, envQ...) args = append(args, envQ...)
// -- .host // -- .host
args = append(args, "--", ".host") args = append(args, "--", ".host")
// /bin/sh -c // /bin/sh -c
if sh, err := exec.LookPath("sh"); err != nil { if sh, err := a.os.LookPath("sh"); err != nil {
// hardcode /bin/sh path since it exists more often than not // hardcode /bin/sh path since it exists more often than not
args = append(args, "/bin/sh", "-c") args = append(args, "/bin/sh", "-c")
} else { } else {
@ -44,21 +42,13 @@ func (a *app) commandBuilderMachineCtl() (args []string) {
// apply custom environment variables to activation environment // apply custom environment variables to activation environment
innerCommand.WriteString("dbus-update-activation-environment --systemd") innerCommand.WriteString("dbus-update-activation-environment --systemd")
for _, e := range a.seal.env { for k := range a.seal.sys.bwrap.SetEnv {
innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0]) innerCommand.WriteString(" " + k)
} }
innerCommand.WriteString("; ") innerCommand.WriteString("; ")
// override message bus address if enabled // launch fortify as shim
if a.seal.et.Has(state.EnableDBus) { innerCommand.WriteString("exec " + a.seal.sys.executable + " shim")
innerCommand.WriteString(dbusSessionBusAddress + "=" + "'" + "unix:path=" + a.seal.sys.dbusAddr[0][1] + "' ")
if a.seal.sys.dbusSystem {
innerCommand.WriteString(dbusSystemBusAddress + "=" + "'" + "unix:path=" + a.seal.sys.dbusAddr[1][1] + "' ")
}
}
// both license and version flags need to be set to activate shim path
innerCommand.WriteString("exec " + a.seal.sys.executable + " -V -license")
// append inner command // append inner command
args = append(args, innerCommand.String()) args = append(args, innerCommand.String())

View File

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

View File

@ -2,15 +2,17 @@ package app
import ( import (
"errors" "errors"
"os" "io/fs"
"os/exec"
"os/user" "os/user"
"path"
"strconv" "strconv"
"git.ophivana.moe/cat/fortify/dbus" "git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal" "git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/verbose" "git.ophivana.moe/security/fortify/internal/shim"
"git.ophivana.moe/security/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/system"
) )
const ( const (
@ -18,6 +20,11 @@ const (
LaunchMethodMachineCtl LaunchMethodMachineCtl
) )
var method = [...]string{
LaunchMethodSudo: "sudo",
LaunchMethodMachineCtl: "systemd",
}
var ( var (
ErrConfig = errors.New("no configuration to seal") ErrConfig = errors.New("no configuration to seal")
ErrUser = errors.New("unknown user") ErrUser = errors.New("unknown user")
@ -28,11 +35,41 @@ var (
ErrMachineCtl = errors.New("machinectl not available") ErrMachineCtl = errors.New("machinectl not available")
) )
type ( // appSeal seals the application with child-related information
SealConfigError BaseError type appSeal struct {
LauncherLookupError BaseError // app unique ID string representation
SecurityError BaseError id string
) // wayland mediation, disabled if nil
wl *shim.Wayland
// freedesktop application ID
fid string
// argv to start process with in the final confined environment
command []string
// persistent process state store
store state.Store
// uint8 representation of launch method sealed from config
launchOption uint8
// process-specific share directory path
share string
// process-specific share directory path local to XDG_RUNTIME_DIR
shareLocal string
// path to launcher program
toolPath string
// pass-through enablement tracking from config
et system.Enablements
// prevents sharing from happening twice
shared bool
// seal system-level component
sys *appSealSys
internal.Paths
// protected by upstream mutex
}
// Seal seals the app launch context // Seal seals the app launch context
func (a *app) Seal(config *Config) error { func (a *app) Seal(config *Config) error {
@ -44,97 +81,194 @@ func (a *app) Seal(config *Config) error {
} }
if config == nil { if config == nil {
return (*SealConfigError)(wrapError(ErrConfig, "attempted to seal app with nil config")) return fmsg.WrapError(ErrConfig,
"attempted to seal app with nil config")
} }
// create seal // create seal
seal := new(appSeal) seal := new(appSeal)
// generate application ID
if id, err := newAppID(); err != nil {
return (*SecurityError)(wrapError(err, "cannot generate application ID:", err))
} else {
seal.id = id
}
// fetch system constants // fetch system constants
seal.SystemConstants = internal.GetSC() seal.Paths = a.os.Paths()
// pass through config values // pass through config values
seal.id = a.id.String()
seal.fid = config.ID seal.fid = config.ID
seal.command = config.Command seal.command = config.Command
// parses launch method text and looks up tool path // parses launch method text and looks up tool path
switch config.Method { switch config.Method {
case "sudo": case method[LaunchMethodSudo]:
seal.launchOption = LaunchMethodSudo seal.launchOption = LaunchMethodSudo
if sudoPath, err := exec.LookPath("sudo"); err != nil { if sudoPath, err := a.os.LookPath("sudo"); err != nil {
return (*LauncherLookupError)(wrapError(ErrSudo, "sudo not found")) return fmsg.WrapError(ErrSudo,
"sudo not found")
} else { } else {
seal.toolPath = sudoPath seal.toolPath = sudoPath
} }
case "systemd": case method[LaunchMethodMachineCtl]:
seal.launchOption = LaunchMethodMachineCtl seal.launchOption = LaunchMethodMachineCtl
if !internal.SdBootedV { if !a.os.SdBooted() {
return (*LauncherLookupError)(wrapError(ErrSystemd, return fmsg.WrapError(ErrSystemd,
"system has not been booted with systemd as init system")) "system has not been booted with systemd as init system")
} }
if machineCtlPath, err := exec.LookPath("machinectl"); err != nil { if machineCtlPath, err := a.os.LookPath("machinectl"); err != nil {
return (*LauncherLookupError)(wrapError(ErrMachineCtl, "machinectl not found")) return fmsg.WrapError(ErrMachineCtl,
"machinectl not found")
} else { } else {
seal.toolPath = machineCtlPath seal.toolPath = machineCtlPath
} }
default: default:
return (*SealConfigError)(wrapError(ErrLaunch, "invalid launch method")) return fmsg.WrapError(ErrLaunch,
"invalid launch method")
} }
// create seal system component // create seal system component
seal.sys = new(appSealTx) seal.sys = new(appSealSys)
// look up fortify executable path // look up fortify executable path
if p, err := os.Executable(); err != nil { if p, err := a.os.Executable(); err != nil {
return (*LauncherLookupError)(wrapError(err, "cannot look up fortify executable path:", err)) return fmsg.WrapErrorSuffix(err, "cannot look up fortify executable path:")
} else { } else {
seal.sys.executable = p seal.sys.executable = p
} }
// look up user from system // look up user from system
if u, err := user.Lookup(config.User); err != nil { if u, err := a.os.Lookup(config.User); err != nil {
if errors.As(err, new(user.UnknownUserError)) { if errors.As(err, new(user.UnknownUserError)) {
return (*SealConfigError)(wrapError(ErrUser, "unknown user", config.User)) return fmsg.WrapError(ErrUser, "unknown user", config.User)
} else { } else {
// unreachable // unreachable
panic(err) panic(err)
} }
} else { } else {
seal.sys.User = u seal.sys.user = u
seal.sys.runtime = path.Join("/run/user", u.Uid)
}
// map sandbox config to bwrap
if config.Confinement.Sandbox == nil {
fmsg.VPrintln("sandbox configuration not supplied, PROCEED WITH CAUTION")
// permissive defaults
conf := &SandboxConfig{
UserNS: true,
Net: true,
NoNewSession: true,
}
// bind entries in /
if d, err := a.os.ReadDir("/"); err != nil {
return err
} else {
b := make([]*FilesystemConfig, 0, len(d))
for _, ent := range d {
p := "/" + ent.Name()
switch p {
case "/proc":
case "/dev":
case "/run":
case "/tmp":
case "/mnt":
case "/etc":
b = append(b, &FilesystemConfig{Src: p, Dst: "/dev/fortify/etc", Write: false, Must: true})
default:
b = append(b, &FilesystemConfig{Src: p, Write: true, Must: true})
}
}
conf.Filesystem = append(conf.Filesystem, b...)
}
// bind entries in /run
if d, err := a.os.ReadDir("/run"); err != nil {
return err
} else {
b := make([]*FilesystemConfig, 0, len(d))
for _, ent := range d {
name := ent.Name()
switch name {
case "user":
case "dbus":
default:
p := "/run/" + name
b = append(b, &FilesystemConfig{Src: p, Write: true, Must: true})
}
}
conf.Filesystem = append(conf.Filesystem, b...)
}
// hide nscd from sandbox if present
nscd := "/var/run/nscd"
if _, err := a.os.Stat(nscd); !errors.Is(err, fs.ErrNotExist) {
conf.Override = append(conf.Override, nscd)
}
// bind GPU stuff
if config.Confinement.Enablements.Has(system.EX11) || config.Confinement.Enablements.Has(system.EWayland) {
conf.Filesystem = append(conf.Filesystem, &FilesystemConfig{Src: "/dev/dri", Device: true})
}
// link host /etc to prevent passwd/group from being overwritten
if d, err := a.os.ReadDir("/etc"); err != nil {
return err
} else {
b := make([][2]string, 0, len(d))
for _, ent := range d {
name := ent.Name()
switch name {
case "passwd":
case "group":
case "mtab":
b = append(b, [2]string{
"/proc/mounts",
"/etc/" + name,
})
default:
b = append(b, [2]string{
"/dev/fortify/etc/" + name,
"/etc/" + name,
})
}
}
conf.Link = append(conf.Link, b...)
}
config.Confinement.Sandbox = conf
}
seal.sys.bwrap = config.Confinement.Sandbox.Bwrap()
seal.sys.override = config.Confinement.Sandbox.Override
if seal.sys.bwrap.SetEnv == nil {
seal.sys.bwrap.SetEnv = make(map[string]string)
}
// create wayland struct and client wait channel if mediated wayland is enabled
// this field being set enables mediated wayland setup later on
if config.Confinement.Sandbox.Wayland {
seal.wl = shim.NewWayland()
} }
// 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.SystemConstants.RunDirPath, seal.sys.Uid) seal.store = state.NewSimple(seal.RunDirPath, seal.sys.user.Uid)
// parse string UID // parse string UID
if u, err := strconv.Atoi(seal.sys.Uid); err != nil { if u, err := strconv.Atoi(seal.sys.user.Uid); err != nil {
// unreachable unless kernel bug // unreachable unless kernel bug
panic("uid parse") panic("uid parse")
} else { } else {
seal.sys.uid = u seal.sys.I = system.New(u)
} }
// pass through enablements // pass through enablements
seal.et = config.Confinement.Enablements seal.et = config.Confinement.Enablements
// this method calls all share methods in sequence // this method calls all share methods in sequence
if err := seal.shareAll([2]*dbus.Config{config.Confinement.SessionBus, config.Confinement.SystemBus}); err != nil { if err := seal.shareAll([2]*dbus.Config{config.Confinement.SessionBus, config.Confinement.SystemBus}, a.os); err != nil {
return err return err
} }
// verbose log seal information // verbose log seal information
verbose.Println("created application seal as user", fmsg.VPrintln("created application seal as user",
seal.sys.Username, "("+seal.sys.Uid+"),", seal.sys.user.Username, "("+seal.sys.user.Uid+"),",
"method:", config.Method+",", "method:", config.Method+",",
"launcher:", seal.toolPath+",", "launcher:", seal.toolPath+",",
"command:", config.Command) "command:", config.Command)

View File

@ -1,15 +1,11 @@
package app package app
import ( import (
"errors"
"fmt"
"os"
"path" "path"
"git.ophivana.moe/cat/fortify/acl" "git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/cat/fortify/dbus" "git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/security/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/verbose"
) )
const ( const (
@ -17,118 +13,30 @@ const (
dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS"
) )
var (
ErrDBusConfig = errors.New("dbus config not supplied")
)
type (
SealDBusError BaseError
LookupDBusError BaseError
StartDBusError BaseError
CloseDBusError BaseError
)
func (seal *appSeal) shareDBus(config [2]*dbus.Config) error { func (seal *appSeal) shareDBus(config [2]*dbus.Config) error {
if !seal.et.Has(state.EnableDBus) { if !seal.et.Has(system.EDBus) {
return nil return nil
} }
// session bus is mandatory
if config[0] == nil {
return (*SealDBusError)(wrapError(ErrDBusConfig, "attempted to seal session bus proxy with nil config"))
}
// system bus is optional
seal.sys.dbusSystem = config[1] != nil
// upstream address, downstream socket path
var sessionBus, systemBus [2]string
// downstream socket paths // downstream socket paths
sessionBus[1] = path.Join(seal.share, "bus") sessionPath, systemPath := path.Join(seal.share, "bus"), path.Join(seal.share, "system_bus_socket")
systemBus[1] = path.Join(seal.share, "system_bus_socket")
// resolve upstream bus addresses // configure dbus proxy
sessionBus[0], systemBus[0] = dbus.Address() if err := seal.sys.ProxyDBus(config[0], config[1], sessionPath, systemPath); err != nil {
return err
// create proxy instance
seal.sys.dbus = dbus.New(sessionBus, systemBus)
// seal dbus proxy
if err := seal.sys.dbus.Seal(config[0], config[1]); err != nil {
return (*SealDBusError)(wrapError(err, "cannot seal message bus proxy:", err))
} }
// store addresses for cleanup and logging
seal.sys.dbusAddr = &[2][2]string{sessionBus, systemBus}
// share proxy sockets // share proxy sockets
seal.appendEnv(dbusSessionBusAddress, "unix:path="+sessionBus[1]) sessionInner := path.Join(seal.sys.runtime, "bus")
seal.sys.updatePerm(sessionBus[1], acl.Read, acl.Write) seal.sys.bwrap.SetEnv[dbusSessionBusAddress] = "unix:path=" + sessionInner
if seal.sys.dbusSystem { seal.sys.bwrap.Bind(sessionPath, sessionInner)
seal.appendEnv(dbusSystemBusAddress, "unix:path="+systemBus[1]) seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write)
seal.sys.updatePerm(systemBus[1], acl.Read, acl.Write) if config[1] != nil {
systemInner := "/run/dbus/system_bus_socket"
seal.sys.bwrap.SetEnv[dbusSystemBusAddress] = "unix:path=" + systemInner
seal.sys.bwrap.Bind(systemPath, systemInner)
seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write)
} }
return nil return nil
} }
func (tx *appSealTx) startDBus() error {
// ready channel passed to dbus package
ready := make(chan error, 1)
// used by waiting goroutine to notify process return
tx.dbusWait = make(chan struct{})
// background dbus proxy start
if err := tx.dbus.Start(ready, os.Stderr, true); err != nil {
return (*StartDBusError)(wrapError(err, "cannot start message bus proxy:", err))
}
verbose.Println("starting message bus proxy:", tx.dbus)
verbose.Println("message bus proxy bwrap args:", tx.dbus.Bwrap())
// background wait for proxy instance and notify completion
go func() {
if err := tx.dbus.Wait(); err != nil {
fmt.Println("fortify: warn: message bus proxy returned error:", err)
go func() { ready <- err }()
} else {
verbose.Println("message bus proxy exit")
}
// ensure socket removal so ephemeral directory is empty at revert
if err := os.Remove(tx.dbusAddr[0][1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("fortify: cannot remove dangling session bus socket:", err)
}
if tx.dbusSystem {
if err := os.Remove(tx.dbusAddr[1][1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("fortify: cannot remove dangling system bus socket:", err)
}
}
// notify proxy completion
tx.dbusWait <- struct{}{}
}()
// ready is not nil if the proxy process faulted
if err := <-ready; err != nil {
// note that err here is either an I/O related error or a predetermined unexpected behaviour error
return (*StartDBusError)(wrapError(err, "message bus proxy fault after start:", err))
}
verbose.Println("message bus proxy ready")
return nil
}
func (tx *appSealTx) stopDBus() error {
if err := tx.dbus.Close(); err != nil {
if errors.Is(err, os.ErrClosed) {
return (*CloseDBusError)(wrapError(err, "message bus proxy already closed"))
} else {
return (*CloseDBusError)(wrapError(err, "cannot close message bus proxy:", err))
}
}
// block until proxy wait returns
<-tx.dbusWait
return nil
}

View File

@ -2,11 +2,12 @@ package app
import ( import (
"errors" "errors"
"os"
"path" "path"
"git.ophivana.moe/cat/fortify/acl" "git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/system"
) )
const ( const (
@ -22,36 +23,44 @@ var (
ErrXDisplay = errors.New(display + " unset") ErrXDisplay = errors.New(display + " unset")
) )
type ErrDisplayEnv BaseError func (seal *appSeal) shareDisplay(os internal.System) error {
func (seal *appSeal) shareDisplay() error {
// pass $TERM to launcher // pass $TERM to launcher
if t, ok := os.LookupEnv(term); ok { if t, ok := os.LookupEnv(term); ok {
seal.appendEnv(term, t) seal.sys.bwrap.SetEnv[term] = t
} }
// set up wayland // set up wayland
if seal.et.Has(state.EnableWayland) { if seal.et.Has(system.EWayland) {
if wd, ok := os.LookupEnv(waylandDisplay); !ok { if wd, ok := os.LookupEnv(waylandDisplay); !ok {
return (*ErrDisplayEnv)(wrapError(ErrWayland, "WAYLAND_DISPLAY is not set")) return fmsg.WrapError(ErrWayland,
} else { "WAYLAND_DISPLAY is not set")
// wayland socket path } else if seal.wl == nil {
// hardlink wayland socket
wp := path.Join(seal.RuntimePath, wd) wp := path.Join(seal.RuntimePath, wd)
seal.appendEnv(waylandDisplay, wp) wpi := path.Join(seal.shareLocal, "wayland")
w := path.Join(seal.sys.runtime, "wayland-0")
seal.sys.Link(wp, wpi)
seal.sys.bwrap.SetEnv[waylandDisplay] = w
seal.sys.bwrap.Bind(wpi, w)
// ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`) // ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`)
seal.sys.updatePerm(wp, acl.Read, acl.Write, acl.Execute) seal.sys.UpdatePermType(system.EWayland, wp, acl.Read, acl.Write, acl.Execute)
} else {
// set wayland socket path for mediation (e.g. `/run/user/%d/wayland-%d`)
seal.wl.Path = path.Join(seal.RuntimePath, wd)
} }
} }
// set up X11 // set up X11
if seal.et.Has(state.EnableX) { if seal.et.Has(system.EX11) {
// discover X11 and grant user permission via the `ChangeHosts` command // discover X11 and grant user permission via the `ChangeHosts` command
if d, ok := os.LookupEnv(display); !ok { if d, ok := os.LookupEnv(display); !ok {
return (*ErrDisplayEnv)(wrapError(ErrXDisplay, "DISPLAY is not set")) return fmsg.WrapError(ErrXDisplay,
"DISPLAY is not set")
} else { } else {
seal.sys.changeHosts(seal.sys.Username) seal.sys.ChangeHosts(seal.sys.user.Username)
seal.appendEnv(display, d) seal.sys.bwrap.SetEnv[display] = d
seal.sys.bwrap.Bind("/tmp/.X11-unix", "/tmp/.X11-unix")
} }
} }

View File

@ -4,11 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os"
"path" "path"
"git.ophivana.moe/cat/fortify/acl" "git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/system"
) )
const ( const (
@ -25,60 +25,60 @@ var (
ErrPulseMode = errors.New("unexpected pulse socket mode") ErrPulseMode = errors.New("unexpected pulse socket mode")
) )
type ( func (seal *appSeal) sharePulse(os internal.System) error {
PulseCookieAccessError BaseError if !seal.et.Has(system.EPulse) {
PulseSocketAccessError BaseError
)
func (seal *appSeal) sharePulse() error {
if !seal.et.Has(state.EnablePulse) {
return nil return nil
} }
// ensure PulseAudio directory ACL (e.g. `/run/user/%d/pulse`) // check PulseAudio directory presence (e.g. `/run/user/%d/pulse`)
pd := path.Join(seal.RuntimePath, "pulse") pd := path.Join(seal.RuntimePath, "pulse")
ps := path.Join(pd, "native") ps := path.Join(pd, "native")
if _, err := os.Stat(pd); err != nil { if _, err := os.Stat(pd); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return (*PulseSocketAccessError)(wrapError(err, return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio directory '%s':", pd), err)) fmt.Sprintf("cannot access PulseAudio directory %q:", pd))
} }
return (*PulseSocketAccessError)(wrapError(ErrPulseSocket, return fmsg.WrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory '%s' not found", pd))) fmt.Sprintf("PulseAudio directory %q not found", pd))
} }
seal.appendEnv(pulseServer, "unix:"+ps) // check PulseAudio socket permission (e.g. `/run/user/%d/pulse/native`)
seal.sys.updatePerm(pd, acl.Execute)
// ensure PulseAudio socket permission (e.g. `/run/user/%d/pulse/native`)
if s, err := os.Stat(ps); err != nil { if s, err := os.Stat(ps); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return (*PulseSocketAccessError)(wrapError(err, return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio socket '%s':", ps), err)) fmt.Sprintf("cannot access PulseAudio socket %q:", ps))
} }
return (*PulseSocketAccessError)(wrapError(ErrPulseSocket, return fmsg.WrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory '%s' found but socket does not exist", pd))) fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pd))
} else { } else {
if m := s.Mode(); m&0o006 != 0o006 { if m := s.Mode(); m&0o006 != 0o006 {
return (*PulseSocketAccessError)(wrapError(ErrPulseMode, return fmsg.WrapError(ErrPulseMode,
fmt.Sprintf("unexpected permissions on '%s':", ps), m)) fmt.Sprintf("unexpected permissions on %q:", ps), m)
} }
} }
// hard link pulse socket into target-executable share
psi := path.Join(seal.shareLocal, "pulse")
p := path.Join(seal.sys.runtime, "pulse", "native")
seal.sys.Link(ps, psi)
seal.sys.bwrap.Bind(psi, p)
seal.sys.bwrap.SetEnv[pulseServer] = "unix:" + p
// publish current user's pulse cookie for target user // publish current user's pulse cookie for target user
if src, err := discoverPulseCookie(); err != nil { if src, err := discoverPulseCookie(os); err != nil {
return err return err
} else { } else {
dst := path.Join(seal.share, "pulse-cookie") dst := path.Join(seal.share, "pulse-cookie")
seal.appendEnv(pulseCookie, dst) seal.sys.bwrap.SetEnv[pulseCookie] = dst
seal.sys.copyFile(dst, src) seal.sys.CopyFile(dst, src)
seal.sys.bwrap.Bind(dst, dst)
} }
return nil return nil
} }
// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie // discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie
func discoverPulseCookie() (string, error) { func discoverPulseCookie(os internal.System) (string, error) {
if p, ok := os.LookupEnv(pulseCookie); ok { if p, ok := os.LookupEnv(pulseCookie); ok {
return p, nil return p, nil
} }
@ -88,8 +88,8 @@ func discoverPulseCookie() (string, error) {
p = path.Join(p, ".pulse-cookie") p = path.Join(p, ".pulse-cookie")
if s, err := os.Stat(p); err != nil { if s, err := os.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return p, (*PulseCookieAccessError)(wrapError(err, return p, fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio cookie '%s':", p), err)) fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
} }
// not found, try next method // not found, try next method
} else if !s.IsDir() { } else if !s.IsDir() {
@ -102,7 +102,8 @@ func discoverPulseCookie() (string, error) {
p = path.Join(p, "pulse", "cookie") p = path.Join(p, "pulse", "cookie")
if s, err := os.Stat(p); err != nil { if s, err := os.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return p, (*PulseCookieAccessError)(wrapError(err, "cannot access PulseAudio cookie", p+":", err)) return p, fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
} }
// not found, try next method // not found, try next method
} else if !s.IsDir() { } else if !s.IsDir() {
@ -110,7 +111,7 @@ func discoverPulseCookie() (string, error) {
} }
} }
return "", (*PulseCookieAccessError)(wrapError(ErrPulseCookie, return "", fmsg.WrapError(ErrPulseCookie,
fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)", fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
pulseCookie, xdgConfigHome, home))) pulseCookie, xdgConfigHome, home))
} }

View File

@ -3,7 +3,8 @@ package app
import ( import (
"path" "path"
"git.ophivana.moe/cat/fortify/acl" "git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/system"
) )
const ( const (
@ -14,37 +15,25 @@ const (
// shareRuntime queues actions for sharing/ensuring the runtime and share directories // shareRuntime queues actions for sharing/ensuring the runtime and share directories
func (seal *appSeal) shareRuntime() { func (seal *appSeal) shareRuntime() {
// mount tmpfs on inner runtime (e.g. `/run/user/%d`)
seal.sys.bwrap.Tmpfs("/run/user", 1*1024*1024)
seal.sys.bwrap.Tmpfs(seal.sys.runtime, 8*1024*1024)
// point to inner runtime path `/run/user/%d`
seal.sys.bwrap.SetEnv[xdgRuntimeDir] = seal.sys.runtime
seal.sys.bwrap.SetEnv[xdgSessionClass] = "user"
seal.sys.bwrap.SetEnv[xdgSessionType] = "tty"
// ensure RunDir (e.g. `/run/user/%d/fortify`) // ensure RunDir (e.g. `/run/user/%d/fortify`)
seal.sys.ensure(seal.RunDirPath, 0700) seal.sys.Ensure(seal.RunDirPath, 0700)
seal.sys.UpdatePermType(system.User, seal.RunDirPath, acl.Execute)
// ensure runtime directory ACL (e.g. `/run/user/%d`) // ensure runtime directory ACL (e.g. `/run/user/%d`)
seal.sys.updatePerm(seal.RuntimePath, acl.Execute) seal.sys.Ensure(seal.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset
seal.sys.UpdatePermType(system.User, seal.RuntimePath, acl.Execute)
// ensure Share (e.g. `/tmp/fortify.%d`) // ensure process-specific share local to XDG_RUNTIME_DIR (e.g. `/run/user/%d/fortify/%s`)
// acl is unnecessary as this directory is world executable seal.shareLocal = path.Join(seal.RunDirPath, seal.id)
seal.sys.ensure(seal.SharePath, 0701) seal.sys.Ephemeral(system.Process, seal.shareLocal, 0700)
seal.sys.UpdatePerm(seal.shareLocal, acl.Execute)
// ensure process-specific share (e.g. `/tmp/fortify.%d/%s`)
// acl is unnecessary as this directory is world executable
seal.share = path.Join(seal.SharePath, seal.id.String())
seal.sys.ensureEphemeral(seal.share, 0701)
}
func (seal *appSeal) shareRuntimeChild() string {
// ensure child runtime parent directory (e.g. `/tmp/fortify.%d/runtime`)
targetRuntimeParent := path.Join(seal.SharePath, "runtime")
seal.sys.ensure(targetRuntimeParent, 0700)
seal.sys.updatePerm(targetRuntimeParent, acl.Execute)
// ensure child runtime directory (e.g. `/tmp/fortify.%d/runtime/%d`)
targetRuntime := path.Join(targetRuntimeParent, seal.sys.Uid)
seal.sys.ensure(targetRuntime, 0700)
seal.sys.updatePerm(targetRuntime, acl.Read, acl.Write, acl.Execute)
// point to ensured runtime path
seal.appendEnv(xdgRuntimeDir, targetRuntime)
seal.appendEnv(xdgSessionClass, "user")
seal.appendEnv(xdgSessionType, "tty")
return targetRuntime
} }

View File

@ -0,0 +1,71 @@
package app
import (
"path"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/system"
)
const (
shell = "SHELL"
)
// shareSystem queues various system-related actions
func (seal *appSeal) shareSystem() {
// ensure Share (e.g. `/tmp/fortify.%d`)
// acl is unnecessary as this directory is world executable
seal.sys.Ensure(seal.SharePath, 0701)
// ensure process-specific share (e.g. `/tmp/fortify.%d/%s`)
// acl is unnecessary as this directory is world executable
seal.share = path.Join(seal.SharePath, seal.id)
seal.sys.Ephemeral(system.Process, seal.share, 0701)
// ensure child tmpdir parent directory (e.g. `/tmp/fortify.%d/tmpdir`)
targetTmpdirParent := path.Join(seal.SharePath, "tmpdir")
seal.sys.Ensure(targetTmpdirParent, 0700)
seal.sys.UpdatePermType(system.User, targetTmpdirParent, acl.Execute)
// ensure child tmpdir (e.g. `/tmp/fortify.%d/tmpdir/%d`)
targetTmpdir := path.Join(targetTmpdirParent, seal.sys.user.Uid)
seal.sys.Ensure(targetTmpdir, 01700)
seal.sys.UpdatePermType(system.User, targetTmpdir, acl.Read, acl.Write, acl.Execute)
seal.sys.bwrap.Bind(targetTmpdir, "/tmp", false, true)
// mount tmpfs on inner shared directory (e.g. `/tmp/fortify.%d`)
seal.sys.bwrap.Tmpfs(seal.SharePath, 1*1024*1024)
}
func (seal *appSeal) sharePasswd(os internal.System) {
// look up shell
sh := "/bin/sh"
if s, ok := os.LookupEnv(shell); ok {
seal.sys.bwrap.SetEnv[shell] = s
sh = s
}
// generate /etc/passwd
passwdPath := path.Join(seal.share, "passwd")
username := "chronos"
if seal.sys.user.Username != "" {
username = seal.sys.user.Username
seal.sys.bwrap.SetEnv["USER"] = seal.sys.user.Username
}
homeDir := "/var/empty"
if seal.sys.user.HomeDir != "" {
homeDir = seal.sys.user.HomeDir
seal.sys.bwrap.SetEnv["HOME"] = seal.sys.user.HomeDir
}
passwd := username + ":x:65534:65534:Fortify:" + homeDir + ":" + sh + "\n"
seal.sys.Write(passwdPath, passwd)
// write /etc/group
groupPath := path.Join(seal.share, "group")
seal.sys.Write(groupPath, "fortify:x:65534:\n")
// bind /etc/passwd and /etc/group
seal.sys.bwrap.Bind(passwdPath, "/etc/passwd")
seal.sys.bwrap.Bind(groupPath, "/etc/group")
}

View File

@ -1,83 +0,0 @@
package app
import (
"bytes"
"encoding/base64"
"encoding/gob"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
)
const shimPayload = "FORTIFY_SHIM_PAYLOAD"
func (a *app) shimPayloadEnv() string {
r := &bytes.Buffer{}
enc := base64.NewEncoder(base64.StdEncoding, r)
if err := gob.NewEncoder(enc).Encode(a.seal.command); err != nil {
// should be unreachable
panic(err)
}
_ = enc.Close()
return shimPayload + "=" + r.String()
}
// TryShim attempts the early hidden launcher shim path
func TryShim() {
// environment variable contains encoded argv
if r, ok := os.LookupEnv(shimPayload); ok {
// everything beyond this point runs as target user
// proceed with caution!
// parse base64 revealing underlying gob stream
dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(r))
// decode argv gob stream
var argv []string
if err := gob.NewDecoder(dec).Decode(&argv); err != nil {
fmt.Println("fortify-shim: cannot decode shim payload:", err)
os.Exit(1)
}
// remove payload variable since the child does not need to see it
if err := os.Unsetenv(shimPayload); err != nil {
fmt.Println("fortify-shim: cannot unset shim payload:", err)
// not fatal, do not fail
}
// look up argv0
var argv0 string
if len(argv) > 0 {
// look up program from $PATH
if p, err := exec.LookPath(argv[0]); err != nil {
fmt.Printf("%s not found: %s\n", argv[0], err)
os.Exit(1)
} else {
argv0 = p
}
} else {
// no argv, look up shell instead
if argv0, ok = os.LookupEnv("SHELL"); !ok {
fmt.Println("fortify-shim: no command was specified and $SHELL was unset")
os.Exit(1)
}
argv = []string{argv0}
}
// exec target process
if err := syscall.Exec(argv0, argv, os.Environ()); err != nil {
fmt.Println("fortify-shim: cannot execute shim payload:", err)
os.Exit(1)
}
// unreachable
os.Exit(1)
return
}
}

View File

@ -2,31 +2,46 @@ package app
import ( import (
"errors" "errors"
"os" "fmt"
"os/exec" "os/exec"
"strconv" "path"
"time" "path/filepath"
"strings"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/security/fortify/helper"
"git.ophivana.moe/cat/fortify/internal/verbose" "git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/shim"
"git.ophivana.moe/security/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/system"
) )
type ( // Start selects a user switcher and starts shim.
// ProcessError encapsulates errors returned by starting *exec.Cmd // Note that Wait must be called regardless of error returned by Start.
ProcessError BaseError
)
// Start starts the fortified child
func (a *app) Start() error { func (a *app) Start() error {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
if err := a.seal.sys.commit(); err != nil { // resolve exec paths
return err shimExec := [3]string{a.seal.sys.executable, helper.BubblewrapName}
if len(a.seal.command) > 0 {
shimExec[2] = a.seal.command[0]
}
for i, n := range shimExec {
if len(n) == 0 {
continue
}
if filepath.Base(n) == n {
if s, err := exec.LookPath(n); err == nil {
shimExec[i] = s
} else {
return fmsg.WrapError(err,
fmt.Sprintf("executable file %q not found in $PATH", n))
}
}
} }
// select command builder // select command builder
var commandBuilder func() (args []string) var commandBuilder shim.CommandBuilder
switch a.seal.launchOption { switch a.seal.launchOption {
case LaunchMethodSudo: case LaunchMethodSudo:
commandBuilder = a.commandBuilderSudo commandBuilder = a.commandBuilderSudo
@ -36,37 +51,48 @@ func (a *app) Start() error {
panic("unreachable") panic("unreachable")
} }
// configure child process // construct shim manager
a.cmd = exec.Command(a.seal.toolPath, commandBuilder()...) a.shim = shim.New(a.seal.toolPath, uint32(a.seal.sys.UID()), path.Join(a.seal.share, "shim"), a.seal.wl,
a.cmd.Env = []string{} &shim.Payload{
a.cmd.Stdin = os.Stdin Argv: a.seal.command,
a.cmd.Stdout = os.Stdout Exec: shimExec,
a.cmd.Stderr = os.Stderr Bwrap: a.seal.sys.bwrap,
a.cmd.Dir = a.seal.RunDirPath WL: a.seal.wl != nil,
// start child process Verbose: fmsg.Verbose(),
verbose.Println("starting main process:", a.cmd) },
if err := a.cmd.Start(); err != nil { // checkPid is impossible at the moment since there is no reliable way to obtain shim's pid
return (*ProcessError)(wrapError(err, "cannot start process:", err)) // this feature is disabled here until sudo is replaced by fortify suid wrapper
false,
)
// startup will go ahead, commit system setup
if err := a.seal.sys.Commit(); err != nil {
return err
} }
startTime := time.Now().UTC() a.seal.sys.needRevert = true
// create process state if startTime, err := a.shim.Start(commandBuilder); err != nil {
return err
} else {
// shim start and setup success, create process state
sd := state.State{ sd := state.State{
PID: a.cmd.Process.Pid, PID: a.shim.Unwrap().Process.Pid,
Command: a.seal.command, Command: a.seal.command,
Capability: a.seal.et, Capability: a.seal.et,
Launcher: a.seal.toolPath, Method: method[a.seal.launchOption],
Argv: a.cmd.Args, Argv: a.shim.Unwrap().Args,
Time: startTime, Time: *startTime,
} }
// register process state // register process state
var e = new(StateStoreError) var err0 = new(StateStoreError)
e.Inner, e.DoErr = a.seal.store.Do(func(b state.Backend) { err0.Inner, err0.DoErr = a.seal.store.Do(func(b state.Backend) {
e.InnerErr = b.Save(&sd) err0.InnerErr = b.Save(&sd)
}) })
return e.equiv("cannot save process state:", e) a.seal.sys.saveState = true
return err0.equiv("cannot save process state:")
}
} }
// StateStoreError is returned for a failed state save // StateStoreError is returned for a failed state save
@ -82,10 +108,10 @@ type StateStoreError struct {
} }
func (e *StateStoreError) equiv(a ...any) error { func (e *StateStoreError) equiv(a ...any) error {
if e.Inner == true && e.DoErr == nil && e.InnerErr == nil && e.Err == nil { if e.Inner && e.DoErr == nil && e.InnerErr == nil && e.Err == nil {
return nil return nil
} else { } else {
return wrapError(e, a...) return fmsg.WrapErrorSuffix(e, a...)
} }
} }
@ -128,51 +154,94 @@ func (a *app) Wait() (int, error) {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
if a.shim == nil {
fmsg.VPrintln("shim not initialised, skipping cleanup")
return 1, nil
}
var r int var r int
if cmd := a.shim.Unwrap(); cmd == nil {
// failure prior to process start
r = 255
} else {
// wait for process and resolve exit code // wait for process and resolve exit code
if err := a.cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
var exitError *exec.ExitError var exitError *exec.ExitError
if !errors.As(err, &exitError) { if !errors.As(err, &exitError) {
// should be unreachable // should be unreachable
a.wait = err a.waitErr = err
} }
// store non-zero return code // store non-zero return code
r = exitError.ExitCode() r = exitError.ExitCode()
} else { } else {
r = a.cmd.ProcessState.ExitCode() r = cmd.ProcessState.ExitCode()
}
fmsg.VPrintf("process %d exited with exit code %d", cmd.Process.Pid, r)
} }
verbose.Println("process", strconv.Itoa(a.cmd.Process.Pid), "exited with exit code", r) // child process exited, resume output
fmsg.Resume()
// close wayland connection
if a.seal.wl != nil {
if err := a.seal.wl.Close(); err != nil {
fmsg.Println("cannot close wayland connection:", err)
}
}
// 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(func(b state.Backend) {
e.InnerErr = func() error { e.InnerErr = func() error {
// destroy defunct state entry // destroy defunct state entry
if err := b.Destroy(a.cmd.Process.Pid); err != nil { if cmd := a.shim.Unwrap(); cmd != nil && a.seal.sys.saveState {
if err := b.Destroy(cmd.Process.Pid); err != nil {
return err return err
} }
}
var global bool // enablements of remaining launchers
rt, ec := new(system.Enablements), new(system.Criteria)
// measure remaining state entries ec.Enablements = new(system.Enablements)
if l, err := b.Len(); err != nil { ec.Set(system.Process)
if states, err := b.Load(); err != nil {
return err return err
} else { } else {
// clean up global modifications if we're the last launcher alive if l := len(states); l == 0 {
global = l == 0 // cleanup globals as the final launcher
fmsg.VPrintln("no other launchers active, will clean up globals")
if !global { ec.Set(system.User)
verbose.Printf("found %d active launchers, cleaning up without globals\n", l)
} else { } else {
verbose.Println("no other launchers active, will clean up globals") fmsg.VPrintf("found %d active launchers, cleaning up without globals", l)
}
// accumulate capabilities of other launchers
for _, s := range states {
*rt |= s.Capability
}
}
// invert accumulated enablements for cleanup
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if !rt.Has(i) {
ec.Set(i)
}
}
if fmsg.Verbose() {
labels := make([]string, 0, system.ELen+1)
for i := system.Enablement(0); i < system.Enablement(system.ELen+2); i++ {
if ec.Has(i) {
labels = append(labels, system.TypeString(i))
}
}
if len(labels) > 0 {
fmsg.VPrintln("reverting operations labelled", strings.Join(labels, ", "))
} }
} }
// FIXME: depending on exit sequence, some parts of the transaction never gets reverted a.shim.AbortWait(errors.New("shim exited"))
if err := a.seal.sys.revert(global); err != nil { if err := a.seal.sys.Revert(ec); err != nil {
return err.(RevertCompoundError) return err.(RevertCompoundError)
} }

View File

@ -1,327 +1,48 @@
package app package app
import ( import (
"errors"
"fmt"
"io/fs"
"os"
"os/user" "os/user"
"git.ophivana.moe/cat/fortify/acl" "git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/cat/fortify/dbus" "git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/cat/fortify/internal" "git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/security/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/verbose"
"git.ophivana.moe/cat/fortify/xcb"
) )
// appSeal seals the application with child-related information // appSealSys encapsulates app seal behaviour with OS interactions
type appSeal struct { type appSealSys struct {
// application unique identifier bwrap *bwrap.Config
id *appID // paths to override by mounting tmpfs over them
override []string
// freedesktop application ID
fid string
// argv to start process with in the final confined environment
command []string
// environment variables of fortified process
env []string
// persistent process state store
store state.Store
// uint8 representation of launch method sealed from config
launchOption uint8
// process-specific share directory path
share string
// path to launcher program
toolPath string
// pass-through enablement tracking from config
et state.Enablements
// prevents sharing from happening twice
shared bool
// seal system-level component
sys *appSealTx
// used in various sealing operations
internal.SystemConstants
// protected by upstream mutex
}
// appendEnv appends an environment variable for the child process
func (seal *appSeal) appendEnv(k, v string) {
seal.env = append(seal.env, k+"="+v)
}
// appSealTx contains the system-level component of the app seal
type appSealTx struct {
// reference to D-Bus proxy instance, nil if disabled
dbus *dbus.Proxy
// notification from goroutine waiting for dbus.Proxy
dbusWait chan struct{}
// upstream address/downstream path used to initialise dbus.Proxy
dbusAddr *[2][2]string
// whether system bus proxy is enabled
dbusSystem bool
// paths to append/strip ACLs (of target user) from
acl []*appACLEntry
// X11 ChangeHosts commands to perform
xhost []string
// paths of directories to ensure
mkdir []appEnsureEntry
// dst, src pairs of temporarily shared files
tmpfiles [][2]string
// default formatted XDG_RUNTIME_DIR of User
runtime string
// sealed path to fortify executable, used by shim // sealed path to fortify executable, used by shim
executable string executable string
// target user UID as an integer
uid int
// target user sealed from config // target user sealed from config
*user.User user *user.User
// prevents commit from happening twice needRevert bool
complete bool saveState bool
// prevents cleanup from happening twice *system.I
closed bool
// protected by upstream mutex // protected by upstream mutex
} }
type appEnsureEntry struct {
path string
perm os.FileMode
remove bool
}
// ensure appends a directory ensure action
func (tx *appSealTx) ensure(path string, perm os.FileMode) {
tx.mkdir = append(tx.mkdir, appEnsureEntry{path, perm, false})
}
// ensureEphemeral appends a directory ensure action with removal in rollback
func (tx *appSealTx) ensureEphemeral(path string, perm os.FileMode) {
tx.mkdir = append(tx.mkdir, appEnsureEntry{path, perm, true})
}
// appACLEntry contains information for applying/reverting an ACL entry
type appACLEntry struct {
path string
perms []acl.Perm
}
func (e *appACLEntry) String() string {
var s = []byte("---")
for _, p := range e.perms {
switch p {
case acl.Read:
s[0] = 'r'
case acl.Write:
s[1] = 'w'
case acl.Execute:
s[2] = 'x'
}
}
return string(s)
}
// updatePerm appends an acl update action
func (tx *appSealTx) updatePerm(path string, perms ...acl.Perm) {
tx.acl = append(tx.acl, &appACLEntry{path, perms})
}
// changeHosts appends target username of an X11 ChangeHosts action
func (tx *appSealTx) changeHosts(username string) {
tx.xhost = append(tx.xhost, username)
}
// copyFile appends a tmpfiles action
func (tx *appSealTx) copyFile(dst, src string) {
tx.tmpfiles = append(tx.tmpfiles, [2]string{dst, src})
tx.updatePerm(dst, acl.Read)
}
type (
ChangeHostsError BaseError
EnsureDirError BaseError
TmpfileError BaseError
DBusStartError BaseError
ACLUpdateError BaseError
)
// commit applies recorded actions
// order: xhost, mkdir, tmpfiles, dbus, acl
func (tx *appSealTx) commit() error {
if tx.complete {
panic("seal transaction committed twice")
}
tx.complete = true
txp := &appSealTx{}
defer func() {
// rollback partial commit
if txp != nil {
// global changes (x11, ACLs) are always repeated and check for other launchers cannot happen here
// attempting cleanup here will cause other fortified processes to lose access to them
// a better (and more secure) fix is to proxy access to these resources and eliminate the ACLs altogether
if err := txp.revert(false); err != nil {
fmt.Println("fortify: errors returned reverting partial commit:", err)
}
}
}()
// insert xhost entries
for _, username := range tx.xhost {
verbose.Printf("inserting XHost entry SI:localuser:%s\n", username)
if err := xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+username); err != nil {
return (*ChangeHostsError)(wrapError(err,
fmt.Sprintf("cannot insert XHost entry SI:localuser:%s, %s", username, err)))
} else {
// register partial commit
txp.changeHosts(username)
}
}
// ensure directories
for _, dir := range tx.mkdir {
verbose.Println("ensuring directory mode:", dir.perm.String(), "path:", dir.path)
if err := os.Mkdir(dir.path, dir.perm); err != nil && !errors.Is(err, fs.ErrExist) {
return (*EnsureDirError)(wrapError(err,
fmt.Sprintf("cannot create directory '%s': %s", dir.path, err)))
} else {
// only ephemeral dirs require rollback
if dir.remove {
// register partial commit
txp.ensureEphemeral(dir.path, dir.perm)
}
}
}
// publish tmpfiles
for _, tmpfile := range tx.tmpfiles {
verbose.Println("publishing tmpfile", tmpfile[0], "from", tmpfile[1])
if err := copyFile(tmpfile[0], tmpfile[1]); err != nil {
return (*TmpfileError)(wrapError(err,
fmt.Sprintf("cannot publish tmpfile '%s' from '%s': %s", tmpfile[0], tmpfile[1], err)))
} else {
// register partial commit
txp.copyFile(tmpfile[0], tmpfile[1])
}
}
if tx.dbus != nil {
// start dbus proxy
verbose.Printf("session bus proxy on '%s' for upstream '%s'\n", tx.dbusAddr[0][1], tx.dbusAddr[0][0])
if tx.dbusSystem {
verbose.Printf("system bus proxy on '%s' for upstream '%s'\n", tx.dbusAddr[1][1], tx.dbusAddr[1][0])
}
if err := tx.startDBus(); err != nil {
return (*DBusStartError)(wrapError(err, "cannot start message bus proxy:", err))
} else {
txp.dbus = tx.dbus
txp.dbusAddr = tx.dbusAddr
txp.dbusSystem = tx.dbusSystem
txp.dbusWait = tx.dbusWait
}
}
// apply ACLs
for _, e := range tx.acl {
verbose.Println("applying ACL", e, "uid:", tx.Uid, "path:", e.path)
if err := acl.UpdatePerm(e.path, tx.uid, e.perms...); err != nil {
return (*ACLUpdateError)(wrapError(err,
fmt.Sprintf("cannot apply ACL to '%s': %s", e.path, err)))
} else {
// register partial commit
txp.updatePerm(e.path, e.perms...)
}
}
// disarm partial commit rollback
txp = nil
return nil
}
// revert rolls back recorded actions
// order: acl, dbus, tmpfiles, mkdir, xhost
// errors are printed but not treated as fatal
func (tx *appSealTx) revert(global bool) error {
if tx.closed {
panic("seal transaction reverted twice")
}
tx.closed = true
// will be slightly over-sized with ephemeral dirs
errs := make([]error, 0, len(tx.acl)+1+len(tx.tmpfiles)+len(tx.mkdir)+len(tx.xhost))
joinError := func(err error, a ...any) {
var e error
if err != nil {
e = wrapError(err, a...)
}
errs = append(errs, e)
}
if global {
// revert ACLs
for _, e := range tx.acl {
verbose.Println("stripping ACL", e, "uid:", tx.Uid, "path:", e.path)
err := acl.UpdatePerm(e.path, tx.uid)
joinError(err, fmt.Sprintf("cannot strip ACL entry from '%s': %s", e.path, err))
}
}
if tx.dbus != nil {
// stop dbus proxy
verbose.Println("terminating message bus proxy")
err := tx.stopDBus()
joinError(err, "cannot stop message bus proxy:", err)
}
// remove tmpfiles
for _, tmpfile := range tx.tmpfiles {
verbose.Println("removing tmpfile", tmpfile[0])
err := os.Remove(tmpfile[0])
joinError(err, fmt.Sprintf("cannot remove tmpfile '%s': %s", tmpfile[0], err))
}
// remove (empty) ephemeral directories
for i := len(tx.mkdir); i > 0; i-- {
dir := tx.mkdir[i-1]
if !dir.remove {
continue
}
verbose.Println("destroying ephemeral directory mode:", dir.perm.String(), "path:", dir.path)
err := os.Remove(dir.path)
joinError(err, fmt.Sprintf("cannot remove ephemeral directory '%s': %s", dir.path, err))
}
if global {
// rollback xhost insertions
for _, username := range tx.xhost {
verbose.Printf("deleting XHost entry SI:localuser:%s\n", username)
err := xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+username)
joinError(err, "cannot remove XHost entry:", err)
}
}
return errors.Join(errs...)
}
// shareAll calls all share methods in sequence // shareAll calls all share methods in sequence
func (seal *appSeal) shareAll(bus [2]*dbus.Config) error { func (seal *appSeal) shareAll(bus [2]*dbus.Config, os internal.System) error {
if seal.shared { if seal.shared {
panic("seal shared twice") panic("seal shared twice")
} }
seal.shared = true seal.shared = true
seal.shareSystem()
seal.shareRuntime() seal.shareRuntime()
if err := seal.shareDisplay(); err != nil { seal.sharePasswd(os)
if err := seal.shareDisplay(os); err != nil {
return err return err
} }
if err := seal.sharePulse(); err != nil { if err := seal.sharePulse(os); err != nil {
return err return err
} }
@ -332,18 +53,11 @@ func (seal *appSeal) shareAll(bus [2]*dbus.Config) error {
if err := seal.shareDBus(bus); err != nil { if err := seal.shareDBus(bus); err != nil {
return err return err
} else if seal.sys.dbusAddr != nil { // set if D-Bus enabled and share successful
verbose.Println("sealed session proxy", bus[0].Args(seal.sys.dbusAddr[0]))
if bus[1] != nil {
verbose.Println("sealed system proxy", bus[1].Args(seal.sys.dbusAddr[1]))
}
verbose.Println("message bus proxy final args:", seal.sys.dbus)
} }
// workaround for launch method sudo // queue overriding tmpfs at the end of seal.sys.bwrap.Filesystem
if seal.launchOption == LaunchMethodSudo { for _, dest := range seal.sys.override {
targetRuntime := seal.shareRuntimeChild() seal.sys.bwrap.Tmpfs(dest, 8*1024)
verbose.Printf("child runtime data dir '%s' configured\n", targetRuntime)
} }
return nil return nil

View File

@ -1,34 +0,0 @@
package internal
import (
"errors"
"fmt"
"io/fs"
"os"
)
const (
systemdCheckPath = "/run/systemd/system"
)
var SdBootedV = func() bool {
if v, err := SdBooted(); err != nil {
fmt.Println("warn: read systemd marker:", err)
return false
} else {
return v
}
}()
// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html
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
}

View File

@ -1,59 +0,0 @@
package internal
import (
"fmt"
"os"
"path"
"strconv"
"sync"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
// state that remain constant for the lifetime of the process
// fetched and cached here
const (
xdgRuntimeDir = "XDG_RUNTIME_DIR"
)
// SystemConstants contains state from the operating system
type SystemConstants struct {
// path to shared directory e.g. /tmp/fortify.%d
SharePath string `json:"share_path"`
// XDG_RUNTIME_DIR value e.g. /run/user/%d
RuntimePath string `json:"runtime_path"`
// application runtime directory e.g. /run/user/%d/fortify
RunDirPath string `json:"run_dir_path"`
}
var (
scVal SystemConstants
scOnce sync.Once
)
func copySC() {
sc := SystemConstants{
SharePath: path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid())),
}
verbose.Println("process share directory at", sc.SharePath)
// runtimePath, runDirPath
if r, ok := os.LookupEnv(xdgRuntimeDir); !ok {
fmt.Println("Env variable", xdgRuntimeDir, "unset")
os.Exit(1)
} else {
sc.RuntimePath = r
sc.RunDirPath = path.Join(sc.RuntimePath, "fortify")
verbose.Println("XDG runtime directory at", sc.RunDirPath)
}
scVal = sc
}
// GetSC returns a populated SystemConstants value
func GetSC() SystemConstants {
scOnce.Do(copySC)
return scVal
}

72
internal/fmsg/defer.go Normal file
View File

@ -0,0 +1,72 @@
package fmsg
import (
"os"
"sync"
"sync/atomic"
)
var (
wstate atomic.Bool
withhold = make(chan struct{}, 1)
msgbuf = make(chan dOp, 64) // these ops are tiny so a large buffer is allocated for withholding output
dequeueOnce sync.Once
queueSync sync.WaitGroup
)
func dequeue() {
go func() {
for {
select {
case op := <-msgbuf:
op.Do()
queueSync.Done()
case <-withhold:
<-withhold
}
}
}()
}
type dOp interface{ Do() }
func Exit(code int) {
queueSync.Wait()
os.Exit(code)
}
func Withhold() {
dequeueOnce.Do(dequeue)
if wstate.CompareAndSwap(false, true) {
withhold <- struct{}{}
}
}
func Resume() {
dequeueOnce.Do(dequeue)
if wstate.CompareAndSwap(true, false) {
withhold <- struct{}{}
}
}
type dPrint []any
func (v dPrint) Do() {
std.Print(v...)
}
type dPrintf struct {
format string
v []any
}
func (d *dPrintf) Do() {
std.Printf(d.format, d.v...)
}
type dPrintln []any
func (v dPrintln) Do() {
std.Println(v...)
}

View File

@ -1,4 +1,4 @@
package app package fmsg
import ( import (
"fmt" "fmt"
@ -29,11 +29,32 @@ func (e *BaseError) Message() string {
return e.message return e.message
} }
func wrapError(err error, a ...any) *BaseError { // WrapError wraps an error with a corresponding message.
return &BaseError{ func WrapError(err error, a ...any) error {
message: fmt.Sprintln(a...), if err == nil {
baseError: baseError{err}, return nil
} }
return wrapError(err, fmt.Sprintln(a...))
}
// WrapErrorSuffix wraps an error with a corresponding message with err at the end of the message.
func WrapErrorSuffix(err error, a ...any) error {
if err == nil {
return nil
}
return wrapError(err, fmt.Sprintln(append(a, err)...))
}
// WrapErrorFunc wraps an error with a corresponding message returned by f.
func WrapErrorFunc(err error, f func(err error) string) error {
if err == nil {
return nil
}
return wrapError(err, f(err))
}
func wrapError(err error, message string) *BaseError {
return &BaseError{message, baseError{err}}
} }
var ( var (

43
internal/fmsg/fmsg.go Normal file
View File

@ -0,0 +1,43 @@
// Package fmsg provides various functions for output messages.
package fmsg
import (
"log"
"os"
)
var std = log.New(os.Stderr, "fortify: ", 0)
func SetPrefix(prefix string) {
prefix += ": "
std.SetPrefix(prefix)
std.SetPrefix(prefix)
}
func Print(v ...any) {
dequeueOnce.Do(dequeue)
queueSync.Add(1)
msgbuf <- dPrint(v)
}
func Printf(format string, v ...any) {
dequeueOnce.Do(dequeue)
queueSync.Add(1)
msgbuf <- &dPrintf{format, v}
}
func Println(v ...any) {
dequeueOnce.Do(dequeue)
queueSync.Add(1)
msgbuf <- dPrintln(v)
}
func Fatal(v ...any) {
Print(v...)
Exit(1)
}
func Fatalf(format string, v ...any) {
Printf(format, v...)
Exit(1)
}

25
internal/fmsg/verbose.go Normal file
View File

@ -0,0 +1,25 @@
package fmsg
import "sync/atomic"
var verbose = new(atomic.Bool)
func Verbose() bool {
return verbose.Load()
}
func SetVerbose(v bool) {
verbose.Store(v)
}
func VPrintf(format string, v ...any) {
if verbose.Load() {
Printf(format, v...)
}
}
func VPrintln(v ...any) {
if verbose.Load() {
Println(v...)
}
}

174
internal/init/main.go Normal file
View File

@ -0,0 +1,174 @@
package init0
import (
"encoding/gob"
"errors"
"flag"
"os"
"os/exec"
"os/signal"
"path"
"strconv"
"syscall"
"time"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
const (
// time to wait for linger processes after death initial process
residualProcessTimeout = 5 * time.Second
)
// everything beyond this point runs within pid namespace
// proceed with caution!
func doInit(fd uintptr) {
fmsg.SetPrefix("init")
// re-exec
if len(os.Args) > 0 && os.Args[0] != "fortify" && path.IsAbs(os.Args[0]) {
if err := syscall.Exec(os.Args[0], []string{"fortify", "init"}, os.Environ()); err != nil {
fmsg.Println("cannot re-exec self:", err)
// continue anyway
}
}
var payload Payload
p := os.NewFile(fd, "config-stream")
if p == nil {
fmsg.Fatal("invalid config descriptor")
}
if err := gob.NewDecoder(p).Decode(&payload); err != nil {
fmsg.Fatal("cannot decode init payload:", err)
} else {
// sharing stdout with parent
// USE WITH CAUTION
fmsg.SetVerbose(payload.Verbose)
// child does not need to see this
if err = os.Unsetenv(EnvInit); err != nil {
fmsg.Println("cannot unset", EnvInit+":", err)
// not fatal
} else {
fmsg.VPrintln("received configuration")
}
}
// close config fd
if err := p.Close(); err != nil {
fmsg.Println("cannot close config fd:", err)
// not fatal
}
// die with parent
if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGKILL), 0); errno != 0 {
fmsg.Fatal("prctl(PR_SET_PDEATHSIG, SIGKILL):", errno.Error())
}
cmd := exec.Command(payload.Argv0)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Args = payload.Argv
cmd.Env = os.Environ()
// pass wayland fd
if payload.WL != -1 {
if f := os.NewFile(uintptr(payload.WL), "wayland"); f != nil {
cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(3+len(cmd.ExtraFiles)))
cmd.ExtraFiles = append(cmd.ExtraFiles, f)
}
}
if err := cmd.Start(); err != nil {
fmsg.Fatalf("cannot start %q: %v", payload.Argv0, err)
}
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
type winfo struct {
wpid int
wstatus syscall.WaitStatus
}
info := make(chan winfo, 1)
done := make(chan struct{})
go func() {
var (
err error
wpid = -2
wstatus syscall.WaitStatus
)
// keep going until no child process is left
for wpid != -1 {
if err != nil {
break
}
if wpid != -2 {
info <- winfo{wpid, wstatus}
}
err = syscall.EINTR
for errors.Is(err, syscall.EINTR) {
wpid, err = syscall.Wait4(-1, &wstatus, 0, nil)
}
}
if !errors.Is(err, syscall.ECHILD) {
fmsg.Println("unexpected wait4 response:", err)
}
close(done)
}()
timeout := make(chan struct{})
r := 2
for {
select {
case s := <-sig:
fmsg.VPrintln("received", s.String())
fmsg.Exit(0)
case w := <-info:
if w.wpid == cmd.Process.Pid {
switch {
case w.wstatus.Exited():
r = w.wstatus.ExitStatus()
case w.wstatus.Signaled():
r = 128 + int(w.wstatus.Signal())
default:
r = 255
}
go func() {
time.Sleep(residualProcessTimeout)
close(timeout)
}()
}
case <-done:
fmsg.Exit(r)
case <-timeout:
fmsg.Println("timeout exceeded waiting for lingering processes")
fmsg.Exit(r)
}
}
}
// Try runs init and stops execution if FORTIFY_INIT is set.
func Try() {
if os.Getpid() != 1 {
return
}
if args := flag.Args(); len(args) == 1 && args[0] == "init" {
if s, ok := os.LookupEnv(EnvInit); ok {
if fd, err := strconv.Atoi(s); err != nil {
fmsg.Fatalf("cannot parse %q: %v", s, err)
} else {
doInit(uintptr(fd))
}
panic("unreachable")
}
}
}

15
internal/init/payload.go Normal file
View File

@ -0,0 +1,15 @@
package init0
const EnvInit = "FORTIFY_INIT"
type Payload struct {
// target full exec path
Argv0 string
// child full argv
Argv []string
// wayland fd, -1 to disable
WL int
// verbosity pass through
Verbose bool
}

179
internal/shim/main.go Normal file
View File

@ -0,0 +1,179 @@
package shim
import (
"encoding/gob"
"errors"
"flag"
"net"
"os"
"path"
"strconv"
"syscall"
"git.ophivana.moe/security/fortify/helper"
"git.ophivana.moe/security/fortify/internal/fmsg"
init0 "git.ophivana.moe/security/fortify/internal/init"
)
// everything beyond this point runs as target user
// proceed with caution!
func doShim(socket string) {
fmsg.SetPrefix("shim")
// re-exec
if len(os.Args) > 0 && os.Args[0] != "fortify" && path.IsAbs(os.Args[0]) {
if err := syscall.Exec(os.Args[0], []string{"fortify", "shim"}, os.Environ()); err != nil {
fmsg.Println("cannot re-exec self:", err)
// continue anyway
}
}
// dial setup socket
var conn *net.UnixConn
if c, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socket, Net: "unix"}); err != nil {
fmsg.Fatal("cannot dial setup socket:", err)
panic("unreachable")
} else {
conn = c
}
// decode payload gob stream
var payload Payload
if err := gob.NewDecoder(conn).Decode(&payload); err != nil {
fmsg.Fatal("cannot decode shim payload:", err)
} else {
// sharing stdout with parent
// USE WITH CAUTION
fmsg.SetVerbose(payload.Verbose)
}
if payload.Bwrap == nil {
fmsg.Fatal("bwrap config not supplied")
}
// receive wayland fd over socket
wfd := -1
if payload.WL {
if fd, err := receiveWLfd(conn); err != nil {
fmsg.Fatal("cannot receive wayland fd:", err)
} else {
wfd = fd
}
}
// close setup socket
if err := conn.Close(); err != nil {
fmsg.Println("cannot close setup socket:", err)
// not fatal
}
var ic init0.Payload
// resolve argv0
ic.Argv = payload.Argv
if len(ic.Argv) > 0 {
// looked up from $PATH by parent
ic.Argv0 = payload.Exec[2]
} else {
// no argv, look up shell instead
var ok bool
if ic.Argv0, ok = os.LookupEnv("SHELL"); !ok {
fmsg.Fatal("no command was specified and $SHELL was unset")
}
ic.Argv = []string{ic.Argv0}
}
conf := payload.Bwrap
var extraFiles []*os.File
// pass wayland fd
if wfd != -1 {
if f := os.NewFile(uintptr(wfd), "wayland"); f != nil {
ic.WL = 3 + len(extraFiles)
extraFiles = append(extraFiles, f)
}
} else {
ic.WL = -1
}
// share config pipe
if r, w, err := os.Pipe(); err != nil {
fmsg.Fatal("cannot pipe:", err)
} else {
conf.SetEnv[init0.EnvInit] = strconv.Itoa(3 + len(extraFiles))
extraFiles = append(extraFiles, r)
fmsg.VPrintln("transmitting config to init")
go func() {
// stream config to pipe
if err = gob.NewEncoder(w).Encode(&ic); err != nil {
fmsg.Fatal("cannot transmit init config:", err)
}
}()
}
helper.BubblewrapName = payload.Exec[1] // resolved bwrap path by parent
if b, err := helper.NewBwrap(conf, nil, payload.Exec[0], func(int, int) []string { return []string{"init"} }); err != nil {
fmsg.Fatal("malformed sandbox config:", err)
} else {
cmd := b.Unwrap()
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.ExtraFiles = extraFiles
if fmsg.Verbose() {
fmsg.VPrintln("bwrap args:", conf.Args())
}
// run and pass through exit code
if err = b.Start(); err != nil {
fmsg.Fatal("cannot start target process:", err)
} else if err = b.Wait(); err != nil {
fmsg.VPrintln("wait:", err)
}
if b.Unwrap().ProcessState != nil {
fmsg.Exit(b.Unwrap().ProcessState.ExitCode())
} else {
fmsg.Exit(127)
}
}
}
func receiveWLfd(conn *net.UnixConn) (int, error) {
oob := make([]byte, syscall.CmsgSpace(4)) // single fd
if _, oobn, _, _, err := conn.ReadMsgUnix(nil, oob); err != nil {
return -1, err
} else if len(oob) != oobn {
return -1, errors.New("invalid message length")
}
var msg syscall.SocketControlMessage
if messages, err := syscall.ParseSocketControlMessage(oob); err != nil {
return -1, err
} else if len(messages) != 1 {
return -1, errors.New("unexpected message count")
} else {
msg = messages[0]
}
if fds, err := syscall.ParseUnixRights(&msg); err != nil {
return -1, err
} else if len(fds) != 1 {
return -1, errors.New("unexpected fd count")
} else {
return fds[0], nil
}
}
// Try runs shim and stops execution if FORTIFY_SHIM is set.
func Try() {
if args := flag.Args(); len(args) == 1 && args[0] == "shim" {
if s, ok := os.LookupEnv(EnvShim); ok {
doShim(s)
panic("unreachable")
}
}
}

200
internal/shim/parent.go Normal file
View File

@ -0,0 +1,200 @@
package shim
import (
"errors"
"net"
"os"
"os/exec"
"sync"
"sync/atomic"
"syscall"
"time"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// used by the parent process
type Shim struct {
// user switcher process
cmd *exec.Cmd
// uid of shim target user
uid uint32
// whether to check shim pid
checkPid bool
// user switcher executable path
executable string
// path to setup socket
socket string
// shim setup abort reason and completion
abort chan error
abortErr atomic.Pointer[error]
abortOnce sync.Once
// wayland mediation, nil if disabled
wl *Wayland
// shim setup payload
payload *Payload
}
func New(executable string, uid uint32, socket string, wl *Wayland, payload *Payload, checkPid bool) *Shim {
return &Shim{uid: uid, executable: executable, socket: socket, wl: wl, payload: payload, checkPid: checkPid}
}
func (s *Shim) String() string {
if s.cmd == nil {
return "(unused shim manager)"
}
return s.cmd.String()
}
func (s *Shim) Unwrap() *exec.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
}
type CommandBuilder func(shimEnv string) (args []string)
func (s *Shim) Start(f CommandBuilder) (*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
s.cmd = exec.Command(s.executable, f(EnvShim+"="+s.socket)...)
s.cmd.Env = []string{}
s.cmd.Stdin, s.cmd.Stdout, s.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
s.cmd.Dir = "/"
fmsg.VPrintln("starting shim via user switcher:", s.cmd)
fmsg.Withhold() // withhold messages to stderr
if err := s.cmd.Start(); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot start user switcher:")
}
startTime := time.Now().UTC()
// kill shim if something goes wrong and an error is returned
killShim := func() {
if err := s.cmd.Process.Signal(os.Interrupt); err != nil {
fmsg.Println("cannot terminate shim on faulted setup:", err)
}
}
defer func() { killShim() }()
accept()
conn := <-cf
if conn == nil {
return &startTime, fmsg.WrapErrorSuffix(*s.abortErr.Load(), "cannot accept call on setup socket:")
}
// 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 s.checkPid && 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 and wayland fd if enabled
// this also closes the connection
err := s.payload.serve(conn, s.wl)
if err == nil {
killShim = func() {}
}
s.Abort(err) // aborting with nil indicates success
return &startTime, err
}
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() {
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)
close(cf)
return
case <-accept:
if conn, err0 := l.AcceptUnix(); err0 != nil {
s.Abort(err0) // does not block, breaks loop
cf <- nil // receiver sees nil value and loads err0 stored during abort
} 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
}

42
internal/shim/payload.go Normal file
View File

@ -0,0 +1,42 @@
package shim
import (
"encoding/gob"
"errors"
"net"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
const EnvShim = "FORTIFY_SHIM"
type Payload struct {
// child full argv
Argv []string
// fortify, bwrap, target full exec path
Exec [3]string
// bwrap config
Bwrap *bwrap.Config
// whether to pass wayland fd
WL bool
// verbosity pass through
Verbose bool
}
func (p *Payload) serve(conn *net.UnixConn, wl *Wayland) error {
if err := gob.NewEncoder(conn).Encode(*p); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot stream shim payload:")
}
if wl != nil {
if err := wl.WriteUnix(conn); err != nil {
return errors.Join(err, conn.Close())
}
}
return fmsg.WrapErrorSuffix(conn.Close(),
"cannot close setup connection:")
}

75
internal/shim/wayland.go Normal file
View File

@ -0,0 +1,75 @@
package shim
import (
"fmt"
"net"
"sync"
"syscall"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// Wayland implements wayland mediation.
type Wayland struct {
// wayland socket path
Path string
// wayland connection
conn *net.UnixConn
connErr error
sync.Once
// wait for wayland client to exit
done chan struct{}
}
func (wl *Wayland) WriteUnix(conn *net.UnixConn) error {
// connect to host wayland socket
if f, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: wl.Path, Net: "unix"}); err != nil {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot connect to wayland at %q:", wl.Path))
} else {
fmsg.VPrintf("connected to wayland at %q", wl.Path)
wl.conn = f
}
// set up for passing wayland socket
if rc, err := wl.conn.SyscallConn(); err != nil {
return fmsg.WrapErrorSuffix(err, "cannot obtain raw wayland connection:")
} else {
ec := make(chan error)
go func() {
// pass wayland connection fd
if err = rc.Control(func(fd uintptr) {
if _, _, err = conn.WriteMsgUnix(nil, syscall.UnixRights(int(fd)), nil); err != nil {
ec <- fmsg.WrapErrorSuffix(err, "cannot pass wayland connection to shim:")
return
}
ec <- nil
// block until shim exits
<-wl.done
fmsg.VPrintln("releasing wayland connection")
}); err != nil {
ec <- fmsg.WrapErrorSuffix(err, "cannot obtain wayland connection fd:")
return
}
}()
return <-ec
}
}
func (wl *Wayland) Close() error {
wl.Do(func() {
close(wl.done)
wl.connErr = wl.conn.Close()
})
return wl.connErr
}
func NewWayland() *Wayland {
wl := new(Wayland)
wl.done = make(chan struct{})
return wl
}

View File

@ -1,6 +1,7 @@
package state package state
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path" "path"
@ -9,43 +10,41 @@ import (
"text/tabwriter" "text/tabwriter"
"time" "time"
"git.ophivana.moe/cat/fortify/internal" "git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/verbose" "git.ophivana.moe/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) { func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer, runDir string) {
sc := internal.GetSC()
now := time.Now().UTC() now := time.Now().UTC()
// read runtime directory to get all UIDs // read runtime directory to get all UIDs
if dirs, err := os.ReadDir(sc.RunDirPath); err != nil { if dirs, err := os.ReadDir(path.Join(runDir, "state")); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("cannot read runtime directory:", err) fmsg.Fatal("cannot read runtime directory:", err)
os.Exit(1)
} else { } else {
for _, e := range dirs { for _, e := range dirs {
// skip non-directories // skip non-directories
if !e.IsDir() { if !e.IsDir() {
verbose.Println("skipped non-directory entry", e.Name()) fmsg.VPrintf("skipped non-directory entry %q", e.Name())
continue continue
} }
// skip non-numerical names // skip non-numerical names
if _, err = strconv.Atoi(e.Name()); err != nil { if _, err = strconv.Atoi(e.Name()); err != nil {
verbose.Println("skipped non-uid entry", e.Name()) fmsg.VPrintf("skipped non-uid entry %q", e.Name())
continue continue
} }
// obtain temporary store // obtain temporary store
s := NewSimple(sc.RunDirPath, e.Name()).(*simpleStore) s := NewSimple(runDir, e.Name()).(*simpleStore)
// print states belonging to this store // print states belonging to this store
s.mustPrintLauncherState(w, now) s.mustPrintLauncherState(w, now)
// mustPrintLauncherState causes store activity so store needs to be closed // mustPrintLauncherState causes store activity so store needs to be closed
if err = s.Close(); err != nil { if err = s.Close(); err != nil {
fmt.Printf("warn: error closing store for user %s: %s\n", e.Name(), err) fmsg.Printf("cannot close store for user %q: %s", e.Name(), err)
} }
} }
} }
@ -67,8 +66,8 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
*w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0) *w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0)
// write header when initialising // write header when initialising
if !verbose.Get() { if !fmsg.Verbose() {
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tUptime\tEnablements\tLauncher\tCommand") _, _ = fmt.Fprintln(*w, "\tUID\tPID\tUptime\tEnablements\tMethod\tCommand")
} else { } else {
// argv is emitted in body when verbose // argv is emitted in body when verbose
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv") _, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv")
@ -86,7 +85,7 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
// build enablements string // build enablements string
ets := strings.Builder{} ets := strings.Builder{}
// append enablement strings in order // append enablement strings in order
for i := Enablement(0); i < EnableLength; i++ { for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if state.Capability.Has(i) { if state.Capability.Has(i) {
ets.WriteString(", " + i.String()) ets.WriteString(", " + i.String())
} }
@ -96,9 +95,9 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
ets.WriteString("(No enablements)") ets.WriteString("(No enablements)")
} }
if !verbose.Get() { if !fmsg.Verbose() {
_, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\t%s\t%s\t%s\n", _, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\t%s\t%s\t%s\n",
s.path[len(s.path)-1], state.PID, now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), state.Launcher, s.path[len(s.path)-1], state.PID, now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), state.Method,
state.Command) state.Command)
} else { } else {
// emit argv instead when verbose // emit argv instead when verbose
@ -110,15 +109,13 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
return nil return nil
}() }()
}); err != nil { }); err != nil {
fmt.Printf("cannot perform action on store '%s': %s\n", path.Join(s.path...), err) fmsg.Printf("cannot perform action on store %q: %s", path.Join(s.path...), err)
if !ok { if !ok {
fmt.Println("warn: store faulted before printing") fmsg.Fatal("store faulted before printing")
os.Exit(1)
} }
} }
if innerErr != nil { if innerErr != nil {
fmt.Printf("cannot print launcher state for store '%s': %s\n", path.Join(s.path...), innerErr) fmsg.Fatalf("cannot print launcher state for store %q: %s", path.Join(s.path...), innerErr)
os.Exit(1)
} }
} }

View File

@ -211,9 +211,8 @@ func (b *simpleBackend) Len() (int, error) {
} }
// NewSimple returns an instance of a file-based store. // NewSimple returns an instance of a file-based store.
// Store prefix is typically (runDir, uid). func NewSimple(runDir string, prefix ...string) Store {
func NewSimple(prefix ...string) Store {
b := new(simpleStore) b := new(simpleStore)
b.path = prefix b.path = append([]string{runDir, "state"}, prefix...)
return b return b
} }

View File

@ -2,6 +2,8 @@ package state
import ( import (
"time" "time"
"git.ophivana.moe/security/fortify/internal/system"
) )
type Store interface { type Store interface {
@ -29,10 +31,10 @@ type State struct {
// command used to seal the app // command used to seal the app
Command []string Command []string
// capability enablements applied to child // capability enablements applied to child
Capability Enablements Capability system.Enablements
// resolved launcher path // user switch method
Launcher string Method string
// full argv whe launching // full argv whe launching
Argv []string Argv []string
// process start time // process start time

126
internal/system.go Normal file
View File

@ -0,0 +1,126 @@
package internal
import (
"errors"
"io/fs"
"os"
"os/exec"
"os/user"
"path"
"strconv"
"sync"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// System provides safe access to operating system resources.
type System interface {
// Geteuid provides [os.Geteuid].
Geteuid() int
// LookupEnv provides [os.LookupEnv].
LookupEnv(key string) (string, bool)
// TempDir provides [os.TempDir].
TempDir() string
// LookPath provides [exec.LookPath].
LookPath(file string) (string, error)
// Executable provides [os.Executable].
Executable() (string, error)
// Lookup provides [user.Lookup].
Lookup(username string) (*user.User, error)
// ReadDir provides [os.ReadDir].
ReadDir(name string) ([]fs.DirEntry, error)
// Stat provides [os.Stat].
Stat(name string) (fs.FileInfo, error)
// Open provides [os.Open]
Open(name string) (fs.File, error)
// Exit provides [os.Exit].
Exit(code int)
// Paths returns a populated [Paths] struct.
Paths() Paths
// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html
SdBooted() bool
}
// Paths contains environment dependent paths used by fortify.
type Paths struct {
// path to shared directory e.g. /tmp/fortify.%d
SharePath string `json:"share_path"`
// XDG_RUNTIME_DIR value e.g. /run/user/%d
RuntimePath string `json:"runtime_path"`
// application runtime directory e.g. /run/user/%d/fortify
RunDirPath string `json:"run_dir_path"`
}
// CopyPaths is a generic implementation of [System.Paths].
func CopyPaths(os System, v *Paths) {
v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid()))
fmsg.VPrintf("process share directory at %q", v.SharePath)
if r, ok := os.LookupEnv(xdgRuntimeDir); !ok || r == "" || !path.IsAbs(r) {
// fall back to path in share since fortify has no hard XDG dependency
v.RunDirPath = path.Join(v.SharePath, "run")
v.RuntimePath = path.Join(v.RunDirPath, "compat")
} else {
v.RuntimePath = r
v.RunDirPath = path.Join(v.RuntimePath, "fortify")
}
fmsg.VPrintf("runtime directory at %q", v.RunDirPath)
}
// Std implements System using the standard library.
type Std struct {
paths Paths
pathsOnce sync.Once
sdBooted bool
sdBootedOnce sync.Once
}
func (s *Std) Geteuid() int { return os.Geteuid() }
func (s *Std) LookupEnv(key string) (string, bool) { return os.LookupEnv(key) }
func (s *Std) TempDir() string { return os.TempDir() }
func (s *Std) LookPath(file string) (string, error) { return exec.LookPath(file) }
func (s *Std) Executable() (string, error) { return os.Executable() }
func (s *Std) Lookup(username string) (*user.User, error) { return user.Lookup(username) }
func (s *Std) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
func (s *Std) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
func (s *Std) Open(name string) (fs.File, error) { return os.Open(name) }
func (s *Std) Exit(code int) { fmsg.Exit(code) }
const xdgRuntimeDir = "XDG_RUNTIME_DIR"
func (s *Std) Paths() Paths {
s.pathsOnce.Do(func() { CopyPaths(s, &s.paths) })
return s.paths
}
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
}

70
internal/system/acl.go Normal file
View File

@ -0,0 +1,70 @@
package system
import (
"fmt"
"slices"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// UpdatePerm appends an ephemeral acl update Op.
func (sys *I) UpdatePerm(path string, perms ...acl.Perm) *I {
sys.UpdatePermType(Process, path, perms...)
return sys
}
// UpdatePermType appends an acl update Op.
func (sys *I) UpdatePermType(et Enablement, path string, perms ...acl.Perm) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &ACL{et, path, perms})
return sys
}
type ACL struct {
et Enablement
path string
perms acl.Perms
}
func (a *ACL) Type() Enablement {
return a.et
}
func (a *ACL) apply(sys *I) error {
fmsg.VPrintln("applying ACL", a)
return fmsg.WrapErrorSuffix(acl.UpdatePerm(a.path, sys.uid, a.perms...),
fmt.Sprintf("cannot apply ACL entry to %q:", a.path))
}
func (a *ACL) revert(sys *I, ec *Criteria) error {
if ec.hasType(a) {
fmsg.VPrintln("stripping ACL", a)
return fmsg.WrapErrorSuffix(acl.UpdatePerm(a.path, sys.uid),
fmt.Sprintf("cannot strip ACL entry from %q:", a.path))
} else {
fmsg.VPrintln("skipping ACL", a)
return nil
}
}
func (a *ACL) Is(o Op) bool {
a0, ok := o.(*ACL)
return ok && a0 != nil &&
a.et == a0.et &&
a.path == a0.path &&
slices.Equal(a.perms, a0.perms)
}
func (a *ACL) Path() string {
return a.path
}
func (a *ACL) String() string {
return fmt.Sprintf("%s type: %s path: %q",
a.perms, TypeString(a.et), a.path)
}

View File

@ -0,0 +1,90 @@
package system
import (
"testing"
"git.ophivana.moe/security/fortify/acl"
)
func TestUpdatePerm(t *testing.T) {
testCases := []struct {
path string
perms []acl.Perm
}{
{"/run/user/1971/fortify", []acl.Perm{acl.Execute}},
{"/tmp/fortify.1971/tmpdir/150", []acl.Perm{acl.Read, acl.Write, acl.Execute}},
}
for _, tc := range testCases {
t.Run(tc.path+permSubTestSuffix(tc.perms), func(t *testing.T) {
sys := New(150)
sys.UpdatePerm(tc.path, tc.perms...)
(&tcOp{Process, tc.path}).test(t, sys.ops, []Op{&ACL{Process, tc.path, tc.perms}}, "UpdatePerm")
})
}
}
func TestUpdatePermType(t *testing.T) {
testCases := []struct {
perms []acl.Perm
tcOp
}{
{[]acl.Perm{acl.Execute}, tcOp{User, "/tmp/fortify.1971/tmpdir"}},
{[]acl.Perm{acl.Read, acl.Write, acl.Execute}, tcOp{User, "/tmp/fortify.1971/tmpdir/150"}},
{[]acl.Perm{acl.Execute}, tcOp{Process, "/run/user/1971/fortify/fcb8a12f7c482d183ade8288c3de78b5"}},
{[]acl.Perm{acl.Read}, tcOp{Process, "/tmp/fortify.1971/fcb8a12f7c482d183ade8288c3de78b5/passwd"}},
{[]acl.Perm{acl.Read}, tcOp{Process, "/tmp/fortify.1971/fcb8a12f7c482d183ade8288c3de78b5/group"}},
{[]acl.Perm{acl.Read, acl.Write, acl.Execute}, tcOp{EWayland, "/run/user/1971/wayland-0"}},
}
for _, tc := range testCases {
t.Run(tc.path+"_"+TypeString(tc.et)+permSubTestSuffix(tc.perms), func(t *testing.T) {
sys := New(150)
sys.UpdatePermType(tc.et, tc.path, tc.perms...)
tc.test(t, sys.ops, []Op{&ACL{tc.et, tc.path, tc.perms}}, "UpdatePermType")
})
}
}
func TestACL_String(t *testing.T) {
testCases := []struct {
want string
et Enablement
perms []acl.Perm
}{
{`--- type: Process path: "/nonexistent"`, Process, []acl.Perm{}},
{`r-- type: User path: "/nonexistent"`, User, []acl.Perm{acl.Read}},
{`-w- type: Wayland path: "/nonexistent"`, EWayland, []acl.Perm{acl.Write}},
{`--x type: X11 path: "/nonexistent"`, EX11, []acl.Perm{acl.Execute}},
{`rw- type: D-Bus path: "/nonexistent"`, EDBus, []acl.Perm{acl.Read, acl.Write}},
{`r-x type: PulseAudio path: "/nonexistent"`, EPulse, []acl.Perm{acl.Read, acl.Execute}},
{`rwx type: User path: "/nonexistent"`, User, []acl.Perm{acl.Read, acl.Write, acl.Execute}},
{`rwx type: Process path: "/nonexistent"`, Process, []acl.Perm{acl.Read, acl.Write, acl.Write, acl.Execute}},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
a := &ACL{et: tc.et, perms: tc.perms, path: "/nonexistent"}
if got := a.String(); got != tc.want {
t.Errorf("String() = %v, want %v",
got, tc.want)
}
})
}
}
func permSubTestSuffix(perms []acl.Perm) (suffix string) {
for _, perm := range perms {
switch perm {
case acl.Read:
suffix += "_read"
case acl.Write:
suffix += "_write"
case acl.Execute:
suffix += "_execute"
default:
panic("unreachable")
}
}
return
}

166
internal/system/dbus.go Normal file
View File

@ -0,0 +1,166 @@
package system
import (
"errors"
"os"
"git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
var (
ErrDBusConfig = errors.New("dbus config not supplied")
)
func (sys *I) MustProxyDBus(sessionPath string, session *dbus.Config, systemPath string, system *dbus.Config) *I {
if err := sys.ProxyDBus(session, system, sessionPath, systemPath); err != nil {
panic(err.Error())
} else {
return sys
}
}
func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath string) error {
d := new(DBus)
// used by waiting goroutine to notify process exit
d.done = make(chan struct{})
// session bus is mandatory
if session == nil {
return fmsg.WrapError(ErrDBusConfig,
"attempted to seal message bus proxy without session bus config")
}
// system bus is optional
d.system = system == nil
// upstream address, downstream socket path
var sessionBus, systemBus [2]string
// resolve upstream bus addresses
sessionBus[0], systemBus[0] = dbus.Address()
// set paths from caller
sessionBus[1], systemBus[1] = sessionPath, systemPath
// create proxy instance
d.proxy = dbus.New(sessionBus, systemBus)
defer func() {
if fmsg.Verbose() && d.proxy.Sealed() {
fmsg.VPrintln("sealed session proxy", session.Args(sessionBus))
if system != nil {
fmsg.VPrintln("sealed system proxy", system.Args(systemBus))
}
fmsg.VPrintln("message bus proxy final args:", d.proxy)
}
}()
// queue operation
sys.ops = append(sys.ops, d)
// seal dbus proxy
return fmsg.WrapErrorSuffix(d.proxy.Seal(session, system),
"cannot seal message bus proxy:")
}
type DBus struct {
proxy *dbus.Proxy
// whether system bus proxy is enabled
system bool
// notification from goroutine waiting for dbus.Proxy
done chan struct{}
}
func (d *DBus) Type() Enablement {
return Process
}
func (d *DBus) apply(_ *I) error {
fmsg.VPrintf("session bus proxy on %q for upstream %q", d.proxy.Session()[1], d.proxy.Session()[0])
if d.system {
fmsg.VPrintf("system bus proxy on %q for upstream %q", d.proxy.System()[1], d.proxy.System()[0])
}
// ready channel passed to dbus package
ready := make(chan error, 1)
// background dbus proxy start
if err := d.proxy.Start(ready, os.Stderr, true); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot start message bus proxy:")
}
fmsg.VPrintln("starting message bus proxy:", d.proxy)
if fmsg.Verbose() { // save the extra bwrap arg build when verbose logging is off
fmsg.VPrintln("message bus proxy bwrap args:", d.proxy.Bwrap())
}
// background wait for proxy instance and notify completion
go func() {
if err := d.proxy.Wait(); err != nil {
fmsg.Println("message bus proxy exited with error:", err)
go func() { ready <- err }()
} else {
fmsg.VPrintln("message bus proxy exit")
}
// ensure socket removal so ephemeral directory is empty at revert
if err := os.Remove(d.proxy.Session()[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmsg.Println("cannot remove dangling session bus socket:", err)
}
if d.system {
if err := os.Remove(d.proxy.System()[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmsg.Println("cannot remove dangling system bus socket:", err)
}
}
// notify proxy completion
close(d.done)
}()
// ready is not nil if the proxy process faulted
if err := <-ready; err != nil {
// note that err here is either an I/O error or a predetermined unexpected behaviour error
return fmsg.WrapErrorSuffix(err,
"message bus proxy fault after start:")
}
fmsg.VPrintln("message bus proxy ready")
return nil
}
func (d *DBus) revert(_ *I, _ *Criteria) error {
// criteria ignored here since dbus is always process-scoped
fmsg.VPrintln("terminating message bus proxy")
if err := d.proxy.Close(); err != nil {
if errors.Is(err, os.ErrClosed) {
return fmsg.WrapError(err,
"message bus proxy already closed")
} else {
return fmsg.WrapErrorSuffix(err,
"cannot stop message bus proxy:")
}
}
// block until proxy wait returns
<-d.done
return nil
}
func (d *DBus) Is(o Op) bool {
d0, ok := o.(*DBus)
return ok && d0 != nil &&
((d.proxy == nil && d0.proxy == nil) ||
(d.proxy != nil && d0.proxy != nil && d.proxy.String() == d0.proxy.String()))
}
func (d *DBus) Path() string {
return "(dbus proxy)"
}
func (d *DBus) String() string {
return d.proxy.String()
}

View File

@ -1,4 +1,4 @@
package state package system
type ( type (
// Enablement represents an optional system resource // Enablement represents an optional system resource
@ -8,22 +8,25 @@ type (
) )
const ( const (
EnableWayland Enablement = iota EWayland Enablement = iota
EnableX EX11
EnableDBus EDBus
EnablePulse EPulse
EnableLength
) )
var enablementString = [EnableLength]string{ var enablementString = [...]string{
"Wayland", EWayland: "Wayland",
"X11", EX11: "X11",
"D-Bus", EDBus: "D-Bus",
"PulseAudio", EPulse: "PulseAudio",
} }
const ELen = len(enablementString)
func (e Enablement) String() string { func (e Enablement) String() string {
if int(e) >= ELen {
return "<invalid enablement>"
}
return enablementString[e] return enablementString[e]
} }

88
internal/system/mkdir.go Normal file
View File

@ -0,0 +1,88 @@
package system
import (
"errors"
"fmt"
"os"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// Ensure the existence and mode of a directory.
func (sys *I) Ensure(name string, perm os.FileMode) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Mkdir{User, name, perm, false})
return sys
}
// Ephemeral ensures the temporary existence and mode of a directory through the life of et.
func (sys *I) Ephemeral(et Enablement, name string, perm os.FileMode) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Mkdir{et, name, perm, true})
return sys
}
type Mkdir struct {
et Enablement
path string
perm os.FileMode
ephemeral bool
}
func (m *Mkdir) Type() Enablement {
return m.et
}
func (m *Mkdir) apply(_ *I) error {
fmsg.VPrintln("ensuring directory", m)
// create directory
err := os.Mkdir(m.path, m.perm)
if !errors.Is(err, os.ErrExist) {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot create directory %q:", m.path))
}
// directory exists, ensure mode
return fmsg.WrapErrorSuffix(os.Chmod(m.path, m.perm),
fmt.Sprintf("cannot change mode of %q to %s:", m.path, m.perm))
}
func (m *Mkdir) revert(_ *I, ec *Criteria) error {
if !m.ephemeral {
// skip non-ephemeral dir and do not log anything
return nil
}
if ec.hasType(m) {
fmsg.VPrintln("destroying ephemeral directory", m)
return fmsg.WrapErrorSuffix(os.Remove(m.path),
fmt.Sprintf("cannot remove ephemeral directory %q:", m.path))
} else {
fmsg.VPrintln("skipping ephemeral directory", m)
return nil
}
}
func (m *Mkdir) Is(o Op) bool {
m0, ok := o.(*Mkdir)
return ok && m0 != nil && *m == *m0
}
func (m *Mkdir) Path() string {
return m.path
}
func (m *Mkdir) String() string {
t := "Ensure"
if m.ephemeral {
t = TypeString(m.Type())
}
return fmt.Sprintf("mode: %s type: %s path: %q", m.perm.String(), t, m.path)
}

View File

@ -0,0 +1,73 @@
package system
import (
"os"
"testing"
)
func TestEnsure(t *testing.T) {
testCases := []struct {
name string
perm os.FileMode
}{
{"/tmp/fortify.1971", 0701},
{"/tmp/fortify.1971/tmpdir", 0700},
{"/tmp/fortify.1971/tmpdir/150", 0700},
{"/run/user/1971/fortify", 0700},
}
for _, tc := range testCases {
t.Run(tc.name+"_"+tc.perm.String(), func(t *testing.T) {
sys := New(150)
sys.Ensure(tc.name, tc.perm)
(&tcOp{User, tc.name}).test(t, sys.ops, []Op{&Mkdir{User, tc.name, tc.perm, false}}, "Ensure")
})
}
}
func TestEphemeral(t *testing.T) {
testCases := []struct {
perm os.FileMode
tcOp
}{
{0700, tcOp{Process, "/run/user/1971/fortify/ec07546a772a07cde87389afc84ffd13"}},
{0701, tcOp{Process, "/tmp/fortify.1971/ec07546a772a07cde87389afc84ffd13"}},
}
for _, tc := range testCases {
t.Run(tc.path+"_"+tc.perm.String()+"_"+TypeString(tc.et), func(t *testing.T) {
sys := New(150)
sys.Ephemeral(tc.et, tc.path, tc.perm)
tc.test(t, sys.ops, []Op{&Mkdir{tc.et, tc.path, tc.perm, true}}, "Ephemeral")
})
}
}
func TestMkdir_String(t *testing.T) {
testCases := []struct {
want string
ephemeral bool
et Enablement
}{
{"Ensure", false, User},
{"Ensure", false, Process},
{"Ensure", false, EWayland},
{"Wayland", true, EWayland},
{"X11", true, EX11},
{"D-Bus", true, EDBus},
{"PulseAudio", true, EPulse},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
m := &Mkdir{
et: tc.et,
path: "/nonexistent",
perm: 0701,
ephemeral: tc.ephemeral,
}
want := "mode: " + os.FileMode(0701).String() + " type: " + tc.want + " path: \"/nonexistent\""
if got := m.String(); got != want {
t.Errorf("String() = %v, want %v", got, want)
}
})
}
}

140
internal/system/op.go Normal file
View File

@ -0,0 +1,140 @@
package system
import (
"errors"
"sync"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
const (
// User type is reverted at final launcher exit.
User = Enablement(ELen)
// Process type is unconditionally reverted on exit.
Process = Enablement(ELen + 1)
)
type Criteria struct {
*Enablements
}
func (ec *Criteria) hasType(o Op) bool {
// nil criteria: revert everything except User
if ec.Enablements == nil {
return o.Type() != User
}
return ec.Has(o.Type())
}
// Op is a reversible system operation.
type Op interface {
// Type returns Op's enablement type.
Type() Enablement
// apply the Op
apply(sys *I) error
// revert reverses the Op if criteria is met
revert(sys *I, ec *Criteria) error
Is(o Op) bool
Path() string
String() string
}
func TypeString(e Enablement) string {
switch e {
case User:
return "User"
case Process:
return "Process"
default:
return e.String()
}
}
type I struct {
uid int
ops []Op
state [2]bool
lock sync.Mutex
}
func (sys *I) UID() int {
return sys.uid
}
func (sys *I) Equal(v *I) bool {
if v == nil || sys.uid != v.uid || len(sys.ops) != len(v.ops) {
return false
}
for i, o := range sys.ops {
if !o.Is(v.ops[i]) {
return false
}
}
return true
}
func (sys *I) Commit() error {
sys.lock.Lock()
defer sys.lock.Unlock()
if sys.state[0] {
panic("sys instance committed twice")
}
sys.state[0] = true
sp := New(sys.uid)
sp.ops = make([]Op, 0, len(sys.ops)) // prevent copies during commits
defer func() {
// sp is set to nil when all ops are applied
if sp != nil {
// rollback partial commit
fmsg.VPrintf("commit faulted after %d ops, rolling back partial commit", len(sp.ops))
if err := sp.Revert(&Criteria{nil}); err != nil {
fmsg.Println("errors returned reverting partial commit:", err)
}
}
}()
for _, o := range sys.ops {
if err := o.apply(sys); err != nil {
return err
} else {
// register partial commit
sp.ops = append(sp.ops, o)
}
}
// disarm partial commit rollback
sp = nil
return nil
}
func (sys *I) Revert(ec *Criteria) error {
sys.lock.Lock()
defer sys.lock.Unlock()
if sys.state[1] {
panic("sys instance reverted twice")
}
sys.state[1] = true
// collect errors
errs := make([]error, len(sys.ops))
for i := range sys.ops {
errs[i] = sys.ops[len(sys.ops)-i-1].revert(sys, ec)
}
// errors.Join filters nils
return errors.Join(errs...)
}
func New(uid int) *I {
return &I{uid: uid}
}

View File

@ -0,0 +1,79 @@
package system
import "testing"
type tcOp struct {
et Enablement
path string
}
// test an instance of the Op interface
func (ptc tcOp) test(t *testing.T, gotOps []Op, wantOps []Op, fn string) {
if len(gotOps) != len(wantOps) {
t.Errorf("%s: inserted %v Ops, want %v", fn,
len(gotOps), len(wantOps))
return
}
t.Run("path", func(t *testing.T) {
if len(gotOps) > 0 {
if got := gotOps[0].Path(); got != ptc.path {
t.Errorf("Path() = %q, want %q",
got, ptc.path)
return
}
}
})
for i := range gotOps {
o := gotOps[i]
t.Run("is", func(t *testing.T) {
if !o.Is(o) {
t.Errorf("Is returned false on self")
return
}
if !o.Is(wantOps[i]) {
t.Errorf("%s: inserted %#v, want %#v",
fn,
o, wantOps[i])
return
}
})
t.Run("criteria", func(t *testing.T) {
testCases := []struct {
name string
ec *Criteria
want bool
}{
{"nil", newCriteria(), ptc.et != User},
{"self", newCriteria(ptc.et), true},
{"all", newCriteria(EWayland, EX11, EDBus, EPulse, User, Process), true},
{"enablements", newCriteria(EWayland, EX11, EDBus, EPulse), ptc.et != User && ptc.et != Process},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.ec.hasType(o); got != tc.want {
t.Errorf("hasType: got %v, want %v",
got, tc.want)
}
})
}
})
}
}
func newCriteria(labels ...Enablement) *Criteria {
ec := new(Criteria)
if len(labels) == 0 {
return ec
}
ec.Enablements = new(Enablements)
for _, e := range labels {
ec.Set(e)
}
return ec
}

129
internal/system/op_test.go Normal file
View File

@ -0,0 +1,129 @@
package system_test
import (
"strconv"
"testing"
"git.ophivana.moe/security/fortify/internal/system"
)
func TestNew(t *testing.T) {
testCases := []struct {
uid int
}{
{150},
{149},
{148},
{147},
}
for _, tc := range testCases {
t.Run("sys initialised with uid "+strconv.Itoa(tc.uid), func(t *testing.T) {
if got := system.New(tc.uid); got.UID() != tc.uid {
t.Errorf("New(%d) uid = %d, want %d",
tc.uid,
got.UID(), tc.uid)
}
})
}
}
func TestTypeString(t *testing.T) {
testCases := []struct {
e system.Enablement
want string
}{
{system.EWayland, system.EWayland.String()},
{system.EX11, system.EX11.String()},
{system.EDBus, system.EDBus.String()},
{system.EPulse, system.EPulse.String()},
{system.User, "User"},
{system.Process, "Process"},
}
for _, tc := range testCases {
t.Run("label type string "+tc.want, func(t *testing.T) {
if got := system.TypeString(tc.e); got != tc.want {
t.Errorf("TypeString(%d) = %v, want %v",
tc.e,
got, tc.want)
}
})
}
}
func TestI_Equal(t *testing.T) {
testCases := []struct {
name string
sys *system.I
v *system.I
want bool
}{
{
"simple UID",
system.New(150),
system.New(150),
true,
},
{
"simple UID differ",
system.New(150),
system.New(151),
false,
},
{
"simple UID nil",
system.New(150),
nil,
false,
},
{
"op length mismatch",
system.New(150).
ChangeHosts("chronos"),
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
false,
},
{
"op value mismatch",
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0644),
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
false,
},
{
"op type mismatch",
system.New(150).
ChangeHosts("chronos").
CopyFile("/tmp/fortify.1971/30c9543e0a2c9621a8bfecb9d874c347/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"),
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
false,
},
{
"op equals",
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.sys.Equal(tc.v) != tc.want {
t.Errorf("Equal: got %v; want %v",
!tc.want, tc.want)
}
})
}
}

145
internal/system/tmpfiles.go Normal file
View File

@ -0,0 +1,145 @@
package system
import (
"errors"
"fmt"
"io"
"os"
"strconv"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// CopyFile registers an Op that copies path dst from src.
func (sys *I) CopyFile(dst, src string) *I {
return sys.CopyFileType(Process, dst, src)
}
// CopyFileType registers a file copying Op labelled with type et.
func (sys *I) CopyFileType(et Enablement, dst, src string) *I {
sys.lock.Lock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileCopy, dst, src})
sys.lock.Unlock()
sys.UpdatePermType(et, dst, acl.Read)
return sys
}
// Link registers an Op that links dst to src.
func (sys *I) Link(oldname, newname string) *I {
return sys.LinkFileType(Process, oldname, newname)
}
// LinkFileType registers a file linking Op labelled with type et.
func (sys *I) LinkFileType(et Enablement, oldname, newname string) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileLink, newname, oldname})
return sys
}
// Write registers an Op that writes dst with the contents of src.
func (sys *I) Write(dst, src string) *I {
return sys.WriteType(Process, dst, src)
}
// WriteType registers a file writing Op labelled with type et.
func (sys *I) WriteType(et Enablement, dst, src string) *I {
sys.lock.Lock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileWrite, dst, src})
sys.lock.Unlock()
sys.UpdatePermType(et, dst, acl.Read)
return sys
}
const (
tmpfileCopy uint8 = iota
tmpfileLink
tmpfileWrite
)
type Tmpfile struct {
et Enablement
method uint8
dst, src string
}
func (t *Tmpfile) Type() Enablement {
return t.et
}
func (t *Tmpfile) apply(_ *I) error {
switch t.method {
case tmpfileCopy:
fmsg.VPrintln("publishing tmpfile", t)
return fmsg.WrapErrorSuffix(copyFile(t.dst, t.src),
fmt.Sprintf("cannot copy tmpfile %q:", t.dst))
case tmpfileLink:
fmsg.VPrintln("linking tmpfile", t)
return fmsg.WrapErrorSuffix(os.Link(t.src, t.dst),
fmt.Sprintf("cannot link tmpfile %q:", t.dst))
case tmpfileWrite:
fmsg.VPrintln("writing", t)
return fmsg.WrapErrorSuffix(os.WriteFile(t.dst, []byte(t.src), 0600),
fmt.Sprintf("cannot write tmpfile %q:", t.dst))
default:
panic("invalid tmpfile method " + strconv.Itoa(int(t.method)))
}
}
func (t *Tmpfile) revert(_ *I, ec *Criteria) error {
if ec.hasType(t) {
fmsg.VPrintf("removing tmpfile %q", t.dst)
return fmsg.WrapErrorSuffix(os.Remove(t.dst),
fmt.Sprintf("cannot remove tmpfile %q:", t.dst))
} else {
fmsg.VPrintf("skipping tmpfile %q", t.dst)
return nil
}
}
func (t *Tmpfile) Is(o Op) bool {
t0, ok := o.(*Tmpfile)
return ok && t0 != nil && *t == *t0
}
func (t *Tmpfile) Path() string {
if t.method == tmpfileWrite {
return fmt.Sprintf("(%d bytes of data)", len(t.src))
}
return t.src
}
func (t *Tmpfile) String() string {
switch t.method {
case tmpfileCopy:
return fmt.Sprintf("%q from %q", t.dst, t.src)
case tmpfileLink:
return fmt.Sprintf("%q from %q", t.dst, t.src)
case tmpfileWrite:
return fmt.Sprintf("%d bytes of data to %q", len(t.src), t.dst)
default:
panic("invalid tmpfile method " + strconv.Itoa(int(t.method)))
}
}
func copyFile(dst, src string) error {
dstD, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
srcD, err := os.Open(src)
if err != nil {
return errors.Join(err, dstD.Close())
}
_, err = io.Copy(dstD, srcD)
return errors.Join(err, dstD.Close(), srcD.Close())
}

View File

@ -0,0 +1,167 @@
package system
import (
"strconv"
"testing"
"git.ophivana.moe/security/fortify/acl"
)
func TestCopyFile(t *testing.T) {
testCases := []struct {
dst, src string
}{
{"/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
{"/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
}
for _, tc := range testCases {
t.Run("copy file "+tc.dst+" from "+tc.src, func(t *testing.T) {
sys := New(150)
sys.CopyFile(tc.dst, tc.src)
(&tcOp{Process, tc.src}).test(t, sys.ops, []Op{
&Tmpfile{Process, tmpfileCopy, tc.dst, tc.src},
&ACL{Process, tc.dst, []acl.Perm{acl.Read}},
}, "CopyFile")
})
}
}
func TestCopyFileType(t *testing.T) {
testCases := []struct {
tcOp
dst string
}{
{tcOp{User, "/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
{tcOp{Process, "/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
}
for _, tc := range testCases {
t.Run("copy file "+tc.dst+" from "+tc.path+" with type "+TypeString(tc.et), func(t *testing.T) {
sys := New(150)
sys.CopyFileType(tc.et, tc.dst, tc.path)
tc.test(t, sys.ops, []Op{
&Tmpfile{tc.et, tmpfileCopy, tc.dst, tc.path},
&ACL{tc.et, tc.dst, []acl.Perm{acl.Read}},
}, "CopyFileType")
})
}
}
func TestLink(t *testing.T) {
testCases := []struct {
dst, src string
}{
{"/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
{"/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
}
for _, tc := range testCases {
t.Run("link file "+tc.dst+" from "+tc.src, func(t *testing.T) {
sys := New(150)
sys.Link(tc.src, tc.dst)
(&tcOp{Process, tc.src}).test(t, sys.ops, []Op{
&Tmpfile{Process, tmpfileLink, tc.dst, tc.src},
}, "Link")
})
}
}
func TestLinkFileType(t *testing.T) {
testCases := []struct {
tcOp
dst string
}{
{tcOp{User, "/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
{tcOp{Process, "/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
}
for _, tc := range testCases {
t.Run("link file "+tc.dst+" from "+tc.path+" with type "+TypeString(tc.et), func(t *testing.T) {
sys := New(150)
sys.LinkFileType(tc.et, tc.path, tc.dst)
tc.test(t, sys.ops, []Op{
&Tmpfile{tc.et, tmpfileLink, tc.dst, tc.path},
}, "LinkFileType")
})
}
}
func TestWrite(t *testing.T) {
testCases := []struct {
dst, src string
}{
{"/etc/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n"},
{"/etc/group", "fortify:x:65534:\n"},
}
for _, tc := range testCases {
t.Run("write "+strconv.Itoa(len(tc.src))+" bytes to "+tc.dst, func(t *testing.T) {
sys := New(150)
sys.Write(tc.dst, tc.src)
(&tcOp{Process, "(" + strconv.Itoa(len(tc.src)) + " bytes of data)"}).test(t, sys.ops, []Op{
&Tmpfile{Process, tmpfileWrite, tc.dst, tc.src},
&ACL{Process, tc.dst, []acl.Perm{acl.Read}},
}, "Write")
})
}
}
func TestWriteType(t *testing.T) {
testCases := []struct {
et Enablement
dst, src string
}{
{Process, "/etc/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n"},
{Process, "/etc/group", "fortify:x:65534:\n"},
{User, "/etc/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n"},
{User, "/etc/group", "fortify:x:65534:\n"},
}
for _, tc := range testCases {
t.Run("write "+strconv.Itoa(len(tc.src))+" bytes to "+tc.dst+" with type "+TypeString(tc.et), func(t *testing.T) {
sys := New(150)
sys.WriteType(tc.et, tc.dst, tc.src)
(&tcOp{tc.et, "(" + strconv.Itoa(len(tc.src)) + " bytes of data)"}).test(t, sys.ops, []Op{
&Tmpfile{tc.et, tmpfileWrite, tc.dst, tc.src},
&ACL{tc.et, tc.dst, []acl.Perm{acl.Read}},
}, "WriteType")
})
}
}
func TestTmpfile_String(t *testing.T) {
t.Run("invalid method panic", func(t *testing.T) {
defer func() {
wantPanic := "invalid tmpfile method 255"
if r := recover(); r != wantPanic {
t.Errorf("String() panic = %v, want %v",
r, wantPanic)
}
}()
_ = (&Tmpfile{method: 255}).String()
})
testCases := []struct {
method uint8
dst, src string
want string
}{
{tmpfileCopy, "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie",
`"/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/pulse-cookie" from "/home/ophestra/xdg/config/pulse/cookie"`},
{tmpfileLink, "/run/user/1971/fortify/4b6bdc9182fb2f1d3a965c5fa8b9b66e/wayland", "/run/user/1971/wayland-0",
`"/run/user/1971/fortify/4b6bdc9182fb2f1d3a965c5fa8b9b66e/wayland" from "/run/user/1971/wayland-0"`},
{tmpfileLink, "/run/user/1971/fortify/4b6bdc9182fb2f1d3a965c5fa8b9b66e/pulse", "/run/user/1971/pulse/native",
`"/run/user/1971/fortify/4b6bdc9182fb2f1d3a965c5fa8b9b66e/pulse" from "/run/user/1971/pulse/native"`},
{tmpfileWrite, "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n",
`75 bytes of data to "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/passwd"`},
{tmpfileWrite, "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/group", "fortify:x:65534:\n",
`17 bytes of data to "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/group"`},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
if got := (&Tmpfile{
method: tc.method,
dst: tc.dst,
src: tc.src,
}).String(); got != tc.want {
t.Errorf("String() = %v, want %v", got, tc.want)
}
})
}
}

54
internal/system/xhost.go Normal file
View File

@ -0,0 +1,54 @@
package system
import (
"fmt"
"git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/xcb"
)
// ChangeHosts appends an X11 ChangeHosts command Op.
func (sys *I) ChangeHosts(username string) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, XHost(username))
return sys
}
type XHost string
func (x XHost) Type() Enablement {
return EX11
}
func (x XHost) apply(_ *I) error {
fmsg.VPrintf("inserting entry %s to X11", x)
return fmsg.WrapErrorSuffix(xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
fmt.Sprintf("cannot insert entry %s to X11:", x))
}
func (x XHost) revert(_ *I, ec *Criteria) error {
if ec.hasType(x) {
fmsg.VPrintf("deleting entry %s from X11", x)
return fmsg.WrapErrorSuffix(xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
fmt.Sprintf("cannot delete entry %s from X11:", x))
} else {
fmsg.VPrintf("skipping entry %s in X11", x)
return nil
}
}
func (x XHost) Is(o Op) bool {
x0, ok := o.(XHost)
return ok && x == x0
}
func (x XHost) Path() string {
return string(x)
}
func (x XHost) String() string {
return string("SI:localuser:" + x)
}

View File

@ -0,0 +1,34 @@
package system
import (
"testing"
)
func TestChangeHosts(t *testing.T) {
testCases := []string{"chronos", "keyring", "cat", "kbd", "yonah"}
for _, tc := range testCases {
t.Run("append ChangeHosts operation for "+tc, func(t *testing.T) {
sys := New(150)
sys.ChangeHosts(tc)
(&tcOp{EX11, tc}).test(t, sys.ops, []Op{
XHost(tc),
}, "ChangeHosts")
})
}
}
func TestXHost_String(t *testing.T) {
testCases := []struct {
username string
want string
}{
{"chronos", "SI:localuser:chronos"},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
if got := XHost(tc.username).String(); got != tc.want {
t.Errorf("String() = %v, want %v", got, tc.want)
}
})
}
}

View File

@ -1,17 +0,0 @@
package verbose
import "fmt"
const prefix = "fortify:"
func Println(a ...any) {
if verbose.Load() {
fmt.Println(append([]any{prefix}, a...)...)
}
}
func Printf(format string, a ...any) {
if verbose.Load() {
fmt.Printf(prefix+" "+format, a...)
}
}

View File

@ -1,67 +0,0 @@
package verbose_test
import (
"os"
"os/exec"
"strconv"
"strings"
"testing"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
const (
testVerbose = "GO_TEST_VERBOSE"
wantStdout = "fortify: println\nfortify: printf"
)
func TestPrinter(t *testing.T) {
switch os.Getenv(testVerbose) {
case "0":
verbose.Set(false)
case "1":
verbose.Set(true)
default:
return
}
verbose.Println("println")
verbose.Printf("%s", "printf")
}
func TestPrintf_Println(t *testing.T) {
testPrintfPrintln(t, false)
testPrintfPrintln(t, true)
// make -cover happy
stdout := os.Stdout
t.Cleanup(func() {
os.Stdout = stdout
})
os.Stdout = nil
verbose.Set(true)
verbose.Printf("")
verbose.Println()
}
func testPrintfPrintln(t *testing.T, v bool) {
t.Run("start verbose printer with verbose "+strconv.FormatBool(v), func(t *testing.T) {
stdout, stderr := new(strings.Builder), new(strings.Builder)
stdout.Grow(len(wantStdout))
cmd := exec.Command(os.Args[0], "-test.run=TestPrinter")
cmd.Stdout, cmd.Stderr = stdout, stderr
if v {
cmd.Env = append(cmd.Env, testVerbose+"=1")
} else {
cmd.Env = append(cmd.Env, testVerbose+"=0")
}
if err := cmd.Run(); err != nil {
panic("cannot run printer process: " + err.Error() + " stderr: " + stderr.String())
}
if got := stdout.String(); strings.Contains(got, wantStdout) != v {
t.Errorf("Print: got %v; want %t",
got, v)
}
})
}

View File

@ -1,13 +0,0 @@
package verbose
import "sync/atomic"
var verbose = new(atomic.Bool)
func Get() bool {
return verbose.Load()
}
func Set(v bool) {
verbose.Store(v)
}

View File

@ -1,20 +0,0 @@
package verbose_test
import (
"testing"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
func TestGetSet(t *testing.T) {
verbose.Set(false)
if verbose.Get() {
t.Errorf("Get() = true, want false")
}
verbose.Set(true)
if !verbose.Get() {
t.Errorf("Get() = false, want true")
}
}

View File

@ -5,14 +5,37 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"git.ophivana.moe/security/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap"
) )
func Exec(p string) ([]*Entry, error) { func Exec(p string) ([]*Entry, error) {
t := exec.Command("ldd", p) var (
t.Stdout, t.Stderr = new(strings.Builder), os.Stderr h helper.Helper
if err := t.Run(); err != nil { cmd *exec.Cmd
)
if b, err := helper.NewBwrap((&bwrap.Config{
Hostname: "fortify-ldd",
Chdir: "/",
NewSession: true,
DieWithParent: true,
}).Bind("/", "/").DevTmpfs("/dev"),
nil, "ldd", func(_, _ int) []string { return []string{p} }); err != nil {
return nil, err
} else {
cmd = b.Unwrap()
h = b
}
cmd.Stdout, cmd.Stderr = new(strings.Builder), os.Stderr
if err := h.Start(); err != nil {
return nil, err
}
if err := h.Wait(); err != nil {
return nil, err return nil, err
} }
return Parse(t.Stdout.(fmt.Stringer)) return Parse(cmd.Stdout.(fmt.Stringer))
} }

View File

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

View File

@ -4,7 +4,6 @@ import (
_ "embed" _ "embed"
"flag" "flag"
"fmt" "fmt"
"os"
) )
var ( var (

200
main.go
View File

@ -1,186 +1,78 @@
package main package main
import ( import (
"encoding/json"
"errors"
"flag" "flag"
"fmt" "syscall"
"os"
"git.ophivana.moe/cat/fortify/dbus" "git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/cat/fortify/internal" "git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/cat/fortify/internal/app" "git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/state" init0 "git.ophivana.moe/security/fortify/internal/init"
"git.ophivana.moe/cat/fortify/internal/verbose" "git.ophivana.moe/security/fortify/internal/shim"
) )
var ( var (
Version = "impure" flagVerbose bool
) )
func tryVersion() { func init() {
if printVersion { flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
fmt.Println(Version)
os.Exit(0)
}
} }
var os = new(internal.Std)
func main() { func main() {
// linux/sched/coredump.h
if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_DUMPABLE, 0, 0); errno != 0 {
fmsg.Printf("fortify: cannot set SUID_DUMP_DISABLE: %s", errno.Error())
}
flag.Parse() flag.Parse()
verbose.Set(flagVerbose) fmsg.SetVerbose(flagVerbose)
if internal.SdBootedV { if os.SdBooted() {
verbose.Println("system booted with systemd as init system") fmsg.VPrintln("system booted with systemd as init system")
} }
// launcher payload early exit // shim/init early exit
if printVersion && printLicense { init0.Try()
app.TryShim() shim.Try()
// root check
if os.Geteuid() == 0 {
fmsg.Fatal("this program must not run as root")
panic("unreachable")
} }
// version/license command early exit // version/license/template command early exit
tryVersion() tryVersion()
tryLicense() tryLicense()
tryTemplate()
// state query command early exit // state query command early exit
tryState() tryState()
// prepare config
var config *app.Config
if confPath == "nil" {
// config from flags
config = configFromFlags()
} else {
// config from file
if f, err := os.Open(confPath); err != nil {
fatalf("cannot access config file '%s': %s\n", confPath, err)
} else {
if err = json.NewDecoder(f).Decode(&config); err != nil {
fatalf("cannot parse config file '%s': %s\n", confPath, err)
}
}
}
// invoke app // invoke app
r := 1 a, err := app.New(os)
a := app.New() if err != nil {
if err := a.Seal(config); err != nil { fmsg.Fatalf("cannot create app: %s\n", err)
logBaseError(err, "fortify: cannot seal app:") } else if err = a.Seal(loadConfig()); err != nil {
logBaseError(err, "cannot seal app:")
fmsg.Exit(1)
} else if err = a.Start(); err != nil { } else if err = a.Start(); err != nil {
logBaseError(err, "fortify: cannot start app:") logBaseError(err, "cannot start app:")
} else if r, err = a.Wait(); err != nil { }
var r int
// wait must be called regardless of result of start
if r, err = a.Wait(); err != nil {
if r < 1 {
r = 1 r = 1
var e *app.BaseError
if !app.AsBaseError(err, &e) {
fmt.Println("fortify: wait failed:", err)
} else {
// Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError
var se *app.StateStoreError
if !errors.As(err, &se) {
// does not need special handling
fmt.Print("fortify: " + e.Message())
} else {
// inner error are either unwrapped store errors
// or joined errors returned by *appSealTx revert
// wrapped in *app.BaseError
var ej app.RevertCompoundError
if !errors.As(se.InnerErr, &ej) {
// does not require special handling
fmt.Print("fortify: " + e.Message())
} else {
errs := ej.Unwrap()
// every error here is wrapped in *app.BaseError
for _, ei := range errs {
var eb *app.BaseError
if !errors.As(ei, &eb) {
// unreachable
fmt.Println("fortify: invalid error type returned by revert:", ei)
} else {
// print inner *app.BaseError message
fmt.Print("fortify: " + eb.Message())
} }
logWaitError(err)
} }
if err = a.WaitErr(); err != nil {
fmsg.Println("inner wait failed:", err)
} }
} fmsg.Exit(r)
}
}
if err := a.WaitErr(); err != nil {
fmt.Println("fortify: inner wait failed:", err)
}
os.Exit(r)
}
func logBaseError(err error, message string) {
var e *app.BaseError
if app.AsBaseError(err, &e) {
fmt.Print("fortify: " + e.Message())
} else {
fmt.Println(message, err)
}
}
func configFromFlags() (config *app.Config) {
// initialise config from flags
config = &app.Config{
ID: dbusID,
User: userName,
Command: flag.Args(),
Method: launchMethodText,
}
// enablements from flags
if mustWayland {
config.Confinement.Enablements.Set(state.EnableWayland)
}
if mustX {
config.Confinement.Enablements.Set(state.EnableX)
}
if mustDBus {
config.Confinement.Enablements.Set(state.EnableDBus)
}
if mustPulse {
config.Confinement.Enablements.Set(state.EnablePulse)
}
// parse D-Bus config file from flags if applicable
if mustDBus {
if dbusConfigSession == "builtin" {
config.Confinement.SessionBus = dbus.NewConfig(dbusID, true, mpris)
} else {
if f, err := os.Open(dbusConfigSession); err != nil {
fatalf("cannot access session bus proxy config file '%s': %s\n", dbusConfigSession, err)
} else {
if err = json.NewDecoder(f).Decode(&config.Confinement.SessionBus); err != nil {
fatalf("cannot parse session bus proxy config file '%s': %s\n", dbusConfigSession, err)
}
}
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if f, err := os.Open(dbusConfigSystem); err != nil {
fatalf("cannot access system bus proxy config file '%s': %s\n", dbusConfigSystem, err)
} else {
if err = json.NewDecoder(f).Decode(&config.Confinement.SystemBus); err != nil {
fatalf("cannot parse system bus proxy config file '%s': %s\n", dbusConfigSystem, err)
}
}
}
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
}
}
return
}
func fatalf(format string, a ...any) {
fmt.Printf("fortify: "+format, a...)
os.Exit(1)
} }

View File

@ -199,14 +199,6 @@ in
description = "Privileged user account."; description = "Privileged user account.";
}; };
shell = mkOption {
type = types.str;
description = ''
Shell set up to source home-manager for the privileged user.
Required for setting up the environment of sandboxed programs.
'';
};
stateDir = mkOption { stateDir = mkOption {
type = types.str; type = types.str;
description = '' description = ''
@ -242,14 +234,14 @@ in
else else
null; null;
capArgs = capArgs =
(if wayland then " -wayland" else "") (if wayland then " --wayland" else "")
+ (if x11 then " -X" else "") + (if x11 then " -X" else "")
+ (if dbus then " -dbus" else "") + (if dbus then " --dbus" else "")
+ (if pulse then " -pulse" else "") + (if pulse then " --pulse" else "")
+ (if launcher.dbus.mpris then " -mpris" else "") + (if launcher.dbus.mpris then " --mpris" else "")
+ (if launcher.dbus.id != null then " -dbus-id ${launcher.dbus.id}" else "") + (if launcher.dbus.id != null then " --dbus-id ${launcher.dbus.id}" else "")
+ (if dbusConfig != null then " -dbus-config ${dbusConfig}" else "") + (if dbusConfig != null then " --dbus-config ${dbusConfig}" else "")
+ (if dbusSystem != null then " -dbus-system ${dbusSystem}" else ""); + (if dbusSystem != null then " --dbus-system ${dbusSystem}" else "");
in in
pkgs.writeShellScriptBin name ( pkgs.writeShellScriptBin name (
if launcher.method == "simple" then if launcher.method == "simple" then
@ -258,7 +250,7 @@ in
'' ''
else else
'' ''
exec fortify${capArgs} -method ${launcher.method} -u ${user} ${cfg.shell} -c "exec ${command} $@" exec fortify${capArgs} --method ${launcher.method} -u ${user} $SHELL -c "exec ${command} $@"
'' ''
) )
) launchers; ) launchers;

View File

@ -10,7 +10,7 @@
buildGoModule rec { buildGoModule rec {
pname = "fortify"; pname = "fortify";
version = "0.0.2"; version = "0.0.10";
src = ./.; src = ./.;
vendorHash = null; vendorHash = null;
@ -20,6 +20,8 @@ buildGoModule rec {
"-w" "-w"
"-X" "-X"
"main.Version=v${version}" "main.Version=v${version}"
"-X"
"main.FortifyPath=${placeholder "out"}/bin/.fortify-wrapped"
]; ];
buildInputs = [ buildInputs = [
@ -36,5 +38,7 @@ buildGoModule rec {
xdg-dbus-proxy xdg-dbus-proxy
] ]
} }
mv $out/bin/fsu $out/bin/.fsu
''; '';
} }

View File

@ -3,10 +3,10 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"os"
"text/tabwriter" "text/tabwriter"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/state"
) )
var ( var (
@ -21,15 +21,15 @@ func init() {
func tryState() { func tryState() {
if stateActionEarly { if stateActionEarly {
var w *tabwriter.Writer var w *tabwriter.Writer
state.MustPrintLauncherStateSimpleGlobal(&w) state.MustPrintLauncherStateSimpleGlobal(&w, os.Paths().RunDirPath)
if w != nil { if w != nil {
if err := w.Flush(); err != nil { if err := w.Flush(); err != nil {
fmt.Println("warn: error formatting output:", err) fmsg.Println("cannot format output:", err)
} }
} else { } else {
fmt.Println("No information available") fmt.Println("No information available")
} }
os.Exit(0) fmsg.Exit(0)
} }
} }

23
version.go Normal file
View File

@ -0,0 +1,23 @@
package main
import (
"flag"
"fmt"
)
var (
Version = "impure"
printVersion bool
)
func init() {
flag.BoolVar(&printVersion, "V", false, "Print version")
}
func tryVersion() {
if printVersion {
fmt.Println(Version)
os.Exit(0)
}
}

View File

@ -37,8 +37,7 @@ var (
) )
func ChangeHosts(mode, family C.uint8_t, address string) error { func ChangeHosts(mode, family C.uint8_t, address string) error {
var c *C.xcb_connection_t c := C.xcb_connect(nil, nil)
c = C.xcb_connect(nil, nil)
defer C.xcb_disconnect(c) defer C.xcb_disconnect(c)
if err := xcbHandleConnectionError(c); err != nil { if err := xcbHandleConnectionError(c); err != nil {