NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

commit f1ac976a12d234389725a84023668613c156442f
parent db287114ebdd6e6019c4fa1dddd535aa6f62e6ca
Author: DAEMON <zer0sec.xp@icloud.com>
Date:   Fri,  9 Oct 2026 07:44:44 +0100

feat: add Zen browser, patched Gradia screenshots, and default apps

- Add zen.nix next to Floorp, sharing the encrypted bookmarks export; Floorp stays the default browser
- Route Print and Shift+Print into Gradia, and bind Ctrl+Print to the region-to-clipboard grab
- Patch Gradia's censor tool into a secure blur whose size slider sets strength (upstream pixelates in fixed 8 px blocks)
- Set zathura for PDFs and Loupe for images via xdg.mimeApps, and source hm-session-vars.sh from the zsh shim since programs.zsh is off
- Add git-crypt, git-remote-gcrypt and glab for the NetrunnerVault sync tooling

Commit-Date: 2026-10-09T07:44:52+01:00
Commit-Host: daemonsec@nixos

Diffstat:
Mflake.lock | 26+++++++++++++++++++++++++-
Mflake.nix | 8++++++++
Amodules/features/desktop/_gradia-secure-blur.patch | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmodules/features/desktop/niri.nix | 28+++++++++++++++++++++++++---
Mmodules/home/default.nix | 1+
Mmodules/home/session.nix | 34+++++++++++++++++++++++++++++++---
Mmodules/home/shell.nix | 4++++
Mmodules/home/tools.nix | 11+++++++++++
Amodules/home/zen.nix | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 340 insertions(+), 7 deletions(-)

