/mnt/persist/private: sandbox in a way that the actual gocryptfs instance doesn't get CAP_SYS_ADMIN

This commit is contained in:
2024-08-06 00:52:48 +00:00
parent 53acab834c
commit 93cb1bc546
4 changed files with 136 additions and 73 deletions

View File

@@ -5,6 +5,6 @@
./ephemeral
./initrd.nix
./plaintext.nix
./private.nix
./private
];
}

View File

@@ -4,73 +4,64 @@ let
persist-base = "/nix/persist";
origin = config.sane.persist.stores."private".origin;
backing = sane-lib.path.concat [ persist-base "private" ];
gocryptfs-private = pkgs.writeShellApplication {
name = "gocryptfs-private";
runtimeInputs = with pkgs; [
coreutils-full
gocryptfs
inotify-tools
util-linux #< gocryptfs complains that it can't exec `logger`, otherwise
];
text = ''
# backing=$1
# facing=$2
mountArgs=("$@")
passdir=/run/gocryptfs
passfile="$passdir/private.key"
waitForPassfileOnce() {
local timeout=$1
if [ -f "$passfile" ]; then
return 0
else
# wait for some file to be created inside the directory.
# inotifywait returns 0 if the file was created. 1 or 2 if timeout was hit or it was interrupted by a different event.
inotifywait --timeout "$timeout" --event create "$passdir"
return 1 #< maybe it was created; we'll pick that up immediately, on next check
fi
}
waitForPassfile() {
# there's a race condition between testing the path and starting `inotifywait`.
# therefore, use a retry loop. exponential backoff to decrease the impact of the race condition,
# especially near the start of boot to allow for quick reboots even if/when i hit the race.
for timeout in 4 4 8 8 8 8 16 16 16 16 16 16 16 16; do
if waitForPassfileOnce "$timeout"; then
return 0
fi
done
while true; do
if waitForPassfileOnce 30; then
return 0
fi
done
}
tryOpenStore() {
# try to open the store (blocking), if it fails, then delete the passfile because the user probably entered the wrong password
echo "mounting with ''${mountArgs[*]}"
# gocryptfs will unlock the store, and *then* fork into the background.
# so when it returns, the files are either immediately accessible, or the mount failed (likely due to a bad password
if ! gocryptfs "''${mountArgs[@]}"; then
echo "failed mount (transient failure)"
rm -f "$passfile"
return 1
fi
}
waitForPassfile
while ! tryOpenStore; do
waitForPassfile
done
echo "mounted"
# mount is complete (successful), and backgrounded.
# remove the passfile even on successful mount, for vague safety reasons (particularly if the user were to explicitly unmount the private store).
rm -f "$passfile"
'';
};
in
lib.mkIf config.sane.persist.enable
{
sane.programs."mount.fuse3.gocryptfs-private" = {
packageUnwrapped = pkgs.static-nix-shell.mkBash {
pname = "mount.fuse3.gocryptfs-private";
srcRoot = ./.;
pkgs = [
"coreutils-full"
"inotify-tools"
"libfuse-sane"
];
};
sandbox.enable = false; #< TODO: re-enable
sandbox.method = "landlock";
sandbox.autodetectCliPaths = "existing";
sandbox.capabilities = [
"sys_admin"
"chown"
"dac_override"
"dac_read_search"
"fowner"
"lease"
"mknod"
"setgid"
"setuid"
];
sandbox.extraPaths = [
"/dev/fuse"
"/run/gocryptfs"
];
suggestedPrograms = [ "gocryptfs-private" ];
};
sane.programs.gocryptfs-private = {
packageUnwrapped = pkgs.static-nix-shell.mkBash {
pname = "gocryptfs-private";
srcRoot = ./.;
pkgs = [ "gocryptfs" ];
};
sandbox.method = "landlock";
sandbox.autodetectCliPaths = "existing";
sandbox.capabilities = [
# "sys_admin" #< omitted: not required if using fuse3-sane with -o pass_fuse_fd
"chown"
"dac_override"
"dac_read_search"
"fowner"
"lease"
"mknod"
"setgid"
"setuid"
];
sandbox.extraPaths = [
"/run/gocryptfs" #< TODO: teach sanebox about `-o FLAG1=VALUE1,FLAG2=VALUE2` style of argument passing, then use `existingOrParent` autodetect, and remove this
];
suggestedPrograms = [ "gocryptfs" ];
};
sane.persist.stores."private" = {
storeDescription = ''
encrypted store which persists across boots.
@@ -90,8 +81,8 @@ lib.mkIf config.sane.persist.enable
};
fileSystems."${origin}" = {
device = "${lib.getExe gocryptfs-private}#${backing}";
fsType = "fuse3";
device = "gocryptfs-private#${backing}";
fsType = "fuse3.gocryptfs-private";
options = [
# "auto"
"nofail"
@@ -107,6 +98,7 @@ lib.mkIf config.sane.persist.enable
"x-systemd.mount-timeout=infinity"
# "retry=10000"
# "fg"
"pass_fuse_fd"
];
noCheck = true;
};
@@ -122,7 +114,7 @@ lib.mkIf config.sane.persist.enable
mount.mountConfig.TimeoutSec = "infinity";
# hardening (systemd-analyze security mnt-persist-private.mount)
mount.mountConfig.AmbientCapabilities = "";
mount.mountConfig.AmbientCapabilities = "CAP_SYS_ADMIN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_CHOWN CAP_MKNOD CAP_LEASE CAP_SETGID CAP_SETUID CAP_FOWNER";
# CAP_LEASE is probably not necessary -- does any fs user use leases?
mount.mountConfig.CapabilityBoundingSet = "CAP_SYS_ADMIN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_CHOWN CAP_MKNOD CAP_LEASE CAP_SETGID CAP_SETUID CAP_FOWNER";
mount.mountConfig.LockPersonality = true;
@@ -140,7 +132,7 @@ lib.mkIf config.sane.persist.enable
mount.mountConfig.SystemCallArchitectures = "native";
mount.mountConfig.SystemCallFilter = [
# unfortunately, i need to keep @network-io (accept, bind, connect, listen, recv, send, socket, ...). not sure why (daemon control socket?).
"@system-service" "@mount" "~@cpu-emulation" "~@keyring"
"@system-service" "@mount" "@sandbox" "~@cpu-emulation" "~@keyring"
];
mount.mountConfig.IPAddressDeny = "any";
mount.mountConfig.DevicePolicy = "closed"; # only allow /dev/{null,zero,full,random,urandom}
@@ -148,13 +140,14 @@ lib.mkIf config.sane.persist.enable
mount.mountConfig.SocketBindDeny = "any";
};
# it also needs to know that the underlying device is an ordinary folder
sane.fs."${backing}".dir.acl.user = config.sane.defaultUser;
sane.fs."${backing}".dir.acl.user = config.sane.defaultUser; #< TODO: shouldn't have to be owned by me, but stalls boot if not.
sane.fs."/run/gocryptfs".dir.acl = {
user = config.sane.defaultUser;
mode = "0700";
user = config.sane.defaultUser; #< must be user-writable so i can unlock it.
mode = "0775"; #< TODO: 770?
};
system.fsPackages = [ gocryptfs-private ];
sane.programs."mount.fuse3.gocryptfs-private".enableFor.system = true;
system.fsPackages = [ pkgs.libfuse-sane config.sane.programs."mount.fuse3.gocryptfs-private".package ];
sane.user.services.gocryptfs-private = {
description = "wait for /mnt/persist/private to be mounted";

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p bash -p gocryptfs
exec gocryptfs --sanebox-path /run/gocryptfs/private.key "$@"

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p bash -p coreutils-full -p inotify-tools -p libfuse-sane
# backing=$1
# facing=$2
mountArgs=("$@")
passdir=/run/gocryptfs
passfile="$passdir/private.key"
waitForPassfileOnce() {
local timeout=$1
if [ -f "$passfile" ]; then
return 0
else
# wait for some file to be created inside the directory.
# inotifywait returns 0 if the file was created. 1 or 2 if timeout was hit or it was interrupted by a different event.
inotifywait --timeout "$timeout" --event create "$passdir"
return 1 #< maybe it was created; we'll pick that up immediately, on next check
fi
}
waitForPassfile() {
# there's a race condition between testing the path and starting `inotifywait`.
# therefore, use a retry loop. exponential backoff to decrease the impact of the race condition,
# especially near the start of boot to allow for quick reboots even if/when i hit the race.
for timeout in 4 4 8 8 8 8 16 16 16 16 16 16 16 16; do
if waitForPassfileOnce "$timeout"; then
return 0
fi
done
while true; do
if waitForPassfileOnce 30; then
return 0
fi
done
}
tryOpenStore() {
# try to open the store (blocking), if it fails, then delete the passfile because the user probably entered the wrong password
echo "mounting with ${mountArgs[*]}"
# gocryptfs will unlock the store, and *then* fork into the background.
# so when it returns, the files are either immediately accessible, or the mount failed (likely due to a bad password
# if ! SANEBOX_APPEND="--sanebox-path $passfile" gocryptfs "${mountArgs[@]}"; then
# echo "failed mount (transient failure)"
# rm -f "$passfile"
# return 1
# fi
#
# the actual `mount` operation blocks fs operations until it succeeds or fails.
# hence, we don't want to be inside `mount` while waiting for the password, else we block the world.
# instead, do this funky recursive thing, where we call ourself after the password is provided.
# TODO: better would be to move the password procurement out of the mount altogether, into some .unit file,
# but that's tricky because the unit file wouldn't have a great way of validating the password.
if ! mount.fuse3.sane "${mountArgs[@]}"; then
echo "failed mount (transient failure)"
rm -f "$passfile"
return 1
fi
}
waitForPassfile
while ! tryOpenStore; do
waitForPassfile
done
echo "mounted"
# mount is complete (successful), and backgrounded.
# remove the passfile even on successful mount, for vague safety reasons (particularly if the user were to explicitly unmount the private store).
rm -f "$passfile"