encrypted.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. # -*- coding: utf-8 -*-
  2. """
  3. Verschlüsselter Secrets-Provider.
  4. Speichert Secrets in einer AES-256 verschlüsselten Datei.
  5. """
  6. from __future__ import annotations
  7. import base64
  8. import hashlib
  9. import json
  10. import os
  11. from datetime import datetime
  12. from pathlib import Path
  13. from typing import Any, TypeVar
  14. from trixy_core.config.secrets.provider import SecretsProvider, SecretValue
  15. T = TypeVar("T")
  16. class EncryptedSecretsProvider(SecretsProvider):
  17. """
  18. Provider für verschlüsselte Secrets.
  19. Verwendet AES-256-GCM für die Verschlüsselung.
  20. Secrets werden in einer JSON-Datei gespeichert.
  21. Example:
  22. # Erstellen mit Passwort
  23. provider = EncryptedSecretsProvider(
  24. path="secrets.enc",
  25. password="my-secret-password"
  26. )
  27. # Secret speichern
  28. provider.set("database_password", "supersecret123")
  29. # Secret laden
  30. password = provider.get("database_password")
  31. """
  32. # AES-256-GCM Parameter
  33. KEY_SIZE = 32 # 256 bits
  34. NONCE_SIZE = 12
  35. TAG_SIZE = 16
  36. SALT_SIZE = 16
  37. ITERATIONS = 100000
  38. def __init__(
  39. self,
  40. path: str | Path,
  41. password: str | None = None,
  42. password_env_var: str = "TRIXY_SECRETS_PASSWORD",
  43. auto_save: bool = True,
  44. ) -> None:
  45. """
  46. Initialisiert den verschlüsselten Provider.
  47. Args:
  48. path: Pfad zur verschlüsselten Datei.
  49. password: Passwort für die Verschlüsselung.
  50. password_env_var: Umgebungsvariable für das Passwort.
  51. auto_save: Ob Änderungen automatisch gespeichert werden.
  52. """
  53. super().__init__("EncryptedSecretsProvider")
  54. self._path = Path(path)
  55. self._auto_save = auto_save
  56. self._secrets: dict[str, Any] = {}
  57. self._dirty = False
  58. # Passwort ermitteln
  59. self._password = password or os.environ.get(password_env_var)
  60. if not self._password:
  61. raise ValueError(
  62. f"Passwort erforderlich. Setze '{password_env_var}' oder übergebe 'password'"
  63. )
  64. # Schlüssel ableiten
  65. self._salt: bytes | None = None
  66. self._key: bytes | None = None
  67. # Datei laden falls vorhanden
  68. if self._path.exists():
  69. self._load()
  70. def _derive_key(self, salt: bytes) -> bytes:
  71. """Leitet den Verschlüsselungsschlüssel vom Passwort ab."""
  72. return hashlib.pbkdf2_hmac(
  73. "sha256",
  74. self._password.encode("utf-8"),
  75. salt,
  76. self.ITERATIONS,
  77. dklen=self.KEY_SIZE,
  78. )
  79. def _encrypt(self, data: bytes) -> bytes:
  80. """Verschlüsselt Daten mit AES-256-GCM."""
  81. try:
  82. from cryptography.hazmat.primitives.ciphers.aead import AESGCM
  83. except ImportError:
  84. raise ImportError(
  85. "cryptography ist erforderlich für EncryptedSecretsProvider"
  86. )
  87. # Neuen Salt generieren
  88. salt = os.urandom(self.SALT_SIZE)
  89. key = self._derive_key(salt)
  90. # Verschlüsseln
  91. nonce = os.urandom(self.NONCE_SIZE)
  92. aesgcm = AESGCM(key)
  93. ciphertext = aesgcm.encrypt(nonce, data, None)
  94. # Format: salt + nonce + ciphertext (enthält Tag)
  95. return salt + nonce + ciphertext
  96. def _decrypt(self, encrypted: bytes) -> bytes:
  97. """Entschlüsselt Daten mit AES-256-GCM."""
  98. try:
  99. from cryptography.hazmat.primitives.ciphers.aead import AESGCM
  100. except ImportError:
  101. raise ImportError(
  102. "cryptography ist erforderlich für EncryptedSecretsProvider"
  103. )
  104. # Daten aufteilen
  105. salt = encrypted[:self.SALT_SIZE]
  106. nonce = encrypted[self.SALT_SIZE:self.SALT_SIZE + self.NONCE_SIZE]
  107. ciphertext = encrypted[self.SALT_SIZE + self.NONCE_SIZE:]
  108. # Schlüssel ableiten und entschlüsseln
  109. key = self._derive_key(salt)
  110. aesgcm = AESGCM(key)
  111. try:
  112. return aesgcm.decrypt(nonce, ciphertext, None)
  113. except Exception as e:
  114. raise ValueError(f"Entschlüsselung fehlgeschlagen: {e}")
  115. def _load(self) -> None:
  116. """Lädt und entschlüsselt die Secrets-Datei."""
  117. with open(self._path, "rb") as f:
  118. encrypted = f.read()
  119. decrypted = self._decrypt(encrypted)
  120. self._secrets = json.loads(decrypted.decode("utf-8"))
  121. self._dirty = False
  122. def _save(self) -> None:
  123. """Verschlüsselt und speichert die Secrets."""
  124. data = json.dumps(self._secrets).encode("utf-8")
  125. encrypted = self._encrypt(data)
  126. # Verzeichnis erstellen falls nötig
  127. self._path.parent.mkdir(parents=True, exist_ok=True)
  128. with open(self._path, "wb") as f:
  129. f.write(encrypted)
  130. self._dirty = False
  131. def get(self, key: str, default: T | None = None) -> T | None:
  132. """
  133. Holt einen Secret-Wert.
  134. Args:
  135. key: Schlüssel.
  136. default: Standardwert.
  137. Returns:
  138. Secret-Wert oder Default.
  139. """
  140. return self._secrets.get(key, default)
  141. def get_secret(self, key: str) -> SecretValue | None:
  142. """
  143. Holt ein Secret mit Metadaten.
  144. Args:
  145. key: Schlüssel.
  146. Returns:
  147. SecretValue oder None.
  148. """
  149. if key not in self._secrets:
  150. return None
  151. return SecretValue(
  152. key=key,
  153. value=self._secrets[key],
  154. source=str(self._path),
  155. )
  156. def set(self, key: str, value: Any) -> bool:
  157. """
  158. Speichert einen Secret-Wert.
  159. Args:
  160. key: Schlüssel.
  161. value: Wert.
  162. Returns:
  163. True wenn erfolgreich.
  164. """
  165. self._secrets[key] = value
  166. self._dirty = True
  167. if self._auto_save:
  168. self._save()
  169. return True
  170. def delete(self, key: str) -> bool:
  171. """
  172. Löscht ein Secret.
  173. Args:
  174. key: Schlüssel.
  175. Returns:
  176. True wenn vorhanden und gelöscht.
  177. """
  178. if key in self._secrets:
  179. del self._secrets[key]
  180. self._dirty = True
  181. if self._auto_save:
  182. self._save()
  183. return True
  184. return False
  185. def exists(self, key: str) -> bool:
  186. """Prüft, ob ein Secret existiert."""
  187. return key in self._secrets
  188. def list_keys(self) -> list[str]:
  189. """Listet alle Secret-Schlüssel."""
  190. return list(self._secrets.keys())
  191. def save(self) -> None:
  192. """Speichert Änderungen manuell."""
  193. if self._dirty:
  194. self._save()
  195. def rotate_password(self, new_password: str) -> None:
  196. """
  197. Rotiert das Passwort.
  198. Args:
  199. new_password: Neues Passwort.
  200. """
  201. self._password = new_password
  202. self._save()
  203. def export_json(self) -> str:
  204. """
  205. Exportiert Secrets als JSON (nur für Debug/Migration).
  206. Returns:
  207. JSON-String.
  208. """
  209. return json.dumps(self._secrets, indent=2)
  210. def import_json(self, json_str: str) -> None:
  211. """
  212. Importiert Secrets aus JSON.
  213. Args:
  214. json_str: JSON-String mit Secrets.
  215. """
  216. imported = json.loads(json_str)
  217. self._secrets.update(imported)
  218. self._dirty = True
  219. if self._auto_save:
  220. self._save()
  221. @classmethod
  222. def create(
  223. cls,
  224. path: str | Path,
  225. password: str,
  226. initial_secrets: dict[str, Any] | None = None,
  227. ) -> "EncryptedSecretsProvider":
  228. """
  229. Erstellt eine neue verschlüsselte Secrets-Datei.
  230. Args:
  231. path: Pfad zur Datei.
  232. password: Passwort.
  233. initial_secrets: Optionale initiale Secrets.
  234. Returns:
  235. EncryptedSecretsProvider-Instanz.
  236. """
  237. provider = cls(path=path, password=password, auto_save=False)
  238. if initial_secrets:
  239. provider._secrets = initial_secrets
  240. provider._save()
  241. return provider