Add browser vault and static Qt bundle support

ober

a667d013baef3eafa09250c272b68e35c0c8742a

diff --git a/.dockerignore b/.dockerignore
index 986c0e0..cfad2ad 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,7 +9,9 @@ jerboa-browser
 jerboa-browser-static-qt-linux-amd64.tar.gz
 *.boot
 *.so
+*.dylib
 *.wpo
 jb-main.c
 jb_*.h
+scheme/browser/build-vault-secret.ss
 .DS_Store
diff --git a/.gitignore b/.gitignore
index a2b1d39..91a32e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 # CMake build trees
 build/
 **/build/
+**/target/
 
 # Native binary + Jerboa build artifacts (see build-binary.ss / Makefile)
 /jerboa-browser
@@ -11,6 +12,7 @@ jerboa-browser-static-qt-linux-amd64.tar.gz
 *.boot
 jb-main.c
 jb_*.h
+scheme/browser/build-vault-secret.ss
 
 # Offline Qt harness output (make test-gui) + CI snapshot bundle
 test-artifacts/
@@ -22,3 +24,4 @@ gui-snapshots.tar.gz
 # local dev: symlink to the Rust crypto lib so `jerbuild exec` (interpreter
 # tests) can load it; the built binary registers these symbols statically.
 lib/libjerboa_native.*
+lib/libyubikey_native.*
diff --git a/.jerbuild b/.jerbuild
index 5034c98..f20f7bf 100644
--- a/.jerbuild
+++ b/.jerbuild
@@ -6,4 +6,6 @@
 
 (entry "scheme/browser-main.ss")
 (output "jerboa-browser")
-(libdirs "scheme")
+(libdirs "vendor/jerboa-yubikey/lib" "scheme" "vendor/jerboa-fuse/lib")
+(pre-build "sh support/ensure-vendor.sh jerboa-yubikey https://git.sr.ht/~lisp/jerboa-yubikey")
+(pre-build "sh support/gen-browser-vault-secret.sh scheme/browser/build-vault-secret.ss")
diff --git a/Dockerfile.qt b/Dockerfile.qt
index 0417fbd..40e5d6d 100644
--- a/Dockerfile.qt
+++ b/Dockerfile.qt
@@ -1,11 +1,11 @@
-# Docker recipe for building the jerboa-browser Linux Qt artifact.
+# Docker recipe for building the bundled jerboa-browser Linux Qt artifact.
 #
 # This mirrors the jerboa-emacs static-qt workflow: build inside Docker, create a
 # container, and copy the finished artifact from /out. Unlike jemacs-qt, this
 # project uses Qt WebEngine, so the result is not a single fully static Qt
-# executable. The tarball contains the native Jerboa browser binary plus the
-# Qt WebEngine backend shared library and expects a system Qt 6 WebEngine
-# runtime, matching packaging/linux/build-tarball.sh.
+# executable. The tarball contains the native Jerboa browser binary, the Qt
+# WebEngine backend shared library, Qt libraries/plugins, QtWebEngineProcess,
+# and WebEngine resource files.
 #
 # Usage:
 #   docker build -f Dockerfile.qt -t jerboa-browser-qt-builder .
@@ -23,7 +23,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     file \
     g++ \
     libgl1-mesa-dev \
+    libudev-dev \
     make \
+    patchelf \
     pkg-config \
     qt6-base-dev \
     qt6-webengine-dev \
@@ -48,7 +50,7 @@ RUN if ! command -v git >/dev/null 2>&1; then \
 COPY . /src
 WORKDIR /src
 
-RUN SCHEME="$(command -v scheme)" bash packaging/linux/build-tarball.sh && \
+RUN BUNDLE_QT=1 SCHEME="$(command -v scheme)" bash packaging/linux/build-tarball.sh && \
     mkdir -p /out && \
     cp qt-webengine/build/jerboa-browser-*-linux-*.tar.gz \
       /out/jerboa-browser-static-qt.tar.gz && \
@@ -57,10 +59,39 @@ RUN SCHEME="$(command -v scheme)" bash packaging/linux/build-tarball.sh && \
     tar tzf "$tb" | grep -E '/bin/jerboa-browser\.bin$'   >/dev/null && \
     tar tzf "$tb" | grep -E '/lib/libjerboa_browser\.so$' >/dev/null && \
     tar tzf "$tb" | grep -E '/lib/libjerboa_native\.so$'  >/dev/null && \
+    tar tzf "$tb" | grep -E '/bin/qt.conf$'               >/dev/null && \
+    tar tzf "$tb" | grep -E '/lib/libjerboa_fuse_mount\.so$' >/dev/null && \
+    tar tzf "$tb" | grep -E '/lib/libyubikey_native\.so$' >/dev/null && \
+    tar tzf "$tb" | grep -E '/lib/libQt6WebEngineCore\.so\.6$' >/dev/null && \
+    tar tzf "$tb" | grep -E '/libexec/QtWebEngineProcess$' >/dev/null && \
+    tar tzf "$tb" | grep -E '/libexec/qt.conf$'           >/dev/null && \
+    tar tzf "$tb" | grep -E '/resources/qtwebengine_resources\.pak$' >/dev/null && \
     bin_path="$(tar tzf "$tb" | grep -E '/bin/jerboa-browser\.bin$' | head -n 1)" && \
     tar xzf "$tb" -O "$bin_path" > /tmp/jerboa-browser && \
     file /tmp/jerboa-browser
 
 FROM ubuntu:24.04
 COPY --from=builder /out/jerboa-browser-static-qt.tar.gz /out/jerboa-browser-static-qt.tar.gz
+RUN set -eux; \
+    mkdir -p /tmp/check; \
+    tar xzf /out/jerboa-browser-static-qt.tar.gz -C /tmp/check; \
+    appdir="$(find /tmp/check -mindepth 1 -maxdepth 1 -type d -name 'jerboa-browser-*' | head -n 1)"; \
+    test -x "$appdir/bin/jerboa-browser"; \
+    test -x "$appdir/bin/jerboa-browser.bin"; \
+    test -f "$appdir/bin/qt.conf"; \
+    test -f "$appdir/lib/libjerboa_browser.so"; \
+    test -f "$appdir/lib/libjerboa_native.so"; \
+    test -f "$appdir/lib/libjerboa_fuse_mount.so"; \
+    test -f "$appdir/lib/libyubikey_native.so"; \
+    test -f "$appdir/lib/libQt6WebEngineCore.so.6"; \
+    test -x "$appdir/libexec/QtWebEngineProcess"; \
+    test -f "$appdir/libexec/qt.conf"; \
+    test -f "$appdir/resources/qtwebengine_resources.pak"; \
+    test -f "$appdir/plugins/platforms/libqxcb.so"; \
+    test -f "$appdir/plugins/platforms/libqoffscreen.so"; \
+    : > /tmp/ldd.txt; \
+    for f in "$appdir/bin/jerboa-browser.bin" "$appdir/lib/libjerboa_browser.so" "$appdir/lib/libjerboa_native.so" "$appdir/lib/libjerboa_fuse_mount.so" "$appdir/lib/libyubikey_native.so" "$appdir/libexec/QtWebEngineProcess"; do \
+      LD_LIBRARY_PATH="$appdir/lib" ldd "$f" >> /tmp/ldd.txt 2>&1 || true; \
+    done; \
+    ! grep -q 'not found' /tmp/ldd.txt
 CMD ["cat", "/out/jerboa-browser-static-qt.tar.gz"]
