Source code for pyaota.util.qrcrypto

"""
qrcrypto.py

QR code image generation and lightweight answer encryption for pyaota.

Encryption uses an HMAC-SHA256-based stream cipher (no external dependencies):
  - Keystream derived from HMAC(key, block_counter) — CTR-mode equivalent
  - 8-byte authentication tag appended to the ciphertext
  - Standard base64 encoding (LaTeX-safe: no underscores)

QR payload format:  <version_label>:<base64(ciphertext + 8-byte-mac)>
"""

import base64
import hashlib
import hmac
import secrets
from pathlib import Path

_MAC_LEN = 8  # bytes of HMAC-SHA256 used as authentication tag


[docs] def generate_key() -> str: """Return a new random 32-byte key as a lowercase hex string.""" return secrets.token_bytes(32).hex()
def _keystream(key: bytes, length: int) -> bytes: """Expand *key* into *length* pseudorandom bytes using HMAC-SHA256.""" stream = bytearray() counter = 0 while len(stream) < length: stream += hmac.new(key, counter.to_bytes(4, "big"), hashlib.sha256).digest() counter += 1 return bytes(stream[:length])
[docs] def encrypt_answers(key_hex: str, answers: list) -> str: """ Encrypt a list of single-character answer strings and return a URL-safe base64 payload suitable for embedding in a QR code. Answers must already be normalised to single characters (True/False → a/b, MCQ choices lower-cased). """ key = bytes.fromhex(key_hex) plaintext = "".join(answers).encode() stream = _keystream(key, len(plaintext)) ciphertext = bytes(a ^ b for a, b in zip(plaintext, stream)) tag = hmac.new(key, ciphertext, hashlib.sha256).digest()[:_MAC_LEN] return base64.b64encode(ciphertext + tag).decode()
[docs] def decrypt_answers(key_hex: str, payload: str) -> list: """ Decrypt a base64 payload produced by *encrypt_answers*. Returns a list of single-character answer strings. Raises ``ValueError`` if the payload is malformed or the MAC check fails. """ key = bytes.fromhex(key_hex) try: raw = base64.b64decode(payload) except Exception as exc: raise ValueError(f"QR payload is not valid base64: {exc}") from exc if len(raw) <= _MAC_LEN: raise ValueError("QR payload is too short to contain answers and MAC") ciphertext, tag = raw[:-_MAC_LEN], raw[-_MAC_LEN:] expected_tag = hmac.new(key, ciphertext, hashlib.sha256).digest()[:_MAC_LEN] if not hmac.compare_digest(tag, expected_tag): raise ValueError("QR payload MAC verification failed — wrong key or corrupted data") stream = _keystream(key, len(ciphertext)) plaintext = bytes(a ^ b for a, b in zip(ciphertext, stream)).decode() return list(plaintext)
# --------------------------------------------------------------------------- # QR image generation # ---------------------------------------------------------------------------
[docs] def generate_qr_png( content: str, output_path: Path, error_correction: str = "M", display_size_cm: float = None, scan_dpi: int = 300, ) -> None: """ Generate a QR code PNG for *content* and write it to *output_path*. *error_correction* selects the Reed-Solomon level: 'L' = ~7 %, 'M' = ~15 % (default), 'Q' = ~25 %, 'H' = ~30 % After writing the image is decoded with OpenCV at full resolution. If *display_size_cm* is given, a second verification is performed by downsampling to the expected scan resolution (``display_size_cm`` × ``scan_dpi`` / 2.54 pixels square) so that readability at print size is confirmed before the exam PDF is built. Requires the ``qrcode[pil]`` package (Pillow is already a dependency). """ import qrcode import qrcode.constants import cv2 import numpy as np from PIL import Image ec_map = { "L": qrcode.constants.ERROR_CORRECT_L, "M": qrcode.constants.ERROR_CORRECT_M, "Q": qrcode.constants.ERROR_CORRECT_Q, "H": qrcode.constants.ERROR_CORRECT_H, } ec = ec_map.get(error_correction.upper(), qrcode.constants.ERROR_CORRECT_M) qr = qrcode.QRCode(error_correction=ec, box_size=10, border=4) qr.add_data(content) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") img.save(str(output_path)) # Full-resolution round-trip check arr = np.array(img.convert("L")) decoded, _, _ = cv2.QRCodeDetector().detectAndDecode(arr) if decoded != content: raise RuntimeError( f"QR round-trip verification failed for '{output_path.name}': " f"encoded {content!r}, decoded {decoded!r}" ) # Scan-resolution check: downsample to what a scanner at scan_dpi will see if display_size_cm is not None: scan_px = round(display_size_cm * scan_dpi / 2.54) scan_px = max(scan_px, 1) pil_small = img.convert("L").resize((scan_px, scan_px), Image.LANCZOS) arr_small = np.array(pil_small) decoded_small, _, _ = cv2.QRCodeDetector().detectAndDecode(arr_small) if decoded_small != content: modules = qr.modules_count if hasattr(qr, "modules_count") else "?" px_per_module = scan_px / modules if modules != "?" else "?" raise RuntimeError( f"QR scan-resolution verification failed for '{output_path.name}': " f"at {scan_dpi} DPI / {display_size_cm:.1f} cm " f"({scan_px}×{scan_px} px, ~{px_per_module:.1f} px/module) " f"the code could not be decoded. " f"Consider increasing qr_size or using a lower error-correction level." )