kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/config/sops.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""SOPS decryption support for configuration files.
|
|
2
|
+
|
|
3
|
+
This module provides transparent SOPS decryption for configuration files
|
|
4
|
+
with .sops.yml, .sops.yaml, .sops.json, or .sops.toml extensions.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Automatic detection of SOPS files by extension
|
|
8
|
+
- LRU cache with mtime-based invalidation
|
|
9
|
+
- Graceful degradation when SOPS is not available
|
|
10
|
+
- Warning detection for unencrypted ENC[...] values
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
>>> from kstlib.config.sops import is_sops_file, get_decryptor
|
|
14
|
+
>>> from pathlib import Path
|
|
15
|
+
>>> is_sops_file(Path("secrets.sops.yml"))
|
|
16
|
+
True
|
|
17
|
+
>>> is_sops_file(Path("config.yml"))
|
|
18
|
+
False
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import pathlib
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from kstlib.config.exceptions import ConfigSopsError, ConfigSopsNotAvailableError
|
|
31
|
+
from kstlib.limits import (
|
|
32
|
+
DEFAULT_MAX_SOPS_CACHE_ENTRIES,
|
|
33
|
+
HARD_MAX_SOPS_CACHE_ENTRIES,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
SOPS_FILE_PATTERNS: tuple[str, ...] = (
|
|
39
|
+
".sops.yml",
|
|
40
|
+
".sops.yaml",
|
|
41
|
+
".sops.json",
|
|
42
|
+
".sops.toml",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
ENC_MARKER = "ENC[AES256_GCM,"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_sops_file(path: pathlib.Path) -> bool:
|
|
49
|
+
"""Check if file should be decrypted via SOPS based on extension.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
path: Path to the configuration file.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True if the file has a SOPS extension (.sops.yml, .sops.yaml, etc.).
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
>>> from pathlib import Path
|
|
59
|
+
>>> is_sops_file(Path("secrets.sops.yml"))
|
|
60
|
+
True
|
|
61
|
+
>>> is_sops_file(Path("config.yml"))
|
|
62
|
+
False
|
|
63
|
+
>>> is_sops_file(Path("data.sops.json"))
|
|
64
|
+
True
|
|
65
|
+
"""
|
|
66
|
+
name = path.name.lower()
|
|
67
|
+
return any(name.endswith(ext) for ext in SOPS_FILE_PATTERNS)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_real_extension(path: pathlib.Path) -> str:
|
|
71
|
+
"""Extract actual format extension, ignoring .sops prefix.
|
|
72
|
+
|
|
73
|
+
For SOPS files like 'secrets.sops.yml', returns '.yml'.
|
|
74
|
+
For non-SOPS files, returns the normal suffix.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
path: Path to the configuration file.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The real format extension (e.g., '.yml', '.json', '.toml').
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
>>> from pathlib import Path
|
|
84
|
+
>>> get_real_extension(Path("secrets.sops.yml"))
|
|
85
|
+
'.yml'
|
|
86
|
+
>>> get_real_extension(Path("config.sops.json"))
|
|
87
|
+
'.json'
|
|
88
|
+
>>> get_real_extension(Path("normal.yml"))
|
|
89
|
+
'.yml'
|
|
90
|
+
"""
|
|
91
|
+
name = path.name.lower()
|
|
92
|
+
for marker in (".sops", ".enc"):
|
|
93
|
+
if marker in name:
|
|
94
|
+
idx = name.rfind(marker)
|
|
95
|
+
return name[idx + len(marker) :]
|
|
96
|
+
return path.suffix.lower()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def has_encrypted_values(data: Any, path: str = "") -> list[str]:
|
|
100
|
+
"""Recursively find keys containing ENC[AES256_GCM,...] values.
|
|
101
|
+
|
|
102
|
+
This function detects SOPS-encrypted values that were not decrypted,
|
|
103
|
+
typically because the file was loaded without SOPS decryption.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
data: The parsed configuration data to inspect.
|
|
107
|
+
path: Current key path (for recursion, start with empty string).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List of dotted key paths containing encrypted values.
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
>>> has_encrypted_values({"key": "ENC[AES256_GCM,data...]"})
|
|
114
|
+
['key']
|
|
115
|
+
>>> has_encrypted_values({"db": {"password": "ENC[AES256_GCM,...]"}})
|
|
116
|
+
['db.password']
|
|
117
|
+
>>> has_encrypted_values({"normal": "value"})
|
|
118
|
+
[]
|
|
119
|
+
"""
|
|
120
|
+
found: list[str] = []
|
|
121
|
+
if isinstance(data, str) and ENC_MARKER in data:
|
|
122
|
+
found.append(path or "<root>")
|
|
123
|
+
elif isinstance(data, dict):
|
|
124
|
+
for k, v in data.items():
|
|
125
|
+
found.extend(has_encrypted_values(v, f"{path}.{k}" if path else k))
|
|
126
|
+
elif isinstance(data, list):
|
|
127
|
+
for i, item in enumerate(data):
|
|
128
|
+
found.extend(has_encrypted_values(item, f"{path}[{i}]"))
|
|
129
|
+
return found
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SopsDecryptor:
|
|
133
|
+
"""Lightweight SOPS decryptor with LRU cache.
|
|
134
|
+
|
|
135
|
+
This class provides SOPS file decryption with:
|
|
136
|
+
- Configurable binary path
|
|
137
|
+
- LRU cache with mtime-based invalidation
|
|
138
|
+
- Clear error messages for troubleshooting
|
|
139
|
+
|
|
140
|
+
Attributes:
|
|
141
|
+
binary: Name or path of the SOPS binary.
|
|
142
|
+
max_cache: Maximum cache entries (clamped to hard limit).
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
>>> decryptor = SopsDecryptor() # doctest: +SKIP
|
|
146
|
+
>>> content = decryptor.decrypt_file(Path("secrets.sops.yml")) # doctest: +SKIP
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
binary: str = "sops",
|
|
152
|
+
max_cache_entries: int = DEFAULT_MAX_SOPS_CACHE_ENTRIES,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Initialize the SOPS decryptor.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
binary: Name or path of the SOPS binary.
|
|
158
|
+
max_cache_entries: Maximum number of cached decrypted files.
|
|
159
|
+
"""
|
|
160
|
+
self._binary = binary
|
|
161
|
+
self._max_cache = min(max_cache_entries, HARD_MAX_SOPS_CACHE_ENTRIES)
|
|
162
|
+
self._cache: OrderedDict[pathlib.Path, tuple[float, str]] = OrderedDict()
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def binary(self) -> str:
|
|
166
|
+
"""Return the configured SOPS binary name."""
|
|
167
|
+
return self._binary
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def max_cache(self) -> int:
|
|
171
|
+
"""Return the maximum cache size."""
|
|
172
|
+
return self._max_cache
|
|
173
|
+
|
|
174
|
+
def decrypt_file(self, path: pathlib.Path) -> str:
|
|
175
|
+
"""Decrypt a SOPS-encrypted file and return content as string.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
path: Path to the SOPS-encrypted file.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Decrypted file content as a string.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
ConfigSopsNotAvailableError: If SOPS binary is not found.
|
|
185
|
+
ConfigSopsError: If decryption fails.
|
|
186
|
+
"""
|
|
187
|
+
resolved = path.resolve()
|
|
188
|
+
mtime = resolved.stat().st_mtime
|
|
189
|
+
|
|
190
|
+
# Cache hit?
|
|
191
|
+
cached = self._cache.get(resolved)
|
|
192
|
+
if cached and cached[0] == mtime:
|
|
193
|
+
self._cache.move_to_end(resolved)
|
|
194
|
+
logger.debug("SOPS cache hit for: %s", path.name)
|
|
195
|
+
return cached[1]
|
|
196
|
+
|
|
197
|
+
# Find binary
|
|
198
|
+
binary_path = shutil.which(self._binary)
|
|
199
|
+
if binary_path is None:
|
|
200
|
+
raise ConfigSopsNotAvailableError(
|
|
201
|
+
f"SOPS binary '{self._binary}' not found in PATH. Install from https://github.com/getsops/sops"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Decrypt - binary_path is validated via shutil.which()
|
|
205
|
+
# resolved is a Path object from user config (trusted source)
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
[binary_path, "--decrypt", str(resolved)],
|
|
208
|
+
capture_output=True,
|
|
209
|
+
text=True,
|
|
210
|
+
check=False,
|
|
211
|
+
timeout=30,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if result.returncode != 0:
|
|
215
|
+
raise ConfigSopsError(f"Failed to decrypt '{path.name}': {result.stderr.strip()}")
|
|
216
|
+
|
|
217
|
+
content = result.stdout
|
|
218
|
+
|
|
219
|
+
# Update cache with LRU eviction
|
|
220
|
+
self._cache[resolved] = (mtime, content)
|
|
221
|
+
self._cache.move_to_end(resolved)
|
|
222
|
+
while len(self._cache) > self._max_cache:
|
|
223
|
+
self._cache.popitem(last=False)
|
|
224
|
+
|
|
225
|
+
logger.debug("SOPS decrypted and cached: %s", path.name)
|
|
226
|
+
return content
|
|
227
|
+
|
|
228
|
+
def purge_cache(self, path: pathlib.Path | None = None) -> None:
|
|
229
|
+
"""Clear cache entries.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
path: If provided, only clear this specific path.
|
|
233
|
+
If None, clear all cached entries.
|
|
234
|
+
"""
|
|
235
|
+
if path is None:
|
|
236
|
+
self._cache.clear()
|
|
237
|
+
logger.debug("SOPS cache cleared")
|
|
238
|
+
else:
|
|
239
|
+
removed = self._cache.pop(path.resolve(), None)
|
|
240
|
+
if removed:
|
|
241
|
+
logger.debug("SOPS cache entry removed: %s", path.name)
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def cache_size(self) -> int:
|
|
245
|
+
"""Return the current number of cached entries."""
|
|
246
|
+
return len(self._cache)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Global singleton
|
|
250
|
+
_decryptor: SopsDecryptor | None = None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_decryptor(binary: str = "sops") -> SopsDecryptor:
|
|
254
|
+
"""Get or create global SOPS decryptor singleton.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
binary: SOPS binary name (only used on first call).
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
The global SopsDecryptor instance.
|
|
261
|
+
|
|
262
|
+
Examples:
|
|
263
|
+
>>> decryptor = get_decryptor() # doctest: +SKIP
|
|
264
|
+
>>> content = decryptor.decrypt_file(path) # doctest: +SKIP
|
|
265
|
+
"""
|
|
266
|
+
global _decryptor
|
|
267
|
+
if _decryptor is None:
|
|
268
|
+
_decryptor = SopsDecryptor(binary=binary)
|
|
269
|
+
return _decryptor
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def reset_decryptor() -> None:
|
|
273
|
+
"""Reset the global decryptor singleton (for testing)."""
|
|
274
|
+
global _decryptor
|
|
275
|
+
_decryptor = None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
__all__ = [
|
|
279
|
+
"ENC_MARKER",
|
|
280
|
+
"SOPS_FILE_PATTERNS",
|
|
281
|
+
"SopsDecryptor",
|
|
282
|
+
"get_decryptor",
|
|
283
|
+
"get_real_extension",
|
|
284
|
+
"has_encrypted_values",
|
|
285
|
+
"is_sops_file",
|
|
286
|
+
"reset_decryptor",
|
|
287
|
+
]
|
kstlib/db/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Async database module for SQLite/SQLCipher.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- AsyncDatabase: High-level async interface
|
|
5
|
+
- ConnectionPool: Connection pooling with retry
|
|
6
|
+
- SQLCipher encryption via SOPS integration
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
Basic in-memory database:
|
|
10
|
+
|
|
11
|
+
>>> from kstlib.db import AsyncDatabase
|
|
12
|
+
>>> db = AsyncDatabase(":memory:")
|
|
13
|
+
|
|
14
|
+
Encrypted database with SOPS:
|
|
15
|
+
|
|
16
|
+
>>> db = AsyncDatabase( # doctest: +SKIP
|
|
17
|
+
... "app.db",
|
|
18
|
+
... cipher_sops="secrets.yml",
|
|
19
|
+
... cipher_sops_key="database_key"
|
|
20
|
+
... )
|
|
21
|
+
|
|
22
|
+
Usage as context manager:
|
|
23
|
+
|
|
24
|
+
>>> async with AsyncDatabase(":memory:") as db: # doctest: +SKIP
|
|
25
|
+
... await db.execute("CREATE TABLE test (id INTEGER)")
|
|
26
|
+
... await db.execute("INSERT INTO test VALUES (?)", (1,))
|
|
27
|
+
... row = await db.fetch_one("SELECT * FROM test")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from kstlib.db.aiosqlcipher import is_sqlcipher_available
|
|
31
|
+
from kstlib.db.cipher import apply_cipher_key, resolve_cipher_key
|
|
32
|
+
from kstlib.db.database import AsyncDatabase
|
|
33
|
+
from kstlib.db.exceptions import (
|
|
34
|
+
DatabaseConnectionError,
|
|
35
|
+
DatabaseError,
|
|
36
|
+
EncryptionError,
|
|
37
|
+
PoolExhaustedError,
|
|
38
|
+
TransactionError,
|
|
39
|
+
)
|
|
40
|
+
from kstlib.db.pool import ConnectionPool, PoolStats
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AsyncDatabase",
|
|
44
|
+
"ConnectionPool",
|
|
45
|
+
"DatabaseConnectionError",
|
|
46
|
+
"DatabaseError",
|
|
47
|
+
"EncryptionError",
|
|
48
|
+
"PoolExhaustedError",
|
|
49
|
+
"PoolStats",
|
|
50
|
+
"TransactionError",
|
|
51
|
+
"apply_cipher_key",
|
|
52
|
+
"is_sqlcipher_available",
|
|
53
|
+
"resolve_cipher_key",
|
|
54
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Async SQLCipher wrapper built on top of aiosqlite.
|
|
2
|
+
|
|
3
|
+
Provides async database connections with SQLCipher AES-256 encryption.
|
|
4
|
+
This module wraps aiosqlite to use sqlcipher3 instead of standard sqlite3.
|
|
5
|
+
|
|
6
|
+
Requirements:
|
|
7
|
+
pip install kstlib[db-crypto] # Installs sqlcipher3
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
Basic encrypted connection::
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from kstlib.db.aiosqlcipher import connect
|
|
14
|
+
|
|
15
|
+
async def main():
|
|
16
|
+
async with connect(":memory:", cipher_key="secret") as db:
|
|
17
|
+
await db.execute("CREATE TABLE test (id INTEGER)")
|
|
18
|
+
|
|
19
|
+
asyncio.run(main())
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from kstlib.db.exceptions import EncryptionError
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from aiosqlite import Connection
|
|
32
|
+
|
|
33
|
+
__all__ = ["connect", "is_sqlcipher_available"]
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_sqlcipher_available() -> bool:
|
|
39
|
+
"""Check if sqlcipher3 is installed and available.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if sqlcipher3 can be imported.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> is_sqlcipher_available() # doctest: +SKIP
|
|
46
|
+
True
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
import sqlcipher3 # noqa: F401
|
|
50
|
+
|
|
51
|
+
return True
|
|
52
|
+
except ImportError:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def connect(
|
|
57
|
+
database: str | Path,
|
|
58
|
+
*,
|
|
59
|
+
cipher_key: str,
|
|
60
|
+
iter_chunk_size: int = 64,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> Connection:
|
|
63
|
+
"""Create an async connection to an encrypted SQLite database.
|
|
64
|
+
|
|
65
|
+
This function is a drop-in replacement for aiosqlite.connect() that
|
|
66
|
+
uses SQLCipher for AES-256 encryption. The cipher key is applied
|
|
67
|
+
immediately after connection using PRAGMA key.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
database: Path to database file or ":memory:" for in-memory.
|
|
71
|
+
cipher_key: Encryption key for SQLCipher (required).
|
|
72
|
+
iter_chunk_size: Rows to fetch per iteration (default: 64).
|
|
73
|
+
**kwargs: Additional arguments passed to sqlcipher3.connect().
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Async Connection object (same interface as aiosqlite.Connection).
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
EncryptionError: If sqlcipher3 is not installed or key is empty.
|
|
80
|
+
sqlite3.DatabaseError: If database exists but key is wrong.
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
>>> async with connect("app.db", cipher_key="secret") as db: # doctest: +SKIP
|
|
84
|
+
... await db.execute("CREATE TABLE users (id INTEGER)")
|
|
85
|
+
|
|
86
|
+
>>> # With SOPS key resolution:
|
|
87
|
+
>>> from kstlib.db.cipher import resolve_cipher_key
|
|
88
|
+
>>> key = resolve_cipher_key(sops_path="secrets.yml") # doctest: +SKIP
|
|
89
|
+
>>> async with connect("app.db", cipher_key=key) as db: # doctest: +SKIP
|
|
90
|
+
... pass
|
|
91
|
+
"""
|
|
92
|
+
if not cipher_key:
|
|
93
|
+
raise EncryptionError("cipher_key is required for encrypted connections")
|
|
94
|
+
|
|
95
|
+
# Import sqlcipher3 (fail early if not installed)
|
|
96
|
+
try:
|
|
97
|
+
import sqlcipher3
|
|
98
|
+
except ImportError as e:
|
|
99
|
+
raise EncryptionError("sqlcipher3 is not installed. Install with: pip install kstlib[db-crypto]") from e
|
|
100
|
+
|
|
101
|
+
# Import aiosqlite Connection class (we reuse its async machinery)
|
|
102
|
+
from aiosqlite.core import Connection
|
|
103
|
+
|
|
104
|
+
# Resolve path
|
|
105
|
+
db_path = str(database) if isinstance(database, Path) else database
|
|
106
|
+
|
|
107
|
+
def connector() -> sqlcipher3.Connection:
|
|
108
|
+
"""Create encrypted connection with key already applied.
|
|
109
|
+
|
|
110
|
+
This runs in a worker thread (via aiosqlite).
|
|
111
|
+
"""
|
|
112
|
+
# Enable autocommit by default (isolation_level=None)
|
|
113
|
+
# This ensures data is persisted immediately without explicit commit
|
|
114
|
+
# User can override by passing isolation_level in kwargs
|
|
115
|
+
connect_kwargs = {"isolation_level": None, **kwargs}
|
|
116
|
+
|
|
117
|
+
# Connect using sqlcipher3 (NOT standard sqlite3)
|
|
118
|
+
conn = sqlcipher3.connect(db_path, **connect_kwargs)
|
|
119
|
+
|
|
120
|
+
# Apply encryption key (MUST be first operation)
|
|
121
|
+
# Escape single quotes to prevent SQL injection
|
|
122
|
+
escaped_key = cipher_key.replace("'", "''")
|
|
123
|
+
conn.execute(f"PRAGMA key = '{escaped_key}'")
|
|
124
|
+
|
|
125
|
+
# Verify key works by reading schema
|
|
126
|
+
# This will raise DatabaseError if key is wrong
|
|
127
|
+
try:
|
|
128
|
+
conn.execute("SELECT count(*) FROM sqlite_master")
|
|
129
|
+
except Exception as e:
|
|
130
|
+
conn.close()
|
|
131
|
+
raise EncryptionError(f"Invalid cipher key or corrupted database: {e}") from e
|
|
132
|
+
|
|
133
|
+
log.debug("SQLCipher connection established: %s", db_path)
|
|
134
|
+
return conn
|
|
135
|
+
|
|
136
|
+
# Return aiosqlite Connection with our custom connector
|
|
137
|
+
return Connection(connector, iter_chunk_size)
|
kstlib/db/cipher.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""SQLCipher integration with SOPS secret resolution.
|
|
2
|
+
|
|
3
|
+
Provides secure key management for encrypted SQLite databases.
|
|
4
|
+
Keys can be loaded from:
|
|
5
|
+
- Direct passphrase
|
|
6
|
+
- Environment variable
|
|
7
|
+
- SOPS-encrypted file via kstlib.secrets
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from kstlib.db.exceptions import EncryptionError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import sqlite3
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_cipher_key(
|
|
25
|
+
*,
|
|
26
|
+
passphrase: str | None = None,
|
|
27
|
+
env_var: str | None = None,
|
|
28
|
+
sops_path: str | Path | None = None,
|
|
29
|
+
sops_key: str = "db_key",
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Resolve encryption key from various sources.
|
|
32
|
+
|
|
33
|
+
Priority: passphrase > env_var > sops_path
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
passphrase: Direct passphrase string.
|
|
37
|
+
env_var: Environment variable name containing the key.
|
|
38
|
+
sops_path: Path to SOPS-encrypted file.
|
|
39
|
+
sops_key: Key name within SOPS file (default: "db_key").
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Resolved encryption key.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
EncryptionError: If no key source provided or resolution fails.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> key = resolve_cipher_key(passphrase="my-secret-key")
|
|
49
|
+
>>> len(key) > 0
|
|
50
|
+
True
|
|
51
|
+
"""
|
|
52
|
+
# Direct passphrase (highest priority)
|
|
53
|
+
if passphrase:
|
|
54
|
+
return passphrase
|
|
55
|
+
|
|
56
|
+
# Environment variable
|
|
57
|
+
if env_var:
|
|
58
|
+
import os
|
|
59
|
+
|
|
60
|
+
key = os.environ.get(env_var)
|
|
61
|
+
if key:
|
|
62
|
+
log.debug("Resolved cipher key from env var: %s", env_var)
|
|
63
|
+
return key
|
|
64
|
+
raise EncryptionError(f"Environment variable '{env_var}' not set or empty")
|
|
65
|
+
|
|
66
|
+
# SOPS file
|
|
67
|
+
if sops_path:
|
|
68
|
+
try:
|
|
69
|
+
from kstlib.secrets.models import SecretRequest
|
|
70
|
+
from kstlib.secrets.providers.sops import SOPSProvider
|
|
71
|
+
|
|
72
|
+
provider = SOPSProvider(path=sops_path)
|
|
73
|
+
request = SecretRequest(name=sops_key, required=True)
|
|
74
|
+
record = provider.resolve(request)
|
|
75
|
+
if record is None or record.value is None:
|
|
76
|
+
raise EncryptionError(f"Key '{sops_key}' not found in SOPS file")
|
|
77
|
+
log.debug("Resolved cipher key from SOPS: %s", sops_path)
|
|
78
|
+
return str(record.value)
|
|
79
|
+
except ImportError as e:
|
|
80
|
+
raise EncryptionError("kstlib.secrets required for SOPS support") from e
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise EncryptionError(f"Failed to resolve SOPS key: {e}") from e
|
|
83
|
+
|
|
84
|
+
raise EncryptionError("No encryption key source provided. Specify passphrase, env_var, or sops_path.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply_cipher_key(conn: sqlite3.Connection, key: str) -> None:
|
|
88
|
+
"""Apply SQLCipher key to a connection.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
conn: SQLite connection object.
|
|
92
|
+
key: Encryption key to apply.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
EncryptionError: If key application fails.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
# SQLCipher PRAGMA to set key
|
|
99
|
+
# Escape single quotes to prevent SQL injection
|
|
100
|
+
escaped_key = key.replace("'", "''")
|
|
101
|
+
cursor = conn.execute(f"PRAGMA key = '{escaped_key}'")
|
|
102
|
+
cursor.close()
|
|
103
|
+
# Verify key works by reading schema
|
|
104
|
+
cursor = conn.execute("SELECT count(*) FROM sqlite_master")
|
|
105
|
+
cursor.fetchone()
|
|
106
|
+
cursor.close()
|
|
107
|
+
log.debug("SQLCipher key applied successfully")
|
|
108
|
+
except Exception as e:
|
|
109
|
+
raise EncryptionError(f"Failed to apply cipher key: {e}") from e
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = ["apply_cipher_key", "resolve_cipher_key"]
|