Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 51 additions & 10 deletions src/device_fingerprinting/secure_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Comment on lines +89 to +106

Copilot AI Jan 11, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.

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.

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation comment should be updated to describe the new file format structure. Currently it just says "Saves the data to the file with salt prepended" but it would be helpful to document the complete format: [16-byte salt][12-byte nonce][ciphertext][16-byte tag].

This documentation would help future maintainers understand the file format and is especially important for a security-critical component.

Suggested change
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 uses AI. Check for mistakes.
"""
if not self._encryptor:
self._setup_encryptor()

json_data = json.dumps(self.data).encode("utf-8")
encrypted_blob = self._encryptor.encrypt(json_data, self._key)

# Write salt + encrypted data
with open(self.file_path, "wb") as f:
f.write(self._salt)
f.write(encrypted_blob)

def load(self):
"""
Loads and decrypts the data from the file.
Loads and decrypts the data from the file, reading salt from file.
Supports both new format (with salt) and old format (without salt).
"""
if not self._encryptor:
self._setup_encryptor()

with open(self.file_path, "rb") as f:
if self._salt_loaded:
# New format: skip salt prefix
f.seek(16)
# Old format or file too short: read from beginning
encrypted_blob = f.read()

try:
decrypted_data = self._encryptor.decrypt(encrypted_blob, self._key)
self.data = json.loads(decrypted_data)
except (ValueError, InvalidTag) as e:
raise IOError(
f"Failed to decrypt or load data. Incorrect password or corrupted file. Reason: {e}"
)
# 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)

Copilot AI Jan 11, 2026

Copy link

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.

Suggested change
self._key = kdf.derive_key(self._password, self._salt)
self._key = kdf.derive_key(self._password, self._salt)
self._salt_loaded = True

Copilot uses AI. Check for mistakes.
except (ValueError, InvalidTag):
Comment on lines +146 to +161

Copilot AI Jan 11, 2026

Copy link

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.

Suggested change
# 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 uses AI. Check for mistakes.
raise IOError(
f"Failed to decrypt or load data. Incorrect password or corrupted file. Reason: {e}"
)
Comment on lines 145 to +164

Copilot AI Jan 11, 2026

Copy link

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})".

Copilot uses AI. Check for mistakes.
except json.JSONDecodeError:
raise IOError("File is corrupted and does not contain valid JSON.")

Expand Down
151 changes: 151 additions & 0 deletions tests/test_secure_storage.py
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):
Expand Down Expand Up @@ -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

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test assumes that different salts will always produce different encrypted outputs, but this is testing the wrong layer. The encrypted content differs primarily because AES-GCM uses a random nonce for each encryption (generated in AESGCMEncryptor.encrypt()), not because of different salts. Different salts produce different keys, but the nonce randomization is what makes the ciphertext different.

While this test will pass (because both salt and nonce contribute to differences), the test name and comment suggest it's testing salt-based differences specifically. Consider renaming to clarify that you're testing that the overall encryption produces different results, or add a more direct test that verifies different salts produce different keys by using the KDF directly.

Copilot uses AI. Check for mistakes.

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

Copilot AI Jan 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probabilistic test assertion could theoretically fail with valid random data. While extremely unlikely, it's possible (though with probability less than 1 in 10^38) for os.urandom(16) to return 16 identical bytes.

Consider either removing this assertion or replacing it with a comment explaining that this is a probabilistic check. The primary assertion on line 204 (checking it's not all zeros) is sufficient to verify the security fix. Alternatively, you could check that the salt is not equal to any known weak patterns rather than checking uniqueness of bytes.

Suggested change
# 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.

Copilot uses AI. Check for mistakes.

def test_backward_compatibility_with_old_format(self):
"""Test that files created with old format (hardcoded salt) can still be loaded."""
# Create an old format file (no salt prefix, using hardcoded salt)
old_format_file = "test_old_format.bin"

try:
# Simulate old format: encrypt data with hardcoded salt
old_salt = b"\x00" * 16
kdf = ScryptKDF()
old_key = kdf.derive_key(self.password, old_salt)
encryptor = AESGCMEncryptor()

test_data = {"legacy": "data", "version": "old"}
json_data = json.dumps(test_data).encode("utf-8")
encrypted_blob = encryptor.encrypt(json_data, old_key)

# Write old format file (no salt prefix)
with open(old_format_file, "wb") as f:
f.write(encrypted_blob)

# Now try to load with new SecureStorage (should handle backward compatibility)
with SecureStorage(old_format_file, self.password) as store:
self.assertEqual(store.get_item("legacy"), "data")
self.assertEqual(store.get_item("version"), "old")
# Add new data to trigger migration
store.set_item("migrated", "new_data")

# After migration, file should have new format with random salt
with open(old_format_file, "rb") as f:
new_salt = f.read(16)

# New salt should not be the hardcoded one
self.assertNotEqual(new_salt, old_salt)

# Verify data is still accessible after migration
with SecureStorage(old_format_file, self.password) as store:
self.assertEqual(store.get_item("legacy"), "data")
self.assertEqual(store.get_item("migrated"), "new_data")

finally:
if os.path.exists(old_format_file):
os.remove(old_format_file)


if __name__ == "__main__":
unittest.main()
Loading