Files
wehub-resource-sync 7a0da7932b
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

361 lines
12 KiB
Python

"""
Tests for SQLCipher rekey (password change) functionality.
These tests verify that password changes work correctly and use
consistent key derivation with initial key setup.
"""
import shutil
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from src.local_deep_research.database.sqlcipher_utils import (
LEGACY_PBKDF2_SALT,
apply_cipher_defaults_before_key,
apply_sqlcipher_pragmas,
get_sqlcipher_settings,
set_sqlcipher_key,
set_sqlcipher_rekey,
_get_key_from_password,
)
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_rekey.db"
yield db_path
shutil.rmtree(temp_dir)
@pytest.fixture
def sqlcipher_module():
"""Get the SQLCipher module."""
return get_sqlcipher_module()
@pytest.fixture
def encrypted_db(sqlcipher_module, temp_db_path):
"""Create an encrypted database with test data."""
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_data (id INTEGER PRIMARY KEY, value TEXT)")
conn.execute("INSERT INTO test_data VALUES (1, 'secret_value_1')")
conn.execute("INSERT INTO test_data VALUES (2, 'secret_value_2')")
conn.execute("INSERT INTO test_data VALUES (3, 'secret_value_3')")
conn.commit()
return {"conn": conn, "path": temp_db_path, "password": "original_password"}
def _open_existing_db(sqlcipher_module, db_path, password):
"""Helper to open an existing encrypted DB with correct PRAGMA ordering."""
conn = sqlcipher_module.connect(str(db_path))
cursor = conn.cursor()
set_sqlcipher_key(cursor, password)
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.close()
return conn
class TestRekeyFunctionality:
"""Tests for basic rekey operations."""
def test_rekey_changes_password(self, encrypted_db, sqlcipher_module):
"""Verify rekey successfully changes the database password."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
# Change password
set_sqlcipher_rekey(conn, "new_password")
conn.close()
# Old password should fail
conn_old = _open_existing_db(
sqlcipher_module, db_path, "original_password"
)
with pytest.raises(Exception):
conn_old.execute("SELECT * FROM test_data").fetchone()
conn_old.close()
# New password should work
conn_new = _open_existing_db(sqlcipher_module, db_path, "new_password")
result = conn_new.execute("SELECT * FROM test_data").fetchall()
assert len(result) == 3, (
"All data should be accessible with new password"
)
conn_new.close()
def test_rekey_preserves_all_data(self, encrypted_db, sqlcipher_module):
"""Verify all data is preserved after password change."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
# Read data before rekey
original_data = conn.execute(
"SELECT id, value FROM test_data ORDER BY id"
).fetchall()
# Change password
set_sqlcipher_rekey(conn, "new_password")
conn.close()
# Read data after rekey
conn_new = _open_existing_db(sqlcipher_module, db_path, "new_password")
new_data = conn_new.execute(
"SELECT id, value FROM test_data ORDER BY id"
).fetchall()
conn_new.close()
assert original_data == new_data, "Data should be identical after rekey"
def test_rekey_uses_pbkdf2_derivation(self):
"""Verify rekey uses the same PBKDF2 key derivation as initial key."""
# Get derived key for a password
password = "test_password_for_derivation"
kdf_iterations = get_sqlcipher_settings()["kdf_iterations"]
derived_key = _get_key_from_password(
password, LEGACY_PBKDF2_SALT, kdf_iterations
)
# Derive again to verify same result (deterministic)
derived_key_2 = _get_key_from_password(
password, LEGACY_PBKDF2_SALT, kdf_iterations
)
assert derived_key == derived_key_2, (
"Key derivation must be deterministic"
)
def test_rekey_multiple_times(self, encrypted_db, sqlcipher_module):
"""Verify password can be changed multiple times."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
passwords = ["password_1", "password_2", "password_3", "final_password"]
for new_password in passwords:
set_sqlcipher_rekey(conn, new_password)
conn.close()
# Only the final password should work
for wrong_password in passwords[:-1]:
conn_wrong = _open_existing_db(
sqlcipher_module, db_path, wrong_password
)
with pytest.raises(Exception):
conn_wrong.execute("SELECT * FROM test_data").fetchone()
conn_wrong.close()
# Final password works
conn_final = _open_existing_db(
sqlcipher_module, db_path, "final_password"
)
result = conn_final.execute("SELECT * FROM test_data").fetchall()
assert len(result) == 3
conn_final.close()
class TestRekeySpecialPasswords:
"""Tests for rekey with special characters in passwords."""
def test_rekey_with_quotes(self, encrypted_db, sqlcipher_module):
"""Verify rekey works with quotes in password."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
new_password = "pass'word\"with'quotes\""
set_sqlcipher_rekey(conn, new_password)
conn.close()
conn_new = _open_existing_db(sqlcipher_module, db_path, new_password)
result = conn_new.execute("SELECT * FROM test_data").fetchone()
assert result is not None, (
"Database should be accessible with quoted password"
)
conn_new.close()
def test_rekey_with_unicode(self, encrypted_db, sqlcipher_module):
"""Verify rekey works with unicode characters."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
new_password = "パスワード安全"
set_sqlcipher_rekey(conn, new_password)
conn.close()
conn_new = _open_existing_db(sqlcipher_module, db_path, new_password)
result = conn_new.execute("SELECT * FROM test_data").fetchone()
assert result is not None, (
"Database should be accessible with unicode password"
)
conn_new.close()
def test_rekey_with_backslashes(self, encrypted_db, sqlcipher_module):
"""Verify rekey works with backslashes in password."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
new_password = "pass\\word\\with\\backslashes"
set_sqlcipher_rekey(conn, new_password)
conn.close()
conn_new = _open_existing_db(sqlcipher_module, db_path, new_password)
result = conn_new.execute("SELECT * FROM test_data").fetchone()
assert result is not None, (
"Database should be accessible with backslash password"
)
conn_new.close()
def test_rekey_with_sql_injection_attempt(
self, encrypted_db, sqlcipher_module
):
"""Verify rekey is safe against SQL injection attempts."""
conn = encrypted_db["conn"]
db_path = encrypted_db["path"]
new_password = "'; DROP TABLE test_data; --"
set_sqlcipher_rekey(conn, new_password)
conn.close()
conn_new = _open_existing_db(sqlcipher_module, db_path, new_password)
result = conn_new.execute("SELECT COUNT(*) FROM test_data").fetchone()
assert result[0] == 3, (
"Table should exist and have all data after 'injection' password"
)
conn_new.close()
class TestRekeyWithSQLAlchemy:
"""Tests for rekey via SQLAlchemy connections."""
def test_rekey_via_sqlalchemy(self, temp_db_path):
"""Verify rekey works through SQLAlchemy connection."""
from sqlalchemy import create_engine, event, text
from src.local_deep_research.database.sqlcipher_utils import (
apply_cipher_defaults_before_key,
apply_sqlcipher_pragmas,
set_sqlcipher_key,
set_sqlcipher_rekey,
)
from src.local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
sqlcipher_module = get_sqlcipher_module()
# Create database with raw connection first
raw_conn = sqlcipher_module.connect(str(temp_db_path))
cursor = raw_conn.cursor()
apply_cipher_defaults_before_key(cursor)
set_sqlcipher_key(cursor, "original_password")
cursor.close()
raw_conn.execute(
"CREATE TABLE test (id INTEGER PRIMARY KEY, data TEXT)"
)
raw_conn.execute("INSERT INTO test VALUES (1, 'test_data')")
raw_conn.commit()
raw_conn.close()
# Open with SQLAlchemy and rekey
engine = create_engine(
f"sqlite:///{temp_db_path}",
module=sqlcipher_module,
)
@event.listens_for(engine, "connect")
def set_cipher(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
set_sqlcipher_key(cursor, "original_password")
apply_sqlcipher_pragmas(cursor, creation_mode=False)
cursor.close()
with engine.connect() as conn:
# Verify data is accessible
result = conn.execute(
text("SELECT data FROM test WHERE id = 1")
).scalar()
assert result == "test_data"
# Rekey via SQLAlchemy connection
set_sqlcipher_rekey(conn, "new_password")
conn.commit()
engine.dispose()
# Verify new password works with raw connection
raw_conn2 = _open_existing_db(
sqlcipher_module, temp_db_path, "new_password"
)
result = raw_conn2.execute(
"SELECT data FROM test WHERE id = 1"
).fetchone()
assert result[0] == "test_data", (
"Data should be accessible with new password"
)
raw_conn2.close()
class TestSetSqlcipherRekey:
"""Unit tests for set_sqlcipher_rekey verifying it uses PBKDF2."""
def test_rekey_calls_get_key_from_password(self):
"""Verify rekey uses get_key_from_password (PBKDF2), not raw encoding."""
mock_conn = Mock()
mock_conn.execute.side_effect = [TypeError("not sqlalchemy"), None]
with patch(
"src.local_deep_research.database.sqlcipher_utils.get_key_from_password",
return_value=b"\xab\xcd\xef",
) as mock_get_key:
set_sqlcipher_rekey(mock_conn, "new_password")
mock_get_key.assert_called_once_with("new_password", db_path=None)
def test_rekey_does_not_use_raw_utf8_hex(self):
"""Verify rekey does NOT use password.encode().hex() (raw UTF-8)."""
mock_conn = Mock()
mock_conn.execute.side_effect = [TypeError("not sqlalchemy"), None]
with patch(
"src.local_deep_research.database.sqlcipher_utils.get_key_from_password",
return_value=b"\xab\xcd\xef",
):
set_sqlcipher_rekey(mock_conn, "test_password")
# The SQL should contain the PBKDF2-derived hex, not raw password hex
rekey_call = mock_conn.execute.call_args_list[-1][0][0]
raw_hex = "test_password".encode().hex()
assert raw_hex not in rekey_call, (
"Rekey should NOT use raw UTF-8 hex encoding"
)
assert "abcdef" in rekey_call, "Rekey should use PBKDF2-derived hex key"
def test_rekey_works_with_sqlalchemy_connection(self):
"""Verify rekey works through SQLAlchemy connection (text() wrapper)."""
mock_conn = Mock()
with patch(
"src.local_deep_research.database.sqlcipher_utils.get_key_from_password",
return_value=b"\xab\xcd\xef",
):
set_sqlcipher_rekey(mock_conn, "new_password")
mock_conn.execute.assert_called_once()