Coverage for src/local_deep_research/database/encrypted_db.py: 94%

463 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Encrypted database management using SQLCipher. 

3Handles per-user encrypted databases with browser-friendly authentication. 

4""" 

5 

6import hmac 

7import secrets 

8import os 

9import threading 

10import time 

11from pathlib import Path 

12from typing import Any, Dict, Optional 

13 

14from loguru import logger 

15from sqlalchemy import create_engine, event, text 

16from sqlalchemy.engine import Engine 

17from sqlalchemy.orm import Session, sessionmaker 

18from sqlalchemy.pool import QueuePool, StaticPool 

19 

20from ..config.paths import get_data_directory, get_user_database_filename 

21from ..settings.env_registry import get_env_setting 

22 

23# ``redact_secrets`` stays local to the exception handlers that use it. A 

24# module-scope import would initialize the broad ``security`` package while 

25# encrypted_db is still defining its bootstrap state, unnecessarily widening 

26# this low-level module's dependency surface. 

27# 

28# Historically, ``security/__init__.py`` eagerly re-exported file-integrity 

29# symbols, which created this cycle during database bootstrap: 

30# 

31# database/encrypted_db.py 

32# → security/__init__.py 

33# → security/file_integrity/integrity_manager.py 

34# → database/session_context.py 

35# → database/encrypted_db.py (mid-load, ``db_manager`` not yet 

36# defined → ImportError) 

37# 

38# File-integrity exports are now lazy, and test_integrity_bootstrap_guard.py 

39# locks in the current fail-loud session-context import. Keeping this import 

40# local avoids coupling encrypted_db bootstrap to unrelated security package 

41# initialization. Other files in PRs #4168/#4175/#4181 can import 

42# ``redact_secrets`` at module scope because they are not on that bootstrap 

43# path. 

44from .sqlcipher_compat import get_sqlcipher_module 

45from .pool_config import ( 

46 MAX_OVERFLOW, 

47 POOL_PRE_PING, 

48 POOL_RECYCLE_SECONDS, 

49 POOL_SIZE, 

50) 

51from .sqlcipher_utils import ( 

52 set_sqlcipher_key, 

53 set_sqlcipher_rekey, 

54 apply_cipher_defaults_before_key, 

55 apply_sqlcipher_pragmas, 

56 apply_performance_pragmas, 

57 verify_sqlcipher_connection, 

58 create_database_salt, 

59 get_salt_file_path, 

60 has_per_database_salt, 

61 get_key_from_password, 

62 get_sqlcipher_version, 

63 create_sqlcipher_connection, 

64) 

65 

66 

67class DatabaseInitializationError(Exception): 

68 """Raised when a per-user database opens but its schema can't be initialised. 

69 

70 Distinct from credential / decryption failures (which return ``None`` 

71 from :py:meth:`DatabaseManager.open_user_database`) so callers — chiefly 

72 the login route — can avoid penalising the user's lockout counter and 

73 surface a different error message. The credentials are valid; the 

74 database state isn't. 

75 """ 

76 

77 

78def _best_effort_chmod(path, mode: int, *, warn: bool = False) -> None: 

79 """Tighten permissions on ``path`` to ``mode``, never raising. 

80 

81 Permission hardening must NOT be able to break database creation. On 

82 filesystems that don't support POSIX chmod — some Docker bind mounts and 

83 network/FUSE volumes, notably Docker Desktop on macOS/Windows — os.chmod 

84 can raise OSError even for the file's owner. In that case we leave the 

85 path at its default (umask) mode rather than failing registration/login. 

86 

87 ``warn=True`` surfaces the failure at warning level: a silent downgrade 

88 leaves the path group/world-readable, which matters for DB files holding 

89 user data (the unencrypted fallback is plaintext). The owner-only 

90 directory chmod is defense-in-depth, so it stays at debug. 

91 """ 

92 try: 

93 os.chmod(str(path), mode) 

94 except OSError: 

95 log = logger.warning if warn else logger.debug 

96 log( 

97 f"Could not set permissions {oct(mode)} on {path}; " 

98 "left at filesystem default", 

99 exc_info=True, 

100 ) 

101 

102 

103def _remove_partial_user_db_files(db_path: Path) -> None: 

104 """Remove a half-created user database and all of its sidecar files. 

105 

106 Called from ``create_user_database``'s failure paths. The encrypted 

107 branch writes a per-database ``.salt`` file (via ``create_database_salt``) 

108 *before* the DB itself, and SQLCipher/WAL leaves ``-wal``/``-shm`` 

109 sidecars. Removing only the ``.db`` — as these cleanup paths used to — 

110 orphans the ``.salt``: a retry then hits ``create_database_salt``'s 

111 ``FileExistsError`` (it refuses to overwrite to prevent data loss), so the 

112 username stays permanently un-registerable even after the orphaned auth 

113 row is cleaned up. Delete every artifact so the on-disk state is atomic 

114 and the username is reusable on a subsequent retry. 

115 

116 ``create_user_database`` calls this from four sites: its three 

117 failure-cleanup blocks (structure creation, engine build, migration — a 

118 graceful error mid-creation) and its salt-creation recovery path (an 

119 orphan left by a prior hard-killed create or by pre-#4934 cleanup that 

120 removed only the ``.db``). In every case the ``db_path.exists()`` guard at 

121 the top of ``create_user_database`` already ran and found no *pre-existing* 

122 database, so any ``.db``/``.salt`` present now is this call's own partial 

123 work — removing it cannot destroy live data, because a valid encrypted DB 

124 always has both the ``.db`` and its ``.salt``. 

125 

126 Both the ``-wal``/``-shm`` (WAL mode, the default) and ``-journal`` 

127 (rollback-journal modes: DELETE/TRUNCATE/PERSIST) sidecars are removed so 

128 the cleanup is correct regardless of the configured ``journal_mode``. 

129 

130 Never raises — cleanup must not mask the original creation error. 

