-
Notifications
You must be signed in to change notification settings - Fork 1
Fix hardcoded encryption salt vulnerability in SecureStorage #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a786fae
ddbdef1
c24100e
056d297
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -41,6 +41,8 @@ def __init__( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.key_iterations = key_iterations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._password = password | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._encryptor = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt_loaded = False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.data: Dict[str, Any] = {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not self._password and keyring: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -83,44 +85,83 @@ def _set_password_in_keyring(self, password: str): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _setup_encryptor(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Sets up the encryptor instance variable.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Derive a key from the password | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # In a real application, the salt should be stored with the encrypted data | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| salt = b"\\x00" * 16 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Sets up the encryptor with proper random salt.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if os.path.exists(self.file_path): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Try to load salt from existing file (new format) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with open(self.file_path, 'rb') as f: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt = f.read(16) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(self._salt) == 16: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt_loaded = True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # File too short - generate new salt | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt = os.urandom(16) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt_loaded = False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except (IOError, OSError): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt = os.urandom(16) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt_loaded = False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Generate random salt for new files | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt = os.urandom(16) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._salt_loaded = False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kdf = ScryptKDF() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._key = kdf.derive_key(self._password, salt) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._key = kdf.derive_key(self._password, self._salt) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._encryptor = AESGCMEncryptor() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def save(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Saves the data to the file. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Saves the data to the file with salt prepended. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Saves the data to the file with salt prepended. | |
| Encrypts and saves the current data to ``self.file_path``. | |
| The file format is: | |
| [16-byte salt][12-byte nonce][ciphertext][16-byte tag] | |
| where: | |
| - ``salt`` is the 16-byte random value used for key derivation. | |
| - ``nonce`` is the 12-byte AES-GCM nonce. | |
| - ``ciphertext`` is the encrypted JSON-serialized payload. | |
| - ``tag`` is the 16-byte AES-GCM authentication tag. |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the backward compatibility scenario, after successfully loading old format data and generating a new salt, the code updates 'self._salt' and 'self._key' but doesn't set 'self._salt_loaded = True'. This means on the next save() call within the same session, it will write the new salt correctly. However, this state inconsistency could be confusing.
Consider setting 'self._salt_loaded = True' after successful migration (line 160) to maintain consistent state, indicating that we now have a proper salt that will be persisted.
| self._key = kdf.derive_key(self._password, self._salt) | |
| self._key = kdf.derive_key(self._password, self._salt) | |
| self._salt_loaded = True |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The backward compatibility fallback has a logical issue. When '_salt_loaded' is True but the file is actually in the old format (e.g., an old format file that happens to start with 16 bytes that look like a salt), the code will skip those first 16 bytes, potentially reading the wrong data for decryption.
This edge case could occur if an old-format encrypted file happens to start with 16 bytes that don't trigger an immediate decryption error. The fallback on line 147 will then read from the beginning again, which is correct, but the logic assumes that any decryption failure with '_salt_loaded=True' means we should try old format. A more robust approach would be to check file size or add a magic header to distinguish formats definitively.
| # Try old format (no salt prefix) for backward compatibility | |
| with open(self.file_path, "rb") as f: | |
| encrypted_blob_old = f.read() | |
| # Use hardcoded salt for old format | |
| old_salt = b"\x00" * 16 | |
| kdf = ScryptKDF() | |
| old_key = kdf.derive_key(self._password, old_salt) | |
| try: | |
| decrypted_data = self._encryptor.decrypt(encrypted_blob_old, old_key) | |
| self.data = json.loads(decrypted_data) | |
| # Successfully loaded old format - generate new salt for migration on next save | |
| self._salt = os.urandom(16) | |
| self._key = kdf.derive_key(self._password, self._salt) | |
| except (ValueError, InvalidTag): | |
| # Only try old format (no salt prefix) when salt was not loaded, | |
| # to avoid incorrectly treating new-format files as old-format. | |
| if not self._salt_loaded: | |
| with open(self.file_path, "rb") as f: | |
| encrypted_blob_old = f.read() | |
| # Use hardcoded salt for old format | |
| old_salt = b"\x00" * 16 | |
| kdf = ScryptKDF() | |
| old_key = kdf.derive_key(self._password, old_salt) | |
| try: | |
| decrypted_data = self._encryptor.decrypt(encrypted_blob_old, old_key) | |
| self.data = json.loads(decrypted_data) | |
| # Successfully loaded old format - generate new salt for migration on next save | |
| self._salt = os.urandom(16) | |
| self._key = kdf.derive_key(self._password, self._salt) | |
| except (ValueError, InvalidTag): | |
| raise IOError( | |
| f"Failed to decrypt or load data. Incorrect password or corrupted file. Reason: {e}" | |
| ) | |
| else: | |
| # For files where a salt was already loaded, decryption failure | |
| # indicates an incorrect password or corrupted file; do not | |
| # attempt legacy format decryption. |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The exception handling in the fallback logic loses the original exception context. When the old format decryption also fails, the error message references 'e' from the outer exception handler, but this could be confusing if both new and old format decryption fail for different reasons.
Consider capturing the original exception in a variable before attempting the fallback, and providing both exceptions in the final error message to give better debugging information. For example: "Failed to decrypt with both new format (Reason: {new_error}) and old format (Reason: {old_error})".
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,10 @@ | ||||||||||||||||||||||||||||||||||||
| import unittest | ||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||
| from unittest.mock import patch, MagicMock | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| from device_fingerprinting.secure_storage import SecureStorage | ||||||||||||||||||||||||||||||||||||
| from device_fingerprinting.crypto import AESGCMEncryptor, ScryptKDF | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| class TestSecureStorage(unittest.TestCase): | ||||||||||||||||||||||||||||||||||||
|
|
@@ -101,6 +103,155 @@ def test_context_manager_saves_on_exit(self): | |||||||||||||||||||||||||||||||||||
| with SecureStorage(self.test_file, self.password) as reloaded_store: | ||||||||||||||||||||||||||||||||||||
| self.assertEqual(reloaded_store.get_item("auto_saved"), "data") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def test_different_salts_for_new_files(self): | ||||||||||||||||||||||||||||||||||||
| """Test that different files get different random salts.""" | ||||||||||||||||||||||||||||||||||||
| test_file1 = "test_salt_1.bin" | ||||||||||||||||||||||||||||||||||||
| test_file2 = "test_salt_2.bin" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||
| # Create two separate storage files with same password | ||||||||||||||||||||||||||||||||||||
| with SecureStorage(test_file1, self.password) as store1: | ||||||||||||||||||||||||||||||||||||
| store1.set_item("key", "value") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| with SecureStorage(test_file2, self.password) as store2: | ||||||||||||||||||||||||||||||||||||
| store2.set_item("key", "value") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Read the first 16 bytes (salt) from each file | ||||||||||||||||||||||||||||||||||||
| with open(test_file1, "rb") as f: | ||||||||||||||||||||||||||||||||||||
| salt1 = f.read(16) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| with open(test_file2, "rb") as f: | ||||||||||||||||||||||||||||||||||||
| salt2 = f.read(16) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Salts should be different | ||||||||||||||||||||||||||||||||||||
| self.assertNotEqual(salt1, salt2) | ||||||||||||||||||||||||||||||||||||
| self.assertEqual(len(salt1), 16) | ||||||||||||||||||||||||||||||||||||
| self.assertEqual(len(salt2), 16) | ||||||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||||||
| if os.path.exists(test_file1): | ||||||||||||||||||||||||||||||||||||
| os.remove(test_file1) | ||||||||||||||||||||||||||||||||||||
| if os.path.exists(test_file2): | ||||||||||||||||||||||||||||||||||||
| os.remove(test_file2) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def test_same_password_different_salts_produces_different_keys(self): | ||||||||||||||||||||||||||||||||||||
| """Test that same password with different salts produces different encryption results.""" | ||||||||||||||||||||||||||||||||||||
| test_file1 = "test_key_1.bin" | ||||||||||||||||||||||||||||||||||||
| test_file2 = "test_key_2.bin" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||
| # Create two storage files with same password and same data | ||||||||||||||||||||||||||||||||||||
| with SecureStorage(test_file1, self.password) as store1: | ||||||||||||||||||||||||||||||||||||
| store1.set_item("test", "same_data") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| with SecureStorage(test_file2, self.password) as store2: | ||||||||||||||||||||||||||||||||||||
| store2.set_item("test", "same_data") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Read the encrypted content (after the salt) | ||||||||||||||||||||||||||||||||||||
| with open(test_file1, "rb") as f: | ||||||||||||||||||||||||||||||||||||
| f.seek(16) # Skip salt | ||||||||||||||||||||||||||||||||||||
| encrypted1 = f.read() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| with open(test_file2, "rb") as f: | ||||||||||||||||||||||||||||||||||||
| f.seek(16) # Skip salt | ||||||||||||||||||||||||||||||||||||
| encrypted2 = f.read() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Encrypted content should be different due to different salts | ||||||||||||||||||||||||||||||||||||
| self.assertNotEqual(encrypted1, encrypted2) | ||||||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||||||
| if os.path.exists(test_file1): | ||||||||||||||||||||||||||||||||||||
| os.remove(test_file1) | ||||||||||||||||||||||||||||||||||||
| if os.path.exists(test_file2): | ||||||||||||||||||||||||||||||||||||
| os.remove(test_file2) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+136
to
+164
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def test_salt_persists_across_save_load_cycles(self): | ||||||||||||||||||||||||||||||||||||
| """Test that salt is stored and correctly loaded from file.""" | ||||||||||||||||||||||||||||||||||||
| # Create and save data | ||||||||||||||||||||||||||||||||||||
| with SecureStorage(self.test_file, self.password) as store: | ||||||||||||||||||||||||||||||||||||
| store.set_item("persistent", "data") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Read the salt from file | ||||||||||||||||||||||||||||||||||||
| with open(self.test_file, "rb") as f: | ||||||||||||||||||||||||||||||||||||
| original_salt = f.read(16) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Load the file again and save without changes | ||||||||||||||||||||||||||||||||||||
| with SecureStorage(self.test_file, self.password) as store: | ||||||||||||||||||||||||||||||||||||
| # Access data to ensure it loaded correctly | ||||||||||||||||||||||||||||||||||||
| self.assertEqual(store.get_item("persistent"), "data") | ||||||||||||||||||||||||||||||||||||
| # Add new data | ||||||||||||||||||||||||||||||||||||
| store.set_item("more", "data") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Read the salt again - should be the same | ||||||||||||||||||||||||||||||||||||
| with open(self.test_file, "rb") as f: | ||||||||||||||||||||||||||||||||||||
| new_salt = f.read(16) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| self.assertEqual(original_salt, new_salt) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Verify data is still accessible | ||||||||||||||||||||||||||||||||||||
| with SecureStorage(self.test_file, self.password) as store: | ||||||||||||||||||||||||||||||||||||
| self.assertEqual(store.get_item("persistent"), "data") | ||||||||||||||||||||||||||||||||||||
| self.assertEqual(store.get_item("more"), "data") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def test_salt_is_random_not_hardcoded(self): | ||||||||||||||||||||||||||||||||||||
| """Test that salt is not the hardcoded all-zero value.""" | ||||||||||||||||||||||||||||||||||||
| with SecureStorage(self.test_file, self.password) as store: | ||||||||||||||||||||||||||||||||||||
| store.set_item("test", "value") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| with open(self.test_file, "rb") as f: | ||||||||||||||||||||||||||||||||||||
| salt = f.read(16) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Salt should NOT be all zeros | ||||||||||||||||||||||||||||||||||||
| hardcoded_salt = b"\x00" * 16 | ||||||||||||||||||||||||||||||||||||
| self.assertNotEqual(salt, hardcoded_salt) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Salt should have some randomness (very unlikely to have all same bytes) | ||||||||||||||||||||||||||||||||||||
| # This is a probabilistic test, but with 16 random bytes, | ||||||||||||||||||||||||||||||||||||
| # having all same is virtually impossible | ||||||||||||||||||||||||||||||||||||
| unique_bytes = len(set(salt)) | ||||||||||||||||||||||||||||||||||||
| self.assertGreater(unique_bytes, 1) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+202
to
+210
|
||||||||||||||||||||||||||||||||||||
| # Salt should NOT be all zeros | |
| hardcoded_salt = b"\x00" * 16 | |
| self.assertNotEqual(salt, hardcoded_salt) | |
| # Salt should have some randomness (very unlikely to have all same bytes) | |
| # This is a probabilistic test, but with 16 random bytes, | |
| # having all same is virtually impossible | |
| unique_bytes = len(set(salt)) | |
| self.assertGreater(unique_bytes, 1) | |
| # Salt should NOT be all zeros (regression test for hardcoded salt bug) | |
| hardcoded_salt = b"\x00" * 16 | |
| self.assertNotEqual(salt, hardcoded_salt) | |
| # NOTE: We intentionally avoid additional probabilistic assertions here | |
| # (for example, checking that all bytes are not identical), because such | |
| # checks can legitimately fail with extremely low probability when using | |
| # a secure random source. The non-zero check above is sufficient to verify | |
| # that the previous hardcoded-salt behavior has been fixed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic for determining whether a file is in the new format has an edge case issue. If an existing file has exactly 16 bytes or fewer (but not zero), those bytes will be interpreted as a salt, and then the code will attempt to decrypt an empty or very short blob. This could happen with a corrupted file or during a write interruption.
Consider explicitly checking the minimum file size. A valid new format file should be at least 16 (salt) + 12 (nonce) + 16 (tag) = 44 bytes. Files shorter than this cannot be valid new format files and should either generate a new salt or raise an error rather than trying to use the first 16 bytes as a salt.