From 93232a809ee3473e95e700913b2342dc044ccc93 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 15 Aug 2026 09:26:40 +0200 Subject: [PATCH] feat: encrypt stored service credentials --- app/security/credentials.py | 20 ++++++++++++++++++++ tests/security/test_credentials.py | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 app/security/credentials.py create mode 100644 tests/security/test_credentials.py diff --git a/app/security/credentials.py b/app/security/credentials.py new file mode 100644 index 0000000..0fbaae9 --- /dev/null +++ b/app/security/credentials.py @@ -0,0 +1,20 @@ +from cryptography.fernet import Fernet, InvalidToken + + +class CredentialCipher: + def __init__(self, key: str) -> None: + try: + self._fernet = Fernet(key.encode("ascii")) + except Exception as exc: + raise ValueError("CREDENTIAL_ENCRYPTION_KEY must be a valid Fernet key") from exc + + def encrypt(self, value: str) -> str: + if not value: + raise ValueError("credential value must not be empty") + return self._fernet.encrypt(value.encode("utf-8")).decode("ascii") + + def decrypt(self, token: str) -> str: + try: + return self._fernet.decrypt(token.encode("ascii")).decode("utf-8") + except InvalidToken as exc: + raise ValueError("stored credential cannot be decrypted") from exc diff --git a/tests/security/test_credentials.py b/tests/security/test_credentials.py new file mode 100644 index 0000000..82e2af9 --- /dev/null +++ b/tests/security/test_credentials.py @@ -0,0 +1,20 @@ +from cryptography.fernet import Fernet + +from app.security.credentials import CredentialCipher + + +def test_round_trip_and_ciphertext_does_not_contain_plaintext() -> None: + cipher = CredentialCipher(Fernet.generate_key().decode("ascii")) + encrypted = cipher.encrypt("secret-password") + + assert "secret-password" not in encrypted + assert cipher.decrypt(encrypted) == "secret-password" + + +def test_empty_credentials_are_rejected() -> None: + cipher = CredentialCipher(Fernet.generate_key().decode("ascii")) + try: + cipher.encrypt("") + except ValueError: + return + raise AssertionError("empty secrets must be rejected")