From c277c81cd6921ffa766c4c7d752ceaf6cab4ef49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Sun, 1 Nov 2026 01:24:47 -0500 Subject: [PATCH] feat(nixpak): sandbox six desktop apps (#497) * feat(nixpak): sandbox six desktop apps * chore(nixpak): trim OBS plugin set * fix(nixpak): enable OBS on aarch64 * fix(nixpak): let imv read the full home directory * fix(home): drop redundant freerdp package * fix(nixpak): grant OBS access to V4L2 camera devices * fix(nixpak): expose V4L2 sysfs to OBS * fix(nixpak): expose NVIDIA encode library to OBS * docs(nixpak): record OBS interactive validation * docs(nixpak): record OBS RTMP validation * fix(nixpak): run MuseScore via XWayland and PulseAudio * docs(nixpak): explain app sandboxing rationale * fix(nixpak): route MuseScore ALSA through PipeWire --- hardening/README.md | 66 ++++++++++++----- hardening/nixpaks/blender.nix | 54 ++++++++++++++ hardening/nixpaks/default.nix | 7 ++ hardening/nixpaks/imv.nix | 71 +++++++++++++++++++ hardening/nixpaks/modules/blender-policy.nix | 40 +++++++++++ .../nixpaks/modules/image-viewer-policy.nix | 17 +++++ hardening/nixpaks/modules/mpv-policy.nix | 34 +++++++++ .../nixpaks/modules/musescore-policy.nix | 58 +++++++++++++++ hardening/nixpaks/modules/obs-policy.nix | 60 ++++++++++++++++ .../nixpaks/modules/remote-desktop-policy.nix | 48 +++++++++++++ hardening/nixpaks/mpv.nix | 64 +++++++++++++++++ hardening/nixpaks/musescore.nix | 64 +++++++++++++++++ hardening/nixpaks/obs-studio.nix | 70 ++++++++++++++++++ hardening/nixpaks/remmina.nix | 63 ++++++++++++++++ hardening/tests/policy-lib.nix | 8 +-- hardening/tests/policy/blender.nix | 21 ++++++ hardening/tests/policy/imv.nix | 20 ++++++ hardening/tests/policy/mpv.nix | 20 ++++++ hardening/tests/policy/musescore.nix | 29 ++++++++ hardening/tests/policy/obs-studio.nix | 34 +++++++++ hardening/tests/policy/remmina.nix | 22 ++++++ hardening/tests/runtime-validation.md | 22 ++++++ home/linux/gui/base/creative.nix | 53 +++----------- home/linux/gui/base/media.nix | 4 +- home/linux/gui/base/misc.nix | 5 +- 25 files changed, 884 insertions(+), 70 deletions(-) create mode 100644 hardening/nixpaks/blender.nix create mode 100644 hardening/nixpaks/imv.nix create mode 100644 hardening/nixpaks/modules/blender-policy.nix create mode 100644 hardening/nixpaks/modules/image-viewer-policy.nix create mode 100644 hardening/nixpaks/modules/mpv-policy.nix create mode 100644 hardening/nixpaks/modules/musescore-policy.nix create mode 100644 hardening/nixpaks/modules/obs-policy.nix create mode 100644 hardening/nixpaks/modules/remote-desktop-policy.nix create mode 100644 hardening/nixpaks/mpv.nix create mode 100644 hardening/nixpaks/musescore.nix create mode 100644 hardening/nixpaks/obs-studio.nix create mode 100644 hardening/nixpaks/remmina.nix create mode 100644 hardening/tests/policy/blender.nix create mode 100644 hardening/tests/policy/imv.nix create mode 100644 hardening/tests/policy/mpv.nix create mode 100644 hardening/tests/policy/musescore.nix create mode 100644 hardening/tests/policy/obs-studio.nix create mode 100644 hardening/tests/policy/remmina.nix diff --git a/hardening/README.md b/hardening/README.md index 25e2e7c1..2284a008 100644 --- a/hardening/README.md +++ b/hardening/README.md @@ -3,13 +3,23 @@ Deployed and interactively validated on the Linux desktop host (2026-10-31). Policy and limits: [SECURITY.md](../SECURITY.md). +## Why these apps are sandboxed + +The packaging scope is driven by blast radius and input exposure, not by treating the applications +as malicious. These apps parse complex local or remote content, load third-party plugins (OBS), or +hold powerful network/session privileges (Remmina); a parser, plugin or session compromise would +otherwise inherit the user's home, session D-Bus, devices and network. Each wrapper grants only the +ports, devices, sockets, writable directories and desktop portals needed by that app's documented +workflow. + ## Shared boundaries - 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. +- No whole host home, system D-Bus, SSH agent, USB/serial or raw camera. Raw PipeWire is granted + only to MuseScore for its ALSA-via-PipeWire audio path; see [Limits](#limits). - Host loopback only for Firefox/Chromium; all other apps use pasta-isolated loopback. Chromium is an intentional development exception. Brave Origin and Edge are backup browsers and receive no media grants. @@ -19,21 +29,27 @@ Deployed and interactively validated on the Linux desktop host (2026-10-31). Pol `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. -| App | Network | Writable paths | Grants | -| ------------ | ------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Audio, browserpass, MPRIS, idle; camera/screen sharing intentionally unavailable | -| 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, Downloads, app-scoped `wechat` data, private app state | Media, Wayland, X11 fallback, SNI tray, file chooser portal | -| Foliate | None | Documents/Downloads/Pictures read-only, app-scoped state | File chooser, OpenURI, notifications, IME, accessibility | -| Krita | None | Documents/Downloads/Pictures writable, app-scoped state | File chooser, OpenURI, print, inhibit, notifications, IME, accessibility | -| Bambu Studio | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, PulseAudio, cloud/LAN network, file chooser, notifications, IME, accessibility | -| OrcaSlicer | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, cloud/LAN network, file chooser, notifications, IME, accessibility | +| App | Network | Writable paths | Grants | +| ------------ | ------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Audio, browserpass, MPRIS, idle; camera/screen sharing intentionally unavailable | +| 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, Downloads, app-scoped `wechat` data, private app state | Media, Wayland, X11 fallback, SNI tray, file chooser portal | +| Foliate | None | Documents/Downloads/Pictures read-only, app-scoped state | File chooser, OpenURI, notifications, IME, accessibility | +| Krita | None | Documents/Downloads/Pictures writable, app-scoped state | File chooser, OpenURI, print, inhibit, notifications, IME, accessibility | +| Bambu Studio | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, PulseAudio, cloud/LAN network, file chooser, notifications, IME, accessibility | +| OrcaSlicer | Isolated | Documents/Downloads/Pictures writable, app-scoped state | X11/XWayland, cloud/LAN network, file chooser, notifications, IME, accessibility | +| Remmina | Isolated | `~/.config/remmina`, `~/.local/share/remmina`, `~/.remmina`, Downloads | PulseAudio, SSH agent, file chooser, OpenURI, notifications, IME, accessibility | +| OBS Studio | Isolated | `~/.config/obs-studio`, Videos/Pictures/Downloads | ScreenCast, V4L2 camera (`/dev/video0-1`), PulseAudio, tray, notifications, IME, accessibility | +| Blender | Isolated | `~/.config/blender`, Documents/Downloads/Pictures/Videos | X11/XWayland, PulseAudio, notifications, IME, accessibility | +| MuseScore | Isolated | `~/.config/MuseScore`, `~/.local/share/MuseScore`, Documents/Downloads/Music/Pictures | ALSA via PipeWire, ALSA sequencer, X11/XWayland, print, notifications, IME, accessibility | +| mpv | Isolated | `~/.config/mpv`, `~/.cache/mpv`, `~/.local/state/mpv`; media dirs read-only | MPRIS, file chooser, OpenURI | +| imv | None | `$HOME` read-only | File chooser, OpenURI | All browsers get read-only NSS certificates, printing, native notifications and accessibility. @@ -65,6 +81,18 @@ All browsers get read-only NSS certificates, printing, native notifications and - Bambu Studio's nixpkgs build omits the `device_page` frontend bundle, so filament management is unavailable. Its MakerWorld sidebar entry also requires the upstream `firstguide.finish = "true"` workaround; the startup splash may render black under XWayland. +- Remmina's SSH Tunnel support receives a read-only SSH agent socket and SSH client config. A + compromised Remmina can use the agent to sign or decrypt while the agent is running. +- OBS, Blender, MuseScore and mpv use pasta-isolated networking. Host loopback services are not + reachable unless the app is changed to a host-loopback exception. +- MuseScore receives the raw PipeWire socket so its ALSA driver can use the host `pcm.!default` + PipeWire plugin. PipeWire is a general media graph, so this is broader than a single audio stream: + a compromised MuseScore could enumerate other user-visible PipeWire media nodes. The alternative, + direct `/dev/snd`, confines the media graph but bypasses PipeWire device selection, per-app volume + and mixing. +- OBS on Linux defaults its recording path to `$HOME`. The sandbox intentionally binds only the + declared home subdirectories, so recording output must target `Videos`, `Pictures` or `Downloads`; + a file written to the home root exists only in the sandbox's transient mount namespace. ## Validation @@ -72,6 +100,12 @@ All browsers get read-only NSS certificates, printing, native notifications and - Interactive validation passed for Firefox, Chrome, Chromium, Brave Origin, Edge, QQ, Telegram, Zoom, Bambu Studio and OrcaSlicer. Chromium's host-loopback/personal-file exception and the Brave/Edge no-media policy are intentional. +- OBS Studio passed interactive validation for PipeWire screen capture, V4L2 camera capture, + desktop/microphone audio, local NVENC H.264 MP4 recording and local RTMP streaming. External + provider authentication and browser sources remain unvalidated. +- Remmina, Blender, mpv and imv build and pass the policy suite; interactive validation is still + pending. MuseScore passed launch, XWayland and ALSA-to-PipeWire routing checks; audible playback + and user workflows still need confirmation. - Chromium smoke test passed: renderer sandbox, environment clearing, bind scope, disposable-profile persistence and loopback policy. - WeChat uses the native nixpkgs-patched package with nixpak as the only bwrap layer. Build/policy diff --git a/hardening/nixpaks/blender.nix b/hardening/nixpaks/blender.nix new file mode 100644 index 00000000..7ebf48d5 --- /dev/null +++ b/hardening/nixpaks/blender.nix @@ -0,0 +1,54 @@ +{ + lib, + blender, + mkNixPak, + buildEnv, + makeDesktopItem, + runCommandLocal, + ... +}: +let + appId = "org.blender.Blender"; + wrapped = mkNixPak { + config = { + imports = [ ./modules/blender-policy.nix ]; + app = { + package = blender; + binPath = "bin/blender"; + }; + flatpak = { inherit appId; }; + }; + }; + exe = lib.getExe wrapped.config.script; + assets = runCommandLocal "blender-desktop-assets" { } '' + mkdir -p $out/share + ln -s ${blender}/share/icons $out/share/icons + ''; +in +buildEnv { + inherit (wrapped.config.script) name passthru; + meta = wrapped.config.script.meta // { + mainProgram = "blender"; + }; + paths = [ + wrapped.config.script + assets + (makeDesktopItem { + name = "blender"; + desktopName = "Blender"; + genericName = "3D Creation Suite"; + comment = "3D modeling, animation and rendering"; + exec = "${exe} %f"; + icon = "blender"; + terminal = false; + startupNotify = true; + startupWMClass = "Blender"; + categories = [ + "Graphics" + "3DGraphics" + ]; + mimeTypes = [ "application/x-blender" ]; + extraConfig.X-Flatpak = appId; + }) + ]; +} diff --git a/hardening/nixpaks/default.nix b/hardening/nixpaks/default.nix index 7b79d4f7..3c8e1e62 100644 --- a/hardening/nixpaks/default.nix +++ b/hardening/nixpaks/default.nix @@ -3,6 +3,7 @@ pkgs-master, pkgs-patched, pkgs-stable, + pkgs-blender, nixpak, ... }: @@ -32,6 +33,12 @@ in bambu-studio = wrapper pkgs-master ./bambu-studio.nix; orca-slicer = wrapper pkgs-master ./orca-slicer.nix; zoom = wrapper super ./zoom.nix; + remmina = wrapper super ./remmina.nix; + obs-studio = wrapper super ./obs-studio.nix; + blender = wrapper pkgs-blender ./blender.nix; + musescore = wrapper pkgs-stable ./musescore.nix; + mpv = wrapper super ./mpv.nix; + imv = wrapper super ./imv.nix; wechat = wrapper pkgs-patched ./wechat.nix; }; }) diff --git a/hardening/nixpaks/imv.nix b/hardening/nixpaks/imv.nix new file mode 100644 index 00000000..494b5dfa --- /dev/null +++ b/hardening/nixpaks/imv.nix @@ -0,0 +1,71 @@ +{ + lib, + pkgs, + imv, + mkNixPak, + buildEnv, + makeDesktopItem, + ... +}: +let + appId = "com.github.exec64.imv"; + wrapped = mkNixPak { + config = { + imports = [ ./modules/image-viewer-policy.nix ]; + app = { + package = imv; + binPath = "bin/imv"; + }; + flatpak = { inherit appId; }; + }; + }; + exe = lib.getExe wrapped.config.script; + # Match the upstream imv-dir helper: one file opens its containing directory; + # multiple arguments are passed through to imv unchanged. + imvDir = pkgs.writeShellScript "imv-dir" '' + if [ "$#" -ne 1 ]; then + exec ${exe} "$@" + fi + exec ${exe} -n "$1" "$(${pkgs.coreutils}/bin/dirname "$1")" + ''; + desktopItem = + name: desktopName: exec: + makeDesktopItem { + inherit name desktopName exec; + genericName = "Image Viewer"; + comment = "View images"; + icon = "multimedia-photo-viewer"; + terminal = false; + startupNotify = true; + startupWMClass = "imv"; + categories = [ + "Graphics" + "2DGraphics" + "Viewer" + ]; + mimeTypes = [ + "image/avif" + "image/bmp" + "image/gif" + "image/heif" + "image/jpeg" + "image/jxl" + "image/png" + "image/svg+xml" + "image/tiff" + "image/webp" + ]; + extraConfig.X-Flatpak = appId; + }; +in +buildEnv { + inherit (wrapped.config.script) name passthru; + meta = wrapped.config.script.meta // { + mainProgram = "imv"; + }; + paths = [ + wrapped.config.script + (desktopItem "imv" "imv" "${exe} %F") + (desktopItem "imv-dir" "imv-dir" "${imvDir} %F") + ]; +} diff --git a/hardening/nixpaks/modules/blender-policy.nix b/hardening/nixpaks/modules/blender-policy.nix new file mode 100644 index 00000000..e15eba0a --- /dev/null +++ b/hardening/nixpaks/modules/blender-policy.nix @@ -0,0 +1,40 @@ +# Blender needs GPU access, audio for the sequencer and network for extensions; +# user files are shared through common document directories and the portal. +{ + sloth, + ... +}: +{ + imports = [ + ./desktop-base.nix + ./network.nix + ]; + pasta.mode = "isolate"; + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + inputMethod = true; + }; + desktopIntegration = { + notifications = true; + accessibility = true; + }; + bubblewrap = { + sockets = { + x11 = true; + pulse = true; + }; + bind.rw = [ + (sloth.mkdir (sloth.concat' sloth.homeDir "/.config/blender")) + sloth.xdgDocumentsDir + sloth.xdgDownloadDir + sloth.xdgPicturesDir + sloth.xdgVideosDir + ]; + env = { + DISPLAY = sloth.envOr "DISPLAY" ""; + XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority"); + }; + }; +} diff --git a/hardening/nixpaks/modules/image-viewer-policy.nix b/hardening/nixpaks/modules/image-viewer-policy.nix new file mode 100644 index 00000000..bf800719 --- /dev/null +++ b/hardening/nixpaks/modules/image-viewer-policy.nix @@ -0,0 +1,17 @@ +# Minimal local image viewer. The whole home is read-only so images can be +# opened from any user directory; network, audio and devices stay unavailable. +{ + sloth, + ... +}: +{ + imports = [ ./desktop-base.nix ]; + portalGroups = { + fileChooser = true; + openUri = true; + }; + bubblewrap = { + sockets.pulse = false; + bind.ro = [ sloth.homeDir ]; + }; +} diff --git a/hardening/nixpaks/modules/mpv-policy.nix b/hardening/nixpaks/modules/mpv-policy.nix new file mode 100644 index 00000000..9c63545b --- /dev/null +++ b/hardening/nixpaks/modules/mpv-policy.nix @@ -0,0 +1,34 @@ +# mpv policy: media directories are read-only, playback state and user config +# remain writable, and MPRIS is the only desktop service it owns. +{ + sloth, + ... +}: +{ + imports = [ + ./desktop-base.nix + ./network.nix + ]; + pasta.mode = "isolate"; + portalGroups = { + fileChooser = true; + openUri = true; + }; + desktopIntegration.mpris = true; + bubblewrap = { + sockets.pulse = true; + bind.rw = [ + (sloth.mkdir (sloth.concat' sloth.homeDir "/.config/mpv")) + (sloth.mkdir (sloth.concat' sloth.homeDir "/.cache/mpv")) + (sloth.mkdir (sloth.concat' sloth.homeDir "/.local/state/mpv")) + ]; + bind.ro = [ + sloth.xdgDocumentsDir + sloth.xdgDownloadDir + sloth.xdgMusicDir + sloth.xdgPicturesDir + sloth.xdgVideosDir + (sloth.concat' sloth.homeDir "/.config/yt-dlp") + ]; + }; +} diff --git a/hardening/nixpaks/modules/musescore-policy.nix b/hardening/nixpaks/modules/musescore-policy.nix new file mode 100644 index 00000000..04d28315 --- /dev/null +++ b/hardening/nixpaks/modules/musescore-policy.nix @@ -0,0 +1,58 @@ +# MuseScore policy: scores and exports use common user directories. Audio uses +# ALSA via the host PipeWire plugin, MIDI uses the ALSA sequencer, and the UI +# runs through XWayland. +{ + lib, + sloth, + ... +}: +{ + imports = [ + ./desktop-base.nix + ./network.nix + ]; + pasta.mode = "isolate"; + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + print = true; + inputMethod = true; + }; + desktopIntegration = { + notifications = true; + accessibility = true; + }; + bubblewrap = { + sockets = { + # MuseScore 4's nixpkgs launcher selects Qt's XCB backend. Niri provides + # that through XWayland, so expose the X11 socket and credentials. + x11 = true; + # MuseScore speaks ALSA; the host ALSA configuration forwards its + # default PCM to PipeWire, so expose the PipeWire socket for audio. + pulse = lib.mkForce false; + pipewire = lib.mkForce true; + }; + bind.rw = [ + (sloth.mkdir (sloth.concat' sloth.homeDir "/.config/MuseScore")) + (sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/MuseScore")) + sloth.xdgDocumentsDir + sloth.xdgDownloadDir + sloth.xdgMusicDir + sloth.xdgPicturesDir + ]; + # Use the host's ALSA configuration, which routes the default PCM + # through PipeWire. Only the sequencer node is needed for MIDI. + bind.ro = [ + "/etc/alsa" + "/etc/static/alsa" + ]; + bind.dev = [ "/dev/snd/seq" ]; + env = { + DISPLAY = sloth.envOr "DISPLAY" ""; + XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority"); + QT_QPA_PLATFORM = "xcb"; + MUSESCORE_FORCE_ALSA = "1"; + }; + }; +} diff --git a/hardening/nixpaks/modules/obs-policy.nix b/hardening/nixpaks/modules/obs-policy.nix new file mode 100644 index 00000000..def07fb2 --- /dev/null +++ b/hardening/nixpaks/modules/obs-policy.nix @@ -0,0 +1,60 @@ +# OBS capture policy: screen capture stays on the XDG portal, audio goes +# through PulseAudio/PipeWire-Pulse, and network access is pasta-isolated. +# OBS's V4L2 source opens camera device nodes directly instead of using the +# Camera portal, so grant the same raw video devices as Zoom. +{ + sloth, + ... +}: +{ + imports = [ + ./desktop-base.nix + ./network.nix + ]; + pasta.mode = "isolate"; + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + screenCast = true; + camera = true; + inhibit = true; + inputMethod = true; + }; + desktopIntegration = { + notifications = true; + tray = true; + accessibility = true; + }; + bubblewrap = { + sockets = { + x11 = true; + pulse = true; + }; + bind.rw = [ + (sloth.mkdir (sloth.concat' sloth.homeDir "/.config/obs-studio")) + sloth.xdgVideosDir + sloth.xdgPicturesDir + sloth.xdgDownloadDir + ]; + # OBS enumerates DRM connectors when building its encoder list, and its + # V4L2 source discovers cameras by reading /sys/class/video4linux before + # opening the corresponding /dev/video* nodes. + bind.ro = [ + "/sys/class/drm" + "/sys/class/video4linux" + ]; + bind.dev = [ + "/dev/video0" + "/dev/video1" + ]; + env = { + DISPLAY = sloth.envOr "DISPLAY" ""; + XAUTHORITY = sloth.envOr "XAUTHORITY" (sloth.concat' sloth.homeDir "/.Xauthority"); + # OBS's NVENC plugin dlopen()s libnvidia-encode.so.1 by soname after + # startup. Nixpak exposes the host driver under /run/opengl-driver but + # clears the host environment, so add it to the loader search path. + LD_LIBRARY_PATH = "/run/opengl-driver/lib"; + }; + }; +} diff --git a/hardening/nixpaks/modules/remote-desktop-policy.nix b/hardening/nixpaks/modules/remote-desktop-policy.nix new file mode 100644 index 00000000..84326124 --- /dev/null +++ b/hardening/nixpaks/modules/remote-desktop-policy.nix @@ -0,0 +1,48 @@ +# Shared policy for graphical remote-desktop clients. No raw camera, video or +# PipeWire access; SSH tunnels use the user's agent socket explicitly. +{ + config, + lib, + sloth, + ... +}: +let + cfg = config.remoteDesktop; +in +{ + imports = [ + ./desktop-base.nix + ./network.nix + ]; + options.remoteDesktop.sshAgent = lib.mkEnableOption "the user's SSH agent and SSH client config"; + config = { + pasta.mode = "isolate"; + portalGroups = { + fileChooser = true; + openUri = true; + notifications = true; + inputMethod = true; + }; + desktopIntegration = { + notifications = true; + accessibility = true; + }; + bubblewrap = { + sockets.pulse = true; + bind.rw = [ + (sloth.mkdir (sloth.concat' sloth.homeDir "/.config/remmina")) + (sloth.mkdir (sloth.concat' sloth.homeDir "/.local/share/remmina")) + (sloth.mkdir (sloth.concat' sloth.homeDir "/.remmina")) + sloth.xdgDownloadDir + ]; + bind.ro = lib.optionals cfg.sshAgent [ + (sloth.concat' sloth.homeDir "/.ssh/config") + (sloth.concat' sloth.homeDir "/.ssh/known_hosts") + (sloth.concat' sloth.runtimeDir "/ssh-agent") + ]; + env = lib.optionalAttrs cfg.sshAgent { + SSH_AUTH_SOCK = sloth.concat' sloth.runtimeDir "/ssh-agent"; + }; + }; + }; +} diff --git a/hardening/nixpaks/mpv.nix b/hardening/nixpaks/mpv.nix new file mode 100644 index 00000000..1b62594d --- /dev/null +++ b/hardening/nixpaks/mpv.nix @@ -0,0 +1,64 @@ +{ + lib, + mpv, + mpvScripts, + mkNixPak, + buildEnv, + makeDesktopItem, + ... +}: +let + appId = "io.mpv.Mpv"; + package = mpv.override { scripts = [ mpvScripts.mpris ]; }; + wrapped = mkNixPak { + config = { + imports = [ ./modules/mpv-policy.nix ]; + app = { + inherit package; + binPath = "bin/mpv"; + }; + flatpak = { inherit appId; }; + }; + }; + exe = lib.getExe wrapped.config.script; +in +buildEnv { + inherit (wrapped.config.script) name passthru; + meta = wrapped.config.script.meta // { + mainProgram = "mpv"; + }; + paths = [ + wrapped.config.script + (makeDesktopItem { + name = "mpv"; + desktopName = "mpv Media Player"; + genericName = "Media Player"; + comment = "Play audio and video files"; + exec = "${exe} --player-operation-mode=pseudo-gui -- %U"; + icon = "mpv"; + terminal = false; + startupNotify = true; + startupWMClass = "mpv"; + categories = [ + "AudioVideo" + "Audio" + "Video" + "Player" + ]; + mimeTypes = [ + "application/ogg" + "application/x-matroska" + "audio/flac" + "audio/mpeg" + "audio/ogg" + "audio/wav" + "video/mp4" + "video/quicktime" + "video/webm" + "video/x-matroska" + "video/x-msvideo" + ]; + extraConfig.X-Flatpak = appId; + }) + ]; +} diff --git a/hardening/nixpaks/musescore.nix b/hardening/nixpaks/musescore.nix new file mode 100644 index 00000000..07652ddf --- /dev/null +++ b/hardening/nixpaks/musescore.nix @@ -0,0 +1,64 @@ +{ + lib, + musescore, + mkNixPak, + buildEnv, + makeDesktopItem, + runCommandLocal, + ... +}: +let + appId = "org.musescore.MuseScore"; + wrapped = mkNixPak { + config = { + imports = [ ./modules/musescore-policy.nix ]; + app = { + package = musescore; + binPath = "bin/mscore"; + }; + flatpak = { inherit appId; }; + }; + }; + exe = lib.getExe wrapped.config.script; + assets = runCommandLocal "musescore-desktop-assets" { } '' + mkdir -p $out/share + ln -s ${musescore}/share/icons $out/share/icons + ''; +in +buildEnv { + inherit (wrapped.config.script) name passthru; + meta = wrapped.config.script.meta // { + mainProgram = "mscore"; + }; + paths = [ + wrapped.config.script + assets + (makeDesktopItem { + name = appId; + desktopName = "MuseScore Studio"; + genericName = "Music Notation"; + comment = "Create, play and print sheet music"; + exec = "${exe} %U"; + icon = "mscore"; + terminal = false; + startupNotify = true; + startupWMClass = "MuseScore4"; + categories = [ + "AudioVideo" + "Audio" + "Graphics" + "Midi" + "Music" + ]; + mimeTypes = [ + "application/x-musescore" + "application/x-musescore+xml" + "application/vnd.recordare.musicxml" + "application/vnd.recordare.musicxml+xml" + "audio/midi" + "x-scheme-handler/musescore" + ]; + extraConfig.X-Flatpak = appId; + }) + ]; +} diff --git a/hardening/nixpaks/obs-studio.nix b/hardening/nixpaks/obs-studio.nix new file mode 100644 index 00000000..9e8850f7 --- /dev/null +++ b/hardening/nixpaks/obs-studio.nix @@ -0,0 +1,70 @@ +{ + lib, + obs-studio, + obs-studio-plugins, + wrapOBS, + mkNixPak, + buildEnv, + makeDesktopItem, + runCommandLocal, + ... +}: +let + appId = "com.obsproject.Studio"; + package = wrapOBS.override { inherit obs-studio; } { + plugins = with obs-studio-plugins; [ + # Wayland screen-capture fallback, per-application audio, and game capture. + wlrobs + obs-pipewire-audio-capture + obs-vkcapture + # Widely used scene, overlay and streaming plugins. + input-overlay + obs-multi-rtmp + obs-shaderfilter + obs-move-transition + obs-backgroundremoval + advanced-scene-switcher + ]; + }; + wrapped = mkNixPak { + config = { + imports = [ ./modules/obs-policy.nix ]; + app = { + inherit package; + binPath = "bin/obs"; + }; + flatpak = { inherit appId; }; + }; + }; + exe = lib.getExe wrapped.config.script; + assets = runCommandLocal "obs-studio-desktop-assets" { } '' + mkdir -p $out/share + ln -s ${package}/share/icons $out/share/icons + ''; +in +buildEnv { + inherit (wrapped.config.script) name passthru; + meta = wrapped.config.script.meta // { + mainProgram = "obs"; + }; + paths = [ + wrapped.config.script + assets + (makeDesktopItem { + name = appId; + desktopName = "OBS Studio"; + genericName = "Screen Recorder"; + comment = "Record and stream the desktop"; + exec = "${exe} %U"; + icon = appId; + terminal = false; + startupNotify = true; + startupWMClass = "obs"; + categories = [ + "AudioVideo" + "Recorder" + ]; + extraConfig.X-Flatpak = appId; + }) + ]; +} diff --git a/hardening/nixpaks/remmina.nix b/hardening/nixpaks/remmina.nix new file mode 100644 index 00000000..eb5e16e4 --- /dev/null +++ b/hardening/nixpaks/remmina.nix @@ -0,0 +1,63 @@ +{ + lib, + remmina, + mkNixPak, + buildEnv, + makeDesktopItem, + runCommandLocal, + ... +}: +let + appId = "org.remmina.Remmina"; + wrapped = mkNixPak { + config = { + imports = [ ./modules/remote-desktop-policy.nix ]; + app = { + package = remmina; + binPath = "bin/remmina"; + }; + flatpak = { inherit appId; }; + # Preserve the SSH Tunnel tab used for localhost VNC access. + remoteDesktop.sshAgent = true; + }; + }; + exe = lib.getExe wrapped.config.script; + assets = runCommandLocal "remmina-desktop-assets" { } '' + mkdir -p $out/share + ln -s ${remmina}/share/icons $out/share/icons + ''; +in +buildEnv { + inherit (wrapped.config.script) name passthru; + meta = wrapped.config.script.meta // { + mainProgram = "remmina"; + }; + paths = [ + wrapped.config.script + assets + (makeDesktopItem { + name = appId; + desktopName = "Remmina"; + genericName = "Remote Desktop Client"; + comment = "Connect to RDP, VNC, SPICE and SSH sessions"; + exec = "${exe} %U"; + icon = appId; + terminal = false; + startupNotify = true; + startupWMClass = appId; + categories = [ + "GTK" + "Network" + ]; + mimeTypes = [ + "application/x-remmina" + "x-scheme-handler/rdp" + "x-scheme-handler/spice" + "x-scheme-handler/vnc" + "x-scheme-handler/ssh" + "x-scheme-handler/remmina" + ]; + extraConfig.X-Flatpak = appId; + }) + ]; +} diff --git a/hardening/tests/policy-lib.nix b/hardening/tests/policy-lib.nix index aa89ddc9..23a1b264 100644 --- a/hardening/tests/policy-lib.nix +++ b/hardening/tests/policy-lib.nix @@ -72,11 +72,11 @@ let throw "unknown network mode for ${app.flatpak.appId}: ${mode}"; mediaCheck = - app: expected: + app: expected: allowPipewire: if expected then app.portalGroups.camera && app.portalGroups.screenCast else - !app.bubblewrap.sockets.pipewire + (!app.bubblewrap.sockets.pipewire || allowPipewire) && !app.portalGroups.camera && !app.portalGroups.screenCast && !(hasDbusFragment app "org.freedesktop.portal.Camera") @@ -113,7 +113,7 @@ let checks = { "${name}/clearEnv" = app.bubblewrap.clearEnv; "${name}/dbus" = app.dbus.enable; - "${name}/noRawPipewire" = !app.bubblewrap.sockets.pipewire; + "${name}/noRawPipewire" = !app.bubblewrap.sockets.pipewire || (spec.allowPipewire or false); "${name}/noForbiddenDbus" = !lib.any forbiddenDbus app.dbus.args; "${name}/noBroadKeyring" = !lib.any (argument: argument == "--talk=org.freedesktop.secrets") app.dbus.args; @@ -123,7 +123,7 @@ let "${name}/privateTmp" = hasPrivateTmp app; "${name}/network" = networkCheck app spec.network; "${name}/x11" = app.bubblewrap.sockets.x11 == (spec.x11 or false); - "${name}/media" = mediaCheck app (spec.media or false); + "${name}/media" = mediaCheck app (spec.media or false) (spec.allowPipewire or false); "${name}/pulse" = app.bubblewrap.sockets.pulse == (spec.pulse or (spec.media or false)); "${name}/mpris" = app.desktopIntegration.mpris == (spec.mpris or false); "${name}/notifications" = app.desktopIntegration.notifications == (spec.notifications or true); diff --git a/hardening/tests/policy/blender.nix b/hardening/tests/policy/blender.nix new file mode 100644 index 00000000..1a1a41dc --- /dev/null +++ b/hardening/tests/policy/blender.nix @@ -0,0 +1,21 @@ +{ + load, + mkAppPolicy, + ... +}: +let + app = load "blender"; +in +mkAppPolicy "blender" { + network = "pasta"; + media = false; + pulse = true; + x11 = true; + notifications = true; + extraChecks = { + fileChooser = app.portalGroups.fileChooser; + openUri = app.portalGroups.openUri; + inputMethod = app.portalGroups.inputMethod; + accessibility = app.desktopIntegration.accessibility; + }; +} diff --git a/hardening/tests/policy/imv.nix b/hardening/tests/policy/imv.nix new file mode 100644 index 00000000..f97b2eec --- /dev/null +++ b/hardening/tests/policy/imv.nix @@ -0,0 +1,20 @@ +{ + load, + mkAppPolicy, + ... +}: +let + app = load "imv"; +in +mkAppPolicy "imv" { + network = "none"; + media = false; + pulse = false; + x11 = false; + notifications = false; + extraChecks = { + fileChooser = app.portalGroups.fileChooser; + openUri = app.portalGroups.openUri; + noAudio = !app.bubblewrap.sockets.pulse; + }; +} diff --git a/hardening/tests/policy/mpv.nix b/hardening/tests/policy/mpv.nix new file mode 100644 index 00000000..03fd53ec --- /dev/null +++ b/hardening/tests/policy/mpv.nix @@ -0,0 +1,20 @@ +{ + load, + mkAppPolicy, + ... +}: +let + app = load "mpv"; +in +mkAppPolicy "mpv" { + network = "pasta"; + media = false; + pulse = true; + x11 = false; + mpris = true; + notifications = false; + extraChecks = { + fileChooser = app.portalGroups.fileChooser; + openUri = app.portalGroups.openUri; + }; +} diff --git a/hardening/tests/policy/musescore.nix b/hardening/tests/policy/musescore.nix new file mode 100644 index 00000000..9a74b901 --- /dev/null +++ b/hardening/tests/policy/musescore.nix @@ -0,0 +1,29 @@ +{ + load, + mkAppPolicy, + ... +}: +let + app = load "musescore"; +in +mkAppPolicy "musescore" { + network = "pasta"; + media = false; + pulse = false; + allowPipewire = true; + x11 = true; + notifications = true; + extraChecks = { + fileChooser = app.portalGroups.fileChooser; + print = app.portalGroups.print; + inputMethod = app.portalGroups.inputMethod; + accessibility = app.desktopIntegration.accessibility; + qtXcb = app.bubblewrap.env.QT_QPA_PLATFORM == "xcb"; + alsaSequencer = builtins.elem "/dev/snd/seq" app.bubblewrap.bind.dev; + hostAlsaConfig = + builtins.elem "/etc/alsa" app.bubblewrap.bind.ro + && builtins.elem "/etc/static/alsa" app.bubblewrap.bind.ro; + pipewireAudio = app.bubblewrap.sockets.pipewire; + forceAlsa = app.bubblewrap.env.MUSESCORE_FORCE_ALSA == "1"; + }; +} diff --git a/hardening/tests/policy/obs-studio.nix b/hardening/tests/policy/obs-studio.nix new file mode 100644 index 00000000..ca76aef8 --- /dev/null +++ b/hardening/tests/policy/obs-studio.nix @@ -0,0 +1,34 @@ +{ + lib, + load, + mkAppPolicy, + ... +}: +let + app = load "obs-studio"; +in +mkAppPolicy "obs-studio" { + network = "pasta"; + media = true; + pulse = true; + x11 = true; + allowedVideoDevices = [ + "/dev/video0" + "/dev/video1" + ]; + notifications = true; + tray = true; + extraChecks = { + fileChooser = app.portalGroups.fileChooser; + screenCast = app.portalGroups.screenCast; + camera = app.portalGroups.camera; + inhibit = app.portalGroups.inhibit; + accessibility = app.desktopIntegration.accessibility; + videoDevices = lib.all (dev: lib.elem dev app.bubblewrap.bind.dev) [ + "/dev/video0" + "/dev/video1" + ]; + video4linuxSysfs = lib.elem "/sys/class/video4linux" app.bubblewrap.bind.ro; + nvencLibraryPath = app.bubblewrap.env.LD_LIBRARY_PATH == "/run/opengl-driver/lib"; + }; +} diff --git a/hardening/tests/policy/remmina.nix b/hardening/tests/policy/remmina.nix new file mode 100644 index 00000000..b8ee7d73 --- /dev/null +++ b/hardening/tests/policy/remmina.nix @@ -0,0 +1,22 @@ +{ + load, + mkAppPolicy, + ... +}: +let + app = load "remmina"; +in +mkAppPolicy "remmina" { + network = "pasta"; + media = false; + pulse = true; + x11 = false; + notifications = true; + extraChecks = { + fileChooser = app.portalGroups.fileChooser; + openUri = app.portalGroups.openUri; + inputMethod = app.portalGroups.inputMethod; + accessibility = app.desktopIntegration.accessibility; + sshAgent = app.remoteDesktop.sshAgent; + }; +} diff --git a/hardening/tests/runtime-validation.md b/hardening/tests/runtime-validation.md index e354df91..bb8076b3 100644 --- a/hardening/tests/runtime-validation.md +++ b/hardening/tests/runtime-validation.md @@ -26,6 +26,12 @@ Nixpak wrappers are accepted in three layers: | Krita | Pass | Interactive validation pending | | Bambu Studio | Pass | Interactive pass; device_page/filament UI unavailable in nixpkgs | | OrcaSlicer | Pass | Interactive pass; blank WebKit home page fixed | +| Remmina | Pass | Interactive validation pending | +| OBS Studio | Pass | Interactive pass; NVENC recording and local RTMP streaming work | +| Blender | Pass | Interactive validation pending | +| MuseScore | Pass | Interactive launch pass; ALSA routed to default PipeWire sink | +| mpv | Pass | Interactive validation pending | +| imv | Pass | Interactive validation pending | Intentional exceptions: @@ -45,6 +51,10 @@ Known packaging limitations: `BambuStudio.conf`; the upstream guide writes `"1"` while the web-view code only accepts `"true"`. - **Bambu Studio:** the startup splash can render black under XWayland. This is cosmetic; the main UI renders normally. +- **MuseScore:** uses Qt's XCB backend, so it runs through XWayland. Its ALSA driver uses the host + `pcm.!default` configuration, which forwards to PipeWire; this requires the raw PipeWire socket. + Launch and audio routing pass, but audible playback and score/print/export workflows still need + user confirmation. ## Browser smoke test @@ -85,6 +95,18 @@ Zoom keeps its upstream FHS runtime inside the outer nixpak boundary. The nested shared-display trust boundary: ScreenCast portal permission does not prevent capture through the X11 connection. Interactive login, audio, camera, screen sharing and file workflows passed. +## OBS + +Interactive validation covered PipeWire screen capture, V4L2 camera capture, desktop/microphone +audio, NVENC H.264 Hybrid MP4 recording and RTMP publishing. Streaming was tested against a +temporary MediaMTX server on the host's LAN address; the OBS pasta namespace cannot reach host +loopback. Bilibili or another external provider still needs a real account-specific stream key to +validate provider authentication and CDN ingest. + +OBS records to the path configured under output settings. In Simple mode that is +`SimpleOutput.FilePath`; Advanced mode uses `AdvOut.RecFilePath` and `AdvOut.FFFilePath`. These must +point at a writable bind such as `~/Videos`, not the sandbox's transient home root. + ## Commands ```bash diff --git a/home/linux/gui/base/creative.nix b/home/linux/gui/base/creative.nix index 0572f3b5..50cc56a3 100644 --- a/home/linux/gui/base/creative.nix +++ b/home/linux/gui/base/creative.nix @@ -1,49 +1,12 @@ +{ pkgs, ... }: { - pkgs, - pkgs-stable, - pkgs-blender, - ... -}: -{ - home.packages = with pkgs-stable; [ + home.packages = with pkgs; [ # creative - pkgs.nixpaks.krita # digital painting - musescore # music notation - pkgs.nixpaks.orca-slicer # 3d printer slicer app - pkgs.nixpaks.bambu-studio # bambu 3d printer slicer app - pkgs-blender.blender # 3d modeling + nixpaks.krita # digital painting + nixpaks.musescore # music notation + nixpaks.orca-slicer # 3d printer slicer app + nixpaks.bambu-studio # bambu 3d printer slicer app + nixpaks.blender # 3d modeling + nixpaks.obs-studio # screen recording and streaming; plugins are bundled by the wrapper ]; - - programs = { - # live streaming - obs-studio = { - enable = pkgs.stdenv.hostPlatform.isx86_64; - plugins = with pkgs.obs-studio-plugins; [ - # screen capture - wlrobs - # obs-ndi - # obs-nvfbc - # obs-teleport - # obs-hyperion - # droidcam-obs - # obs-vkcapture - obs-gstreamer - input-overlay - obs-multi-rtmp - obs-source-clone - # obs-shaderfilter - # obs-source-record - # obs-livesplit-one - # looking-glass-obs - # obs-vintage-filter - # obs-command-source - # obs-move-transition - # obs-backgroundremoval - # advanced-scene-switcher - obs-pipewire-audio-capture - obs-vaapi - # obs-3d-effect - ]; - }; - }; } diff --git a/home/linux/gui/base/media.nix b/home/linux/gui/base/media.nix index e140ed94..01ff6fc6 100644 --- a/home/linux/gui/base/media.nix +++ b/home/linux/gui/base/media.nix @@ -12,7 +12,7 @@ pavucontrol playerctl pulsemixer - imv # simple image viewer + pkgs.nixpaks.imv # simple image viewer # music go-musicfox # TUI NetEase Cloud Music client; plays through mpv (configured below) @@ -30,8 +30,8 @@ programs.mpv = { enable = true; + package = pkgs.nixpaks.mpv; defaultProfiles = [ "gpu-hq" ]; - scripts = [ pkgs.mpvScripts.mpris ]; }; services = { diff --git a/home/linux/gui/base/misc.nix b/home/linux/gui/base/misc.nix index 82b598b6..e343486d 100644 --- a/home/linux/gui/base/misc.nix +++ b/home/linux/gui/base/misc.nix @@ -9,9 +9,8 @@ # do not support .pdf nixpaks.foliate - # remote desktop(rdp connect) - remmina - freerdp # required by remmina + # remote desktop (RDP/VNC/SPICE); FreeRDP is a Remmina runtime dependency + nixpaks.remmina # my custom hardened packages nixpaks.qq