From 15ca5ebe96371d9d4e7436fe5500e0c7e0dd169b Mon Sep 17 00:00:00 2001 From: andrewthomaslee Date: Sat, 14 Nov 2026 17:48:45 -0600 Subject: [PATCH] 1Password: allowed browsers, autostart and SSH agent for every desktop user Zen's extension could not be unlocked by the desktop app: 1Password rejects browsers it does not know ("UnknownBrowser ... isn't a supported browser") unless their binary is listed in /etc/1password/custom_allowed_browsers. customAllowedBrowsers writes it. System authentication only works once the app has been unlocked with the account password after it starts, so autostart.enable (default on) starts it minimised to the tray at every desktop login. sshAgent.enable (default on) points ssh at ~/.1password/agent.sock only for users whose socket exists, so users and root without the agent keep their usual agent and key files. Polkit is enabled with the module: system authentication and the CLI and SSH approvals are polkit actions. Co-Authored-By: Claude Opus 5.5 --- flake-parts/nixosModules/onepassword.nix | 92 ++++++++++++++++++++++-- tests/fixtures/nixos.nix | 1 + vm-tests/onepassword.nix | 62 ++++++++++++++++ 3 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 vm-tests/onepassword.nix diff --git a/flake-parts/nixosModules/onepassword.nix b/flake-parts/nixosModules/onepassword.nix index b537b08..3eecac1 100644 --- a/flake-parts/nixosModules/onepassword.nix +++ b/flake-parts/nixosModules/onepassword.nix @@ -7,13 +7,24 @@ # home packages: a Nix-store `op` earlier on PATH fails with # "connecting to desktop app" errors. # - # After the rebuild, in the app: sign in, then Settings > Security > - # "Unlock using system authentication" (fingerprint also needs - # services.fprintd.enable and an enrolled finger) and Settings > - # Developer > "Integrate with 1Password CLI". `op vault list` should - # then prompt for authorization. - flake.nixosModules.onepassword = {config, ...}: let + # Everything here is system-wide, so every desktop user (present and + # future) gets it without a per-user toggle. + # + # After the rebuild, in the app, once per user: sign in, then Settings > + # Security > "Unlock using system authentication" (a polkit prompt for + # the Linux login password; fingerprint also needs services.fprintd and + # an enrolled finger), Settings > General > "Keep 1Password in the + # system tray", Settings > Developer > "Integrate with 1Password CLI" + # and, for the SSH agent, "Set up the SSH Agent". `op vault list` should + # then prompt for authorization. Quit 1Password from its menu once after + # changing customAllowedBrowsers: it reads the file at start. + flake.nixosModules.onepassword = { + config, + pkgs, + ... + }: let cfg = config.hostSpec.services.onepassword; + gui = config.programs._1password-gui.package; in { options.hostSpec.services.onepassword = { enable = lib.mkEnableOption "1Password desktop app and the setgid op CLI wrapper"; @@ -23,6 +34,42 @@ example = ["alice"]; description = "Users allowed to unlock 1Password with system authentication (polkit) and to use the CLI integration. No default: set it per machine."; }; + + customAllowedBrowsers = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + example = ["zen"]; + description = '' + Binary names of browsers 1Password does not ship support for, + written to /etc/1password/custom_allowed_browsers. The desktop + app verifies the browser that starts its native-messaging host + and refuses unknown ones ("UnknownBrowser ... isn't a supported + browser" in ~/.config/1Password/logs/BrowserSupport), so the + extension in such a browser cannot be unlocked by the app. + Zen's binary is `zen`. + ''; + }; + + autostart.enable = + lib.mkEnableOption '' + starting 1Password minimised to the tray (`1password --silent`) + at every desktop login, through /etc/xdg/autostart. 1Password + asks for the account password whenever it starts and accepts + system authentication only after that, so a running app is what + makes system authentication and browser unlock useful. A user's + own ~/.config/autostart/1password.desktop (the app's "Start at + login") overrides this entry instead of starting a second copy'' + // {default = true;}; + + sshAgent.enable = + lib.mkEnableOption '' + the 1Password SSH agent for every user who turned it on in the + app. /etc/ssh/ssh_config points IdentityAgent at + ~/.1password/agent.sock only while that socket exists, so users + (and root) without it keep their usual agent. Keys outside + 1Password still work: ssh falls back to its default identity + files after the agent's keys'' + // {default = true;}; }; config = lib.mkIf cfg.enable { @@ -33,12 +80,45 @@ pkg: builtins.elem (lib.getName pkg) ["1password" "1password-cli"] ); + # System authentication, CLI and SSH agent approvals are polkit + # actions; desktops enable polkit anyway, a bare host would not. + security.polkit.enable = true; + programs = { _1password.enable = true; _1password-gui = { enable = true; inherit (cfg) polkitPolicyOwners; }; + # mkAfter: nixpkgs writes extraConfig above its own `Host *`, so + # this Match block ends there and cannot swallow global options + # another module appends. %d is the local user's home directory. + ssh.extraConfig = lib.mkIf cfg.sshAgent.enable (lib.mkAfter '' + # 1Password SSH agent, for users who turned it on in the app + Match exec "test -S %d/.1password/agent.sock" + IdentityAgent %d/.1password/agent.sock + ''); + }; + + environment.etc = { + # 1Password requires the file to be root-owned and not writable by + # anyone else; 0755 is the mode its docs and installers use. + "1password/custom_allowed_browsers" = lib.mkIf (cfg.customAllowedBrowsers != []) { + text = lib.concatLines cfg.customAllowedBrowsers; + mode = "0755"; + }; + "xdg/autostart/1password.desktop" = lib.mkIf cfg.autostart.enable { + source = pkgs.writeText "1password-autostart.desktop" '' + [Desktop Entry] + Name=1Password + Exec=${lib.getExe gui} --silent + Icon=1password + Terminal=false + Type=Application + X-GNOME-Autostart-enabled=true + X-KDE-autostart-after=panel + ''; + }; }; }; }; diff --git a/tests/fixtures/nixos.nix b/tests/fixtures/nixos.nix index 12fd01b..58f12df 100644 --- a/tests/fixtures/nixos.nix +++ b/tests/fixtures/nixos.nix @@ -89,6 +89,7 @@ onepassword = { enable = true; polkitPolicyOwners = ["alice"]; + customAllowedBrowsers = ["zen"]; }; openshell.gateway.enable = true; openssh.enable = true; diff --git a/vm-tests/onepassword.nix b/vm-tests/onepassword.nix new file mode 100644 index 0000000..71b3499 --- /dev/null +++ b/vm-tests/onepassword.nix @@ -0,0 +1,62 @@ +# hostSpec.services.onepassword: the system-wide pieces every desktop user +# gets. The allowed-browsers file is root-owned with the listed binaries, +# the autostart entry starts the app minimised, the polkit policy names +# the owners, and ssh uses the 1Password agent socket only for a user +# whose socket exists (the app is never started: it needs a sign-in). +_: { + nodes.machine = {pkgs, ...}: { + virtualisation = { + memorySize = 2048; + cores = 2; + }; + hostSpec.services.onepassword = { + enable = true; + polkitPolicyOwners = ["alice"]; + customAllowedBrowsers = ["zen" "librewolf"]; + }; + users.users = { + alice.isNormalUser = true; + bob.isNormalUser = true; + }; + environment.systemPackages = [pkgs.python3]; + }; + + testScript = '' + machine.wait_for_unit("multi-user.target", timeout=300) + + with subtest("allowed browsers file is root-owned and lists the binaries"): + f = "/etc/1password/custom_allowed_browsers" + machine.succeed(f"test \"$(stat -L -c '%U %a' {f})\" = 'root 755'") + machine.succeed(f"test \"$(cat {f})\" = \"$(printf 'zen\\nlibrewolf')\"") + + with subtest("autostart entry starts the app minimised"): + d = "/etc/xdg/autostart/1password.desktop" + machine.succeed(f"grep -E '^Exec=/nix/store/.*/bin/1password --silent$' {d}") + machine.succeed(f"test -x \"$(sed -n 's/^Exec=\\(.*\\) --silent$/\\1/p' {d})\"") + + with subtest("polkit policy names the owners"): + machine.succeed( + "grep 'unix-user:alice' " + "/run/current-system/sw/share/polkit-1/actions/com.1password.1Password.policy" + ) + + def agent(user): + return machine.succeed( + f"su - {user} -c 'ssh -G host.example' | sed -n 's/^identityagent //p'" + ).strip() + + with subtest("no agent socket: ssh keeps the default agent"): + for user in ["alice", "bob", "root"]: + assert ".1password" not in agent(user), user + + with subtest("agent socket present: ssh uses it for that user only"): + machine.succeed( + "su - alice -c 'mkdir -p ~/.1password && python3 -c " + "\"import socket; socket.socket(socket.AF_UNIX).bind(\\\"/home/alice/.1password/agent.sock\\\")\"'" + ) + assert agent("alice") == "/home/alice/.1password/agent.sock", agent("alice") + assert ".1password" not in agent("bob"), agent("bob") + # The Match block must not swallow the global options after it. + machine.succeed("su - alice -c 'ssh -G host.example' | grep -x 'forwardx11 no'") + ''; +}