Skip to content
4 changes: 2 additions & 2 deletions secure_qrcode/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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(
Expand Down
16 changes: 7 additions & 9 deletions secure_qrcode/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -28,30 +28,28 @@ 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:
raise DecryptError("Incorrect decryption, exc=Invalid Tag") from exc
except Exception as exc:
raise DecryptError(f"Incorrect decryption, exc={exc}") from exc

return plaintext.decode("utf-8")
return plaintext.decode()
5 changes: 2 additions & 3 deletions secure_qrcode/qrcode.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
from io import BytesIO

import qrcode
Expand All @@ -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,
Expand All @@ -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
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from fastapi.testclient import TestClient

from secure_qrcode.api import app
from secure_qrcode.models import EncryptedData


@pytest.fixture
Expand All @@ -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)
24 changes: 6 additions & 18 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -19,31 +19,19 @@ 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
response_data = response.json()
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())

Expand Down
12 changes: 3 additions & 9 deletions tests/test_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions tests/test_performance.py
Original file line number Diff line number Diff line change
@@ -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