From 818965a11fa38a93083e4f0d2ef7b232b2f7c715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Sat, 31 Oct 2026 22:27:58 +0800 Subject: [PATCH] feat(nixpak): unify desktop app sandboxes (#491) * refactor(nixpak): unify desktop app sandboxes Move common GUI, portal, and network policy into reusable modules and migrate browsers plus Zoom/WeChat/QQ/Telegram to the same boundary. Assisted-by: DeepSeek Harness (deepseek-flash) --- SECURITY.md | 26 +- WORKAROUNDS.md | 11 +- hardening/README.md | 158 +++---- hardening/nixpaks/brave-origin.nix | 16 + hardening/nixpaks/chromium.nix | 19 + hardening/nixpaks/default.nix | 6 + hardening/nixpaks/firefox.nix | 42 +- hardening/nixpaks/google-chrome.nix | 17 + hardening/nixpaks/microsoft-edge.nix | 17 + .../nixpaks/modules/browser-permissions.nix | 64 +++ .../nixpaks/modules/chromium-browser.nix | 121 +++++ hardening/nixpaks/modules/custom-policy.nix | 228 ---------- hardening/nixpaks/modules/desktop-base.nix | 161 +++++++ .../nixpaks/modules/desktop-integration.nix | 67 +++ hardening/nixpaks/modules/gui-base.nix | 173 -------- hardening/nixpaks/modules/portal-groups.nix | 62 +++ hardening/nixpaks/modules/wechat-policy.nix | 54 +++ hardening/nixpaks/modules/zoom-policy.nix | 42 ++ hardening/nixpaks/qq.nix | 19 +- hardening/nixpaks/telegram-desktop.nix | 19 +- hardening/nixpaks/wechat.nix | 72 +++ hardening/nixpaks/zoom.nix | 70 +++ hardening/tests/browser-runtime.md | 126 ++++++ hardening/tests/policy.nix | 221 ++++++++++ hardening/tests/unified-app-runtime.md | 35 ++ hardening/tests/verify-chromium.py | 416 ++++++++++++++++++ hardening/tests/wechat-runtime.md | 58 +++ hardening/tests/zoom-runtime.md | 54 +++ home/linux/gui/base/browsers.nix | 24 +- home/linux/gui/base/media.nix | 2 +- home/linux/gui/base/misc.nix | 2 +- home/linux/gui/base/xdg/autostart.nix | 2 +- home/linux/gui/base/xdg/mime.nix | 7 +- .../tests/nixpak-policy/expected.nix | 1 + .../x86_64-linux/tests/nixpak-policy/expr.nix | 11 + 35 files changed, 1858 insertions(+), 565 deletions(-) create mode 100644 hardening/nixpaks/brave-origin.nix create mode 100644 hardening/nixpaks/chromium.nix create mode 100644 hardening/nixpaks/google-chrome.nix create mode 100644 hardening/nixpaks/microsoft-edge.nix create mode 100644 hardening/nixpaks/modules/browser-permissions.nix create mode 100644 hardening/nixpaks/modules/chromium-browser.nix delete mode 100644 hardening/nixpaks/modules/custom-policy.nix create mode 100644 hardening/nixpaks/modules/desktop-base.nix create mode 100644 hardening/nixpaks/modules/desktop-integration.nix delete mode 100644 hardening/nixpaks/modules/gui-base.nix create mode 100644 hardening/nixpaks/modules/portal-groups.nix create mode 100644 hardening/nixpaks/modules/wechat-policy.nix create mode 100644 hardening/nixpaks/modules/zoom-policy.nix create mode 100644 hardening/nixpaks/wechat.nix create mode 100644 hardening/nixpaks/zoom.nix create mode 100644 hardening/tests/browser-runtime.md create mode 100644 hardening/tests/policy.nix create mode 100644 hardening/tests/unified-app-runtime.md create mode 100755 hardening/tests/verify-chromium.py create mode 100644 hardening/tests/wechat-runtime.md create mode 100644 hardening/tests/zoom-runtime.md create mode 100644 outputs/x86_64-linux/tests/nixpak-policy/expected.nix create mode 100644 outputs/x86_64-linux/tests/nixpak-policy/expr.nix diff --git a/SECURITY.md b/SECURITY.md index e9597e65..19fb4ebc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -323,13 +323,25 @@ isolation. Source review and socket metadata—not reads of private credentials - `ssh -G` reports forwarding for explicit `192.168.*` destinations, but not the sampled `shoryu` alias or GitHub destination. Do not treat forwarding as enabled for every LAN alias, or remove it without checking deployment/build workflows. -- Nixpak apps retain outbound networking (through pasta, without the host's loopback), - GUI/audio/portal/GPU access, selected writable document directories, and a read-only view of the - whole `/nix/store`. Firefox additionally retains browserpass/GnuPG integration. These are - filesystem sandboxes, not blanket credential or management-network isolation. -- WeChat retains networking and X11 through its FHS wrapper. Source arguments alone do not prove - complete runtime isolation. App launch, file chooser, IME, audio/video, GPU and disposable-agent - probes are still needed before narrowing mounts or sockets. +- All nine desktop wrappers use shared nixpak policies: cleared environment, filtered session D-Bus + and selected mounts. The [permission inventory](hardening/README.md#app-permissions) lists each + app's file, network, credential and media grants. +- Only Firefox/Chromium share host networking. Other apps isolate loopback with pasta but can still + reach the LAN/Internet. Browser media and browserpass are enabled only for + Firefox/Chromium/Chrome; browserpass exposes password-store/GPG data and agent operations. +- Nixpak supplements Chromium's renderer sandbox, which remains enabled. Zoom/WeChat retain their + FHS runtimes inside the outer boundary; inner mounts cannot recover hidden host resources. +- Desktop access has limits: GPU drivers remain exposed, PulseAudio permits recording, read-only + sockets permit agent operations, and Zoom/WeChat's X11 connections have shared-display trust. + Zoom's 7.2.1 client receives only `/dev/video0` and `/dev/video1` because it captures the webcam + directly via V4L2; no other app gets raw camera nodes and no raw PipeWire access is added. + Chromium-family browsers use only the app-scoped Secret portal for v12 cookie encryption; no + browser receives broad GNOME Keyring authority. Firefox uses its own NSS storage. +- **Rollout note (WA-045):** v12 is verified on a disposable profile, but if the portal is + unavailable Chromium may fall back to weaker v10 storage; portal initialization is monitored. +- Runtime evidence is partial: Brave/Edge headless export times out; interactive login, media, IME + and file workflows require acceptance. See the + [validation records](hardening/README.md#validation). #### Agent-first diagnostics by host role diff --git a/WORKAROUNDS.md b/WORKAROUNDS.md index 45aa12b8..de4fcd60 100644 --- a/WORKAROUNDS.md +++ b/WORKAROUNDS.md @@ -82,11 +82,12 @@ A known limitation with no fix yet. An unfinished feature or gap. -| ID | What & where | Why | Removal condition | Added | Revisit | Status | -| ------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ---------- | ------------------------ | ------ | -| WA-013 | NixOS tests — `outputs/README.md` | full-host tests still rebuild custom packages (no private cache) and need the host ssh key to decrypt agenix; the shared-firewall security-exporters VM test now runs in CI (KVM); `outputs/x86_64-linux/nixos-tests/idols-ruby.nix` is exposed as `packages.x86_64-linux.ruby-nixos-tests` but is not in `checks` or CI | Resolve when a private cache exists and tests can run per-service | 2024-03-13 | quarterly | active | -| WA-027 | NFS golden store `no_root_squash` — `hosts/12kingdoms-youko/nfs-golden.nix` | the existing PVC subdirectories are root-owned, so enabling `root_squash` would break running workloads until they are migrated; the CSI node plugin is hostNetwork and sources from the allowlisted node IPs | Drop when the PVC subdirectories are migrated and `root_squash` is enabled | 2026-10-07 | next maintenance window | active | -| WA-044 | transmission AppArmor lock permission — `hosts/12kingdoms-youko/homelab-services/transmission.nix` | nixpkgs' transmission 4.1.4 profile grants `rw` on the daemon's config dir but not `k`, while 4.1.4 takes an fcntl lock on `~/.config/transmission-daemon/lock`; the daemon exits with "Another process is already using". A `local/bin.transmission-daemon` include adds `rwk` for that lock file only | Drop once the nixpkgs profile includes the lock permission | 2026-10-31 | on the next nixpkgs bump | active | +| ID | What & where | Why | Removal condition | Added | Revisit | Status | +| ------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------ | ---------- | +| WA-013 | NixOS tests — `outputs/README.md` | full-host tests still rebuild custom packages (no private cache) and need the host ssh key to decrypt agenix; the shared-firewall security-exporters VM test now runs in CI (KVM); `outputs/x86_64-linux/nixos-tests/idols-ruby.nix` is exposed as `packages.x86_64-linux.ruby-nixos-tests` but is not in `checks` or CI | Resolve when a private cache exists and tests can run per-service | 2024-03-13 | quarterly | active | +| WA-027 | NFS golden store `no_root_squash` — `hosts/12kingdoms-youko/nfs-golden.nix` | the existing PVC subdirectories are root-owned, so enabling `root_squash` would break running workloads until they are migrated; the CSI node plugin is hostNetwork and sources from the allowlisted node IPs | Drop when the PVC subdirectories are migrated and `root_squash` is enabled | 2026-10-07 | next maintenance window | active | +| WA-044 | transmission AppArmor lock permission — `hosts/12kingdoms-youko/homelab-services/transmission.nix` | nixpkgs' transmission 4.1.4 profile grants `rw` on the daemon's config dir but not `k`, while 4.1.4 takes an fcntl lock on `~/.config/transmission-daemon/lock`; the daemon exits with "Another process is already using". A `local/bin.transmission-daemon` include adds `rwk` for that lock file only | Drop once the nixpkgs profile includes the lock permission | 2026-10-31 | on the next nixpkgs bump | active | +| WA-045 | Chromium cookie crypto — `hardening/nixpaks/modules/chromium-browser.nix` | Chromium-family browsers use the app-scoped Secret portal and write v12 cookies. If the portal is unavailable, Chromium may fall back to weak v10 storage. Brave/Edge headless export also times out. | Drop once the v10 fallback is eliminated and the Brave/Edge headless checks pass; monitor portal initialization and interactive browser checks | 2026-10-31 | on Chromium updates | active gap | ## Related diff --git a/hardening/README.md b/hardening/README.md index cedff97f..6c5d6cba 100644 --- a/hardening/README.md +++ b/hardening/README.md @@ -1,111 +1,79 @@ # Linux Hardening -> Work in progress. +Deployed and interactively validated on `ai` (2026-10-31). Policy and limits: +[SECURITY.md](../SECURITY.md). -Application sandboxing (nixpak/bubblewrap) and Linux hardening knowledge for this repository. For -the repository security policy, architecture and audit status, see [`SECURITY.md`](../SECURITY.md); -for temporary exceptions and known gaps, see [`WORKAROUNDS.md`](../WORKAROUNDS.md). +## Shared boundaries -## Goal +- Cleared environment; filtered session D-Bus; private `/dev/shm`; per-app `/tmp`. Chromium-family + browsers share an app-specific `/tmp` so external URLs reuse one browser instance. +- Read-only `/nix/store`; GPU and display; fonts and themes; LAN and Internet. +- Portals for file chooser, URI opening, notifications, input methods and document exports. +- No whole host home, system D-Bus, SSH agent, USB/serial, raw camera or raw PipeWire socket. +- Host loopback only for Firefox/Chromium; all other apps use pasta-isolated loopback. -- **System Level**: Protect critical files from being accessed by untrusted applications. - 1. Such as browser cookies, SSH keys, etc. -- **Per-App Level**: Prevent untrusted applications(such as closed-source apps) from: - 1. Accessing files they shouldn't. - - Such as a malicious application accessing your browser's cookies, SSH keys, etc. - 1. Accessing the network when they don't need to. - 1. Accessing hardware devices they don't need. +## App permissions -## Current Structure +`Media` means audio playback, microphone, Camera and ScreenCast portals. Writable paths are listed +explicitly; other files use the document portal. Every app also has private writable state. -### 1. **System Level** +| App | Network | Writable paths | Grants | +| ------------ | ------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Media, browserpass, MPRIS, idle | +| Chromium | Host loopback | `~/.config/chromium`, personal files | Media, browserpass, MPRIS, idle, v12 Secret | +| Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, v12 Secret | +| Brave Origin | Isolated | `~/.config/BraveSoftware/Brave-Origin`, Downloads | v12 Secret | +| Edge | Isolated | `~/.config/microsoft-edge`, Downloads | v12 Secret | +| QQ | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility | +| Telegram | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility, Ayatana indicators | +| Zoom | Isolated | `~/.zoom`, `zoomus.conf`, `zoom.conf`, Downloads, `Documents/Zoom`, private app state | Media, X11/XWayland, FHS, SNI tray, idle, `/dev/video0-1`, read-only `/sys/class/drm` | +| WeChat | Isolated | `Documents/WeChat_Data`, its HOME mapping and `xwechat_files` link, private app state | Media, X11/xcb, FHS, SNI tray | -- **AppArmor** (`apparmor/`): AppArmor profiles and configuration -- **Kernel & process hardening** (`kernel-hardening.nix`): sysctl baseline and unused-module deny - list; rationale and verification live in [`SECURITY.md`](../SECURITY.md). +All browsers get read-only NSS certificates, printing, native notifications and accessibility. -### 2. **Per-App Level** +## Browser secrets -- **Nixpak** (`nixpaks/`): Bubblewrap-based sandboxing for applications - - Firefox configuration - - QQ (Chinese messaging app) configuration - - Telegram Desktop configuration - - Modular system with reusable components -- **Bubblewrap** (`bwraps/`): Direct bubblewrap configurations - - WeChat sandboxing configuration +- Chromium family: app-scoped Secret portal only. `SecretPortalKeyProviderUseForEncryption` writes + v12 cookies; no `org.freedesktop.secrets` or broad keyring access. +- If the Secret portal is unavailable, Chromium can fall back to v10. Monitor + `Local State.os_crypt.portal.prev_init_success`. +- Firefox uses NSS: `key4.db` and `logins.json` for logins, `cookies.sqlite` for cookies. It has no + Secret portal or keyring grant. A Primary Password protects saved logins, not cookies. +- browserpass is separate: it exposes the password store and GPG-agent socket, not cookie + encryption. +- Bookmarks, history, localStorage, IndexedDB and extension data remain readable by the browser. + nixpak isolates them from other apps, not from browser compromise. -## Current Implementation Status +## Limits -| Component | Status | Notes | -| ----------------- | --------- | ----------------------------- | -| AppArmor Profiles | 🚧 WIP | Basic structure in place | -| Nixpak Firefox | ✅ Active | Firefox sandboxing via nixpak | -| Nixpak QQ | ✅ Active | QQ application sandboxing | -| Nixpak Telegram | ✅ Active | Telegram Desktop sandboxing | -| Bubblewrap WeChat | ✅ Active | WeChat specific sandboxing | +- X11: Zoom and WeChat have shared-display trust; portal permissions cannot enforce portal-only + capture. +- PulseAudio grants recording authority. Read-only GPG sockets still permit signing and decryption. +- pasta isolates loopback, not LAN access. Chromecast discovery is unverified. +- Brave/Edge renderer and permission checks pass, but headless page export still times out. -## Directory Structure +## Validation -``` -hardening/ -├── README.md -├── kernel-hardening.nix # sysctl baseline + unused-module deny list -├── apparmor/ # AppArmor security profiles -│ └── default.nix -├── bwraps/ # Direct bubblewrap configurations -│ ├── default.nix -│ └── wechat.nix -└── nixpaks/ # Nixpak application sandboxing - ├── default.nix - ├── firefox.nix - ├── qq.nix - ├── telegram-desktop.nix - └── modules/ # Reusable nixpak modules - ├── custom-policy.nix - ├── gui-base.nix - └── network.nix -``` +- Passed: `just fmt`, `just test`, `ai-niri` build and `shoukei-niri` eval. +- Deployed on `ai`; interactive startup and tray checks passed for Chrome, Firefox, QQ, Telegram, + Zoom and WeChat. +- Chromium/Chrome: renderer sandbox, mounts, rendering and disposable-profile persistence passed. + Chromium reaches host loopback; Chrome cannot. +- Firefox: disposable headless screenshot passed. +- Brave/Edge: renderer and permission checks passed; headless page export timed out. +- Evidence: [browsers](tests/browser-runtime.md), + [Firefox/QQ/Telegram](tests/unified-app-runtime.md), [Zoom](tests/zoom-runtime.md), + [WeChat](tests/wechat-runtime.md). -## System Hardening +## Code map -- Apparmor: [roddhjav/apparmor.d](https://github.com/roddhjav/apparmor.d) - - https://gitlab.com/apparmor/apparmor/-/wikis/Documentation - - AppArmor.d is a set of over 1500 AppArmor profiles whose aim is to confine most Linux based - applications and processes. - - But its profiles assume an FHS filesystem, so most of them never match the Nix store paths a - NixOS system actually executes. - - Apparmor on NixOS Roadmap: - - https://discourse.nixos.org/t/apparmor-on-nixos-roadmap/57217 - - https://github.com/LordGrimmauld/aa-alias-manager -- SELinux: too complex, not recommended for personal use. - -## Application Sandboxing - -- [Bubblewrap](https://github.com/containers/bubblewrap): - [nixpak](https://github.com/nixpak/nixpak), more secure than firejail, but no batteries included. - - NixOS's FHSEnv is implemented using bubblewrap by default. -- [Firejail](https://github.com/netblue30/firejail/tree/master/etc): A SUID security sandbox with - hundreds of security profiles for many common applications in the default installation. - - https://wiki.nixos.org/wiki/Firejail - - Firejail needs SUID to work, which is considered a security risk - - [Does firejail improve the security of my system?](https://github.com/netblue30/firejail/discussions/4601) -- [Systemd/Hardening](https://wiki.nixos.org/wiki/Systemd/Hardening): Systemd also provides some - sandboxing features. - -## NOTE - -**Running untrusted code is never safe, kernel hardening & sandboxing cannot change this**; run -untrusted code in a VM on an isolated network. The threat model lives in -[`SECURITY.md`](../SECURITY.md). - -## References - -- [Harden your NixOS workstation - dataswamp](https://dataswamp.org/~solene/2022-01-13-nixos-hardened.html) -- [Linux Insecurities - Madaidans](https://madaidans-insecurities.github.io/linux.html) -- [Sandboxing all programs by default - NixOS Discourse](https://discourse.nixos.org/t/sandboxing-all-programs-by-default/7792) -- [Paranoid NixOS Setup - xeiaso](https://xeiaso.net/blog/paranoid-nixos-2021-07-18/) -- [nix-mineral](https://github.com/cynicsketch/nix-mineral): NixOS module for convenient system - hardening. -- apparmor configs: - - https://github.com/zramctl/dotfiles/blob/4fe177f6984154960942bb47d5a375098ec6ed6a/modules/nixos/security/apparmor.nix#L4 - - https://git.grimmauld.de/Grimmauld/grimm-nixos-laptop/src/branch/main/hardening +- [nixpaks/](nixpaks/): wrappers and Home Manager package overlay. +- [desktop-base.nix](nixpaks/modules/desktop-base.nix): shared runtime and XDG storage. +- [portal-groups.nix](nixpaks/modules/portal-groups.nix), + [desktop-integration.nix](nixpaks/modules/desktop-integration.nix): portal and desktop service + grants. +- [browser-permissions.nix](nixpaks/modules/browser-permissions.nix), + [chromium-browser.nix](nixpaks/modules/chromium-browser.nix): browser scopes and launcher. +- [bwraps/wechat.nix](bwraps/wechat.nix): WeChat FHS and data setup. +- [kernel-hardening.nix](kernel-hardening.nix), [apparmor/](apparmor/): kernel sysctl and AppArmor + profiles. diff --git a/hardening/nixpaks/brave-origin.nix b/hardening/nixpaks/brave-origin.nix new file mode 100644 index 00000000..a675a4c6 --- /dev/null +++ b/hardening/nixpaks/brave-origin.nix @@ -0,0 +1,16 @@ +{ + callPackage, + mkNixPak, + brave-origin, + ... +}: +(callPackage ./modules/chromium-browser.nix { inherit mkNixPak; }) { + package = brave-origin; + appId = "com.brave.Origin"; + profile = "BraveSoftware/Brave-Origin"; + command = "brave-origin"; + desktopName = "Brave Origin"; + desktopFile = "brave-origin"; + browserpass = false; + secretPortal = true; +} diff --git a/hardening/nixpaks/chromium.nix b/hardening/nixpaks/chromium.nix new file mode 100644 index 00000000..d9641149 --- /dev/null +++ b/hardening/nixpaks/chromium.nix @@ -0,0 +1,19 @@ +{ + callPackage, + mkNixPak, + chromium, + ... +}: +(callPackage ./modules/chromium-browser.nix { inherit mkNixPak; }) { + package = chromium; + appId = "org.chromium.Chromium"; + profile = "chromium"; + command = "chromium"; + desktopName = "Chromium"; + desktopFile = "chromium-browser"; + browserpass = true; + media = true; + hostLoopback = true; + personalFiles = true; + secretPortal = true; +} diff --git a/hardening/nixpaks/default.nix b/hardening/nixpaks/default.nix index 4dd56707..ffa57a0c 100644 --- a/hardening/nixpaks/default.nix +++ b/hardening/nixpaks/default.nix @@ -21,6 +21,12 @@ in qq = wrapper pkgs-master ./qq.nix; telegram-desktop = wrapper super ./telegram-desktop.nix; firefox = wrapper super ./firefox.nix; + google-chrome = wrapper super ./google-chrome.nix; + chromium = wrapper super ./chromium.nix; + brave-origin = wrapper super ./brave-origin.nix; + microsoft-edge = wrapper super ./microsoft-edge.nix; + zoom = wrapper super ./zoom.nix; + wechat = wrapper super ./wechat.nix; }; }) ]; diff --git a/hardening/nixpaks/firefox.nix b/hardening/nixpaks/firefox.nix index 5f733b6e..8c8261f9 100644 --- a/hardening/nixpaks/firefox.nix +++ b/hardening/nixpaks/firefox.nix @@ -21,9 +21,6 @@ let sloth, ... }: - let - passwordStore = sloth.concat' sloth.homeDir "/.local/share/password-store"; - in { app = { package = firefox; @@ -32,14 +29,19 @@ let flatpak.appId = appId; imports = [ - ./modules/gui-base.nix + ./modules/browser-permissions.nix ./modules/network.nix - ./modules/custom-policy.nix ]; + browserPermissions = { + media = true; + personalFiles = true; + browserpass = true; + }; + # Firefox keeps the host network: local development servers and the # mihomo controller live on 127.0.0.1, and pasta's isolated loopback - # would hide them. The other sandboxed apps do not need host loopback. + # would hide them. Chromium uses the same personal-browser policy. pasta.enable = lib.mkForce false; bubblewrap = { @@ -55,16 +57,6 @@ let # path over MPRIS. Without this bind it lands under the sandboxed # XDG_DATA_HOME and Noctalia (outside) cannot read it. (sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/firefox-mpris")) - - # Files deleted from Firefox go to the real trash instead of a - # private one under the sandboxed XDG_DATA_HOME. - (sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/Trash")) - - sloth.xdgDocumentsDir - sloth.xdgDownloadDir - sloth.xdgMusicDir - sloth.xdgVideosDir - sloth.xdgPicturesDir ]; bind.ro = [ "/sys/bus/pci" @@ -72,25 +64,7 @@ let "${config.app.package}/lib/firefox" "/app/etc/firefox" ] - - # ================ for browserpass extension =============================== - "/etc/gnupg" - (sloth.concat' sloth.homeDir "/.gnupg") # gpg's config - passwordStore # my secrets - (sloth.concat' sloth.runtimeDir "/gnupg") # for access gpg-agent socket - - # Match the host's default-app associations and XDG user dirs. - (sloth.concat' sloth.homeDir "/.config/mimeapps.list") - (sloth.concat' sloth.homeDir "/.config/user-dirs.dirs") - - # Shared NSS client-certificate store (imported outside Firefox). - (sloth.concat' sloth.homeDir "/.pki/nssdb") - - # dconf settings. Not verified as needed; candidate for removal. - (sloth.concat' sloth.xdgConfigHome "/dconf") ]; - # browserpass looks for the store here, not at pass's default path. - env.PASSWORD_STORE_DIR = passwordStore; }; }; }; diff --git a/hardening/nixpaks/google-chrome.nix b/hardening/nixpaks/google-chrome.nix new file mode 100644 index 00000000..e4917ef4 --- /dev/null +++ b/hardening/nixpaks/google-chrome.nix @@ -0,0 +1,17 @@ +{ + callPackage, + mkNixPak, + google-chrome, + ... +}: +(callPackage ./modules/chromium-browser.nix { inherit mkNixPak; }) { + package = google-chrome; + appId = "com.google.Chrome"; + profile = "google-chrome"; + command = "google-chrome"; + desktopName = "Google Chrome"; + desktopFile = "google-chrome"; + browserpass = true; + media = true; + secretPortal = true; +} diff --git a/hardening/nixpaks/microsoft-edge.nix b/hardening/nixpaks/microsoft-edge.nix new file mode 100644 index 00000000..dee5a9a4 --- /dev/null +++ b/hardening/nixpaks/microsoft-edge.nix @@ -0,0 +1,17 @@ +{ + callPackage, + mkNixPak, + microsoft-edge, + ... +}: +(callPackage ./modules/chromium-browser.nix { inherit mkNixPak; }) { + package = microsoft-edge; + appId = "com.microsoft.Edge"; + profile = "microsoft-edge"; + command = "microsoft-edge"; + desktopName = "Microsoft Edge"; + desktopFile = "microsoft-edge"; + browserpass = false; + secretPortal = true; + privateWindowFlag = "--inprivate"; +} diff --git a/hardening/nixpaks/modules/browser-permissions.nix b/hardening/nixpaks/modules/browser-permissions.nix new file mode 100644 index 00000000..4c26004f --- /dev/null +++ b/hardening/nixpaks/modules/browser-permissions.nix @@ -0,0 +1,64 @@ +# Shared browser capabilities. Network topology and application profiles are +# selected by the app; personal files, browserpass, Secret portal and media are explicit opt-ins. +{ + config, + lib, + sloth, + ... +}: +let + cfg = config.browserPermissions; + passwordStore = sloth.concat' sloth.homeDir "/.local/share/password-store"; +in +{ + imports = [ ./desktop-base.nix ]; + options.browserPermissions = { + media = lib.mkEnableOption "audio playback/recording, camera and desktop screen sharing"; + personalFiles = lib.mkEnableOption "personal Documents, media directories and Trash"; + browserpass = lib.mkEnableOption "browserpass access to the password store and GPG agent"; + secretPortal = lib.mkEnableOption "the app-scoped Secret portal for v12 cookie encryption"; + }; + config = { + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + screenCast = cfg.media; + camera = cfg.media; + print = true; + inhibit = cfg.media; + inputMethod = true; + secret = cfg.secretPortal; + }; + desktopIntegration = { + notifications = true; + mpris = cfg.media; + accessibility = true; + }; + bubblewrap = { + sockets.pulse = cfg.media; + bind.rw = [ + sloth.xdgDownloadDir + ] + ++ lib.optionals cfg.personalFiles [ + sloth.xdgDocumentsDir + sloth.xdgMusicDir + sloth.xdgVideosDir + sloth.xdgPicturesDir + (sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/Trash")) + ]; + bind.ro = [ + (sloth.concat' sloth.homeDir "/.config/mimeapps.list") + (sloth.concat' sloth.homeDir "/.config/user-dirs.dirs") + (sloth.concat' sloth.homeDir "/.pki/nssdb") + ] + ++ lib.optionals cfg.browserpass [ + "/etc/gnupg" + (sloth.concat' sloth.homeDir "/.gnupg") + passwordStore + (sloth.concat' sloth.runtimeDir "/gnupg") + ]; + env = lib.optionalAttrs cfg.browserpass { PASSWORD_STORE_DIR = passwordStore; }; + }; + }; +} diff --git a/hardening/nixpaks/modules/chromium-browser.nix b/hardening/nixpaks/modules/chromium-browser.nix new file mode 100644 index 00000000..b5a7fe42 --- /dev/null +++ b/hardening/nixpaks/modules/chromium-browser.nix @@ -0,0 +1,121 @@ +# Chromium-family wrapper: nixpak adds filesystem, D-Bus and network boundaries; +# the upstream Chromium renderer sandbox remains enabled. +{ + lib, + mkNixPak, + buildEnv, + makeDesktopItem, + runCommandLocal, + makeWrapper, + ... +}: +{ + package, + appId, + profile, + command, + desktopName, + desktopFile ? command, + browserpass ? false, + media ? false, + secretPortal ? false, + hostLoopback ? false, + personalFiles ? false, + privateWindowFlag ? "--incognito", +}: +let + # Chromium 156 disables PipeWire camera capture by default; media apps need + # the portal FD path instead of raw video devices. + # Keep the Wayland feature from nixpkgs: the last --enable-features wins. + enabledFeatures = lib.concatStringsSep "," ( + [ "WaylandWindowDecorations" ] + ++ lib.optional media "WebRtcPipeWireCamera" + ++ lib.optional secretPortal "SecretPortalKeyProviderUseForEncryption" + ); + usernsPackage = + runCommandLocal "${package.name}-userns" + { + nativeBuildInputs = [ makeWrapper ]; + } + '' + mkdir -p $out/bin + makeWrapper ${package}/bin/${command} $out/bin/${command} \ + --add-flags "--enable-features=${enabledFeatures}" + ''; + wrapped = mkNixPak { + config = + { sloth, ... }: + { + imports = [ + ./browser-permissions.nix + ./network.nix + ]; + app = { + package = usernsPackage; + binPath = "bin/${command}"; + }; + flatpak = { inherit appId; }; + # Firefox/Chromium need host loopback for local services; Chrome and the rest use pasta. + pasta.enable = lib.mkForce (!hostLoopback); + pasta.mode = "isolate"; + browserPermissions = { + inherit + browserpass + personalFiles + media + secretPortal + ; + }; + bubblewrap.bind.rw = [ + (sloth.mkdir (sloth.concat' sloth.homeDir "/.config/${profile}")) + ]; + }; + }; + exe = "${wrapped.config.script}/bin/${command}"; + # Copy desktop assets only; the full package would expose unwrapped binaries. + assets = runCommandLocal "${command}-desktop-assets" { } '' + mkdir -p $out/share + for directory in icons pixmaps; do + if [ -d ${package}/share/$directory ]; then + ln -s ${package}/share/$directory $out/share/$directory + fi + done + ''; +in +buildEnv { + inherit (wrapped.config.script) name meta passthru; + paths = [ + wrapped.config.script + assets + (makeDesktopItem { + name = appId; + inherit desktopName; + genericName = "Web Browser"; + exec = "${exe} %U"; + icon = desktopFile; + terminal = false; + startupNotify = true; + categories = [ + "Network" + "WebBrowser" + ]; + mimeTypes = [ + "text/html" + "application/xhtml+xml" + "x-scheme-handler/http" + "x-scheme-handler/https" + ]; + actions = { + new-window = { + name = "New Window"; + exec = "${exe} --new-window %U"; + }; + new-private-window = { + name = "New Private Window"; + exec = "${exe} ${privateWindowFlag} %U"; + }; + }; + extraConfig.X-Flatpak = appId; + }) + ]; +} diff --git a/hardening/nixpaks/modules/custom-policy.nix b/hardening/nixpaks/modules/custom-policy.nix deleted file mode 100644 index 809ce423..00000000 --- a/hardening/nixpaks/modules/custom-policy.nix +++ /dev/null @@ -1,228 +0,0 @@ -# Custom, repo-local policy for the sandboxed desktop apps (Firefox, QQ, -# Telegram). Unlike ./gui-base.nix, which forks nixpak/pkgs's gui-base and is -# meant to track upstream, this file is hand-written for this repository: it -# grants the D-Bus surface those apps need (portals, MPRIS, tray, input -# methods, a11y), redirects the XDG dirs Flatpak-style, and adds the remaining -# document/device mounts. Keep only app-specific mounts in the app files. -# -# Adapted from: -# - https://github.com/mnixry/nixos-config/blob/74913c2b90d06e31170bbbaa0074f915721da224/desktop/packages/nixpaks-common.nix -# - https://github.com/Kraftland/portable/blob/09c4a4227538a3f42de208a6ecbdc938ac9c00dd/portable.sh -# - https://flatpak.github.io/xdg-desktop-portal/docs/api-reference.html -{ - lib, - sloth, - config, - ... -}: -let - inherit (config.flatpak) appId; -in -{ - config = { - # list all dbus services: - # ls -al /run/current-system/sw/share/dbus-1/services/ - # ls -al /etc/profiles/per-user/ryan/share/dbus-1/services/ - dbus = { - # `--see`: The bus name can be enumerated by the application. - # `--talk`: The application can send messages to, and receive replies and signals from, the bus name. - # `--own`: The application can own the bus name - policies = { - "${appId}" = "own"; - "${appId}.*" = "own"; - "org.freedesktop.DBus" = "talk"; - "ca.desrt.dconf" = "talk"; - "org.freedesktop.appearance" = "talk"; - "org.freedesktop.appearance.*" = "talk"; - } - // (builtins.listToAttrs ( - map (id: lib.nameValuePair "org.kde.StatusNotifierItem-${toString id}-1" "own") ( - lib.lists.range 2 29 - ) - )) - // { - # --- MPRIS Media Control --- - # Allows the app to register as a media player. These are derived from the appID. - "org.mpris.MediaPlayer2.${appId}" = "own"; - "org.mpris.MediaPlayer2.${appId}.*" = "own"; - "org.mpris.MediaPlayer2.${lib.lists.last (lib.strings.splitString "." appId)}" = "own"; - "org.mpris.MediaPlayer2.${lib.lists.last (lib.strings.splitString "." appId)}.*" = "own"; - - # --- General Desktop Integration --- - "com.canonical.AppMenu.Registrar" = "talk"; # For Ubuntu AppMenu - "org.freedesktop.FileManager1" = "talk"; - "org.freedesktop.Notifications" = "talk"; - "org.kde.StatusNotifierWatcher" = "talk"; - "org.gnome.Shell.Screencast" = "talk"; - - # --- Accessibility (a11y) 无障碍服务 --- - "org.a11y.Bus" = "see"; - - # --- Portal Access --- - # "org.freedesktop.portal.*" = "talk"; - "org.freedesktop.portal.Documents" = "talk"; - "org.freedesktop.portal.FileTransfer" = "talk"; - "org.freedesktop.portal.FileTransfer.*" = "talk"; - "org.freedesktop.portal.Notification" = "talk"; - "org.freedesktop.portal.OpenURI" = "talk"; - "org.freedesktop.portal.OpenURI.OpenFile" = "talk"; - "org.freedesktop.portal.OpenURI.OpenURI" = "talk"; - "org.freedesktop.portal.Print" = "talk"; - "org.freedesktop.portal.Request" = "see"; - - # --- Input Method Portals --- - "org.freedesktop.portal.Fcitx" = "talk"; - "org.freedesktop.portal.Fcitx.*" = "talk"; - "org.freedesktop.portal.IBus" = "talk"; - "org.freedesktop.portal.IBus.*" = "talk"; - }; - # '--call' rules permit specific method calls on D-Bus interfaces. - rules.call = { - # --- Accessibility (a11y) 无障碍服务 --- - "org.a11y.Bus" = [ - "org.a11y.Bus.GetAddress@/org/a11y/bus" - "org.freedesktop.DBus.Properties.Get@/org/a11y/bus" - ]; - - # --- General Portal Rules --- - "org.freedesktop.FileManager1" = [ "*" ]; - "org.freedesktop.Notifications.*" = [ "*" ]; - "org.freedesktop.portal.Documents" = [ "*" ]; - "org.freedesktop.portal.FileTransfer" = [ "*" ]; - "org.freedesktop.portal.FileTransfer.*" = [ "*" ]; - "org.freedesktop.portal.Fcitx" = [ "*" ]; - "org.freedesktop.portal.Fcitx.*" = [ "*" ]; - "org.freedesktop.portal.IBus" = [ "*" ]; - "org.freedesktop.portal.IBus.*" = [ "*" ]; - "org.freedesktop.portal.Notification" = [ "*" ]; - "org.freedesktop.portal.OpenURI" = [ "*" ]; - "org.freedesktop.portal.OpenURI.OpenFile" = [ "*" ]; - "org.freedesktop.portal.OpenURI.OpenURI" = [ "*" ]; - "org.freedesktop.portal.Print" = [ "*" ]; - "org.freedesktop.portal.Request" = [ "*" ]; - - # --- Main Desktop Portal Interface --- - # A comprehensive list of permissions for interacting with the desktop environment. - "org.freedesktop.portal.Desktop" = [ - # Properties & Settings - "org.freedesktop.DBus.Properties.GetAll" - "org.freedesktop.DBus.Properties.Get@/org/freedesktop/portal/desktop" - "org.freedesktop.portal.Session.Close" - "org.freedesktop.portal.Settings.ReadAll" - "org.freedesktop.portal.Settings.Read" - "org.freedesktop.portal.Account.GetUserInformation" - - # Network & Proxy - "org.freedesktop.portal.NetworkMonitor" - "org.freedesktop.portal.NetworkMonitor.*" - "org.freedesktop.portal.ProxyResolver.Lookup" - "org.freedesktop.portal.ProxyResolver.Lookup.*" - - # Screenshot / Screen Capture & Sharing - "org.freedesktop.portal.ScreenCast" - "org.freedesktop.portal.ScreenCast.*" - "org.freedesktop.portal.Screenshot" - "org.freedesktop.portal.Screenshot.Screenshot" - - # Device Access(Camera / USB) - "org.freedesktop.portal.Camera" - "org.freedesktop.portal.Camera.*" - "org.freedesktop.portal.Usb" - "org.freedesktop.portal.Usb.*" - - # Remote Desktop - "org.freedesktop.portal.RemoteDesktop" - "org.freedesktop.portal.RemoteDesktop.*" - - # File Operations - "org.freedesktop.portal.Documents" - "org.freedesktop.portal.Documents.*" - "org.freedesktop.portal.FileChooser" - "org.freedesktop.portal.FileChooser.*" - "org.freedesktop.portal.FileTransfer" - "org.freedesktop.portal.FileTransfer.*" - - # Notifications & Printing - "org.freedesktop.portal.Notification" - "org.freedesktop.portal.Notification.*" - "org.freedesktop.portal.Print" - "org.freedesktop.portal.Print.*" - - # Open/Launch Handlers - "org.freedesktop.portal.OpenURI" - "org.freedesktop.portal.OpenURI.*" - "org.freedesktop.portal.Email.ComposeEmail" - - # Input Methods - "org.freedesktop.portal.Fcitx" - "org.freedesktop.portal.Fcitx.*" - "org.freedesktop.portal.IBus" - "org.freedesktop.portal.IBus.*" - - # Secrets (Keyring) - "org.freedesktop.portal.Secret" - "org.freedesktop.portal.Secret.RetrieveSecret" - - # Get/Update GlobalShortcuts - # "org.freedesktop.portal.GlobalShortcuts" - # "org.freedesktop.portal.GlobalShortcuts.*" - - # -- get the user's location - # "org.freedesktop.portal.Location" - # "org.freedesktop.portal.Location.*" - - # -- inhibit the user session from ending, suspending, idling or getting switched away. - "org.freedesktop.portal.Inhibit" - "org.freedesktop.portal.Inhibit.*" - - # Generic Request Fallback - "org.freedesktop.portal.Request" - ]; - }; - - # 'broadcast' rules permit receiving signals from D-Bus names. - rules.broadcast = { - "org.freedesktop.portal.*" = [ "@/org/freedesktop/portal/*" ]; - }; - args = [ - "--filter" - "--sloppy-names" - ]; - }; - - etc.sslCertificates.enable = true; - bubblewrap = { - network = lib.mkDefault true; - - # Desktop-wide mounts (Wayland/at-spi/gvfsd, the app cache, fontconfig and - # Mesa caches, GTK config, /dev/shm) are provided by ./gui-base.nix, so - # each path is bound exactly once. This is what keeps a read-only bind - # from shadowing a writable one. Only app-specific mounts belong in this - # file. - bind.rw = with sloth; [ - [ - (mkdir appDataDir) - xdgDataHome - ] - [ - (mkdir appConfigDir) - xdgConfigHome - ] - - # FileChooser may return a Document Portal path even for a directly mapped XDG directory; - # keep the FUSE mount writable so SaveFile can create the selected file. - (sloth.concat' sloth.runtimeDir "/doc") - - (sloth.concat' sloth.runtimeDir "/dconf") - - (sloth.concat' sloth.xdgCacheHome "/mesa_shader_cache_db") - (sloth.concat' sloth.xdgCacheHome "/radv_builtin_shaders") - ]; - bind.ro = [ - (sloth.concat' sloth.xdgConfigHome "/kdeglobals") - (sloth.concat' sloth.xdgConfigHome "/dconf") - ]; - bind.dev = map (id: "/dev/video${toString id}") (lib.lists.range 0 9); - }; - }; -} diff --git a/hardening/nixpaks/modules/desktop-base.nix b/hardening/nixpaks/modules/desktop-base.nix new file mode 100644 index 00000000..1f152ede --- /dev/null +++ b/hardening/nixpaks/modules/desktop-base.nix @@ -0,0 +1,161 @@ +# Shared desktop runtime; portal and native services are opt-in. No host GVfs, +# shared caches, raw camera or PipeWire socket. +{ + config, + lib, + pkgs, + sloth, + ... +}: +{ + imports = [ + ./portal-groups.nix + ./desktop-integration.nix + ]; + gpu = { + enable = lib.mkDefault true; + provider = "nixos"; + bundlePackage = pkgs.mesa.drivers; + }; + fonts = { + enable = true; + # Preserve nixpak's default coverage and add Chinese/Japanese/Korean glyphs. + fonts = with pkgs; [ + cantarell-fonts + dejavu_fonts + liberation_ttf + gyre-fonts + source-sans + source-code-pro + noto-fonts-color-emoji + noto-fonts-cjk-sans + ]; + }; + locale.enable = true; + dbus = { + policies = { + "${config.flatpak.appId}" = "own"; + "${config.flatpak.appId}.*" = "own"; + }; + rules.call."org.freedesktop.portal.Desktop" = [ + "org.freedesktop.DBus.Properties.Get@/org/freedesktop/portal/desktop" + "org.freedesktop.DBus.Properties.GetAll@/org/freedesktop/portal/desktop" + "org.freedesktop.portal.Settings.Read@/org/freedesktop/portal/desktop" + "org.freedesktop.portal.Settings.ReadAll@/org/freedesktop/portal/desktop" + "org.freedesktop.portal.Request.Close@/org/freedesktop/portal/desktop/request/*" + "org.freedesktop.portal.Session.Close@/org/freedesktop/portal/desktop/session/*" + ]; + rules.broadcast."org.freedesktop.portal.Desktop" = [ + "org.freedesktop.portal.Settings.SettingChanged@/org/freedesktop/portal/desktop" + "@/org/freedesktop/portal/desktop/*" + ]; + }; + bubblewrap = { + network = lib.mkDefault false; + newSession = true; + dieWithParent = true; + clearEnv = true; + sockets = { + wayland = true; + x11 = lib.mkDefault false; + pulse = lib.mkDefault true; + pipewire = false; + }; + bind.rw = with sloth; [ + [ + (mkdir appCacheDir) + xdgCacheHome + ] + [ + (mkdir appConfigDir) + xdgConfigHome + ] + [ + (mkdir appDataDir) + xdgDataHome + ] + [ + (mkdir (concat' appDir "/state")) + xdgStateHome + ] + (concat' runtimeDir "/doc") + # Chrome/Firefox/Telegram/etc. use /tmp for single-instance IPC. Share + # this directory across launches of the same app only; app IDs differ. + [ + (mkdir (concat' runtimeDir "/nixpak-${config.flatpak.appId}-tmp")) + "/tmp" + ] + ]; + bind.ro = [ + "/etc/localtime" + "/etc/zoneinfo" + (sloth.concat' sloth.xdgConfigHome "/gtk-2.0") + (sloth.concat' sloth.xdgConfigHome "/gtk-3.0") + (sloth.concat' sloth.xdgConfigHome "/gtk-4.0") + (sloth.concat' sloth.xdgConfigHome "/kdeglobals") + "/sys/devices/platform" + ] + ++ lib.optionals config.gpu.enable [ + "/etc/egl" + "/etc/static/egl" + ]; + # nixpak's NixOS GPU provider maps DRM nodes, but NVIDIA also needs its + # dedicated interfaces. Missing nodes are skipped by --dev-bind-try. + bind.dev = lib.optionals config.gpu.enable [ + "/dev/nvidia0" + "/dev/nvidiactl" + "/dev/nvidia-modeset" + "/dev/nvidia-uvm" + ]; + tmpfs = [ + "/dev/shm" + ]; + env = { + HOME = sloth.homeDir; + USER = sloth.env "USER"; + XDG_RUNTIME_DIR = sloth.runtimeDir; + XDG_CACHE_HOME = sloth.xdgCacheHome; + XDG_CONFIG_HOME = sloth.xdgConfigHome; + XDG_DATA_HOME = sloth.xdgDataHome; + XDG_STATE_HOME = sloth.xdgStateHome; + XDG_SESSION_TYPE = "wayland"; + # Selects the Wayland path for portal clients. + XDG_CURRENT_DESKTOP = sloth.envOr "XDG_CURRENT_DESKTOP" "niri"; + WAYLAND_DISPLAY = sloth.envOr "WAYLAND_DISPLAY" "wayland-0"; + NIXOS_OZONE_WL = "1"; + GTK_USE_PORTAL = "1"; + GTK_A11Y = sloth.envOr "GTK_A11Y" "0"; + XDG_DATA_DIRS = lib.makeSearchPath "share" [ + pkgs.papirus-icon-theme + pkgs.bibata-cursors + pkgs.shared-mime-info + ]; + XCURSOR_PATH = "${pkgs.bibata-cursors}/share/icons:${pkgs.bibata-cursors}/share/pixmaps"; + # Fontconfig configuration mounted by nixpak fonts.enable. + FONTCONFIG_FILE = "/etc/fonts/fonts.conf"; + NIXOS_XDG_OPEN_USE_PORTAL = "1"; + } + // lib.genAttrs [ + "LANG" + "LANGUAGE" + "LC_ALL" + "LC_ADDRESS" + "LC_COLLATE" + "LC_CTYPE" + "LC_IDENTIFICATION" + "LC_MEASUREMENT" + "LC_MESSAGES" + "LC_MONETARY" + "LC_NAME" + "LC_NUMERIC" + "LC_PAPER" + "LC_TELEPHONE" + "LC_TIME" + "XCURSOR_THEME" + "XCURSOR_SIZE" + "NO_AT_BRIDGE" + "QT_QPA_PLATFORMTHEME" + "QT_WAYLAND_DISABLE_WINDOWDECORATION" + ] (name: sloth.envOr name ""); + }; +} diff --git a/hardening/nixpaks/modules/desktop-integration.nix b/hardening/nixpaks/modules/desktop-integration.nix new file mode 100644 index 00000000..1694fc1f --- /dev/null +++ b/hardening/nixpaks/modules/desktop-integration.nix @@ -0,0 +1,67 @@ +# Non-portal desktop services are separate opt-ins from portal capabilities. +{ + config, + lib, + sloth, + ... +}: +let + cfg = config.desktopIntegration; + appId = config.flatpak.appId; + shortName = lib.last (lib.splitString "." appId); +in +{ + options.desktopIntegration = { + notifications = lib.mkEnableOption "native desktop notifications"; + tray = lib.mkEnableOption "StatusNotifier tray integration"; + mpris = lib.mkEnableOption "MPRIS media controls for this application"; + accessibility = lib.mkEnableOption "the accessibility bus and AT-SPI socket"; + }; + config = lib.mkMerge [ + (lib.mkIf cfg.notifications { + dbus.policies."org.freedesktop.Notifications" = "talk"; + }) + (lib.mkIf cfg.tray { + dbus.policies = { + # Qt probes NameHasOwner/GetNameOwner before registering its SNI. + "org.freedesktop.DBus" = "talk"; + "org.kde.StatusNotifierWatcher" = "talk"; + } + # SNI name is -; WeChat 4.1.13 uses -2. + // builtins.listToAttrs ( + lib.concatMap ( + id: + map ( + instance: lib.nameValuePair "org.kde.StatusNotifierItem-${toString id}-${toString instance}" "own" + ) (lib.range 1 5) + ) (lib.range 2 5) + ); + }) + (lib.mkIf cfg.mpris { + # Chromium's MPRIS name is lowercase even though its app ID ends in + # Chromium; retain the full ID and toolkit spelling as well. + dbus.policies = builtins.listToAttrs ( + lib.concatMap + (name: [ + (lib.nameValuePair "org.mpris.MediaPlayer2.${name}" "own") + (lib.nameValuePair "org.mpris.MediaPlayer2.${name}.*" "own") + ]) + ( + lib.unique [ + appId + shortName + (lib.toLower shortName) + ] + ) + ); + }) + (lib.mkIf cfg.accessibility { + dbus.policies."org.a11y.Bus" = "see"; + dbus.rules.call."org.a11y.Bus" = [ + "org.a11y.Bus.GetAddress@/org/a11y/bus" + "org.freedesktop.DBus.Properties.Get@/org/a11y/bus" + ]; + bubblewrap.bind.ro = [ (sloth.concat' sloth.runtimeDir "/at-spi/bus") ]; + }) + ]; +} diff --git a/hardening/nixpaks/modules/gui-base.nix b/hardening/nixpaks/modules/gui-base.nix deleted file mode 100644 index 2d0b96f6..00000000 --- a/hardening/nixpaks/modules/gui-base.nix +++ /dev/null @@ -1,173 +0,0 @@ -# Fork of nixpak/pkgs's gui-base.nix: the shared GUI runtime plumbing (GPU, -# fonts, desktop caches, /dev/shm, and the audio/display sockets) that lets a -# GUI app run under bubblewrap. Paths bound here must not be bound again in -# ./custom-policy.nix or the app configs: nixpak emits read-only binds last, so -# a duplicate read-only bind would silently shadow a writable one. -# -# https://github.com/nixpak/pkgs/blob/master/pkgs/modules/gui-base.nix -{ - config, - lib, - pkgs, - sloth, - ... -}: -let - envSuffix = envKey: suffix: sloth.concat' (sloth.env envKey) suffix; - # nixpak cannot pass a variable only when it is set, so an unset one arrives - # empty; only list variables whose readers treat empty as unset. - passHostEnv = names: lib.genAttrs names (name: sloth.envOr name ""); - # cursor & icon's theme should be the same as the host's one. - cursorTheme = pkgs.bibata-cursors; - iconTheme = pkgs.papirus-icon-theme; -in -{ - config = { - dbus.policies = { - "${config.flatpak.appId}" = "own"; - # we add other policies in ./custom-policy.nix - }; - # https://github.com/nixpak/nixpak/blob/master/modules/gpu.nix - # The NixOS provider exposes host drivers at /run/opengl-driver (read-only) - # and all /dev/dri nodes (device access), including Intel, AMD and Asahi DRM devices. - # Nixpak also maps /sys/dev/char and /sys/devices/pci0000:00 read-only for discovery. - # These mappings grant access; they do not select the GPU used by an application. - gpu = { - enable = lib.mkDefault true; - provider = "nixos"; - bundlePackage = pkgs.mesa.drivers; - }; - # https://github.com/nixpak/nixpak/blob/master/modules/gui/fonts.nix - # it works not well, bind system's /etc/fonts directly instead - fonts.enable = false; - # https://github.com/nixpak/nixpak/blob/master/modules/locale.nix - locale.enable = true; - bubblewrap = { - # Nixpak uses --ro-bind-try / --dev-bind-try: absent host paths are skipped, - # so shared mappings work on Intel/NVIDIA hosts and Apple Silicon alike. - network = lib.mkDefault false; - # setsid keeps the app off the launching terminal (TIOCSTI), and the - # sandbox goes down with the launcher instead of outliving it. - newSession = true; - dieWithParent = true; - - # nixpak binds sockets read-only, which is sufficient: connect() only needs - # the socket inode's write bit, and the kernel's read-only-filesystem check - # does not apply to sockets. Every app here is Wayland-only, so X11 stays - # disabled (the nixpak default, spelled out for intent) and PipeWire is - # shared for screensharing. - sockets = { - wayland = true; - pipewire = true; - pulse = true; - x11 = false; - }; - - bind.rw = [ - [ - (envSuffix "HOME" "/.var/app/${config.flatpak.appId}/cache") - sloth.xdgCacheHome - ] - (sloth.concat' sloth.xdgCacheHome "/fontconfig") - (sloth.concat' sloth.xdgCacheHome "/mesa_shader_cache") - - # gvfsd is a directory that holds FUSE mounts, not a socket, so it must - # stay writable. - (envSuffix "XDG_RUNTIME_DIR" "/gvfsd") - ]; - bind.ro = [ - # Sockets only need to be connectable, and connect() works on a read-only - # bind (see the note on `sockets` above). - (envSuffix "XDG_RUNTIME_DIR" "/at-spi/bus") - - (sloth.concat' sloth.xdgConfigHome "/gtk-2.0") - (sloth.concat' sloth.xdgConfigHome "/gtk-3.0") - (sloth.concat' sloth.xdgConfigHome "/gtk-4.0") - (sloth.concat' sloth.xdgConfigHome "/fontconfig") - - "/etc/fonts" # for fontconfig - "/etc/localtime" # this is a symlink to /etc/zoneinfo/xxx - "/etc/zoneinfo" - - # Host EGL configuration (read-only); needed by some driver setups. - "/etc/egl" - "/etc/static/egl" - - # Asahi GPU discovery follows /sys/dev/char links into platform devices. - # Expose their targets read-only; this includes non-GPU platform metadata too. - "/sys/devices/platform" - ]; - bind.dev = [ - "/dev/shm" # Shared Memory - - # Additional NVIDIA interfaces, retained for primary-GPU and offload use. - # Unlike /dev/dri above, numbered NVIDIA nodes are not mapped as a directory. - "/dev/nvidia0" # GPU index 0; additional GPUs need explicit mappings. - "/dev/nvidiactl" # Driver control interface. - "/dev/nvidia-modeset" # Display mode setting. - "/dev/nvidia-uvm" # Unified virtual memory. - ]; - - tmpfs = [ - "/tmp" - ]; - - # Start from an empty environment so host secrets (tokens, credentials) - # stay outside; everything the apps need is listed here. - clearEnv = true; - env = { - HOME = sloth.homeDir; - USER = sloth.env "USER"; - XDG_RUNTIME_DIR = sloth.runtimeDir; - XDG_CACHE_HOME = sloth.xdgCacheHome; - XDG_CONFIG_HOME = sloth.xdgConfigHome; - XDG_DATA_HOME = sloth.xdgDataHome; - XDG_STATE_HOME = sloth.xdgStateHome; - # Same fallback as nixpak's Wayland socket bind, so they always agree. - WAYLAND_DISPLAY = sloth.envOr "WAYLAND_DISPLAY" "wayland-0"; - XDG_SESSION_TYPE = "wayland"; - # GTK reads an empty GTK_A11Y as an unknown backend; "0" is its unset value. - GTK_A11Y = sloth.envOr "GTK_A11Y" "0"; - - XDG_DATA_DIRS = lib.mkForce ( - lib.makeSearchPath "share" [ - iconTheme - cursorTheme - pkgs.shared-mime-info - ] - ); - XCURSOR_PATH = lib.mkForce ( - lib.concatStringsSep ":" [ - "${cursorTheme}/share/icons" - "${cursorTheme}/share/pixmaps" - ] - ); - } - // passHostEnv [ - "LANG" - "LANGUAGE" - "LC_ALL" - "LC_ADDRESS" - "LC_COLLATE" - "LC_CTYPE" - "LC_IDENTIFICATION" - "LC_MEASUREMENT" - "LC_MESSAGES" - "LC_MONETARY" - "LC_NAME" - "LC_NUMERIC" - "LC_PAPER" - "LC_TELEPHONE" - "LC_TIME" - - "NO_AT_BRIDGE" - "NIXOS_XDG_OPEN_USE_PORTAL" - "NIXOS_OZONE_WL" # QQ's wrapper adds its Wayland flags only when set - "XCURSOR_THEME" - "XCURSOR_SIZE" - "QT_QPA_PLATFORMTHEME" - "QT_WAYLAND_DISABLE_WINDOWDECORATION" - ]; - }; - }; -} diff --git a/hardening/nixpaks/modules/portal-groups.nix b/hardening/nixpaks/modules/portal-groups.nix new file mode 100644 index 00000000..db44141a --- /dev/null +++ b/hardening/nixpaks/modules/portal-groups.nix @@ -0,0 +1,62 @@ +# xdg-dbus-proxy rules target interfaces on the Desktop bus name, not fictitious +# per-interface bus names. Each capability is opt-in. +{ config, lib, ... }: +let + groups = { + fileChooser = [ + "FileChooser.OpenFile" + "FileChooser.SaveFile" + "FileChooser.SaveFiles" + ]; + openUri = [ + "OpenURI.OpenURI" + "OpenURI.OpenFile" + "OpenURI.OpenDirectory" + ]; + notifications = [ + "Notification.AddNotification" + "Notification.RemoveNotification" + ]; + screenCast = [ + "ScreenCast.CreateSession" + "ScreenCast.SelectSources" + "ScreenCast.Start" + "ScreenCast.OpenPipeWireRemote" + ]; + camera = [ + "Camera.AccessCamera" + "Camera.OpenPipeWireRemote" + ]; + print = [ + "Print.PreparePrint" + "Print.Print" + ]; + secret = [ "Secret.RetrieveSecret" ]; + inhibit = [ + "Inhibit.Inhibit" + "Inhibit.CreateMonitor" + "Inhibit.QueryEndResponse" + ]; + }; +in +{ + options.portalGroups = + lib.mapAttrs (name: _: lib.mkEnableOption "the ${name} desktop portal methods") groups + // { + inputMethod = lib.mkEnableOption "Fcitx and IBus input method portals"; + }; + config = { + dbus.rules.call."org.freedesktop.portal.Desktop" = lib.concatLists ( + lib.mapAttrsToList ( + name: methods: + lib.optionals config.portalGroups.${name} ( + map (method: "org.freedesktop.portal.${method}@/org/freedesktop/portal/desktop") methods + ) + ) groups + ); + dbus.policies = lib.mkIf config.portalGroups.inputMethod { + "org.freedesktop.portal.Fcitx" = "talk"; + "org.freedesktop.portal.IBus" = "talk"; + }; + }; +} diff --git a/hardening/nixpaks/modules/wechat-policy.nix b/hardening/nixpaks/modules/wechat-policy.nix new file mode 100644 index 00000000..49973e2f --- /dev/null +++ b/hardening/nixpaks/modules/wechat-policy.nix @@ -0,0 +1,54 @@ +# The existing FHS layer owns WeChat's HOME remapping and xwechat_files link. +{ + config, + lib, + pkgs, + sloth, + ... +}: +{ + imports = [ + ./desktop-base.nix + ./network.nix + ]; + pasta.mode = "isolate"; + desktopIntegration.tray = true; + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + screenCast = true; + camera = true; + inputMethod = true; + }; + bubblewrap = { + sockets.x11 = true; + # libdbus aborts without /etc/machine-id; use a synthetic app-scoped value. + bind.ro = [ + [ + (toString ( + pkgs.writeText "wechat-machine-id" "${ + builtins.substring 0 32 (builtins.hashString "sha256" config.flatpak.appId) + }\n" + )) + "/etc/machine-id" + ] + ]; + # The inner FHS /home reset replaces base XDG mounts with this backing tree. + bind.rw = lib.mkForce [ + (sloth.mkdir (sloth.concat' sloth.xdgDocumentsDir "/WeChat_Data")) + (sloth.concat' sloth.runtimeDir "/doc") + [ + (sloth.mkdir (sloth.concat' sloth.runtimeDir "/nixpak-${config.flatpak.appId}-tmp")) + "/tmp" + ] + ]; + env = { + DISPLAY = sloth.envOr "DISPLAY" ""; + XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority"); + XDG_DOCUMENTS_DIR = sloth.xdgDocumentsDir; + # The existing pre-FHS script uses mkdir/ln before /usr/bin exists. + PATH = lib.makeBinPath [ pkgs.coreutils ]; + }; + }; +} diff --git a/hardening/nixpaks/modules/zoom-policy.nix b/hardening/nixpaks/modules/zoom-policy.nix new file mode 100644 index 00000000..e352cedb --- /dev/null +++ b/hardening/nixpaks/modules/zoom-policy.nix @@ -0,0 +1,42 @@ +# Zoom keeps the upstream FHS runtime inside the outer nixpak boundary. +{ lib, sloth, ... }: +{ + imports = [ + ./desktop-base.nix + ./network.nix + ]; + pasta.mode = "isolate"; + desktopIntegration.tray = true; + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + screenCast = true; + camera = true; + inhibit = true; + inputMethod = true; + }; + bubblewrap = { + # Screen sharing enumerates DRM connectors through sysfs. Missing this + # directory makes Zoom abort with std::filesystem_error. + bind.ro = [ "/sys/class/drm" ]; + # ZoomLauncher selects xcb; the X11 connection retains X11's display trust. + sockets.x11 = true; + env = { + DISPLAY = sloth.envOr "DISPLAY" ""; + XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority"); + }; + bind.dev = [ + # Zoom 7.2.1 still opens V4L2 device nodes directly for webcam capture. + "/dev/video0" + "/dev/video1" + ]; + bind.rw = [ + (sloth.mkdir (sloth.concat' sloth.homeDir "/.zoom")) + (sloth.concat' sloth.xdgConfigHome "/zoomus.conf") + (sloth.concat' sloth.xdgConfigHome "/zoom.conf") + (sloth.mkdir (sloth.concat' sloth.xdgDocumentsDir "/Zoom")) + sloth.xdgDownloadDir + ]; + }; +} diff --git a/hardening/nixpaks/qq.nix b/hardening/nixpaks/qq.nix index b2800ab1..79e31db9 100644 --- a/hardening/nixpaks/qq.nix +++ b/hardening/nixpaks/qq.nix @@ -26,11 +26,26 @@ let flatpak.appId = appId; imports = [ - ./modules/gui-base.nix + ./modules/desktop-base.nix ./modules/network.nix - ./modules/custom-policy.nix ]; + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + screenCast = true; + camera = true; + inhibit = true; + inputMethod = true; + }; + desktopIntegration = { + notifications = true; + tray = true; + mpris = true; + accessibility = true; + }; + bubblewrap = { # To trace all the home files QQ accesses, you can use the following nushell command: # just trace-access qq diff --git a/hardening/nixpaks/telegram-desktop.nix b/hardening/nixpaks/telegram-desktop.nix index c4aadbb8..b27cd4fc 100644 --- a/hardening/nixpaks/telegram-desktop.nix +++ b/hardening/nixpaks/telegram-desktop.nix @@ -13,9 +13,8 @@ let { sloth, ... }: { imports = [ - ./modules/gui-base.nix + ./modules/desktop-base.nix ./modules/network.nix - ./modules/custom-policy.nix ]; app.package = telegram-desktop; flatpak = { @@ -30,6 +29,22 @@ let }; }; + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + screenCast = true; + camera = true; + inhibit = true; + inputMethod = true; + }; + desktopIntegration = { + notifications = true; + tray = true; + mpris = true; + accessibility = true; + }; + bubblewrap = { # nixpak exposes /.flatpak-info, so Telegram uses FLATPAK_ID as its Wayland app ID. env.FLATPAK_ID = appId; diff --git a/hardening/nixpaks/wechat.nix b/hardening/nixpaks/wechat.nix new file mode 100644 index 00000000..654c32b0 --- /dev/null +++ b/hardening/nixpaks/wechat.nix @@ -0,0 +1,72 @@ +{ + lib, + bwraps, + mkNixPak, + buildEnv, + makeDesktopItem, + runCommandLocal, + ... +}: +let + appId = "com.tencent.WeChat"; + # As with Zoom, preserve the outer metadata file across the inner FHS layer. + # Keep the existing AppImage runtime and WeChat_Data layout unchanged. + package = bwraps.wechat.overrideAttrs (old: { + # Refuse unexpected layouts before the existing ln -snf can modify them. + extraPreBwrapCmds = '' + wechat_link="''${XDG_DOCUMENTS_DIR}/WeChat_Data/home/xwechat_files" + wechat_files="''${XDG_DOCUMENTS_DIR}/WeChat_Data/xwechat_files" + if [[ -e "$wechat_link" || -L "$wechat_link" ]]; then + if [[ ! -L "$wechat_link" || "$(readlink "$wechat_link")" != "$wechat_files" ]]; then + echo 'WeChat: unexpected xwechat_files layout; refusing to change existing data.' >&2 + exit 1 + fi + fi + '' + + (old.extraPreBwrapCmds or ""); + extraBwrapArgs = (old.extraBwrapArgs or [ ]) ++ [ + "--ro-bind /.flatpak-info /.flatpak-info" + # The inner /home reset must not hide an Xauthority file stored there. + ''--ro-bind-try "''${XAUTHORITY}" "''${XAUTHORITY}"'' + ]; + }); + wrapped = mkNixPak { + config = { + imports = [ ./modules/wechat-policy.nix ]; + app = { + inherit package; + binPath = "bin/wechat"; + }; + flatpak = { inherit appId; }; + }; + }; + exe = lib.getExe wrapped.config.script; + assets = runCommandLocal "wechat-desktop-assets" { } '' + mkdir -p $out/share + ln -s ${package}/share/pixmaps $out/share/pixmaps + ''; +in +buildEnv { + inherit (wrapped.config.script) name passthru; + meta = wrapped.config.script.meta // { + mainProgram = "wechat"; + }; + paths = [ + wrapped.config.script + assets + (makeDesktopItem { + name = "wechat"; + desktopName = "WeChat"; + exec = "${exe} %U"; + icon = "wechat"; + terminal = false; + startupWMClass = "wechat"; + categories = [ + "Network" + "InstantMessaging" + ]; + mimeTypes = [ "x-scheme-handler/weixin" ]; + extraConfig.X-Flatpak = appId; + }) + ]; +} diff --git a/hardening/nixpaks/zoom.nix b/hardening/nixpaks/zoom.nix new file mode 100644 index 00000000..e200138b --- /dev/null +++ b/hardening/nixpaks/zoom.nix @@ -0,0 +1,70 @@ +{ + lib, + zoom-us, + buildFHSEnv, + mkNixPak, + buildEnv, + makeDesktopItem, + runCommandLocal, + ... +}: +let + appId = "us.zoom.Zoom"; + # Preserve the upstream FHS wrapper and /opt mount; its root mounts omit the + # metadata file needed for portal identification inside the nested sandbox. + package = zoom-us.override { + xdgDesktopPortalSupport = true; + buildFHSEnv = + args: + buildFHSEnv ( + args + // { + extraBwrapArgs = (args.extraBwrapArgs or [ ]) ++ [ + "--ro-bind /.flatpak-info /.flatpak-info" + ]; + } + ); + }; + wrapped = mkNixPak { + config = { + imports = [ ./modules/zoom-policy.nix ]; + app = { + inherit package; + binPath = "bin/zoom"; + }; + flatpak = { inherit appId; }; + }; + }; + exe = lib.getExe wrapped.config.script; + assets = runCommandLocal "zoom-desktop-assets" { } '' + mkdir -p $out/share + for directory in icons pixmaps; do + if [ -d ${package}/share/$directory ]; then + ln -s ${package}/share/$directory $out/share/$directory + fi + done + ''; +in +buildEnv { + inherit (wrapped.config.script) name meta passthru; + paths = [ + wrapped.config.script + assets + (makeDesktopItem { + name = "Zoom"; + desktopName = "Zoom Workplace"; + comment = "Zoom Video Conference"; + exec = "${exe} %U"; + icon = "Zoom"; + terminal = false; + startupWMClass = "zoom"; + categories = [ "Network" ]; + mimeTypes = [ + "x-scheme-handler/zoommtg" + "x-scheme-handler/zoomus" + "application/x-zoom" + ]; + extraConfig.X-Flatpak = appId; + }) + ]; +} diff --git a/hardening/tests/browser-runtime.md b/hardening/tests/browser-runtime.md new file mode 100644 index 00000000..f6f5ea39 --- /dev/null +++ b/hardening/tests/browser-runtime.md @@ -0,0 +1,126 @@ +# Browser runtime verification + +- Run from the repository root as the Linux desktop user, with user D-Bus; no sudo or activation. +- Tests use disposable profiles only, synthetic environment canaries, and no environment dumps. +- **Keep HOME under `/var/tmp`:** the sandbox's private `/tmp` masks a HOME created there. Rendering + reads a host-prepared HTML file through the profile bind; persistence requires a nonempty + `Local State` file in the disposable host HOME. + +## Reproduce + +Build against the locked flake inputs: + +```bash +mkdir -p browser-validation +for app in chromium google-chrome brave-origin microsoft-edge; do + APP="$app" nix build --impure --out-link "browser-validation/$app" --expr ' + let + flake = builtins.getFlake (toString ./.); + pkgs = import flake.inputs.nixpkgs { + system = builtins.currentSystem; + config.allowUnfree = true; + }; + in pkgs.callPackage + (./hardening/nixpaks + "/${builtins.getEnv "APP"}.nix") + { + mkNixPak = flake.inputs.nixpak.lib.nixpak { + inherit pkgs; + inherit (pkgs) lib; + }; + } + ' +done +``` + +Run each test separately so a failing browser does not hide the other results: + +```bash +python3 hardening/tests/verify-chromium.py browser-validation/chromium/bin/chromium +python3 hardening/tests/verify-chromium.py browser-validation/google-chrome/bin/google-chrome +python3 hardening/tests/verify-chromium.py browser-validation/brave-origin/bin/brave-origin +python3 hardening/tests/verify-chromium.py browser-validation/microsoft-edge/bin/microsoft-edge +``` + +- Each launch has a 35-second limit; failures exit nonzero. Temporary HOME is cleaned up. +- Intermediate JSON records process checks; final JSON appears only after all checks pass. + +## Current measured results + +Host: `ai`, x86_64 Linux. All four rebuilt after the shared policy/media opt-in changes and tested +with `/var/tmp` HOME, explicit Fontconfig configuration, CJK fonts, and narrow NVIDIA mappings. + +| Browser | Process/security checks | HTML rendering | Profile persistence | Host loopback | Overall | +| ---------------------- | ----------------------- | --------------- | ------------------- | ------------- | -------- | +| Chromium 156.0.8078.73 | Pass | Pass | Pass | Pass: allowed | **Pass** | +| Chrome 156.0.8078.73 | Pass | Pass | Pass | Pass: blocked | **Pass** | +| Brave Origin 1.98.60 | Pass | **35s timeout** | Not reached | Not reached | **Fail** | +| Edge 156.0.4314.42 | Pass | **35s timeout** | Not reached | Not reached | **Fail** | + +Measured evidence: + +- All four build and have live renderers with `Seccomp=2`, `NoNewPrivs=1`; no sandbox-disabling + flags are present. +- Actual bwrap arguments pass user/PID namespace, effective network flag, clear environment, new + session, parent-death, and private `/dev/shm` checks. No broad HOME/`/home`/`/dev`, SSH, + system-bus, raw video, X11, GVfs, or direct PipeWire binds. Brave/Edge also have no browserpass + credential or PulseAudio binds. +- All nine canaries are absent: `NIXPAK_VALIDATION_CANARY`, `SSH_AUTH_SOCK`, `GPG_AGENT_INFO`, and + upper/lowercase `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`. +- Chromium/Chrome render the prepared file without Fontconfig errors and persist `Local State`. Host + HTTP control succeeds; Chromium fetches its marker. Chrome reports `ERR_CONNECTION_REFUSED`, + returns no marker, and sends zero requests to the host listener. +- Brave/Edge stall on `--headless=new --dump-dom` despite `--timeout=10000`. Cause unresolved; + system D-Bus/UPower warnings also occur in passing browsers. No upstream-package control was run, + so this does not establish a wrapper regression. + +## Current permission checks + +- **Firefox/Chromium/Chrome:** evaluated media opt-in enables PulseAudio, camera/screencast portals, + MPRIS, and inhibition. **Brave/Edge:** all disabled; live socket-bind checks pass. +- Chromium/Chrome main processes enable `WaylandWindowDecorations,WebRtcPipeWireCamera`. Brave/Edge + retain Wayland decorations and have **no camera feature**; all flag checks pass. +- Firefox/Chromium share host networking and allow personal files; Chrome/Brave/Edge use pasta + isolation without personal-file access. Firefox parity is evaluated, not runtime-tested here. +- GPU nodes remain available for webpage rendering. Interactive audio/camera/capture, hardware + acceleration, CJK/IME, portals, downloads, and browserpass remain unverified by this headless + test. +- Inventory: [hardening README](../README.md). Security limits: [SECURITY.md](../../SECURITY.md). + +## Cookie crypto + +- Chromium-family wrappers enable `SecretPortalKeyProviderUseForEncryption` and receive only + `org.freedesktop.portal.Secret.RetrieveSecret`; they do **not** get + `--talk=org.freedesktop.secrets`. +- A disposable-profile probe under `/var/tmp` served an HTTP `Set-Cookie`, then inspected the + profile DB: the cookie prefix was `v12` (`763132`) and + `Local State.os_crypt.portal.prev_init_success` was true. +- If the portal is unavailable, Chromium can fall back to v10. That is weak; monitor portal startup + Portal startup is a monitored degradation, not a fail-closed guarantee. +- Firefox uses NSS (`key4.db`/`logins.json`) and `cookies.sqlite`, not Chrome OSCrypt. Set a Firefox + Primary Password if saved-login at-rest protection matters. +- Sources: + [feature defaults](https://github.com/chromium/chromium/blob/156.0.8078.73/chrome/browser/browser_features.cc), + [v12 provider](https://github.com/chromium/chromium/blob/156.0.8078.73/components/os_crypt/async/browser/secret_portal_key_provider.cc), + [prefix decryption](https://github.com/chromium/chromium/blob/156.0.8078.73/components/os_crypt/async/common/encryptor.cc). + +## Check these files + +```bash +ruff format --check hardening/tests/verify-chromium.py +ruff check hardening/tests/verify-chromium.py +prettier --check hardening/tests/browser-runtime.md +git diff --check +``` + +## Manual Chrome media smoke test + +Use the official WebRTC samples. They require no account and do not upload the stream; the preview +stays local. Chrome uses pasta-isolated loopback, so a test server on the host is intentionally not +reachable. + +- Camera + microphone: + - Click **Start**, accept Chrome and portal prompts, verify local video preview and audio level. +- Screen sharing: + - Click **Start**, choose a window/screen, verify preview, then **Stop**. +- Files: use any site's upload control, then download the same file; confirm the file chooser and + Downloads work. diff --git a/hardening/tests/policy.nix b/hardening/tests/policy.nix new file mode 100644 index 00000000..4dd9bc6b --- /dev/null +++ b/hardening/tests/policy.nix @@ -0,0 +1,221 @@ +# Evaluate with the same pkgs/mkNixPak as the wrappers. Returns true or identifies +# the violated policy boundary; does not build or launch an application. +{ pkgs, mkNixPak }: +let + inherit (pkgs) lib; + load = name: (pkgs.callPackage (../nixpaks + "/${name}.nix") { inherit mkNixPak; }).config; + firefox = load "firefox"; + chromium = load "chromium"; + chrome = load "google-chrome"; + restrictedBrowsers = map load [ + "brave-origin" + "microsoft-edge" + ]; + chromiumFamily = map load [ + "chromium" + "google-chrome" + "brave-origin" + "microsoft-edge" + ]; + browsers = map load [ + "firefox" + "chromium" + "google-chrome" + "brave-origin" + "microsoft-edge" + ]; + nonBrowserApps = map load [ + "qq" + "telegram-desktop" + "zoom" + "wechat" + ]; + browserAndChatApps = + browsers + ++ map load [ + "qq" + "telegram-desktop" + ]; + apps = browsers ++ nonBrowserApps; + # Inspect effective proxy arguments, not just capability switches: an extra + # direct service grant could otherwise bypass a disabled portal group. + forbiddenDbus = + argument: + lib.any (name: lib.hasInfix name argument) [ + "org.freedesktop.portal.Usb" + "org.freedesktop.portal.RemoteDesktop" + "org.gnome.Shell.Screencast" + ] + || argument == "--talk=org.freedesktop.portal.Desktop"; + withoutSecretPortal = + calls: + calls + // { + "org.freedesktop.portal.Desktop" = lib.filter ( + arg: !lib.hasInfix "org.freedesktop.portal.Secret" arg + ) calls."org.freedesktop.portal.Desktop"; + }; + mountStrings = + app: builtins.filter builtins.isString (lib.flatten (with app.bubblewrap.bind; rw ++ ro ++ dev)); + forbiddenMount = + path: + path == "/dev" + || lib.hasPrefix "/dev/video" path + || lib.hasPrefix "/dev/bus/usb" path + || path == "/run/dbus" + || path == "/run/dbus/system_bus_socket"; + checks = { + personalBrowserCapabilitiesMatch = + firefox.browserPermissions.media == chromium.browserPermissions.media + && firefox.browserPermissions.personalFiles == chromium.browserPermissions.personalFiles + && firefox.browserPermissions.browserpass == chromium.browserPermissions.browserpass + && + (removeAttrs firefox.portalGroups [ "secret" ]) == (removeAttrs chromium.portalGroups [ "secret" ]) + && firefox.desktopIntegration == chromium.desktopIntegration; + personalBrowserPortalCallsMatch = + withoutSecretPortal firefox.dbus.rules.call == withoutSecretPortal chromium.dbus.rules.call; + personalBrowserHostLoopback = lib.all (app: app.bubblewrap.network && !app.pasta.enable) [ + firefox + chromium + ]; + workBrowserIsolatedLoopback = + chrome.bubblewrap.network + && chrome.pasta.enable + && chrome.pasta.mode == "isolate" + && lib.elem "--no-map-gw" chrome.pasta.args; + zoomDrmSysfs = lib.elem "/sys/class/drm" (load "zoom").bubblewrap.bind.ro; + zoomCameraDevices = + let + zoom = load "zoom"; + in + lib.all (dev: lib.elem dev zoom.bubblewrap.bind.dev) [ + "/dev/video0" + "/dev/video1" + ]; + fhsTrayIntegration = + lib.all + ( + app: + app.desktopIntegration.tray + && app.dbus.policies."org.freedesktop.DBus" == "talk" + && app.dbus.policies."org.kde.StatusNotifierWatcher" == "talk" + && app.dbus.policies."org.kde.StatusNotifierItem-2-1" == "own" + && app.dbus.policies."org.kde.StatusNotifierItem-2-2" == "own" + ) + ( + map load [ + "wechat" + "zoom" + ] + ); + appTmpIsolation = + let + tmpPath = + app: + lib.findFirst ( + entry: builtins.isList entry && builtins.length entry == 2 && builtins.elem "/tmp" entry + ) null app.bubblewrap.bind.rw; + paths = map tmpPath apps; + in + lib.all (path: builtins.isList path && builtins.length path == 2) paths + && lib.length (lib.unique (map builtins.head paths)) == builtins.length apps; + workBrowserMedia = + chrome.portalGroups.camera + && chrome.portalGroups.screenCast + && chrome.bubblewrap.sockets.pulse + && chrome.gpu.enable + && lib.elem "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Camera.OpenPipeWireRemote@/org/freedesktop/portal/desktop" chrome.dbus.args + && lib.elem "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.ScreenCast.OpenPipeWireRemote@/org/freedesktop/portal/desktop" chrome.dbus.args; + secretPortalScope = + lib.all ( + app: + app.browserPermissions.secretPortal + && lib.any ( + arg: + arg + == "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Secret.RetrieveSecret@/org/freedesktop/portal/desktop" + ) app.dbus.args + && !lib.any (arg: arg == "--talk=org.freedesktop.secrets") app.dbus.args + ) chromiumFamily + && !firefox.browserPermissions.secretPortal + && lib.all ( + app: + !(app.browserPermissions.secretPortal or false) + && !lib.any (arg: lib.hasInfix "org.freedesktop.portal.Secret" arg) app.dbus.args + ) nonBrowserApps; + mediaScope = + lib.all + ( + app: + app.browserPermissions.media + && app.bubblewrap.sockets.pulse + && app.portalGroups.camera + && app.portalGroups.screenCast + ) + [ + firefox + chromium + chrome + ]; + restrictedBrowserMedia = lib.all ( + app: + !app.browserPermissions.media + && !app.bubblewrap.sockets.pulse + && !app.bubblewrap.sockets.pipewire + && !app.portalGroups.camera + && !app.portalGroups.screenCast + && !app.desktopIntegration.mpris + && !lib.any ( + arg: + lib.hasInfix "org.freedesktop.portal.Camera" arg + || lib.hasInfix "org.freedesktop.portal.ScreenCast" arg + || lib.hasInfix "org.mpris.MediaPlayer2" arg + ) app.dbus.args + && !lib.any (path: lib.hasPrefix "/dev/snd" path) (mountStrings app) + ) restrictedBrowsers; + personalFileScope = + firefox.browserPermissions.personalFiles + && chromium.browserPermissions.personalFiles + && !chrome.browserPermissions.personalFiles; + browserpassScope = + lib.all (app: app.browserPermissions.browserpass) [ + firefox + chromium + chrome + ] + && lib.all (app: !app.browserPermissions.browserpass) ( + map load [ + "brave-origin" + "microsoft-edge" + ] + ); + deniedServices = lib.all (app: !lib.any forbiddenDbus app.dbus.args) apps; + noBroadKeyring = lib.all ( + app: !lib.any (arg: arg == "--talk=org.freedesktop.secrets") app.dbus.args + ) apps; + deniedDevicesAndSystemBus = + let + zoomVideoDevices = [ + "/dev/video0" + "/dev/video1" + ]; + forbiddenMountFor = + app: + if app.flatpak.appId == "us.zoom.Zoom" then + path: forbiddenMount path && !lib.elem path zoomVideoDevices + else + forbiddenMount; + in + lib.all (app: !lib.any (forbiddenMountFor app) (mountStrings app)) apps; + filteredEnvironmentAndMedia = lib.all ( + app: + app.bubblewrap.clearEnv + && app.dbus.enable + && !app.bubblewrap.sockets.pipewire + && !app.bubblewrap.sockets.x11 + ) browserAndChatApps; + }; +in +lib.all (name: checks.${name} || throw "nixpak policy check failed: ${name}") ( + builtins.attrNames checks +) diff --git a/hardening/tests/unified-app-runtime.md b/hardening/tests/unified-app-runtime.md new file mode 100644 index 00000000..f5ca4072 --- /dev/null +++ b/hardening/tests/unified-app-runtime.md @@ -0,0 +1,35 @@ +# Unified desktop-policy validation + +Validation on the Linux `ai` host, using the pinned nixpkgs and nixpak inputs. These checks use +disposable profiles and do not read existing browser or chat accounts. + +| Check | Result | Scope | +| --------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Firefox, QQ, Telegram derivations | Pass | All three wrappers evaluated and built after conversion to the shared desktop policy. | +| Browser policy regression checks | Pass | `policy.nix` returned `true`; covers Firefox/Chromium capability and portal-call parity, host loopback, Chrome isolated networking and media grants, browserpass scope, and forbidden service/device access. | +| Firefox headless screenshot | Pass | Exit status 0; `about:blank` produced a PNG with a fresh profile and HOME under `/var/tmp`. | +| QQ startup | Partial | With a disposable HOME and `--ozone-platform=headless`, initialized private QQ data and stayed running for 12 seconds until explicitly terminated. Headless EGL/GPU initialization errors occurred. No account login or media function was tested. | +| Telegram runtime | Not run | Evaluation and build only. | + +The Firefox invocation used the built wrapper with +`--headless --no-remote --profile "$HOME/.mozilla/test" --screenshot "$HOME/Downloads/smoke.png" about:blank`. +The disposable environment retained only the launcher's PATH, USER, locale, Wayland display and +session-bus/runtime-directory variables, with HOME and all XDG storage paths redirected to the +temporary tree. Existing account profiles were not mounted. The temporary tree was removed after the +process exited. + +Use `/var/tmp`, not `/tmp`, for disposable host profiles. Nixpak emits the sandbox's private `/tmp` +mount after filesystem binds; a profile prepared under host `/tmp` is hidden by that mount. An +application that creates a fresh profile inside the resulting tmpfs can appear to work without +testing persistence. + +The common policy replaces the earlier direct camera-device and PipeWire-socket grants with explicit +Camera and ScreenCast portal methods. QQ and Telegram retain their private XDG data and existing +personal-file mappings, and opt into notifications, tray, MPRIS and accessibility. Firefox retains +its `.mozilla` profile and MPRIS artwork mounts and shares browser capability definitions with +Chromium. + +Interactive validation remains necessary for camera capture, microphone input, video playback, +desktop/window sharing, file chooser/save operations, IME, tray, notifications and media controls. +The QQ headless startup is not evidence that those functions work. Browser encrypted-login behavior +is covered in `browser-runtime.md`. diff --git a/hardening/tests/verify-chromium.py b/hardening/tests/verify-chromium.py new file mode 100755 index 00000000..56f3cb88 --- /dev/null +++ b/hardening/tests/verify-chromium.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Runtime smoke test: verify a built nixpak Chromium without existing profiles. + +Usage: python3 hardening/tests/verify-chromium.py /nix/store/.../bin/chromium +Requires Linux procfs and an active user D-Bus session. Uses only stdlib. +""" + +import argparse +import http.server +import json +import os +import signal +import subprocess +import tempfile +import threading +import time +import urllib.request +from pathlib import Path + + +def processes(): + result = {} + for path in Path("/proc").glob("[0-9]*"): + try: + status = dict( + line.split(":", 1) + for line in (path / "status").read_text().splitlines() + if ":" in line + ) + args = (path / "cmdline").read_bytes().split(b"\0") + result[int(path.name)] = (int(status["PPid"]), status, args) + except (OSError, ValueError): + pass + return result + + +def descendants(root, table): + found = {root} + while True: + more = {pid for pid, (parent, _, _) in table.items() if parent in found} + if more <= found: + return found + found |= more + + +def run_browser(command, env): + try: + return subprocess.run( + command, check=False, env=env, capture_output=True, timeout=35 + ) + except subprocess.TimeoutExpired as error: + detail = (error.stderr or b"").decode(errors="replace")[-6000:] + raise AssertionError( + "Disposable headless command timed out:\n" + detail + ) from None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("browser", type=Path) + args = parser.parse_args() + browser = str(args.browser.absolute()) + profile = { + "chromium": "chromium", + "google-chrome": "google-chrome", + "microsoft-edge": "microsoft-edge", + "brave-origin": "BraveSoftware/Brave-Origin", + }[args.browser.name] + media = args.browser.name in {"chromium", "google-chrome"} + report = {} + with tempfile.TemporaryDirectory( + prefix="nixpak-chromium-test-", dir="/var/tmp" + ) as tmp: + home = Path(tmp) + for directory in [ + f".config/{profile}", + "Downloads", + ".cache", + ".local/share", + ".local/state", + ]: + (home / directory).mkdir(parents=True, exist_ok=True) + (home / ".config/user-dirs.dirs").write_text( + 'XDG_DOWNLOAD_DIR="$HOME/Downloads"\n' + ) + env = { + key: os.environ[key] + for key in [ + "PATH", + "USER", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "WAYLAND_DISPLAY", + "LANG", + ] + if key in os.environ + } + canaries = [ + "NIXPAK_VALIDATION_CANARY", + "SSH_AUTH_SOCK", + "GPG_AGENT_INFO", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ] + env.update({name: "disposable-test-value" for name in canaries}) + env.update( + HOME=tmp, + XDG_CONFIG_HOME=f"{tmp}/.config", + XDG_CACHE_HOME=f"{tmp}/.cache", + XDG_DATA_HOME=f"{tmp}/.local/share", + XDG_STATE_HOME=f"{tmp}/.local/state", + NIXPAK_VALIDATION_CANARY="disposable-test-value", + ) + base = [ + browser, + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + f"--user-data-dir={tmp}/.config/{profile}/validation-profile", + ] + with (home / "browser.log").open("w+") as log: + proc = subprocess.Popen( + base + ["--remote-debugging-port=0", "about:blank"], + env=env, + stdout=log, + stderr=log, + start_new_session=True, + ) + try: + deadline = time.monotonic() + 35 + renderers = [] + while time.monotonic() < deadline: + table = processes() + owned = descendants(proc.pid, table) + renderers = [ + pid + for pid in owned + if pid in table + and b"--type=renderer" in b" ".join(table[pid][2]).split() + ] + if renderers or proc.poll() is not None: + break + time.sleep(0.2) + if not renderers: + log.seek(0) + # This log belongs only to the disposable browser, never a host environment dump. + raise AssertionError( + "No live renderer; disposable browser log:\n" + + log.read()[-6000:] + ) + report["renderers"] = [ + { + "pid": pid, + "Seccomp": table[pid][1]["Seccomp"].strip(), + "NoNewPrivs": table[pid][1]["NoNewPrivs"].strip(), + } + for pid in renderers + ] + assert all( + row["Seccomp"] == "2" and row["NoNewPrivs"] == "1" + for row in report["renderers"] + ), report + bwrap = [ + row[2] + for pid, row in table.items() + if pid in owned and Path(os.fsdecode(row[2][0])).name == "bwrap" + ] + required = [ + b"--unshare-user-try", + b"--unshare-pid", + b"--unshare-net", + b"--clearenv", + b"--new-session", + b"--die-with-parent", + ] + assert any( + all(flag in command for flag in required) for command in bwrap + ), "Missing bwrap isolation arguments" + report["bwrap_required_flags"] = [ + os.fsdecode(flag) for flag in required + ] + browser_pids = set() + for renderer in renderers: + pid = table[renderer][0] + while pid in owned and pid in table: + command = b" ".join(table[pid][2]).split() + if ( + any(arg.startswith(b"--user-data-dir=") for arg in command) + and not any(arg.startswith(b"--type=") for arg in command) + and b"--clearenv" not in command + ): + browser_pids.add(pid) + break + pid = table[pid][0] + checked = 0 + for pid in browser_pids: + command = b" ".join(table[pid][2]).split() + assert b"--no-sandbox" not in command, "Chromium sandbox disabled" + features = [ + arg.split(b"=", 1)[1].split(b",") + for arg in command + if arg.startswith(b"--enable-features=") + ] + assert features and b"WaylandWindowDecorations" in features[-1], ( + "Missing Wayland feature flag" + ) + assert (b"WebRtcPipeWireCamera" in features[-1]) == media, ( + "Camera feature does not match browser media policy" + ) + + assert all( + b"--no-sandbox" not in b" ".join(table[pid][2]).split() + for pid in renderers + ), "Renderer sandbox disabled" + report["camera_and_wayland_features"] = { + "WaylandWindowDecorations": True, + "WebRtcPipeWireCamera": media, + } + report["chromium_sandbox_flags"] = ( + "--no-sandbox absent; renderer Seccomp=2/NoNewPrivs=1" + ) + for pid in browser_pids: + try: + entries = Path(f"/proc/{pid}/environ").read_bytes().split(b"\0") + except PermissionError: + continue + assert not any( + entry.startswith(name.encode() + b"=") + for entry in entries + for name in canaries + ), "Canary leaked into browser" + checked += 1 + assert checked, "Could not inspect browser environment" + report["browser_environments_without_canary"] = checked + report["absent_canary_variables"] = canaries + command = next(command for command in bwrap if b"--clearenv" in command) + network_flags = [ + arg + for arg in command[: command.index(b"--")] + if arg in {b"--unshare-net", b"--share-net"} + ] + expected_network = ( + b"--share-net" + if args.browser.name == "chromium" + else b"--unshare-net" + ) + assert network_flags[-1] == expected_network, ( + "Unexpected effective network namespace policy" + ) + report["effective_network_namespace_flag"] = os.fsdecode( + expected_network + ) + + for forbidden in [b"/dev/video0", b"/tmp/.X11-unix"]: + assert forbidden not in command, "Unexpected device/socket bind" + assert not any( + b"/pipewire-0" in arg or b"/gvfsd" in arg for arg in command + ), "Unexpected direct desktop socket bind" + assert any( + command[i : i + 2] == [b"--tmpfs", b"/dev/shm"] + for i in range(len(command) - 1) + ), "Missing private shared memory" + report["private_shm_and_no_direct_pipewire_gvfs_camera_x11"] = True + # Inspect bind operands, not synthetic --dev /dev or tmpfs mounts. + bind_options = { + b"--bind", + b"--bind-try", + b"--ro-bind", + b"--ro-bind-try", + b"--dev-bind", + b"--dev-bind-try", + } + options = command[: command.index(b"--")] + binds = [ + (options[i + 1], options[i + 2]) + for i, option in enumerate(options) + if option in bind_options + ] + broad_paths = { + b"/", + b"/home", + b"/dev", + os.fsencode(tmp), + os.fsencode(os.environ["HOME"]), + } + for source, destination in binds: + for path in (source, destination): + assert path.rstrip(b"/") not in broad_paths and path != b"/", ( + "Broad host filesystem bind" + ) + parts = path.split(b"/") + if not media: + assert b"pulse" not in parts and not any( + part.startswith(b"pipewire-") for part in parts + ), ( + "Media-disabled browser has an audio/PipeWire socket bind" + ) + + assert b".ssh" not in parts, "SSH directory bind" + assert not ( + path == b"/run/dbus" or path.startswith(b"/run/dbus/") + ), "System D-Bus bind" + assert not ( + path.startswith((b"/dev/video", b"/tmp/.X11-unix")) + ), "Raw camera or X11 bind" + if args.browser.name in {"brave-origin", "microsoft-edge"}: + assert not ( + {b".gnupg", b"gnupg", b"password-store"} & set(parts) + ), "Unexpected browserpass credentials bind" + report["no_broad_home_dev_ssh_or_system_bus_bind"] = True + report["media_socket_policy"] = ( + "PulseAudio allowed; direct PipeWire absent" + if media + else "PulseAudio and PipeWire binds absent" + ) + + report["browserpass_bind_scope"] = ( + "allowed only for Chromium/Chrome; absent for Brave/Edge" + ) + + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + + print(json.dumps(report, indent=2), flush=True) + render_file = home / ".config" / profile / "render-canary.html" + render_file.write_text("NIXPAK_RENDER_OK") + positive = run_browser( + base + + [ + "--timeout=10000", + "--dump-dom", + render_file.as_uri(), + ], + env, + ) + assert positive.returncode == 0 and b"NIXPAK_RENDER_OK" in positive.stdout, ( + "Disposable page failed to render" + ) + assert b"Fontconfig error" not in positive.stderr, ( + "Fontconfig could not load sandbox configuration" + ) + report["headless_page_render"] = ( + "host-prepared file rendered; no Fontconfig errors" + ) + state_file = home / ".config" / profile / "validation-profile" / "Local State" + assert state_file.is_file() and state_file.stat().st_size > 0, ( + "Browser profile state did not persist to disposable host HOME" + ) + report["disposable_profile_persistence"] = ( + "nonempty Local State exists in host HOME" + ) + + class Handler(http.server.BaseHTTPRequestHandler): + hits = 0 + + def do_GET(self): + type(self).hits += 1 + self.send_response(200) + self.end_headers() + self.wfile.write( + b"NIXPAK_HOST_LOOPBACK_CANARY" + ) + + def log_message(self, *args): + pass + + with http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_port}/" + assert ( + b"NIXPAK_HOST_LOOPBACK_CANARY" + in urllib.request.urlopen(url, timeout=5).read() + ) + Handler.hits = 0 + try: + result = run_browser(base + ["--timeout=10000", "--dump-dom", url], env) + assert result.returncode == 0, "Loopback browser command failed" + if args.browser.name == "chromium": + assert ( + b"NIXPAK_HOST_LOOPBACK_CANARY" in result.stdout + and Handler.hits > 0 + ), "Chromium must reach host loopback" + report["host_loopback"] = ( + "allowed: host and Chromium fetched the listener marker" + ) + else: + assert b"ERR_CONNECTION_REFUSED" in result.stdout + result.stderr, ( + "Expected explicit browser connection refusal" + ) + assert ( + b"NIXPAK_HOST_LOOPBACK_CANARY" not in result.stdout + and Handler.hits == 0 + ), "Browser reached host loopback" + report["host_loopback"] = ( + "blocked: host positive control succeeded; browser ERR_CONNECTION_REFUSED; zero server requests" + ) + finally: + server.shutdown() + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/hardening/tests/wechat-runtime.md b/hardening/tests/wechat-runtime.md new file mode 100644 index 00000000..498f3766 --- /dev/null +++ b/hardening/tests/wechat-runtime.md @@ -0,0 +1,58 @@ +# WeChat nested runtime verification + +WeChat 4.1.13.26 built and opened a `Weixin` X11 window with a disposable profile. The final +candidate was `/nix/store/6wr94i7da0674k9hzs23fk2nr1ih4i3f-nixpak-wechat-4.1.13.26`. The existing +AppImage extraction, FHS runtime, X11 and fcitx settings remain in `hardening/bwraps/wechat.nix`; +the new nixpak wrapper adds an outer boundary. + +The live extracted WeChat binary had three PID namespace identifiers, `NoNewPrivs: 1`, `Seccomp: 0`, +a network namespace different from the host, and `/.flatpak-info` identifying `com.tencent.WeChat`. +Its device view contained standard synthetic devices, `dri`, and the four explicit NVIDIA nodes; no +raw camera, USB or block devices. This smoke test does not establish an internal renderer sandbox +guarantee. + +A sentinel in the disposable `Documents/WeChat_Data/home` was readable at WeChat's remapped HOME. +Its `xwechat_files` link retained the absolute target `Documents/WeChat_Data/xwechat_files`, which +remains bound inside both layers. The final launcher also rejected a separate disposable layout +containing a real `home/xwechat_files` directory, exited 1, and preserved its sentinel and contents. +It will not silently migrate an unexpected existing layout. + +## Compatibility changes + +As with Zoom, the wrapper preserves the outer `/.flatpak-info` file explicitly: the FHS automatic +root mount loop only propagates directories. The inner `--dev-bind /dev /dev` inherits the outer +filtered devices. The original inner network setting inherits pasta's isolated outer namespace, +including its private loopback. + +The original inner `/home` reset hides the shared desktop base's XDG mounts. WeChat therefore +exposes its existing backing tree to the outer sandbox and keeps the original FHS HOME mapping. It +supplies `XDG_DOCUMENTS_DIR` and a coreutils-only PATH for the pre-FHS mkdir/link commands. +Xauthority is rebound after the inner HOME reset when present. + +The first run aborted in libdbus because no machine ID was available. The WeChat policy supplies a +synthetic app-specific ID rather than the host ID. Login/device identity continuity with an existing +account remains untested. No credentials or real profile were used for this test. + +## Historical constraint + +Commit `0c7e43a2` removed `nixpaks/wechat-uos.nix`. Its previous TODO said the FHS runtime was +problematic inside another sandbox, but supplied no failure log. That old module also disabled X11 +and set `QT_QPA_PLATFORM=kcb`, despite its xcb-only comment. Commit `28dee277` subsequently changed +the AppImage wrapper from Wayland to xcb and added fcitx environment settings. These records do not +prove the original failure's cause. Current runtime testing establishes that retaining FHS inside +nixpak is feasible with the explicit compatibility changes above. + +## Repeat safely + +Use the same disposable-home command as [Zoom](zoom-runtime.md), with WeChat's output and +executable, and additionally set `XDG_DOCUMENTS_DIR="$wechat_probe_dir/Documents"`. Prepare +`Documents/WeChat_Data/home` and `Documents/WeChat_Data/xwechat_files` beneath that disposable home +before launching. The smoke test stops before QR login and uses no account. Inspect the exact test +PID through `/proc` while the timeout is active; verify that its window and processes disappear +after timeout. + +Pending manual checks: existing account persistence, QR/login, fcitx input, notifications/tray, +portal file chooser and selected-file access, downloads, voice/video calls, camera/screencast portal +support and clipboard workflows. Camera and ScreenCast methods are explicit grants; raw capture +devices and the host PipeWire socket are not exposed. X11 retains its display trust limitations, so +portal permission does not imply all capture requires portal consent. diff --git a/hardening/tests/zoom-runtime.md b/hardening/tests/zoom-runtime.md new file mode 100644 index 00000000..9a430454 --- /dev/null +++ b/hardening/tests/zoom-runtime.md @@ -0,0 +1,54 @@ +# Zoom nested runtime verification + +The Zoom 7.2.1.5760 candidate built and opened two `Zoom Workplace` X11 windows on the NVIDIA +desktop. The final tested output was +`/nix/store/yy6sxsgn5rqafvvgs3k53akwqjcfa2r7-nixpak-zoom-7.2.1.5760`. + +The live main process had `NoNewPrivs: 1`, a network namespace different from the host, and +`/.flatpak-info` with `name=us.zoom.Zoom`. Its ancestry included ZoomLauncher and the nested +bubblewrap processes. Its `/dev` contained only standard synthetic devices, `dri`, `nvidia0`, +`nvidiactl`, `nvidia-modeset`, and `nvidia-uvm`; no camera, USB or block devices were exposed. +Zoom's main process reported `Seccomp: 0`; this test does not establish a renderer seccomp +guarantee. + +The upstream FHS runtime remains intact. Its automatic root mounts propagate directories, so the +wrapper explicitly propagates the outer `/.flatpak-info` file for portal identification. Its `/dev` +bind inherits the outer sandbox's filtered devices. The outer network uses pasta isolate mode, +without host loopback sharing. X11 remains a display trust boundary: explicit ScreenCast portal +permission does not prevent access through the X11 connection. + +Initial display testing found that `/tmp` tmpfs shadowed the X11 socket and that NVIDIA OpenGL +needed the four device mappings now in `desktop-base.nix`. Zoom therefore keeps the sandbox's +private root `/tmp` instead of mounting another tmpfs over the socket, and explicitly passes +`DISPLAY` and `XAUTHORITY`. + +## Repeat without touching the normal Zoom profile + +Build the candidate without activating a host, then substitute its output path below. The temporary +home must be outside `/tmp` when testing policies that mount a `/tmp` tmpfs. Keep the current +display and session bus environment. + +```bash +zoom_probe_dir=$(mktemp -d "$HOME/.cache/zoom-nixpak-probe.XXXXXX") +mkdir -p "$zoom_probe_dir"/{.config,.cache,.local/share,.local/state,Documents,Downloads} +HOME="$zoom_probe_dir" \ +XDG_CONFIG_HOME="$zoom_probe_dir/.config" \ +XDG_CACHE_HOME="$zoom_probe_dir/.cache" \ +XDG_DATA_HOME="$zoom_probe_dir/.local/share" \ +XDG_STATE_HOME="$zoom_probe_dir/.local/state" \ +timeout --kill-after=3 30 /nix/store/OUTPUT/bin/zoom +``` + +While it runs, inspect the exact test process through `/proc/PID/root/dev`, +`/proc/PID/root/.flatpak-info`, `/proc/PID/ns/net`, `/proc/PID/status`, and its parent processes. Do +not sign in or join a meeting during this smoke test. The timeout terminates the owned launcher and +its sandbox. Confirm the test windows and processes are gone afterward. + +The disposable run created `zoomus.conf` under the private application config directory. Existing +host `zoomus.conf` and `zoom.conf` are individually mounted when present. Application caches use the +private application cache directory. + +Still pending: login and meeting join, microphone and speaker routing, camera portal behavior on +supported hardware, screencast selection and PipeWire FD handoff, recording/export paths, file +chooser access, URL opening and IME. No successful camera, audio or screencast session is claimed by +this smoke test. diff --git a/home/linux/gui/base/browsers.nix b/home/linux/gui/base/browsers.nix index ef783dfa..acc863f3 100644 --- a/home/linux/gui/base/browsers.nix +++ b/home/linux/gui/base/browsers.nix @@ -1,20 +1,16 @@ { pkgs, + lib, ... }: { - home.packages = with pkgs; [ - nixpaks.firefox - ]; - - # source code: https://github.com/nix-community/home-manager/blob/master/modules/programs/chromium.nix - - # open-source upstream Chromium, the base for all the browsers below - programs.chromium.enable = true; - # the world's most widely used browser, with deep Google account and services integration - programs.google-chrome.enable = true; - # Brave's privacy-first build (Shields ad/tracker blocking), minus the crypto/AI extras - programs.brave-origin.enable = true; - # Microsoft's browser, with built-in phishing/malware protection, x86_64 build only - programs.microsoft-edge.enable = pkgs.stdenv.hostPlatform.isx86_64; + home.packages = + with pkgs; + [ + nixpaks.firefox + nixpaks.google-chrome + nixpaks.chromium + nixpaks.brave-origin + ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isx86_64 [ nixpaks.microsoft-edge ]; } diff --git a/home/linux/gui/base/media.nix b/home/linux/gui/base/media.nix index e7061255..e140ed94 100644 --- a/home/linux/gui/base/media.nix +++ b/home/linux/gui/base/media.nix @@ -26,7 +26,7 @@ ] # Zoom: Settings > Share Screen > Advanced > Screen Capture Mode on Wayland > PipeWire Mode. # zoom-us ships only an x86_64 Linux client, so the aarch64 host skips it. - ++ lib.optionals pkgs.stdenv.hostPlatform.isx86_64 [ pkgs.zoom-us ]; + ++ lib.optionals pkgs.stdenv.hostPlatform.isx86_64 [ pkgs.nixpaks.zoom ]; programs.mpv = { enable = true; diff --git a/home/linux/gui/base/misc.nix b/home/linux/gui/base/misc.nix index 0d31bf5e..211b7de0 100644 --- a/home/linux/gui/base/misc.nix +++ b/home/linux/gui/base/misc.nix @@ -16,7 +16,7 @@ # my custom hardened packages nixpaks.qq nixpaks.telegram-desktop - bwraps.wechat + nixpaks.wechat # discord # update too frequently, use the web version instead ]; diff --git a/home/linux/gui/base/xdg/autostart.nix b/home/linux/gui/base/xdg/autostart.nix index b633e6b5..ca5244df 100644 --- a/home/linux/gui/base/xdg/autostart.nix +++ b/home/linux/gui/base/xdg/autostart.nix @@ -17,6 +17,6 @@ # nixpaks "${pkgs.nixpaks.firefox}/share/applications/org.mozilla.firefox.desktop" "${pkgs.nixpaks.telegram-desktop}/share/applications/org.telegram.desktop.desktop" - "${pkgs.google-chrome}/share/applications/google-chrome.desktop" + "${pkgs.nixpaks.google-chrome}/share/applications/com.google.Chrome.desktop" ]; } diff --git a/home/linux/gui/base/xdg/mime.nix b/home/linux/gui/base/xdg/mime.nix index 137f279f..5f325d56 100644 --- a/home/linux/gui/base/xdg/mime.nix +++ b/home/linux/gui/base/xdg/mime.nix @@ -14,8 +14,8 @@ defaultApplications = let browser = [ - "google-chrome.desktop" - "firefox.desktop" + "com.google.Chrome.desktop" + "org.mozilla.firefox.desktop" ]; editor = [ "dev.zed.Zed.desktop" @@ -48,11 +48,14 @@ "x-scheme-handler/http" = browser; "x-scheme-handler/https" = browser; "x-scheme-handler/zoommtg" = [ "Zoom.desktop" ]; + "x-scheme-handler/zoomus" = [ "Zoom.desktop" ]; + "application/x-zoom" = [ "Zoom.desktop" ]; # all other unknown schemes will be opened by this default application. # "x-scheme-handler/unknown" = editor; "x-scheme-handler/tg" = [ "org.telegram.desktop.desktop" ]; + "x-scheme-handler/weixin" = [ "wechat.desktop" ]; "audio/*" = [ "mpv.desktop" ]; "video/*" = [ "mpv.desktop" ]; diff --git a/outputs/x86_64-linux/tests/nixpak-policy/expected.nix b/outputs/x86_64-linux/tests/nixpak-policy/expected.nix new file mode 100644 index 00000000..1baa2726 --- /dev/null +++ b/outputs/x86_64-linux/tests/nixpak-policy/expected.nix @@ -0,0 +1 @@ +{ ... }: true diff --git a/outputs/x86_64-linux/tests/nixpak-policy/expr.nix b/outputs/x86_64-linux/tests/nixpak-policy/expr.nix new file mode 100644 index 00000000..6755acc9 --- /dev/null +++ b/outputs/x86_64-linux/tests/nixpak-policy/expr.nix @@ -0,0 +1,11 @@ +{ inputs, outputs, ... }: +let + pkgs = outputs.nixosConfigurations.ai-niri.pkgs; +in +import ../../../../hardening/tests/policy.nix { + inherit pkgs; + mkNixPak = inputs.nixpak.lib.nixpak { + inherit pkgs; + inherit (pkgs) lib; + }; +}