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'") + ''; +}