From 64b5ee31af7dba0ffbc3dfdf98a5fea7e0f94a9a Mon Sep 17 00:00:00 2001 From: Philippe Muller Date: Tue, 30 Apr 2024 23:01:25 +0800 Subject: [PATCH] feat: allow configuration of age identities using strings --- CHANGELOG.md | 5 ++++ README.md | 1 + src/saltstack_age/cli.py | 2 +- src/saltstack_age/identities.py | 17 ++++++++++- src/saltstack_age/renderers/age.py | 9 ++++-- tests/conftest.py | 21 ++++++++++++-- tests/integration/test_cli.py | 26 ++++++++++++++--- .../test_renderer_identity_from_config.py | 7 +++-- ...test_renderer_identity_from_environment.py | 4 +-- .../test_identity_file_from_config.py | 29 +++++++++++++++++++ .../test_identity_from_environment.py | 9 ++++-- ...py => test_identity_string_from_config.py} | 6 ++-- 12 files changed, 117 insertions(+), 19 deletions(-) create mode 100644 tests/unit/renderers/test_identity_file_from_config.py rename tests/unit/renderers/{test_identity_from_config.py => test_identity_string_from_config.py} (77%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf4d96..e13a079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # saltstack-age change log +## Unreleased + +* feat: allow configuration of an identity string using the `AGE_IDENTITY` + environment variable and the `age_identity` configuration directive + ## 0.3.0 * fix: add support for nested pillar data diff --git a/README.md b/README.md index 20e0c50..4f4872a 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ daemon configuration file, or in the daemon environment. | Type | Configuration directive | Environment variable | Expected value | | ------------ | ----------------------- | -------------------- | ---------------------------- | | identity | `age_identity_file` | `AGE_IDENTITY_FILE` | Path of an age identity file | +| identity | `age_identity` | `AGE_IDENTITY` | An age identity string | | passphrase | `age_passphrase` | `AGE_PASSPHRASE` | An age passphrase | You can check this [example configuration](./example/config/minion). diff --git a/src/saltstack_age/cli.py b/src/saltstack_age/cli.py index 6865761..4189ef3 100644 --- a/src/saltstack_age/cli.py +++ b/src/saltstack_age/cli.py @@ -172,7 +172,7 @@ def decrypt(arguments: Namespace) -> None: ) raise SystemExit(-1) - _ = sys.stdout.write(secure_value.decrypt(arguments.identities[0])) + _ = sys.stdout.write(secure_value.decrypt(identities[0])) else: # isinstance(secure_value, PassphraseSecureValue) _ = sys.stdout.write(secure_value.decrypt(get_passphrase(arguments))) diff --git a/src/saltstack_age/identities.py b/src/saltstack_age/identities.py index 3429c73..894ac75 100644 --- a/src/saltstack_age/identities.py +++ b/src/saltstack_age/identities.py @@ -20,7 +20,7 @@ def read_identity_file(path: Path | str) -> pyrage.x25519.Identity: return pyrage.x25519.Identity.from_str(identity_string) -def get_identity_from_environment() -> pyrage.x25519.Identity | None: +def get_identity_file_from_environment() -> pyrage.x25519.Identity | None: path_string = os.environ.get("AGE_IDENTITY_FILE") if path_string is None: @@ -32,3 +32,18 @@ def get_identity_from_environment() -> pyrage.x25519.Identity | None: raise FileNotFoundError(f"AGE_IDENTITY_FILE does not exist: {path}") return read_identity_file(path) + + +def get_identity_string_from_environment() -> pyrage.x25519.Identity | None: + identity_string = os.environ.get("AGE_IDENTITY") + + if identity_string is None: + return None + + return pyrage.x25519.Identity.from_str(identity_string.strip()) + + +def get_identity_from_environment() -> pyrage.x25519.Identity | None: + return ( + get_identity_string_from_environment() or get_identity_file_from_environment() + ) diff --git a/src/saltstack_age/renderers/age.py b/src/saltstack_age/renderers/age.py index 1ad166f..337ff79 100644 --- a/src/saltstack_age/renderers/age.py +++ b/src/saltstack_age/renderers/age.py @@ -33,7 +33,12 @@ def __virtual__() -> str | tuple[bool, str]: # noqa: N807 def _get_identity() -> pyrage.x25519.Identity: - # 1. Try to get identity file from Salt configuration + # Try to get identity string from Salt configuration + identity_string: str | None = __salt__["config.get"]("age_identity") + if identity_string: + return pyrage.x25519.Identity.from_str(identity_string.strip()) + + # Try to get identity file from Salt configuration identity_file_string: str | None = __salt__["config.get"]("age_identity_file") if identity_file_string: identity_file_path = Path(identity_file_string) @@ -45,7 +50,7 @@ def _get_identity() -> pyrage.x25519.Identity: return read_identity_file(identity_file_path) - # 2. Try to get identity from the environment + # Try to get identity from the environment identity = get_identity_from_environment() if identity: return identity diff --git a/tests/conftest.py b/tests/conftest.py index 8d9ed58..c526f7d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,28 @@ from pathlib import Path +import pyrage import pytest +from saltstack_age.identities import read_identity_file ROOT = Path(__file__).parent.parent EXAMPLE_PATH = ROOT / "example" @pytest.fixture() -def example_age_key() -> str: - return str(EXAMPLE_PATH / "config" / "age.key") +def example_age_key_path() -> Path: + return EXAMPLE_PATH / "config" / "age.key" + + +@pytest.fixture() +def example_age_key_path_str(example_age_key_path: Path) -> str: + return str(example_age_key_path) + + +@pytest.fixture() +def example_age_key(example_age_key_path: Path) -> pyrage.x25519.Identity: + return read_identity_file(example_age_key_path) + + +@pytest.fixture() +def example_age_key_str(example_age_key: pyrage.x25519.Identity) -> str: + return str(example_age_key) diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index bf8c0b1..e0f7f7f 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -25,16 +25,16 @@ def test_encrypt__passphrase(capsys: pytest.CaptureFixture[str]) -> None: def test_encrypt__single_recipient( capsys: pytest.CaptureFixture[str], - example_age_key: str, + example_age_key_path_str: str, ) -> None: # Run the CLI tool - main(["-i", example_age_key, "enc", "foo"]) + main(["-i", example_age_key_path_str, "enc", "foo"]) # Ensure we get an identity secure value string secure_value_string = capsys.readouterr().out secure_value = parse_secure_value(secure_value_string) assert isinstance(secure_value, IdentitySecureValue) # Ensure we can decrypt it using the same identity - assert secure_value.decrypt(read_identity_file(example_age_key)) == "foo" + assert secure_value.decrypt(read_identity_file(example_age_key_path_str)) == "foo" def test_encrypt__multiple_recipients( @@ -71,7 +71,7 @@ def test_encrypt__multiple_recipients( @pytest.mark.parametrize( ("environment", "args", "result"), [ - # Test decryption using a single identity file + # Test decryption by using an identity file passed as CLI argument ( None, ( @@ -82,6 +82,24 @@ def test_encrypt__multiple_recipients( ), "test-secret-value", ), + # Test decryption by using an identity file passed through environment + ( + {"AGE_IDENTITY_FILE": "example/config/age.key"}, + ( + "dec", + "ENC[age-identity,YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWHZYRkU2bjc4M2VtaElEZGxudmkwNW95ZHlNZy84K3U4MmlXejIzRkJNCktPbkhLU0h4VXBFYTZUUDlzbFFzdUx5R1VyaDZhd2doNkE2QnFpUmV6OFEKLS0tIFd3Wlg1UWQ3NHEwKyt6bTZkdmp3bWRCTTZkakppTFovbkhBcDhFeGdJazgKnf48DyGjBm2wOpM11YZ0+1btASDDSdgqXiM4SXXEMHhylmW8G9pSoTtovj0aZu9QVA==]", + ), + "test-secret-value", + ), + # Test decryption by using an identity string passed through environment + ( + {"AGE_IDENTITY": str(read_identity_file("example/config/age.key"))}, + ( + "dec", + "ENC[age-identity,YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWHZYRkU2bjc4M2VtaElEZGxudmkwNW95ZHlNZy84K3U4MmlXejIzRkJNCktPbkhLU0h4VXBFYTZUUDlzbFFzdUx5R1VyaDZhd2doNkE2QnFpUmV6OFEKLS0tIFd3Wlg1UWQ3NHEwKyt6bTZkdmp3bWRCTTZkakppTFovbkhBcDhFeGdJazgKnf48DyGjBm2wOpM11YZ0+1btASDDSdgqXiM4SXXEMHhylmW8G9pSoTtovj0aZu9QVA==]", + ), + "test-secret-value", + ), # Test decryption using a passphrase passed through CLI argument ( None, diff --git a/tests/integration/test_renderer_identity_from_config.py b/tests/integration/test_renderer_identity_from_config.py index 84cefd3..2faa5c1 100644 --- a/tests/integration/test_renderer_identity_from_config.py +++ b/tests/integration/test_renderer_identity_from_config.py @@ -8,9 +8,12 @@ @pytest.fixture() -def minion(salt_factories: FactoriesManager, example_age_key: str) -> SaltMinion: +def minion( + salt_factories: FactoriesManager, + example_age_key_path_str: str, +) -> SaltMinion: overrides = MINION_CONFIG.copy() - overrides["age_identity_file"] = example_age_key + overrides["age_identity_file"] = example_age_key_path_str return salt_factories.salt_minion_daemon( random_string("minion-"), overrides=overrides, diff --git a/tests/integration/test_renderer_identity_from_environment.py b/tests/integration/test_renderer_identity_from_environment.py index b22dfbd..28d89d6 100644 --- a/tests/integration/test_renderer_identity_from_environment.py +++ b/tests/integration/test_renderer_identity_from_environment.py @@ -11,9 +11,9 @@ def minion( salt_factories: FactoriesManager, monkeypatch: pytest.MonkeyPatch, - example_age_key: str, + example_age_key_path_str: str, ) -> SaltMinion: - monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key) + monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key_path_str) return salt_factories.salt_minion_daemon( random_string("minion-"), overrides=MINION_CONFIG, diff --git a/tests/unit/renderers/test_identity_file_from_config.py b/tests/unit/renderers/test_identity_file_from_config.py new file mode 100644 index 0000000..5bc8be8 --- /dev/null +++ b/tests/unit/renderers/test_identity_file_from_config.py @@ -0,0 +1,29 @@ +from types import ModuleType +from typing import Any, Callable + +import pytest +from saltstack_age.renderers import age + +from tests.unit.renderers import _test_identity + + +@pytest.fixture() +def config_get(example_age_key_path_str: str) -> Callable[[str], str | None]: + def _config_get(key: str) -> str | None: + if key == "age_identity": + return None + assert key == "age_identity_file" + return example_age_key_path_str + + return _config_get + + +@pytest.fixture() +def configure_loader_modules( + config_get: Callable[[str], str | None], +) -> dict[ModuleType, Any]: + return {age: {"__salt__": {"config.get": config_get}}} + + +def test() -> None: + _test_identity.test() diff --git a/tests/unit/renderers/test_identity_from_environment.py b/tests/unit/renderers/test_identity_from_environment.py index 6939745..5b66188 100644 --- a/tests/unit/renderers/test_identity_from_environment.py +++ b/tests/unit/renderers/test_identity_from_environment.py @@ -12,6 +12,11 @@ def configure_loader_modules() -> dict[ModuleType, Any]: return {age: {"__salt__": {"config.get": lambda _key: None}}} -def test(monkeypatch: pytest.MonkeyPatch, example_age_key: str) -> None: - monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key) +def test_file(monkeypatch: pytest.MonkeyPatch, example_age_key_path_str: str) -> None: + monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key_path_str) + _test_identity.test() + + +def test_string(monkeypatch: pytest.MonkeyPatch, example_age_key_str: str) -> None: + monkeypatch.setenv("AGE_IDENTITY", example_age_key_str) _test_identity.test() diff --git a/tests/unit/renderers/test_identity_from_config.py b/tests/unit/renderers/test_identity_string_from_config.py similarity index 77% rename from tests/unit/renderers/test_identity_from_config.py rename to tests/unit/renderers/test_identity_string_from_config.py index 2380aa6..a4e0bf8 100644 --- a/tests/unit/renderers/test_identity_from_config.py +++ b/tests/unit/renderers/test_identity_string_from_config.py @@ -8,10 +8,10 @@ @pytest.fixture() -def config_get(example_age_key: str) -> Callable[[str], str]: +def config_get(example_age_key_str: str) -> Callable[[str], str]: def _config_get(key: str) -> str: - assert key == "age_identity_file" - return example_age_key + assert key == "age_identity" + return example_age_key_str return _config_get