Coverage for src/local_deep_research/database/sqlcipher_utils.py: 99%

177 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2SQLCipher utility functions for consistent database operations. 

3 

4This module centralizes all SQLCipher-specific operations to ensure 

5consistent password handling and PRAGMA settings across the codebase. 

6""" 

7 

8import os 

9import secrets 

10import threading 

11import time 

12from hashlib import pbkdf2_hmac 

13from pathlib import Path 

14from typing import Any, Dict, Optional, Union 

15 

16from loguru import logger 

17 

18from ..settings.env_registry import get_env_setting 

19from ..utilities.type_utils import to_bool 

20 

21# Lock to protect cipher_default_* global state during creation 

22_cipher_default_lock = threading.Lock() 

23 

24# Salt file constants 

25SALT_FILE_SUFFIX = ".salt" 

26SALT_SIZE = 32 # 256 bits 

27 

28 

29def get_salt_file_path(db_path: Union[str, Path]) -> Path: 

30 """ 

31 Get the path to the salt file for a database. 

32 

33 Args: 

34 db_path: Path to the database file 

35 

36 Returns: 

37 Path to the corresponding .salt file 

38 """ 

39 return Path(db_path).with_suffix(Path(db_path).suffix + SALT_FILE_SUFFIX) 

40 

41 

42def get_salt_for_database(db_path: Union[str, Path]) -> bytes: 

43 """ 

44 Get the salt for a database file. 

45 

46 For new databases (v2+): reads from the .salt file alongside the database. 

47 For legacy databases (v1): returns LEGACY_PBKDF2_SALT for backwards compatibility. 

48 

49 Args: 

50 db_path: Path to the database file 

51 

52 Returns: 

53 The salt bytes to use for key derivation 

54 """ 

55 salt_file = get_salt_file_path(db_path) 

56 

57 try: 

58 salt = salt_file.read_bytes() 

59 except FileNotFoundError: 

60 # v1: Legacy salt for backwards compatibility 

61 logger.warning( 

62 f"Database '{Path(db_path).name}' uses the legacy shared salt " 

63 f"(deprecated). Consider creating a new database to benefit from " 

64 f"per-database salt security. See issue #1439 for migration details." 

65 ) 

66 return LEGACY_PBKDF2_SALT 

67 

68 # v2: Per-database random salt 

69 if len(salt) != SALT_SIZE: 

70 raise ValueError( 

71 f"Salt file {salt_file} has unexpected size ({len(salt)} bytes), " 

72 f"expected {SALT_SIZE}. The salt file may be corrupted." 

73 ) 

74 return salt 

75 

76 

77def create_database_salt(db_path: Union[str, Path]) -> bytes: 

78 """ 

79 Create and store a new random salt for a database. 

80 

81 This should be called when creating a new database. 

82 The salt is stored in a .salt file alongside the database. 

83 

84 WARNING: If this salt file is deleted, the associated database becomes 

85 permanently unreadable. Always back up .salt files alongside their .db files. 

86 

87 Args: 

88 db_path: Path to the database file 

89 

90 Returns: 

91 The newly generated salt bytes 

92 

93 Raises: 

94 FileExistsError: If a salt file already exists for this database 

95 """ 

96 salt_file = get_salt_file_path(db_path) 

97 

98 if salt_file.exists(): 

99 raise FileExistsError( 

100 f"Salt file already exists: {salt_file}. " 

101 f"Refusing to overwrite to prevent data loss." 

102 ) 

103 

104 salt = secrets.token_bytes(SALT_SIZE) 

105 

106 # Ensure parent directory exists 

107 salt_file.parent.mkdir(parents=True, exist_ok=True) 

108 

109 # Write salt file with owner-only permissions (0o600) 

110 fd = os.open(str(salt_file), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) 

111 try: 

112 os.write(fd, salt) 

113 finally: 

114 os.close(fd) 

115 

116 logger.info(f"Created new database salt file: {salt_file}") 

117 return salt 

118 

119 

120def has_per_database_salt(db_path: Union[str, Path]) -> bool: 

121 """ 

