dotfiles
Apps

Zen: the launch guard, and the NSS check that had to be written backwards

Edit on GitHub

modules/home/apps/zen.nix. The browser, plus a guard that closes it when the screen stays locked and asks for Zen's own primary password on the next launch. Its theme is the last section.

What this is NOT, and why that is the point

People who sit at this machine know my login password, and this account is in wheel with security.sudo.wheelNeedsPassword = true. Whoever knows that password is root here, so any lock that lives inside the browser or leans on file permissions is a speed bump and nothing else: the profile can be copied out of ~/.zen and opened somewhere quieter.

The only thing that survives an adversary with root is a passphrase that never touches the disk, which means an encrypted profile. That was designed and REJECTED, see below. What I asked for and what this module delivers is the speed bump: a UI barrier against someone opening my browser and finding every session already logged in.

Why Zen's own primary password and not a secret of my own

Rule 12 says the repo never holds a credential, and a new password would mean a Bitwarden item, a sync-secrets run and a second password to remember for the same browser. Zen already has one that does half the job (it encrypts logins.json through key4.db), and NSS already knows how to verify it from outside the browser:

certutil -K -d sql:<profile> -f /dev/stdin

The password arrives on stdin, so it is never written anywhere. One password, no new secret, and turning it on protects the saved logins at the same time.

The prompt is zenity --password (a rofi -password field until 13/10/2026, when rofi left for Vicinae). It prints only the password on stdout, straight into a shell variable, and Cancel, Esc, a closed window or a timeout all exit non-zero, which keeps Zen closed. Three tries, then the launch is refused. Not Vicinae: a searchable launcher with a clipboard history is the wrong place to type a password. MEASURED on 13/10/2026 against a throwaway NSS db: right, wrong then right, three wrong, cancel, empty, no profiles.ini and an empty db each behaved as before (only the right password clears the flag).

The NSS trap: no password means EVERY password

With no primary password set, NSS authenticates with the empty one and ignores whatever it is handed. A profile in that state answers "fine" to any string, so a naive check would produce a prompt that looks like security and is not. The only way to tell "set" from "not set" is to offer a deliberately wrong password and look for SEC_ERROR_BAD_PASSWORD, which is what the probe in arm does.

That is also why nss_accepts is written POSITIVELY. Reading it as "not a bad password" would turn a broken db, a missing certutil or any unexpected output into a free pass. It returns 0 only when NSS actually answered, and everything else is a refusal.

And the pipeline has to be captured, not piped into grep: under set -o pipefail, printf | certutil | grep -q returns certutil's 255 even when grep matched, because certutil exits non-zero on the happy path too ("no keys found"). The output goes into a variable first.

One precondition checked, one enforced, and why it disarms out loud

A primary password must exist, for the reason above. When it does not, arm writes a priority-4 line to the journal, leaves Zen alone and does not arm: a guard that cannot verify is not a guard. It is a UI setting I flip inside Zen, and until I do, the journal says DISARMED on every lock, which is rule 16's shape of failure: loud, never silent.

Zen must reopen the previous session, and this one is NOT checked, it is enforced. browser.startup.page defaults to 1 in Zen (measured inside browser/omni.ja, defaults/preferences/firefox.js), so every cold start (the guard closing it, an update, a logout) dropped the tabs. Closing a browser that does not come back is not a security feature, it is data loss. The pref used to live in a user.js symlinked from the Arch-era repo, and when that path died the session went with it in silence. Now the package carries the Homepage policy (StartPage = "previous-session", Locked), so no prefs.js or Settings click can turn it off. about:policies shows it. A policy writes the DEFAULT branch, so prefs.js never gets the line, which is why the old grep precondition had to go.

The same policy file installs Vicinae's add-on (ExtensionSettings, firefox@vicinae.com, normal_installed from AMO's latest.xpi), so tab search survives a new profile. It is the signed AMO build, so signature checks stay as they are, and AMO keeps it updated. Why it is there: vicinae.md.

Killing precisely, because pgrep -f is wider than it looks

SIGTERM is Firefox's clean quit: it writes sessionstore and exits, the same path a logout takes. SIGKILL only runs after 10 seconds of waiting.

Finding the process is the delicate half. The parent carries --class=zen-beta in its cmdline and the content processes do not, so pgrep -f looks like enough, and it is not: while writing this it also matched the shell running the pgrep itself, because the pattern was sitting in that command line. Every candidate is confirmed through /proc/<pid>/exe against */lib/zen-bin-*/zen before anything is signalled.

The wiring, and where it deliberately does NOT live

The countdown is a TIMER with WantedBy=lockscreen.service and BindsTo=lockscreen.service. Locking the screen pulls it in, OnActiveSec counts the grace, and unlocking in time stops the timer and cancels the pending close. Coffee does not cost me the browser; walking away does.

