Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 86 additions & 6 deletions flake-parts/nixosModules/onepassword.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -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
'';
};
};
};
};
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/nixos.nix
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
onepassword = {
enable = true;
polkitPolicyOwners = ["alice"];
customAllowedBrowsers = ["zen"];
};
openshell.gateway.enable = true;
openssh.enable = true;
Expand Down
62 changes: 62 additions & 0 deletions vm-tests/onepassword.nix
Original file line number Diff line number Diff line change
@@ -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'")
'';
}
Loading