122 Check if a database has a per-database salt file (v2). 

123 

124 Args: 

125 db_path: Path to the database file 

126 

127 Returns: 

128 True if the database has a .salt file, False otherwise 

129 """ 

130 return get_salt_file_path(db_path).exists() 

131 

132 

133def _get_key_from_password( 

134 password: str, salt: bytes, kdf_iterations: int 

135) -> bytes: 

136 """ 

137 Generates an encryption key from the user's password and salt. 

138 

139 Args: 

140 password: The password. 

141 salt: The salt bytes to use for key derivation. 

142 kdf_iterations: Number of PBKDF2 iterations. 

143 

144 Returns: 

145 The generated key. 

146 """ 

147 logger.debug( 

148 f"Generating DB encryption key with {kdf_iterations} iterations..." 

149 ) 

150 

151 start = time.perf_counter() 

152 key = pbkdf2_hmac( 

153 "sha512", 

154 password.encode(), 

155 salt, 

156 kdf_iterations, 

157 ) 

158 elapsed_ms = (time.perf_counter() - start) * 1000 

159 

160 if elapsed_ms > 500: 

161 logger.info( 

162 f"PBKDF2 key derivation took {elapsed_ms:.0f}ms " 

163 f"({kdf_iterations} iterations)" 

164 ) 

165 else: 

166 logger.debug( 

167 f"PBKDF2 key derivation took {elapsed_ms:.0f}ms " 

168 f"({kdf_iterations} iterations)" 

169 ) 

170 return key 

171 

172 

173def get_key_from_password( 

174 password: str, db_path: Optional[Union[str, Path]] = None 

175) -> bytes: 

176 """ 

177 Wrapper that gets salt and settings, then calls the key derivation. 

178 

179 Args: 

180 password: The password. 

181 db_path: Optional path to the database file. If provided, uses 

182 per-database salt. If not provided, uses legacy salt. 

183 

184 Returns: 

185 The derived encryption key bytes. 

186 """ 

187 if db_path is not None: 

188 salt = get_salt_for_database(db_path) 

189 else: 

190 salt = LEGACY_PBKDF2_SALT 

191 

192 settings = get_sqlcipher_settings() 

193 return _get_key_from_password(password, salt, settings["kdf_iterations"]) 

194 

195 

196def set_sqlcipher_key( 

197 cursor_or_conn: Any, 

198 password: str, 

199 db_path: Optional[Union[str, Path]] = None, 

200) -> None: 

201 """ 

202 Set the SQLCipher encryption key using hexadecimal encoding. 

203 

204 This avoids SQL injection and escaping issues with special characters. 

205 

206 Args: 

207 cursor_or_conn: SQLCipher cursor or connection object 

208 password: The password to use for encryption 

209 db_path: Optional path to the database file. If provided, uses 

210 per-database salt. If not provided, uses legacy salt. 

211 """ 

212 key = get_key_from_password(password, db_path=db_path) # gitleaks:allow 

213 cursor_or_conn.execute(f"PRAGMA key = \"x'{key.hex()}'\"") 

214 

215 

216def set_sqlcipher_key_from_hex(cursor_or_conn: Any, hex_key: str) -> None: 

217 """ 

218 Set the SQLCipher encryption key from a pre-derived hex key string. 

219 

220 Used by connection closures to avoid capturing plaintext passwords. 

221 

222 Args: 

223 cursor_or_conn: SQLCipher cursor or connection object 

224 hex_key: Pre-derived hex key string (from get_key_from_password().hex()) 

225 """ 

226 cursor_or_conn.execute(f"PRAGMA key = \"x'{hex_key}'\"") # gitleaks:allow 

227 

228 

229def set_sqlcipher_rekey( 

230 cursor_or_conn: Any, 

231 new_password: str, 

232 db_path: Optional[Union[str, Path]] = None, 

233) -> None: 

234 """ 

235 Change the SQLCipher encryption key using hexadecimal encoding. 

236 

