Harden FUSE loading and mount helper validation
ober
3509258ad20173e54b4f25cae5a91d56a61d9590
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: build-test-audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install system tools run: | --- a/.github/workflows/security-baseline.yml +++ b/.github/workflows/security-baseline.yml @@ -13,7 +13,7 @@ jobs: baseline: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Required release files run: | --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ CFLAGS ?= -O2 -fPIC $(WARN_CFLAGS) $(HARDEN_CFLAGS) SHIM := libjerboa_fuse_mount$(SO_EXT) -.PHONY: all build transpile test test-memfs test-secmem test-access test-vault clean shim ensure-jerboa-tools audit security openssl-advisory-check sbom reproducibility-report target-evidence verify release-evidence +.PHONY: all build transpile test test-loader-policy test-native-hardening test-memfs test-secmem test-access test-vault clean shim ensure-jerboa-tools audit security openssl-advisory-check sbom reproducibility-report target-evidence verify release-evidence all: build @@ -62,9 +62,20 @@ else EXTRA_LDPATH = endif -RUNTEST = JERBOA_FUSE_LIB=$(CURDIR) $(LD_VAR)=$(CURDIR)$(EXTRA_LDPATH) $(JERBUILD) exec --libdirs "$(LIBDIRS)" +RUNTEST = JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB=$(CURDIR) $(LD_VAR)=$(CURDIR)$(EXTRA_LDPATH) $(JERBUILD) exec --libdirs "$(LIBDIRS)" -test: test-memfs test-secmem test-access test-vault +test: test-loader-policy test-native-hardening test-memfs test-secmem test-access test-vault + +test-loader-policy: build + @REPO_ROOT="$(CURDIR)" JERBUILD="$(JERBUILD)" \ + LIBDIRS="$(LIBDIRS)" SHIM="$(CURDIR)/$(SHIM)" \ + sh tests/loader-policy.sh + +test-native-hardening: shim + $(CC) $(CFLAGS) $(OPENSSL_CFLAGS) -o tests/test_mount_helper \ + tests/test_mount_helper.c src/mount_helper.c $(OPENSSL_LIBS) + ./tests/test_mount_helper + rm -f tests/test_mount_helper test-memfs: build $(RUNTEST) tests/test-memfs.ss --- a/docs/ffi-boundary.md +++ b/docs/ffi-boundary.md @@ -8,6 +8,10 @@ - Copy mountpoint, filesystem-name, and vault-file paths from NUL-free bytevectors into temporary C strings. - Perform fixed-arity blockstore open/create wrappers so Scheme never declares variadic `open(2)`. - Retry `read`, `write`, `pread`, `pwrite`, and `fsync` around `EINTR` where retrying is safe. +- On macOS, launch the fixed `umount` argv/environment with `posix_spawn`, a + clean signal mask/default set, `/dev/null` stderr, and `--` before the + caller-controlled mountpoint. No Scheme or C callback resumes after raw + `fork`. - Allocate, zero, copy, and free mlock-backed secure memory with null/size guards. - Delegate vault CSPRNG, PBKDF2-HMAC-SHA256, and AES-256-GCM to OpenSSL. --- a/lib/jerboa-fuse/mount.sls +++ b/lib/jerboa-fuse/mount.sls @@ -14,7 +14,12 @@ with-input-from-string with-output-to-string iota \x31;+ \x31;- partition make-date make-time meta atom?) (except (jerboa prelude) c-lambda) - (only (jerboa ffi) c-lambda load-shared-object*)) + (only (jerboa ffi) c-lambda load-shared-object*) + (only (std native-loader) native-loader-privileged? + native-loader-development-enabled? + native-loader-validate-library! + native-loader-validate-directory!) + (only (chezscheme) foreign-entry?)) (def *native-loaded?* #f) (def *bindings-ready?* #f) (def (try-load-one! path) @@ -27,15 +32,46 @@ [(null? paths) #f] [(try-load-one! (car paths)) #t] [else (loop (cdr paths))]))) + (def (native-symbols-registered?) + (and (foreign-entry? "jerboa_fuse_open_device") + (foreign-entry? "jerboa_fuse_mount_bv") + (foreign-entry? "jerboa_fuse_close_fd"))) (def (native-search-paths) - (let ([base (or (getenv "JERBOA_FUSE_LIB") ".")]) - (list (string-append base "/libjerboa_fuse_mount.so") - (string-append base "/libjerboa_fuse_mount.dylib") - "./libjerboa_fuse_mount.so" "./libjerboa_fuse_mount.dylib" - "libjerboa_fuse_mount.so" "libjerboa_fuse_mount.dylib"))) + (let ([base (getenv "JERBOA_FUSE_LIB")]) + (unless (native-loader-development-enabled? + "JERBOA_FUSE_DEV_NATIVE") + (error 'jerboa-fuse/mount + "dynamic loading requires JERBOA_FUSE_DEV_NATIVE=1")) + (native-loader-validate-directory! + 'jerboa-fuse/mount + base + #t) + (let ([candidates (filter + file-regular? + (list + (string-append + base + "/libjerboa_fuse_mount.so") + (string-append + base + "/libjerboa_fuse_mount.dylib")))]) + (when (null? candidates) + (error 'jerboa-fuse/mount + "configured FUSE native shim is missing" + base)) + (map (lambda (path) + (native-loader-validate-library! + 'jerboa-fuse/mount + path + #t)) + candidates)))) (def (try-load-native!) (or *native-loaded?* - (and (try-load-any! (native-search-paths)) + (and (native-symbols-registered?) + (begin (set! *native-loaded?* #t) #t)) + (and (not (native-loader-privileged?)) + (try-load-any! (native-search-paths)) + (native-symbols-registered?) (begin (set! *native-loaded?* #t) #t)))) (def (ensure-mount-lib!) (try-load-native!)) (def c-open-device #f) --- a/lib/jerboa-fuse/vault/format.sls +++ b/lib/jerboa-fuse/vault/format.sls @@ -69,6 +69,11 @@ out)) (def (encode-vault-header total-blocks salt kdf-iterations mk-enc sb-enc) + (unless (= kdf-iterations KDF-ITERATIONS) + (error 'encode-vault-header + "unsupported KDF iteration count for vault format version" + VAULT-VERSION + kdf-iterations)) (let ([bv (make-bytevector HEADER-SIZE 0)]) (bv-set-u32le! bv 0 VAULT-MAGIC) (bytevector-u16-set! bv 4 VAULT-VERSION (endianness little)) @@ -87,9 +92,15 @@ (= ver VAULT-VERSION) (= blksz BLOCK-SIZE)) (error 'decode-vault-header "not a valid vault file")) - (values magic ver blksz (bv-u64le bv 10) (bv-sub bv 18 32) - (bv-u64le bv 50) (bv-sub bv 58 VAULT-MK-ENC-LEN) - (bv-sub bv 118 VAULT-SB-ENC-LEN)))) + (let ([kdf-iterations (bv-u64le bv 50)]) + (unless (= kdf-iterations KDF-ITERATIONS) + (error 'decode-vault-header + "unsupported KDF iteration count for vault format version" + ver + kdf-iterations)) + (values magic ver blksz (bv-u64le bv 10) (bv-sub bv 18 32) + kdf-iterations (bv-sub bv 58 VAULT-MK-ENC-LEN) + (bv-sub bv 118 VAULT-SB-ENC-LEN))))) (def (encode-superblock root-inode-block bitmap-start bitmap-blocks generation policy-block) (let ([bv (make-bytevector BLOCK-PAYLOAD 0)]) --- a/scripts/security-check.sh +++ b/scripts/security-check.sh @@ -66,6 +66,26 @@ if ! rg -q 'load-shared-object[*]' src || fail=1 fi +if ! test -f tests/loader-policy.sh || + ! grep -q 'JERBOA_FUSE_DEV_NATIVE' src/jerboa-fuse/mount.ss || + ! grep -q 'native-loader-validate-library!' src/jerboa-fuse/mount.ss; then + printf '%s\n' "FUSE shared-loader trust policy or regression is missing" >&2 + fail=1 +fi + +if rg -n -e '"\./libjerboa_fuse_mount|"libjerboa_fuse_mount' \ + src/jerboa-fuse/mount.ss; then + printf '%s\n' "FUSE native loader must not search CWD or bare library names" >&2 + fail=1 +fi + +if ! grep -q 'O_EXCL | O_NOFOLLOW' src/mount_helper.c || + ! grep -q 'if (mlock(p, size) != 0)' src/mount_helper.c || + ! grep -q 'if (ok != 1 && out && out_len > 0)' src/mount_helper.c; then + printf '%s\n' "vault create, secure-memory, or GCM failure hardening is missing" >&2 + fail=1 +fi + if ! grep -q 'jerboa_fuse_secmem_zero_checked' src/mount_helper.c || ! grep -q 'volatile unsigned char' src/mount_helper.c || ! grep -q 'copy_cstring' src/mount_helper.c || --- a/src/jerboa-fuse/mount.ss +++ b/src/jerboa-fuse/mount.ss @@ -17,7 +17,13 @@ (import (jerboa prelude) - (only (jerboa ffi) c-lambda load-shared-object*)) + (only (jerboa ffi) c-lambda load-shared-object*) + (only (std native-loader) + native-loader-privileged? + native-loader-development-enabled? + native-loader-validate-library! + native-loader-validate-directory!) + (only (chezscheme) foreign-entry?)) ;; Load native objects lazily so importing this module does not crash static ;; binaries or tools that only inspect the Scheme API. @@ -36,19 +42,38 @@ [(try-load-one! (car paths)) #t] [else (loop (cdr paths))]))) + (def (native-symbols-registered?) + (and (foreign-entry? "jerboa_fuse_open_device") + (foreign-entry? "jerboa_fuse_mount_bv") + (foreign-entry? "jerboa_fuse_close_fd"))) + (def (native-search-paths) - (let ([base (or (getenv "JERBOA_FUSE_LIB") ".")]) - (list - (string-append base "/libjerboa_fuse_mount.so") - (string-append base "/libjerboa_fuse_mount.dylib") - "./libjerboa_fuse_mount.so" - "./libjerboa_fuse_mount.dylib" - "libjerboa_fuse_mount.so" - "libjerboa_fuse_mount.dylib"))) + (let ([base (getenv "JERBOA_FUSE_LIB")]) + (unless (native-loader-development-enabled? + "JERBOA_FUSE_DEV_NATIVE") + (error 'jerboa-fuse/mount + "dynamic loading requires JERBOA_FUSE_DEV_NATIVE=1")) + (native-loader-validate-directory! 'jerboa-fuse/mount base #t) + (let ([candidates + (filter file-regular? + (list + (string-append base "/libjerboa_fuse_mount.so") + (string-append base "/libjerboa_fuse_mount.dylib")))]) + (when (null? candidates) + (error 'jerboa-fuse/mount + "configured FUSE native shim is missing" base)) + (map (lambda (path) + (native-loader-validate-library! + 'jerboa-fuse/mount path #t)) + candidates)))) (def (try-load-native!) (or *native-loaded?* - (and (try-load-any! (native-search-paths)) + (and (native-symbols-registered?) + (begin (set! *native-loaded?* #t) #t)) + (and (not (native-loader-privileged?)) + (try-load-any! (native-search-paths)) + (native-symbols-registered?) (begin (set! *native-loaded?* #t) #t)))) --- a/src/jerboa-fuse/vault/format.ss +++ b/src/jerboa-fuse/vault/format.ss @@ -93,6 +93,10 @@ ;; Total: 256 bytes (def (encode-vault-header total-blocks salt kdf-iterations mk-enc sb-enc) + (unless (= kdf-iterations KDF-ITERATIONS) + (error 'encode-vault-header + "unsupported KDF iteration count for vault format version" + VAULT-VERSION kdf-iterations)) (let ([bv (make-bytevector HEADER-SIZE 0)]) (bv-set-u32le! bv 0 VAULT-MAGIC) (bytevector-u16-set! bv 4 VAULT-VERSION (endianness little)) @@ -112,13 +116,21 @@ [blksz (bv-u32le bv 6)]) (unless (and (= magic VAULT-MAGIC) (= ver VAULT-VERSION) (= blksz BLOCK-SIZE)) (error 'decode-vault-header "not a valid vault file")) - (values - magic ver blksz - (bv-u64le bv 10) - (bv-sub bv 18 32) - (bv-u64le bv 50) - (bv-sub bv 58 VAULT-MK-ENC-LEN) - (bv-sub bv 118 VAULT-SB-ENC-LEN)))) + (let ([kdf-iterations (bv-u64le bv 50)]) + ;; The version-1 KDF cost is part of the format policy. Validate it + ;; before any password work so an unauthenticated header cannot turn + ;; vault-open into an attacker-selected multi-billion-round job. + (unless (= kdf-iterations KDF-ITERATIONS) + (error 'decode-vault-header + "unsupported KDF iteration count for vault format version" + ver kdf-iterations)) + (values + magic ver blksz + (bv-u64le bv 10) + (bv-sub bv 18 32) + kdf-iterations + (bv-sub bv 58 VAULT-MK-ENC-LEN) + (bv-sub bv 118 VAULT-SB-ENC-LEN))))) ;; ---- Superblock ---- ;; Encrypted; occupies one BLOCK-PAYLOAD-byte block (block 0). @@ -252,4 +264,3 @@ [name-bv (bv-sub bv (+ slot-offset 9) name-len)]) (values iblock name-bv))) - --- a/src/mount_helper.c +++ b/src/mount_helper.c @@ -39,10 +39,19 @@ #include <string.h> #include <unistd.h> #include <signal.h> +#include <spawn.h> #include <sys/mman.h> #include <sys/wait.h> #include <sys/types.h> +#if defined(__FreeBSD__) && !defined(FREEBSD) +#define FREEBSD 1 +#elif defined(__linux__) && !defined(LINUX) +#define LINUX 1 +#elif defined(__APPLE__) && !defined(DARWIN) +#define DARWIN 1 +#endif + #ifndef JERBOA_FUSE_NO_OPENSSL #include <openssl/evp.h> #include <openssl/rand.h> @@ -52,6 +61,10 @@ #define O_CLOEXEC 0 #endif +#ifndef O_NOFOLLOW +#error "jerboa-fuse requires O_NOFOLLOW for safe vault file handling" +#endif + #define JERBOA_FUSE_MAX_CSTRING 4096 #define JERBOA_FUSE_MAX_FSNAME 255 #define JERBOA_FUSE_MAX_SECMEM (64u * 1024u * 1024u) @@ -59,6 +72,12 @@ #define JERBOA_FUSE_GCM_TAG_LEN 16 #define JERBOA_FUSE_KEY_LEN 32 +static void jerboa_secure_zero(void *ptr, size_t size) { + volatile unsigned char *p = (volatile unsigned char *)ptr; + if (!p) return; + for (size_t i = 0; i < size; i++) p[i] = 0; +} + static int bytes_have_nul(const unsigned char *src, int len) { if (!src || len < 0) return 1; for (int i = 0; i < len; i++) { @@ -156,6 +175,7 @@ static ssize_t retry_pwrite_full(int fd, const unsigned char *buf, size_t len, #elif defined(DARWIN) #include <libproc.h> #include <sys/proc_info.h> +#include <sys/resource.h> #endif /* ==================================================================== @@ -173,14 +193,66 @@ void *jerboa_fuse_secmem_alloc(size_t size) { MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) return NULL; - /* Lock into RAM — prevent swapping */ - mlock(p, size); /* best-effort; may fail without RLIMIT_MEMLOCK */ + /* Lock into RAM. Key callers rely on this as a security boundary, so a + * best-effort allocation would be a dangerous lie. */ + if (mlock(p, size) != 0) { + int saved_errno = errno; + jerboa_secure_zero(p, size); + (void)munmap(p, size); + errno = saved_errno; + return NULL; + } /* Exclude from core dumps */ #if defined(FREEBSD) - madvise(p, size, MADV_NOCORE); + if (madvise(p, size, MADV_NOCORE) != 0) { + int saved_errno = errno; + jerboa_secure_zero(p, size); + (void)munlock(p, size); + (void)munmap(p, size); + errno = saved_errno; + return NULL; + } #elif defined(LINUX) - madvise(p, size, MADV_DONTDUMP); + if (madvise(p, size, MADV_DONTDUMP) != 0) { + int saved_errno = errno; + jerboa_secure_zero(p, size); + (void)munlock(p, size); + (void)munmap(p, size); + errno = saved_errno; + return NULL; + } +#elif defined(DARWIN) + /* Darwin has no per-range DONTDUMP advice. Fail closed by disabling core + * dumps for the process and ask the kernel to zero wired pages if the + * mapping is torn down without an explicit munlock. */ + { + struct rlimit no_core = {0, 0}; + if (setrlimit(RLIMIT_CORE, &no_core) != 0 || + madvise(p, size, MADV_ZERO_WIRED_PAGES) != 0) { + int saved_errno = errno; + jerboa_secure_zero(p, size); + (void)munlock(p, size); + (void)munmap(p, size); + errno = saved_errno; + return NULL; + } + } +#elif defined(MADV_DONTDUMP) + if (madvise(p, size, MADV_DONTDUMP) != 0) { + int saved_errno = errno; + jerboa_secure_zero(p, size); + (void)munlock(p, size); + (void)munmap(p, size); + errno = saved_errno; + return NULL; + } +#else + jerboa_secure_zero(p, size); + (void)munlock(p, size); + (void)munmap(p, size); + errno = ENOTSUP; + return NULL; #endif memset(p, 0, size); @@ -193,8 +265,7 @@ int jerboa_fuse_secmem_free_checked(void *ptr, size_t size) { return -1; } /* Volatile-safe zeroing — compiler cannot optimize this away */ - volatile unsigned char *vp = (volatile unsigned char *)ptr; - for (size_t i = 0; i < size; i++) vp[i] = 0; + jerboa_secure_zero(ptr, size); munlock(ptr, size); return munmap(ptr, size); } @@ -204,8 +275,7 @@ int jerboa_fuse_secmem_zero_checked(void *ptr, size_t size) { errno = EINVAL; return -1; } - volatile unsigned char *vp = (volatile unsigned char *)ptr; - for (size_t i = 0; i < size; i++) vp[i] = 0; + jerboa_secure_zero(ptr, size); return 0; } @@ -297,6 +367,9 @@ int jerboa_fuse_gcm_encrypt(const unsigned char *key, int key_len, ok = 1; done: + if (ok != 1 && out && out_len > 0) { + jerboa_secure_zero(out, (size_t)out_len); + } EVP_CIPHER_CTX_free(ctx); return ok; } @@ -346,6 +419,9 @@ int jerboa_fuse_gcm_decrypt(const unsigned char *key, int key_len, ok = 1; done: + if (ok != 1 && out && out_len > 0) { + jerboa_secure_zero(out, (size_t)out_len); + } EVP_CIPHER_CTX_free(ctx); return ok; } @@ -509,7 +585,8 @@ int jerboa_fuse_blockstore_create_bv(const unsigned char *path, int path_len) { char *path_c = copy_cstring(path, path_len, JERBOA_FUSE_MAX_CSTRING, 0); int fd; if (!path_c) return -1; - fd = open(path_c, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + fd = open(path_c, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); free(path_c); return fd; } @@ -676,14 +753,64 @@ int jerboa_fuse_mount(int fd, const char *mountpoint, const char *fsname, } int jerboa_fuse_unmount(const char *mountpoint) { - pid_t pid = fork(); - if (pid < 0) return -1; - if (pid == 0) { - /* Child: exec umount directly — no shell, no injection */ - int devnull = open("/dev/null", O_WRONLY); - if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); } - execl("/sbin/umount", "umount", mountpoint, (char *)NULL); - _exit(127); + char *const argv[] = {"umount", "--", (char *)mountpoint, NULL}; + char *const envp[] = { + "PATH=/usr/bin:/bin:/usr/sbin:/sbin", + "LC_ALL=C", + NULL + }; + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + sigset_t default_signals; + sigset_t empty_signal_mask; + short flags = POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK; + pid_t pid; + int rc; + + if (!mountpoint) { + errno = EINVAL; + return -1; + } + + rc = posix_spawn_file_actions_init(&actions); + if (rc != 0) { + errno = rc; + return -1; + } + rc = posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, + "/dev/null", O_WRONLY, 0); + if (rc != 0) { + (void)posix_spawn_file_actions_destroy(&actions); + errno = rc; + return -1; + } + rc = posix_spawnattr_init(&attributes); + if (rc != 0) { + (void)posix_spawn_file_actions_destroy(&actions); + errno = rc; + return -1; + } + if (sigfillset(&default_signals) != 0 || + sigdelset(&default_signals, SIGKILL) != 0 || + sigdelset(&default_signals, SIGSTOP) != 0 || + sigemptyset(&empty_signal_mask) != 0) { + rc = errno; + } else if ((rc = posix_spawnattr_setsigdefault(&attributes, + &default_signals)) == 0 && + (rc = posix_spawnattr_setsigmask(&attributes, + &empty_signal_mask)) == 0) { + rc = posix_spawnattr_setflags(&attributes, flags); + } + if (rc == 0) { + /* `--` keeps a mountpoint beginning with '-' in operand position. */ + rc = posix_spawn(&pid, "/sbin/umount", &actions, &attributes, + argv, envp); + } + (void)posix_spawnattr_destroy(&attributes); + (void)posix_spawn_file_actions_destroy(&actions); + if (rc != 0) { + errno = rc; + return -1; } int status; while (waitpid(pid, &status, 0) < 0) { new file mode 100644 --- /dev/null +++ b/tests/loader-policy.sh @@ -0,0 +1,66 @@ +#!/bin/sh +set -eu + +: "${REPO_ROOT:?}" +: "${JERBUILD:?}" +: "${LIBDIRS:?}" +: "${SHIM:?}" + +unsafe_tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-fuse-loader.XXXXXXXX") +trusted_tmp=$(mktemp -d "${HOME:?}/.jerboa-fuse-loader.XXXXXXXX") +trap 'rm -rf "$unsafe_tmp" "$trusted_tmp"' EXIT HUP INT TERM +chmod 700 "$trusted_tmp" +probe=$REPO_ROOT/tests/loader-probe.ss +shim_name=$(basename "$SHIM") + +mkdir "$unsafe_tmp/cwd" +cp "$SHIM" "$unsafe_tmp/cwd/$shim_name" +if (cd "$unsafe_tmp/cwd" && env -u JERBOA_FUSE_LIB -u JERBOA_FUSE_DEV_NATIVE \ + "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1); then + echo "FUSE loader accepted a shim from the current directory" >&2 + exit 1 +fi + +if (cd "$unsafe_tmp/cwd" && JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB=. \ + "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1); then + echo "FUSE loader accepted a relative override" >&2 + exit 1 +fi + +if JERBOA_FUSE_LIB="$REPO_ROOT" \ + "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then + echo "FUSE loader accepted an absolute override without development opt-in" >&2 + exit 1 +fi + +mkdir "$trusted_tmp/real" +cp "$SHIM" "$trusted_tmp/real/$shim_name" +chmod go-w "$trusted_tmp/real" "$trusted_tmp/real/$shim_name" +ln -s "$trusted_tmp/real" "$trusted_tmp/link" +if JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB="$trusted_tmp/link" \ + "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then + echo "FUSE loader accepted a symlink ancestor" >&2 + exit 1 +fi + +mkdir "$trusted_tmp/writable" +cp "$SHIM" "$trusted_tmp/writable/$shim_name" +chmod 777 "$trusted_tmp/writable" +if JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB="$trusted_tmp/writable" \ + "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then + echo "FUSE loader accepted a world-writable ancestor" >&2 + exit 1 +fi + +if test "$(id -u)" -eq 0; then + if JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB="$REPO_ROOT" \ + "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null 2>&1; then + echo "FUSE loader honored an environment override as root" >&2 + exit 1 + fi +else + JERBOA_FUSE_DEV_NATIVE=1 JERBOA_FUSE_LIB="$REPO_ROOT" \ + "$JERBUILD" exec --libdirs "$LIBDIRS" "$probe" >/dev/null +fi + +echo "fuse-loader-policy: ok" new file mode 100644 --- /dev/null +++ b/tests/loader-probe.ss @@ -0,0 +1,5 @@ +(import (jerboa prelude) + (only (jerboa-fuse mount) ensure-mount-lib!)) + +(unless (ensure-mount-lib!) + (error 'loader-probe "FUSE native shim was not loaded")) --- a/tests/test-vault.ss +++ b/tests/test-vault.ss @@ -4,6 +4,7 @@ (import (jerboa prelude)) (import (jerboa-fuse vault)) (import (jerboa-fuse access)) +(import (jerboa-fuse vault format)) (define pass 0) (define fail 0) @@ -36,6 +37,17 @@ (display "=== vault create / open / close ===") (newline) +;; Reject unauthenticated attacker-selected KDF work before deriving a key. +(let* ([salt (make-bytevector 32 0)] + [mk (make-bytevector VAULT-MK-ENC-LEN 0)] + [sb (make-bytevector VAULT-SB-ENC-LEN 0)] + [hdr (encode-vault-header 256 salt KDF-ITERATIONS mk sb)]) + (bv-set-u64le! hdr 50 (+ KDF-ITERATIONS 1)) + (test-assert "vault header rejects unsupported KDF iterations" + (guard (exn [#t #t]) + (call-with-values (lambda () (decode-vault-header hdr)) list) + #f))) + ;; Test 1: Create a vault (mkdir-if-missing test-dir) (cleanup) new file mode 100644 --- /dev/null +++ b/tests/test_mount_helper.c @@ -0,0 +1,163 @@ +#define _DARWIN_C_SOURCE +#define _POSIX_C_SOURCE 200809L + +#include <errno.h> +#include <fcntl.h> +#include <limits.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/resource.h> +#include <sys/stat.h> +#include <unistd.h> + +int jerboa_fuse_blockstore_create_bv(const unsigned char *path, int path_len); +int jerboa_fuse_gcm_encrypt(const unsigned char *key, int key_len, + const unsigned char *plaintext, int plaintext_len, + unsigned char *out, int out_len); +int jerboa_fuse_gcm_decrypt(const unsigned char *key, int key_len, + const unsigned char *ciphertext, int ciphertext_len, + unsigned char *out, int out_len); +void *jerboa_fuse_secmem_alloc(size_t size); +int jerboa_fuse_secmem_free_checked(void *ptr, size_t size); +int jerboa_fuse_unmount(const char *mountpoint); + +static int failures; + +static void check(int condition, const char *message) { + if (!condition) { + fprintf(stderr, "FAIL: %s\n", message); + failures++; + } +} + +static int read_exact(const char *path, char *buf, size_t len) { + int fd = open(path, O_RDONLY | O_CLOEXEC); + ssize_t got; + if (fd < 0) return -1; + do { + got = read(fd, buf, len); /* jerboa-security: suppress missing-eintr-retry -- errno/EINTR is retried immediately below */ + } while (got < 0 && errno == EINTR); + (void)close(fd); + return got == (ssize_t)len ? 0 : -1; +} + +static int write_exact(int fd, const char *buf, size_t len) { + size_t done = 0; + while (done < len) { + ssize_t wrote = write(fd, buf + done, len - done); + if (wrote < 0 && errno == EINTR) continue; + if (wrote <= 0) return -1; + done += (size_t)wrote; + } + return 0; +} + +static void test_exclusive_nofollow_create(void) { + char tmp[] = "/tmp/jerboa-fuse-native-XXXXXX"; + char target[PATH_MAX]; + char linkpath[PATH_MAX]; + char buf[8] = {0}; + int fd; + + check(mkdtemp(tmp) != NULL, "mkdtemp succeeds"); + if (failures) return; + (void)snprintf(target, sizeof(target), "%s/target", tmp); + (void)snprintf(linkpath, sizeof(linkpath), "%s/vault", tmp); + + fd = open(target, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + check(fd >= 0, "sentinel target create succeeds"); + if (fd >= 0) { + check(write_exact(fd, "sentinel", 8) == 0, + "sentinel target write succeeds"); + (void)close(fd); + } + check(symlink(target, linkpath) == 0, "vault symlink create succeeds"); + + errno = 0; + fd = jerboa_fuse_blockstore_create_bv((const unsigned char *)linkpath, + (int)strlen(linkpath)); + check(fd < 0, "blockstore create rejects a symlink destination"); + if (fd >= 0) (void)close(fd); + check(read_exact(target, buf, sizeof(buf)) == 0 && + memcmp(buf, "sentinel", sizeof(buf)) == 0, + "rejected symlink leaves its target unchanged"); + + (void)unlink(linkpath); + errno = 0; + fd = jerboa_fuse_blockstore_create_bv((const unsigned char *)target, + (int)strlen(target)); + check(fd < 0, "blockstore create rejects an existing regular file"); + if (fd >= 0) (void)close(fd); + memset(buf, 0, sizeof(buf)); + check(read_exact(target, buf, sizeof(buf)) == 0 && + memcmp(buf, "sentinel", sizeof(buf)) == 0, + "rejected existing file remains unchanged"); + + (void)unlink(target); + (void)rmdir(tmp); +} + +static void test_bad_gcm_tag_zeros_plaintext(void) { + unsigned char key[32] = {0}; + unsigned char plaintext[32]; + unsigned char ciphertext[12 + sizeof(plaintext) + 16]; + unsigned char output[sizeof(plaintext)]; + int rc; + + memset(plaintext, 0x3c, sizeof(plaintext)); + rc = jerboa_fuse_gcm_encrypt(key, sizeof(key), plaintext, sizeof(plaintext), + ciphertext, sizeof(ciphertext)); + check(rc == 1, "GCM encryption succeeds"); + if (rc != 1) return; + + ciphertext[sizeof(ciphertext) - 1] ^= 1; + memset(output, 0xa5, sizeof(output)); + rc = jerboa_fuse_gcm_decrypt(key, sizeof(key), ciphertext, + sizeof(ciphertext), output, sizeof(output)); + check(rc == 0, "GCM decryption rejects a bad tag"); + for (size_t i = 0; i < sizeof(output); i++) { + if (output[i] != 0) { + check(0, "bad-tag plaintext buffer is completely zeroed"); + break; + } + } +} + +static void test_secmem_lock_failure_is_fatal(void) { +#ifdef RLIMIT_MEMLOCK + struct rlimit zero = {0, 0}; + if (setrlimit(RLIMIT_MEMLOCK, &zero) == 0) { + void *p = jerboa_fuse_secmem_alloc(4096); + check(p == NULL, "secure-memory allocation fails when mlock is denied"); + if (p) (void)jerboa_fuse_secmem_free_checked(p, 4096); + } else { + fprintf(stderr, "SKIP: unable to lower RLIMIT_MEMLOCK\n"); + } +#endif +} + +static void test_unmount_launcher_fails_safely(void) { +#if defined(__APPLE__) + char tmp[] = "/tmp/jerboa-fuse-unmount-test-XXXXXX"; + char *created = mkdtemp(tmp); + check(created != NULL, "unmount probe directory create succeeds"); + if (!created) return; + check(jerboa_fuse_unmount(tmp) != 0, + "posix_spawn unmount reports a non-mount without hanging"); + check(rmdir(tmp) == 0, "unmount probe leaves directory intact"); +#endif + errno = 0; + check(jerboa_fuse_unmount(NULL) == -1 && errno == EINVAL, + "unmount rejects a null mountpoint before launching"); +} + +int main(void) { + test_exclusive_nofollow_create(); + test_bad_gcm_tag_zeros_plaintext(); + test_unmount_launcher_fails_safely(); + test_secmem_lock_failure_is_fatal(); + if (failures) return 1; + puts("native hardening tests: ok"); + return 0; +}