claude-memory-mcp/tests/test_crypto.py
Viktor Barzin 0ed5e1e016
feat: standalone claude-memory-mcp with multi-user support and Vault integration
Extracted from private infra repo into standalone open-source project.

Three operating modes:
- Local: SQLite + FTS5 (zero dependencies)
- Server: PostgreSQL via HTTP API with multi-user auth
- Full: PostgreSQL + HashiCorp Vault for secret management

Features:
- MCP stdio server with 5 tools (store/recall/list/delete/secret_get)
- FastAPI HTTP API with multi-user Bearer token auth (API_KEYS JSON map)
- Regex-based credential detection with auto-redaction
- AES-256-GCM encryption fallback for non-Vault deployments
- Vault KV v2 client (stdlib urllib, K8s SA auto-auth)
- Per-user data isolation (all queries scoped by user_id)
- Secret migration endpoint for existing plain-text credentials
- Backward-compatible env var aliases (CLAUDE_MEMORY_API_URL)

Infrastructure:
- Docker + docker-compose (API + PostgreSQL + optional Vault)
- Woodpecker CI (test → build → push → kubectl deploy)
- GitHub Actions CI (Python 3.11/3.12/3.13) + Release (GHCR + PyPI)
- Helm chart + raw Kubernetes manifests

96 tests passing across 6 test files.
2026-03-14 09:42:05 +00:00

134 lines
4.3 KiB
Python

"""Tests for AES-256-GCM encryption module."""
import hashlib
import os
import pytest
from claude_memory.crypto import (
ENCRYPTION_KEY_ENV,
decrypt,
decrypt_b64,
encrypt,
encrypt_b64,
is_encryption_configured,
)
# A valid 32-byte hex key for testing
TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
TEST_PASSPHRASE = "my-test-passphrase"
@pytest.fixture
def hex_key_env(monkeypatch):
monkeypatch.setenv(ENCRYPTION_KEY_ENV, TEST_HEX_KEY)
@pytest.fixture
def passphrase_env(monkeypatch):
monkeypatch.setenv(ENCRYPTION_KEY_ENV, TEST_PASSPHRASE)
@pytest.fixture
def no_key_env(monkeypatch):
monkeypatch.delenv(ENCRYPTION_KEY_ENV, raising=False)
class TestEncryptionConfigured:
def test_configured_with_hex_key(self, hex_key_env):
assert is_encryption_configured() is True
def test_configured_with_passphrase(self, passphrase_env):
assert is_encryption_configured() is True
def test_not_configured_without_env(self, no_key_env):
assert is_encryption_configured() is False
class TestEncryptDecrypt:
def test_roundtrip_with_hex_key(self, hex_key_env):
plaintext = "Hello, this is a secret message!"
encrypted = encrypt(plaintext)
decrypted = decrypt(encrypted)
assert decrypted == plaintext
def test_roundtrip_with_passphrase(self, passphrase_env):
plaintext = "Another secret message with passphrase key"
encrypted = encrypt(plaintext)
decrypted = decrypt(encrypted)
assert decrypted == plaintext
def test_different_plaintexts_produce_different_ciphertexts(self, hex_key_env):
ct1 = encrypt("message one")
ct2 = encrypt("message two")
assert ct1 != ct2
def test_same_plaintext_produces_different_ciphertexts(self, hex_key_env):
"""Due to random nonce, encrypting the same text twice gives different results."""
ct1 = encrypt("same message")
ct2 = encrypt("same message")
assert ct1 != ct2
def test_missing_key_raises_on_encrypt(self, no_key_env):
with pytest.raises(RuntimeError, match=ENCRYPTION_KEY_ENV):
encrypt("test")
def test_missing_key_raises_on_decrypt(self, no_key_env):
with pytest.raises(RuntimeError, match=ENCRYPTION_KEY_ENV):
decrypt(b"\x00" * 28)
def test_decrypt_with_wrong_key_fails(self, hex_key_env, monkeypatch):
plaintext = "secret data"
encrypted = encrypt(plaintext)
# Change to a different key
monkeypatch.setenv(ENCRYPTION_KEY_ENV, "ff" * 32)
with pytest.raises(Exception):
decrypt(encrypted)
def test_encrypted_data_format(self, hex_key_env):
"""Encrypted data should be at least 12 (nonce) + 16 (tag) bytes."""
encrypted = encrypt("x")
assert len(encrypted) >= 28 # 12 nonce + 1 plaintext + 16 tag = 29 minimum
def test_unicode_roundtrip(self, hex_key_env):
plaintext = "Unicode test: cafe\u0301, \u00fc\u00f6\u00e4, \U0001f512"
decrypted = decrypt(encrypt(plaintext))
assert decrypted == plaintext
class TestBase64Variants:
def test_b64_roundtrip(self, hex_key_env):
plaintext = "base64 test message"
encrypted_b64 = encrypt_b64(plaintext)
assert isinstance(encrypted_b64, str)
decrypted = decrypt_b64(encrypted_b64)
assert decrypted == plaintext
def test_b64_output_is_valid_base64(self, hex_key_env):
import base64
encrypted_b64 = encrypt_b64("test")
# Should not raise
decoded = base64.b64decode(encrypted_b64)
assert len(decoded) >= 28
class TestKeyDerivation:
def test_hex_key_used_directly(self, hex_key_env):
"""A valid 64-char hex string should be used as-is (32 bytes)."""
ct = encrypt("test")
pt = decrypt(ct)
assert pt == "test"
def test_passphrase_derived_via_sha256(self, passphrase_env):
"""Non-hex strings should be derived via SHA-256."""
ct = encrypt("test")
pt = decrypt(ct)
assert pt == "test"
def test_short_hex_treated_as_passphrase(self, monkeypatch):
"""Hex string that's not exactly 32 bytes should be treated as passphrase."""
monkeypatch.setenv(ENCRYPTION_KEY_ENV, "abcd1234")
ct = encrypt("test")
pt = decrypt(ct)
assert pt == "test"