131 """ 

132 # Derive every artifact path up front, guarded: the "Never raises" 

133 # contract has to hold even if a path helper is handed something odd 

134 # (e.g. a nameless path, where ``with_name``/``with_suffix`` raise), so a 

135 # derivation failure is logged and swallowed rather than propagating and 

136 # masking the original creation error. 

137 try: 

138 db_path = Path(db_path) 

139 artifacts = ( 

140 db_path, 

141 get_salt_file_path(db_path), 

142 db_path.with_name(db_path.name + "-wal"), 

143 db_path.with_name(db_path.name + "-shm"), 

144 db_path.with_name(db_path.name + "-journal"), 

145 ) 

146 except Exception: 

147 logger.warning( 

148 f"Could not derive partial user DB artifact paths: {db_path!r}" 

149 ) 

150 return 

151 

152 for path in artifacts: 

153 try: 

154 path.unlink(missing_ok=True) 

155 except OSError: 

156 # Best-effort: a leftover artifact is worth surfacing but must not 

157 # mask the original creation error. The path is enough to 

158 # diagnose; skip the exception detail per the sensitive-logging 

159 # policy. 

160 logger.warning(f"Could not remove partial user DB artifact {path}") 

161 

162 

163class DatabaseManager: 

164 """Manages encrypted SQLCipher databases for each user.""" 

165 

166 def __init__(self): 

167 self.connections: Dict[str, Engine] = {} 

168 # username -> (salt, digest) proving which password opened the cached 

169 # engine. `connections` is keyed by username ALONE, so a cache hit 

170 # says nothing about the credential presented on THIS call; without a 

171 # verifier, returning the cached engine silently accepts any password. 

172 # See _password_matches_cached(). 

173 # 

174 # A fast keyed digest is the right primitive here: this is an 

175 # in-process equality check, not at-rest storage, and the plaintext 

176 # password already lives in memory in session_password_store, so a 

177 # slow KDF would add cost without reducing exposure. 

178 self._password_verifiers: Dict[str, tuple[bytes, bytes]] = {} 

179 self._verifier_key = secrets.token_bytes(32) 

180 self._connections_lock = threading.RLock() 

181 # Per-user locks serializing the cold-open (engine build + migration) 

182 # so two concurrent first-opens of one user never run alembic against 

183 # the same database file at once. Created lazily under 

184 # _connections_lock; see open_user_database / _get_init_lock. 

185 self._init_locks: Dict[str, threading.Lock] = {} 

186 self._data_dir_override: Optional[Path] = None 

187 self._initialized_data_dirs: set[Path] = set() 

188 

189 # Check SQLCipher availability 

190 self.has_encryption = self._check_encryption_available() 

191 

192 # ---------------------------------------------------------------- 

193 # Pool class selection — see ADR-0004 

194 # 

195 # We use QueuePool (pool_size=20, max_overflow=40, 

196 # pool_timeout=10) for production and StaticPool for tests. 

197 # 

198 # Why pool_size=20: 

199 # 

200 # 1. SQLCipher + WAL mode can leak file handles when connections 

201 # close out of open-order. Fewer pooled connections = fewer 

202 # opportunities for out-of-order closes during pool_recycle. 

203 # See: https://github.com/sqlcipher/android-database-sqlcipher/issues/6 

204 # See: https://github.com/dotnet/efcore/issues/35010 

205 # 

206 # 2. SQLite serializes all writes through a single file lock. 

207 # Multiple pooled connections don't improve throughput — they 

208 # just hold FDs (up to 3 per connection in WAL mode). 

209 # 

210 # 3. The cleanup scheduler periodically calls engine.dispose() 

211 # to release all pooled connections, preventing long-lived 

212 # handles from accumulating over days of idle operation. 

213 # 

214 # Why pool_size=20 and not 1: inject_current_user() creates a 

215 # QueuePool session on every request via g.db_session. With the 

216 # UI polling /api/research/<id>/status every 1-2s plus other 

217 # API calls and before_request middleware, pool_size=1 

218 # (max_overflow=2, so 3 total) is easily exhausted — causing 

219 # 30-second timeouts and PendingRollbackError cascades. 

220 # pool_size=20 + max_overflow=40 (60 total) provides ample 

221 # headroom for concurrent requests and multiple browser tabs. 

222 # 

223 # Why not NullPool: SQLCipher's PRAGMA key adds ~0.2ms per 

224 # connection open. With 20-30 queries per page load, NullPool 

225 # adds a noticeable 4-6ms overhead vs QueuePool's ~1.5ms. 

226 # ---------------------------------------------------------------- 

227 self._use_static_pool = bool(os.environ.get("TESTING")) 

228 self._pool_class = StaticPool if self._use_static_pool else QueuePool 

229 

230 def _ensure_data_dir(self, path: Path) -> None: 

231 """Create and harden one resolved encrypted-database directory.""" 

232 path = Path(path) 

233 with self._connections_lock: 

234 if path in self._initialized_data_dirs: 

235 return 

236 # Lazy import: avoid a security -> ... -> database import cycle at 

237 # module load time (this can run during DatabaseManager 

238 # construction, which happens early in app bootstrap). 

239 from ..security.directory_creation import create_directory 

240 

241 create_directory( 

242 path, 

243 context="encrypted database storage", 

244 ) 

245 # Restrict the per-user DB directory to the owner (0o700). 

246 # This covers SQLite WAL/SHM sidecars and plaintext fallback temp 

247 # files even when SQLite recreates them with umask permissions. 

248 _best_effort_chmod(path, 0o700) 

249 self._initialized_data_dirs.add(path) 

250 

251 @property 

252 def data_dir(self) -> Path: 

253 """Return the encrypted-DB directory for the live data root. 

254 

255 The default path is resolved on every access so a process that imports 

256 ``db_manager`` before ``LDR_DATA_DIR`` is set does not stay bound to 

257 the platform default. Explicit assignments use the setter below and 

258 remain stable for tests and programmatic callers. 

259 """ 

260 path = self._data_dir_override 

261 if path is None: 

262 path = get_data_directory() / "encrypted_databases" 

263 self._ensure_data_dir(path) 

264 return path 

265 

266 @data_dir.setter 

267 def data_dir(self, value: Path) -> None: 

268 """Set an explicit data-directory override for this manager.""" 

269 path = Path(value) 

270 self._data_dir_override = path 

271 self._ensure_data_dir(path) 

272 

273 def _get_pool_kwargs(self) -> Dict[str, Any]: 

274 """Get pool configuration kwargs based on pool type. 

275 

276 StaticPool doesn't support pool_size or max_overflow. 

277 QueuePool uses moderate sizing to handle concurrent web requests 

278 while limiting FD usage. See ADR-0004 for rationale. 

279 """ 

280 if self._use_static_pool: 

281 return {} 

282 return { 

283 "pool_size": POOL_SIZE, 

284 "max_overflow": MAX_OVERFLOW, 

285 "pool_timeout": 10, 

286 "pool_pre_ping": POOL_PRE_PING, 

287 "pool_recycle": POOL_RECYCLE_SECONDS, 

288 } 

289 

290 def _is_valid_encryption_key(self, password: str) -> bool: 

291 """ 

292 Check if the provided password is valid (not None, empty, or whitespace-only). 

293 

294 Args: 

295 password: The password to check 

296 

297 Returns: 

298 True if the password is valid, False otherwise 

299 """ 

300 return password is not None and password.strip() != "" 

301 

302 def is_user_connected(self, username: str) -> bool: 

303 """Check if a user has an active database connection. 

304 

305 Thread-safe accessor for external callers. 

306 

307 Args: 

308 username: The username to check 

309 

310 Returns: 

311 True if the user has an active connection 

312 """ 

313 with self._connections_lock: 

314 return username in self.connections 

315 

316 def _check_encryption_available(self) -> bool: 

317 """Check if SQLCipher is available for encryption.""" 

318 try: 

319 import os as os_module 

320 import tempfile 

321 

322 # Test if SQLCipher actually works, not just if it imports 

323 with tempfile.NamedTemporaryFile(delete=False) as tmp: 

324 tmp_path = tmp.name 

325 

326 try: 

327 # Try to create a test encrypted database 

328 sqlcipher_module = get_sqlcipher_module() 

329 sqlcipher = sqlcipher_module.dbapi2 

330 

331 conn = sqlcipher.connect(tmp_path) 

332 try: 

333 cursor = conn.cursor() 

334 # Use creation_mode=True since we're creating a new test database 

335 apply_cipher_defaults_before_key(cursor) 

336 # Use centralized key setting 

337 set_sqlcipher_key(cursor, "testpass") 

338 # Apply post-key pragmas (kdf_iter for new DB) 

339 apply_sqlcipher_pragmas(cursor, creation_mode=True) 

340 apply_performance_pragmas(cursor) 

341 

342 # Check SQLCipher version 

343 version = get_sqlcipher_version(cursor) 

344 if version: 344 ↛ 356line 344 didn't jump to line 356 because the condition on line 344 was always true

345 major = ( 

346 version.split(".")[0] 

347 if "." in version 

348 else version[0] 

349 ) 

350 if major.isdigit() and int(major) < 4: 350 ↛ 351line 350 didn't jump to line 351 because the condition on line 350 was never true

351 logger.warning( 

352 f"SQLCipher version {version} detected. " 

353 "Version 4.x+ is recommended for proper PRAGMA ordering." 

354 ) 

355 

356 cursor.close() 

357 # Now use the connection for table operations 

358 conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)") 

359 conn.execute("INSERT INTO test VALUES (1)") 

360 result = conn.execute("SELECT * FROM test").fetchone() 

361 

362 if result != (1,): 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 raise RuntimeError("SQLCipher encryption test failed") 

364 logger.info( 

365 "SQLCipher available and working - databases will be encrypted" 

366 ) 

367 return True 

368 finally: 

369 from ..utilities.resource_utils import safe_close 

370 

371 safe_close(conn, "SQLCipher test connection") 

372 except Exception: 

373 logger.warning("SQLCipher module found but not working") 

374 raise ImportError("SQLCipher not functional") 

375 finally: 

376 # Clean up test file 

377 try: 

378 os_module.unlink(tmp_path) 

379 except OSError as e: 

380 logger.debug( 

381 f"Failed to clean up temp file {tmp_path}: {e}" 

382 ) 

383 

384 except ImportError: 

385 # Check if user has explicitly allowed unencrypted databases. 

386 # Registry handles deprecated LDR_ALLOW_UNENCRYPTED fallback automatically. 

387 allow_unencrypted = get_env_setting( 

388 "bootstrap.allow_unencrypted", False 

389 ) 

390 

391 if not allow_unencrypted: 

392 # No ``password`` is in scope here (this runs at 

393 # bootstrap, before any user authenticates), and the 

394 # caught ``ImportError`` is purely about the SQLCipher 

395 # package being missing. Still drop the traceback to 

396 # keep this file's logging discipline uniform with the 

397 # rest of the #4182 sweep: no exception traceback is 

398 # written to the production log from this module. The 

399 # install-hint message is the user-facing diagnostic; 

400 # the traceback adds no value beyond it. ``warning`` 

401 # rather than ``error``/``exception`` so the custom 

402 # "logger.exception in except blocks" lint passes 

403 # without re-introducing a traceback render. 

404 logger.warning( 

405 "SECURITY ERROR: SQLCipher is not installed!\n" 

406 "Your databases will NOT be encrypted.\n" 

407 "To fix this:\n" 

408 "1. Install SQLCipher: sudo apt install sqlcipher libsqlcipher-dev\n" 

409 "2. Reinstall project: pdm install\n" 

410 "Or use Docker with SQLCipher pre-installed.\n\n" 

411 "To explicitly allow unencrypted databases (NOT RECOMMENDED):\n" 

412 "export LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true" 

413 ) 

414 raise RuntimeError( 

415 "SQLCipher not available. Set LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true to proceed without encryption (NOT RECOMMENDED)" 

416 ) 

417 logger.warning( 

418 "WARNING: Running with UNENCRYPTED databases!\n" 

419 "This means:\n" 

420 "- Passwords don't protect data access\n" 

421 "- API keys are stored in plain text\n" 

422 "- Anyone with file access can read all data\n" 

423 "Install SQLCipher for secure operation!" 

424 ) 

425 return False 

426 

427 def _get_user_db_path(self, username: str) -> Path: 

428 """Get the path for a user's encrypted database. 

