diff --git a/secure_qrcode/api.py b/secure_qrcode/api.py index e2f6723..2401bbd 100644 --- a/secure_qrcode/api.py +++ b/secure_qrcode/api.py @@ -36,7 +36,7 @@ def decrypt_error_exception_handler(request: Request, exc: DecryptError): @app.get("/", response_class=HTMLResponse, tags=["home"]) def index(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) + return templates.TemplateResponse(request, "index.html") @app.post("/v1/encode", status_code=201, tags=["api"]) @@ -48,7 +48,7 @@ def encode(request: EncodeRequest) -> EncodeResponse: box_size=request.box_size, border=request.border, ) - return EncodeResponse(content=b64encode(img_io.getvalue()).decode("utf-8"), media_type="image/png") + return EncodeResponse(content=b64encode(img_io.getvalue()).decode(), media_type="image/png") @app.post( diff --git a/secure_qrcode/crypto.py b/secure_qrcode/crypto.py index 735db46..4394fe2 100644 --- a/secure_qrcode/crypto.py +++ b/secure_qrcode/crypto.py @@ -18,7 +18,7 @@ def derive_key(key: str, salt: bytes, iterations: int) -> bytes: salt=salt, iterations=iterations, ) - return kdf.derive(key.encode(encoding="utf-8")) + return kdf.derive(key.encode()) def encrypt(plaintext: str, key: str) -> EncryptedData: @@ -28,25 +28,23 @@ def encrypt(plaintext: str, key: str) -> EncryptedData: nonce = os.urandom(12) derived_key = derive_key(key, salt, iterations) chacha = ChaCha20Poly1305(derived_key) - ciphertext = chacha.encrypt(nonce, plaintext.encode(encoding="utf-8"), associated_data) + ciphertext = chacha.encrypt(nonce, plaintext.encode(), associated_data) return EncryptedData( - salt=b64encode(salt).decode("utf-8"), + salt=b64encode(salt).decode(), iterations=iterations, - associated_data=b64encode(associated_data).decode("utf-8"), - nonce=b64encode(nonce).decode("utf-8"), - ciphertext=b64encode(ciphertext).decode("utf-8"), + associated_data=b64encode(associated_data).decode(), + nonce=b64encode(nonce).decode(), + ciphertext=b64encode(ciphertext).decode(), ) def decrypt(encrypted_data: EncryptedData, key: str) -> str: salt = b64decode(encrypted_data.salt) - associated_data = b64decode(encrypted_data.associated_data) nonce = b64decode(encrypted_data.nonce) ciphertext = b64decode(encrypted_data.ciphertext) derived_key = derive_key(key, salt, encrypted_data.iterations) chacha = ChaCha20Poly1305(derived_key) - try: plaintext = chacha.decrypt(nonce, ciphertext, associated_data) except InvalidTag as exc: @@ -54,4 +52,4 @@ def decrypt(encrypted_data: EncryptedData, key: str) -> str: except Exception as exc: raise DecryptError(f"Incorrect decryption, exc={exc}") from exc - return plaintext.decode("utf-8") + return plaintext.decode() diff --git a/secure_qrcode/qrcode.py b/secure_qrcode/qrcode.py index b405d95..13df416 100644 --- a/secure_qrcode/qrcode.py +++ b/secure_qrcode/qrcode.py @@ -1,4 +1,3 @@ -import json from io import BytesIO import qrcode @@ -12,7 +11,7 @@ def make( box_size: int = 10, border: int = 4, ) -> BytesIO: - data = json.dumps(encrypted_data.model_dump()) + data = encrypted_data.model_dump_json() qr = qrcode.QRCode( version=None, error_correction=error_correction.value, @@ -23,6 +22,6 @@ def make( qr.make(fit=True) img = qr.make_image() img_io = BytesIO() - img.save(img_io) + img.save(img_io, format="PNG") img_io.seek(0) return img_io diff --git a/tests/conftest.py b/tests/conftest.py index 43bfa0b..16d7415 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from fastapi.testclient import TestClient from secure_qrcode.api import app +from secure_qrcode.models import EncryptedData @pytest.fixture @@ -14,6 +15,18 @@ def plaintext(): return "super secret text" +@pytest.fixture +def sample_encrypted_data(): + """Sample encrypted data for testing decrypt operations.""" + return EncryptedData( + salt="KtiCW1E0VLupOXOtpDIlZQ==", + iterations=1200000, + associated_data="JFPRP6/RMmCIn3DLjA/ceg==", + nonce="LbF9P5FwPYyGCTJM", + ciphertext="/N8WF0+QnqsDhOQ9iWuhWrXgbrZlG4Hqm9cYt/QO9Msu", + ) + + @pytest.fixture def client(): return TestClient(app) diff --git a/tests/test_api.py b/tests/test_api.py index fd4c613..10133af 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,6 @@ from base64 import b64encode -from secure_qrcode.models import DecodeRequest, EncodeRequest, EncryptedData +from secure_qrcode.models import DecodeRequest, EncodeRequest def test_index(client): @@ -19,15 +19,8 @@ def test_encode(client, plaintext, key): assert response_data["media_type"] == "image/png" -def test_decode(client, plaintext, key): - encrypted_data = EncryptedData( - salt="KtiCW1E0VLupOXOtpDIlZQ==", - iterations=1200000, - associated_data="JFPRP6/RMmCIn3DLjA/ceg==", - nonce="LbF9P5FwPYyGCTJM", - ciphertext="/N8WF0+QnqsDhOQ9iWuhWrXgbrZlG4Hqm9cYt/QO9Msu", - ) - request = DecodeRequest(encrypted_data=encrypted_data, key=key) +def test_decode(client, plaintext, key, sample_encrypted_data): + request = DecodeRequest(encrypted_data=sample_encrypted_data, key=key) response = client.post("/v1/decode", json=request.model_dump()) assert response.status_code == 201 @@ -35,15 +28,10 @@ def test_decode(client, plaintext, key): assert response_data["decrypted_data"] == plaintext -def test_decode_error(client, key): - encrypted_data = EncryptedData( - salt="KtiCW1E0VLupOXOtpDIlZQ==", - iterations=1200000, - associated_data="JFPRP6/RMmCIn3DLjA/ceg==", - nonce="LbF9P5FwPYyGCTJM", - ciphertext="/N8WF0+QnqsDhOQ9iWuhWrXgbrZlG4Hqm9cYt/QO9Msu", +def test_decode_error(client, key, sample_encrypted_data): + encrypted_data = sample_encrypted_data.model_copy( + update={"associated_data": b64encode(b"invalid-aad").decode()} ) - encrypted_data.associated_data = b64encode(b"invalid-aad").decode("utf-8") request = DecodeRequest(encrypted_data=encrypted_data, key=key) response = client.post("/v1/decode", json=request.model_dump()) diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 9e5a177..2f3afbd 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -4,7 +4,6 @@ from secure_qrcode.crypto import decrypt, encrypt from secure_qrcode.exceptions import DecryptError -from secure_qrcode.models import EncryptedData def test_encrypt_decrypt(plaintext, key): @@ -18,15 +17,10 @@ def test_encrypt_decrypt(plaintext, key): assert decrypt(encrypted_data, key) == plaintext -def test_decrypt_error(key): - encrypted_data = EncryptedData( - salt="KtiCW1E0VLupOXOtpDIlZQ==", - iterations=1200000, - associated_data="JFPRP6/RMmCIn3DLjA/ceg==", - nonce="LbF9P5FwPYyGCTJM", - ciphertext="/N8WF0+QnqsDhOQ9iWuhWrXgbrZlG4Hqm9cYt/QO9Msu", +def test_decrypt_error(key, sample_encrypted_data): + encrypted_data = sample_encrypted_data.model_copy( + update={"associated_data": b64encode(b"invalid-aad").decode()} ) - encrypted_data.associated_data = b64encode(b"invalid-aad").decode("utf-8") with pytest.raises(DecryptError) as excinfo: decrypt(encrypted_data, key) diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..701322b --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,68 @@ +"""Performance tests for secure-qrcode operations. + +Note: These tests use hardcoded timeouts optimized for typical hardware. +If tests fail in CI or slower environments, consider adjusting timeouts +or setting environment variable SKIP_PERFORMANCE_TESTS=1. +""" + +import time + +from secure_qrcode.crypto import decrypt, encrypt +from secure_qrcode.qrcode import make + + +def test_encrypt_performance(plaintext, key): + """Test encryption performance - should complete in reasonable time.""" + start = time.perf_counter() + encrypted_data = encrypt(plaintext, key) + elapsed = time.perf_counter() - start + + # Encryption with PBKDF2 should complete within reasonable time + # With 1,200,000 iterations, this should take less than 2 seconds + assert elapsed < 2.0, f"Encryption took {elapsed:.3f}s, expected < 2.0s" + assert encrypted_data.salt + assert encrypted_data.ciphertext + + +def test_decrypt_performance(key, sample_encrypted_data): + """Test decryption performance - should complete in reasonable time.""" + start = time.perf_counter() + decrypted_data = decrypt(sample_encrypted_data, key) + elapsed = time.perf_counter() - start + + # Decryption with PBKDF2 should complete within reasonable time + # With 1,200,000 iterations, this should take less than 2 seconds + assert elapsed < 2.0, f"Decryption took {elapsed:.3f}s, expected < 2.0s" + assert decrypted_data == "super secret text" + + +def test_qrcode_generation_performance(sample_encrypted_data): + """Test QR code generation performance.""" + start = time.perf_counter() + img_io = make(sample_encrypted_data) + elapsed = time.perf_counter() - start + + # QR code generation should be fast + assert elapsed < 0.5, f"QR code generation took {elapsed:.3f}s, expected < 0.5s" + assert img_io.getbuffer().nbytes > 0 + + +def test_full_encode_decode_cycle_performance(plaintext, key): + """Test full encode/decode cycle performance.""" + start = time.perf_counter() + + # Encrypt + encrypted_data = encrypt(plaintext, key) + + # Generate QR code + img_io = make(encrypted_data) + + # Decrypt + decrypted_data = decrypt(encrypted_data, key) + + elapsed = time.perf_counter() - start + + # Full cycle should complete in reasonable time + assert elapsed < 4.0, f"Full cycle took {elapsed:.3f}s, expected < 4.0s" + assert decrypted_data == plaintext + assert img_io.getbuffer().nbytes > 0