237 Uses the same PBKDF2 key derivation as set_sqlcipher_key() to ensure 

238 consistency when re-opening databases after password change. 

239 

240 Args: 

241 cursor_or_conn: SQLCipher cursor or connection object 

242 new_password: The new password to use for encryption 

243 db_path: Optional path to the database file. If provided, uses 

244 per-database salt. If not provided, uses legacy salt. 

245 """ 

246 # Use the same key derivation as set_sqlcipher_key for consistency 

247 key = get_key_from_password(new_password, db_path=db_path) # gitleaks:allow 

248 

249 # The hex encoding already prevents injection since it only contains [0-9a-f] 

250 safe_sql = f"PRAGMA rekey = \"x'{key.hex()}'\"" 

251 

252 try: 

253 # Try SQLAlchemy connection (needs text() wrapper) 

254 from sqlalchemy import text 

255 

256 cursor_or_conn.execute(text(safe_sql)) 

257 except TypeError: 

258 # Raw SQLCipher connection - use string directly 

259 cursor_or_conn.execute(safe_sql) 

260 

261 

262# Default SQLCipher configuration (can be overridden by settings) 

263DEFAULT_KDF_ITERATIONS = 256000 

264DEFAULT_PAGE_SIZE = 16384 # 16KB pages for maximum performance with caching 

265DEFAULT_HMAC_ALGORITHM = "HMAC_SHA512" 

266DEFAULT_KDF_ALGORITHM = "PBKDF2_HMAC_SHA512" 

267 

268# Valid page sizes (powers of 2 within the SQLite range). 

269# IntegerSetting validates min/max but not that the value is a power of 2, 

270# so we check against this set as an additional safeguard. 

271VALID_PAGE_SIZES = frozenset({512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}) 

272MAX_KDF_ITERATIONS = 1_000_000 

273 

274# Production minimum KDF iterations. Relaxed automatically in test/CI environments. 

275MIN_KDF_ITERATIONS_PRODUCTION = 100_000 

276MIN_KDF_ITERATIONS_TESTING = 1 

277 

278 

279def _get_min_kdf_iterations() -> int: 

280 """Get minimum KDF iterations, relaxed for test/CI environments. 

281 

282 Only relaxes when PYTEST_CURRENT_TEST (set automatically by pytest) or 

283 LDR_TEST_MODE (project-specific) is set. Generic env vars like CI or 

284 TESTING are NOT checked to avoid accidentally weakening production 

285 encryption in Docker/CD pipelines that set CI=true. 

286 

287 PYTEST_CURRENT_TEST is presence-based: pytest sets it to a descriptive 

288 non-boolean string (e.g. "tests/foo.py::test_bar (call)"), so its mere 

289 presence signals a test run. LDR_TEST_MODE is parsed as a proper boolean 

290 so an explicit LDR_TEST_MODE=0 / =false does NOT relax the floor — a bare 

291 truthiness check would treat any non-empty string (including "false") as 

292 enabled and silently weaken encryption for someone trying to disable it. 

293 """ 

294 is_testing = bool(os.environ.get("PYTEST_CURRENT_TEST")) or to_bool( 

295 os.environ.get("LDR_TEST_MODE") 

296 ) 

297 return ( 

298 MIN_KDF_ITERATIONS_TESTING 

299 if is_testing 

300 else MIN_KDF_ITERATIONS_PRODUCTION 

301 ) 

302 

303 

304# Legacy salt for backwards compatibility with databases created before v2. 

305# New databases use per-database random salts stored in .salt files. 

306# WARNING: Do NOT change this value - it would break all existing legacy databases! 

307LEGACY_PBKDF2_SALT = b"no salt" 

308 

309# Alias for backwards compatibility with code that references the old name 

310PBKDF2_PLACEHOLDER_SALT = LEGACY_PBKDF2_SALT 

311 

312 

313def get_sqlcipher_settings() -> dict: 

314 """ 

315 Get SQLCipher settings from environment variables or use defaults. 

316 

317 These settings cannot be changed after database creation, so they 

318 must be configured via environment variables only. 