429 

430 Raises: 

431 ValueError: Propagated from ``get_user_database_filename`` if 

432 ``username`` is empty/None -- that's the actual sha256 

433 chokepoint, so this method fails closed the same way rather 

434 than resolving to a phantom shared ``sha256("")`` filename. 

435 Registration already enforces a non-empty username; this is 

436 defense-in-depth at the path-resolution chokepoint. 

437 """ 

438 return self.data_dir / get_user_database_filename(username) 

439 

440 def _apply_pragmas(self, connection, connection_record): 

441 """Apply pragmas for optimal performance.""" 

442 # Check if this is SQLCipher or regular SQLite 

443 is_encrypted = self.has_encryption 

444 

445 # Use centralized performance pragma application 

446 

447 apply_performance_pragmas(connection) 

448 

449 # SQLCipher-specific pragmas 

450 if is_encrypted: 450 ↛ 451line 450 didn't jump to line 451 because the condition on line 450 was never true

451 from .sqlcipher_utils import get_sqlcipher_settings 

452 

453 settings = get_sqlcipher_settings() 

454 pragmas = [ 

455 f"PRAGMA kdf_iter = {settings['kdf_iterations']}", 

456 f"PRAGMA cipher_page_size = {settings['page_size']}", 

457 ] 

458 for pragma in pragmas: 

459 try: 

460 connection.execute(pragma) 

461 except Exception as e: 

462 logger.debug(f"Could not apply pragma '{pragma}': {e}") 

463 else: 

464 # Regular SQLite pragma 

465 try: 

466 connection.execute( 

467 "PRAGMA mmap_size = 268435456" 

468 ) # 256MB memory mapping 

469 except Exception as e: 

470 logger.debug(f"Could not apply mmap_size pragma: {e}") 

471 

472 @staticmethod 

473 def _make_sqlcipher_connection( 

474 db_path: Path, 

475 password: str, 

476 isolation_level: Optional[str] = "IMMEDIATE", 

477 check_same_thread: bool = False, 

478 ) -> Any: 

479 """Create a properly initialized SQLCipher connection. 

480 

481 Follows the canonical SQLCipher initialization order: set key, 

482 apply cipher pragmas, verify, then apply performance pragmas. 

483 Cipher pragmas (page size, HMAC algorithm, KDF iterations) must 

484 be configured before the first query (verification) because that 

485 query triggers page decryption with the active cipher settings. 

486 

487 Args: 

488 db_path: Path to the database file 

489 password: The database encryption passphrase 

490 isolation_level: SQLite isolation level (``""`` for deferred 

491 transactions, ``None`` for autocommit) 

492 check_same_thread: SQLite check_same_thread flag 

493 

494 Returns: 

495 A raw ``sqlcipher3`` connection ready for use. 

496 

497 Raises: 

498 ValueError: If the database key cannot be verified. 