BindsTo and not PartOf, and this one was measured, not read. PartOf is what lockscreen-idle-lock uses and it was the obvious first choice here, but it only propagates an EXPLICIT stop. The lock is Type=simple and ENDS BY ITSELF when the screen is unlocked, and a unit that exits on its own enqueues no stop job, so nothing reaches the timer. Two throwaway units proved it: parent exits on its own, is-active on the PartOf timer still says active. The countdown would have survived the unlock and closed Zen three minutes into me using it, which is the worst possible shape for this feature. BindsTo plus After stops on the bound unit going inactive for ANY reason, and the same test says inactive.

The coupling is declared in zen.nix and not in lockscreen.nix on purpose. The lock is a security function and must not become hostage to a browser module, and WantedBy produces a weak Wants=, so a guard that fails to start can never hold the screen unlocked.

The armed flag lives in $XDG_STATE_HOME/zen-guard/armed and not in /run: a reboot must not be the way around the prompt. Booting locks the screen (autostart.lua), so a fresh boot arms it after the same grace.

The escape hatch, when the check itself breaks, is rm ~/.local/state/zen-guard/armed.

What was designed and rejected

A gocryptfs vault over ~/.zen (4.5 GB, ciphertext beside it, unmounted on lock). This is the only option on the list that holds against root, and it is the one I did not want: it means re-encrypting the whole profile, FUSE in the path of every sqlite write the browser makes, and a new user mount inside /home, which is the exact trap restic.md records three times. If it ever comes back, the prompt does not change, only what it unlocks.

fscrypt, the kernel's own per-directory encryption, would have been the elegant version of the same idea: no FUSE, and fscrypt lock is instant. It supports ext4, F2FS and UBIFS, and this home is btrfs.

Browser extensions (Browser Lock, Locksy and friends) live inside the profile they claim to protect and switch off in about:addons.

Hiding the windows in a Hyprland special workspace instead of closing Zen: no session loss and no preconditions at all, but the browser stays in RAM one hyprctl dispatch away. It is the fallback if closing the browser ever becomes annoying in practice.

The theme: the palette on Zen's own variables