diff --git a/Makefile b/Makefile
index d1aaae0..0d9b70e 100644
--- a/Makefile
+++ b/Makefile
@@ -3,9 +3,14 @@
 # full Qt/WebKit GUI bundle additionally needs the native wrapper.)
 JERBUILD ?= jerbuild
 JH := $(shell $(JERBUILD) --jerboa-home 2>/dev/null)
-JEXEC       := $(JERBUILD) exec --libdirs $(CURDIR)/scheme:$(JH)/lib
+JERBOA_YUBIKEY_URL ?= https://git.sr.ht/~lisp/jerboa-yubikey
+JERBOA_YUBIKEY_DIR := vendor/jerboa-yubikey
+JERBOA_YUBIKEY_LIB := $(JERBOA_YUBIKEY_DIR)/lib
+JEXEC       := $(JERBUILD) exec --libdirs $(CURDIR)/$(JERBOA_YUBIKEY_LIB):$(CURDIR)/scheme:$(CURDIR)/vendor/jerboa-fuse/lib:$(JH)/lib
 JERBUILD_ERROR := "ERROR: jerbuild not found on PATH (or '$(JERBUILD) --jerboa-home' failed). Install jerbuild, or set JERBUILD=/path/to/jerbuild."
 
+CC ?= cc
+CARGO ?= cargo
 DOCKER ?= docker
 DOCKER_PLATFORM ?= linux/amd64
 DOCKER_BUILD_FLAGS ?=
@@ -23,6 +28,10 @@ STATIC_QT_TARBALL ?= jerboa-browser-static-qt-linux-amd64.tar.gz
 # static-qt tarball bundles a copy in lib/; its launcher adds lib/ to
 # LD_LIBRARY_PATH so the binary finds it from any CWD.)
 SOEXT := $(if $(filter Darwin,$(shell uname -s)),dylib,so)
+FUSE_SHARED_FLAG := $(if $(filter Darwin,$(shell uname -s)),-dynamiclib,-shared)
+FUSE_SHIM := libjerboa_fuse_mount.so
+BUILD_VAULT_SECRET := scheme/browser/build-vault-secret.ss
+YUBIKEY_NATIVE_SHARED := $(JERBOA_YUBIKEY_DIR)/yubikey-native/target/release/libyubikey_native.$(SOEXT)
 # `make binary` links the static archive from a temp dir and leaves no shared
 # lib behind, so build the cdylib from jerbuild's bundled crate if it isn't
 # already cached, then symlink it where native-rust probes (./lib, CWD-relative).
@@ -37,7 +46,25 @@ lib/libjerboa_native.$(SOEXT): | check-jerbuild
 	 if [ -f "$$src" ]; then ln -sf "$$src" "$@" && echo "staged $@ -> $$src"; \
 	 else echo "note: libjerboa_native.$(SOEXT) unavailable; crypto tests may fail"; fi
 
-.PHONY: all build binary run test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-securestore test-adblock test-gui test-buffers repl clean help check-jerbuild check-docker static-qt docker-static-qt
+$(FUSE_SHIM): vendor/jerboa-fuse/src/mount_helper.c
+	$(CC) $(FUSE_SHARED_FLAG) -fPIC -O2 -o $@ $<
+
+$(BUILD_VAULT_SECRET):
+	sh support/gen-browser-vault-secret.sh $@
+
+vendor-yubikey:
+	sh support/ensure-vendor.sh jerboa-yubikey "$(JERBOA_YUBIKEY_URL)"
+	@test -f "$(JERBOA_YUBIKEY_LIB)/yubikey/auth.sls" || \
+	  { echo "ERROR: missing $(JERBOA_YUBIKEY_LIB)/yubikey/auth.sls" >&2; exit 1; }
+
+lib/libyubikey_native.$(SOEXT): vendor-yubikey
+	$(CARGO) build --manifest-path $(JERBOA_YUBIKEY_DIR)/yubikey-native/Cargo.toml --release --no-default-features
+	@mkdir -p lib
+	cp -f $(YUBIKEY_NATIVE_SHARED) $@
+
+yubikey-native: lib/libyubikey_native.$(SOEXT)
+
+.PHONY: all build binary run test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-securestore test-adblock test-gui test-buffers repl clean help check-jerbuild check-docker static-qt docker-static-qt vault-secret rotate-vault-secret vendor-yubikey yubikey-native
 .DEFAULT_GOAL := help
 
 all: binary
@@ -50,7 +77,7 @@ check-docker:
 	  { echo "ERROR: Docker executable '$(DOCKER)' not found. Install Docker or set DOCKER=/path/to/docker."; exit 1; }
 
 # Build the self-contained native ./jerboa-browser via .jerbuild.
-binary: check-jerbuild
+binary: check-jerbuild vendor-yubikey $(FUSE_SHIM) $(BUILD_VAULT_SECRET)
 	$(JERBUILD) build
 	@echo "" && ls -lh jerboa-browser && file jerboa-browser
 
@@ -61,8 +88,8 @@ run: binary
 
 # The crypto-using interpreter suites load (std crypto native-rust), so stage the
 # Rust crypto lib (rule above) as a prerequisite of each.
-test-securestore test-commands test-keys test-nav test-hint test-pass test-polish test-adblock: lib/libjerboa_native.$(SOEXT)
-test test-keymap test-minibuffer test-gui test-buffers repl: check-jerbuild
+test-securestore test-commands test-keys test-nav test-hint test-pass test-polish test-adblock: lib/libjerboa_native.$(SOEXT) vendor-yubikey
+test test-keymap test-minibuffer test-gui test-buffers repl: check-jerbuild vendor-yubikey
 
 # Run the (browser) test suite in the interpreter (no binary needed). Depends on
 # the pure secure-store unit tests so they run as part of `make test`.
@@ -139,9 +166,14 @@ test-buffers:
 repl:
 	$(JEXEC) scheme/browser-repl.ss
 
+vault-secret: $(BUILD_VAULT_SECRET)
+
+rotate-vault-secret:
+	sh support/gen-browser-vault-secret.sh --force $(BUILD_VAULT_SECRET)
+
 # Docker wrapper matching the jerboa-emacs static-qt workflow. Qt WebEngine is
-# not emitted as one fully static executable; the artifact is the existing Linux
-# tarball shape with the native browser binary plus system-Qt WebEngine backend.
+# not emitted as one fully static executable; the artifact is a bundled Linux
+# tarball with the native browser binary plus the Qt WebEngine runtime.
 static-qt: docker-static-qt
 
 docker-static-qt: check-docker