499 """ 

500 sqlcipher3 = get_sqlcipher_module() 

501 conn = sqlcipher3.connect( 

502 str(db_path), 

503 isolation_level=isolation_level, 

504 check_same_thread=check_same_thread, 

505 ) 

506 cursor = conn.cursor() 

507 

508 try: 

509 set_sqlcipher_key(cursor, password, db_path=db_path) 

510 apply_sqlcipher_pragmas(cursor, creation_mode=False) 

511 

512 if not verify_sqlcipher_connection(cursor): 

513 raise ValueError("Failed to verify database key") # noqa: TRY301 — cleanup in except before re-raise 

514 

515 apply_performance_pragmas(cursor) 

516 except Exception: 

517 try: 

518 cursor.close() 

519 except Exception: # noqa: BLE001 

520 logger.warning("Failed to close cursor during cleanup") 

521 from ..utilities.resource_utils import safe_close 

522 

523 safe_close(conn, "encrypted DB connection") 

524 raise 

525 

526 cursor.close() 

527 return conn 

528 

529 def create_user_database(self, username: str, password: str) -> Engine: 

530 """Create a new encrypted database for a user.""" 

531 

532 # Validate the encryption key 

533 if not self._is_valid_encryption_key(password): 

534 logger.error( 

535 f"Invalid encryption key for user {username}: password is None or empty" 

536 ) 

537 raise ValueError( 

538 "Invalid encryption key: password cannot be None or empty" 

539 ) 

540 

541 db_path = self._get_user_db_path(username) 

542 

543 if db_path.exists(): 

544 raise ValueError(f"Database already exists for user {username}") 

545 

546 # Create connection string - use regular SQLite when SQLCipher not available 

547 if self.has_encryption: 

548 # Create directory if it doesn't exist 

549 from ..security.directory_creation import create_directory 

550 

551 create_directory( 

552 db_path.parent, 

553 context="per-user encrypted database directory", 

554 ) 

555 

556 # Create per-database salt for new databases (v2 security improvement). 

557 # 

558 # A pre-existing salt here is an ORPHAN, not live data: the 

559 # ``db_path.exists()`` guard above already established that no 

560 # database file exists, and a valid encrypted DB always has BOTH 

561 # the ``.db`` and its ``.salt``. Such an orphan is left by a create 

562 # that died before completing — a hard process death (SIGKILL/OOM/ 

563 # power loss) mid-creation, or the pre-#4934 cleanup code that 

564 # removed only the ``.db``. Without recovery, ``create_database_salt`` 

565 # raises ``FileExistsError`` (it refuses to overwrite to protect 

566 # live data) on every retry, so the username stays permanently 

567 # un-registerable. Remove the stale artifacts and create fresh — 

568 # safe precisely because no database depends on this salt. 

569 # 

570 # Note: backups are keyed off the *source* DB's salt (they store no 

571 # salt copy — see backup_service), so deleting a salt also strands 

572 # any backups for it. That is not reachable here: this only fires 

573 # when db_path is absent, and the app never removes a live .db while 

574 # keeping its .salt (there is no in-app restore). Only out-of-band 

575 # manual deletion of the .db could turn this into backup loss. 

576 # 

577 # SAFETY INVARIANT: this deletion is safe only while a salt-without- 

578 # a-.db always means garbage. That holds today because create_user_ 

579 # database is the sole salt writer/caller and nothing restores or 

580 # imports a .db from a salt. If an in-app restore/import that stages 

581 # a .salt ahead of its .db is ever added, RE-EVALUATE this branch — 

582 # it could delete a salt whose database is legitimately incoming. 

583 try: 

584 create_database_salt(db_path) 

585 except FileExistsError: 

586 logger.warning( 

587 f"Removing orphaned salt (no matching database) for " 

588 f"{username} before creating a fresh one" 

589 ) 

590 _remove_partial_user_db_files(db_path) 

591 create_database_salt(db_path) 

592 logger.info(f"Created per-database salt for {username}") 

593 

594 # Create database structure using raw SQLCipher outside SQLAlchemy 

595 try: 

596 # Pre-derive key before closures to avoid capturing plaintext 

597 # password. Kept inside the try so a derivation failure here 

598 # also triggers the partial-file cleanup below, rather than 

599 # orphaning the just-created salt file. 

600 hex_key = get_key_from_password(password, db_path=db_path).hex() 

601 

602 conn = create_sqlcipher_connection( 

603 db_path, 

604 password=password, 

605 creation_mode=True, 

606 connect_kwargs={ 

607 # DEFERRED (empty string) so pure-SELECT transactions 

608 # acquire only SQLite's SHARED lock, letting WAL-mode 

609 # concurrent readers proceed while a writer is active. 

610 # IMMEDIATE was previously set "defensively" and made 

611 # every transaction (even reads) take a RESERVED lock, 

612 # which was the single biggest contention source on 

613 # the login-hang path. Race-prone check-then-insert 

614 # call sites were made race-free at the application 

615 # layer in the preceding prerequisite PR. 

616 "isolation_level": "", 

617 "check_same_thread": False, 

618 }, 

619 ) 

620 _best_effort_chmod(db_path, 0o600, warn=True) 

621 try: 

622 # Get the CREATE TABLE statements from SQLAlchemy models 

623 from sqlalchemy.dialects import sqlite 

624 from sqlalchemy.schema import CreateIndex, CreateTable 

625 

626 from .models import Base 

627 

628 # Indexes must be emitted explicitly — SQLAlchemy compiles 

629 # `index=True`/`unique=True` and `Index(...)` to separate 

630 # CREATE [UNIQUE] INDEX statements, not inline. 

631 sqlite_dialect = sqlite.dialect() 

632 for table in Base.metadata.sorted_tables: 

633 if table.name == "users": 

634 continue 

635 create_sql = str( 

636 CreateTable(table, if_not_exists=True).compile( 

637 dialect=sqlite_dialect 

638 ) 

639 ) 

640 logger.debug(f"Creating table {table.name}") 

641 conn.execute(create_sql) 

642 for index in table.indexes: 

643 index_sql = str( 

644 CreateIndex(index, if_not_exists=True).compile( 

645 dialect=sqlite_dialect 

646 ) 

647 ) 

648 conn.execute(index_sql) 

649 

650 conn.commit() 

651 finally: 

652 from ..utilities.resource_utils import safe_close 

653 

654 safe_close(conn, "user DB setup connection") 

655 

656 logger.info( 

657 f"Database structure created successfully for {username}" 

658 ) 

659 

660 except Exception as e: 

661 # ``password`` is in lexical scope (function parameter) and 

662 # is passed into ``create_sqlcipher_connection`` / 

663 # ``set_sqlcipher_key`` above. A traceback rendered with 

664 # loguru ``diagnose=True`` would dump frame locals — which 

665 # includes the plaintext SQLCipher master password. Use 

666 # ``logger.warning`` to drop the traceback chain and 

667 # redact the password from str(e) for defense in depth. 

668 # SQLCipher master passwords are unrecoverable (see 

669 # TRUST.md §5) so the impact of a leak is permanent. 

670 from ..security.log_sanitizer import redact_secrets 

671 

672 safe_msg = redact_secrets(str(e), password) 

673 logger.warning(f"Error creating database structure: {safe_msg}") 

674 # Remove the partial DB *and* its salt/WAL/journal sidecars so 

675 # a retry of the same username isn't blocked by a leftover salt. 

676 _remove_partial_user_db_files(db_path) 

677 raise 

678 

679 # Small delay to ensure file is fully written 

680 import time 

681 

682 time.sleep(0.1) 

683 

684 # Now create SQLAlchemy engine using custom connection creator 

685 def create_engine_connection(): 

686 """Create a properly initialized SQLCipher connection.""" 

687 return create_sqlcipher_connection( 

688 db_path, 

689 hex_key=hex_key, 

690 creation_mode=False, 

691 connect_kwargs={ 

692 # DEFERRED (empty string) so pure-SELECT transactions 

693 # acquire only SQLite's SHARED lock, letting WAL-mode 

694 # concurrent readers proceed while a writer is active. 

695 # IMMEDIATE was previously set "defensively" and made 

696 # every transaction (even reads) take a RESERVED lock, 

697 # which was the single biggest contention source on 

698 # the login-hang path. Race-prone check-then-insert 

699 # call sites were made race-free at the application 

700 # layer in the preceding prerequisite PR. 

701 "isolation_level": "", 

702 "check_same_thread": False, 

703 }, 

704 ) 

705 

706 # Create engine with custom creator function and optimized cache. 

707 # The .db and .salt already exist on disk (structure creation 

708 # succeeded above). This step sits between the structure-creation 

709 # and migration cleanup blocks; without its own cleanup a failure 

710 # here would orphan those files, and unlike every other failure 

711 # path it would NOT be self-healing — the retry trips the 

712 # db_path.exists() guard and bricks the username. So clean up on 

713 # any escape. (create_engine is lazy so this is near-unreachable, 

714 # but it closes the one gap in the otherwise-uniform coverage.) 

715 try: 

716 engine = create_engine( 

717 "sqlite://", 

718 creator=create_engine_connection, 

719 poolclass=self._pool_class, 

720 echo=False, 

721 query_cache_size=1000, 

722 **self._get_pool_kwargs(), 

723 ) 

724 except Exception: 

725 _remove_partial_user_db_files(db_path) 

726 raise 

727 else: 

728 logger.warning( 

729 f"SQLCipher not available - creating UNENCRYPTED database for user {username}" 

730 ) 

731 # Fall back to regular SQLite with query cache 

732 engine = create_engine( 

733 f"sqlite:///{db_path}", 

734 connect_args={"check_same_thread": False, "timeout": 30}, 

735 poolclass=self._pool_class, 

736 echo=False, 

737 query_cache_size=1000, 

738 **self._get_pool_kwargs(), 

739 ) 

740 

741 # For unencrypted databases, just apply pragmas 

742 event.listen(engine, "connect", self._apply_pragmas) 

743 

744 # Tables have already been created using raw SQLCipher above 

745 # No need to create them again with SQLAlchemy 

746 

747 # Initialize database tables using centralized initialization 

748 from .initialize import initialize_database 

749 

750 # Mirror of the fail-loud change #3635 made to open_user_database. 

751 # Previously this swallowed the exception with "tables exist but 

752 # schema version not stamped — migrations will be retried on next 

753 # process restart". That left a half-broken DB on disk: tables 

754 # present, no alembic_version row. The next login then re-ran 

755 # alembic, hit the same error, and (post-#3635) 503'd — so the 

756 # user could register but never log in again. Better to fail 

757 # registration loudly with the partial DB removed, so the real 

758 # cause (e.g. world-writable migrations dir) gets fixed instead 

759 # of producing a permanently-locked-out account. 

760 try: 

761 Session = sessionmaker(bind=engine) 

762 with Session() as session: 

763 initialize_database(engine, session) 

764 except Exception as e: 

765 # ``password`` is in scope and was passed into the engine 

766 # creator closure above. Drop the traceback to avoid leaking 

767 # frame locals under ``diagnose=True`` and redact the 

768 # password from str(e) defensively. 

769 from ..security.log_sanitizer import redact_secrets 

770 

771 safe_msg = redact_secrets(str(e), password) 

772 logger.warning( 

773 f"Database migration failed for {username} during creation" 

774 f" — removing partial DB: {safe_msg}" 

775 ) 

776 engine.dispose() 

777 # Remove the partial DB *and* its salt/WAL/journal sidecars so a 

778 # retry of the same username isn't blocked by a leftover salt. 

779 _remove_partial_user_db_files(db_path) 

780 raise 

781 

782 # Restrict DB file to owner-only (0o600). The encrypted branch 

783 # chmod's right after create_sqlcipher_connection, but the 

784 # unencrypted fallback creates the file lazily on first connect 

785 # (during initialize_database above), so it is only guaranteed to 

786 # exist here. This file holds PLAINTEXT user data — leaving it at 

787 # umask-default perms (commonly 0o644) would expose it to other 

788 # local accounts. 

789 if not self.has_encryption and db_path.exists(): 

790 _best_effort_chmod(db_path, 0o600, warn=True) 

791 

792 # Store connection AFTER migrations complete. This password just proved 

793 # itself by creating the database; publish the engine and its verifier 

794 # together so the cache is never briefly trusted without one. 

795 self._cache_connection(username, engine, password) 

796 

797 logger.info(f"Created encrypted database for user {username}") 

798 return engine 

799 

800 def _compute_verifier_digest(self, salt: bytes, password: str) -> bytes: 

801 """Keyed digest binding ``password`` to a per-entry ``salt``. 

