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
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""
2SQLCipher utility functions for consistent database operations.
4This module centralizes all SQLCipher-specific operations to ensure
5consistent password handling and PRAGMA settings across the codebase.
6"""
8import os
9import secrets
10import threading
11import time
12from hashlib import pbkdf2_hmac
13from pathlib import Path
14from typing import Any, Dict, Optional, Union
16from loguru import logger
18from ..settings.env_registry import get_env_setting
19from ..utilities.type_utils import to_bool
21# Lock to protect cipher_default_* global state during creation
22_cipher_default_lock = threading.Lock()
24# Salt file constants
25SALT_FILE_SUFFIX = ".salt"
26SALT_SIZE = 32 # 256 bits
29def get_salt_file_path(db_path: Union[str, Path]) -> Path:
30 """
31 Get the path to the salt file for a database.
33 Args:
34 db_path: Path to the database file
36 Returns:
37 Path to the corresponding .salt file
38 """
39 return Path(db_path).with_suffix(Path(db_path).suffix + SALT_FILE_SUFFIX)
42def get_salt_for_database(db_path: Union[str, Path]) -> bytes:
43 """
44 Get the salt for a database file.
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.
49 Args:
50 db_path: Path to the database file
52 Returns:
53 The salt bytes to use for key derivation
54 """
55 salt_file = get_salt_file_path(db_path)
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
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
77def create_database_salt(db_path: Union[str, Path]) -> bytes:
78 """
79 Create and store a new random salt for a database.
81 This should be called when creating a new database.
82 The salt is stored in a .salt file alongside the database.
84 WARNING: If this salt file is deleted, the associated database becomes
85 permanently unreadable. Always back up .salt files alongside their .db files.
87 Args:
88 db_path: Path to the database file
90 Returns:
91 The newly generated salt bytes
93 Raises:
94 FileExistsError: If a salt file already exists for this database
95 """
96 salt_file = get_salt_file_path(db_path)
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 )
104 salt = secrets.token_bytes(SALT_SIZE)
106 # Ensure parent directory exists
107 salt_file.parent.mkdir(parents=True, exist_ok=True)
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)
116 logger.info(f"Created new database salt file: {salt_file}")
117 return salt
120def has_per_database_salt(db_path: Union[str, Path]) -> bool:
121 """
122 Check if a database has a per-database salt file (v2).
124 Args:
125 db_path: Path to the database file
127 Returns:
128 True if the database has a .salt file, False otherwise
129 """
130 return get_salt_file_path(db_path).exists()
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.
139 Args:
140 password: The password.
141 salt: The salt bytes to use for key derivation.
142 kdf_iterations: Number of PBKDF2 iterations.
144 Returns:
145 The generated key.
146 """
147 logger.debug(
148 f"Generating DB encryption key with {kdf_iterations} iterations..."
149 )
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
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
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.
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.
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
192 settings = get_sqlcipher_settings()
193 return _get_key_from_password(password, salt, settings["kdf_iterations"])
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.
204 This avoids SQL injection and escaping issues with special characters.
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()}'\"")
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.
220 Used by connection closures to avoid capturing plaintext passwords.
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
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.
237 Uses the same PBKDF2 key derivation as set_sqlcipher_key() to ensure
238 consistency when re-opening databases after password change.
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
249 # The hex encoding already prevents injection since it only contains [0-9a-f]
250 safe_sql = f"PRAGMA rekey = \"x'{key.hex()}'\""
252 try:
253 # Try SQLAlchemy connection (needs text() wrapper)
254 from sqlalchemy import text
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)
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"
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
274# Production minimum KDF iterations. Relaxed automatically in test/CI environments.
275MIN_KDF_ITERATIONS_PRODUCTION = 100_000
276MIN_KDF_ITERATIONS_TESTING = 1
279def _get_min_kdf_iterations() -> int:
280 """Get minimum KDF iterations, relaxed for test/CI environments.
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.
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 )
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"
309# Alias for backwards compatibility with code that references the old name
310PBKDF2_PLACEHOLDER_SALT = LEGACY_PBKDF2_SALT
313def get_sqlcipher_settings() -> dict:
314 """
315 Get SQLCipher settings from environment variables or use defaults.
317 These settings cannot be changed after database creation, so they
318 must be configured via environment variables only.
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.
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 )
332 # KDF algorithm - registry validates against allowed values
333 kdf_algorithm = get_env_setting(
334 "db_config.kdf_algorithm", DEFAULT_KDF_ALGORITHM
335 )
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
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
359 return {
360 "kdf_iterations": kdf_iterations,
361 "page_size": page_size,
362 "hmac_algorithm": hmac_algorithm,
363 "kdf_algorithm": kdf_algorithm,
364 }
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``.
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).
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.
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.
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
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
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
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.
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.
434 For EXISTING databases, cipher_page_size/cipher_hmac_algorithm/
435 cipher_kdf_algorithm are set AFTER the key via apply_sqlcipher_pragmas().
437 Args:
438 cursor_or_conn: SQLCipher cursor or connection object
439 """
440 settings = get_sqlcipher_settings()
442 logger.debug(
443 f"Applying cipher_default_* pragmas for new DB: settings={settings}"
444 )
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 )
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.
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.
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()
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 )
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']}")
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}")
503def apply_performance_pragmas(cursor_or_conn: Any) -> None:
504 """
505 Apply performance-related PRAGMA settings from environment variables.
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.
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
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")
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}")
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}")
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}")
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}")
545def verify_sqlcipher_connection(cursor_or_conn: Any) -> bool:
546 """
547 Verify that the SQLCipher connection is working correctly.
549 Args:
550 cursor_or_conn: SQLCipher cursor or connection object
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
573def get_sqlcipher_version(cursor_or_conn: Any) -> Optional[str]:
574 """
575 Get the SQLCipher version string.
577 Args:
578 cursor_or_conn: SQLCipher cursor or connection object
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
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.
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
606 Uses per-database salt if a .salt file exists alongside the database,
607 otherwise falls back to legacy salt for backwards compatibility.
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)
616 Returns:
617 SQLCipher connection object
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
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 )
633 conn = sqlcipher3.connect(str(db_path), **(connect_kwargs or {}))
634 try:
635 cursor = conn.cursor()
637 if creation_mode:
638 with _cipher_default_lock:
639 apply_cipher_defaults_before_key(cursor)
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
649 # Apply post-key pragmas (cipher_* for existing, kdf_iter for both)
650 apply_sqlcipher_pragmas(cursor, creation_mode=creation_mode)
652 # Apply performance settings
653 apply_performance_pragmas(cursor)
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 )
661 cursor.close()
662 return conn
663 except Exception:
664 from ..utilities.resource_utils import safe_close
666 safe_close(conn, "SQLCipher connection")
667 raise
670# Backwards compatibility alias — old name still importable
671apply_cipher_settings_before_key = apply_cipher_defaults_before_key