From 1694a977f172c911c70286ee9cf050716eced6c9 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Wed, 9 Apr 2025 16:06:46 +0100 Subject: [PATCH 1/3] feat: support age plugins Extends how we parse the contents of `SOPS_AGE_KEY` / `SOPS_AGE_KEY_FILE` / `keys.txt`, allowing a user to prepend a comment before any `AGE-PLUGIN-` secret key entry to indicate its corresponding public key. For example: ``` AGE-PLUGIN-FIDO2-HMAC-xxxxxxxxxxxxx ``` The comment can use any prefix (e.g. `# public key: age1xxxx`, `# recipient: age1xxx`) as we are looking directly for `age1xxxx` within the line. This change is necessary to support `age` plugins as there is no unified mechanism to recover the public key from a plugin's secret key. If a plugin secret key does not have a preceding public key comment, an error will be thrown when attempting to set a secret. --- docs/site/getting-started/secrets.md | 59 +++++- pkgs/clan-cli/clan_cli/secrets/secrets.py | 12 +- pkgs/clan-cli/clan_cli/secrets/sops.py | 135 ++++++++----- pkgs/clan-cli/clan_cli/secrets/types.py | 25 ++- .../clan_cli/tests/test_import_sops_cli.py | 1 + pkgs/clan-cli/clan_cli/tests/test_modules.py | 140 +++++++------- .../clan_cli/tests/test_secrets_cli.py | 182 ++++++++++-------- .../clan_cli/tests/test_secrets_upload.py | 83 ++++---- pkgs/clan-cli/clan_cli/tests/test_vms_cli.py | 44 +++-- 9 files changed, 400 insertions(+), 281 deletions(-) diff --git a/docs/site/getting-started/secrets.md b/docs/site/getting-started/secrets.md index 25c9ce52e..8838a9003 100644 --- a/docs/site/getting-started/secrets.md +++ b/docs/site/getting-started/secrets.md @@ -1,18 +1,19 @@ Clan enables encryption of secrets (such as passwords & keys) ensuring security and ease-of-use among users. -By default Clan utilizes the [sops](https://github.com/getsops/sops) format and integrates with [sops-nix](https://github.com/Mic92/sops-nix) on NixOS machines. +By default, Clan uses the [sops](https://github.com/getsops/sops) format +and integrates with [sops-nix](https://github.com/Mic92/sops-nix) on NixOS machines. Clan can also be configured to be used with other secret store [backends](https://docs.clan.lol/reference/clan-core/vars/#clan.core.vars.settings.secretStore). This guide will walk you through: -- **Creating a Keypair for Your User**: Learn how to generate a keypair for $USER to securely control all secrets. +- **Creating a Keypair for Your User**: Learn how to generate a keypair for `$USER` to securely control all secrets. - **Creating Your First Secret**: Step-by-step instructions on creating your initial secret. - **Assigning Machine Access to the Secret**: Understand how to grant a machine access to the newly created secret. ## Create Your Admin Keypair -To get started, you'll need to create **Your admin keypair**. +To get started, you'll need to create **your admin keypair**. !!! info Don't worry — if you've already made one before, this step won't change or overwrite it. @@ -34,9 +35,42 @@ Also add your age public key to the repository with 'clan secrets users add YOUR Make sure to keep a safe backup of the private key you've just created. If it's lost, you won't be able to get to your secrets anymore because they all need the admin key to be unlocked. +If you already have an [age] secret key and want to use that instead, you can simply edit `~/.config/sops/age/keys.txt`: + +```title="~/.config/sops/age/keys.txt" +AGE-SECRET-KEY-13GWMK0KNNKXPTJ8KQ9LPSQZU7G3KU8LZDW474NX3D956GGVFAZRQTAE3F4 +``` + +Alternatively, you can provide your [age] secret key as an environment variable `SOPS_AGE_KEY`, or in a different file +using `SOPS_AGE_KEY_FILE`. +For more information see the [SOPS] guide on [encrypting with age]. + !!! note It's safe to add any secrets created by the clan CLI and placed in your repository to version control systems like `git`. +### Using Age Plugins + +If you wish to use a key generated using an [age plugin] as your admin key, extra care is needed. + +You must **precede your secret key with a comment that contains its corresponding recipient**. + +This is usually output as part of the generation process +and is only required because there is no unified mechanism for recovering a recipient from a plugin secret key. + +Here is an example: + +```title="~/.config/sops/age/keys.txt" +# public key: age1zdy49ek6z60q9r34vf5mmzkx6u43pr9haqdh5lqdg7fh5tpwlfwqea356l +AGE-PLUGIN-FIDO2-HMAC-1QQPQZRFR7ZZ2WCV... +``` + +!!! note + The comment that precedes the plugin secret key need only contain the recipient. + Any other text is ignored. + + In the example above, you can specify `# recipient: age1zdy...`, `# public: age1zdy....` or even + just `# age1zdy....` + ### Add Your Public Key(s) ```console @@ -70,7 +104,7 @@ If you followed the quickstart tutorial all necessary secrets are initialized at You can list keys for your user with `clan secrets users get $USER`: ```console -❯ bin/clan secrets users get alice +clan secrets users get alice [ { @@ -83,17 +117,22 @@ You can list keys for your user with `clan secrets users get $USER`: "type": "age", "username": "alice" } -] +] ``` -To add a new key to your user: +To add a new key to your user: -```console +```console clan secrets users add-key $USER --age-key ``` -To remove a key from your user: +To remove a key from your user: -```console +```console clan secrets users remove-key $USER --age-key -``` \ No newline at end of file +``` + +[age]: https://github.com/FiloSottile/age +[age plugin]: https://github.com/FiloSottile/awesome-age?tab=readme-ov-file#plugins +[sops]: https://github.com/getsops/sops +[encrypting with age]: https://github.com/getsops/sops?tab=readme-ov-file#encrypting-using-age diff --git a/pkgs/clan-cli/clan_cli/secrets/secrets.py b/pkgs/clan-cli/clan_cli/secrets/secrets.py index 56e54a1c7..a4e11eb66 100644 --- a/pkgs/clan-cli/clan_cli/secrets/secrets.py +++ b/pkgs/clan-cli/clan_cli/secrets/secrets.py @@ -155,16 +155,14 @@ def encrypt_secret( if add_users is None: add_users = [] - keys = sops.ensure_admin_public_key(flake_dir) + admin_keys = sops.ensure_admin_public_keys(flake_dir) - if not keys: + if not admin_keys: # todo double check the correct command to run msg = "No keys found. Please run 'clan secrets add-key' to add a key." raise ClanError(msg) - username = next(iter(keys)).username - - recipient_keys = set() + username = next(iter(admin_keys)).username # encrypt_secret can be called before the secret has been created # so don't try to call sops.update_keys on a non-existent file: @@ -203,8 +201,8 @@ def encrypt_secret( recipient_keys = collect_keys_for_path(secret_path) - if not keys.intersection(recipient_keys): - recipient_keys.update(keys) + if not admin_keys.intersection(recipient_keys): + recipient_keys.update(admin_keys) files_to_commit.extend( allow_member( diff --git a/pkgs/clan-cli/clan_cli/secrets/sops.py b/pkgs/clan-cli/clan_cli/secrets/sops.py index 388e97f4c..c43af643a 100644 --- a/pkgs/clan-cli/clan_cli/secrets/sops.py +++ b/pkgs/clan-cli/clan_cli/secrets/sops.py @@ -4,6 +4,7 @@ import io import json import logging import os +import re import shutil import subprocess from collections.abc import Iterable, Sequence @@ -19,7 +20,9 @@ from clan_cli.dirs import user_config_dir from clan_cli.errors import ClanError from clan_cli.nix import nix_shell -from .folders import sops_machines_folder, sops_users_folder +from .folders import sops_users_folder + +AGE_RECIPIENT_REGEX = re.compile(r"^.*((age1|ssh-(rsa|ed25519) ).*?)(\s|$)") log = logging.getLogger(__name__) @@ -55,14 +58,20 @@ class KeyType(enum.Enum): def maybe_read_from_path(key_path: Path) -> None: try: # as in parse.go in age: - lines = Path(key_path).read_text().strip().splitlines() - for private_key in filter(lambda ln: not ln.startswith("#"), lines): - public_key = get_public_age_key(private_key) - log.info( - f"Found age public key from a private key " - f"in {key_path}: {public_key}" - ) - keyring.append(public_key) + content = Path(key_path).read_text().strip() + + try: + for public_key in get_public_age_keys(content): + log.info( + f"Found age public key from a private key " + f"in {key_path}: {public_key}" + ) + + keyring.append(public_key) + except ClanError as e: + error_msg = f"Failed to read age keys from {key_path}" + raise ClanError(error_msg) from e + except FileNotFoundError: return except Exception as ex: @@ -72,13 +81,19 @@ class KeyType(enum.Enum): # SOPS_AGE_KEY is fed into age.ParseIdentities by Sops, and # reads identities line by line. See age/keysource.go in # Sops, and age/parse.go in Age. - for private_key in keys.strip().splitlines(): - public_key = get_public_age_key(private_key) - log.info( - f"Found age public key from a private key " - f"in the environment (SOPS_AGE_KEY): {public_key}" - ) - keyring.append(public_key) + content = keys.strip() + + try: + for public_key in get_public_age_keys(content): + log.info( + f"Found age public key from a private key " + f"in the environment (SOPS_AGE_KEY): {public_key}" + ) + + keyring.append(public_key) + except ClanError as e: + error_msg = "Failed to read age keys from SOPS_AGE_KEY" + raise ClanError(error_msg) from e # Sops will try every location, see age/keysource.go elif key_path := os.environ.get("SOPS_AGE_KEY_FILE"): @@ -249,7 +264,44 @@ def sops_run( return p.returncode, p.stdout -def get_public_age_key(privkey: str) -> str: +def get_public_age_keys(contents: str) -> set[str]: + # we use a set as it's possible we may detect the same key twice, once in a `# comment` and once by recovering it + # from AGE-SECRET-KEY + keys: set[str] = set() + recipient: str | None = None + + for line_number, line in enumerate(contents.splitlines()): + match = AGE_RECIPIENT_REGEX.match(line) + + if match: + recipient = match[1] + keys.add(recipient) + + if line.startswith("#"): + continue + + if line.startswith("AGE-PLUGIN-"): + if not recipient: + msg = f"Did you forget to precede line {line_number} with it's corresponding `# recipient: age1xxxxxxxx` entry?" + raise ClanError(msg) + + # reset recipient + recipient = None + + if line.startswith("AGE-SECRET-KEY-"): + try: + keys.add(get_public_age_key_from_private_key(line)) + except Exception as e: + msg = "Failed to get public key for age private key. Is the key malformed?" + raise ClanError(msg) from e + + # reset recipient + recipient = None + + return keys + + +def get_public_age_key_from_private_key(privkey: str) -> str: cmd = nix_shell(["age"], ["age-keygen", "-y"]) error_msg = "Failed to get public key for age private key. Is the key malformed?" @@ -298,23 +350,7 @@ def get_user_name(flake_dir: Path, user: str) -> str: print(f"{flake_dir / user} already exists") -def maybe_get_user_or_machine(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None: - folders = [sops_users_folder(flake_dir), sops_machines_folder(flake_dir)] - - for folder in folders: - if folder.exists(): - for user in folder.iterdir(): - if not (user / "key.json").exists(): - continue - - keys = read_keys(user) - if key in keys: - return {SopsKey(key.pubkey, user.name, key.key_type)} - - return None - - -def get_user(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None: +def maybe_get_user(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None: folder = sops_users_folder(flake_dir) if folder.exists(): @@ -329,15 +365,6 @@ def get_user(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None: return None -@API.register -def ensure_user_or_machine(flake_dir: Path, key: SopsKey) -> set[SopsKey]: - maybe_keys = maybe_get_user_or_machine(flake_dir, key) - if maybe_keys: - return maybe_keys - msg = f"A sops key is not yet added to the repository. Please add it with 'clan secrets users add youruser {key.pubkey}' (replace youruser with your user name)" - raise ClanError(msg) - - def default_admin_private_key_path() -> Path: raw_path = os.environ.get("SOPS_AGE_KEY_FILE") if raw_path: @@ -346,8 +373,9 @@ def default_admin_private_key_path() -> Path: @API.register -def maybe_get_admin_public_key() -> None | SopsKey: +def maybe_get_admin_public_key() -> SopsKey | None: keyring = SopsKey.collect_public_keys() + if len(keyring) == 0: return None @@ -366,12 +394,21 @@ def maybe_get_admin_public_key() -> None | SopsKey: return keyring[0] -def ensure_admin_public_key(flake_dir: Path) -> set[SopsKey]: +def ensure_admin_public_keys(flake_dir: Path) -> set[SopsKey]: key = maybe_get_admin_public_key() - if key: - return ensure_user_or_machine(flake_dir, key) - msg = "No sops key found. Please generate one with 'clan secrets key generate'." - raise ClanError(msg) + + if not key: + msg = "No SOPS key found. Please generate one with `clan secrets key generate`." + raise ClanError(msg) + + user_keys = maybe_get_user(flake_dir, key) + + if not user_keys: + # todo improve error message + msg = f"We could not figure out which Clan secrets user you are with the SOPS key we found: {key.pubkey}" + raise ClanError(msg) + + return user_keys def update_keys(secret_path: Path, keys: Iterable[SopsKey]) -> list[Path]: diff --git a/pkgs/clan-cli/clan_cli/secrets/types.py b/pkgs/clan-cli/clan_cli/secrets/types.py index 5293ab11f..1b3d0eb42 100644 --- a/pkgs/clan-cli/clan_cli/secrets/types.py +++ b/pkgs/clan-cli/clan_cli/secrets/types.py @@ -5,7 +5,7 @@ from pathlib import Path from clan_cli.errors import ClanError -from .sops import get_public_age_key +from .sops import get_public_age_keys VALID_SECRET_NAME = re.compile(r"^[a-zA-Z0-9._-]+$") VALID_USER_NAME = re.compile(r"^[a-z_]([a-z0-9_-]{0,31})?$") @@ -21,15 +21,20 @@ def secret_name_type(arg_value: str) -> str: def public_or_private_age_key_type(arg_value: str) -> str: if Path(arg_value).is_file(): arg_value = Path(arg_value).read_text().strip() - for line in arg_value.splitlines(): - if line.startswith("#"): - continue - if line.startswith("age1"): - return line.strip() - if line.startswith("AGE-SECRET-KEY-"): - return get_public_age_key(line) - msg = f"Please provide an age public key starting with age1 or an age private key AGE-SECRET-KEY-, got: '{arg_value}'" - raise ClanError(msg) + + public_keys = get_public_age_keys(arg_value) + + match len(public_keys): + case 0: + msg = f"Please provide an age public key starting with age1 or an age private key starting with AGE-SECRET-KEY- or AGE-PLUGIN-, got: '{arg_value}'" + raise ClanError(msg) + + case 1: + return next(iter(public_keys)) + + case n: + msg = f"{n} age keys were provided, please provide only 1: '{arg_value}'" + raise ClanError(msg) def group_or_user_name_type(what: str) -> Callable[[str], str]: diff --git a/pkgs/clan-cli/clan_cli/tests/test_import_sops_cli.py b/pkgs/clan-cli/clan_cli/tests/test_import_sops_cli.py index 09ca39cd5..e19d3da5d 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_import_sops_cli.py +++ b/pkgs/clan-cli/clan_cli/tests/test_import_sops_cli.py @@ -91,6 +91,7 @@ def test_import_sops( cli.run(cmd) with capture_output as output: cli.run(["secrets", "users", "list", "--flake", str(test_flake.path)]) + users = sorted(output.out.rstrip().split()) assert users == ["user1", "user2"] diff --git a/pkgs/clan-cli/clan_cli/tests/test_modules.py b/pkgs/clan-cli/clan_cli/tests/test_modules.py index 46d4620b3..bb1412f27 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_modules.py +++ b/pkgs/clan-cli/clan_cli/tests/test_modules.py @@ -40,79 +40,89 @@ def test_add_module_to_inventory( age_keys: list["KeyPair"], ) -> None: base_path = test_flake_with_core.path - monkeypatch.chdir(test_flake_with_core.path) - monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey) - cli.run( - [ - "secrets", - "users", - "add", - "--flake", - str(test_flake_with_core.path), - "user1", - age_keys[0].pubkey, - ] - ) - opts = CreateOptions( - clan_dir=Flake(str(base_path)), - machine=Machine(name="machine1", tags=[], deploy=MachineDeploy()), - ) + with monkeypatch.context(): + monkeypatch.chdir(test_flake_with_core.path) + monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey) - create_machine(opts) - (test_flake_with_core.path / "machines" / "machine1" / "facter.json").write_text( - json.dumps( - { - "version": 1, - "system": "x86_64-linux", - } + cli.run( + [ + "secrets", + "users", + "add", + "--flake", + str(test_flake_with_core.path), + "user1", + age_keys[0].pubkey, + ] + ) + opts = CreateOptions( + clan_dir=Flake(str(base_path)), + machine=Machine(name="machine1", tags=[], deploy=MachineDeploy()), ) - ) - subprocess.run(["git", "add", "."], cwd=test_flake_with_core.path, check=True) - inventory: Inventory = {} + create_machine(opts) + ( + test_flake_with_core.path / "machines" / "machine1" / "facter.json" + ).write_text( + json.dumps( + { + "version": 1, + "system": "x86_64-linux", + } + ) + ) + subprocess.run(["git", "add", "."], cwd=test_flake_with_core.path, check=True) - inventory["services"] = { - "borgbackup": { - "borg1": { - "meta": {"name": "borg1"}, - "roles": { - "client": {"machines": ["machine1"]}, - "server": {"machines": ["machine1"]}, - }, + inventory: Inventory = {} + + inventory["services"] = { + "borgbackup": { + "borg1": { + "meta": {"name": "borg1"}, + "roles": { + "client": {"machines": ["machine1"]}, + "server": {"machines": ["machine1"]}, + }, + } } } - } - set_inventory(inventory, base_path, "Add borgbackup service") + set_inventory(inventory, base_path, "Add borgbackup service") - # cmd = ["facts", "generate", "--flake", str(test_flake_with_core.path), "machine1"] - cmd = ["vars", "generate", "--flake", str(test_flake_with_core.path), "machine1"] - - cli.run(cmd) - - machine = MachineMachine( - name="machine1", flake=Flake(str(test_flake_with_core.path)) - ) - - generator = None - - for gen in machine.vars_generators: - if gen.name == "borgbackup": - generator = gen - break - - assert generator - - ssh_key = machine.public_vars_store.get(generator, "borgbackup.ssh.pub") - - cmd = nix_eval( - [ - f"{base_path}#nixosConfigurations.machine1.config.services.borgbackup.repos", - "--json", + # cmd = ["facts", "generate", "--flake", str(test_flake_with_core.path), "machine1"] + cmd = [ + "vars", + "generate", + "--flake", + str(test_flake_with_core.path), + "machine1", ] - ) - proc = run_no_stdout(cmd) - res = json.loads(proc.stdout.strip()) - assert res["machine1"]["authorizedKeys"] == [ssh_key.decode()] + cli.run(cmd) + + machine = MachineMachine( + name="machine1", flake=Flake(str(test_flake_with_core.path)) + ) + + generator = None + + for gen in machine.vars_generators: + if gen.name == "borgbackup": + generator = gen + break + + assert generator + + ssh_key = machine.public_vars_store.get(generator, "borgbackup.ssh.pub") + + cmd = nix_eval( + [ + f"{base_path}#nixosConfigurations.machine1.config.services.borgbackup.repos", + "--json", + ] + ) + proc = run_no_stdout(cmd) + res = json.loads(proc.stdout.strip()) + + assert res["machine1"]["authorizedKeys"] == [ssh_key.decode()] diff --git a/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py b/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py index 2364d2f9e..7ce3746ea 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py +++ b/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py @@ -75,9 +75,10 @@ def _test_identities( ] ) - with monkeypatch.context(): - monkeypatch.setenv("SOPS_NIX_SECRET", "deadfeed") - monkeypatch.setenv("SOPS_AGE_KEY", admin_age_key.privkey) + with monkeypatch.context() as m: + m.setenv("SOPS_NIX_SECRET", "deadfeed") + m.setenv("SOPS_AGE_KEY", admin_age_key.privkey) + cli.run( [ "secrets", @@ -89,6 +90,7 @@ def _test_identities( test_secret_name, ] ) + assert_secrets_file_recipients( test_flake.path, test_secret_name, @@ -157,87 +159,92 @@ def test_users( age_keys: list["KeyPair"], monkeypatch: pytest.MonkeyPatch, ) -> None: - _test_identities("users", test_flake, capture_output, age_keys, monkeypatch) + with monkeypatch.context(): + _test_identities("users", test_flake, capture_output, age_keys, monkeypatch) - # some additional user-specific tests + # some additional user-specific tests - admin_key = age_keys[2] - sops_folder = test_flake.path / "sops" + admin_key = age_keys[2] + sops_folder = test_flake.path / "sops" - user_keys = { - "bob": [age_keys[0], age_keys[1]], - "alice": [age_keys[2]], - "charlie": [age_keys[3], age_keys[4]], - } + user_keys = { + "bob": [age_keys[0], age_keys[1]], + "alice": [age_keys[2]], + "charlie": [age_keys[3], age_keys[4]], + } - for user, keys in user_keys.items(): - key_args = [f"--age-key={key.pubkey}" for key in keys] + monkeypatch.setenv("SOPS_NIX_SECRET", "deadfeed") - # add the user keys - cli.run( - [ - "secrets", - "users", - "add", - "--flake", - str(test_flake.path), - user, - *key_args, - ] - ) - assert (sops_folder / "users" / user / "key.json").exists() + for user, keys in user_keys.items(): + key_args = [f"--age-key={key.pubkey}" for key in keys] - # check they are returned in get - with capture_output as output: - cli.run(["secrets", "users", "get", "--flake", str(test_flake.path), user]) + # add the user keys + cli.run( + [ + "secrets", + "users", + "add", + "--flake", + str(test_flake.path), + user, + *key_args, + ] + ) + assert (sops_folder / "users" / user / "key.json").exists() - for key in keys: - assert key.pubkey in output.out + # check they are returned in get + with capture_output as output: + cli.run( + ["secrets", "users", "get", "--flake", str(test_flake.path), user] + ) - # set a secret - secret_name = f"{user}_secret" - cli.run( - [ - "secrets", - "set", - "--flake", - str(test_flake.path), - "--user", - user, + for key in keys: + assert key.pubkey in output.out + + # set a secret + secret_name = f"{user}_secret" + cli.run( + [ + "secrets", + "set", + "--flake", + str(test_flake.path), + "--user", + user, + secret_name, + ] + ) + + # check the secret has each of our user's keys as a recipient + # in addition the admin key should be there + assert_secrets_file_recipients( + test_flake.path, secret_name, - ] - ) + expected_age_recipients_keypairs=[admin_key, *keys], + ) - # check the secret has each of our user's keys as a recipient - # in addition the admin key should be there - assert_secrets_file_recipients( - test_flake.path, - secret_name, - expected_age_recipients_keypairs=[admin_key, *keys], - ) + if len(keys) == 1: + continue - if len(keys) == 1: - continue + # remove one of the keys + cli.run( + [ + "secrets", + "users", + "remove-key", + "--flake", + str(test_flake.path), + user, + keys[0].pubkey, + ] + ) - # remove one of the keys - cli.run( - [ - "secrets", - "users", - "remove-key", - "--flake", - str(test_flake.path), - user, - keys[0].pubkey, - ] - ) - - # check the secret has been updated - assert_secrets_file_recipients( - test_flake.path, - secret_name, - expected_age_recipients_keypairs=[admin_key, *keys[1:]], - ) + # check the secret has been updated + assert_secrets_file_recipients( + test_flake.path, + secret_name, + expected_age_recipients_keypairs=[admin_key, *keys[1:]], + ) def test_machines( @@ -368,6 +375,7 @@ def test_groups( with monkeypatch.context(): monkeypatch.setenv("SOPS_NIX_SECRET", "deafbeef") monkeypatch.setenv("SOPS_AGE_KEY", admin_age_key.privkey) + cli.run( [ "secrets", @@ -539,11 +547,20 @@ def test_secrets( # Read the key that was generated with capture_output as output: cli.run(["secrets", "key", "show", "--flake", str(test_flake.path)]) - key = json.loads(output.out)["publickey"] - assert key.startswith("age1") + + key = json.loads(output.out) + assert key["publickey"].startswith("age1") # Add testuser with the key that was generated for the clan cli.run( - ["secrets", "users", "add", "--flake", str(test_flake.path), "testuser", key] + [ + "secrets", + "users", + "add", + "--flake", + str(test_flake.path), + "testuser", + key["publickey"], + ] ) with pytest.raises(ClanError): # does not exist yet @@ -855,6 +872,7 @@ def test_secrets_key_generate_gpg( "testuser", ] ) + with capture_output as output: cli.run( [ @@ -873,8 +891,12 @@ def test_secrets_key_generate_gpg( assert key["type"] == "pgp" assert key["publickey"] == gpg_key.fingerprint - monkeypatch.setenv("SOPS_NIX_SECRET", "secret-value") - cli.run(["secrets", "set", "--flake", str(test_flake.path), "secret-name"]) - with capture_output as output: - cli.run(["secrets", "get", "--flake", str(test_flake.path), "secret-name"]) - assert output.out == "secret-value" + with monkeypatch.context() as m: + m.setenv("SOPS_NIX_SECRET", "secret-value") + + cli.run(["secrets", "set", "--flake", str(test_flake.path), "secret-name"]) + with capture_output as output: + cli.run( + ["secrets", "get", "--flake", str(test_flake.path), "secret-name"] + ) + assert output.out == "secret-value" diff --git a/pkgs/clan-cli/clan_cli/tests/test_secrets_upload.py b/pkgs/clan-cli/clan_cli/tests/test_secrets_upload.py index 6dd220525..352e9fbb4 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_secrets_upload.py +++ b/pkgs/clan-cli/clan_cli/tests/test_secrets_upload.py @@ -44,50 +44,55 @@ def test_secrets_upload( config["clan"]["networking"]["targetHost"] = addr config["clan"]["core"]["facts"]["secretUploadDirectory"] = str(flake.path / "facts") flake.refresh() - monkeypatch.chdir(str(flake.path)) - monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey) - sops_dir = flake.path / "facts" + with monkeypatch.context(): + monkeypatch.chdir(str(flake.path)) + monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey) - # the flake defines this path as the location where the sops key should be installed - sops_key = sops_dir / "key.txt" - sops_key2 = sops_dir / "key2.txt" + sops_dir = flake.path / "facts" - # Create old state, which should be cleaned up - sops_dir.mkdir() - sops_key.write_text("OLD STATE") - sops_key2.write_text("OLD STATE2") + # the flake defines this path as the location where the sops key should be installed + sops_key = sops_dir / "key.txt" + sops_key2 = sops_dir / "key2.txt" - cli.run( - [ - "secrets", - "users", - "add", - "--flake", - str(flake.path), - "user1", - age_keys[0].pubkey, - ] - ) + # Create old state, which should be cleaned up + sops_dir.mkdir() + sops_key.write_text("OLD STATE") + sops_key2.write_text("OLD STATE2") - cli.run( - [ - "secrets", - "machines", - "add", - "--flake", - str(flake.path), - "vm1", - age_keys[1].pubkey, - ] - ) - monkeypatch.setenv("SOPS_NIX_SECRET", age_keys[0].privkey) - cli.run(["secrets", "set", "--flake", str(flake.path), "vm1-age.key"]) + cli.run( + [ + "secrets", + "users", + "add", + "--flake", + str(flake.path), + "user1", + age_keys[0].pubkey, + ] + ) - flake_path = flake.path.joinpath("flake.nix") + cli.run( + [ + "secrets", + "machines", + "add", + "--flake", + str(flake.path), + "vm1", + age_keys[1].pubkey, + ] + ) - cli.run(["facts", "upload", "--flake", str(flake_path), "vm1"]) + with monkeypatch.context() as m: + m.setenv("SOPS_NIX_SECRET", age_keys[0].privkey) - assert sops_key.exists() - assert sops_key.read_text() == age_keys[0].privkey - assert not sops_key2.exists() + cli.run(["secrets", "set", "--flake", str(flake.path), "vm1-age.key"]) + + flake_path = flake.path.joinpath("flake.nix") + + cli.run(["facts", "upload", "--flake", str(flake_path), "vm1"]) + + assert sops_key.exists() + assert sops_key.read_text() == age_keys[0].privkey + assert not sops_key2.exists() diff --git a/pkgs/clan-cli/clan_cli/tests/test_vms_cli.py b/pkgs/clan-cli/clan_cli/tests/test_vms_cli.py index 81018fa8d..a2a0ed2d9 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_vms_cli.py +++ b/pkgs/clan-cli/clan_cli/tests/test_vms_cli.py @@ -32,27 +32,29 @@ def test_run( test_flake_with_core: FlakeForTest, age_keys: list["KeyPair"], ) -> None: - monkeypatch.chdir(test_flake_with_core.path) - monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey) - cli.run( - [ - "secrets", - "users", - "add", - "user1", - age_keys[0].pubkey, - ] - ) - cli.run( - [ - "secrets", - "groups", - "add-user", - "admins", - "user1", - ] - ) - cli.run(["vms", "run", "--no-block", "vm1", "-c", "shutdown", "-h", "now"]) + with monkeypatch.context(): + monkeypatch.chdir(test_flake_with_core.path) + monkeypatch.setenv("SOPS_AGE_KEY", age_keys[0].privkey) + + cli.run( + [ + "secrets", + "users", + "add", + "user1", + age_keys[0].pubkey, + ] + ) + cli.run( + [ + "secrets", + "groups", + "add-user", + "admins", + "user1", + ] + ) + cli.run(["vms", "run", "--no-block", "vm1", "-c", "shutdown", "-h", "now"]) @pytest.mark.skipif(no_kvm, reason="Requires KVM") From d3e1c0b4e4676a40d31d6bcade16fb5aff460ebb Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Fri, 18 Apr 2025 17:05:47 +0100 Subject: [PATCH 2/3] fix: multiple user keys in secrets We were not loading all the user keys, only the first one. --- pkgs/clan-cli/clan_cli/secrets/secrets.py | 2 +- pkgs/clan-cli/clan_cli/secrets/sops.py | 2 +- pkgs/clan-cli/clan_cli/tests/age_keys.py | 4 + .../clan_cli/tests/test_secrets_cli.py | 154 ++++++++++++------ 4 files changed, 106 insertions(+), 56 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/secrets/secrets.py b/pkgs/clan-cli/clan_cli/secrets/secrets.py index a4e11eb66..2f1a79a08 100644 --- a/pkgs/clan-cli/clan_cli/secrets/secrets.py +++ b/pkgs/clan-cli/clan_cli/secrets/secrets.py @@ -201,7 +201,7 @@ def encrypt_secret( recipient_keys = collect_keys_for_path(secret_path) - if not admin_keys.intersection(recipient_keys): + if admin_keys not in recipient_keys: recipient_keys.update(admin_keys) files_to_commit.extend( diff --git a/pkgs/clan-cli/clan_cli/secrets/sops.py b/pkgs/clan-cli/clan_cli/secrets/sops.py index c43af643a..585557a3e 100644 --- a/pkgs/clan-cli/clan_cli/secrets/sops.py +++ b/pkgs/clan-cli/clan_cli/secrets/sops.py @@ -360,7 +360,7 @@ def maybe_get_user(flake_dir: Path, key: SopsKey) -> set[SopsKey] | None: keys = read_keys(user) if key in keys: - return {SopsKey(key.pubkey, user.name, key.key_type)} + return {SopsKey(key.pubkey, user.name, key.key_type) for key in keys} return None diff --git a/pkgs/clan-cli/clan_cli/tests/age_keys.py b/pkgs/clan-cli/clan_cli/tests/age_keys.py index 084b6d354..b3efb9217 100644 --- a/pkgs/clan-cli/clan_cli/tests/age_keys.py +++ b/pkgs/clan-cli/clan_cli/tests/age_keys.py @@ -62,6 +62,10 @@ KEYS = [ "age1eyyhln9g3cdwtrwpckugvqgtf5p8ugt0426sw38ra3wkc0t4rfhslq7txv", "AGE-SECRET-KEY-1567QKA63Y9P62SHF5TCHVCT5GZX2LZ8NS0E9RKA2QHDA662SF5LQ2VJJYX", ), + KeyPair( + "age1e9ufa6wrsr5danka50qp0np0832uz7jca7s00wyeg2nt3aqnvaks7p4jfr", + "AGE-SECRET-KEY-1Z89SHU9KAF709TTAZDARUWKC7H9TPZW4L8A2PVYSYAF7QVLCNQZQZ07U5J", + ), ] diff --git a/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py b/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py index 7ce3746ea..b06d400ea 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py +++ b/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py @@ -1,7 +1,9 @@ import json import logging import os +import random import re +import string from collections.abc import Iterator from contextlib import contextmanager from typing import TYPE_CHECKING @@ -162,71 +164,95 @@ def test_users( with monkeypatch.context(): _test_identities("users", test_flake, capture_output, age_keys, monkeypatch) - # some additional user-specific tests - admin_key = age_keys[2] - sops_folder = test_flake.path / "sops" +def test_multiple_user_keys( + test_flake: FlakeForTest, + capture_output: CaptureOutput, + age_keys: list["KeyPair"], + monkeypatch: pytest.MonkeyPatch, +) -> None: + sops_folder = test_flake.path / "sops" - user_keys = { - "bob": [age_keys[0], age_keys[1]], - "alice": [age_keys[2]], - "charlie": [age_keys[3], age_keys[4]], - } + users_keys = { + "bob": {age_keys[0], age_keys[1]}, + "alice": {age_keys[2]}, + "charlie": {age_keys[3], age_keys[4], age_keys[5]}, + } - monkeypatch.setenv("SOPS_NIX_SECRET", "deadfeed") + for user, user_keys in users_keys.items(): + # add the user + cli.run( + [ + "secrets", + "users", + "add", + "--flake", + str(test_flake.path), + user, + *[f"--age-key={key.pubkey}" for key in user_keys], + ] + ) + assert (sops_folder / "users" / user / "key.json").exists() - for user, keys in user_keys.items(): - key_args = [f"--age-key={key.pubkey}" for key in keys] + # check they are returned in get + with capture_output as output: + cli.run(["secrets", "users", "get", "--flake", str(test_flake.path), user]) - # add the user keys - cli.run( - [ - "secrets", - "users", - "add", - "--flake", - str(test_flake.path), - user, - *key_args, - ] - ) - assert (sops_folder / "users" / user / "key.json").exists() + for user_key in user_keys: + assert user_key.pubkey in output.out + + # let's do some setting and getting of secrets + + def random_str() -> str: + return "".join(random.choices(string.ascii_letters, k=10)) + + for user_key in user_keys: + # set a secret using each of the user's private keys + with monkeypatch.context(): + secret_name = f"{user}_secret_{random_str()}" + secret_value = random_str() + + monkeypatch.setenv("SOPS_AGE_KEY", user_key.privkey) + monkeypatch.setenv("SOPS_NIX_SECRET", secret_value) - # check they are returned in get - with capture_output as output: cli.run( - ["secrets", "users", "get", "--flake", str(test_flake.path), user] + [ + "secrets", + "set", + "--flake", + str(test_flake.path), + secret_name, + ] ) - for key in keys: - assert key.pubkey in output.out - - # set a secret - secret_name = f"{user}_secret" - cli.run( - [ - "secrets", - "set", - "--flake", - str(test_flake.path), - "--user", - user, + # check the secret has each of our user's keys as a recipient + assert_secrets_file_recipients( + test_flake.path, secret_name, - ] - ) + expected_age_recipients_keypairs=[*user_keys], + ) - # check the secret has each of our user's keys as a recipient - # in addition the admin key should be there - assert_secrets_file_recipients( - test_flake.path, - secret_name, - expected_age_recipients_keypairs=[admin_key, *keys], - ) + # check we can get the secret + with capture_output as output: + cli.run( + ["secrets", "get", "--flake", str(test_flake.path), secret_name] + ) - if len(keys) == 1: - continue + assert secret_value in output.out + + if len(user_keys) == 1: + continue + + # remove one of the user keys, + user_keys_iter = iter(user_keys) + + key_to_remove = next(user_keys_iter) + key_to_encrypt_with = next(user_keys_iter) + + with monkeypatch.context(): + monkeypatch.setenv("SOPS_AGE_KEY", key_to_encrypt_with.privkey) + monkeypatch.setenv("SOPS_NIX_SECRET", "deafbeef") - # remove one of the keys cli.run( [ "secrets", @@ -235,7 +261,7 @@ def test_users( "--flake", str(test_flake.path), user, - keys[0].pubkey, + key_to_remove.pubkey, ] ) @@ -243,7 +269,27 @@ def test_users( assert_secrets_file_recipients( test_flake.path, secret_name, - expected_age_recipients_keypairs=[admin_key, *keys[1:]], + expected_age_recipients_keypairs=list({*user_keys} - {key_to_remove}), + ) + + # add the key back + cli.run( + [ + "secrets", + "users", + "add-key", + "--flake", + str(test_flake.path), + user, + key_to_remove.pubkey, + ] + ) + + # check the secret has been updated + assert_secrets_file_recipients( + test_flake.path, + secret_name, + expected_age_recipients_keypairs=user_keys, ) From a438fe77a711ea47197d18ef705e39a89de223b4 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Mon, 28 Apr 2025 12:54:27 +0100 Subject: [PATCH 3/3] feat: configure age plugins for SOPS in `buildClan` --- docs/site/getting-started/secrets.md | 36 +++ lib/build-clan/interface.nix | 9 + lib/build-clan/module.nix | 1 + lib/build-clan/secrets/interface.nix | 18 ++ .../clan_cli/nix/allowed-packages.json | 6 + pkgs/clan-cli/clan_cli/secrets/groups.py | 3 +- pkgs/clan-cli/clan_cli/secrets/machines.py | 5 +- pkgs/clan-cli/clan_cli/secrets/secrets.py | 22 +- pkgs/clan-cli/clan_cli/secrets/sops.py | 51 ++- pkgs/clan-cli/clan_cli/secrets/users.py | 3 +- .../clan_cli/tests/test_import_sops_cli.py | 21 +- .../clan_cli/tests/test_secrets_cli.py | 301 ++++++++++++------ .../clan_cli/vars/secret_modules/sops.py | 2 + 13 files changed, 357 insertions(+), 121 deletions(-) create mode 100644 lib/build-clan/secrets/interface.nix diff --git a/docs/site/getting-started/secrets.md b/docs/site/getting-started/secrets.md index 8838a9003..55b6b99a0 100644 --- a/docs/site/getting-started/secrets.md +++ b/docs/site/getting-started/secrets.md @@ -71,6 +71,42 @@ AGE-PLUGIN-FIDO2-HMAC-1QQPQZRFR7ZZ2WCV... In the example above, you can specify `# recipient: age1zdy...`, `# public: age1zdy....` or even just `# age1zdy....` +You will need to add an entry into your `flake.nix` to ensure that the necessary `age` plugins +are loaded when using Clan: + +```nix title="flake.nix" +{ + inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz"; + inputs.nixpkgs.follows = "clan-core/nixpkgs"; + + outputs = + { self, clan-core, ... }: + let + clan = clan-core.clanLib.buildClan { + inherit self; + + meta.name = "myclan"; + + # Add Yubikey and FIDO2 HMAC plugins + # Note: the plugins listed here must be available in nixpkgs. + secrets.age.plugins = [ + "age-plugin-yubikey" + "age-plugin-fido2-hmac" + ]; + + machines = { + # elided for brevity + }; + }; + in + { + inherit (clan) nixosConfigurations clanInternals; + + # elided for brevity + }; +} +``` + ### Add Your Public Key(s) ```console diff --git a/lib/build-clan/interface.nix b/lib/build-clan/interface.nix index 405be0470..db090ec2b 100644 --- a/lib/build-clan/interface.nix +++ b/lib/build-clan/interface.nix @@ -108,6 +108,14 @@ in default = { }; }; + secrets = lib.mkOption { + type = types.submodule { imports = [ ./secrets/interface.nix ]; }; + description = '' + Secrets related options such as AGE plugins required to encrypt/decrypt secrets using the CLI. + ''; + default = { }; + }; + pkgsForSystem = lib.mkOption { type = types.functionTo (types.nullOr types.attrs); default = _system: null; @@ -165,6 +173,7 @@ in clanModules = lib.mkOption { type = lib.types.raw; }; source = lib.mkOption { type = lib.types.raw; }; meta = lib.mkOption { type = lib.types.raw; }; + secrets = lib.mkOption { type = lib.types.raw; }; clanLib = lib.mkOption { type = lib.types.raw; }; all-machines-json = lib.mkOption { type = lib.types.raw; }; machines = lib.mkOption { type = lib.types.raw; }; diff --git a/lib/build-clan/module.nix b/lib/build-clan/module.nix index 1e7e7d338..acb7a2519 100644 --- a/lib/build-clan/module.nix +++ b/lib/build-clan/module.nix @@ -219,6 +219,7 @@ in templates = config.templates; inventory = config.inventory; meta = config.inventory.meta; + secrets = config.secrets; source = "${clan-core}"; diff --git a/lib/build-clan/secrets/interface.nix b/lib/build-clan/secrets/interface.nix new file mode 100644 index 000000000..2c6405ab1 --- /dev/null +++ b/lib/build-clan/secrets/interface.nix @@ -0,0 +1,18 @@ +{ + lib, + ... +}: +let + inherit (lib) types; +in +{ + options = { + age.plugins = lib.mkOption { + type = types.listOf (types.strMatching "age-plugin-.*"); + default = [ ]; + description = '' + A list of age plugins which must be available in the shell when encrypting and decrypting secrets. + ''; + }; + }; +} diff --git a/pkgs/clan-cli/clan_cli/nix/allowed-packages.json b/pkgs/clan-cli/clan_cli/nix/allowed-packages.json index ebf9efd71..a7f456bc0 100644 --- a/pkgs/clan-cli/clan_cli/nix/allowed-packages.json +++ b/pkgs/clan-cli/clan_cli/nix/allowed-packages.json @@ -1,5 +1,11 @@ [ "age", + "age-plugin-fido2-hmac", + "age-plugin-ledger", + "age-plugin-se", + "age-plugin-sss", + "age-plugin-tpm", + "age-plugin-yubikey", "avahi", "bash", "bubblewrap", diff --git a/pkgs/clan-cli/clan_cli/secrets/groups.py b/pkgs/clan-cli/clan_cli/secrets/groups.py index 7d4b23438..42d015d0c 100644 --- a/pkgs/clan-cli/clan_cli/secrets/groups.py +++ b/pkgs/clan-cli/clan_cli/secrets/groups.py @@ -240,6 +240,7 @@ def add_group_argument(parser: argparse.ArgumentParser) -> None: def add_secret(flake_dir: Path, group: str, name: str) -> None: secrets.allow_member( + flake_dir, secrets.groups_folder(sops_secrets_folder(flake_dir) / name), sops_groups_folder(flake_dir), group, @@ -267,7 +268,7 @@ def add_secret_command(args: argparse.Namespace) -> None: def remove_secret(flake_dir: Path, group: str, name: str) -> None: updated_paths = secrets.disallow_member( - secrets.groups_folder(sops_secrets_folder(flake_dir) / name), group + flake_dir, secrets.groups_folder(sops_secrets_folder(flake_dir) / name), group ) commit_files( updated_paths, diff --git a/pkgs/clan-cli/clan_cli/secrets/machines.py b/pkgs/clan-cli/clan_cli/secrets/machines.py index fe8d20522..75bd03078 100644 --- a/pkgs/clan-cli/clan_cli/secrets/machines.py +++ b/pkgs/clan-cli/clan_cli/secrets/machines.py @@ -74,6 +74,7 @@ def list_sops_machines(flake_dir: Path) -> list[str]: def add_secret(flake_dir: Path, machine: str, secret_path: Path) -> None: paths = secrets.allow_member( + flake_dir, secrets.machines_folder(secret_path), sops_machines_folder(flake_dir), machine, @@ -87,7 +88,9 @@ def add_secret(flake_dir: Path, machine: str, secret_path: Path) -> None: def remove_secret(flake_dir: Path, machine: str, secret: str) -> None: updated_paths = secrets.disallow_member( - secrets.machines_folder(sops_secrets_folder(flake_dir) / secret), machine + flake_dir, + secrets.machines_folder(sops_secrets_folder(flake_dir) / secret), + machine, ) commit_files( updated_paths, diff --git a/pkgs/clan-cli/clan_cli/secrets/secrets.py b/pkgs/clan-cli/clan_cli/secrets/secrets.py index 2f1a79a08..c8e76a52e 100644 --- a/pkgs/clan-cli/clan_cli/secrets/secrets.py +++ b/pkgs/clan-cli/clan_cli/secrets/secrets.py @@ -86,6 +86,7 @@ def update_secrets( changed_files.extend(cleanup_dangling_symlinks(path / "machines")) changed_files.extend( update_keys( + flake_dir, path, collect_keys_for_path(path), ) @@ -172,6 +173,7 @@ def encrypt_secret( for user in add_users: files_to_commit.extend( allow_member( + flake_dir, users_folder(secret_path), sops_users_folder(flake_dir), user, @@ -182,6 +184,7 @@ def encrypt_secret( for machine in add_machines: files_to_commit.extend( allow_member( + flake_dir, machines_folder(secret_path), sops_machines_folder(flake_dir), machine, @@ -192,6 +195,7 @@ def encrypt_secret( for group in add_groups: files_to_commit.extend( allow_member( + flake_dir, groups_folder(secret_path), sops_groups_folder(flake_dir), group, @@ -206,6 +210,7 @@ def encrypt_secret( files_to_commit.extend( allow_member( + flake_dir, users_folder(secret_path), sops_users_folder(flake_dir), username, @@ -214,7 +219,7 @@ def encrypt_secret( ) secret_path = secret_path / "secret" - encrypt_file(secret_path, value, sorted(recipient_keys)) + encrypt_file(flake_dir, secret_path, value, sorted(recipient_keys)) files_to_commit.append(secret_path) if git_commit: commit_files( @@ -274,7 +279,11 @@ def list_directory(directory: Path) -> str: def allow_member( - group_folder: Path, source_folder: Path, name: str, do_update_keys: bool = True + flake_dir: str | Path, + group_folder: Path, + source_folder: Path, + name: str, + do_update_keys: bool = True, ) -> list[Path]: source = source_folder / name if not source.exists(): @@ -297,6 +306,7 @@ def allow_member( if do_update_keys: changed.extend( update_keys( + flake_dir, group_folder.parent, collect_keys_for_path(group_folder.parent), ) @@ -304,7 +314,7 @@ def allow_member( return changed -def disallow_member(group_folder: Path, name: str) -> list[Path]: +def disallow_member(flake_dir: str | Path, group_folder: Path, name: str) -> list[Path]: target = group_folder / name if not target.exists(): msg = f"{name} does not exist in group in {group_folder}: " @@ -324,7 +334,9 @@ def disallow_member(group_folder: Path, name: str) -> list[Path]: if next(group_folder.parent.iterdir(), None) is None: group_folder.parent.rmdir() - return update_keys(target.parent.parent, collect_keys_for_path(group_folder.parent)) + return update_keys( + flake_dir, target.parent.parent, collect_keys_for_path(group_folder.parent) + ) def has_secret(secret_path: Path) -> bool: @@ -364,7 +376,7 @@ def decrypt_secret(flake_dir: Path, secret_path: Path) -> str: if not path.exists(): msg = f"Secret '{secret_path!s}' does not exist" raise ClanError(msg) - return decrypt_file(path) + return decrypt_file(flake_dir, path) def get_command(args: argparse.Namespace) -> None: diff --git a/pkgs/clan-cli/clan_cli/secrets/sops.py b/pkgs/clan-cli/clan_cli/secrets/sops.py index 585557a3e..70ec7fbc0 100644 --- a/pkgs/clan-cli/clan_cli/secrets/sops.py +++ b/pkgs/clan-cli/clan_cli/secrets/sops.py @@ -18,7 +18,7 @@ from clan_lib.api import API from clan_cli.cmd import Log, RunOpts, run from clan_cli.dirs import user_config_dir from clan_cli.errors import ClanError -from clan_cli.nix import nix_shell +from clan_cli.nix import nix_eval, nix_shell from .folders import sops_users_folder @@ -191,7 +191,41 @@ class Operation(enum.StrEnum): UPDATE_KEYS = "updatekeys" +def load_age_plugins(flake_dir: str | Path) -> list[str]: + if not flake_dir: + msg = "Missing flake directory" + raise ClanError(msg) + + cmd = nix_eval( + [ + f"{flake_dir}#clanInternals.secrets.age.plugins", + "--json", + ] + ) + + try: + result = run(cmd) + except Exception as e: + msg = f"Failed to load age plugins {flake_dir}" + raise ClanError(msg) from e + + json_str = result.stdout.strip() + + try: + plugins = json.loads(json_str) + except json.JSONDecodeError as e: + msg = f"Failed to decode '{json_str}': {e}" + raise ClanError(msg) from e + + if isinstance(plugins, list): + return plugins + + msg = f"Expected a list of age plugins but {type(plugins)!r} was provided" + raise ClanError(msg) + + def sops_run( + flake_dir: str | Path, call: Operation, secret_path: Path, public_keys: Iterable[SopsKey], @@ -249,7 +283,9 @@ def sops_run( raise ClanError(msg) sops_cmd.append(str(secret_path)) - cmd = nix_shell(["sops", "gnupg"], sops_cmd) + age_plugins = load_age_plugins(flake_dir) + + cmd = nix_shell(["sops", "gnupg", *age_plugins], sops_cmd) opts = ( dataclasses.replace(run_opts, env=environ) if run_opts @@ -411,11 +447,14 @@ def ensure_admin_public_keys(flake_dir: Path) -> set[SopsKey]: return user_keys -def update_keys(secret_path: Path, keys: Iterable[SopsKey]) -> list[Path]: +def update_keys( + flake_dir: str | Path, secret_path: Path, keys: Iterable[SopsKey] +) -> list[Path]: secret_path = secret_path / "secret" error_msg = f"Could not update keys for {secret_path}" rc, _ = sops_run( + flake_dir, Operation.UPDATE_KEYS, secret_path, keys, @@ -426,6 +465,7 @@ def update_keys(secret_path: Path, keys: Iterable[SopsKey]) -> list[Path]: def encrypt_file( + flake_dir: str | Path, secret_path: Path, content: str | IO[bytes] | bytes | None, pubkeys: list[SopsKey], @@ -436,6 +476,7 @@ def encrypt_file( if not content: # This will spawn an editor to edit the file. rc, _ = sops_run( + flake_dir, Operation.EDIT, secret_path, pubkeys, @@ -474,6 +515,7 @@ def encrypt_file( msg = f"Invalid content type: {type(content)}" raise ClanError(msg) sops_run( + flake_dir, Operation.ENCRYPT, Path(source.name), pubkeys, @@ -488,11 +530,12 @@ def encrypt_file( Path(source.name).unlink() -def decrypt_file(secret_path: Path) -> str: +def decrypt_file(flake_dir: str | Path, secret_path: Path) -> str: # decryption uses private keys from the environment or default paths: no_public_keys_needed: list[SopsKey] = [] _, stdout = sops_run( + flake_dir, Operation.DECRYPT, secret_path, no_public_keys_needed, diff --git a/pkgs/clan-cli/clan_cli/secrets/users.py b/pkgs/clan-cli/clan_cli/secrets/users.py index 4c3bdd88f..6c4c5f8a6 100644 --- a/pkgs/clan-cli/clan_cli/secrets/users.py +++ b/pkgs/clan-cli/clan_cli/secrets/users.py @@ -93,6 +93,7 @@ def list_users(flake_dir: Path) -> list[str]: def add_secret(flake_dir: Path, user: str, secret: str) -> None: updated_paths = secrets.allow_member( + flake_dir, secrets.users_folder(sops_secrets_folder(flake_dir) / secret), sops_users_folder(flake_dir), user, @@ -106,7 +107,7 @@ def add_secret(flake_dir: Path, user: str, secret: str) -> None: def remove_secret(flake_dir: Path, user: str, secret: str) -> None: updated_paths = secrets.disallow_member( - secrets.users_folder(sops_secrets_folder(flake_dir) / secret), user + flake_dir, secrets.users_folder(sops_secrets_folder(flake_dir) / secret), user ) commit_files( updated_paths, diff --git a/pkgs/clan-cli/clan_cli/tests/test_import_sops_cli.py b/pkgs/clan-cli/clan_cli/tests/test_import_sops_cli.py index e19d3da5d..0ad719be2 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_import_sops_cli.py +++ b/pkgs/clan-cli/clan_cli/tests/test_import_sops_cli.py @@ -10,9 +10,10 @@ if TYPE_CHECKING: from .age_keys import KeyPair +@pytest.mark.with_core def test_import_sops( test_root: Path, - test_flake: FlakeForTest, + test_flake_with_core: FlakeForTest, capture_output: CaptureOutput, monkeypatch: pytest.MonkeyPatch, age_keys: list["KeyPair"], @@ -24,7 +25,7 @@ def test_import_sops( "machines", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "machine1", age_keys[0].pubkey, ] @@ -35,7 +36,7 @@ def test_import_sops( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "user1", age_keys[1].pubkey, ] @@ -46,7 +47,7 @@ def test_import_sops( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "user2", age_keys[2].pubkey, ] @@ -57,7 +58,7 @@ def test_import_sops( "groups", "add-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "user1", ] @@ -68,7 +69,7 @@ def test_import_sops( "groups", "add-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "user2", ] @@ -80,7 +81,7 @@ def test_import_sops( "secrets", "import-sops", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "--group", "group1", "--machine", @@ -90,11 +91,13 @@ def test_import_sops( cli.run(cmd) with capture_output as output: - cli.run(["secrets", "users", "list", "--flake", str(test_flake.path)]) + cli.run(["secrets", "users", "list", "--flake", str(test_flake_with_core.path)]) users = sorted(output.out.rstrip().split()) assert users == ["user1", "user2"] with capture_output as output: - cli.run(["secrets", "get", "--flake", str(test_flake.path), "secret-key"]) + cli.run( + ["secrets", "get", "--flake", str(test_flake_with_core.path), "secret-key"] + ) assert output.out == "secret-value" diff --git a/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py b/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py index b06d400ea..a63d539e7 100644 --- a/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py +++ b/pkgs/clan-cli/clan_cli/tests/test_secrets_cli.py @@ -22,14 +22,15 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) +@pytest.mark.with_core def _test_identities( what: str, - test_flake: FlakeForTest, + test_flake_with_core: FlakeForTest, capture_output: CaptureOutput, age_keys: list["KeyPair"], monkeypatch: pytest.MonkeyPatch, ) -> None: - sops_folder = test_flake.path / "sops" + sops_folder = test_flake_with_core.path / "sops" what_singular = what[:-1] test_secret_name = f"{what_singular}_secret" @@ -45,7 +46,7 @@ def _test_identities( what, "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "foo", age_keys[0].pubkey, ] @@ -58,7 +59,7 @@ def _test_identities( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin", admin_age_key.pubkey, ] @@ -71,7 +72,7 @@ def _test_identities( what, "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "foo", age_keys[0].pubkey, ] @@ -86,7 +87,7 @@ def _test_identities( "secrets", "set", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), f"--{what_singular}", "foo", test_secret_name, @@ -94,7 +95,7 @@ def _test_identities( ) assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, test_secret_name, expected_age_recipients_keypairs=[age_keys[0], admin_age_key], ) @@ -107,14 +108,14 @@ def _test_identities( what, "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "-f", "foo", age_keys[1].privkey, ] ) assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, test_secret_name, expected_age_recipients_keypairs=[age_keys[1], admin_age_key], ) @@ -126,24 +127,35 @@ def _test_identities( what, "get", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "foo", ] ) assert age_keys[1].pubkey in output.out with capture_output as output: - cli.run(["secrets", what, "list", "--flake", str(test_flake.path)]) + cli.run(["secrets", what, "list", "--flake", str(test_flake_with_core.path)]) assert "foo" in output.out - cli.run(["secrets", what, "remove", "--flake", str(test_flake.path), "foo"]) + cli.run( + ["secrets", what, "remove", "--flake", str(test_flake_with_core.path), "foo"] + ) assert not (sops_folder / what / "foo" / "key.json").exists() with pytest.raises(ClanError): # already removed - cli.run(["secrets", what, "remove", "--flake", str(test_flake.path), "foo"]) + cli.run( + [ + "secrets", + what, + "remove", + "--flake", + str(test_flake_with_core.path), + "foo", + ] + ) with capture_output as output: - cli.run(["secrets", what, "list", "--flake", str(test_flake.path)]) + cli.run(["secrets", what, "list", "--flake", str(test_flake_with_core.path)]) assert "foo" not in output.out user_or_machine_symlink = sops_folder / "secrets" / test_secret_name / what / "foo" @@ -155,23 +167,27 @@ def _test_identities( assert not user_or_machine_symlink.exists(follow_symlinks=False), err_msg +@pytest.mark.with_core def test_users( - test_flake: FlakeForTest, + test_flake_with_core: FlakeForTest, capture_output: CaptureOutput, age_keys: list["KeyPair"], monkeypatch: pytest.MonkeyPatch, ) -> None: with monkeypatch.context(): - _test_identities("users", test_flake, capture_output, age_keys, monkeypatch) + _test_identities( + "users", test_flake_with_core, capture_output, age_keys, monkeypatch + ) +@pytest.mark.with_core def test_multiple_user_keys( - test_flake: FlakeForTest, + test_flake_with_core: FlakeForTest, capture_output: CaptureOutput, age_keys: list["KeyPair"], monkeypatch: pytest.MonkeyPatch, ) -> None: - sops_folder = test_flake.path / "sops" + sops_folder = test_flake_with_core.path / "sops" users_keys = { "bob": {age_keys[0], age_keys[1]}, @@ -187,7 +203,7 @@ def test_multiple_user_keys( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), user, *[f"--age-key={key.pubkey}" for key in user_keys], ] @@ -196,7 +212,16 @@ def test_multiple_user_keys( # check they are returned in get with capture_output as output: - cli.run(["secrets", "users", "get", "--flake", str(test_flake.path), user]) + cli.run( + [ + "secrets", + "users", + "get", + "--flake", + str(test_flake_with_core.path), + user, + ] + ) for user_key in user_keys: assert user_key.pubkey in output.out @@ -220,14 +245,14 @@ def test_multiple_user_keys( "secrets", "set", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), secret_name, ] ) # check the secret has each of our user's keys as a recipient assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, secret_name, expected_age_recipients_keypairs=[*user_keys], ) @@ -235,7 +260,13 @@ def test_multiple_user_keys( # check we can get the secret with capture_output as output: cli.run( - ["secrets", "get", "--flake", str(test_flake.path), secret_name] + [ + "secrets", + "get", + "--flake", + str(test_flake_with_core.path), + secret_name, + ] ) assert secret_value in output.out @@ -259,7 +290,7 @@ def test_multiple_user_keys( "users", "remove-key", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), user, key_to_remove.pubkey, ] @@ -267,7 +298,7 @@ def test_multiple_user_keys( # check the secret has been updated assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, secret_name, expected_age_recipients_keypairs=list({*user_keys} - {key_to_remove}), ) @@ -279,7 +310,7 @@ def test_multiple_user_keys( "users", "add-key", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), user, key_to_remove.pubkey, ] @@ -287,29 +318,35 @@ def test_multiple_user_keys( # check the secret has been updated assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, secret_name, expected_age_recipients_keypairs=user_keys, ) +@pytest.mark.with_core def test_machines( - test_flake: FlakeForTest, + test_flake_with_core: FlakeForTest, capture_output: CaptureOutput, age_keys: list["KeyPair"], monkeypatch: pytest.MonkeyPatch, ) -> None: - _test_identities("machines", test_flake, capture_output, age_keys, monkeypatch) + _test_identities( + "machines", test_flake_with_core, capture_output, age_keys, monkeypatch + ) +@pytest.mark.with_core def test_groups( - test_flake: FlakeForTest, + test_flake_with_core: FlakeForTest, capture_output: CaptureOutput, age_keys: list["KeyPair"], monkeypatch: pytest.MonkeyPatch, ) -> None: with capture_output as output: - cli.run(["secrets", "groups", "list", "--flake", str(test_flake.path)]) + cli.run( + ["secrets", "groups", "list", "--flake", str(test_flake_with_core.path)] + ) assert output.out == "" machine1_age_key = age_keys[0] @@ -323,7 +360,7 @@ def test_groups( "groups", "add-machine", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "machine1", ] @@ -335,7 +372,7 @@ def test_groups( "groups", "add-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "groupb1", "user1", ] @@ -346,7 +383,7 @@ def test_groups( "machines", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "machine1", machine1_age_key.pubkey, ] @@ -357,7 +394,7 @@ def test_groups( "groups", "add-machine", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "machine1", ] @@ -370,7 +407,7 @@ def test_groups( "groups", "add-machine", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "machine1", ] @@ -382,7 +419,7 @@ def test_groups( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "user1", user1_age_key.pubkey, ] @@ -393,7 +430,7 @@ def test_groups( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin", admin_age_key.pubkey, ] @@ -404,14 +441,16 @@ def test_groups( "groups", "add-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "user1", ] ) with capture_output as output: - cli.run(["secrets", "groups", "list", "--flake", str(test_flake.path)]) + cli.run( + ["secrets", "groups", "list", "--flake", str(test_flake_with_core.path)] + ) out = output.out assert "user1" in out assert "machine1" in out @@ -427,7 +466,7 @@ def test_groups( "secrets", "set", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "--group", "group1", secret_name, @@ -435,7 +474,7 @@ def test_groups( ) assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, secret_name, expected_age_recipients_keypairs=[ machine1_age_key, @@ -454,13 +493,13 @@ def test_groups( "groups", "remove-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "user1", ] ) assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, secret_name, expected_age_recipients_keypairs=[machine1_age_key, admin_age_key], err_msg=( @@ -476,13 +515,13 @@ def test_groups( "groups", "add-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "user1", ] ) assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, secret_name, expected_age_recipients_keypairs=[ machine1_age_key, @@ -498,12 +537,12 @@ def test_groups( "users", "remove", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "user1", ] ) assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, secret_name, expected_age_recipients_keypairs=[machine1_age_key, admin_age_key], err_msg=( @@ -518,13 +557,13 @@ def test_groups( "groups", "remove-machine", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "group1", "machine1", ] ) assert_secrets_file_recipients( - test_flake.path, + test_flake_with_core.path, secret_name, expected_age_recipients_keypairs=[admin_age_key], err_msg=( @@ -533,11 +572,11 @@ def test_groups( ), ) - first_group = next((test_flake.path / "sops" / "groups").iterdir(), None) + first_group = next((test_flake_with_core.path / "sops" / "groups").iterdir(), None) assert first_group is None # Check if the symlink to the group was removed from our foo test secret: - group_symlink = test_flake.path / "sops/secrets/foo/groups/group1" + group_symlink = test_flake_with_core.path / "sops/secrets/foo/groups/group1" err_msg = ( "Symlink to group1's key in foo secret " "was not cleaned up after group1 was removed" @@ -574,25 +613,30 @@ def use_gpg_key(key: GpgKey, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.setenv("SOPS_AGE_KEY", old_key) +@pytest.mark.with_core def test_secrets( - test_flake: FlakeForTest, + test_flake_with_core: FlakeForTest, capture_output: CaptureOutput, monkeypatch: pytest.MonkeyPatch, gpg_key: GpgKey, age_keys: list["KeyPair"], ) -> None: with capture_output as output: - cli.run(["secrets", "list", "--flake", str(test_flake.path)]) + cli.run(["secrets", "list", "--flake", str(test_flake_with_core.path)]) assert output.out == "" # Generate a new key for the clan - monkeypatch.setenv("SOPS_AGE_KEY_FILE", str(test_flake.path / ".." / "age.key")) + monkeypatch.setenv( + "SOPS_AGE_KEY_FILE", str(test_flake_with_core.path / ".." / "age.key") + ) with capture_output as output: - cli.run(["secrets", "key", "generate", "--flake", str(test_flake.path)]) + cli.run( + ["secrets", "key", "generate", "--flake", str(test_flake_with_core.path)] + ) assert "age private key" in output.out # Read the key that was generated with capture_output as output: - cli.run(["secrets", "key", "show", "--flake", str(test_flake.path)]) + cli.run(["secrets", "key", "show", "--flake", str(test_flake_with_core.path)]) key = json.loads(output.out) assert key["publickey"].startswith("age1") @@ -603,41 +647,71 @@ def test_secrets( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "testuser", key["publickey"], ] ) with pytest.raises(ClanError): # does not exist yet - cli.run(["secrets", "get", "--flake", str(test_flake.path), "nonexisting"]) + cli.run( + ["secrets", "get", "--flake", str(test_flake_with_core.path), "nonexisting"] + ) monkeypatch.setenv("SOPS_NIX_SECRET", "foo") - cli.run(["secrets", "set", "--flake", str(test_flake.path), "initialkey"]) + cli.run(["secrets", "set", "--flake", str(test_flake_with_core.path), "initialkey"]) with capture_output as output: - cli.run(["secrets", "get", "--flake", str(test_flake.path), "initialkey"]) + cli.run( + ["secrets", "get", "--flake", str(test_flake_with_core.path), "initialkey"] + ) assert output.out == "foo" with capture_output as output: - cli.run(["secrets", "users", "list", "--flake", str(test_flake.path)]) + cli.run(["secrets", "users", "list", "--flake", str(test_flake_with_core.path)]) users = output.out.rstrip().split("\n") assert len(users) == 1, f"users: {users}" owner = users[0] monkeypatch.setenv("EDITOR", "cat") - cli.run(["secrets", "set", "--edit", "--flake", str(test_flake.path), "initialkey"]) + cli.run( + [ + "secrets", + "set", + "--edit", + "--flake", + str(test_flake_with_core.path), + "initialkey", + ] + ) monkeypatch.delenv("EDITOR") - cli.run(["secrets", "rename", "--flake", str(test_flake.path), "initialkey", "key"]) + cli.run( + [ + "secrets", + "rename", + "--flake", + str(test_flake_with_core.path), + "initialkey", + "key", + ] + ) with capture_output as output: - cli.run(["secrets", "list", "--flake", str(test_flake.path)]) + cli.run(["secrets", "list", "--flake", str(test_flake_with_core.path)]) assert output.out == "key\n" with capture_output as output: - cli.run(["secrets", "list", "--flake", str(test_flake.path), "nonexisting"]) + cli.run( + [ + "secrets", + "list", + "--flake", + str(test_flake_with_core.path), + "nonexisting", + ] + ) assert output.out == "" with capture_output as output: - cli.run(["secrets", "list", "--flake", str(test_flake.path), "key"]) + cli.run(["secrets", "list", "--flake", str(test_flake_with_core.path), "key"]) assert output.out == "key\n" # using the `age_keys` KeyPair, add a machine and rotate its key @@ -648,7 +722,7 @@ def test_secrets( "machines", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "machine1", age_keys[1].pubkey, ] @@ -659,18 +733,22 @@ def test_secrets( "machines", "add-secret", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "machine1", "key", ] ) with capture_output as output: - cli.run(["secrets", "machines", "list", "--flake", str(test_flake.path)]) + cli.run( + ["secrets", "machines", "list", "--flake", str(test_flake_with_core.path)] + ) assert output.out == "machine1\n" with use_age_key(age_keys[1].privkey, monkeypatch): with capture_output as output: - cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) + cli.run( + ["secrets", "get", "--flake", str(test_flake_with_core.path), "key"] + ) assert output.out == "foo" # rotate machines key @@ -680,7 +758,7 @@ def test_secrets( "machines", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "-f", "machine1", age_keys[0].privkey, @@ -690,7 +768,9 @@ def test_secrets( # should also rotate the encrypted secret with use_age_key(age_keys[0].privkey, monkeypatch): with capture_output as output: - cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) + cli.run( + ["secrets", "get", "--flake", str(test_flake_with_core.path), "key"] + ) assert output.out == "foo" cli.run( @@ -699,7 +779,7 @@ def test_secrets( "machines", "remove-secret", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "machine1", "key", ] @@ -711,7 +791,7 @@ def test_secrets( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "user1", age_keys[1].pubkey, ] @@ -722,13 +802,13 @@ def test_secrets( "users", "add-secret", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "user1", "key", ] ) with capture_output as output, use_age_key(age_keys[1].privkey, monkeypatch): - cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) + cli.run(["secrets", "get", "--flake", str(test_flake_with_core.path), "key"]) assert output.out == "foo" cli.run( [ @@ -736,7 +816,7 @@ def test_secrets( "users", "remove-secret", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "user1", "key", ] @@ -749,7 +829,7 @@ def test_secrets( "groups", "add-secret", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin-group", "key", ] @@ -760,7 +840,7 @@ def test_secrets( "groups", "add-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin-group", "user1", ] @@ -771,7 +851,7 @@ def test_secrets( "groups", "add-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin-group", owner, ] @@ -782,7 +862,7 @@ def test_secrets( "groups", "add-secret", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin-group", "key", ] @@ -793,7 +873,7 @@ def test_secrets( "secrets", "set", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "--group", "admin-group", "key2", @@ -802,7 +882,9 @@ def test_secrets( with use_age_key(age_keys[1].privkey, monkeypatch): with capture_output as output: - cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) + cli.run( + ["secrets", "get", "--flake", str(test_flake_with_core.path), "key"] + ) assert output.out == "foo" # Add an user with a GPG key @@ -812,7 +894,7 @@ def test_secrets( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "--pgp-key", gpg_key.fingerprint, "user2", @@ -826,7 +908,7 @@ def test_secrets( "groups", "add-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin-group", "user2", ] @@ -834,7 +916,9 @@ def test_secrets( with use_gpg_key(gpg_key, monkeypatch): # user2 with capture_output as output: - cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) + cli.run( + ["secrets", "get", "--flake", str(test_flake_with_core.path), "key"] + ) assert output.out == "foo" cli.run( @@ -843,7 +927,7 @@ def test_secrets( "groups", "remove-user", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin-group", "user2", ] @@ -854,7 +938,7 @@ def test_secrets( capture_output as output, ): # user2 is not in the group anymore - cli.run(["secrets", "get", "--flake", str(test_flake.path), "key"]) + cli.run(["secrets", "get", "--flake", str(test_flake_with_core.path), "key"]) print(output.out) cli.run( @@ -863,22 +947,23 @@ def test_secrets( "groups", "remove-secret", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "admin-group", "key", ] ) - cli.run(["secrets", "remove", "--flake", str(test_flake.path), "key"]) - cli.run(["secrets", "remove", "--flake", str(test_flake.path), "key2"]) + cli.run(["secrets", "remove", "--flake", str(test_flake_with_core.path), "key"]) + cli.run(["secrets", "remove", "--flake", str(test_flake_with_core.path), "key2"]) with capture_output as output: - cli.run(["secrets", "list", "--flake", str(test_flake.path)]) + cli.run(["secrets", "list", "--flake", str(test_flake_with_core.path)]) assert output.out == "" +@pytest.mark.with_core def test_secrets_key_generate_gpg( - test_flake: FlakeForTest, + test_flake_with_core: FlakeForTest, capture_output: CaptureOutput, monkeypatch: pytest.MonkeyPatch, gpg_key: GpgKey, @@ -893,14 +978,16 @@ def test_secrets_key_generate_gpg( "key", "generate", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), ] ) assert "age private key" not in output.out assert re.match(r"PGP key.+is already set", output.err) is not None with capture_output as output: - cli.run(["secrets", "key", "show", "--flake", str(test_flake.path)]) + cli.run( + ["secrets", "key", "show", "--flake", str(test_flake_with_core.path)] + ) key = json.loads(output.out) assert key["type"] == "pgp" assert key["publickey"] == gpg_key.fingerprint @@ -912,7 +999,7 @@ def test_secrets_key_generate_gpg( "users", "add", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "--pgp-key", gpg_key.fingerprint, "testuser", @@ -926,7 +1013,7 @@ def test_secrets_key_generate_gpg( "users", "get", "--flake", - str(test_flake.path), + str(test_flake_with_core.path), "testuser", ] ) @@ -940,9 +1027,23 @@ def test_secrets_key_generate_gpg( with monkeypatch.context() as m: m.setenv("SOPS_NIX_SECRET", "secret-value") - cli.run(["secrets", "set", "--flake", str(test_flake.path), "secret-name"]) + cli.run( + [ + "secrets", + "set", + "--flake", + str(test_flake_with_core.path), + "secret-name", + ] + ) with capture_output as output: cli.run( - ["secrets", "get", "--flake", str(test_flake.path), "secret-name"] + [ + "secrets", + "get", + "--flake", + str(test_flake_with_core.path), + "secret-name", + ] ) assert output.out == "secret-value" diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py index 02cb9f283..bd6f7a37a 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py @@ -302,6 +302,7 @@ class SecretStore(StoreBase): for group in self.machine.deployment["sops"]["defaultGroups"]: allow_member( + self.machine.flake_dir, groups_folder(secret_path), sops_groups_folder(self.machine.flake_dir), group, @@ -310,6 +311,7 @@ class SecretStore(StoreBase): ) update_keys( + self.machine.flake_dir, secret_path, collect_keys_for_path(secret_path), )