802 

803 A fast keyed HMAC, deliberately not a slow KDF: this is an in-process 

804 equality check, never persisted, and the plaintext password already 

805 lives in memory in session_password_store, so a KDF would add cost 

806 without reducing exposure. 

807 

808 NOTE: CodeQL py/weak-sensitive-data-hashing flags the hash below as 

809 weak password hashing. It is a reviewed FALSE POSITIVE: this app stores 

810 no password hashes at rest (SQLCipher's PBKDF2 is the real KDF on the 

811 cold-open path); this keyed digest only proves, in memory, that the SAME 

812 password opened the cached engine, so a slow KDF here would add login 

813 latency without reducing exposure. The rule is temporarily excluded in 

814 .github/codeql/codeql-config.yml (see the TEMPORARY note there); the 

815 preferred long-term fix is a per-alert dismissal in code scanning. 

816 """ 

817 return hmac.new( 

818 self._verifier_key, salt + password.encode("utf-8"), "sha256" 

819 ).digest() 

820 

821 def _make_verifier(self, password: str) -> tuple[bytes, bytes]: 

822 """Build a ``(salt, digest)`` verifier for ``password``. Takes no lock.""" 

823 salt = secrets.token_bytes(16) 

824 return salt, self._compute_verifier_digest(salt, password) 

825 

826 def _cache_connection( 

827 self, username: str, engine: Engine, password: str 

828 ) -> None: 

829 """Publish an engine and the verifier for the password that opened it, 

830 atomically. 

831 

832 Called only on paths that have ALREADY proved the password by opening 

833 (or creating) the SQLCipher database with it. Storing the engine and 

834 its verifier under a single lock keeps the invariant "a cached 

835 connection always has a matching verifier" true by construction, so a 

836 concurrent open can never observe a connection with a missing or stale 

837 verifier. 

838 """ 

839 verifier = self._make_verifier(password) 

840 with self._connections_lock: 

841 self.connections[username] = engine 

842 self._password_verifiers[username] = verifier 

843 

844 def _record_password_verifier(self, username: str, password: str) -> None: 

845 """Arm the verifier for a connection cached by other means. 

846 

847 Prefer :meth:`_cache_connection` when publishing the engine too — it 

848 stores both atomically. This standalone form arms a verifier against an 

849 already-cached engine. Caller must not hold ``_connections_lock``. 

850 """ 

851 verifier = self._make_verifier(password) 

852 with self._connections_lock: 

853 self._password_verifiers[username] = verifier 

854 

855 def _verifier_matches(self, username: str, password: str) -> bool: 

856 """Lock-free verifier comparison. **Caller must hold _connections_lock.** 

857 

858 Fails CLOSED: a missing verifier returns False so the caller falls 

859 through to a real key-validating open. Returning True on a missing 

860 verifier would reopen exactly the hole this closes. 

861 """ 

862 entry = self._password_verifiers.get(username) 

863 if entry is None: 

864 return False 

865 salt, expected = entry 

866 return hmac.compare_digest( 

867 self._compute_verifier_digest(salt, password), expected 

868 ) 

869 

870 def _cached_engine_trusted(self, username: str, password: str) -> bool: 

871 """May the cached engine for ``username`` be returned to a caller 

872 presenting ``password``? **Caller must hold _connections_lock.** 

873 

874 Unencrypted mode has no credential to verify — the cold path opens the 

875 plain-SQLite database with any password too — so the verifier is 

876 meaningless there and would only force needless cold opens against the 

877 placeholder passwords the app uses (session_context / 

878 database_middleware). Trust the cache directly. Encrypted mode requires 

879 a verifier match. 

880 """ 

881 if not self.has_encryption: 

882 return True 

883 return self._verifier_matches(username, password) 

884 

885 def _password_matches_cached(self, username: str, password: str) -> bool: 

886 """Does ``password`` match the one that opened the cached engine? 

887 

888 Fails CLOSED (see :meth:`_verifier_matches`). Takes the lock; prefer 

889 :meth:`_verifier_matches` when already holding it. 

890 """ 

891 with self._connections_lock: 

892 return self._verifier_matches(username, password) 

893 

894 def _get_init_lock(self, username: str) -> threading.Lock: 

895 """Return the per-user cold-open lock, creating it on first use. 

896 

897 Guards the engine-build + migration in ``open_user_database`` so two 

898 concurrent first-opens of the same user serialize instead of running 

899 migrations against one database file simultaneously. Per-user (not the 

900 global ``_connections_lock``) so different users still open in 

901 parallel. 

902 """ 

903 with self._connections_lock: 

904 lock = self._init_locks.get(username) 

905 if lock is None: 

906 lock = threading.Lock() 

907 self._init_locks[username] = lock 

908 return lock 

909 

910 def open_user_database( 

911 self, username: str, password: str 

912 ) -> Optional[Engine]: 

913 """Open an existing encrypted database for a user.""" 

914 open_start = time.perf_counter() 

915 

916 # Validate the encryption key 

917 if not self._is_valid_encryption_key(password): 

918 logger.error( 

919 f"Invalid encryption key when opening database for user {username}: password is None or empty" 

920 ) 

921 # TODO: Fix the root cause - research threads are not getting the correct password 

922 logger.error( 

923 "TODO: This usually means the research thread is not receiving the user's " 

924 "password for database encryption. Need to ensure password is passed from " 

925 "the main thread to research threads." 

926 ) 

927 raise ValueError( 

928 "Invalid encryption key: password cannot be None or empty" 

929 ) 

930 

931 # Check if already open. 

932 # 

933 # `connections` is keyed by username ALONE, so a hit proves only that 

934 # SOMEONE opened this user's database -- not that the caller supplied 

935 # the right password. Returning the engine on a bare hit made login 

936 # accept ANY password for any user with a live cached connection, 

937 # which is the normal state whenever they are logged in anywhere. 

938 # Verify the credential against the one that actually opened it, and 

939 # fall through to a real (key-validating) open when it does not match. 

940 # Look up the engine and check its verifier under ONE lock acquisition 

941 # so the engine cannot be evicted between the lookup and the verify 

942 # (which would hand back a disposed, de-registered engine). 

943 with self._connections_lock: 

944 cached = self.connections.get(username) 

945 if cached is not None and self._cached_engine_trusted( 

946 username, password 

947 ): 

948 return cached 

949 

950 # Serialize the cold-open (engine build + migration) per user so two 

951 # concurrent first-opens never run alembic against the same database 

952 # file at once -- alembic's module-level proxy and the version-row 

953 # UPDATE are not safe under concurrent migration of one DB. 

954 init_lock = self._get_init_lock(username) 

955 with init_lock: 

956 # Re-check: another thread may have completed the cold-open while 

957 # we were waiting for the per-user lock. One lock acquisition, as 

958 # above, so the engine cannot be evicted between lookup and verify. 

959 with self._connections_lock: 

960 cached = self.connections.get(username) 

961 if cached is not None and self._cached_engine_trusted( 

962 username, password 

963 ): 

964 return cached 

965 engine = self._open_user_database_cold( 

966 username, password, open_start 

967 ) 

968 

969 # Phase 2 of the RAG vector-store cutover runs OUTSIDE init_lock (only 

970 # on the path that actually did the cold-open — the early returns above 

971 # belong to a thread that already ran it). It re-enters 

972 # open_user_database() via get_user_db_session on a session-cache miss; 

973 # doing it while still holding the non-reentrant init_lock would 

974 # self-deadlock if a concurrent close_user_database() evicted the 

975 # freshly-cached connection between the cache write and that re-entry. 

976 if engine is not None: 

977 self._run_phase2_rekey(username, password) 

978 return engine 

979 

980 def _run_phase2_rekey(self, username: str, password: str) -> None: 

981 """Re-key a user's legacy FAISS indices (uuid-keyed -> DocumentChunk.id 

