Major release introducing the Live Platform architecture:
- SQLite database backend with async operations and migrations (E.1)
- AES-256-GCM credential encryption with HKDF key derivation (E.1)
- OAuth Social Login with GitHub and Google (E.2)
- Site management API with encrypted credential storage (E.3)
- Per-user MCP endpoints at /u/{user_id}/{alias}/mcp (E.3)
- User API keys with bcrypt hashing (E.3)
- Auto-generated config snippets for 5 MCP clients (E.3)
- Dashboard: dark/light mode, RBAC, My Sites, Connect page
- Active background health checks
- 452 tests (up from 303), all passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""Tests for Credential Encryption (core/encryption.py)."""
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
from cryptography.exceptions import InvalidTag
|
|
|
|
from core.encryption import (
|
|
CredentialEncryption,
|
|
get_credential_encryption,
|
|
initialize_credential_encryption,
|
|
)
|
|
|
|
# A valid base64-encoded 32-byte key for testing
|
|
TEST_KEY = base64.b64encode(os.urandom(32)).decode()
|
|
|
|
|
|
@pytest.fixture
|
|
def encryption():
|
|
"""Create a CredentialEncryption instance with a test key."""
|
|
return CredentialEncryption(encryption_key=TEST_KEY)
|
|
|
|
|
|
@pytest.fixture
|
|
def _clear_singleton():
|
|
"""Reset the global singleton before and after each test that uses it."""
|
|
import core.encryption as mod
|
|
|
|
original = mod._credential_encryption
|
|
mod._credential_encryption = None
|
|
yield
|
|
mod._credential_encryption = original
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestEncryptDecrypt:
|
|
"""Test basic encrypt/decrypt round-trips."""
|
|
|
|
def test_round_trip(self, encryption):
|
|
"""Encrypt then decrypt should return the original plaintext."""
|
|
plaintext = "Hello, World!"
|
|
site_id = "site_001"
|
|
|
|
cipherdata = encryption.encrypt(plaintext, site_id)
|
|
result = encryption.decrypt(cipherdata, site_id)
|
|
|
|
assert result == plaintext
|
|
|
|
def test_credentials_round_trip(self, encryption):
|
|
"""encrypt_credentials then decrypt_credentials should return the same dict."""
|
|
credentials = {
|
|
"username": "admin",
|
|
"app_password": "xxxx xxxx xxxx xxxx",
|
|
"api_key": "sk-1234567890",
|
|
}
|
|
site_id = "site_002"
|
|
|
|
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
|
result = encryption.decrypt_credentials(cipherdata, site_id)
|
|
|
|
assert result == credentials
|
|
|
|
def test_empty_credentials(self, encryption):
|
|
"""Empty dict should encrypt and decrypt correctly."""
|
|
credentials = {}
|
|
site_id = "site_empty"
|
|
|
|
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
|
result = encryption.decrypt_credentials(cipherdata, site_id)
|
|
|
|
assert result == {}
|
|
|
|
def test_unicode_credentials(self, encryption):
|
|
"""Non-ASCII characters in credentials should survive round-trip."""
|
|
credentials = {
|
|
"username": "\u06a9\u0627\u0631\u0628\u0631",
|
|
"password": "\u0631\u0645\u0632\u0639\u0628\u0648\u0631-\u0627\u06cc\u0645\u0646",
|
|
"display_name": "\u5f20\u4e09\u7684\u535a\u5ba2",
|
|
"notes": "Emoji \u2764\ufe0f test \U0001f680",
|
|
}
|
|
site_id = "site_unicode"
|
|
|
|
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
|
result = encryption.decrypt_credentials(cipherdata, site_id)
|
|
|
|
assert result == credentials
|
|
|
|
def test_large_payload(self, encryption):
|
|
"""A large JSON payload (~10KB) should encrypt and decrypt correctly."""
|
|
credentials = {f"key_{i}": f"value_{i}_{'x' * 100}" for i in range(85)}
|
|
json_size = len(json.dumps(credentials))
|
|
assert json_size > 10000, f"Payload should be >10KB, got {json_size}"
|
|
|
|
site_id = "site_large"
|
|
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
|
result = encryption.decrypt_credentials(cipherdata, site_id)
|
|
|
|
assert result == credentials
|
|
|
|
def test_empty_string_encrypt_decrypt(self, encryption):
|
|
"""Empty string should encrypt and decrypt correctly."""
|
|
cipherdata = encryption.encrypt("", "site_empty_str")
|
|
result = encryption.decrypt(cipherdata, "site_empty_str")
|
|
assert result == ""
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSiteIsolation:
|
|
"""Test that different site_ids produce different ciphertext and keys."""
|
|
|
|
def test_different_site_ids_produce_different_ciphertext(self, encryption):
|
|
"""Same plaintext encrypted with different site_ids should differ."""
|
|
plaintext = "same-secret-value"
|
|
cipher_a = encryption.encrypt(plaintext, "site_alpha")
|
|
cipher_b = encryption.encrypt(plaintext, "site_beta")
|
|
|
|
# Ciphertext should differ (different derived keys + different nonces)
|
|
assert cipher_a != cipher_b
|
|
|
|
def test_wrong_site_id_fails_to_decrypt(self, encryption):
|
|
"""Decrypting with the wrong site_id should raise InvalidTag."""
|
|
plaintext = "secret-data"
|
|
cipherdata = encryption.encrypt(plaintext, "correct_site")
|
|
|
|
with pytest.raises(InvalidTag):
|
|
encryption.decrypt(cipherdata, "wrong_site")
|
|
|
|
def test_wrong_site_id_fails_credentials(self, encryption):
|
|
"""decrypt_credentials with wrong site_id should raise InvalidTag."""
|
|
credentials = {"username": "admin", "password": "secret"}
|
|
cipherdata = encryption.encrypt_credentials(credentials, "correct_site")
|
|
|
|
with pytest.raises(InvalidTag):
|
|
encryption.decrypt_credentials(cipherdata, "wrong_site")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTampering:
|
|
"""Test that tampered ciphertext is detected."""
|
|
|
|
def test_tampered_ciphertext_fails(self, encryption):
|
|
"""Modifying a byte in the ciphertext should cause decryption to fail."""
|
|
plaintext = "sensitive-data"
|
|
site_id = "site_tamper"
|
|
|
|
cipherdata = encryption.encrypt(plaintext, site_id)
|
|
|
|
# Tamper with a byte in the ciphertext portion (after version + nonce)
|
|
tampered = bytearray(cipherdata)
|
|
tampered[16] ^= 0xFF # Flip bits in a ciphertext byte
|
|
tampered = bytes(tampered)
|
|
|
|
with pytest.raises(InvalidTag):
|
|
encryption.decrypt(tampered, site_id)
|
|
|
|
def test_tampered_nonce_fails(self, encryption):
|
|
"""Modifying the nonce should cause decryption to fail."""
|
|
plaintext = "sensitive-data"
|
|
site_id = "site_nonce_tamper"
|
|
|
|
cipherdata = encryption.encrypt(plaintext, site_id)
|
|
|
|
tampered = bytearray(cipherdata)
|
|
tampered[1] ^= 0xFF # Flip bits in the nonce (byte 1, after version byte)
|
|
tampered = bytes(tampered)
|
|
|
|
with pytest.raises(InvalidTag):
|
|
encryption.decrypt(tampered, site_id)
|
|
|
|
def test_truncated_cipherdata_fails(self, encryption):
|
|
"""Truncated cipherdata should raise ValueError."""
|
|
with pytest.raises(ValueError, match="too short"):
|
|
encryption.decrypt(b"short", "site_trunc")
|
|
|
|
def test_unsupported_version_fails(self, encryption):
|
|
"""Cipherdata with wrong version byte should raise ValueError."""
|
|
cipherdata = encryption.encrypt("test", "site_ver")
|
|
# Replace version byte with 0x99
|
|
bad_version = b"\x99" + cipherdata[1:]
|
|
with pytest.raises(ValueError, match="Unsupported encryption format version"):
|
|
encryption.decrypt(bad_version, "site_ver")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestKeyValidation:
|
|
"""Test encryption key validation."""
|
|
|
|
def test_missing_key_raises_valueerror(self, monkeypatch):
|
|
"""Missing ENCRYPTION_KEY should raise ValueError."""
|
|
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
|
|
|
with pytest.raises(ValueError, match="ENCRYPTION_KEY is required"):
|
|
CredentialEncryption()
|
|
|
|
def test_invalid_base64_raises_valueerror(self):
|
|
"""Non-base64 key should raise ValueError."""
|
|
with pytest.raises(ValueError, match="valid base64"):
|
|
CredentialEncryption(encryption_key="not-valid-base64!!!")
|
|
|
|
def test_wrong_length_key_raises_valueerror(self):
|
|
"""Key that decodes to wrong number of bytes should raise ValueError."""
|
|
short_key = base64.b64encode(b"too-short").decode()
|
|
with pytest.raises(ValueError, match="exactly 32 bytes"):
|
|
CredentialEncryption(encryption_key=short_key)
|
|
|
|
def test_16_byte_key_raises_valueerror(self):
|
|
"""A 16-byte key (AES-128) should be rejected; we require 32 bytes."""
|
|
key_16 = base64.b64encode(os.urandom(16)).decode()
|
|
with pytest.raises(ValueError, match="exactly 32 bytes"):
|
|
CredentialEncryption(encryption_key=key_16)
|
|
|
|
def test_valid_key_from_env(self, monkeypatch):
|
|
"""ENCRYPTION_KEY from env should be accepted when valid."""
|
|
key = base64.b64encode(os.urandom(32)).decode()
|
|
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
|
|
|
enc = CredentialEncryption()
|
|
# Should work without error
|
|
cipherdata = enc.encrypt("test", "site")
|
|
assert enc.decrypt(cipherdata, "site") == "test"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestKeyDerivation:
|
|
"""Test HKDF key derivation properties."""
|
|
|
|
def test_deterministic(self, encryption):
|
|
"""Same site_id should always produce the same derived key."""
|
|
key_a = encryption._derive_key("site_deterministic")
|
|
key_b = encryption._derive_key("site_deterministic")
|
|
assert key_a == key_b
|
|
|
|
def test_different_sites_different_keys(self, encryption):
|
|
"""Different site_ids should produce different derived keys."""
|
|
key_a = encryption._derive_key("site_one")
|
|
key_b = encryption._derive_key("site_two")
|
|
assert key_a != key_b
|
|
|
|
def test_derived_key_length(self, encryption):
|
|
"""Derived key should be 32 bytes."""
|
|
key = encryption._derive_key("any_site")
|
|
assert len(key) == 32
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNonceUniqueness:
|
|
"""Test that random nonces ensure ciphertext uniqueness."""
|
|
|
|
def test_same_plaintext_different_ciphertext(self, encryption):
|
|
"""Two encryptions of the same plaintext should produce different ciphertext."""
|
|
plaintext = "identical-value"
|
|
site_id = "site_nonce"
|
|
|
|
cipher_a = encryption.encrypt(plaintext, site_id)
|
|
cipher_b = encryption.encrypt(plaintext, site_id)
|
|
|
|
# Both should decrypt to the same value
|
|
assert encryption.decrypt(cipher_a, site_id) == plaintext
|
|
assert encryption.decrypt(cipher_b, site_id) == plaintext
|
|
|
|
# But the ciphertext should differ (different random nonces)
|
|
assert cipher_a != cipher_b
|
|
|
|
def test_nonce_is_12_bytes(self, encryption):
|
|
"""The nonce prefix should be exactly 12 bytes (after version byte)."""
|
|
cipherdata = encryption.encrypt("test", "site_nonce_len")
|
|
# Minimum size: 1 (version) + 12 (nonce) + 0 (empty plaintext encrypted) + 16 (tag)
|
|
assert len(cipherdata) >= 29
|
|
# Version byte is 0x01
|
|
assert cipherdata[0:1] == b"\x01"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSingleton:
|
|
"""Test the module-level singleton getter."""
|
|
|
|
@pytest.mark.usefixtures("_clear_singleton")
|
|
def test_get_credential_encryption_returns_instance(self, monkeypatch):
|
|
"""get_credential_encryption should return a CredentialEncryption instance."""
|
|
key = base64.b64encode(os.urandom(32)).decode()
|
|
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
|
|
|
enc = get_credential_encryption()
|
|
assert isinstance(enc, CredentialEncryption)
|
|
|
|
@pytest.mark.usefixtures("_clear_singleton")
|
|
def test_singleton_returns_same_instance(self, monkeypatch):
|
|
"""Calling get_credential_encryption twice should return the same object."""
|
|
key = base64.b64encode(os.urandom(32)).decode()
|
|
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
|
|
|
enc_a = get_credential_encryption()
|
|
enc_b = get_credential_encryption()
|
|
assert enc_a is enc_b
|
|
|
|
@pytest.mark.usefixtures("_clear_singleton")
|
|
def test_singleton_raises_without_key(self, monkeypatch):
|
|
"""get_credential_encryption should raise ValueError if ENCRYPTION_KEY is missing."""
|
|
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
|
|
|
with pytest.raises(ValueError, match="ENCRYPTION_KEY is required"):
|
|
get_credential_encryption()
|
|
|
|
@pytest.mark.usefixtures("_clear_singleton")
|
|
def test_initialize_with_explicit_key(self, monkeypatch):
|
|
"""initialize_credential_encryption should accept an explicit key."""
|
|
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
|
key = base64.b64encode(os.urandom(32)).decode()
|
|
|
|
enc = initialize_credential_encryption(key)
|
|
assert isinstance(enc, CredentialEncryption)
|
|
# Should be the same as get_credential_encryption now
|
|
assert get_credential_encryption() is enc
|