319 

320 Settings are read via the env settings registry, which handles 

321 canonical env var names (LDR_DB_CONFIG_*) with automatic fallback 

322 to deprecated names (LDR_DB_*) and deprecation warnings. 

323 

324 Returns: 

325 Dictionary with SQLCipher configuration 

326 """ 

327 # HMAC algorithm - registry validates against allowed values 

328 hmac_algorithm = get_env_setting( 

329 "db_config.hmac_algorithm", DEFAULT_HMAC_ALGORITHM 

330 ) 

331 

332 # KDF algorithm - registry validates against allowed values 

333 kdf_algorithm = get_env_setting( 

334 "db_config.kdf_algorithm", DEFAULT_KDF_ALGORITHM 

335 ) 

336 

337 # Page size - registry validates range, we also check power-of-2 

338 page_size = get_env_setting("db_config.page_size", DEFAULT_PAGE_SIZE) 

339 if page_size not in VALID_PAGE_SIZES: 

340 logger.warning( 

341 f"Invalid page_size value '{page_size}', using default " 

342 f"'{DEFAULT_PAGE_SIZE}'. Valid values: {sorted(VALID_PAGE_SIZES)}" 

343 ) 

344 page_size = DEFAULT_PAGE_SIZE 

345 

346 # KDF iterations - registry validates basic range, then apply CI-aware minimum 

347 kdf_iterations = get_env_setting( 

348 "db_config.kdf_iterations", DEFAULT_KDF_ITERATIONS 

349 ) 

350 min_kdf = _get_min_kdf_iterations() 

351 if not (min_kdf <= kdf_iterations <= MAX_KDF_ITERATIONS): 

352 logger.warning( 

353 f"KDF iterations value '{kdf_iterations}' outside safe range " 

354 f"[{min_kdf}, {MAX_KDF_ITERATIONS}], using default " 

355 f"'{DEFAULT_KDF_ITERATIONS}'." 

356 ) 

357 kdf_iterations = DEFAULT_KDF_ITERATIONS 

358 

359 return { 

360 "kdf_iterations": kdf_iterations, 

361 "page_size": page_size, 

362 "hmac_algorithm": hmac_algorithm, 

363 "kdf_algorithm": kdf_algorithm, 

364 } 

365 

366 

367def warn_if_weak_kdf_with_existing_databases( 

368 data_dir: Union[str, Path], 

369) -> bool: 

370 """Warn loudly when the effective SQLCipher KDF is below the production 

371 floor *and* user databases already exist in ``data_dir``. 

372 

373 The KDF iteration count is derived from the environment at open time 

374 (see :func:`get_sqlcipher_settings`) and is NOT stored with the 

375 database. So if the floor is relaxed (test mode) on a deployment that 

376 already holds real data, newly created databases get a weak at-rest work 

377 factor, and — separately — any existing database that was created at a 

378 *higher* KDF can no longer be opened: its on-disk key is unchanged, but 

379 the server now derives a different, weaker key, so decryption fails and 

380 login returns a generic "Invalid username or password" 401 that is 