982 -keyed), once, at login. See vector_stores/legacy_rekey.py. 

983 

984 A fast no-op (one get_setting call) once the per-user marker 

985 (research_library.rag_index_rekeyed_v2) is set, or when the user has no 

986 legacy sidecars — the common case. Wrapped so a re-key problem never 

987 blocks login; it logs its own errors. Must be called AFTER the init_lock 

988 is released (see open_user_database) to avoid a re-entrant self-deadlock. 

989 """ 

990 try: 

991 from ..vector_stores.legacy_rekey import rekey_user_indexes 

992 

993 rekey_user_indexes(username, password) 

994 except Exception as e: 

995 # NO rendered traceback + redacted message — the password-redaction 

996 # invariant (tests/security/test_password_redaction_invariant.py). 

997 # `password` (the unrecoverable SQLCipher master key) is live in this 

998 # frame and every frame of the rekey chain, so ANY traceback render 

999 # leaks it under LDR_LOGURU_DIAGNOSE. logger.opt(exception=True) does 

1000 # NOT suppress that (it renders identically to logger.exception) and 

1001 # also evades the AST guardrail, so log a redacted message with no 

1002 # stack, matching every other handler in this file. 

1003 from ..security.log_sanitizer import redact_secrets 

1004 

1005 safe_msg = redact_secrets(str(e), password) 

1006 logger.warning( 

1007 f"RAG index re-key failed for user {username}: {safe_msg}" 

1008 ) 

1009 

1010 def _open_user_database_cold( 

1011 self, username: str, password: str, open_start: float 

1012 ) -> Optional[Engine]: 

1013 """Build the engine and run migrations for a not-yet-cached user DB. 

1014 

1015 Must be called while holding this user's init lock (acquired in 

1016 ``open_user_database``) so concurrent first-opens of the same user do 

1017 not migrate one database file simultaneously. 

1018 """ 

1019 db_path = self._get_user_db_path(username) 

1020 

1021 # Prevent timing attacks: always derive key before checking file existence 

1022 # This ensures both existing and non-existent users take the same amount of time, 

1023 # preventing username enumeration via timing analysis. 

1024 # Pre-derive key before closures to avoid capturing plaintext password 

1025 hex_key = get_key_from_password(password, db_path=db_path).hex() 

1026 

1027 if not db_path.exists(): 

1028 logger.error(f"No database found for user {username}") 

1029 return None 

1030 

1031 # Warn if this is a legacy database without per-database salt 

1032 if self.has_encryption and not has_per_database_salt(db_path): 1032 ↛ 1033line 1032 didn't jump to line 1033 because the condition on line 1032 was never true

1033 logger.warning( 

1034 f"Database for user '{username}' uses the legacy shared salt " 

1035 f"(deprecated). For improved security, consider creating a new " 

1036 f"account to get a per-database salt. Legacy databases remain " 

1037 f"fully functional but are less resistant to multi-target attacks." 

1038 ) 

1039 

1040 # Create connection string - use regular SQLite when SQLCipher not available 

1041 if self.has_encryption: 

1042 

1043 def create_open_connection(): 

1044 """Create a properly initialized SQLCipher connection.""" 

1045 return create_sqlcipher_connection( 

1046 db_path, 

1047 hex_key=hex_key, 

1048 creation_mode=False, 

1049 connect_kwargs={ 

1050 # DEFERRED (empty string) so pure-SELECT transactions 

1051 # acquire only SQLite's SHARED lock, letting WAL-mode 

1052 # concurrent readers proceed while a writer is active. 

1053 # IMMEDIATE was previously set "defensively" and made 

1054 # every transaction (even reads) take a RESERVED lock, 

1055 # which was the single biggest contention source on 

1056 # the login-hang path. Race-prone check-then-insert 

1057 # call sites were made race-free at the application 

1058 # layer in the preceding prerequisite PR. 

1059 "isolation_level": "", 

1060 "check_same_thread": False, 

1061 }, 

1062 ) 

1063 

1064 # Create engine with custom creator function and optimized cache 

1065 engine = create_engine( 

1066 "sqlite://", 

1067 creator=create_open_connection, 

1068 poolclass=self._pool_class, 

1069 echo=False, 

1070 query_cache_size=1000, 

1071 **self._get_pool_kwargs(), 

1072 ) 

1073 else: 

1074 logger.warning( 

1075 f"SQLCipher not available - opening UNENCRYPTED database for user {username}" 

1076 ) 

1077 # Fall back to regular SQLite (no password protection!) 

1078 engine = create_engine( 

1079 f"sqlite:///{db_path}", 

1080 connect_args={"check_same_thread": False, "timeout": 30}, 

1081 poolclass=self._pool_class, 

1082 echo=False, 

1083 query_cache_size=1000, 

1084 **self._get_pool_kwargs(), 

1085 ) 

1086 

1087 # For unencrypted databases, just apply pragmas 

1088 event.listen(engine, "connect", self._apply_pragmas) 

1089 

1090 try: 

1091 # Test connection by running a simple query 

1092 with engine.connect() as conn: 

1093 conn.execute(text("SELECT 1")) 

1094 

1095 # Run database initialization (creates missing tables and runs migrations) 

1096 from .initialize import initialize_database 

1097 

1098 # Create backup before migration to protect against schema change failures 

1099 from .alembic_runner import needs_migration 

1100 

1101 if needs_migration(engine): 

1102 try: 

1103 from .backup.backup_service import BackupService 

1104 

1105 result = BackupService( 

1106 username=username, password=password 

1107 ).create_backup(force=True) 

1108 if result.success: 1108 ↛ 1109line 1108 didn't jump to line 1109 because the condition on line 1108 was never true

1109 logger.info( 

1110 f"Pre-migration backup created: {result.backup_path}" 

1111 ) 

1112 else: 

1113 logger.error( 

1114 f"Pre-migration backup failed: {result.error}" 

1115 ) 

1116 except Exception as e: 

1117 # ``password`` is in scope and was passed into 

1118 # ``BackupService`` above. Drop traceback + redact 

1119 # so the upstream error (which can carry the 

1120 # password through ``set_sqlcipher_key`` frames) is 

1121 # not written to log sinks. 

1122 from ..security.log_sanitizer import redact_secrets 

1123 

1124 safe_msg = redact_secrets(str(e), password) 

1125 logger.warning( 

1126 f"Pre-migration backup failed — proceeding with migration: {safe_msg}" 

1127 ) 

1128 

1129 # Init failures need to be distinguishable from credential 

1130 # failures at the call site: the credentials worked (we 

1131 # decrypted and ran SELECT 1), but the schema couldn't be 

1132 # brought up. Re-raise as a typed error so the login route 

1133 # can skip the lockout counter and surface a server-error 

1134 # message instead of "Invalid username or password". 

1135 try: 

1136 initialize_database(engine) 

1137 except Exception as init_err: 

1138 elapsed_ms = (time.perf_counter() - open_start) * 1000 

1139 # ``password`` is in scope (function parameter). The 

1140 # initializer ran on an engine whose creator captured 

1141 # ``hex_key`` derived from the password — a SQLAlchemy 

1142 # ``OperationalError`` traceback could surface frames 

1143 # holding the plaintext password under ``diagnose=True``. 

1144 from ..security.log_sanitizer import redact_secrets 

1145 

1146 safe_msg = redact_secrets(str(init_err), password) 

1147 logger.warning( 

1148 f"Database migration failed for {username} " 

1149 f"after {elapsed_ms:.0f}ms — refusing login: {safe_msg}" 

1150 ) 

1151 engine.dispose() 

1152 # Sanitised message + ``from None``: ``init_err`` (its 

1153 # str() and its chained traceback frames) can carry the 

1154 # plaintext password, and the caller that catches this 

1155 # typed error logs it with ``logger.exception`` 

1156 # (thread_local_session) — which would render the chain 

1157 # and defeat the redaction applied just above. Break the 

1158 # chain per ADR-0003; the redacted detail is already 

1159 # logged at this site. 

1160 raise DatabaseInitializationError( 

1161 f"Database initialisation failed for {username}: {safe_msg}" 

1162 ) from None 

1163 

1164 # Store connection AFTER migrations complete. This password just 

1165 # proved itself against SQLCipher; publish the engine and its 

1166 # verifier together so the cache is never briefly trusted without 

1167 # one. 

1168 self._cache_connection(username, engine, password) 

1169 

1170 # NOTE: the phase-2 RAG index re-key does NOT run here. It re-enters 

1171 # open_user_database() (via get_user_db_session on a session-cache 

1172 # miss), and this method runs while holding the non-reentrant 

1173 # per-user init_lock — so a concurrent close_user_database() evicting 

1174 # the just-cached connection would make that re-entrant open miss the 

1175 # fast-path and self-deadlock on the lock this thread already holds. 

1176 # open_user_database() runs it AFTER releasing the lock instead. 

1177 

1178 elapsed_ms = (time.perf_counter() - open_start) * 1000 

1179 if elapsed_ms > 100: 

1180 logger.info( 

1181 f"Opened encrypted database for user {username} " 

1182 f"(cold-open wall clock: {elapsed_ms:.0f}ms)" 

1183 ) 

1184 else: 

1185 logger.info( 

1186 f"Opened encrypted database for user {username} " 

1187 f"({elapsed_ms:.0f}ms)" 

1188 ) 

1189 return engine 

1190 

1191 except DatabaseInitializationError: 

1192 # Already logged + engine disposed at the raise site. Re-raise 

1193 # past the catch-all below so callers see the typed error. 

1194 raise 

1195 except Exception as e: 

1196 elapsed_ms = (time.perf_counter() - open_start) * 1000 

1197 # Catches connection errors during ``engine.connect()`` — 

1198 # the connection creator passes the password into 

1199 # ``set_sqlcipher_key`` so a failure there can carry the 

1200 # password in the traceback's frame locals. Drop the 

1201 # traceback and redact str(e). 

1202 from ..security.log_sanitizer import redact_secrets 

1203 

1204 safe_msg = redact_secrets(str(e), password) 

1205 logger.warning( 

1206 f"Failed to open database for user {username} " 

1207 f"after {elapsed_ms:.0f}ms: {safe_msg}" 

1208 ) 

1209 engine.dispose() 

1210 return None 

1211 

1212 def get_session(self, username: str) -> Optional[Session]: 

1213 """Bind a Session to ``username``'s ALREADY-open engine. Post-auth accessor. 

