sovereign 0.19.3__py3-none-any.whl → 1.0.0a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sovereign might be problematic. Click here for more details.

Files changed (99) hide show
  1. sovereign/__init__.py +13 -81
  2. sovereign/app.py +62 -48
  3. sovereign/cache/__init__.py +245 -0
  4. sovereign/cache/backends/__init__.py +110 -0
  5. sovereign/cache/backends/s3.py +161 -0
  6. sovereign/cache/filesystem.py +74 -0
  7. sovereign/cache/types.py +17 -0
  8. sovereign/configuration.py +607 -0
  9. sovereign/constants.py +1 -0
  10. sovereign/context.py +270 -104
  11. sovereign/dynamic_config/__init__.py +112 -0
  12. sovereign/dynamic_config/deser.py +78 -0
  13. sovereign/dynamic_config/loaders.py +120 -0
  14. sovereign/error_info.py +2 -3
  15. sovereign/events.py +49 -0
  16. sovereign/logging/access_logger.py +85 -0
  17. sovereign/logging/application_logger.py +54 -0
  18. sovereign/logging/base_logger.py +41 -0
  19. sovereign/logging/bootstrapper.py +36 -0
  20. sovereign/logging/types.py +10 -0
  21. sovereign/middlewares.py +8 -7
  22. sovereign/modifiers/lib.py +2 -1
  23. sovereign/rendering.py +124 -0
  24. sovereign/rendering_common.py +91 -0
  25. sovereign/response_class.py +18 -0
  26. sovereign/server.py +112 -35
  27. sovereign/statistics.py +19 -21
  28. sovereign/templates/base.html +59 -46
  29. sovereign/templates/resources.html +203 -102
  30. sovereign/testing/loaders.py +9 -0
  31. sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
  32. sovereign/tracing.py +103 -0
  33. sovereign/types.py +304 -0
  34. sovereign/utils/auth.py +27 -13
  35. sovereign/utils/crypto/__init__.py +0 -0
  36. sovereign/utils/crypto/crypto.py +135 -0
  37. sovereign/utils/crypto/suites/__init__.py +21 -0
  38. sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
  39. sovereign/utils/crypto/suites/base_cipher.py +21 -0
  40. sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
  41. sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
  42. sovereign/utils/dictupdate.py +3 -2
  43. sovereign/utils/eds.py +40 -22
  44. sovereign/utils/entry_point_loader.py +2 -2
  45. sovereign/utils/mock.py +56 -17
  46. sovereign/utils/resources.py +17 -0
  47. sovereign/utils/templates.py +4 -2
  48. sovereign/utils/timer.py +5 -3
  49. sovereign/utils/version_info.py +8 -0
  50. sovereign/utils/weighted_clusters.py +2 -1
  51. sovereign/v2/__init__.py +0 -0
  52. sovereign/v2/data/data_store.py +621 -0
  53. sovereign/v2/data/render_discovery_response.py +24 -0
  54. sovereign/v2/data/repositories.py +90 -0
  55. sovereign/v2/data/utils.py +33 -0
  56. sovereign/v2/data/worker_queue.py +273 -0
  57. sovereign/v2/jobs/refresh_context.py +117 -0
  58. sovereign/v2/jobs/render_discovery_job.py +145 -0
  59. sovereign/v2/logging.py +81 -0
  60. sovereign/v2/types.py +41 -0
  61. sovereign/v2/web.py +101 -0
  62. sovereign/v2/worker.py +199 -0
  63. sovereign/views/__init__.py +7 -0
  64. sovereign/views/api.py +82 -0
  65. sovereign/views/crypto.py +46 -15
  66. sovereign/views/discovery.py +55 -119
  67. sovereign/views/healthchecks.py +107 -20
  68. sovereign/views/interface.py +171 -111
  69. sovereign/worker.py +193 -0
  70. {sovereign-0.19.3.dist-info → sovereign-1.0.0a4.dist-info}/METADATA +80 -76
  71. sovereign-1.0.0a4.dist-info/RECORD +85 -0
  72. {sovereign-0.19.3.dist-info → sovereign-1.0.0a4.dist-info}/WHEEL +1 -1
  73. sovereign-1.0.0a4.dist-info/entry_points.txt +46 -0
  74. sovereign_files/__init__.py +0 -0
  75. sovereign_files/static/darkmode.js +51 -0
  76. sovereign_files/static/node_expression.js +42 -0
  77. sovereign_files/static/panel.js +76 -0
  78. sovereign_files/static/resources.css +246 -0
  79. sovereign_files/static/resources.js +642 -0
  80. sovereign_files/static/sass/style.scss +33 -0
  81. sovereign_files/static/style.css +16143 -0
  82. sovereign_files/static/style.css.map +1 -0
  83. sovereign/config_loader.py +0 -225
  84. sovereign/discovery.py +0 -175
  85. sovereign/logs.py +0 -131
  86. sovereign/schemas.py +0 -780
  87. sovereign/sources/__init__.py +0 -3
  88. sovereign/sources/file.py +0 -21
  89. sovereign/sources/inline.py +0 -38
  90. sovereign/sources/lib.py +0 -40
  91. sovereign/sources/poller.py +0 -294
  92. sovereign/static/sass/style.scss +0 -27
  93. sovereign/static/style.css +0 -13553
  94. sovereign/templates/ul_filter.html +0 -22
  95. sovereign/utils/crypto.py +0 -103
  96. sovereign/views/admin.py +0 -120
  97. sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
  98. sovereign-0.19.3.dist-info/RECORD +0 -47
  99. sovereign-0.19.3.dist-info/entry_points.txt +0 -10