381 indistinguishable from a wrong password (the KDF-mismatch symptom class 

382 behind PR #4775). 

383 

384 Scope: this only catches the "server is now weak" direction, and only a 

385 *sub-floor* effective KDF. It cannot detect the inverse (a production-KDF 

386 server opening databases created weak) or general in-range KDF drift, 

387 because the creation-time KDF is not persisted to compare against — only 

388 a sub-floor effective KDF is observable from the environment alone. 

389 

390 Intentionally silent on a fresh deployment (no pre-existing databases → 

391 nothing to mismatch) and when the effective KDF is at/above the floor 

392 (e.g. the 256000 default) — that is harmless. 

393 

394 Returns ``True`` if a warning was emitted (used by tests). 

395 """ 

396 effective_kdf = get_sqlcipher_settings()["kdf_iterations"] 

397 if effective_kdf >= MIN_KDF_ITERATIONS_PRODUCTION: 

398 return False 

399 

400 # Only count + truthiness are used, so no need to sort the glob result. 

401 existing = list(Path(data_dir).glob("ldr_user_*.db")) 

402 if not existing: 

403 return False 

404 

405 logger.warning( 

406 "SQLCipher KDF is configured to {} iterations — below the production " 

407 "floor of {} (reached only in test mode, e.g. LDR_TEST_MODE) — while " 

408 "{} user database(s) already exist in {}. New databases created now " 

409 "get this weak at-rest work factor. Separately, any of those existing " 

410 "databases that was created at a higher KDF can no longer be opened: " 

411 "its on-disk key is unchanged, but the server now derives a different, " 

412 "weaker key, so decryption fails and login returns a generic 'Invalid " 

413 "username or password' 401 for every affected user (the KDF-mismatch " 

414 "symptom class behind PR #4775). If this is a production deployment, " 

415 "unset LDR_TEST_MODE (and any low LDR_DB_CONFIG_KDF_ITERATIONS).", 

416 effective_kdf, 

417 MIN_KDF_ITERATIONS_PRODUCTION, 

418 len(existing), 

419 data_dir, 

420 ) 

421 return True 

422 

423 

424def apply_cipher_defaults_before_key( 

425 cursor_or_conn: Any, 

426) -> None: 

427 """ 

428 Apply cipher_default_* pragmas BEFORE PRAGMA key for new database creation. 

429 

430 Per SQLCipher 4.x docs, cipher_default_* pragmas set the defaults that 

431 apply when a key is set on a NEW database. These MUST be called before 

432 PRAGMA key. 

433 

434 For EXISTING databases, cipher_page_size/cipher_hmac_algorithm/ 

435 cipher_kdf_algorithm are set AFTER the key via apply_sqlcipher_pragmas(). 

436 

437 Args: 

438 cursor_or_conn: SQLCipher cursor or connection object 

439 """ 

440 settings = get_sqlcipher_settings() 

441 

442 logger.debug( 

443 f"Applying cipher_default_* pragmas for new DB: settings={settings}" 

444 ) 

445 

446 cursor_or_conn.execute( 

447 f"PRAGMA cipher_default_page_size = {settings['page_size']}" 

448 ) 

449 cursor_or_conn.execute( 

450 f"PRAGMA cipher_default_hmac_algorithm = {settings['hmac_algorithm']}" 

451 ) 

452 cursor_or_conn.execute( 

453 f"PRAGMA cipher_default_kdf_algorithm = {settings['kdf_algorithm']}" 

454 ) 

455 

456 

457def apply_sqlcipher_pragmas( 

458 cursor_or_conn: Any, 

459 creation_mode: bool = False, 

460) -> None: 

461 """ 

462 Apply SQLCipher PRAGMA settings that are set AFTER the key. 

463 

464 For SQLCipher 4.x: 

465 - New databases: cipher_default_* are set before key via 

466 apply_cipher_defaults_before_key(). This function only sets kdf_iter. 

467 - Existing databases: cipher_page_size, cipher_hmac_algorithm, 

468 cipher_kdf_algorithm MUST be set AFTER the key (not before). 

469 This function handles that. 

470 

471 Args: 

472 cursor_or_conn: SQLCipher cursor or connection object 

473 creation_mode: If True, only sets kdf_iter (defaults already applied). 

474 If False, sets cipher_* settings + kdf_iter for existing DB. 

475 """ 

476 settings = get_sqlcipher_settings() 

477 

478 if not creation_mode: 

479 # For existing databases: cipher_* pragmas go AFTER the key 

480 cursor_or_conn.execute( 

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

482 ) 

483 cursor_or_conn.execute( 

484 f"PRAGMA cipher_hmac_algorithm = {settings['hmac_algorithm']}" 

485 ) 

486 cursor_or_conn.execute( 

487 f"PRAGMA cipher_kdf_algorithm = {settings['kdf_algorithm']}" 

488 ) 

489 

490 # kdf_iter can be set after the key (applies to future derivation) 

491 cursor_or_conn.execute(f"PRAGMA kdf_iter = {settings['kdf_iterations']}") 

492 

493 # cipher_memory_security is a runtime PRAGMA. ON zeroes SQLCipher buffers 

494 # and calls mlock() to prevent swap; OFF skips this. Defaulting to OFF 

495 # because the password already sits unprotected in Flask session, db_manager, 

496 # and thread-local storage — mlock on SQLCipher's buffers alone doesn't help. 

497 # Users can opt in with LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON + IPC_LOCK. 

498 # Applied on every connection (not just creation) so env var overrides work. 

499 mem_security = get_env_setting("db_config.cipher_memory_security", "OFF") 

500 cursor_or_conn.execute(f"PRAGMA cipher_memory_security = {mem_security}") 

501 

502 

503def apply_performance_pragmas(cursor_or_conn: Any) -> None: 

504 """ 

505 Apply performance-related PRAGMA settings from environment variables. 

506 

507 Settings are read via the env settings registry, which handles 

508 canonical env var names (LDR_DB_CONFIG_*) with automatic fallback 

509 to deprecated names (LDR_DB_*) and deprecation warnings. 

510 

511 Args: 

512 cursor_or_conn: SQLCipher cursor or connection object 

513 """ 

514 # Default values that are always applied 

515 cursor_or_conn.execute("PRAGMA temp_store = MEMORY") 

516 cursor_or_conn.execute("PRAGMA busy_timeout = 10000") # 10 second timeout 

517 

518 # SQLite defaults foreign_keys to OFF — without this every 

519 # ondelete="CASCADE"/"SET NULL" declared on an FK is inert, 

520 # including for raw-SQL DELETEs issued by Query.delete(). 

521 cursor_or_conn.execute("PRAGMA foreign_keys = ON") 

522 

523 # Cache size - registry validates min/max range 

524 cache_mb = get_env_setting("db_config.cache_size_mb", 64) 

525 cache_pages = -(cache_mb * 1024) # Negative for KB cache size 

526 cursor_or_conn.execute(f"PRAGMA cache_size = {cache_pages}") 

527 

528 # Journal mode - registry validates against allowed values 

529 journal_mode = get_env_setting("db_config.journal_mode", "WAL") 

530 cursor_or_conn.execute(f"PRAGMA journal_mode = {journal_mode}") 

531 

532 # Synchronous mode - registry validates against allowed values 

533 sync_mode = get_env_setting("db_config.synchronous", "NORMAL") 

534 cursor_or_conn.execute(f"PRAGMA synchronous = {sync_mode}") 

535 

536 # WAL autocheckpoint frame threshold. SQLite's default of 1000 frames 

537 # paired with our 16 KB page size means the WAL can grow to ~16 MB 

538 # before SQLite triggers a PASSIVE checkpoint at commit. Lowering the 

539 # threshold bounds the WAL high-water-mark on disk for users who never 

540 # log out (the explicit TRUNCATE checkpoint runs on dispose, not here). 

541 wal_autocheckpoint = get_env_setting("db_config.wal_autocheckpoint", 250) 

542 cursor_or_conn.execute(f"PRAGMA wal_autocheckpoint = {wal_autocheckpoint}") 

543 

544 

545def verify_sqlcipher_connection(cursor_or_conn: Any) -> bool: 

546 """ 

547 Verify that the SQLCipher connection is working correctly. 

548 

549 Args: 

550 cursor_or_conn: SQLCipher cursor or connection object 

551 

552 Returns: 

553 True if the connection is valid, False otherwise 

554 """ 

555 try: 

556 cursor_or_conn.execute("SELECT 1") 

557 result = ( 

558 cursor_or_conn.fetchone() 

559 if hasattr(cursor_or_conn, "fetchone") 

560 else cursor_or_conn.execute("SELECT 1").fetchone() 

561 ) 

562 is_valid = result == (1,) 

563 if not is_valid: 

564 logger.error( 

565 f"SQLCipher verification failed: result {result} != (1,)" 

566 ) 

567 return is_valid 

568 except Exception: 

569 logger.exception("SQLCipher verification failed") 

570 return False 

571 

572 

573def get_sqlcipher_version(cursor_or_conn: Any) -> Optional[str]: 

574 """ 

575 Get the SQLCipher version string. 

576 

577 Args: 

578 cursor_or_conn: SQLCipher cursor or connection object 

579 

580 Returns: 

581 Version string (e.g. "4.6.1 community") or None if unavailable 

582 """ 

583 try: 

584 cursor_or_conn.execute("PRAGMA cipher_version") 

585 result = cursor_or_conn.fetchone() 

586 return result[0] if result else None 

587 except Exception: 

588 logger.debug("Could not query SQLCipher version", exc_info=True) 

589 return None 

590 

591 

592def create_sqlcipher_connection( 

593 db_path: Union[str, Path], 

594 password: Optional[str] = None, 

595 creation_mode: bool = False, 

596 connect_kwargs: Optional[Dict[str, Any]] = None, 

597 hex_key: Optional[str] = None, 

598) -> Any: 

599 """ 

600 Create a properly configured SQLCipher connection. 

601 

602 Implements the full PRAGMA sequence with proper error cleanup: 

603 - Creation: cipher_default_* -> key -> kdf_iter -> performance -> verify 

604 - Existing: key -> cipher_* + kdf_iter -> performance -> verify 

605 

606 Uses per-database salt if a .salt file exists alongside the database, 

607 otherwise falls back to legacy salt for backwards compatibility. 

608 

609 Args: 

610 db_path: Path to the database file 

611 password: The password for encryption (mutually exclusive with hex_key) 

612 creation_mode: If True, set cipher_default_* before key (new DB) 

613 connect_kwargs: Extra kwargs passed to sqlcipher3.connect() 

614 hex_key: Pre-derived hex key (skips PBKDF2 derivation) 

615 

616 Returns: 

617 SQLCipher connection object 

618 

619 Raises: 

620 ImportError: If sqlcipher3 is not available 

621 ValueError: If the connection cannot be established 

622 """ 

623 from .sqlcipher_compat import get_sqlcipher_module 

624 

625 try: 

626 sqlcipher3 = get_sqlcipher_module() 

627 except ImportError: 

628 raise ImportError( 

629 "sqlcipher3 is not available for encrypted databases. " 

630 "Ensure SQLCipher system library is installed, then run: pdm install" 

631 ) 

632 

633 conn = sqlcipher3.connect(str(db_path), **(connect_kwargs or {})) 

634 try: 

635 cursor = conn.cursor() 

636 

637 if creation_mode: 

638 with _cipher_default_lock: 

639 apply_cipher_defaults_before_key(cursor) 

640 

641 # Set encryption key (uses per-database salt when password + db_path) 

642 if hex_key: 

643 set_sqlcipher_key_from_hex(cursor, hex_key) 

644 elif password: 644 ↛ 647line 644 didn't jump to line 647 because the condition on line 644 was always true

645 set_sqlcipher_key(cursor, password, db_path=db_path) 

646 else: 

647 raise ValueError("Either password or hex_key must be provided") # noqa: TRY301 — except does connection cleanup before re-raise 

648 

649 # Apply post-key pragmas (cipher_* for existing, kdf_iter for both) 

650 apply_sqlcipher_pragmas(cursor, creation_mode=creation_mode) 

651 

652 # Apply performance settings 

653 apply_performance_pragmas(cursor) 

654 

655 # Verify connection works 

656 if not verify_sqlcipher_connection(cursor): 

657 raise ValueError( # noqa: TRY301 — except does connection cleanup before re-raise 

658 "Failed to establish encrypted database connection" 

659 ) 

660 

661 cursor.close() 

662 return conn 

663 except Exception: 

664 from ..utilities.resource_utils import safe_close 

665 

666 safe_close(conn, "SQLCipher connection") 

667 raise 

668 

669 

670# Backwards compatibility alias — old name still importable 

671apply_cipher_settings_before_key = apply_cipher_defaults_before_key