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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {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"]