sovereign 0.19.3__py3-none-any.whl → 1.0.0b148__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 (80) hide show
  1. sovereign/__init__.py +13 -81
  2. sovereign/app.py +59 -48
  3. sovereign/cache/__init__.py +172 -0
  4. sovereign/cache/backends/__init__.py +110 -0
  5. sovereign/cache/backends/s3.py +143 -0
  6. sovereign/cache/filesystem.py +73 -0
  7. sovereign/cache/types.py +15 -0
  8. sovereign/configuration.py +573 -0
  9. sovereign/constants.py +1 -0
  10. sovereign/context.py +271 -104
  11. sovereign/dynamic_config/__init__.py +113 -0
  12. sovereign/dynamic_config/deser.py +78 -0
  13. sovereign/dynamic_config/loaders.py +120 -0
  14. sovereign/events.py +49 -0
  15. sovereign/logging/access_logger.py +85 -0
  16. sovereign/logging/application_logger.py +54 -0
  17. sovereign/logging/base_logger.py +41 -0
  18. sovereign/logging/bootstrapper.py +36 -0
  19. sovereign/logging/types.py +10 -0
  20. sovereign/middlewares.py +8 -7
  21. sovereign/modifiers/lib.py +1 -0
  22. sovereign/rendering.py +192 -0
  23. sovereign/response_class.py +18 -0
  24. sovereign/server.py +93 -35
  25. sovereign/sources/file.py +1 -1
  26. sovereign/sources/inline.py +1 -0
  27. sovereign/sources/lib.py +1 -0
  28. sovereign/sources/poller.py +296 -53
  29. sovereign/statistics.py +17 -20
  30. sovereign/templates/base.html +59 -46
  31. sovereign/templates/resources.html +203 -102
  32. sovereign/testing/loaders.py +8 -0
  33. sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
  34. sovereign/tracing.py +102 -0
  35. sovereign/types.py +299 -0
  36. sovereign/utils/auth.py +26 -13
  37. sovereign/utils/crypto/__init__.py +0 -0
  38. sovereign/utils/crypto/crypto.py +135 -0
  39. sovereign/utils/crypto/suites/__init__.py +21 -0
  40. sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
  41. sovereign/utils/crypto/suites/base_cipher.py +21 -0
  42. sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
  43. sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
  44. sovereign/utils/dictupdate.py +2 -1
  45. sovereign/utils/eds.py +37 -21
  46. sovereign/utils/mock.py +54 -16
  47. sovereign/utils/resources.py +17 -0
  48. sovereign/utils/version_info.py +8 -0
  49. sovereign/views/__init__.py +4 -0
  50. sovereign/views/api.py +61 -0
  51. sovereign/views/crypto.py +46 -15
  52. sovereign/views/discovery.py +37 -116
  53. sovereign/views/healthchecks.py +87 -18
  54. sovereign/views/interface.py +112 -112
  55. sovereign/worker.py +204 -0
  56. {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/METADATA +79 -76
  57. sovereign-1.0.0b148.dist-info/RECORD +77 -0
  58. {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/WHEEL +1 -1
  59. sovereign-1.0.0b148.dist-info/entry_points.txt +38 -0
  60. sovereign_files/__init__.py +0 -0
  61. sovereign_files/static/darkmode.js +51 -0
  62. sovereign_files/static/node_expression.js +42 -0
  63. sovereign_files/static/panel.js +76 -0
  64. sovereign_files/static/resources.css +246 -0
  65. sovereign_files/static/resources.js +642 -0
  66. sovereign_files/static/sass/style.scss +33 -0
  67. sovereign_files/static/style.css +16143 -0
  68. sovereign_files/static/style.css.map +1 -0
  69. sovereign/config_loader.py +0 -225
  70. sovereign/discovery.py +0 -175
  71. sovereign/logs.py +0 -131
  72. sovereign/schemas.py +0 -780
  73. sovereign/static/sass/style.scss +0 -27
  74. sovereign/static/style.css +0 -13553
  75. sovereign/templates/ul_filter.html +0 -22
  76. sovereign/utils/crypto.py +0 -103
  77. sovereign/views/admin.py +0 -120
  78. sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
  79. sovereign-0.19.3.dist-info/RECORD +0 -47
  80. sovereign-0.19.3.dist-info/entry_points.txt +0 -10
sovereign/tracing.py ADDED
@@ -0,0 +1,102 @@
1
+ from typing_extensions import NotRequired
2
+ from typing import TypedDict, Any
3
+ from sovereign.configuration import TracingConfig
4
+ import time
5
+ import uuid
6
+ import requests
7
+ from contextvars import ContextVar
8
+ from contextlib import nullcontext
9
+
10
+ from sovereign import config
11
+
12
+ _trace_id_ctx_var: ContextVar[str] = ContextVar("trace_id", default="")
13
+ _span_id_ctx_var: ContextVar[str] = ContextVar("span_id", default="")
14
+
15
+
16
+ def get_trace_id() -> str:
17
+ return _trace_id_ctx_var.get()
18
+
19
+
20
+ def get_span_id() -> str:
21
+ return _span_id_ctx_var.get()
22
+
23
+
24
+ def generate_128bit():
25
+ return str(uuid.uuid4()).replace("-", "")
26
+
27
+
28
+ def generate_64bit():
29
+ return generate_128bit()[:32]
30
+
31
+
32
+ def timestamp():
33
+ return str(time.time()).replace(".", "")
34
+
35
+
36
+ TRACING = config.tracing
37
+ TRACING_DISABLED = not getattr(TRACING, "enabled", False)
38
+
39
+
40
+ class Trace(TypedDict):
41
+ traceId: str
42
+ id: str
43
+ name: str
44
+ timestamp: float
45
+ tags: dict[str, Any]
46
+ duration: NotRequired[float]
47
+ parentSpanId: NotRequired[str]
48
+
49
+
50
+ class Tracer:
51
+ def __init__(self, span_name):
52
+ if TRACING_DISABLED:
53
+ return
54
+
55
+ self.tracing: TracingConfig = TRACING
56
+ span_id = get_span_id()
57
+ self.parent_span_id = None
58
+ if span_id != "":
59
+ # We are already inside a trace context
60
+ self.parent_span_id = span_id
61
+ self.trace_id = get_trace_id()
62
+ self.span_id = self.gen_id()
63
+ self.span_name = span_name
64
+
65
+ def gen_id(self):
66
+ if self.tracing.trace_id_128bit:
67
+ trace_id = generate_128bit()
68
+ else:
69
+ trace_id = generate_64bit()
70
+ _trace_id_ctx_var.set(trace_id)
71
+ return trace_id
72
+
73
+ def __enter__(self):
74
+ if TRACING_DISABLED:
75
+ return nullcontext()
76
+ self.trace = Trace(
77
+ {
78
+ "traceId": self.trace_id,
79
+ "id": self.span_id,
80
+ "name": self.span_name,
81
+ "timestamp": time.time(),
82
+ "tags": self.tracing.tags,
83
+ }
84
+ )
85
+ if self.parent_span_id:
86
+ self.trace["parentSpanId"] = self.parent_span_id
87
+ return self
88
+
89
+ def __exit__(self, exc_type, exc_value, traceback):
90
+ if TRACING_DISABLED:
91
+ return
92
+ self.trace["duration"] = time.time() - self.trace["timestamp"]
93
+ self.submit()
94
+
95
+ def submit(self):
96
+ print(f"{self.span_name}: {self.trace['duration']}")
97
+ try:
98
+ url = f"{self.tracing.collector}{self.tracing.endpoint}"
99
+ requests.post(url, json=self.trace)
100
+ # pylint: disable=broad-except
101
+ except Exception as e:
102
+ print(f"Failed to submit trace: {self.trace}, Error:{e}")
sovereign/types.py ADDED
@@ -0,0 +1,299 @@
1
+ import hashlib
2
+ import importlib
3
+ from types import ModuleType
4
+ from typing_extensions import Any, cast
5
+ from functools import cached_property
6
+
7
+ import jmespath
8
+ from jinja2 import Template
9
+ from pydantic import (
10
+ BaseModel,
11
+ ConfigDict,
12
+ Field,
13
+ computed_field,
14
+ )
15
+
16
+ from sovereign.dynamic_config import Loadable
17
+ from sovereign.utils.version_info import compute_hash
18
+
19
+
20
+ missing_arguments = {"missing", "positional", "arguments:"}
21
+
22
+
23
+ class Resources(list[str]):
24
+ """
25
+ Acts like a regular list except it returns True
26
+ for all membership tests when empty.
27
+ """
28
+
29
+ def __contains__(self, item: object) -> bool:
30
+ if len(self) == 0:
31
+ return True
32
+ return super().__contains__(item)
33
+
34
+
35
+ class Locality(BaseModel):
36
+ region: str | None = Field(None)
37
+ zone: str | None = Field(None)
38
+ sub_zone: str | None = Field(None)
39
+
40
+ def __str__(self) -> str:
41
+ return f"{self.region}::{self.zone}::{self.sub_zone}"
42
+
43
+
44
+ class SemanticVersion(BaseModel):
45
+ major_number: int = 0
46
+ minor_number: int = 0
47
+ patch: int = 0
48
+
49
+ def __str__(self) -> str:
50
+ return f"{self.major_number}.{self.minor_number}.{self.patch}"
51
+
52
+
53
+ class BuildVersion(BaseModel):
54
+ version: SemanticVersion = SemanticVersion()
55
+ metadata: dict[str, Any] = {}
56
+
57
+
58
+ class Extension(BaseModel):
59
+ name: str | None = None
60
+ category: str | None = None
61
+ version: BuildVersion | None = None
62
+ disabled: bool | None = None
63
+
64
+
65
+ class Node(BaseModel):
66
+ id: str = Field("-", title="Hostname")
67
+ cluster: str = Field(
68
+ ...,
69
+ title="Envoy service-cluster",
70
+ description="The ``--service-cluster`` configured by the Envoy client",
71
+ )
72
+ metadata: dict[str, Any] = Field(default_factory=dict, title="Key:value metadata")
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 e
139
+ message_start = str(e).find(":")
140
+ missing_args = str(e)[message_start + 2 :]
141
+ supplied_args = list(kwargs.keys())
142
+ raise TypeError(
143
+ f"Tried to render a template using partial arguments. "
144
+ f"Missing args: {missing_args}. Supplied args: {args} "
145
+ f"Supplied keyword args: {supplied_args}"
146
+ )
147
+ else:
148
+ return self.code.render(*args, **kwargs)
149
+
150
+ @property
151
+ def source(self) -> str:
152
+ old_serialization = self.loadable.serialization
153
+ if self.loadable.serialization in ("jinja", "jinja2"):
154
+ # The Jinja2 template serializer does not properly set a name
155
+ # for the loaded template.
156
+ # The repr for the template prints out as the memory address
157
+ # This makes it really hard to generate a consistent version_info string
158
+ # in rendered configuration.
159
+ # For this reason, we re-load the template as a string instead, and create a checksum.
160
+ self.loadable.serialization = "string"
161
+ ret = self.loadable.load()
162
+ self.loadable.serialization = old_serialization
163
+ return str(ret)
164
+ elif self.is_python_source:
165
+ # If the template specified is a python source file,
166
+ # we can simply read and return the source of it.
167
+ old_protocol = self.loadable.protocol
168
+ self.loadable.protocol = "inline"
169
+ self.loadable.serialization = "string"
170
+ ret = self.loadable.load()
171
+ self.loadable.protocol = old_protocol
172
+ self.loadable.serialization = old_serialization
173
+ return str(ret)
174
+ ret = self.loadable.load()
175
+ return str(ret)
176
+
177
+ def __repr__(self) -> str:
178
+ return f"XdsTemplate({self.loadable}, {hash(self)})"
179
+
180
+ @computed_field # type: ignore[prop-decorator]
181
+ @cached_property
182
+ def version(self) -> str:
183
+ return compute_hash(self.source)
184
+
185
+ def __hash__(self) -> int:
186
+ return int(self.version)
187
+
188
+ __str__ = __repr__
189
+
190
+
191
+ class DiscoveryRequest(BaseModel):
192
+ # Actual envoy fields
193
+ node: Node = Field(..., title="Node information about the envoy proxy")
194
+ version_info: str = Field(
195
+ "0", title="The version of the envoy clients current configuration"
196
+ )
197
+ resource_names: list[str] = Field(
198
+ default_factory=list, title="list of requested resource names"
199
+ )
200
+ error_detail: Status | None = Field(
201
+ None, title="Error details from the previous xDS request"
202
+ )
203
+ # Internal fields for sovereign
204
+ is_internal_request: bool = False
205
+ type_url: str | None = Field(
206
+ None, title="The corresponding type_url for the requested resource"
207
+ )
208
+ resource_type: str | None = Field(None, title="Resource type requested")
209
+ api_version: str | None = Field(None, title="Envoy API version (v2/v3/etc)")
210
+ desired_controlplane: str | None = Field(
211
+ None, title="The host header provided in the Discovery Request"
212
+ )
213
+ # Pydantic
214
+ model_config = ConfigDict(extra="ignore")
215
+
216
+ @computed_field # type: ignore[prop-decorator]
217
+ @cached_property
218
+ def envoy_version(self) -> str:
219
+ try:
220
+ version = str(self.node.user_agent_build_version.version)
221
+ assert version != "0.0.0"
222
+ except AssertionError:
223
+ try:
224
+ build_version = self.node.build_version
225
+ if build_version is None:
226
+ return "default"
227
+ _, version, *_ = build_version.split("/")
228
+ except (AttributeError, ValueError):
229
+ # TODO: log/metric this?
230
+ return "default"
231
+ return version
232
+
233
+ @property
234
+ def resources(self) -> Resources:
235
+ return Resources(self.resource_names)
236
+
237
+ def cache_key(self, rules: list[str]) -> str:
238
+ map = self.model_dump()
239
+ hash = hashlib.sha256()
240
+ for expr in sorted(rules):
241
+ value = cast(str, jmespath.search(expr, map))
242
+ val_str = f"{expr}={repr(value)}"
243
+ hash.update(val_str.encode())
244
+ return hash.hexdigest()
245
+
246
+ @computed_field # type: ignore[misc]
247
+ @property
248
+ def template(self) -> XdsTemplate:
249
+ # lazy load configured templates
250
+ mod = importlib.import_module("sovereign.configuration")
251
+ templates = mod.XDS_TEMPLATES
252
+
253
+ version = self.envoy_version
254
+ selection = "default"
255
+ for v in templates.keys():
256
+ if version.startswith(v):
257
+ selection = v
258
+ selected_version = templates[selection]
259
+ try:
260
+ assert self.resource_type
261
+ return selected_version[self.resource_type]
262
+ except AssertionError:
263
+ raise RuntimeError(
264
+ "DiscoveryRequest has no resource type set, cannot find template"
265
+ )
266
+ except KeyError:
267
+ raise KeyError(
268
+ (
269
+ f"Unable to get {self.resource_type} for template "
270
+ f'version "{selection}". Envoy client version: {version}'
271
+ )
272
+ )
273
+
274
+ def debug(self):
275
+ return f"version={self.envoy_version}, cluster={self.node.cluster}, resource={self.resource_type}, names={self.resources}"
276
+
277
+ def __str__(self) -> str:
278
+ return f"DiscoveryRequest({self.debug()})"
279
+
280
+
281
+ class DiscoveryResponse(BaseModel):
282
+ version_info: str = Field(
283
+ ..., title="The version of the configuration in the response"
284
+ )
285
+ resources: list[Any] = Field(..., title="The requested configuration resources")
286
+
287
+
288
+ class RegisterClientRequest(BaseModel):
289
+ request: DiscoveryRequest
290
+
291
+
292
+ class ProcessedTemplate(BaseModel):
293
+ resources: list[dict[str, Any]]
294
+ metadata: list[str] = Field(default_factory=list, exclude=True)
295
+
296
+ @computed_field # type: ignore[prop-decorator]
297
+ @cached_property
298
+ def version_info(self) -> str:
299
+ return compute_hash(self.resources)
sovereign/utils/auth.py CHANGED
@@ -1,18 +1,20 @@
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 server_cipher_container, stats, application_logger as log
5
+ from sovereign.types import DiscoveryRequest
6
+ from sovereign.configuration import config
5
7
 
6
8
  AUTH_ENABLED = config.authentication.enabled
7
9
 
8
10
 
11
+ @stats.timed("discovery.auth.ms")
9
12
  def validate_authentication_string(s: str) -> bool:
10
13
  try:
11
- password = cipher_suite.decrypt(s)
14
+ password = server_cipher_container.decrypt(s)
12
15
  except Exception:
13
16
  stats.increment("discovery.auth.failed")
14
17
  raise
15
-
16
18
  if password in config.passwords:
17
19
  stats.increment("discovery.auth.success")
18
20
  return True
@@ -23,28 +25,39 @@ def validate_authentication_string(s: str) -> bool:
23
25
  def authenticate(request: DiscoveryRequest) -> None:
24
26
  if not AUTH_ENABLED:
25
27
  return
26
- if not cipher_suite.key_available:
28
+ if not server_cipher_container.key_available:
27
29
  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"
30
+ "No encryption key loaded, and auth is enabled. "
31
+ "An encryption key must be provided via SOVEREIGN_ENCRYPTION_KEY. "
32
32
  )
33
33
  try:
34
34
  encrypted_auth = request.node.metadata["auth"]
35
- with stats.timed("discovery.auth.ms"):
36
- assert validate_authentication_string(encrypted_auth)
37
35
  except KeyError:
38
36
  raise HTTPException(
39
37
  status_code=401,
40
38
  detail=f"Discovery request from {request.node.id} is missing auth field",
41
39
  )
40
+ except Exception as e:
41
+ description = getattr(e, "detail", "unknown")
42
+ raise HTTPException(
43
+ status_code=400,
44
+ detail=f"The authentication provided was malformed [Reason: {description}]",
45
+ )
46
+
47
+ try:
48
+ assert isinstance(encrypted_auth, str)
49
+ assert validate_authentication_string(encrypted_auth)
42
50
  except (InvalidToken, AssertionError):
43
51
  raise HTTPException(
44
52
  status_code=401, detail="The authentication provided was invalid"
45
53
  )
46
54
  except Exception as e:
47
- description = getattr(e, "detail", "Unknown")
55
+ alt_desc = repr(e)
56
+ alt_desc = alt_desc.replace(encrypted_auth, "********")
57
+ for password in config.passwords:
58
+ alt_desc = alt_desc.replace(password, "********")
59
+ description = getattr(e, "detail", alt_desc)
60
+ log.exception(f"Failed to auth client: {description}")
48
61
  raise HTTPException(
49
62
  status_code=400,
50
63
  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: ...