- Crypto backend: use native AES-GCM from `cryptography` when importable (~1.5 GiB/s), falling back to the pure-Python implementation with a byte-identical format and identical authentication failures. - E2E shard: decrypted E2E text is indexed in an in-memory SQLite database, persisted only as serialize() -> AES-256-GCM with an HKDF key bound to the project and memory-secret identity, authenticated header, fresh nonce, 0600 atomic writes. It loads only while the project is unlocked; tampered, foreign, wrong-key or rotated shards fail closed and rebuild. Search merges shard hits (search_source local_e2e) and reports locked/unavailable/slow states explicitly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
153 lines
6.2 KiB
Python
153 lines
6.2 KiB
Python
"""AES-256-GCM backend selection: known answers, interop and tamper detection.
|
|
|
|
Both the optional native backend (`cryptography`) and the stdlib-only
|
|
pure-Python backend run the same vectors through the public
|
|
aes_gcm_encrypt/aes_gcm_decrypt API used by node and bundle encryption.
|
|
"""
|
|
|
|
import importlib.util
|
|
import pathlib
|
|
|
|
import pytest
|
|
|
|
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py"
|
|
|
|
h = bytes.fromhex
|
|
GCM_KEY = h("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308")
|
|
GCM_IV = h("cafebabefacedbaddecaf888")
|
|
GCM_P = h(
|
|
"d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72"
|
|
"1c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b391aafd255"
|
|
)
|
|
GCM_C = h(
|
|
"522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa"
|
|
"8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662898015ad"
|
|
)
|
|
# The GCM specification (McGrew & Viega) test cases 13-16, AES-256.
|
|
KNOWN_ANSWERS = [
|
|
("tc13", bytes(32), bytes(12), b"", b"", b"", h("530f8afbc74536b9a963b4f1c4cb738b")),
|
|
("tc14", bytes(32), bytes(12), b"", bytes(16), h("cea7403d4d606b6e074ec5d3baf39d18"),
|
|
h("d0d1c8a799996bf0265b98b5d48ab919")),
|
|
("tc15", GCM_KEY, GCM_IV, b"", GCM_P, GCM_C, h("b094dac5d93471bdec1a502270e3cc6c")),
|
|
("tc16", GCM_KEY, GCM_IV, h("feedfacedeadbeeffeedfacedeadbeefabaddad2"), GCM_P[:60], GCM_C[:60],
|
|
h("76fc6ece0f4e1768cddf8853bb2d551b")),
|
|
]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def mod():
|
|
spec = importlib.util.spec_from_file_location("crypto_backend_test_worker", SCRIPT)
|
|
module = importlib.util.module_from_spec(spec)
|
|
import sys
|
|
sys.path.insert(0, str(SCRIPT.parent))
|
|
spec.loader.exec_module(module)
|
|
yield module
|
|
module.select_crypto_backend()
|
|
|
|
|
|
def backends(mod):
|
|
names = [mod.CRYPTO_BACKEND_PURE]
|
|
if mod.native_crypto_available():
|
|
names.append(mod.CRYPTO_BACKEND_NATIVE)
|
|
return names
|
|
|
|
|
|
def use(mod, name):
|
|
assert mod.select_crypto_backend(name) == name
|
|
return name
|
|
|
|
|
|
def test_native_backend_is_selected_when_cryptography_is_importable(mod, monkeypatch):
|
|
pytest.importorskip("cryptography")
|
|
monkeypatch.delenv("NAPSEER_CRYPTO_BACKEND", raising=False)
|
|
assert mod.select_crypto_backend() == mod.CRYPTO_BACKEND_NATIVE
|
|
monkeypatch.setenv("NAPSEER_CRYPTO_BACKEND", "pure_python")
|
|
assert mod.select_crypto_backend() == mod.CRYPTO_BACKEND_PURE
|
|
assert mod.crypto_backend_status()["crypto_backend"] == "pure_python"
|
|
|
|
|
|
def test_pure_backend_is_used_when_native_is_unavailable(mod, monkeypatch):
|
|
monkeypatch.setattr(mod, "_NativeAESGCM", None)
|
|
monkeypatch.delenv("NAPSEER_CRYPTO_BACKEND", raising=False)
|
|
try:
|
|
assert mod.select_crypto_backend() == mod.CRYPTO_BACKEND_PURE
|
|
status = mod.crypto_backend_status()
|
|
assert status["native_crypto_available"] is False
|
|
assert "cryptography" in status["crypto_backend_note"]
|
|
finally:
|
|
monkeypatch.undo()
|
|
mod.select_crypto_backend()
|
|
|
|
|
|
@pytest.mark.parametrize("case", KNOWN_ANSWERS, ids=[case[0] for case in KNOWN_ANSWERS])
|
|
def test_known_answer_vectors_through_every_backend(mod, case):
|
|
_name, key, iv, aad, plaintext, ciphertext, tag = case
|
|
for backend in backends(mod):
|
|
use(mod, backend)
|
|
assert mod.aes_gcm_encrypt(key, iv, aad, plaintext) == ciphertext + tag, backend
|
|
assert mod.aes_gcm_decrypt(key, iv, aad, ciphertext + tag) == plaintext, backend
|
|
mod.select_crypto_backend()
|
|
|
|
|
|
def test_cross_backend_interop(mod):
|
|
pytest.importorskip("cryptography")
|
|
key = bytes(range(32))
|
|
nonce = bytes(range(12))
|
|
aad = b"napseer interop aad"
|
|
for size in (0, 1, 15, 16, 17, 1000):
|
|
plaintext = bytes((index * 7) & 0xFF for index in range(size))
|
|
use(mod, mod.CRYPTO_BACKEND_PURE)
|
|
pure = mod.aes_gcm_encrypt(key, nonce, aad, plaintext)
|
|
use(mod, mod.CRYPTO_BACKEND_NATIVE)
|
|
native = mod.aes_gcm_encrypt(key, nonce, aad, plaintext)
|
|
assert pure == native
|
|
assert mod.aes_gcm_decrypt(key, nonce, aad, pure) == plaintext
|
|
use(mod, mod.CRYPTO_BACKEND_PURE)
|
|
assert mod.aes_gcm_decrypt(key, nonce, aad, native) == plaintext
|
|
mod.select_crypto_backend()
|
|
|
|
|
|
def test_tamper_detection_raises_the_same_error_in_every_backend(mod):
|
|
key = bytes(range(32))
|
|
nonce = bytes(12)
|
|
for backend in backends(mod):
|
|
use(mod, backend)
|
|
packed = mod.aes_gcm_encrypt(key, nonce, b"aad", b"synthetic payload")
|
|
tampered_body = bytes([packed[0] ^ 1]) + packed[1:]
|
|
tampered_tag = packed[:-1] + bytes([packed[-1] ^ 0x80])
|
|
wrong_key = bytes([key[0] ^ 1]) + key[1:]
|
|
for label, attempt in (
|
|
("body", lambda: mod.aes_gcm_decrypt(key, nonce, b"aad", tampered_body)),
|
|
("tag", lambda: mod.aes_gcm_decrypt(key, nonce, b"aad", tampered_tag)),
|
|
("aad", lambda: mod.aes_gcm_decrypt(key, nonce, b"other", packed)),
|
|
("key", lambda: mod.aes_gcm_decrypt(wrong_key, nonce, b"aad", packed)),
|
|
):
|
|
with pytest.raises(RuntimeError, match="^AES-GCM authentication failed$"):
|
|
attempt()
|
|
|
|
with pytest.raises(RuntimeError, match="missing tag"):
|
|
mod.aes_gcm_decrypt(key, nonce, b"aad", b"short")
|
|
with pytest.raises(RuntimeError, match="nonce must be 12 bytes"):
|
|
mod.aes_gcm_encrypt(key, bytes(16), b"", b"x")
|
|
with pytest.raises(RuntimeError, match="key must be 32 bytes"):
|
|
mod.aes_gcm_encrypt(bytes(16), nonce, b"", b"x")
|
|
mod.select_crypto_backend()
|
|
|
|
|
|
def test_hkdf_sha256_matches_rfc5869_vectors(mod):
|
|
# RFC 5869 Appendix A, test cases 1 and 3.
|
|
okm = mod.hkdf_sha256(b"\x0b" * 22, bytes(range(13)), bytes(range(0xF0, 0xFA)), 42)
|
|
assert okm.hex() == (
|
|
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
|
|
)
|
|
okm = mod.hkdf_sha256(b"\x0b" * 22, b"", b"", 42)
|
|
assert okm.hex() == (
|
|
"8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8"
|
|
)
|
|
|
|
|
|
def test_diagnostics_report_the_backend_without_key_material(mod):
|
|
status = mod.crypto_backend_status()
|
|
assert status["crypto_backend"] in {"native", "pure_python"}
|
|
assert set(status) <= {"crypto_backend", "native_crypto_available", "crypto_backend_note"}
|