modules/home/apps/zen/theme.nix. A rewrite and not a port of the Arch userChrome.css (archive/arch-final:zen-browser/userChrome.css, 618 lines). That one painted element by element (#urlbar, #identity-box, .searchmode-switcher...) with the hexes typed by hand, so every Zen update that renamed an element broke a piece in silence, and the palette lived twice. Measured on Zen 1.23b (zen-styles/zen-theme.css inside omni.ja): the whole UI derives from --zen-primary-color and --zen-branding-dark through color-mix(), so overriding about ten VARIABLES recolors it, and the colors come from my.theme.palette, the same source as kitty, Vicinae and Quickshell.

Two things the measurement decided:

  • Every line is !important. userChrome.css is a USER sheet, so it loses to Zen's author sheet, and the gradient generator writes --zen-primary-color and --zen-main-browser-background INLINE on <html> at runtime (ZenGradientGenerator.mjs).
  • The background variables go on the background ELEMENTS too. The generator writes --zen-main-browser-background (and its -toolbar and -old twins) inline on #zen-browser-background and #zen-toolbar-background, so a value on :root never reached what is painted. The first version shipped like that: the variable read #1a1b26 and the frame painted Zen's #131313. The lab caught it by sampling pixels, which is what it is for.
  • It links into the profile the guard finds (zen-guard profile), since the profile dir is a random name Zen chose. That is the lookup's only owner, and an activation script that cannot find a profile does nothing instead of failing the switch. The Arch symlink there pointed at ~/dotfiles/zen-browser/, a path gone since the cutover, so the old theme had been off since then.

Two additions chosen on 24/10/2026 in a bake-off of lab renders (four split layouts, five shadows), both under the same guard:

  • A split pair reads as one entry, the way Arc shows a split. Zen gives each half flex: 1 1 0%, so a pinned Jira + ChatGPT pair showed [V1C-95 and V1C-95: the key twice and no summary. Now the first tab keeps the whole title and the others shrink to their icon, which still opens that half. The order matters: the tab with the useful title goes left. Stacking the halves (the store's Vertical Split Tab Groups) was the runner-up; it shows both titles but doubles the height.
  • The page gets the desktop's window shadow: 0 0 2px plus 0 0 6px in the palette's shadow, the CSS reading of Hyprland's range 6, render_power 3, no offset. Stock Zen 1.23 draws no shadow under the page at all, and the frame leaves only ~20px before clipping one, so it has to be short and dense. A 1px rim in border came first (24/10) and lasted a day: inside the window's own border it read as a frame in the frame.

A second round the same day, against the real mods (above), settled the folders:

  • A folder's children hang from a hairline under the folder icon (left: 19px, the icon's center), indented to 26px, like the tree guides of VS Code and Linear. Pairs inside a folder drop their surface, so the guide alone does the grouping. Rejected: folders as small-caps section titles (cleanest, but folder and children blur together) and the open folder as one tinted tray (groups well, but adds a block of color). Only open folders get the guide; a collapsed one still shows its loaded tabs, without it.

A third round, against a copy of the real tree (nested folders, custom icons, the ChatGPT half in focus), fixed what the first two left unclear:

  • The title follows the focus. In the open pair the focused half takes the title and the other shrinks to its icon; an idle pair keeps its first tab's title. With the title always on the left, focusing ChatGPT left the row naming Jira. Rejected: opening the active pair into two lines (the list jumps in height) and only ringing the focused icon (still no name).
  • The focused pane wears Hyprland's focus border, 1px, blue to magenta at 45deg, drawn as a masked ::after. Zen's own mark is a 2px light outline on the focused pane only, and a ringed pane beside a bare one read as a Glance, the floating link preview. Rejected: dimming the other pane (hides the Jira being read) and a small bar on top (too quiet).
  • Folders show a chevron, not Zen's drawing. svg .back, .front and .dots are hidden; the .icon part, where a custom folder icon lives, stays. The chevron is a background data SVG: as a mask, Gecko drew nothing. The guides of the folders holding the focused tab turn accent, so the tree points at it. Rejected: the drawing kept, gray and smaller.

Switching back to the stock theme, live: in about:config, create my.zen.stock-theme as a Boolean and set it to true. The whole sheet sits under @media not -moz-pref(...), the same query Zen's own CSS uses, so the flip applies at once (measured on 13/10/2026, no restart) and survives restarts (it lives in prefs.js). Deleting the pref, or setting it to false, brings the palette back. Uninstalling for good is removing ./zen/theme.nix from the imports: the activation stops relinking, and the two dead symlinks are deleted by hand.

user.js declares toolkit.legacyUserProfileCustomizations.stylesheets, without which Gecko ignores the sheet, and one pref for a mod (below).

Two Zen mods live in the profile, NOT in the repo: SuperPins and Ghost Tabs, installed through Zen's own mod UI in 03/2026 and compiled by Zen into chrome/zen-themes.css. Ghost Tabs grays out unloaded tabs. SuperPins drew the boxes around every pinned tab and pinned split pair, because its uc.superpins.border was "pins"; its rules only match "pins", "essentials" and "both", so user.js sets "none" and the boxes are gone on every start. The lab does not load the mods by itself: to see what the real profile shows, inject a copy of zen-themes.css with zen-theme-lab css (found 24/10/2026, after renders without it missed the boxes entirely). The Arch user.js also forced VA-API and DMABUF past Firefox's blocklist, but that was for the NVIDIA card; with the Arc those are a separate question and stay out.

The theme lab: measuring the theme instead of guessing

zen-theme-lab, declared next to the theme. It starts a SECOND Zen on a throwaway profile under $XDG_RUNTIME_DIR, with the same generated userChrome.css, Marionette on port 2829 and the window moved to special:zenlab, so it never lands on the workspace in use. Verbs: start [URL], eval JS (runs in the browser's CHROME context, so getComputedStyle reaches the UI), css FILE (injects one extra user sheet live, replacing the last; an empty file clears it), shot OUT.png and stop.

Why Marionette and not Mozilla's firefox-devtools-mcp: the powers that matter are the same (the MCP drives Marionette underneath), but the MCP's screenshots cover the PAGE only, never the browser UI, which is the whole subject here; it would also mean packaging an npm server. The client is zen-lab-client in theme.nix, bash with jq: Marionette is len:json frames over TCP, so a /dev/tcp descriptor, read -d : for the size and read -N for the body under LC_ALL=C (the prefix counts BYTES). It replaced zen/lab.py on 22/10/2026 (rule 24). MEASURED against it on a live lab: the same eval output (objects, nested arrays, null, non-ASCII), the same css answers, a byte-identical screenshot. One change: a script error prints the error as JSON where the Python printed a dict repr.

Why a second instance and never the real profile: Mozilla's own advice for that MCP, and the same reasoning applies: whatever drives the browser reaches its cookies and passwords, and Marionette marks it as automated (navigator.webdriver). The lab still launches through the guarded package, so it does not open a way around the launch guard.

Measured on 13/10/2026: the screenshot captures the whole window, sidebar included, while it sits on the hidden special workspace. Two limits, both measured the same day:

  • Popups are not in it. The suggestions panel, menus and the Glance are separate Wayland surfaces, so shot misses them; grim -o <monitor> through the compositor gets them.
  • Popups need focus. Firefox closes a popup on a window with no focus, so for those the lab has to be on screen: hyprctl dispatch 'hl.dsp.workspace.toggle_special("zenlab")' shows and hides it. Typing goes through Marionette's WebDriver:ElementSendKeys; gURLBar.search() alone does not open the panel.

Two more, measured 24/10/2026. The hidden workspace gets no frames, so Zen's sidebar animations froze half-way (folders over the Essentials, collapsed folders still open); the lab now starts with ui.prefersReducedMotion on. And a shot can still show a stale paint after a state change, so when a picture and getComputedStyle disagree, the computed style is the truth.

InspectorUtils.getMatchingCSSRules(el) in an eval is what names the rule behind a color, which is how the inline per-space copies and the -moz-dialog status bar were found.