sovereign 0.14.2__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.
- sovereign/__init__.py +17 -78
- sovereign/app.py +74 -59
- sovereign/cache/__init__.py +245 -0
- sovereign/cache/backends/__init__.py +110 -0
- sovereign/cache/backends/s3.py +161 -0
- sovereign/cache/filesystem.py +74 -0
- sovereign/cache/types.py +17 -0
- sovereign/configuration.py +607 -0
- sovereign/constants.py +1 -0
- sovereign/context.py +271 -100
- sovereign/dynamic_config/__init__.py +112 -0
- sovereign/dynamic_config/deser.py +78 -0
- sovereign/dynamic_config/loaders.py +120 -0
- sovereign/error_info.py +61 -0
- sovereign/events.py +49 -0
- sovereign/logging/access_logger.py +85 -0
- sovereign/logging/application_logger.py +54 -0
- sovereign/logging/base_logger.py +41 -0
- sovereign/logging/bootstrapper.py +36 -0
- sovereign/logging/types.py +10 -0
- sovereign/middlewares.py +8 -7
- sovereign/modifiers/lib.py +2 -1
- sovereign/rendering.py +124 -0
- sovereign/rendering_common.py +91 -0
- sovereign/response_class.py +18 -0
- sovereign/server.py +123 -28
- sovereign/statistics.py +19 -21
- sovereign/templates/base.html +59 -46
- sovereign/templates/resources.html +203 -102
- sovereign/testing/loaders.py +9 -0
- sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
- sovereign/tracing.py +103 -0
- sovereign/types.py +304 -0
- sovereign/utils/auth.py +27 -13
- sovereign/utils/crypto/__init__.py +0 -0
- sovereign/utils/crypto/crypto.py +135 -0
- sovereign/utils/crypto/suites/__init__.py +21 -0
- sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
- sovereign/utils/crypto/suites/base_cipher.py +21 -0
- sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
- sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
- sovereign/utils/dictupdate.py +3 -2
- sovereign/utils/eds.py +40 -22
- sovereign/utils/entry_point_loader.py +18 -0
- sovereign/utils/mock.py +60 -17
- sovereign/utils/resources.py +17 -0
- sovereign/utils/templates.py +4 -2
- sovereign/utils/timer.py +5 -3
- sovereign/utils/version_info.py +8 -0
- sovereign/utils/weighted_clusters.py +2 -1
- sovereign/v2/__init__.py +0 -0
- sovereign/v2/data/data_store.py +621 -0
- sovereign/v2/data/render_discovery_response.py +24 -0
- sovereign/v2/data/repositories.py +90 -0
- sovereign/v2/data/utils.py +33 -0
- sovereign/v2/data/worker_queue.py +273 -0
- sovereign/v2/jobs/refresh_context.py +117 -0
- sovereign/v2/jobs/render_discovery_job.py +145 -0
- sovereign/v2/logging.py +81 -0
- sovereign/v2/types.py +41 -0
- sovereign/v2/web.py +101 -0
- sovereign/v2/worker.py +199 -0
- sovereign/views/__init__.py +7 -0
- sovereign/views/api.py +82 -0
- sovereign/views/crypto.py +46 -15
- sovereign/views/discovery.py +52 -67
- sovereign/views/healthchecks.py +107 -20
- sovereign/views/interface.py +173 -117
- sovereign/worker.py +193 -0
- {sovereign-0.14.2.dist-info → sovereign-1.0.0a4.dist-info}/METADATA +81 -73
- sovereign-1.0.0a4.dist-info/RECORD +85 -0
- {sovereign-0.14.2.dist-info → sovereign-1.0.0a4.dist-info}/WHEEL +1 -1
- sovereign-1.0.0a4.dist-info/entry_points.txt +46 -0
- sovereign_files/__init__.py +0 -0
- sovereign_files/static/darkmode.js +51 -0
- sovereign_files/static/node_expression.js +42 -0
- sovereign_files/static/panel.js +76 -0
- sovereign_files/static/resources.css +246 -0
- sovereign_files/static/resources.js +642 -0
- sovereign_files/static/sass/style.scss +33 -0
- sovereign_files/static/style.css +16143 -0
- sovereign_files/static/style.css.map +1 -0
- sovereign/config_loader.py +0 -225
- sovereign/discovery.py +0 -175
- sovereign/logs.py +0 -131
- sovereign/schemas.py +0 -715
- sovereign/sources/__init__.py +0 -3
- sovereign/sources/file.py +0 -21
- sovereign/sources/inline.py +0 -38
- sovereign/sources/lib.py +0 -40
- sovereign/sources/poller.py +0 -298
- sovereign/static/sass/style.scss +0 -27
- sovereign/static/style.css +0 -13553
- sovereign/templates/ul_filter.html +0 -22
- sovereign/utils/crypto.py +0 -64
- sovereign/views/admin.py +0 -120
- sovereign-0.14.2.dist-info/LICENSE.txt +0 -13
- sovereign-0.14.2.dist-info/RECORD +0 -45
- sovereign-0.14.2.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
|
|
4
|
-
|
|
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 =
|
|
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
|
|
29
|
+
if not server_cipher_container.key_available:
|
|
27
30
|
raise RuntimeError(
|
|
28
|
-
"No
|
|
29
|
-
"
|
|
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
|
-
|
|
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))
|
sovereign/utils/dictupdate.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# type: ignore
|
|
2
2
|
# pylint: disable=no-name-in-module,too-many-branches
|
|
3
|
-
"""
|
|
4
|
-
|
|
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):
|