QR functionality (sorta).

This commit is contained in:
Aaron D. Lee
2025-12-28 12:04:15 -05:00
parent 5bd49cb581
commit 653de8cbaa
17 changed files with 1677 additions and 599 deletions

View File

@@ -149,6 +149,29 @@ from .utils import (
format_file_size,
)
# QR Code utilities (optional, depends on qrcode and pyzbar)
try:
from .qr_utils import (
generate_qr_code,
read_qr_code,
read_qr_code_from_file,
extract_key_from_qr,
extract_key_from_qr_file,
compress_data,
decompress_data,
auto_decompress,
normalize_pem,
is_compressed,
can_fit_in_qr,
needs_compression,
has_qr_read,
has_qr_write,
has_qr_support,
)
HAS_QR_UTILS = True
except ImportError:
HAS_QR_UTILS = False
from datetime import date
from pathlib import Path
from typing import Optional, Union

336
src/stegasoo/qr_utils.py Normal file
View File

@@ -0,0 +1,336 @@
"""
Stegasoo QR Code Utilities
Functions for generating and reading QR codes containing RSA keys.
Supports automatic compression for large keys.
"""
import io
import zlib
import base64
from typing import Optional, Tuple
from PIL import Image
# QR code generation
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M
HAS_QRCODE_WRITE = True
except ImportError:
HAS_QRCODE_WRITE = False
# QR code reading
try:
from pyzbar.pyzbar import decode as pyzbar_decode
from pyzbar.pyzbar import ZBarSymbol
HAS_QRCODE_READ = True
except ImportError:
HAS_QRCODE_READ = False
# Constants
COMPRESSION_PREFIX = "STEGASOO-Z:"
QR_MAX_BINARY = 2900 # Safe limit for binary data in QR
def compress_data(data: str) -> str:
"""
Compress string data for QR code storage.
Args:
data: String to compress
Returns:
Compressed string with STEGASOO-Z: prefix
"""
compressed = zlib.compress(data.encode('utf-8'), level=9)
encoded = base64.b64encode(compressed).decode('ascii')
return COMPRESSION_PREFIX + encoded
def decompress_data(data: str) -> str:
"""
Decompress data from QR code.
Args:
data: Compressed string with STEGASOO-Z: prefix
Returns:
Original uncompressed string
Raises:
ValueError: If data is not valid compressed format
"""
if not data.startswith(COMPRESSION_PREFIX):
raise ValueError("Data is not in compressed format")
encoded = data[len(COMPRESSION_PREFIX):]
compressed = base64.b64decode(encoded)
return zlib.decompress(compressed).decode('utf-8')
def normalize_pem(pem_data: str) -> str:
"""
Normalize PEM data to ensure proper formatting.
Fixes common issues:
- Inconsistent line endings
- Missing newlines after header/before footer
- Extra whitespace
Args:
pem_data: Raw PEM string
Returns:
Properly formatted PEM string
"""
import re
# Normalize line endings
pem_data = pem_data.replace('\r\n', '\n').replace('\r', '\n')
# Remove any leading/trailing whitespace
pem_data = pem_data.strip()
# Extract header, content, and footer using regex
# Match patterns like -----BEGIN RSA PRIVATE KEY----- or -----BEGIN PRIVATE KEY-----
pattern = r'(-----BEGIN [^-]+-----)(.*?)(-----END [^-]+-----)'
match = re.search(pattern, pem_data, re.DOTALL)
if not match:
return pem_data # Return as-is if not recognized
header = match.group(1)
content = match.group(2)
footer = match.group(3)
# Clean up the base64 content
# Remove all whitespace and rejoin with proper 64-char lines
content_clean = ''.join(content.split())
# Split into 64-character lines (PEM standard)
lines = [content_clean[i:i+64] for i in range(0, len(content_clean), 64)]
# Reconstruct PEM
return header + '\n' + '\n'.join(lines) + '\n' + footer + '\n'
def is_compressed(data: str) -> bool:
"""Check if data has compression prefix."""
return data.startswith(COMPRESSION_PREFIX)
def auto_decompress(data: str) -> str:
"""
Automatically decompress data if compressed, otherwise return as-is.
Args:
data: Possibly compressed string
Returns:
Decompressed string
"""
if is_compressed(data):
return decompress_data(data)
return data
def get_compressed_size(data: str) -> int:
"""Get size of data after compression (including prefix)."""
return len(compress_data(data))
def can_fit_in_qr(data: str, compress: bool = False) -> bool:
"""
Check if data can fit in a QR code.
Args:
data: String data
compress: Whether compression will be used
Returns:
True if data fits
"""
if compress:
size = get_compressed_size(data)
else:
size = len(data.encode('utf-8'))
return size <= QR_MAX_BINARY
def needs_compression(data: str) -> bool:
"""Check if data needs compression to fit in QR code."""
return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True)
def generate_qr_code(
data: str,
compress: bool = False,
error_correction=None
) -> bytes:
"""
Generate a QR code PNG from string data.
Args:
data: String data to encode
compress: Whether to compress data first
error_correction: QR error correction level (default: auto)
Returns:
PNG image bytes
Raises:
RuntimeError: If qrcode library not available
ValueError: If data too large for QR code
"""
if not HAS_QRCODE_WRITE:
raise RuntimeError("qrcode library not installed. Run: pip install qrcode[pil]")
qr_data = data
# Compress if requested
if compress:
qr_data = compress_data(data)
# Check size
if len(qr_data.encode('utf-8')) > QR_MAX_BINARY:
raise ValueError(
f"Data too large for QR code ({len(qr_data)} bytes). "
f"Maximum: {QR_MAX_BINARY} bytes"
)
# Use lower error correction for larger data
if error_correction is None:
error_correction = ERROR_CORRECT_L if len(qr_data) > 1000 else ERROR_CORRECT_M
qr = qrcode.QRCode(
version=None,
error_correction=error_correction,
box_size=10,
border=4,
)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf.getvalue()
def read_qr_code(image_data: bytes) -> Optional[str]:
"""
Read QR code from image data.
Args:
image_data: Image bytes (PNG, JPG, etc.)
Returns:
Decoded string, or None if no QR code found
Raises:
RuntimeError: If pyzbar library not available
"""
if not HAS_QRCODE_READ:
raise RuntimeError(
"pyzbar library not installed. Run: pip install pyzbar\n"
"Also requires system library: sudo apt-get install libzbar0"
)
try:
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if necessary (pyzbar works best with RGB/grayscale)
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
# Decode QR codes
decoded = pyzbar_decode(img, symbols=[ZBarSymbol.QRCODE])
if not decoded:
return None
# Return first QR code found
return decoded[0].data.decode('utf-8')
except Exception:
return None
def read_qr_code_from_file(filepath: str) -> Optional[str]:
"""
Read QR code from image file.
Args:
filepath: Path to image file
Returns:
Decoded string, or None if no QR code found
"""
with open(filepath, 'rb') as f:
return read_qr_code(f.read())
def extract_key_from_qr(image_data: bytes) -> Optional[str]:
"""
Extract RSA key from QR code image, auto-decompressing if needed.
Args:
image_data: Image bytes containing QR code
Returns:
PEM-encoded RSA key string, or None if not found/invalid
"""
qr_data = read_qr_code(image_data)
if not qr_data:
return None
# Auto-decompress if needed
try:
if is_compressed(qr_data):
key_pem = decompress_data(qr_data)
else:
key_pem = qr_data
except Exception:
key_pem = qr_data
# Validate it looks like a PEM key
if '-----BEGIN' in key_pem and '-----END' in key_pem:
# Normalize PEM format to fix potential line ending issues
key_pem = normalize_pem(key_pem)
return key_pem
return None
def extract_key_from_qr_file(filepath: str) -> Optional[str]:
"""
Extract RSA key from QR code image file.
Args:
filepath: Path to image file containing QR code
Returns:
PEM-encoded RSA key string, or None if not found/invalid
"""
with open(filepath, 'rb') as f:
return extract_key_from_qr(f.read())
def has_qr_write() -> bool:
"""Check if QR code writing is available."""
return HAS_QRCODE_WRITE
def has_qr_read() -> bool:
"""Check if QR code reading is available."""
return HAS_QRCODE_READ
def has_qr_support() -> bool:
"""Check if full QR code support is available."""
return HAS_QRCODE_WRITE and HAS_QRCODE_READ