1214 

1215 SECURITY INVARIANT -- this intentionally takes NO password and performs 

1216 NO credential check, unlike ``open_user_database`` / 

1217 ``create_thread_safe_session_for_metrics`` (which gate their cache hit 

1218 through ``_cached_engine_trusted``). That is correct ONLY because this 

1219 is a post-authentication accessor, never an authentication entry point: 

1220 

1221 * It NEVER opens a database -- a cache miss returns ``None``. The 

1222 engine it hands back was published to ``connections`` solely by a 

1223 credential-verified create/open, so no unverified password can ever 

1224 reach the cache via this method. 

1225 * Every caller supplies a server-trusted ``username`` -- the signed 

1226 Flask-session identity (``session["username"]`` / ``g.current_user``) 

1227 or an internal server value (settings-snapshot thread, log drain) 

1228 -- and most also gate on ``is_user_connected`` (the never-open-on- 

1229 miss property above is the load-bearing guarantee, not that gate). 

1230 The username is never an attacker-supplied login attempt paired with 

1231 an unverified password, which is the shape that made the cached- 

1232 connection bypass exploitable (see ``open_user_database``). 

1233 

1234 DO NOT add a lazy open-on-miss here, and DO NOT call this from an 

1235 auth-decision path: either turns this accessor into an unauthenticated 

1236 bypass. Credential-taking entry points must go through 

1237 ``open_user_database`` instead. 

1238 """ 

1239 with self._connections_lock: 

1240 if username not in self.connections: 

1241 # Use debug level for this common scenario to reduce log noise 

1242 logger.debug(f"No open database for user {username}") 

1243 return None 

1244 engine = self.connections[username] 

1245 # Create session inside lock to prevent race with close_user_database() 

1246 SessionLocal = sessionmaker(bind=engine) 

1247 return SessionLocal() 

1248 

1249 def get_connected_usernames(self) -> set: 

1250 """Return a snapshot of usernames with open connections.""" 

1251 with self._connections_lock: 

1252 return set(self.connections.keys()) 

1253 

1254 def _checkpoint_wal(self, engine, context: str = ""): 

1255 """Checkpoint WAL before disposing engine to flush pending writes.""" 

1256 try: 

1257 with engine.connect() as conn: 

1258 # TRUNCATE returns (busy, log_pages, checkpointed_pages). 

1259 # busy=1 means a reader/writer held a WAL lock and the WAL 

1260 # was NOT truncated — surface that so we can tell from logs 

1261 # whether the helper actually shrank the file. 

1262 result = conn.execute( 

1263 text("PRAGMA wal_checkpoint(TRUNCATE)") 

1264 ).fetchone() 

1265 if result and result[0] == 1: 1265 ↛ 1266line 1265 didn't jump to line 1266 because the condition on line 1265 was never true

1266 logger.debug( 

1267 f"WAL checkpoint busy {context} — WAL not truncated" 

1268 ) 

1269 except Exception: 

1270 logger.debug(f"WAL checkpoint failed {context}", exc_info=True) 

1271 

1272 def close_user_database(self, username: str): 

1273 """Close a user's database connection.""" 

1274 with self._connections_lock: 

1275 if username in self.connections: 

1276 try: 

1277 self._checkpoint_wal( 

1278 self.connections[username], f"for {username}" 

1279 ) 

1280 self.connections[username].dispose() 

1281 except Exception: 

1282 logger.warning( 

1283 f"Failed to dispose engine for {username}", 

1284 ) 

1285 del self.connections[username] 

1286 # Drop the verifier with the connection it described, so a 

1287 # stale entry can never outlive the engine it was recorded for. 

1288 self._password_verifiers.pop(username, None) 

1289 # Deliberately do NOT pop _init_locks[username] here. A 

1290 # concurrent open_user_database may already hold a reference to 

1291 # this user's lock (fetched via _get_init_lock) and be about to 

1292 # enter its cold-open; dropping the entry would let a later open 

1293 # create a *second* lock for the same user, so two cold-opens 

1294 # could migrate one DB file at once -- the very race this lock 

1295 # exists to prevent. The dict is bounded by the number of 

1296 # distinct usernames (one small Lock each), so retaining it is 

1297 # cheap; close_all_databases clears it wholesale at shutdown. 

1298 logger.info(f"Closed database for user {username}") 

1299 

1300 def close_all_databases(self): 

1301 """Close all open user database connections and release file locks.""" 

1302 with self._connections_lock: 

1303 for username, engine in list(self.connections.items()): 

1304 try: 

