From 2d7fbd1e0d47a1f0eb9f2bb1c81384e80e91098e Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Fri, 9 Jan 2026 23:38:51 -0500 Subject: [PATCH] Add QR code generation to CLI and API CLI generate command: - --qr to save RSA key as QR image - --qr-ascii to print ASCII QR code to terminal API endpoints: - POST /generate-key-qr - generate QR from key_pem - Supports png, jpg, and ascii output formats - Uses zstd compression by default - Added has_qrcode_write to /capabilities Core: - generate_qr_code() now supports jpg/jpeg output format - New generate_qr_ascii() for terminal display Co-Authored-By: Claude Opus 4.5 --- frontends/api/main.py | 80 +++++++++++++++++++++++++++++++++++++++ frontends/cli/main.py | 44 ++++++++++++++++++++- src/stegasoo/qr_utils.py | 82 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 5 deletions(-) diff --git a/frontends/api/main.py b/frontends/api/main.py index 55a75f3..c5b5742 100644 --- a/frontends/api/main.py +++ b/frontends/api/main.py @@ -74,13 +74,20 @@ from stegasoo.constants import ( try: from stegasoo.qr_utils import ( extract_key_from_qr, + generate_qr_ascii, + generate_qr_code, has_qr_read, + has_qr_write, ) HAS_QR_READ = has_qr_read() + HAS_QR_WRITE = has_qr_write() except ImportError: HAS_QR_READ = False + HAS_QR_WRITE = False extract_key_from_qr = None + generate_qr_code = None + generate_qr_ascii = None # ============================================================================ @@ -363,6 +370,7 @@ class StatusResponse(BaseModel): version: str has_argon2: bool has_qrcode_read: bool + has_qrcode_write: bool # v4.2.0: QR generation capability has_dct: bool max_payload_kb: int available_modes: list[str] @@ -378,6 +386,32 @@ class QrExtractResponse(BaseModel): error: str | None = None +class QrGenerateRequest(BaseModel): + """Request to generate QR code from RSA key.""" + + key_pem: str = Field(..., description="RSA private key in PEM format") + output_format: str = Field( + default="png", + description="Output format: 'png', 'jpg', or 'ascii'", + ) + compress: bool = Field( + default=True, + description="Compress key data with zstd (recommended for larger keys)", + ) + + +class QrGenerateResponse(BaseModel): + """Response containing generated QR code.""" + + success: bool + format: str | None = None + qr_data: str | None = Field( + default=None, + description="Base64-encoded image data (for png/jpg) or ASCII string", + ) + error: str | None = None + + class WillFitRequest(BaseModel): """Request to check if payload will fit.""" @@ -496,6 +530,7 @@ async def root(): version=__version__, has_argon2=has_argon2(), has_qrcode_read=HAS_QR_READ, + has_qrcode_write=HAS_QR_WRITE, has_dct=has_dct_support(), max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024, available_modes=available_modes, @@ -787,6 +822,51 @@ async def api_extract_key_from_qr( return QrExtractResponse(success=False, error=str(e)) +@app.post("/generate-key-qr", response_model=QrGenerateResponse) +async def api_generate_key_qr(request: QrGenerateRequest): + """ + Generate QR code from an RSA private key. + + Supports PNG, JPG, and ASCII output formats. + Uses zstd compression by default for better QR code density. + """ + if not HAS_QR_WRITE: + raise HTTPException(501, "QR code generation not available. Install qrcode library.") + + try: + fmt = request.output_format.lower() + + if fmt == "ascii": + ascii_qr = generate_qr_ascii( + request.key_pem, + compress=request.compress, + invert=False, + ) + return QrGenerateResponse(success=True, format="ascii", qr_data=ascii_qr) + + elif fmt in ("png", "jpg", "jpeg"): + import base64 + + qr_bytes = generate_qr_code( + request.key_pem, + compress=request.compress, + output_format=fmt, + ) + qr_b64 = base64.b64encode(qr_bytes).decode("ascii") + return QrGenerateResponse(success=True, format=fmt, qr_data=qr_b64) + + else: + return QrGenerateResponse( + success=False, + error=f"Unsupported format: {fmt}. Use 'png', 'jpg', or 'ascii'", + ) + + except ValueError as e: + return QrGenerateResponse(success=False, error=str(e)) + except Exception as e: + return QrGenerateResponse(success=False, error=f"QR generation failed: {e}") + + # ============================================================================ # ROUTES - GENERATE # ============================================================================ diff --git a/frontends/cli/main.py b/frontends/cli/main.py index 6a708df..a012bed 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -120,6 +120,7 @@ try: from stegasoo.qr_utils import ( # noqa: F401 can_fit_in_qr, extract_key_from_qr_file, + generate_qr_ascii, generate_qr_code, has_qr_read, has_qr_write, @@ -136,6 +137,9 @@ except ImportError: def has_qr_write() -> bool: return False + def generate_qr_ascii(*args, **kwargs): + raise RuntimeError("QR code generation not available") + # ============================================================================ # CLI SETUP @@ -247,7 +251,13 @@ def format_channel_status_line(quiet: bool = False) -> str | None: @click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)") @click.option("--password", "-p", help="Password for RSA key file") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") -def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): +@click.option( + "--qr", + type=click.Path(), + help="Save RSA key QR code to file (png/jpg, uses zstd compression)", +) +@click.option("--qr-ascii", is_flag=True, help="Print RSA key as ASCII QR code to terminal") +def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, qr, qr_ascii): """ Generate credentials for encoding/decoding. @@ -263,11 +273,16 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): stegasoo generate --words 5 stegasoo generate --rsa --rsa-bits 3072 stegasoo generate --rsa -o mykey.pem -p "secretpassword" + stegasoo generate --rsa --qr key.png + stegasoo generate --rsa --qr-ascii stegasoo generate --no-pin --rsa """ if not pin and not rsa: raise click.UsageError("Must enable at least one of --pin or --rsa") + if (qr or qr_ascii) and not rsa: + raise click.UsageError("QR output requires --rsa to generate an RSA key") + if output and not password: raise click.UsageError("--password is required when saving RSA key to file") @@ -334,6 +349,33 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): click.echo(creds.rsa_key_pem) click.echo() + # QR code output (v4.2.0) + if qr: + if not HAS_QR: + click.secho(" ⚠️ QR code library not available", fg="yellow") + else: + # Determine format from extension + qr_path = Path(qr) + ext = qr_path.suffix.lower() + fmt = "jpeg" if ext in (".jpg", ".jpeg") else "png" + + qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt) + qr_path.write_bytes(qr_bytes) + click.secho(f"─── RSA KEY QR CODE ───", fg="green") + click.secho(f" Saved to: {qr}", fg="bright_white") + click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow") + click.echo() + + if qr_ascii: + if not HAS_QR: + click.secho(" ⚠️ QR code library not available", fg="yellow") + else: + click.secho("─── RSA KEY QR CODE (ASCII) ───", fg="green") + click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow") + click.echo() + ascii_qr = generate_qr_ascii(creds.rsa_key_pem, compress=True, invert=True) + click.echo(ascii_qr) + click.secho("─── SECURITY ───", fg="green") click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)") if creds.pin: diff --git a/src/stegasoo/qr_utils.py b/src/stegasoo/qr_utils.py index 9ef06d3..c793917 100644 --- a/src/stegasoo/qr_utils.py +++ b/src/stegasoo/qr_utils.py @@ -252,17 +252,23 @@ def needs_compression(data: str) -> bool: 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: +def generate_qr_code( + data: str, + compress: bool = False, + error_correction=None, + output_format: str = "png", +) -> bytes: """ - Generate a QR code PNG from string data. + Generate a QR code image from string data. Args: data: String data to encode compress: Whether to compress data first error_correction: QR error correction level (default: auto) + output_format: Image format - 'png' or 'jpg'/'jpeg' Returns: - PNG image bytes + Image bytes in requested format Raises: RuntimeError: If qrcode library not available @@ -299,11 +305,79 @@ def generate_qr_code(data: str, compress: bool = False, error_correction=None) - img = qr.make_image(fill_color="black", back_color="white") buf = io.BytesIO() - img.save(buf, format="PNG") + fmt = output_format.lower() + if fmt in ("jpg", "jpeg"): + # Convert to RGB for JPEG (no alpha channel) + img = img.convert("RGB") + img.save(buf, format="JPEG", quality=95) + else: + img.save(buf, format="PNG") buf.seek(0) return buf.getvalue() +def generate_qr_ascii( + data: str, + compress: bool = False, + invert: bool = False, +) -> str: + """ + Generate an ASCII representation of a QR code. + + Uses Unicode block characters for compact display. + + Args: + data: String data to encode + compress: Whether to compress data first + invert: Invert colors (white on black for dark terminals) + + Returns: + ASCII string representation of QR code + + 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" + ) + + qr = qrcode.QRCode( + version=None, + error_correction=ERROR_CORRECT_L, + box_size=1, + border=2, + ) + qr.add_data(qr_data) + qr.make(fit=True) + + # Get the QR matrix + # Use print_ascii to a StringIO to capture output + import sys + from io import StringIO + + old_stdout = sys.stdout + sys.stdout = StringIO() + try: + qr.print_ascii(invert=invert) + ascii_qr = sys.stdout.getvalue() + finally: + sys.stdout = old_stdout + + return ascii_qr + + def read_qr_code(image_data: bytes) -> str | None: """ Read QR code from image data.