diff --git a/flake.lock b/flake.lock @@ -921,7 +921,8 @@ "nvf": "nvf", "sops-nix": "sops-nix", "stylix": "stylix", - "wrapper-modules": "wrapper-modules" + "wrapper-modules": "wrapper-modules", + "zen-browser": "zen-browser" } }, "sops-nix": { @@ -1177,6 +1178,29 @@ "repo": "xdg-desktop-portal-hyprland", "type": "github" } + }, + "zen-browser": { + "inputs": { + "home-manager": [ + "home-manager" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791521685, + "narHash": "sha256-S4ZBR4y3cQ6NbLDJqoQR48718TWCM1ofpjt8Mzo83Hg=", + "owner": "0xc000022070", + "repo": "zen-browser-flake", + "rev": "841dba01deda5e994a9838da9d2216f493fbaea9", + "type": "github" + }, + "original": { + "owner": "0xc000022070", + "repo": "zen-browser-flake", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix @@ -55,6 +55,14 @@ inputs.nixpkgs.follows = "nixpkgs"; }; + # Zen Browser, which is not in nixpkgs (modules/home/zen.nix). + # `packages.default` is the general release channel; `twilight` is nightly. + zen-browser = { + url = "github:0xc000022070/zen-browser-flake"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.home-manager.follows = "home-manager"; + }; + # Neovim, configured in Nix (modules/home/neovim.nix). nvf = { url = "github:notashelf/nvf"; diff --git a/modules/features/desktop/_gradia-secure-blur.patch b/modules/features/desktop/_gradia-secure-blur.patch @@ -0,0 +1,132 @@ +--- a/gradia/backend/tool_config.py ++++ b/gradia/backend/tool_config.py +@@ -344,7 +344,7 @@ + icon="checkerboard-big-symbolic", + column=3, + row=1, +- has_scale=False, ++ has_scale=True, + has_primary_color=False, + ), + ToolConfig( +--- a/gradia/overlay/drawing_actions.py ++++ b/gradia/overlay/drawing_actions.py +@@ -21,6 +21,7 @@ + from gi.repository import Gtk, Gdk, Gio, Pango, PangoCairo, GdkPixbuf + from enum import Enum + import math ++import random + from gradia.backend.logger import Logger + from gradia.utils.colors import has_visible_color + import time +@@ -640,19 +641,25 @@ + + + class CensorAction(RectAction): ++ # Secure blur: the area is averaged down to cells (4x the size slider, in ++ # screen pixels), every cell gets random noise, then it is scaled back up ++ # smoothly. Averaging throws the detail away; the noise means the cells no ++ # longer equal the true averages, which is what depixelation tools match on. ++ NOISE_SIGMA = 10 ++ + def __init__(self, start: tuple[int, int], end: tuple[int, int], background_pixbuf: GdkPixbuf.Pixbuf, options): + super().__init__(start, end, False, options) + + self.original_scale = 1.0 +- self.base_block_size = 8 + self.background_pixbuf = background_pixbuf ++ self.noise_seed = random.SystemRandom().getrandbits(64) + + def set_original_scale(self, scale: float): + self.original_scale = scale + + def _get_scaled_block_size(self, current_scale: float) -> float: + scale_ratio = current_scale / self.original_scale +- return self.base_block_size * scale_ratio ++ return max(3, self.options.size) * 4 * scale_ratio + + def draw(self, cr: cairo.Context, image_to_widget_coords: Callable[[int, int], tuple[float, float]], scale: float): + crop = self._get_image_crop() +@@ -675,37 +682,59 @@ + return + + scaled_block_size = self._get_scaled_block_size(scale) +- self._draw_pixelation(cr, crop, x, y, width, height, scaled_block_size) ++ self._draw_secure_blur(cr, crop, x, y, width, height, scaled_block_size) ++ ++ def _draw_secure_blur(self, cr: cairo.Context, crop: dict, x: float, y: float, width: float, height: float, block_size: float): ++ blocks_x = max(2, round(width / block_size)) ++ blocks_y = max(2, round(height / block_size)) ++ ++ cells = cairo.ImageSurface(cairo.FORMAT_ARGB32, blocks_x, blocks_y) ++ cells_cr = cairo.Context(cells) ++ cells_cr.scale(blocks_x / crop['width'], blocks_y / crop['height']) ++ cells_cr.translate(-crop['x'], -crop['y']) ++ Gdk.cairo_set_source_pixbuf(cells_cr, self.background_pixbuf, 0, 0) ++ cells_cr.get_source().set_filter(cairo.FILTER_GOOD) # box-average when shrinking ++ cells_cr.paint() ++ cells.flush() ++ self._add_noise(cells) ++ cells.mark_dirty() ++ ++ # Two smooth upscales (cells -> 4x -> area) read as a blur, not a mosaic. ++ mid_w, mid_h = blocks_x * 4, blocks_y * 4 ++ mid = cairo.ImageSurface(cairo.FORMAT_ARGB32, mid_w, mid_h) ++ mid_cr = cairo.Context(mid) ++ mid_cr.scale(4, 4) ++ self._set_smooth_source(mid_cr, cells) ++ mid_cr.paint() + +- def _draw_pixelation(self, cr: cairo.Context, crop: dict, x: float, y: float, width: float, height: float, block_size: float): + cr.save() + cr.rectangle(x, y, width, height) + cr.clip() +- +- blocks_x = max(1, int(width / block_size)) +- blocks_y = max(1, int(height / block_size)) +- +- tiny_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, blocks_x, blocks_y) +- tiny_cr = cairo.Context(tiny_surface) +- +- tiny_cr.set_operator(cairo.OPERATOR_CLEAR) +- tiny_cr.paint() +- tiny_cr.set_operator(cairo.OPERATOR_OVER) +- +- tiny_cr.scale(blocks_x / crop['width'], blocks_y / crop['height']) +- tiny_cr.translate(-crop['x'], -crop['y']) +- Gdk.cairo_set_source_pixbuf(tiny_cr, self.background_pixbuf, 0, 0) +- tiny_cr.paint() +- + cr.translate(x, y) +- cr.scale(width / blocks_x, height / blocks_y) +- pattern = cairo.SurfacePattern(tiny_surface) +- pattern.set_filter(cairo.FILTER_NEAREST) +- cr.set_source(pattern) ++ cr.scale(width / mid_w, height / mid_h) ++ self._set_smooth_source(cr, mid) + cr.paint() +- + cr.restore() + ++ @staticmethod ++ def _set_smooth_source(cr: cairo.Context, surface: cairo.ImageSurface): ++ pattern = cairo.SurfacePattern(surface) ++ pattern.set_filter(cairo.FILTER_BILINEAR) ++ pattern.set_extend(cairo.EXTEND_PAD) # no see-through fringe at the edges ++ cr.set_source(pattern) ++ ++ def _add_noise(self, surface: cairo.ImageSurface): ++ rng = random.Random(self.noise_seed) # fixed per box, so it does not flicker ++ data = surface.get_data() ++ stride = surface.get_stride() ++ for row in range(surface.get_height()): ++ for col in range(surface.get_width()): ++ i = row * stride + col * 4 ++ alpha = data[i + 3] ++ shift = rng.gauss(0, self.NOISE_SIGMA) # brightness only, so it reads as grain ++ for c in range(3): # premultiplied B, G, R stay within alpha ++ data[i + c] = min(alpha, max(0, round(data[i + c] + shift))) ++ + def _get_image_crop(self) -> dict | None: + img_w, img_h = self.background_pixbuf.get_width(), self.background_pixbuf.get_height() + x1 = int(self.start[0] + img_w / 2) diff --git a/modules/features/desktop/niri.nix b/modules/features/desktop/niri.nix @@ -21,6 +21,7 @@ nautilus = lib.getExe pkgs.nautilus; grim = lib.getExe pkgs.grim; slurp = lib.getExe pkgs.slurp; + gradia = lib.getExe self'.packages.gradia; wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy"; wpctl = "${pkgs.wireplumber}/bin/wpctl"; brightnessctl = lib.getExe pkgs.brightnessctl; @@ -43,6 +44,20 @@ }) { } (lib.range 1 9); in { + # Gradia, the screenshot editor the Print binds open, with its Censor tool + # replaced by a secure blur (_gradia-secure-blur.patch). Upstream + # pixelates in fixed 8 px blocks with the size slider greyed out, which + # leaves large text readable. Patched, the slider sets the strength: the + # area is averaged into cells 4× the slider value (12–100 px, 56 by + # default), each cell gets random brightness noise so depixelation tools + # can't match the true averages, and it is scaled back up smoothly. + # Still not zero-leak: for secrets, a filled rectangle is the safe choice. + # Shared with home-manager (modules/home/session.nix) so the launcher + # entry runs the same build. + packages.gradia = pkgs.gradia.overrideAttrs (old: { + patches = (old.patches or [ ]) ++ [ ./_gradia-secure-blur.patch ]; + }); + packages.niri = inputs.wrapper-modules.wrappers.niri.wrap { inherit pkgs; settings = { @@ -157,9 +172,16 @@ "Mod+O".toggle-overview = _: { }; "Mod+Shift+Slash".show-hotkey-overlay = _: { }; - # screenshots - "Print".spawn-sh = "${grim} -g \"$(${slurp})\" - | ${wl-copy}"; - "Shift+Print".spawn-sh = "${grim} - | ${wl-copy}"; + # screenshots: Print and Shift+Print open the shot in Gradia to + # annotate (arrows, text, blur, background) then copy or save; + # Ctrl+Print is the quick region-to-clipboard grab + "Print" = titled "Screenshot region → editor" { + spawn-sh = "region=$(${slurp}) && ${grim} -g \"$region\" - | ${gradia}"; + }; + "Shift+Print" = titled "Screenshot screen → editor" { spawn-sh = "${grim} - | ${gradia}"; }; + "Ctrl+Print" = titled "Screenshot region → clipboard" { + spawn-sh = "region=$(${slurp}) && ${grim} -g \"$region\" - | ${wl-copy}"; + }; # media and hardware keys, also on the lock screen "XF86AudioRaiseVolume" = locked "${wpctl} set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+"; diff --git a/modules/home/default.nix b/modules/home/default.nix @@ -29,6 +29,7 @@ yazi # yazi with previews, Rosé Pine, plugins gtk # Yaru-purple icons, cursor, prefer-dark floorp # Floorp as the main browser: ShyFox (Rosé Pine), the Dia bookmarks + zen # Zen Browser alongside Floorp, sharing the one encrypted bookmarks export ]; home = { diff --git a/modules/home/session.nix b/modules/home/session.nix @@ -1,21 +1,49 @@ # modules/home/session.nix — what every Wayland session needs, whichever # compositor is running: the polkit agent (privilege prompts), clipboard # history, auto-mounting, and the screenshot / clipboard tools the Niri binds -# and the toolbox scripts expect on PATH. Not gated on a desktop switch, so a +# and the toolbox scripts expect on PATH. Gradia (patched, see niri.nix) is the +# screenshot editor the Print binds pipe into (arrows, text, censor, gradient +# backgrounds); Loupe opens images and zathura opens PDFs by default. Not gated on a desktop switch, so a # Niri-only system keeps working. Everything hangs off graphical-session.target, # which uwsm (Hyprland) and niri-session both manage. -{ ... }: +{ self, ... }: { flake.homeModules.session = - { pkgs, ... }: + { lib, pkgs, ... }: { home.packages = with pkgs; [ grim slurp wl-clipboard libnotify + self.packages.${pkgs.stdenv.hostPlatform.system}.gradia # patched: modules/features/desktop/niri.nix + loupe ]; + xdg.mimeApps = { + enable = true; + defaultApplications = + lib.genAttrs [ + "application/pdf" + "application/postscript" + "image/vnd.djvu" + "application/vnd.comicbook+zip" + ] (_: [ "org.pwmt.zathura.desktop" ]) + // lib.genAttrs [ + "image/png" + "image/jpeg" + "image/gif" + "image/webp" + "image/avif" + "image/heif" + "image/jxl" + "image/bmp" + "image/tiff" + "image/svg+xml" + "image/x-icon" + ] (_: [ "org.gnome.Loupe.desktop" ]); + }; + systemd.user.services.hyprpolkitagent = { Unit = { Description = "Hyprland polkit authentication agent"; diff --git a/modules/home/shell.nix b/modules/home/shell.nix @@ -70,6 +70,10 @@ # Managed by home-manager (NixDaemon modules/home/shell.nix). The shell # configuration lives in ~/.dotfiles (→ ~/git/daemon-sec-dotfiles). export ZDOTDIR="$HOME/.dotfiles" + # home.sessionVariables (PASSAGE_*, XDG_*, …); programs.zsh is off, so + # nothing else sources this. + [[ -r "${config.home.profileDirectory}/etc/profile.d/hm-session-vars.sh" ]] \ + && . "${config.home.profileDirectory}/etc/profile.d/hm-session-vars.sh" [[ -r "$HOME/.cargo/env" ]] && . "$HOME/.cargo/env" ''; ".fzf.zsh".text = '' diff --git a/modules/home/tools.nix b/modules/home/tools.nix @@ -47,6 +47,17 @@ perl git jujutsu + # `vaultx` syncs NetrunnerVault to GitLab with file contents + # encrypted. git-crypt does that through git's clean/smudge filters, + # which is only safe because the vault is plain git now -- jj does not + # run those filters and would commit plaintext (Vault-Encryption.md). + # git-remote-gcrypt stays for the older whole-repo scheme the vault + # notes still describe; it cannot carry this vault to gitlab.com + # because it packs each push into one blob and the cap is 100 MiB. + # glab is how vaultx creates and purges the project. + git-crypt + git-remote-gcrypt + glab gnutar zstd pigz diff --git a/modules/home/zen.nix b/modules/home/zen.nix @@ -0,0 +1,103 @@ +# modules/home/zen.nix — Zen Browser alongside Floorp, sharing one bookmarks file. +# +# Floorp stays the default browser: this module deliberately sets neither +# xdg.mimeApps nor $BROWSER, so links keep opening in Floorp +# (modules/home/floorp.nix owns both). Zen is here to be tried with the real +# bookmarks; promote it by moving those two settings over, or drop this module +# to back out. +# +# Zen is not in nixpkgs, so the package comes from the community flake +# (inputs.zen-browser, pinned in flake.nix). `packages.default` is the general +# release channel — the binary is literally `zen-beta` and versions carry a `b`, +# which is how upstream labels its releases; `twilight` is the nightly. +# +# The profile is written by hand under ~/.zen, for the same reason as Floorp: +# Zen's application.ini declares `Profile=zen`, so Gecko reads ~/.zen rather +# than anywhere home-manager's Firefox-family modules would look. +# +# Bookmarks are NOT duplicated. The one encrypted export at +# secrets/floorp/bookmarks.html (111 links, 19 folders, matching the live Floorp +# profile) is declared here a second time under its own name so this module does +# not depend on floorp.nix being imported. Edit it once with +# `TMPDIR=/dev/shm sops secrets/floorp/bookmarks.html` and both browsers see it. +# +# Prefs live in Nix here, not sops, because nothing in them is private yet — +# only the start page and the bookmark-file path. If this grows personal prefs, +# move it to secrets/zen/user.js and mirror the sops.secrets block floorp.nix +# uses. +{ ... }: +{ + flake.homeModules.zen = + { config, lib, pkgs, inputs, ... }: + let + profile = "daemon"; + profileDir = ".zen/${profile}"; + zen = inputs.zen-browser.packages.${pkgs.stdenv.hostPlatform.system}.default; + bookmarksPath = config.sops.secrets."zen-bookmarks.html".path; + in + { + home.packages = [ zen ]; + + # The same encrypted export Floorp imports, under its own secret name so + # neither module needs the other. sops-nix is happy for two secrets to come + # from one sopsFile; they decrypt to separate runtime paths. + sops.secrets."zen-bookmarks.html" = { + sopsFile = ../../secrets/floorp/bookmarks.html; + format = "binary"; + }; + + home.file = { + # Written here rather than left to Zen so the Nix-built profile is the + # default one. Trade-off, as with Floorp: profile changes made in Zen's + # own profile manager are reverted on the next `nh os switch`. + ".zen/profiles.ini".text = '' + [Profile0] + Name=${profile} + IsRelative=1 + Path=${profile} + Default=1 + + [General] + StartWithLastProfile=1 + Version=2 + ''; + + # browser.places.importBookmarksHTML is deliberately absent: see the + # activation script below for why it cannot live in user.js. + "${profileDir}/user.js".text = '' + // Managed by modules/home/zen.nix. Zen reads this only at startup. + user_pref("browser.bookmarks.file", "${bookmarksPath}"); + // Don't let Zen's shipped default bookmarks compete with the import. + user_pref("browser.bookmarks.restore_default_bookmarks", false); + user_pref("browser.startup.homepage", "https://startpage.daemon-sec.xyz/"); + // No first-run tour or import wizard racing the seeded import. + user_pref("browser.aboutwelcome.enabled", false); + user_pref("browser.startup.firstrunSkipsHomepage", true); + user_pref("datareporting.policy.firstRunURL", ""); + ''; + }; + + # One-time bookmark import, same mechanism floorp.nix documents. + # + # `browser.places.importBookmarksHTML` is what makes Gecko read + # browser.bookmarks.file into the places database. It cannot live in + # user.js: the browser consumes it and sets it back to false, but user.js + # re-applies every pref at every startup, so the import would run again on + # each launch and duplicate all 111 bookmarks. Seeding it into prefs.js + # instead, only while the profile has no places.sqlite yet, imports once on + # first launch and never again. + home.activation.zenSeedBookmarkImport = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + zenProfile="$HOME/${profileDir}" + zenPrefs="$zenProfile/prefs.js" + if [ ! -e "$zenProfile/places.sqlite" ] \ + && ! grep -qs 'browser.places.importBookmarksHTML' "$zenPrefs"; then + verboseEcho "Zen: seeding the one-time bookmark import in $zenPrefs" + if [ -z "''${DRY_RUN:-}" ]; then + mkdir -p "$zenProfile" + printf '%s\n' 'user_pref("browser.places.importBookmarksHTML", true);' >> "$zenPrefs" + fi + fi + ''; + } + ; +}