1305 self._checkpoint_wal(engine, f"for {username}") 

1306 engine.dispose() 

1307 except Exception: 

1308 logger.debug(f"Error disposing engine for {username}") 

1309 self.connections.clear() 

1310 self._password_verifiers.clear() 

1311 self._init_locks.clear() 

1312 

1313 def check_database_integrity(self, username: str) -> bool: 

1314 """Check integrity of a user's encrypted database.""" 

1315 with self._connections_lock: 

1316 if username not in self.connections: 

1317 return False 

1318 engine = self.connections[username] 

1319 

1320 try: 

1321 with engine.connect() as conn: 

1322 # Quick integrity check 

1323 result = conn.execute(text("PRAGMA quick_check")) 

1324 if result.fetchone()[0] != "ok": 

1325 return False 

1326 

1327 # SQLCipher integrity check 

1328 result = conn.execute(text("PRAGMA cipher_integrity_check")) 

1329 # If this returns any rows, there are HMAC failures 

1330 failures = list(result) 

1331 if failures: 

1332 logger.error( 

1333 f"Integrity check failed for {username}: {len(failures)} HMAC failures" 

1334 ) 

1335 return False 

1336 

1337 return True 

1338 

1339 except Exception as e: 

1340 # No ``password`` parameter on this method and the engine 

1341 # was created via a hex-key closure (the plaintext password 

1342 # is not retained on the engine itself), so the traceback 

1343 # frames in this method do not hold a credential. Still 

1344 # drop the traceback to stay uniform with the rest of the 

1345 # #4182 sweep and to guard against a caller frame that 

1346 # happens to hold one (e.g., a route handler that 

1347 # retrieved the password from the session store). 

1348 from ..security.log_sanitizer import redact_secrets 

1349 

1350 safe_msg = redact_secrets(str(e), None) 

1351 logger.warning( 

1352 f"Integrity check error for user: {username}: {safe_msg}" 

1353 ) 

1354 return False 

1355 

1356 def change_password( 

1357 self, username: str, old_password: str, new_password: str 

1358 ) -> bool: 

1359 """Change the encryption password for a user's database. 

1360 

1361 This rekeys the SQLCipher database — no separate auth-DB 

1362 password-hash update is needed because passwords are never 

1363 stored. Login verification is done by attempting decryption. 

1364 """ 

1365 if not self.has_encryption: 

1366 logger.warning( 

1367 "Cannot change password - SQLCipher not available (databases are unencrypted)" 

1368 ) 

1369 return False 

1370 

1371 db_path = self._get_user_db_path(username) 

1372 

1373 if not db_path.exists(): 

1374 return False 

1375 

1376 try: 

1377 # Close existing connection if any 

1378 self.close_user_database(username) 

1379 

1380 # Open with old password 

1381 engine = self.open_user_database(username, old_password) 

1382 if not engine: 

1383 return False 

1384 

1385 # Rekey the database (only works with SQLCipher) 

1386 with engine.connect() as conn: 

1387 # Use centralized rekey function 

1388 set_sqlcipher_rekey(conn, new_password, db_path=db_path) 

1389 

1390 # Evict the cached engine and its verifier NOW, while still inside 

1391 # the try. open_user_database() above cold-opened with old_password 

1392 # and cached that engine together with a verifier for old_password. 

1393 # The rekey just invalidated that engine's key (its creator closure 

1394 # still derives the OLD hex key, so any freshly pooled connection is 

1395 # mis-keyed) yet the cached verifier still matches old_password -- so 

1396 # until this eviction a concurrent open_user_database(old_password) 

1397 # would pass the verifier check and be handed the stale-key engine. 

1398 # change_password holds no lock across the rekey, so evict here 

1399 # rather than waiting for the finally. close_user_database is 

1400 # idempotent (no-ops when the user is not cached), so the 

1401 # finally-close below stays a backstop and is a no-op once this ran. 

1402 self.close_user_database(username) 

1403 

1404 logger.info(f"Password changed for user {username}") 

1405 return True 

1406 

1407 except Exception as e: 

1408 # Both ``old_password`` and ``new_password`` are in lexical 

1409 # scope. ``open_user_database`` / ``set_sqlcipher_rekey`` 

1410 # carry these into nested frames — a traceback rendered 

1411 # with ``diagnose=True`` would leak them. Redact both and 

1412 # drop the traceback chain. 

1413 from ..security.log_sanitizer import redact_secrets 

1414 

1415 safe_msg = redact_secrets(str(e), old_password, new_password) 

1416 logger.warning( 

1417 f"Failed to change password for user: {username}: {safe_msg}" 

1418 ) 

1419 return False 

1420 finally: 

1421 # Close the connection 

1422 self.close_user_database(username) 

1423 

1424 def user_exists(self, username: str) -> bool: 

1425 """Check if a user exists in the auth database.""" 

1426 from .auth_db import auth_db_session 

1427 from .models.auth import User 

1428 

1429 with auth_db_session() as session: 

1430 user = session.query(User).filter_by(username=username).first() 

1431 return user is not None 

1432 

1433 def get_memory_usage(self) -> Dict[str, Any]: 

1434 """Get memory usage statistics.""" 

1435 with self._connections_lock: 

1436 num_connections = len(self.connections) 

1437 return { 

1438 "active_connections": num_connections, 

1439 "active_sessions": 0, # Sessions are created on-demand, not tracked 

1440 "estimated_memory_mb": num_connections 

1441 * 3.5, # ~3.5MB per connection 

1442 } 

1443 

1444 def create_thread_safe_session_for_metrics( 

1445 self, username: str, password: str 

1446 ): 

1447 """ 

1448 Create a new database session safe for use in background threads. 

1449 

1450 Previously this method created a dedicated NullPool engine per 

1451 (username, thread_id) pair, which leaked file descriptors under 

1452 load (SQLCipher + WAL holds 3 FDs per active connection and 

1453 orphaned engines accumulated when @thread_cleanup did not fire). 

1454 

1455 It now routes through the shared per-user QueuePool engine at 

1456 ``self.connections[username]``. That engine is already created 

1457 with ``check_same_thread=False`` (so background threads are 

1458 safe), is bounded by ``pool_size + max_overflow``, and is 

1459 subject to the periodic ``dispose()`` workaround in 

1460 ``connection_cleanup.py`` that mitigates the SQLCipher+WAL 

1461 out-of-order-close FD leak. 

1462 

1463 Args: 

1464 username: The username 

1465 password: The user's password (encryption key). Verified against 

1466 the cached connection's recorded verifier on a cache hit, and 

1467 used to open the user database on a cache miss or mismatch. 

1468 

1469 Returns: 

1470 A SQLAlchemy Session bound to the per-user QueuePool engine. 

1471 """ 

1472 db_path = self._get_user_db_path(username) 

1473 

1474 if not db_path.exists(): 

1475 raise ValueError(f"No database found for user {username}") 

1476 

1477 # Trust the cached engine only when the presented password matches the 

1478 # one that opened it -- the same fail-closed rule as 

1479 # open_user_database. Returning a session on a bare cache hit here 

1480 # would be a second instance of exactly the cached-connection password 

1481 # bypass this change closes; falling through to open_user_database on a 

1482 # mismatch re-validates the key against SQLCipher. 

1483 with self._connections_lock: 

1484 engine = self.connections.get(username) 

1485 if engine is not None and not self._cached_engine_trusted( 

1486 username, password 

1487 ): 

1488 engine = None 

1489 

1490 if engine is None: 

1491 # Cache miss (or a rejected cache hit) — open the user database. 

1492 # This is idempotent: after the first call it just returns the 

1493 # cached engine. 

1494 engine = self.open_user_database(username, password) 

1495 if engine is None: 1495 ↛ 1499line 1495 didn't jump to line 1499 because the condition on line 1495 was always true

1496 raise ValueError(f"Failed to open database for user {username}") 

1497 

1498 # Use SQLAlchemy's default expire_on_commit=True. 

1499 Session = sessionmaker(bind=engine) 

1500 return Session() 

1501 

1502 

1503# Global instance 

1504db_manager = DatabaseManager()