7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
334 lines
11 KiB
Python
334 lines
11 KiB
Python
"""
|
|
Tests for SQLCipher key derivation functions.
|
|
|
|
These tests verify that password-to-key derivation is consistent,
|
|
deterministic, and secure.
|
|
"""
|
|
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from src.local_deep_research.database.sqlcipher_utils import (
|
|
LEGACY_PBKDF2_SALT,
|
|
_get_key_from_password,
|
|
get_sqlcipher_settings,
|
|
set_sqlcipher_key,
|
|
set_sqlcipher_rekey,
|
|
)
|
|
from src.local_deep_research.database.sqlcipher_compat import (
|
|
get_sqlcipher_module,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db_path():
|
|
"""Create a temporary database path."""
|
|
temp_dir = tempfile.mkdtemp()
|
|
db_path = Path(temp_dir) / "test_key.db"
|
|
yield db_path
|
|
shutil.rmtree(temp_dir)
|
|
|
|
|
|
@pytest.fixture
|
|
def sqlcipher_module():
|
|
"""Get the SQLCipher module."""
|
|
return get_sqlcipher_module()
|
|
|
|
|
|
class TestKeyDerivation:
|
|
"""Tests for password-to-key derivation."""
|
|
|
|
def test_key_derivation_deterministic(self):
|
|
"""Verify same password always produces same key."""
|
|
password = "test_password_123"
|
|
|
|
key1 = _get_key_from_password(
|
|
password,
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
key2 = _get_key_from_password(
|
|
password,
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
|
|
assert key1 == key2, "Same password should always produce same key"
|
|
|
|
def test_key_derivation_different_passwords(self):
|
|
"""Verify different passwords produce different keys."""
|
|
key1 = _get_key_from_password(
|
|
"password1",
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
key2 = _get_key_from_password(
|
|
"password2",
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
|
|
assert key1 != key2, "Different passwords should produce different keys"
|
|
|
|
def test_key_hex_encoding_only_hex_chars(self):
|
|
"""Verify derived key only contains hex characters (SQL injection safe)."""
|
|
key = _get_key_from_password(
|
|
"any_password",
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
hex_string = key.hex()
|
|
|
|
# Verify only hex characters
|
|
valid_hex_chars = set("0123456789abcdef")
|
|
assert all(c in valid_hex_chars for c in hex_string), (
|
|
"Key hex encoding should only contain [0-9a-f]"
|
|
)
|
|
|
|
def test_special_characters_in_password(self):
|
|
"""Verify passwords with special characters are handled correctly."""
|
|
special_passwords = [
|
|
"password with spaces",
|
|
"password'with'quotes",
|
|
'password"with"double"quotes',
|
|
"password\\with\\backslashes",
|
|
"password\nwith\nnewlines",
|
|
"password\twith\ttabs",
|
|
"unicode: 日本語 emoji: 🔐🔑",
|
|
"sql_injection: '; DROP TABLE users; --",
|
|
"null_byte: \x00test",
|
|
]
|
|
|
|
keys = []
|
|
for password in special_passwords:
|
|
try:
|
|
key = _get_key_from_password(
|
|
password,
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
keys.append(key)
|
|
# Verify key is valid bytes
|
|
assert isinstance(key, bytes), (
|
|
f"Key should be bytes for: {password}"
|
|
)
|
|
assert len(key) > 0, f"Key should not be empty for: {password}"
|
|
except Exception as e:
|
|
pytest.fail(f"Password '{password}' caused error: {e}")
|
|
|
|
# All keys should be unique
|
|
assert len(set(keys)) == len(keys), (
|
|
"All special passwords should produce unique keys"
|
|
)
|
|
|
|
def test_key_length_correct(self):
|
|
"""Verify derived key has correct length (64 bytes = 512 bits for SHA512)."""
|
|
key = _get_key_from_password(
|
|
"test_password",
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
|
|
# SHA512-based PBKDF2 produces 64-byte key (512 bits)
|
|
assert len(key) == 64, f"Key should be 64 bytes, got {len(key)}"
|
|
|
|
def test_empty_password_produces_key(self):
|
|
"""Verify empty password still produces a valid key (validation is elsewhere)."""
|
|
# Note: Empty password validation happens in DatabaseManager, not in key derivation
|
|
# The key derivation function itself should work with any input
|
|
key = _get_key_from_password(
|
|
"", LEGACY_PBKDF2_SALT, get_sqlcipher_settings()["kdf_iterations"]
|
|
)
|
|
assert isinstance(key, bytes), (
|
|
"Empty password should still produce bytes"
|
|
)
|
|
assert len(key) == 64, (
|
|
"Key length should be consistent (64 bytes for SHA512)"
|
|
)
|
|
|
|
|
|
class TestKeyDerivationWithDatabase:
|
|
"""Tests for key derivation in actual database operations."""
|
|
|
|
def test_derived_key_opens_database(self, sqlcipher_module, temp_db_path):
|
|
"""Verify derived key can create and open a database."""
|
|
from src.local_deep_research.database.sqlcipher_utils import (
|
|
apply_cipher_defaults_before_key,
|
|
)
|
|
|
|
password = "test_database_password"
|
|
|
|
# Create database
|
|
conn = sqlcipher_module.connect(str(temp_db_path))
|
|
cursor = conn.cursor()
|
|
# New database, so use creation_mode=True
|
|
apply_cipher_defaults_before_key(cursor)
|
|
set_sqlcipher_key(cursor, password)
|
|
cursor.close()
|
|
|
|
conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)")
|
|
conn.execute("INSERT INTO test VALUES (1)")
|
|
conn.commit() # Important: commit before closing
|
|
conn.close()
|
|
|
|
# Reopen with same password
|
|
from src.local_deep_research.database.sqlcipher_utils import (
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
conn2 = sqlcipher_module.connect(str(temp_db_path))
|
|
cursor2 = conn2.cursor()
|
|
# Existing database: key first, then cipher_* pragmas
|
|
set_sqlcipher_key(cursor2, password)
|
|
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
|
|
cursor2.close()
|
|
|
|
result = conn2.execute("SELECT * FROM test").fetchone()
|
|
assert result == (1,), "Should be able to read data with same password"
|
|
conn2.close()
|
|
|
|
def test_wrong_password_cannot_open(self, sqlcipher_module, temp_db_path):
|
|
"""Verify wrong password cannot open database."""
|
|
from src.local_deep_research.database.sqlcipher_utils import (
|
|
apply_cipher_defaults_before_key,
|
|
)
|
|
|
|
# Create database with password1
|
|
conn = sqlcipher_module.connect(str(temp_db_path))
|
|
cursor = conn.cursor()
|
|
# New database, so use creation_mode=True
|
|
apply_cipher_defaults_before_key(cursor)
|
|
set_sqlcipher_key(cursor, "password1")
|
|
cursor.close()
|
|
|
|
conn.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Try to open with password2
|
|
from src.local_deep_research.database.sqlcipher_utils import (
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
conn2 = sqlcipher_module.connect(str(temp_db_path))
|
|
cursor2 = conn2.cursor()
|
|
# Existing database: key first, then cipher_* pragmas
|
|
set_sqlcipher_key(cursor2, "password2")
|
|
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
|
|
cursor2.close()
|
|
|
|
# Should fail when trying to access data
|
|
with pytest.raises(Exception):
|
|
conn2.execute("SELECT * FROM test").fetchone()
|
|
|
|
conn2.close()
|
|
|
|
def test_rekey_uses_same_derivation(self, sqlcipher_module, temp_db_path):
|
|
"""Verify rekey produces key compatible with set_sqlcipher_key."""
|
|
from src.local_deep_research.database.sqlcipher_utils import (
|
|
apply_cipher_defaults_before_key,
|
|
)
|
|
|
|
# Create database
|
|
conn = sqlcipher_module.connect(str(temp_db_path))
|
|
cursor = conn.cursor()
|
|
# New database, so use creation_mode=True
|
|
apply_cipher_defaults_before_key(cursor)
|
|
set_sqlcipher_key(cursor, "original_password")
|
|
cursor.close()
|
|
|
|
conn.execute("CREATE TABLE test (id INTEGER, value TEXT)")
|
|
conn.execute("INSERT INTO test VALUES (1, 'secret_data')")
|
|
conn.commit()
|
|
|
|
# Rekey to new password
|
|
set_sqlcipher_rekey(conn, "new_password")
|
|
conn.close()
|
|
|
|
# Open with new password using set_sqlcipher_key
|
|
from src.local_deep_research.database.sqlcipher_utils import (
|
|
apply_sqlcipher_pragmas,
|
|
)
|
|
|
|
conn2 = sqlcipher_module.connect(str(temp_db_path))
|
|
cursor2 = conn2.cursor()
|
|
# Existing database: key first, then cipher_* pragmas
|
|
set_sqlcipher_key(cursor2, "new_password")
|
|
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
|
|
cursor2.close()
|
|
|
|
# Should be able to read data
|
|
result = conn2.execute("SELECT value FROM test WHERE id = 1").fetchone()
|
|
assert result[0] == "secret_data", "Data should be readable after rekey"
|
|
conn2.close()
|
|
|
|
# Original password should no longer work
|
|
conn3 = sqlcipher_module.connect(str(temp_db_path))
|
|
cursor3 = conn3.cursor()
|
|
# Existing database: key first, then cipher_* pragmas
|
|
set_sqlcipher_key(cursor3, "original_password")
|
|
apply_sqlcipher_pragmas(cursor3, creation_mode=False)
|
|
cursor3.close()
|
|
|
|
with pytest.raises(Exception):
|
|
conn3.execute("SELECT * FROM test").fetchone()
|
|
conn3.close()
|
|
|
|
|
|
class TestKeyDerivationSecurity:
|
|
"""Security-focused tests for key derivation."""
|
|
|
|
def test_pbkdf2_iterations_sufficient(self):
|
|
"""Verify KDF iterations are set to a secure value."""
|
|
settings = get_sqlcipher_settings()
|
|
iterations = settings["kdf_iterations"]
|
|
|
|
# OWASP recommends at least 600,000 for PBKDF2-HMAC-SHA512
|
|
# We use 256,000 which is a reasonable balance for performance
|
|
assert iterations >= 100000, (
|
|
f"KDF iterations should be at least 100000, got {iterations}"
|
|
)
|
|
|
|
def test_key_not_stored_in_plaintext(self):
|
|
"""Verify derived key is bytes, not plaintext password."""
|
|
password = "my_secret_password"
|
|
key = _get_key_from_password(
|
|
password,
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
|
|
# Key should be bytes, not string
|
|
assert isinstance(key, bytes), "Key should be bytes"
|
|
|
|
# Key should not contain the password
|
|
assert password.encode() not in key, (
|
|
"Key should not contain plaintext password"
|
|
)
|
|
|
|
def test_different_salts_would_produce_different_keys(self):
|
|
"""
|
|
Document that our fixed salt means same password = same key.
|
|
|
|
This is intentional for database compatibility - if salt changed,
|
|
existing databases would become inaccessible.
|
|
"""
|
|
# Same password should always produce same key (fixed salt)
|
|
key1 = _get_key_from_password(
|
|
"test",
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
key2 = _get_key_from_password(
|
|
"test",
|
|
LEGACY_PBKDF2_SALT,
|
|
get_sqlcipher_settings()["kdf_iterations"],
|
|
)
|
|
|
|
assert key1 == key2, (
|
|
"Fixed salt ensures consistent key derivation for database compatibility"
|
|
)
|