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
380 lines
12 KiB
Python
380 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test that encryption constants never change.
|
|
|
|
These tests protect against breaking changes to encryption parameters.
|
|
If ANY of these tests fail, it means ALL existing encrypted databases
|
|
will become unreadable. REVERT THE CHANGE IMMEDIATELY.
|
|
|
|
This test file exists because a "documentation only" commit changed
|
|
the PBKDF2 salt and broke all existing user databases.
|
|
|
|
Note: New databases (v2+) use per-database random salts stored in .salt files.
|
|
Legacy databases continue to use LEGACY_PBKDF2_SALT for backwards compatibility.
|
|
"""
|
|
|
|
import hashlib
|
|
from hashlib import pbkdf2_hmac
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
class TestEncryptionConstants:
|
|
"""
|
|
Verify that encryption constants haven't changed.
|
|
|
|
CRITICAL: These values are used to derive encryption keys.
|
|
Changing ANY of them will make existing databases unreadable.
|
|
"""
|
|
|
|
def test_legacy_salt_value_is_stable(self):
|
|
"""
|
|
Ensure the legacy PBKDF2 salt value hasn't changed.
|
|
|
|
WARNING: Changing this salt will break ALL existing legacy databases!
|
|
If this test fails, you MUST revert the salt change.
|
|
|
|
Note: New databases use per-database salts, but legacy databases
|
|
still depend on this constant value.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
LEGACY_PBKDF2_SALT,
|
|
PBKDF2_PLACEHOLDER_SALT,
|
|
)
|
|
|
|
expected_salt = b"no salt"
|
|
|
|
assert LEGACY_PBKDF2_SALT == expected_salt, (
|
|
f"CRITICAL: Legacy PBKDF2 salt has changed!\n"
|
|
f"Expected: {expected_salt!r}\n"
|
|
f"Actual: {LEGACY_PBKDF2_SALT!r}\n\n"
|
|
"This will break ALL existing legacy encrypted databases!\n"
|
|
"Users will be unable to log in.\n"
|
|
"REVERT THIS CHANGE IMMEDIATELY."
|
|
)
|
|
|
|
# Also verify the alias is still pointing to the same value
|
|
assert PBKDF2_PLACEHOLDER_SALT == LEGACY_PBKDF2_SALT, (
|
|
"PBKDF2_PLACEHOLDER_SALT should be an alias for LEGACY_PBKDF2_SALT"
|
|
)
|
|
|
|
def test_kdf_iterations_stable(self):
|
|
"""
|
|
Ensure KDF iterations haven't changed.
|
|
|
|
Changing this will make existing databases unreadable.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
DEFAULT_KDF_ITERATIONS,
|
|
)
|
|
|
|
expected_iterations = 256000
|
|
|
|
assert DEFAULT_KDF_ITERATIONS == expected_iterations, (
|
|
f"CRITICAL: KDF iterations changed!\n"
|
|
f"Expected: {expected_iterations}\n"
|
|
f"Actual: {DEFAULT_KDF_ITERATIONS}\n\n"
|
|
"This will break ALL existing encrypted databases!\n"
|
|
"REVERT THIS CHANGE IMMEDIATELY."
|
|
)
|
|
|
|
def test_hmac_algorithm_stable(self):
|
|
"""
|
|
Ensure HMAC algorithm hasn't changed.
|
|
|
|
Changing this will make existing databases unreadable.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
DEFAULT_HMAC_ALGORITHM,
|
|
)
|
|
|
|
expected_algorithm = "HMAC_SHA512"
|
|
|
|
assert DEFAULT_HMAC_ALGORITHM == expected_algorithm, (
|
|
f"CRITICAL: HMAC algorithm changed!\n"
|
|
f"Expected: {expected_algorithm}\n"
|
|
f"Actual: {DEFAULT_HMAC_ALGORITHM}\n\n"
|
|
"This will break ALL existing encrypted databases!\n"
|
|
"REVERT THIS CHANGE IMMEDIATELY."
|
|
)
|
|
|
|
def test_page_size_stable(self):
|
|
"""
|
|
Ensure cipher page size hasn't changed.
|
|
|
|
Changing this will make existing databases unreadable.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
DEFAULT_PAGE_SIZE,
|
|
)
|
|
|
|
expected_page_size = 16384 # 16KB
|
|
|
|
assert DEFAULT_PAGE_SIZE == expected_page_size, (
|
|
f"CRITICAL: Page size changed!\n"
|
|
f"Expected: {expected_page_size}\n"
|
|
f"Actual: {DEFAULT_PAGE_SIZE}\n\n"
|
|
"This will break ALL existing encrypted databases!\n"
|
|
"REVERT THIS CHANGE IMMEDIATELY."
|
|
)
|
|
|
|
def test_kdf_algorithm_stable(self):
|
|
"""
|
|
Ensure KDF algorithm hasn't changed.
|
|
|
|
Changing this will make existing databases unreadable.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
DEFAULT_KDF_ALGORITHM,
|
|
)
|
|
|
|
expected_algorithm = "PBKDF2_HMAC_SHA512"
|
|
|
|
assert DEFAULT_KDF_ALGORITHM == expected_algorithm, (
|
|
f"CRITICAL: KDF algorithm changed!\n"
|
|
f"Expected: {expected_algorithm}\n"
|
|
f"Actual: {DEFAULT_KDF_ALGORITHM}\n\n"
|
|
"This will break ALL existing encrypted databases!\n"
|
|
"REVERT THIS CHANGE IMMEDIATELY."
|
|
)
|
|
|
|
def test_key_derivation_produces_expected_output(self):
|
|
"""
|
|
Verify the key derivation function produces the expected output.
|
|
|
|
This test uses a known password and verifies the derived key matches
|
|
the expected hash. This catches ANY change to the key derivation:
|
|
- Salt changes
|
|
- Iteration count changes
|
|
- Algorithm changes
|
|
- Any other parameter changes
|
|
|
|
If this test fails, existing databases WILL NOT be openable.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
PBKDF2_PLACEHOLDER_SALT,
|
|
DEFAULT_KDF_ITERATIONS,
|
|
)
|
|
|
|
# Use a known test password
|
|
test_password = "test_password_for_key_derivation_check"
|
|
|
|
# Derive the key the same way the production code does
|
|
derived_key = pbkdf2_hmac(
|
|
"sha512",
|
|
test_password.encode(),
|
|
PBKDF2_PLACEHOLDER_SALT,
|
|
DEFAULT_KDF_ITERATIONS,
|
|
)
|
|
|
|
# This is the expected hash of the derived key
|
|
# Generated with: hashlib.sha256(derived_key).hexdigest()
|
|
# If this changes, ALL existing databases will break!
|
|
# DevSkim: ignore DS173237 - This is a verification hash, not a secret
|
|
expected_key_hash = (
|
|
"cfac783084917231b28210859f7722be29b54120161f43709363c07cfc6c63ed"
|
|
)
|
|
|
|
actual_key_hash = hashlib.sha256(derived_key).hexdigest()
|
|
|
|
assert actual_key_hash == expected_key_hash, (
|
|
f"CRITICAL: Key derivation output has changed!\n"
|
|
f"Expected hash: {expected_key_hash}\n"
|
|
f"Actual hash: {actual_key_hash}\n\n"
|
|
"This means the encryption key for the same password is now different.\n"
|
|
"ALL existing user databases will be unreadable!\n"
|
|
"REVERT WHATEVER CHANGE CAUSED THIS IMMEDIATELY."
|
|
)
|
|
|
|
|
|
class TestPerDatabaseSalt:
|
|
"""
|
|
Test the per-database salt functionality (v2 databases).
|
|
|
|
New databases use random per-database salts stored in .salt files.
|
|
This provides better security than the shared legacy salt.
|
|
"""
|
|
|
|
def test_salt_file_path_generation(self):
|
|
"""
|
|
Verify salt file paths are generated correctly.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_salt_file_path,
|
|
)
|
|
|
|
db_path = Path("/data/user.db")
|
|
salt_path = get_salt_file_path(db_path)
|
|
|
|
assert salt_path == Path("/data/user.db.salt")
|
|
|
|
def test_create_database_salt_generates_correct_size(self, tmp_path):
|
|
"""
|
|
Verify that created salts are the correct size.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_database_salt,
|
|
SALT_SIZE,
|
|
)
|
|
|
|
db_path = tmp_path / "test.db"
|
|
salt = create_database_salt(db_path)
|
|
|
|
assert len(salt) == SALT_SIZE
|
|
assert db_path.with_suffix(".db.salt").exists()
|
|
|
|
def test_create_database_salt_is_random(self, tmp_path):
|
|
"""
|
|
Verify that each call to create_database_salt generates a unique salt.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_database_salt,
|
|
)
|
|
|
|
db_path1 = tmp_path / "test1.db"
|
|
db_path2 = tmp_path / "test2.db"
|
|
|
|
salt1 = create_database_salt(db_path1)
|
|
salt2 = create_database_salt(db_path2)
|
|
|
|
assert salt1 != salt2, "Each database should have a unique salt"
|
|
|
|
def test_get_salt_for_database_with_salt_file(self, tmp_path):
|
|
"""
|
|
Verify that get_salt_for_database returns the salt from the file.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_database_salt,
|
|
get_salt_for_database,
|
|
)
|
|
|
|
db_path = tmp_path / "test.db"
|
|
created_salt = create_database_salt(db_path)
|
|
retrieved_salt = get_salt_for_database(db_path)
|
|
|
|
assert retrieved_salt == created_salt
|
|
|
|
def test_get_salt_for_database_without_salt_file(self, tmp_path):
|
|
"""
|
|
Verify that get_salt_for_database returns legacy salt when no .salt file exists.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_salt_for_database,
|
|
LEGACY_PBKDF2_SALT,
|
|
)
|
|
|
|
db_path = tmp_path / "legacy.db"
|
|
# Don't create a salt file - simulating a legacy database
|
|
|
|
salt = get_salt_for_database(db_path)
|
|
|
|
assert salt == LEGACY_PBKDF2_SALT, (
|
|
"Should return legacy salt for databases without .salt file"
|
|
)
|
|
|
|
def test_has_per_database_salt(self, tmp_path):
|
|
"""
|
|
Verify has_per_database_salt correctly identifies v2 databases.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_database_salt,
|
|
has_per_database_salt,
|
|
)
|
|
|
|
new_db = tmp_path / "new.db"
|
|
legacy_db = tmp_path / "legacy.db"
|
|
|
|
# Create salt for new database
|
|
create_database_salt(new_db)
|
|
|
|
assert has_per_database_salt(new_db) is True
|
|
assert has_per_database_salt(legacy_db) is False
|
|
|
|
def test_get_salt_for_database_raises_on_corrupted_salt(self, tmp_path):
|
|
"""
|
|
Verify that get_salt_for_database raises ValueError for corrupted salt files.
|
|
|
|
If a salt file exists but has wrong size, it indicates corruption.
|
|
Falling back to legacy salt would fail anyway (wrong key), so we
|
|
raise an exception to make the failure explicit.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
get_salt_for_database,
|
|
get_salt_file_path,
|
|
)
|
|
|
|
db_path = tmp_path / "corrupted.db"
|
|
salt_file = get_salt_file_path(db_path)
|
|
|
|
# Write a corrupted salt file (wrong size)
|
|
salt_file.write_bytes(b"too short")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
get_salt_for_database(db_path)
|
|
|
|
assert "unexpected size" in str(exc_info.value)
|
|
assert "corrupted" in str(exc_info.value).lower()
|
|
|
|
def test_create_database_salt_refuses_to_overwrite(self, tmp_path):
|
|
"""
|
|
Verify that create_database_salt raises FileExistsError if a salt
|
|
file already exists, preventing accidental data loss.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_database_salt,
|
|
)
|
|
|
|
db_path = tmp_path / "test.db"
|
|
create_database_salt(db_path)
|
|
|
|
with pytest.raises(FileExistsError):
|
|
create_database_salt(db_path)
|
|
|
|
def test_same_password_different_salts_produce_different_keys(self):
|
|
"""
|
|
Verify the core security guarantee: the same password with different
|
|
salts must produce different encryption keys.
|
|
"""
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
_get_key_from_password,
|
|
)
|
|
|
|
password = "same_password"
|
|
salt1 = b"a" * 32
|
|
salt2 = b"b" * 32
|
|
|
|
key1 = _get_key_from_password(password, salt1, 1000)
|
|
key2 = _get_key_from_password(password, salt2, 1000)
|
|
|
|
assert key1 != key2, (
|
|
"Same password with different salts MUST produce different keys"
|
|
)
|
|
|
|
def test_salt_file_has_restrictive_permissions(self, tmp_path):
|
|
"""
|
|
Verify that salt files are created with owner-only permissions (0o600).
|
|
"""
|
|
import os
|
|
import stat
|
|
|
|
from local_deep_research.database.sqlcipher_utils import (
|
|
create_database_salt,
|
|
get_salt_file_path,
|
|
)
|
|
|
|
db_path = tmp_path / "test.db"
|
|
create_database_salt(db_path)
|
|
|
|
salt_file = get_salt_file_path(db_path)
|
|
file_stat = os.stat(salt_file)
|
|
mode = stat.S_IMODE(file_stat.st_mode)
|
|
|
|
assert mode == 0o600, (
|
|
f"Salt file should have 0o600 permissions, got {oct(mode)}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|