sovereign/types.py ADDED
@@ -0,0 +1,304 @@
1
+ import hashlib
2
+ import importlib
3
+ from functools import cached_property
4
+ from types import ModuleType
5
+
6
+ import jmespath
7
+ from jinja2 import Template
8
+ from pydantic import (
9
+ BaseModel,
10
+ ConfigDict,
11
+ Field,
12
+ computed_field,
13
+ )
14
+ from typing_extensions import Any, cast
15
+
16
+ from sovereign.dynamic_config import Loadable
17
+ from sovereign.utils.version_info import compute_hash
18
+
19
+ missing_arguments = {"missing", "positional", "arguments:"}
20
+
21
+
22
+ class Resources(list[str]):
23
+ """
24
+ Acts like a regular list except it returns True
25
+ for all membership tests when empty.
26
+ """
27
+
28
+ def __contains__(self, item: object) -> bool:
29
+ if len(self) == 0:
30
+ return True
31
+ return super().__contains__(item)
32
+
33
+
34
+ class Locality(BaseModel):
35
+ region: str | None = Field(None)
36
+ zone: str | None = Field(None)
37
+ sub_zone: str | None = Field(None)
38
+
39
+ def __str__(self) -> str:
40
+ return f"{self.region}::{self.zone}::{self.sub_zone}"
41
+
42
+
43
+ class SemanticVersion(BaseModel):
44
+ major_number: int = 0
45
+ minor_number: int = 0
46
+ patch: int = 0
47
+
48
+ def __str__(self) -> str:
49
+ return f"{self.major_number}.{self.minor_number}.{self.patch}"
50
+
51
+
52
+ class BuildVersion(BaseModel):
53
+ version: SemanticVersion = SemanticVersion()
54
+ metadata: dict[str, Any] = {}
55
+
56
+
57
+ class Extension(BaseModel):
58
+ name: str | None = None
59
+ category: str | None = None
60
+ version: BuildVersion | None = None
61
+ disabled: bool | None = None
62
+
63
+
64
+ class Node(BaseModel):
65
+ id: str = Field("-", title="Hostname")
66
+ cluster: str = Field(
67
+ ...,
68
+ title="Envoy service-cluster",
69
+ description="The ``--service-cluster`` configured by the Envoy client",
70
+ )
71
+ metadata: dict[str, Any] = Field(default_factory=dict, title="Key:value metadata")
72
+ # noinspection PyArgumentList
73
+ locality: Locality = Field(Locality(), title="Locality")
74
+ build_version: str | None = Field(
75
+ None, # Optional in the v3 Envoy API
76
+ title="Envoy build/release version string",
77
+ description="Used to identify what version of Envoy the "
78
+ "client is running, and what config to provide in response",
79
+ )
80
+ user_agent_name: str = "envoy"
81
+ user_agent_version: str = ""
82
+ user_agent_build_version: BuildVersion = BuildVersion()
83
+ extensions: list[Extension] = []
84
+ client_features: list[str] = []
85
+
86
+ @property
87
+ def common(self) -> tuple[str, str | None, str, BuildVersion, Locality]:
88
+ """
89
+ Returns fields that are the same in adjacent proxies
90
+ ie. proxies that are part of the same logical group
91
+ """
92
+ return (
93
+ self.cluster,
94
+ self.build_version,
95
+ self.user_agent_version,
96
+ self.user_agent_build_version,
97
+ self.locality,
98
+ )
99
+
100
+
101
+ class Status(BaseModel):
102
+ code: int
103
+ message: str
104
+ details: list[Any]
105
+
106
+
107
+ class XdsTemplate(BaseModel):
108
+ path: str | Loadable
109
+ resource_type: str
110
+ depends_on: list[str] = Field(default_factory=list)
111
+
112
+ @property
113
+ def loadable(self):
114
+ if isinstance(self.path, str):
115
+ return Loadable.from_legacy_fmt(self.path)
116
+ elif isinstance(self.path, Loadable):
117
+ return self.path
118
+ raise TypeError(
119
+ "Template path must be a loadable format. "
120
+ "e.g. file+yaml:///etc/templates/clusters.yaml"
121
+ )
122
+
123
+ @property
124
+ def is_python_source(self):
125
+ return self.loadable.protocol == "python"
126
+
127
+ @property
128
+ def code(self) -> ModuleType | Template:
129
+ return self.loadable.load()
130
+
131
+ def generate(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str | None:
132
+ if isinstance(self.code, ModuleType):
133
+ try:
134
+ template_fn = self.code.call # type: ignore
135
+ return {"resources": list(template_fn(*args, **kwargs))}
136
+ except TypeError as e:
137
+ if not set(str(e).split()).issuperset(missing_arguments):
138
+ raise ValueError(
139
+ f"Tried to render template '{self.resource_type}'. "
140
+ f"Error calling function: {str(e)}"
141
+ )
142
+ message_start = str(e).find(":")
143
+ missing_args = str(e)[message_start + 2 :]
144
+ supplied_args = list(kwargs.keys())
145
+ raise TypeError(
146
+ f"Tried to render template '{self.resource_type}' using partial arguments. "
147
+ f"Missing args: {missing_args}. Supplied args: {args} "
148
+ f"Supplied keyword args: {supplied_args}. "
149
+ f"Add to `depends_on` to ensure required context is provided."
150
+ )
151
+ else:
152
+ return self.code.render(*args, **kwargs)
153
+
154
+ @property
155
+ def source(self) -> str:
156
+ old_serialization = self.loadable.serialization
157
+ if self.loadable.serialization in ("jinja", "jinja2"):
158
+ # The Jinja2 template serializer does not properly set a name
159
+ # for the loaded template.
160
+ # The repr for the template prints out as the memory address
161
+ # This makes it really hard to generate a consistent version_info string
162
+ # in rendered configuration.
163
+ # For this reason, we re-load the template as a string instead, and create a checksum.
164
+ self.loadable.serialization = "string"
165
+ ret = self.loadable.load()
166
+ self.loadable.serialization = old_serialization
167
+ return str(ret)
168
+ elif self.is_python_source:
169
+ # If the template specified is a python source file,
170
+ # we can simply read and return the source of it.
171
+ old_protocol = self.loadable.protocol
172
+ self.loadable.protocol = "inline"
173
+ self.loadable.serialization = "string"
174
+ ret = self.loadable.load()
175
+ self.loadable.protocol = old_protocol
176
+ self.loadable.serialization = old_serialization
177
+ return str(ret)
178
+ ret = self.loadable.load()
179
+ return str(ret)
180
+
181
+ def __repr__(self) -> str:
182
+ return f"XdsTemplate({self.loadable}, {hash(self)})"
183
+
184
+ @computed_field # type: ignore[prop-decorator]
185
+ @cached_property
186
+ def version(self) -> str:
187
+ return compute_hash(self.source)
188
+
189
+ def __hash__(self) -> int:
190
+ return int(self.version)
191
+
192
+ __str__ = __repr__
193
+
194
+
195
+ class DiscoveryRequest(BaseModel):
196
+ # Actual envoy fields
197
+ node: Node = Field(..., title="Node information about the envoy proxy")
198
+ version_info: str = Field(
199
+ "0", title="The version of the envoy clients current configuration"
200
+ )
201
+ resource_names: list[str] = Field(
202
+ default_factory=list, title="list of requested resource names"
203
+ )
204
+ error_detail: Status | None = Field(
205
+ None, title="Error details from the previous xDS request"
206
+ )
207
+ # Internal fields for sovereign
208
+ is_internal_request: bool = False
209
+ type_url: str | None = Field(
210
+ None, title="The corresponding type_url for the requested resource"
211
+ )
212
+ resource_type: str | None = Field(None, title="Resource type requested")
213
+ api_version: str | None = Field(None, title="Envoy API version (v2/v3/etc)")
214
+ desired_controlplane: str | None = Field(
215
+ None, title="The host header provided in the Discovery Request"
216
+ )
217
+ # Pydantic
218
+ model_config = ConfigDict(extra="ignore")
219
+
220
+ @computed_field # type: ignore[prop-decorator]
221
+ @cached_property
222
+ def envoy_version(self) -> str:
223
+ try:
224
+ version = str(self.node.user_agent_build_version.version)
225
+ assert version != "0.0.0"
226
+ except AssertionError:
227
+ try:
228
+ build_version = self.node.build_version
229
+ if build_version is None:
230
+ return "default"
231
+ _, version, *_ = build_version.split("/")
232
+ except (AttributeError, ValueError):
233
+ # TODO: log/metric this?
234
+ return "default"
235
+ return version
236
+
237
+ @property
238
+ def resources(self) -> Resources:
239
+ return Resources(self.resource_names)
240
+
241
+ # noinspection PyShadowingBuiltins
242
+ def cache_key(self, rules: list[str]) -> str:
243
+ map = self.model_dump()
244
+ hash = hashlib.sha256()
245
+ for expr in sorted(rules):
246
+ value = cast(str, jmespath.search(expr, map))
247
+ val_str = f"{expr}={repr(value)}"
248
+ hash.update(val_str.encode())
249
+ return hash.hexdigest()
250
+
251
+ @computed_field # type: ignore[misc]
252
+ @property
253
+ def template(self) -> XdsTemplate:
254
+ # lazy load configured templates
255
+ mod = importlib.import_module("sovereign.configuration")
256
+ templates = mod.XDS_TEMPLATES
257
+
258
+ version = self.envoy_version
259
+ selection = "default"
260
+ for v in templates.keys():
261
+ if version.startswith(v):
262
+ selection = v
263
+ selected_version = templates[selection]
264
+ try:
265
+ assert self.resource_type
266
+ return selected_version[self.resource_type]
267
+ except AssertionError:
268
+ raise RuntimeError(
269
+ "DiscoveryRequest has no resource type set, cannot find template"
270
+ )
271
+ except KeyError:
272
+ raise KeyError(
273
+ (
274
+ f"Unable to get {self.resource_type} for template "
275
+ f'version "{selection}". Envoy client version: {version}'
276
+ )
277
+ )
278
+
279
+ def debug(self):
280
+ return f"version={self.envoy_version}, cluster={self.node.cluster}, resource={self.resource_type}, names={self.resources}"
281
+
282
+ def __str__(self) -> str:
283
+ return f"DiscoveryRequest({self.debug()})"
284
+
285
+
286
+ class DiscoveryResponse(BaseModel):
287
+ version_info: str = Field(
288
+ ..., title="The version of the configuration in the response"
289
+ )
290
+ resources: list[Any] = Field(..., title="The requested configuration resources")
291
+
292
+
293
+ class ProcessedTemplate(BaseModel):
294
+ resources: list[dict[str, Any]]
295
+ metadata: list[str] = Field(default_factory=list, exclude=True)
296
+
297
+ @computed_field # type: ignore[prop-decorator]
298
+ @cached_property
299
+ def version_info(self) -> str:
300
+ return compute_hash(self.resources)
301
+
302
+
303
+ class RegisterClientRequest(BaseModel):
304
+ request: DiscoveryRequest
sovereign/utils/auth.py CHANGED
@@ -1,18 +1,21 @@
1
- from fastapi.exceptions import HTTPException
2
1
  from cryptography.fernet import InvalidToken
3
- from sovereign import config, stats, cipher_suite
4
- from sovereign.schemas import DiscoveryRequest
2
+ from fastapi.exceptions import HTTPException
3
+
4
+ from sovereign import application_logger as log
5
+ from sovereign import server_cipher_container, stats
6
+ from sovereign.configuration import config
7
+ from sovereign.types import DiscoveryRequest
5
8
 
6
9
  AUTH_ENABLED = config.authentication.enabled
7
10
 
8
11
 
12
+ @stats.timed("discovery.auth.ms")
9
13
  def validate_authentication_string(s: str) -> bool:
10
14
  try:
11
- password = cipher_suite.decrypt(s)
15
+ password = server_cipher_container.decrypt(s)
12
16
  except Exception:
13
17
  stats.increment("discovery.auth.failed")
14
18
  raise
15
-
16
19
  if password in config.passwords:
17
20
  stats.increment("discovery.auth.success")
18
21
  return True
@@ -23,28 +26,39 @@ def validate_authentication_string(s: str) -> bool:
23
26
  def authenticate(request: DiscoveryRequest) -> None:
24
27
  if not AUTH_ENABLED:
25
28
  return
26
- if not cipher_suite.key_available:
29
+ if not server_cipher_container.key_available:
27
30
  raise RuntimeError(
28
- "No Fernet key loaded, and auth is enabled. "
29
- "A fernet key must be provided via SOVEREIGN_ENCRYPTION_KEY. "
30
- "See https://vsyrakis.bitbucket.io/sovereign/docs/html/guides/encryption.html "
31
- "for more details"
31
+ "No encryption key loaded, and auth is enabled. "
32
+ "An encryption key must be provided via SOVEREIGN_ENCRYPTION_KEY. "
32
33
  )
33
34
  try:
34
35
  encrypted_auth = request.node.metadata["auth"]
35
- with stats.timed("discovery.auth.ms"):
36
- assert validate_authentication_string(encrypted_auth)
37
36
  except KeyError:
38
37
  raise HTTPException(
39
38
  status_code=401,
40
39
  detail=f"Discovery request from {request.node.id} is missing auth field",
41
40
  )
41
+ except Exception as e:
42
+ description = getattr(e, "detail", "unknown")
43
+ raise HTTPException(
44
+ status_code=400,
45
+ detail=f"The authentication provided was malformed [Reason: {description}]",
46
+ )
47
+
48
+ try:
49
+ assert isinstance(encrypted_auth, str)
50
+ assert validate_authentication_string(encrypted_auth)
42
51
  except (InvalidToken, AssertionError):
43
52
  raise HTTPException(
44
53
  status_code=401, detail="The authentication provided was invalid"
45
54
  )
46
55
  except Exception as e:
47
- description = getattr(e, "detail", "Unknown")
56
+ alt_desc = repr(e)
57
+ alt_desc = alt_desc.replace(encrypted_auth, "********")
58
+ for password in config.passwords:
59
+ alt_desc = alt_desc.replace(password, "********")
60
+ description = getattr(e, "detail", alt_desc)
61
+ log.exception(f"Failed to auth client: {description}")
48
62
  raise HTTPException(
49
63
  status_code=400,
50
64
  detail=f"The authentication provided was malformed [Reason: {description}]",
File without changes
@@ -0,0 +1,135 @@
1
+ from typing import Literal, Self, Sequence
2
+
3
+ from cachetools import TTLCache, cached
4
+ from fastapi.exceptions import HTTPException
5
+ from structlog.stdlib import BoundLogger
6
+ from typing_extensions import TypedDict
7
+
8
+ from sovereign.configuration import EncryptionConfig
9
+ from sovereign.utils.crypto.suites import (
10
+ AESGCMCipher,
11
+ CipherSuite,
12
+ DisabledCipher,
13
+ EncryptionType,
14
+ FernetCipher,
15
+ )
16
+
17
+
18
+ class EncryptOutput(TypedDict):
19
+ encrypted_data: str
20
+ encryption_type: str
21
+
22
+
23
+ class DecryptOutput(TypedDict):
24
+ decrypted_data: str
25
+ encryption_type: str
26
+
27
+
28
+ class CipherContainer:
29
+ """
30
+ Object which intercepts encrypt/decryptions
31
+ Tries to decrypt data using the ciphers provided in order
32
+ Encrypts with the first suite available.
33
+ """
34
+
35
+ def __init__(self, suites: Sequence[CipherSuite], logger: BoundLogger) -> None:
36
+ self.suites: Sequence[CipherSuite] = suites
37
+ self.logger = logger
38
+
39
+ def encrypt(self, data: str) -> EncryptOutput:
40
+ try:
41
+ # Use the first cipher suite to encrypt the data
42
+ encrypted_data = self.suites[0].encrypt(data)
43
+ return {
44
+ "encrypted_data": encrypted_data,
45
+ "encryption_type": str(self.suites[0]),
46
+ }
47
+ # pylint: disable=broad-except
48
+ except Exception as e:
49
+ self.logger.exception(str(e))
50
+ # TODO: defer this http error to later, return a normal error here
51
+ raise HTTPException(status_code=400, detail="Encryption failed")
52
+
53
+ def decrypt_with_type(self, data: str) -> DecryptOutput:
54
+ return self._decrypt(data)
55
+
56
+ def decrypt(self, data: str) -> str:
57
+ return self._decrypt(data)["decrypted_data"]
58
+
59
+ @cached(cache=TTLCache(maxsize=128, ttl=600))
60
+ def _decrypt(self, data: str) -> DecryptOutput:
61
+ try:
62
+ for suite in self.suites:
63
+ try:
64
+ decrypted_data = suite.decrypt(data)
65
+ return {
66
+ "decrypted_data": decrypted_data,
67
+ "encryption_type": str(suite),
68
+ }
69
+ # pylint: disable=broad-except
70
+ except Exception as e:
71
+ self.logger.exception(str(e))
72
+ self.logger.debug(f"Failed to decrypt with suite {suite}")
73
+ else:
74
+ raise ValueError("Could not decrypt with any suite")
75
+ except Exception as e:
76
+ self.logger.exception(str(e))
77
+ # TODO: defer this http error to later, return a normal error here
78
+ raise HTTPException(status_code=400, detail="Decryption failed")
79
+
80
+ @property
81
+ def key_available(self) -> bool:
82
+ if not self.suites:
83
+ return False
84
+ return self.suites[0].key_available
85
+
86
+ AVAILABLE_CIPHERS: dict[EncryptionType | Literal["default"], type[CipherSuite]] = {
87
+ EncryptionType.DISABLED: DisabledCipher,
88
+ EncryptionType.AESGCM: AESGCMCipher,
89
+ EncryptionType.FERNET: FernetCipher,
90
+ "default": FernetCipher,
91
+ }
92
+
93
+ @classmethod
94
+ def get_cipher_suite(cls, encryption_type: EncryptionType) -> type[CipherSuite]:
95
+ SelectedCipher = cls.AVAILABLE_CIPHERS.get(
96
+ encryption_type, cls.AVAILABLE_CIPHERS["default"]
97
+ )
98
+ return SelectedCipher
99
+
100
+ @classmethod
101
+ def create_cipher_suite(
102
+ cls,
103
+ encryption_type: EncryptionType,
104
+ key: str,
105
+ logger: BoundLogger,
106
+ ) -> CipherSuite:
107
+ kwargs = {
108
+ "secret_key": key,
109
+ }
110
+ try:
111
+ SelectedCipher = cls.get_cipher_suite(encryption_type)
112
+ return SelectedCipher(**kwargs)
113
+ except TypeError:
114
+ pass
115
+ except ValueError as e:
116
+ if key not in (b"", ""):
117
+ logger.error(
118
+ f"Encryption key was provided, but appears to be invalid: {repr(e)}"
119
+ )
120
+ return DisabledCipher(**kwargs)
121
+
122
+ @classmethod
123
+ def from_encryption_configs(
124
+ cls, encryption_configs: Sequence[EncryptionConfig], logger: BoundLogger
125
+ ) -> Self:
126
+ cipher_suites: list[CipherSuite] = []
127
+ for encryption_config in encryption_configs:
128
+ cipher_suites.append(
129
+ cls.create_cipher_suite(
130
+ key=encryption_config.encryption_key,
131
+ encryption_type=encryption_config.encryption_type,
132
+ logger=logger,
133
+ )
134
+ )
135
+ return cls(suites=cipher_suites, logger=logger)
@@ -0,0 +1,21 @@
1
+ from enum import StrEnum
2
+
3
+ from sovereign.utils.crypto.suites.aes_gcm_cipher import AESGCMCipher
4
+ from sovereign.utils.crypto.suites.base_cipher import CipherSuite
5
+ from sovereign.utils.crypto.suites.disabled_cipher import DisabledCipher
6
+ from sovereign.utils.crypto.suites.fernet_cipher import FernetCipher
7
+
8
+
9
+ class EncryptionType(StrEnum):
10
+ FERNET = "fernet"
11
+ AESGCM = "aesgcm"
12
+ DISABLED = "disabled"
13
+
14
+
15
+ __all__ = [
16
+ "AESGCMCipher",
17
+ "CipherSuite",
18
+ "DisabledCipher",
19
+ "FernetCipher",
20
+ "EncryptionType",
21
+ ]
@@ -0,0 +1,42 @@
1
+ import base64
2
+ import os
3
+
4
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
5
+
6
+ from .base_cipher import CipherSuite
7
+
8
+
9
+ class AESGCMCipher(CipherSuite):
10
+ AUTHENTICATE_DATA = "sovereign".encode()
11
+ NONCE_LEN = 12
12
+
13
+ def __str__(self) -> str:
14
+ return "aesgcm"
15
+
16
+ def __init__(self, secret_key: str):
17
+ self.secret_key = base64.urlsafe_b64decode(secret_key)
18
+
19
+ def encrypt(self, data: str) -> str:
20
+ aesgcm = AESGCM(self.secret_key)
21
+ nonce = os.urandom(self.NONCE_LEN)
22
+ ct = aesgcm.encrypt(nonce, data.encode(), self.AUTHENTICATE_DATA)
23
+ return base64.b64encode(nonce + ct).decode("utf-8")
24
+
25
+ def decrypt(self, data: str) -> str:
26
+ decoded_data = base64.b64decode(data)
27
+ nonce, ct = (
28
+ decoded_data[: self.NONCE_LEN],
29
+ decoded_data[self.NONCE_LEN :],
30
+ )
31
+ aesgcm = AESGCM(self.secret_key)
32
+ decrypted_output = aesgcm.decrypt(nonce, ct, self.AUTHENTICATE_DATA)
33
+ return decrypted_output.decode("utf-8")
34
+
35
+ @property
36
+ def key_available(self) -> bool:
37
+ return True
38
+
39
+ @classmethod
40
+ def generate_key(cls) -> bytes:
41
+ # Generate 256 bit length key
42
+ return base64.urlsafe_b64encode(os.urandom(32))
@@ -0,0 +1,21 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+
5
+ class CipherSuite(ABC):
6
+ @abstractmethod
7
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
8
+
9
+ @abstractmethod
10
+ def encrypt(self, data: str) -> str: ...
11
+
12
+ @abstractmethod
13
+ def decrypt(self, data: str) -> str: ...
14
+
15
+ @property
16
+ @abstractmethod
17
+ def key_available(self) -> bool: ...
18
+
19
+ @classmethod
20
+ @abstractmethod
21
+ def generate_key(cls) -> bytes: ...
@@ -0,0 +1,25 @@
1
+ from typing import Any
2
+
3
+ from .base_cipher import CipherSuite
4
+
5
+
6
+ class DisabledCipher(CipherSuite):
7
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
8
+ pass
9
+
10
+ def __str__(self) -> str:
11
+ return "disabled"
12
+
13
+ def encrypt(self, data: str) -> str:
14
+ return "Unavailable (No Secret Key)"
15
+
16
+ def decrypt(self, data: str) -> str:
17
+ return "Unavailable (No Secret Key)"
18
+
19
+ @property
20
+ def key_available(self) -> bool:
21
+ return False
22
+
23
+ @classmethod
24
+ def generate_key(cls) -> bytes:
25
+ return b"Unavailable (No key to generate)"
@@ -0,0 +1,29 @@
1
+ import base64
2
+ import os
3
+
4
+ from cryptography.fernet import Fernet
5
+
6
+ from .base_cipher import CipherSuite
7
+
8
+
9
+ class FernetCipher(CipherSuite):
10
+ def __str__(self) -> str:
11
+ return "fernet"
12
+
13
+ def __init__(self, secret_key: str):
14
+ self.fernet = Fernet(secret_key)
15
+
16
+ def encrypt(self, data: str) -> str:
17
+ return self.fernet.encrypt(data.encode()).decode("utf-8")
18
+
19
+ def decrypt(self, data: str) -> str:
20
+ return self.fernet.decrypt(data).decode("utf-8")
21
+
22
+ @property
23
+ def key_available(self) -> bool:
24
+ return True
25
+
26
+ @classmethod
27
+ def generate_key(cls) -> bytes:
28
+ # Generate 256 bit length key
29
+ return base64.urlsafe_b64encode(os.urandom(32))
@@ -1,8 +1,9 @@
1
1
  # type: ignore
2
2
  # pylint: disable=no-name-in-module,too-many-branches
3
- """ Stolen from the saltstack library """
4
- from collections.abc import Mapping
3
+ """Stolen from the saltstack library"""
4
+
5
5
  import copy
6
+ from collections.abc import Mapping
6
7
 
7
8
 
8
9
  def update(dest, upd, recursive_update=True, merge_lists=False):