@@ -159,13 +191,33 @@ docker-static-qt: check-docker
 	  { echo "ERROR: launcher missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
 	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/bin/jerboa-browser\.bin$$' >/dev/null || \
 	  { echo "ERROR: native binary missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/bin/qt.conf$$' >/dev/null || \
+	  { echo "ERROR: Qt config missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
 	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/lib/libjerboa_browser.so$$' >/dev/null || \
 	  { echo "ERROR: Qt WebEngine backend missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
 	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/lib/libjerboa_native.so$$' >/dev/null || \
 	  { echo "ERROR: Rust crypto lib (libjerboa_native) missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/lib/libjerboa_fuse_mount.so$$' >/dev/null || \
+	  { echo "ERROR: FUSE vault helper missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/lib/libyubikey_native.so$$' >/dev/null || \
+	  { echo "ERROR: YubiKey native backend missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/lib/libQt6WebEngineCore.so.6$$' >/dev/null || \
+	  { echo "ERROR: Qt WebEngineCore runtime missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/libexec/QtWebEngineProcess$$' >/dev/null || \
+	  { echo "ERROR: QtWebEngineProcess missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/libexec/qt.conf$$' >/dev/null || \
+	  { echo "ERROR: QtWebEngineProcess Qt config missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/resources/qtwebengine_resources.pak$$' >/dev/null || \
+	  { echo "ERROR: Qt WebEngine resources missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/plugins/platforms/libqxcb.so$$' >/dev/null || \
+	  { echo "ERROR: Qt xcb platform plugin missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
+	@tar tzf $(STATIC_QT_TARBALL) | grep -E '/plugins/platforms/libqoffscreen.so$$' >/dev/null || \
+	  { echo "ERROR: Qt offscreen platform plugin missing from $(STATIC_QT_TARBALL)" >&2; exit 1; }
 
 clean:
 	rm -f jerboa-browser jb-main.c jb-main.o jb_*.h jerboa-browser.boot
+	rm -f $(FUSE_SHIM)
+	rm -f lib/libyubikey_native.$(SOEXT)
 	rm -f $(STATIC_QT_TARBALL)
 	rm -rf .bcache test-artifacts
 	find scheme -name '*.so' -delete 2>/dev/null || true
@@ -175,7 +227,11 @@ help:
 	@echo "jerboa-browser — programmable Qt WebEngine pane from the Jerboa REPL"
 	@echo ""
 	@echo "  make binary   build the self-contained native ./jerboa-browser"
-	@echo "  make static-qt build the Linux Qt/WebEngine tarball via Docker"
+	@echo "  make static-qt build the bundled Linux Qt/WebEngine tarball via Docker"
+	@echo "  make vault-secret create the local build vault secret, if missing"
+	@echo "  make rotate-vault-secret replace the local build vault secret"
+	@echo "  make vendor-yubikey clone jerboa-yubikey into vendor/"
+	@echo "  make yubikey-native build lib/libyubikey_native for direct USB"
 	@echo "  make test     run the (browser) test suite (interpreter)"
 	@echo "  make test-gui offline Qt GUI/snapshot harness (offscreen, headless)"
 	@echo "  make repl     REPL with (browser) preloaded (interpreter)"
diff --git a/README.md b/README.md
index a262775..fbeb644 100644
--- a/README.md
+++ b/README.md
@@ -78,10 +78,11 @@ and the echo area reports the running count of blocked requests.
 ## YubiKey-encrypted bookmarks
 
 Bookmarks (not passwords) can be saved **encrypted at rest** and unlocked with a
-YubiKey's PIV applet — no `ykman`, `pcscd`, or other external tool needed. The
-PIV transport is built-in PC/SC (Apple's PCSC.framework on macOS, pcsc-lite on
-Linux); the crypto and the minimal YubiKey library are vendored
-(`scheme/browser/{secure-store,yubikey-unlock}.ss`, `scheme/yubikey/`).
+YubiKey's PIV applet — no `ykman` or `yubico-piv-tool` needed. The crypto lives
+in `scheme/browser/secure-store.ss` and YubiKey support comes from the vendored
+`vendor/jerboa-yubikey/lib` library. On macOS it uses Apple's built-in
+PCSC.framework; Linux packages bundle `libyubikey_native.so` for direct USB CCID
+access, with pcsc-lite as a fallback when available.
 
 - `C-c y i` — **init**: provisions PIV slot 9D if empty (generates an RSA-2048
   key — PIN-policy ALWAYS, touch CACHED — authenticating the default management
@@ -97,11 +98,59 @@ key); only unlocking talks to the card. Store: `$XDG_DATA_HOME/jerboa-browser/
 bookmarks.enc`. Needs a YubiKey 5 (firmware 5.3+); on Linux, install `pcsc-lite`
 and run `pcscd`.
 
+## Encrypted browser vault
+
+By default, WebEngine still uses an off-record profile. To keep browser cache,
+cookies, local storage, downloads, and other profile data in an encrypted FUSE
+vault, launch with:
+
+```sh
+JERBOA_BROWSER_VAULT=1 jerboa-browser
+```
+
+The browser process creates and mounts its own vault before the WebEngine
+profile is opened, then points Qt at directories inside that mount. The FUSE
+access controller trusts only the browser PID and descendants such as
+`QtWebEngineProcess`; a `jsh` `,vault mount` can be used at a different path but
+is not reused by the browser. Defaults:
+
+- Vault file: `$XDG_DATA_HOME/jerboa-browser/browser.vault`
+- Mountpoint: `$XDG_RUNTIME_DIR/jerboa-browser/vault-<browser-pid>` (or `/tmp`)
+- Size: `JERBOA_BROWSER_VAULT_BLOCKS` blocks, default 65536 (256 MiB)
+
+The production default is YubiKey PIV unlock. With `JERBOA_BROWSER_VAULT=1`, the
+browser prompts for the YubiKey PIV PIN before the Qt profile opens and derives
+the vault passphrase through the card operation, requiring the YubiKey, PIN, and
+touch when the slot policy requires it. A stolen vault file plus browser binary
+is not enough to open the profile.
+
+For first use with a blank PIV slot 9D, launch once with
+`JERBOA_BROWSER_VAULT_YUBIKEY_PROVISION=1`; the browser will generate an
+RSA-2048 key in the key-management slot using the default PIV management key,
+matching the bookmark-store provisioning path.
+
+Explicit overrides are still available:
+
+- `JERBOA_BROWSER_VAULT_PASSPHRASE_FILE` — 0600 file containing a recovery or
+  test passphrase.
+- `JERBOA_BROWSER_VAULT_PASSPHRASE` — local testing only.
+- `JERBOA_BROWSER_VAULT_UNLOCK=prompt` — terminal passphrase prompt.
+- `JERBOA_BROWSER_VAULT_UNLOCK=build-secret` — development fallback.
+
+The build-secret fallback is not production-grade: `make binary` creates
+`scheme/browser/build-vault-secret.ss` if it is missing, and that generated,
+ignored module holds a random build-local passphrase as masked bytes rather
+than a printable string. Normal rebuilds preserve it; `make rotate-vault-secret`
+replaces it. Anyone with the generated source or binary can still recover that
+secret, so use it only for development or migration.
+
 ## Build / test
 
 ```sh
 make binary           # the self-contained native ./jerboa-browser
-make static-qt        # Linux Qt/WebEngine tarball via Docker (linux/amd64)
+make static-qt        # bundled Linux Qt/WebEngine tarball via Docker (linux/amd64)
+make vendor-yubikey   # clone https://git.sr.ht/~lisp/jerboa-yubikey into vendor/
+make yubikey-native   # build lib/libyubikey_native for direct USB YubiKey access
 make test             # (browser) suite + secure-store unit tests; also test-{keymap,…,polish}
 make test-securestore # just the pure secure-store crypto/format unit tests
 make test-gui         # offline Qt snapshot harness (offscreen, headless)
@@ -109,9 +158,10 @@ make test-gui         # offline Qt snapshot harness (offscreen, headless)
 
 `make static-qt` mirrors the jerboa-emacs Docker workflow and copies out
 `jerboa-browser-static-qt-linux-amd64.tar.gz`. Because this backend is Qt
-WebEngine, the artifact is a Linux tarball with the native browser binary plus
-`libjerboa_browser.so`; it still expects a system Qt 6 WebEngine runtime rather
-than a literal one-file static WebEngine executable.
+WebEngine, the artifact is a bundled Linux tarball rather than a literal
+one-file static executable. It includes the native browser binary, the
+`libjerboa_browser.so` backend, the browser vault FUSE helper, Qt
+libraries/plugins, `QtWebEngineProcess`, and WebEngine resource files.
 
 The functional suites run offscreen (`QT_QPA_PLATFORM=offscreen`) and hermetic
 (`JWB_TEST_NO_NETWORK=1`), modeled on the jerboa-emacs Qt test harness.
@@ -125,6 +175,7 @@ qt-webengine/              Qt WebEngine backend (CMake; builds libjerboa_browser
   probe/                   Ticket 0.1 standalone proof-of-host (kept for re-checks)
   tests/                   ABI/handle tests (ctest)
 scheme/                    Jerboa wrapper module + tests (.ss)
+vendor/jerboa-fuse/        browser-owned encrypted FUSE vault
 notes/                     host pin, design watchpoints
 jerboa-browser.md          living plan / ticket log
 ```
diff --git a/packaging/README.md b/packaging/README.md
index 252d59a..ce4d4ef 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -96,24 +96,44 @@ Total ≈ 299 MB (dominated by QtWebEngineCore + ICU).
 
 ```sh
 packaging/linux/build-tarball.sh      # → qt-webengine/build/jerboa-browser-<ver>-linux-<arch>.tar.gz
+BUNDLE_QT=1 packaging/linux/build-tarball.sh
 make static-qt                        # Docker build, copies ./jerboa-browser-static-qt-linux-amd64.tar.gz
 ```
 
-The tarball bundles the backend `libjerboa_browser.so`, the Jerboa `scheme/`
-sources, and `build-binary.ss` + `Makefile`. When the Jerboa toolchain (Chez +
-Jerboa stdlib) is present on the build host it also compiles and bundles the
-self-contained native `bin/jerboa-browser` — a Jerboa program with the Chez
-kernel, boot image, and the `(browser)` library all embedded (built by
-`build-binary.ss`, **not** a C/C++ launcher); otherwise unpack and run `make
-binary` to produce it. Either way it relies on a **system Qt 6 WebEngine**
-install (documented prerequisite) rather than vendoring Qt.
-
-`make static-qt` wraps that Linux package path in Docker, using the
-`jerboa21/jerboa` image plus Ubuntu Qt WebEngine packages, and has been verified
-on `linux.cons.io` (`linux/amd64`).
-
-For a fully self-contained Linux artifact, run the bundle through
-[`linuxdeployqt`](https://github.com/probonopd/linuxdeployqt) to produce an
-AppImage — the analogue of `macdeployqt`. It has the same WebEngine helper
-caveat (`QtWebEngineProcess` must find the bundled Qt libs); linuxdeployqt
-handles it via `RUNPATH`/`AppRun`. Wiring that into CI is future work.
+The tarball bundles the backend `libjerboa_browser.so`, the browser-owned FUSE
+vault helper `libjerboa_fuse_mount.so`, the direct-USB YubiKey backend
+`libyubikey_native.so`, the Jerboa `scheme/` sources, `vendor/jerboa-fuse/`,
+`vendor/jerboa-yubikey/`, the support scripts, and `build-binary.ss` +
+`Makefile`.
+When the Jerboa toolchain (Chez + Jerboa stdlib) is present on the build host it
+also compiles and bundles the self-contained native `bin/jerboa-browser` — a
+Jerboa program with the Chez kernel, boot image, and the `(browser)` library all
+embedded (built by `make binary`/`jerbuild`, **not** a C/C++ launcher);
+otherwise unpack and run `make binary` to produce it.
+
+With `BUNDLE_QT=1`, the package also stages Qt libraries, platform plugins,
+`QtWebEngineProcess`, WebEngine `.pak` resources, ICU data, locales, and the
+shared-library dependency closure under the package root. The launcher
+`bin/jerboa-browser` sets `LD_LIBRARY_PATH`, `QT_PLUGIN_PATH`,
+`QML2_IMPORT_PATH`, `QT_TRANSLATIONS_PATH`, `QTWEBENGINEPROCESS_PATH`,
+`QTWEBENGINE_RESOURCES_PATH`, `QTWEBENGINE_LOCALES_PATH`, and
+`JERBOA_FUSE_MOUNT_LIB`, then execs `bin/jerboa-browser.bin`. `qt.conf` files
+next to the native binary and `QtWebEngineProcess` point Qt at the package root.
+The Chromium sandbox remains enabled.
+
+Browser profile persistence is opt-in with `JERBOA_BROWSER_VAULT=1`. The
+browser mounts its own encrypted vault and points Qt cache/storage paths inside
+that mount; do not reuse a `jsh` `,vault mount` path for the browser. The
+default unlock method is YubiKey PIV through `vendor/jerboa-yubikey/lib`: the
+browser prompts for the PIV PIN before the Qt profile opens and uses the card
+operation as the vault passphrase. Linux packages bundle `libyubikey_native.so`
+for direct USB CCID access, with PC/SC as the library fallback when available.
+For first use with a blank PIV slot 9D, launch once with
+`JERBOA_BROWSER_VAULT_YUBIKEY_PROVISION=1`. `JERBOA_BROWSER_VAULT_PASSPHRASE_FILE`
+or `JERBOA_BROWSER_VAULT_PASSPHRASE` overrides it for recovery/testing, and
+`JERBOA_BROWSER_VAULT_UNLOCK=build-secret` selects the development-only
+build-local secret fallback.
+
+`make static-qt` always uses `BUNDLE_QT=1` inside Docker, with the
+`jerboa21/jerboa` image plus Ubuntu Qt WebEngine packages, and validates the
+tarball in a clean Ubuntu stage with no Qt package installed.
diff --git a/packaging/linux/build-tarball.sh b/packaging/linux/build-tarball.sh
index 4eb01ec..9de3117 100755
--- a/packaging/linux/build-tarball.sh
+++ b/packaging/linux/build-tarball.sh
@@ -2,15 +2,13 @@
 #
 # build-tarball.sh — package the Jerboa Browser for Linux.
 #
-# Builds the Qt WebEngine FFI backend (libjerboa_browser.so) via CMake, then —
-# when the Jerboa toolchain (Chez + Jerboa stdlib) is available — compiles the
-# self-contained native `jerboa-browser` binary with build-binary.ss. The
-# tarball always ships the sources + build files so the binary can be built
-# later with `make binary`. It relies on a SYSTEM Qt 6 WebEngine install rather
-# than vendoring Qt; for a fully self-contained artifact run the staged tree
-# through linuxdeployqt to get an AppImage (see packaging/README.md).
+# Builds the Qt WebEngine FFI backend (libjerboa_browser.so) via CMake, then -
+# when the Jerboa toolchain (Chez + Jerboa stdlib) is available - compiles the
+# self-contained native `jerboa-browser` binary. With BUNDLE_QT=1, it also
+# stages the Qt WebEngine runtime: Qt libraries, plugins, QtWebEngineProcess,
+# Chromium resource files, and the shared-library dependency closure.
 #
-# Env overrides: BUILD, QT_PREFIX, VERSION, JERBOA_HOME, SCHEME.
+# Env overrides: BUILD, QT_PREFIX, VERSION, JERBOA_HOME, SCHEME, BUNDLE_QT.
 set -euo pipefail
 
 REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -21,6 +19,216 @@ STAGE="$BUILD/jerboa-browser-$VERSION-linux-$ARCH"
 TARBALL="$STAGE.tar.gz"
 JERBOA_HOME="${JERBOA_HOME:-$HOME/mine/jerboa}"
 SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}"
+BUNDLE_QT="${BUNDLE_QT:-0}"
+
+copy_tree_if_exists() {
+    local src="$1" dst="$2"
+    if [ -d "$src" ]; then
+        mkdir -p "$dst"
+        cp -aL "$src"/. "$dst"/
+    fi
+}
+
+copy_named_library() {
+    local name="$1" path=""
+    path="$(ldconfig -p 2>/dev/null | awk -v n="$name" '$1 == n { print $NF; exit }' || true)"
+    if [ -z "$path" ]; then
+        for d in /lib /usr/lib /lib/* /usr/lib/*; do
+            if [ -f "$d/$name" ]; then
+                path="$d/$name"
+                break
+            fi
+        done
+    fi
+    if [ -n "$path" ] && [ -f "$path" ]; then
+        cp -L "$path" "$STAGE/lib/"
+    fi
+}
+
+should_bundle_dep() {
+    local dep="$1" base
+    base="$(basename "$dep")"
+    case "$base" in
+        ld-linux*.so*|ld-musl-*.so*|linux-vdso.so.*)
+            return 1
+            ;;
+        libc.so.*|libdl.so.*|libm.so.*|libpthread.so.*|librt.so.*|libresolv.so.*|libnsl.so.*|libutil.so.*|libanl.so.*)
+            return 1
+            ;;
+    esac
+    case "$dep" in
+        "$STAGE"/*)
+            return 1
+            ;;
+    esac
+    [ -f "$dep" ]
+}
+
+copy_ldd_deps_once() {
+    local changed=1 elf line dep dest
+    for elf in "$@"; do
+        [ -f "$elf" ] || continue
+        while IFS= read -r line; do
+            dep=
+            case "$line" in
+                *"=>"*)
+                    dep="${line#*=> }"
+                    dep="${dep%% (*}"
+                    ;;
+                /*)
+                    dep="${line%% (*}"
+                    ;;
+            esac
+            [ -n "${dep:-}" ] || continue
+            should_bundle_dep "$dep" || continue
+            dest="$STAGE/lib/$(basename "$dep")"
+            if [ ! -e "$dest" ]; then
+                cp -L "$dep" "$dest"
+                changed=0
+            fi
+        done < <(ldd "$elf" 2>/dev/null || true)
+    done
+    return "$changed"
+}
+
+copy_ldd_closure() {
+    while :; do
+        mapfile -t elf_files < <(
+            find "$STAGE/bin" "$STAGE/lib" "$STAGE/libexec" "$STAGE/plugins" "$STAGE/qml" \
+                -type f 2>/dev/null
+        )
+        copy_ldd_deps_once "${elf_files[@]}" || break
+    done
+}
+
+patch_rpath() {
+    local file="$1" rpath="$2"
+    [ -f "$file" ] || return 0
+    patchelf --set-rpath "$rpath" "$file" 2>/dev/null || true
+}
+
+write_qt_conf() {
+    local path="$1"
+    cat > "$path" <<'EOF'
+[Paths]
+Prefix=..
+Libraries=lib
+Plugins=plugins
+Qml2Imports=qml
+Translations=translations
+EOF
+}
+
+bundle_qt_runtime() {
+    [ "$HAVE_BIN" = 1 ] || {
+        echo "BUNDLE_QT=1 requires a native bin/jerboa-browser in the package" >&2
+        exit 1
+    }
+    command -v patchelf >/dev/null 2>&1 || {
+        echo "BUNDLE_QT=1 requires patchelf" >&2
+        exit 1
+    }
+
+    echo "==> bundling Qt WebEngine runtime"
+    mkdir -p "$STAGE/libexec" "$STAGE/plugins" "$STAGE/qml" \
+             "$STAGE/resources/qtwebengine_locales" \
+             "$STAGE/resources/qtwebengine_dictionaries"
+
+    if [ ! -x "$STAGE/bin/jerboa-browser.bin" ]; then
+        mv "$STAGE/bin/jerboa-browser" "$STAGE/bin/jerboa-browser.bin"
+    fi
+    cat > "$STAGE/bin/jerboa-browser" <<'EOF'
+#!/bin/sh
+set -eu
+APPDIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
+export LD_LIBRARY_PATH="$APPDIR/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+export QT_PLUGIN_PATH="$APPDIR/plugins"
+export QML2_IMPORT_PATH="$APPDIR/qml${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}"
+export QT_TRANSLATIONS_PATH="$APPDIR/translations${QT_TRANSLATIONS_PATH:+:$QT_TRANSLATIONS_PATH}"
+export JERBOA_BROWSER_LIB="${JERBOA_BROWSER_LIB:-$APPDIR/lib/libjerboa_browser.so}"
+export QTWEBENGINEPROCESS_PATH="${QTWEBENGINEPROCESS_PATH:-$APPDIR/libexec/QtWebEngineProcess}"
+export QTWEBENGINE_RESOURCES_PATH="${QTWEBENGINE_RESOURCES_PATH:-$APPDIR/resources}"
+export QTWEBENGINE_LOCALES_PATH="${QTWEBENGINE_LOCALES_PATH:-$APPDIR/resources/qtwebengine_locales}"
+export QTWEBENGINE_DICTIONARIES_PATH="${QTWEBENGINE_DICTIONARIES_PATH:-$APPDIR/resources/qtwebengine_dictionaries}"
+export JERBOA_FUSE_MOUNT_LIB="${JERBOA_FUSE_MOUNT_LIB:-$APPDIR/lib/libjerboa_fuse_mount.so}"
+export JERBOA_BROWSER_EXE="${JERBOA_BROWSER_EXE:-$APPDIR/bin/jerboa-browser.bin}"
+exec "$APPDIR/bin/jerboa-browser.bin" "$@"
+EOF
+    chmod +x "$STAGE/bin/jerboa-browser" "$STAGE/bin/jerboa-browser.bin"
+    write_qt_conf "$STAGE/bin/qt.conf"
+    write_qt_conf "$STAGE/libexec/qt.conf"
+
+    for d in /usr/lib/*/qt6/plugins /usr/lib/qt6/plugins; do
+        copy_tree_if_exists "$d" "$STAGE/plugins"
+    done
+    for d in /usr/lib/*/qt6/qml /usr/lib/qt6/qml; do
+        copy_tree_if_exists "$d" "$STAGE/qml"
+    done
+    for d in /usr/share/qt6/translations /usr/lib/*/qt6/translations /usr/lib/qt6/translations; do
+        copy_tree_if_exists "$d" "$STAGE/translations"
+    done
+
+    local process_path
+    process_path="$(find /usr -type f -name QtWebEngineProcess 2>/dev/null | head -n 1 || true)"
+    [ -n "$process_path" ] || {
+        echo "QtWebEngineProcess not found; install the Qt WebEngine runtime package" >&2
+        exit 1
+    }
+    install -m 0755 "$process_path" "$STAGE/libexec/QtWebEngineProcess"
+
+    while IFS= read -r f; do
+        case "$f" in
+            */qtwebengine_locales/*)
+                cp -L "$f" "$STAGE/resources/qtwebengine_locales/"
+                ;;
+            */qtwebengine_dictionaries/*)
+                cp -L "$f" "$STAGE/resources/qtwebengine_dictionaries/"
+                ;;
+            *)
+                cp -L "$f" "$STAGE/resources/"
+                ;;
+        esac
+    done < <(
+        find /usr -type f \( \
+            -name 'qtwebengine_resources*.pak' -o \
+            -name 'qtwebengine_devtools_resources.pak' -o \
+            -name 'icudtl.dat' -o \
+            -path '*/qtwebengine_locales/*.pak' -o \
+            -path '*/qtwebengine_dictionaries/*' \
+        \) 2>/dev/null
+    )
+
+    copy_named_library libcrypto.so.3
+    copy_ldd_closure
+
+    patch_rpath "$STAGE/bin/jerboa-browser.bin" '$ORIGIN/../lib'
+    patch_rpath "$STAGE/lib/libjerboa_browser.so" '$ORIGIN'
+    patch_rpath "$STAGE/libexec/QtWebEngineProcess" '$ORIGIN/../lib'
+    for f in "$STAGE"/lib/*.so*; do
+        patch_rpath "$f" '$ORIGIN'
+    done
+
+    [ -f "$STAGE/libexec/QtWebEngineProcess" ] || {
+        echo "missing bundled QtWebEngineProcess" >&2
+        exit 1
+    }
+    [ -f "$STAGE/resources/qtwebengine_resources.pak" ] || {
+        echo "missing bundled qtwebengine_resources.pak" >&2
+        exit 1
+    }
+    [ -f "$STAGE/plugins/platforms/libqxcb.so" ] || {
+        echo "missing bundled xcb platform plugin" >&2
+        exit 1
+    }
+    [ -f "$STAGE/plugins/platforms/libqoffscreen.so" ] || {
+        echo "missing bundled offscreen platform plugin" >&2
+        exit 1
+    }
+    [ -f "$STAGE/lib/libQt6WebEngineCore.so.6" ] || {
+        echo "missing bundled Qt WebEngineCore library" >&2
+        exit 1
+    }
+}
 
 echo "==> configuring + building the FFI backend (.so)"
 cmake_args=(-S "$REPO/qt-webengine" -B "$BUILD")
@@ -31,12 +239,38 @@ cmake --build "$BUILD" --target jerboa_browser
 LIB="$BUILD/libjerboa_browser.so"
 [ -f "$LIB" ] || { echo "expected $LIB — Linux build did not produce it" >&2; exit 1; }
 
+echo "==> building the FUSE mount helper"
+( cd "$REPO" && make libjerboa_fuse_mount.so )
+FUSE_SHIM="$REPO/libjerboa_fuse_mount.so"
+[ -f "$FUSE_SHIM" ] || { echo "expected $FUSE_SHIM" >&2; exit 1; }
+
+echo "==> ensuring vendored YubiKey library"
+( cd "$REPO" && make vendor-yubikey )
+
+echo "==> building YubiKey native USB backend"
+( cd "$REPO" && make lib/libyubikey_native.so )
+YUBIKEY_NATIVE="$REPO/lib/libyubikey_native.so"
+[ -f "$YUBIKEY_NATIVE" ] || { echo "expected $YUBIKEY_NATIVE" >&2; exit 1; }
+
 echo "==> staging tarball tree at $STAGE"
 rm -rf "$STAGE"
-mkdir -p "$STAGE/lib" "$STAGE/bin" "$STAGE/scheme"
+mkdir -p "$STAGE/lib" "$STAGE/bin" "$STAGE/scheme" "$STAGE/support"
 cp -f "$LIB" "$STAGE/lib/"
-cp -f "$REPO"/scheme/*.ss "$STAGE/scheme/" 2>/dev/null || true
-cp -f "$REPO/build-binary.ss" "$REPO/Makefile" "$STAGE/"
+cp -f "$FUSE_SHIM" "$STAGE/lib/"
+cp -f "$YUBIKEY_NATIVE" "$STAGE/lib/"
+( cd "$REPO" && tar --exclude='*.so' --exclude='*.wpo' \
+    --exclude='scheme/browser/build-vault-secret.ss' -cf - scheme ) | \
+    ( cd "$STAGE" && tar -xf - )
+( cd "$REPO" && tar --exclude='*.so' --exclude='*.wpo' -cf - vendor/jerboa-fuse ) | \
+    ( cd "$STAGE" && tar -xf - )
+( cd "$REPO" && tar --exclude='.git' --exclude='target' --exclude='*/target' \
+    --exclude='*.so' --exclude='*.dylib' --exclude='*.a' --exclude='*.wpo' \
+    -cf - vendor/jerboa-yubikey ) | \
+    ( cd "$STAGE" && tar -xf - )
+cp -f "$REPO/build-binary.ss" "$REPO/Makefile" "$REPO/.jerbuild" "$STAGE/"
+cp -f "$REPO/support/gen-browser-vault-secret.sh" \
+      "$REPO/support/ensure-vendor.sh" \
+      "$REPO/support/README.md" "$STAGE/support/"
 
 # The native binary embeds Chez + the (browser) library; build it when the
 # Jerboa toolchain is present (e.g. a developer host). CI images without Chez
@@ -100,6 +334,42 @@ else
     echo "    (unpack, then run 'make binary' with the Jerboa toolchain installed)"
 fi
 
+if [ "$BUNDLE_QT" = 1 ]; then
+    bundle_qt_runtime
+fi
+
+if [ "$BUNDLE_QT" = 1 ]; then
+    runtime_text="$(printf '%s\n' \
+        "  Qt WebEngine is bundled: Qt libraries, plugins, QtWebEngineProcess," \
+        "  Chromium resources, locales, and shared-library dependencies are staged" \
+        "  inside this tree. The Chromium sandbox is left enabled.")"
+    bin_desc="launcher"
+    real_bin_line="  bin/jerboa-browser.bin     self-contained native Jerboa program"
+    bundle_layout_text="$(printf '%s\n' \
+        "  lib/                       bundled shared libraries" \
+        "  libexec/QtWebEngineProcess Chromium helper process" \
+        "  plugins/                   Qt platform/image/tls/etc. plugins" \
+        "  resources/                 Qt WebEngine pak/ICU/locales/dictionaries")"
+    bin_state_line=""
+else
+    runtime_text="$(printf '%s\n' \
+        "  Prerequisite: Qt 6 WebEngine runtime installed system-wide" \
+        "    (e.g. apt install qt6-webengine-dev libqt6webenginewidgets6, or distro" \
+        "     equivalent). The Chromium sandbox is left enabled.")"
+    bin_desc="native binary, when present"
+    real_bin_line=""
+    bundle_layout_text=""
+    if [ "$HAVE_BIN" = 1 ]; then
+        bin_state_line="  bin/jerboa-browser         self-contained native binary (<url> | browse | repl | test | run <file>)"
+    else
+        bin_state_line="  bin/                       (empty - build with 'make binary'; needs Chez + Jerboa)"
+    fi
+fi
+native_layout_line=""
+if [ "$HAVE_NATIVE" = 1 ]; then
+    native_layout_line="  lib/libjerboa_native.so    Rust crypto for the secure store"
+fi
+
 cat > "$STAGE/README.txt" <<EOF
 Jerboa Browser $VERSION (linux-$ARCH)
 
@@ -124,22 +394,36 @@ Keys (Emacs-style: C-=Ctrl, M-=Alt; a space is a chord, e.g. C-x b is
   C-x C-c      quit
   C-h b        show ALL key bindings        M-x   run any command by name
 
-Prerequisite: Qt 6 WebEngine runtime installed system-wide
-  (e.g. apt install qt6-webengine-dev libqt6webenginewidgets6, or distro
-   equivalent). The Chromium sandbox is left enabled.
+Runtime:
+$runtime_text
 
 Layout:
-$([ "$HAVE_BIN" = 1 ] && echo "  bin/jerboa-browser         launcher: points the loader at lib/, runs the binary" || echo "  bin/                       (empty — build with 'make binary'; needs Chez + Jerboa)")
-$([ "$HAVE_BIN" = 1 ] && echo "  bin/jerboa-browser.bin     self-contained native binary (<url> | browse | repl | test | run <file>)")
-  lib/libjerboa_browser.so   Qt WebEngine backend (the native binary dlopens this)
-$([ "$HAVE_NATIVE" = 1 ] && echo "  lib/libjerboa_native.so    Rust crypto for the secure store (dlopened at runtime)")
+  bin/jerboa-browser         $bin_desc
+$real_bin_line
+  lib/libjerboa_browser.so   browser backend
+$native_layout_line
+  lib/libjerboa_fuse_mount.so browser-owned encrypted vault helper
+  lib/libyubikey_native.so   direct USB YubiKey backend
+$bundle_layout_text
   scheme/                    the (browser) Jerboa module + entry points
-  build-binary.ss, Makefile  rebuild the native binary: \`make binary\`
+  support/                   build-local browser vault secret generator
+  vendor/jerboa-fuse/        encrypted FUSE vault modules
+  vendor/jerboa-yubikey/     YubiKey Scheme + native backend source
+  build-binary.ss, Makefile  build the native binary: \`make binary\`
+$bin_state_line
 
 The native jerboa-browser binary embeds Chez Scheme, the boot image, and the
-(browser) library; only libjerboa_browser.so + system Qt 6 are external.
+(browser) library.
 
-For a no-prerequisite AppImage, run this tree through linuxdeployqt.
+Encrypted browser vault:
+  Set JERBOA_BROWSER_VAULT=1 to store Qt cache, cookies, local storage, and
+  downloads inside the browser-owned encrypted FUSE vault. The default unlock
+  method is YubiKey PIV: the browser prompts for the PIV PIN before opening the
+  Qt profile and uses the card operation as the vault passphrase. Explicit
+  JERBOA_BROWSER_VAULT_PASSPHRASE_FILE or JERBOA_BROWSER_VAULT_PASSPHRASE
+  overrides it; JERBOA_BROWSER_VAULT_UNLOCK=build-secret uses the development
+  build-local secret instead. For first use with a blank PIV slot 9D, launch
+  once with JERBOA_BROWSER_VAULT_YUBIKEY_PROVISION=1.
 EOF
 
 echo "==> creating $TARBALL"
diff --git a/qt-webengine/src/browser_view.cpp b/qt-webengine/src/browser_view.cpp
index 026cfc5..f85f352 100644
--- a/qt-webengine/src/browser_view.cpp
+++ b/qt-webengine/src/browser_view.cpp
@@ -6,8 +6,34 @@
 // single-threaded, so creation and destruction happen inline on that thread.
 #include "browser_objects.h"
 
+#include <QDir>
 #include <QUrl>
 
+namespace {
+
+QString env_path(const char *name) {
+  const QByteArray value = qgetenv(name);
+  if (value.isEmpty()) return QString();
+  return QString::fromLocal8Bit(value);
+}
+
+bool ensure_dir(const QString &path, const char *label, JwbStatus *err) {
+  if (path.isEmpty()) {
+    jwb::set_last_error(std::string(label) + " is empty");
+    *err = JWB_ERR_INVALID_ARGUMENT;
+    return false;
+  }
+  if (!QDir().mkpath(path)) {
+    jwb::set_last_error(std::string("could not create ") + label + ": " +
+                        path.toStdString());
+    *err = JWB_ERR_QT;
+    return false;
+  }
+  return true;
+}
+
+} // namespace
+
 namespace jwb {
 
 void *backend_context_create(uint32_t capability_flags, JwbStatus *err) {
@@ -16,9 +42,40 @@ void *backend_context_create(uint32_t capability_flags, JwbStatus *err) {
     *err = JWB_ERR_QT;
     return nullptr;
   }
-  // Default ctor = off-the-record profile (no on-disk storage). install_policy
-  // applies capability_flags via a request interceptor and profile settings.
-  auto *profile = new QWebEngineProfile();
+  QWebEngineProfile *profile = nullptr;
+  if (capability_flags & JWB_CAP_PERSISTENT_STORAGE) {
+    const QString storage_path = env_path("JERBOA_BROWSER_STORAGE_PATH");
+    const QString cache_path = env_path("JERBOA_BROWSER_CACHE_PATH");
+    const QString download_path = env_path("JERBOA_BROWSER_DOWNLOAD_PATH");
+    if (storage_path.isEmpty() || cache_path.isEmpty()) {
+      set_last_error("persistent storage requested without browser vault paths");
+      *err = JWB_ERR_INVALID_ARGUMENT;
+      return nullptr;
+    }
+    if (!ensure_dir(storage_path, "persistent storage path", err) ||
+        !ensure_dir(cache_path, "cache path", err) ||
+        (!download_path.isEmpty() &&
+         !ensure_dir(download_path, "download path", err))) {
+      return nullptr;
+    }
+    QString profile_name = env_path("JERBOA_BROWSER_PROFILE_NAME");
+    if (profile_name.isEmpty()) profile_name = QStringLiteral("browser-vault");
+    profile = new QWebEngineProfile(profile_name);
+    profile->setPersistentStoragePath(storage_path);
+    profile->setCachePath(cache_path);
+    profile->setHttpCacheType(QWebEngineProfile::DiskHttpCache);
+    profile->setPersistentCookiesPolicy(QWebEngineProfile::AllowPersistentCookies);
+    if (!download_path.isEmpty()) profile->setDownloadPath(download_path);
+    bool ok = false;
+    const int cache_max =
+        qgetenv("JERBOA_BROWSER_HTTP_CACHE_MAX_BYTES").toInt(&ok);
+    if (ok && cache_max > 0) profile->setHttpCacheMaximumSize(cache_max);
+  } else {
+    // Default ctor = off-the-record profile (no on-disk storage).
+    profile = new QWebEngineProfile();
+  }
+  // install_policy applies capability_flags via a request interceptor and
+  // profile settings.
   auto *c = new Context{capability_flags, profile, nullptr};
   install_policy(c);
   return c;
diff --git a/scheme/browser-main.ss b/scheme/browser-main.ss
index 881f571..14490f7 100644
--- a/scheme/browser-main.ss
+++ b/scheme/browser-main.ss
@@ -14,7 +14,12 @@
 ;;;   jerboa-browser test       run the (browser) test suite
 ;;;   jerboa-browser run FILE   run a Jerboa script with (browser) available
 
-(import (chezscheme) (browser) (browser buffers) (browser commands) (std repl))
+(import (chezscheme)
+        (browser)
+        (browser buffers)
+        (browser commands)
+        (browser vault)
+        (std repl))
 
 ;; Directory of argv[0], or "." when it has no slash / is unavailable.
 (define (exe-dir)
@@ -45,8 +50,15 @@
   ;; jerboa-repl evaluates in (interaction-environment); import (browser) into it
   ;; so browser-* is callable at the prompt with no further imports.
   (eval '(import (browser)) (interaction-environment))
+  (eval '(import (browser vault)) (interaction-environment))
   (display ";; (browser) loaded — call (browser-init) then (browser-open-context ...)\n")
-  (jerboa-repl))
+  (let ((vault #f))
+    (guard (e (#t
+               (when vault (browser-vault-unmount! vault))
+               (raise e)))
+      (set! vault (browser-vault-mount-if-enabled!))
+      (jerboa-repl)
+      (when vault (browser-vault-unmount! vault)))))
 
 (define default-url "https://example.com")
 
@@ -87,29 +99,41 @@
 ;; Set JWB_BROWSE_MS=<ms> for a headless/no-block smoke that pumps for ms, prints
 ;; the page title, then exits — used by tests/CI.
 (define (run-browse url)
-  (guard (e (#t (browse-fail "could not launch the browser" e)))
-    (let* ((s (open-browser-session))          ; init + network context + window
-           (win (session-window s)))
-      (browser-window-set-title win "Jerboa Browser")
-      (let ((buf (session-open-buffer s url))) ; open + focus + load the buffer
-        (session-update-status! s)
-        (browser-window-show win)
-        (let ((ms (and (getenv "JWB_BROWSE_MS")
-                       (string->number (getenv "JWB_BROWSE_MS")))))
-          (if ms
-              (begin
-                (browser-pump ms)
-                (let ((tr (browser-eval (buffer-view buf) "document.title")))
-                  (when (browser-ok? tr) (display (browser-value tr)) (newline)))
-                (close-browser-session! s) (exit 0))
-              (begin
-                ;; Keyboard-driven: build the controller and run the keymap loop
-                ;; (installs the app-wide key hook, pumps Qt events, drains the
-                ;; key queue → emacs keymap/command dispatch) until the window is
-                ;; closed or C-x C-c quits.
-                (let ((app (make-browser-app s)))
-                  (app-run-loop! app))
-                (close-browser-session! s) (exit 0))))))))
+  (let ((vault #f) (s #f))
+    (define (cleanup!)
+      (when s
+        (guard (e (#t #f)) (close-browser-session! s))
+        (set! s #f))
+      (when vault
+        (browser-vault-unmount! vault)
+        (set! vault #f)))
+    (guard (e (#t (cleanup!) (browse-fail "could not launch the browser" e)))
+      (set! vault (browser-vault-mount-if-enabled!))
+      (set! s (open-browser-session
+                (if vault
+                    (browser-capabilities 'network 'persistent-storage)
+                    (browser-capabilities 'network))))
+      (let ((win (session-window s)))
+        (browser-window-set-title win "Jerboa Browser")
+        (let ((buf (session-open-buffer s url))) ; open + focus + load the buffer
+          (session-update-status! s)
+          (browser-window-show win)
+          (let ((ms (and (getenv "JWB_BROWSE_MS")
+                         (string->number (getenv "JWB_BROWSE_MS")))))
+            (if ms
+                (begin
+                  (browser-pump ms)
+                  (let ((tr (browser-eval (buffer-view buf) "document.title")))
+                    (when (browser-ok? tr) (display (browser-value tr)) (newline)))
+                  (cleanup!) (exit 0))
+                (begin
+                  ;; Keyboard-driven: build the controller and run the keymap loop
+                  ;; (installs the app-wide key hook, pumps Qt events, drains the
+                  ;; key queue → emacs keymap/command dispatch) until the window is
+                  ;; closed or C-x C-c quits.
+                  (let ((app (make-browser-app s)))
+                    (app-run-loop! app))
+                  (cleanup!) (exit 0)))))))))
 
 (define (usage port code)
   (display "usage: jerboa-browser [<url> | browse [<url>] | repl | test | run <file>]\n" port)
diff --git a/scheme/browser/vault.ss b/scheme/browser/vault.ss
new file mode 100644
index 0000000..859d836
--- /dev/null
+++ b/scheme/browser/vault.ss
@@ -0,0 +1,255 @@
+#!chezscheme
+;;; (browser vault) — browser-owned encrypted storage for Qt WebEngine.
+;;;
+;;; The mount is created by the browser process itself, so jerboa-fuse's access
+;;; controller trusts only the browser PID and descendants such as
+;;; QtWebEngineProcess. A jsh `,vault mount` can still exist at a different
+;;; mountpoint; it is intentionally not reused here.
+
+(library (browser vault)
+  (export
+    browser-vault-enabled?
+    browser-vault-mount-if-enabled!
+    browser-vault-unmount!
+    browser-vault-active?
+    browser-vault-mountpoint)
+
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?)
+          (browser build-vault-secret)