Skip to content

Commit

Permalink
feat: allow configuration of age identities using strings
Browse files Browse the repository at this point in the history
  • Loading branch information
pmuller committed Apr 30, 2024
1 parent 9ca9965 commit 64b5ee3
Show file tree
Hide file tree
Showing 12 changed files with 117 additions and 19 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion src/saltstack_age/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
17 changes: 16 additions & 1 deletion src/saltstack_age/identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
)
9 changes: 7 additions & 2 deletions src/saltstack_age/renderers/age.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
21 changes: 19 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 22 additions & 4 deletions tests/integration/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
(
Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions tests/integration/test_renderer_identity_from_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/test_renderer_identity_from_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/renderers/test_identity_file_from_config.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 7 additions & 2 deletions tests/unit/renderers/test_identity_from_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit 64b5ee3

Please sign in to comment.