Add QR code generation to CLI and API
CLI generate command: - --qr <file.png|jpg> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -74,13 +74,20 @@ from stegasoo.constants import (
|
|||||||
try:
|
try:
|
||||||
from stegasoo.qr_utils import (
|
from stegasoo.qr_utils import (
|
||||||
extract_key_from_qr,
|
extract_key_from_qr,
|
||||||
|
generate_qr_ascii,
|
||||||
|
generate_qr_code,
|
||||||
has_qr_read,
|
has_qr_read,
|
||||||
|
has_qr_write,
|
||||||
)
|
)
|
||||||
|
|
||||||
HAS_QR_READ = has_qr_read()
|
HAS_QR_READ = has_qr_read()
|
||||||
|
HAS_QR_WRITE = has_qr_write()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_QR_READ = False
|
HAS_QR_READ = False
|
||||||
|
HAS_QR_WRITE = False
|
||||||
extract_key_from_qr = None
|
extract_key_from_qr = None
|
||||||
|
generate_qr_code = None
|
||||||
|
generate_qr_ascii = None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -363,6 +370,7 @@ class StatusResponse(BaseModel):
|
|||||||
version: str
|
version: str
|
||||||
has_argon2: bool
|
has_argon2: bool
|
||||||
has_qrcode_read: bool
|
has_qrcode_read: bool
|
||||||
|
has_qrcode_write: bool # v4.2.0: QR generation capability
|
||||||
has_dct: bool
|
has_dct: bool
|
||||||
max_payload_kb: int
|
max_payload_kb: int
|
||||||
available_modes: list[str]
|
available_modes: list[str]
|
||||||
@@ -378,6 +386,32 @@ class QrExtractResponse(BaseModel):
|
|||||||
error: str | None = None
|
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):
|
class WillFitRequest(BaseModel):
|
||||||
"""Request to check if payload will fit."""
|
"""Request to check if payload will fit."""
|
||||||
|
|
||||||
@@ -496,6 +530,7 @@ async def root():
|
|||||||
version=__version__,
|
version=__version__,
|
||||||
has_argon2=has_argon2(),
|
has_argon2=has_argon2(),
|
||||||
has_qrcode_read=HAS_QR_READ,
|
has_qrcode_read=HAS_QR_READ,
|
||||||
|
has_qrcode_write=HAS_QR_WRITE,
|
||||||
has_dct=has_dct_support(),
|
has_dct=has_dct_support(),
|
||||||
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||||
available_modes=available_modes,
|
available_modes=available_modes,
|
||||||
@@ -787,6 +822,51 @@ async def api_extract_key_from_qr(
|
|||||||
return QrExtractResponse(success=False, error=str(e))
|
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
|
# ROUTES - GENERATE
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ try:
|
|||||||
from stegasoo.qr_utils import ( # noqa: F401
|
from stegasoo.qr_utils import ( # noqa: F401
|
||||||
can_fit_in_qr,
|
can_fit_in_qr,
|
||||||
extract_key_from_qr_file,
|
extract_key_from_qr_file,
|
||||||
|
generate_qr_ascii,
|
||||||
generate_qr_code,
|
generate_qr_code,
|
||||||
has_qr_read,
|
has_qr_read,
|
||||||
has_qr_write,
|
has_qr_write,
|
||||||
@@ -136,6 +137,9 @@ except ImportError:
|
|||||||
def has_qr_write() -> bool:
|
def has_qr_write() -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def generate_qr_ascii(*args, **kwargs):
|
||||||
|
raise RuntimeError("QR code generation not available")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CLI SETUP
|
# 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("--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("--password", "-p", help="Password for RSA key file")
|
||||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
@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.
|
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 --words 5
|
||||||
stegasoo generate --rsa --rsa-bits 3072
|
stegasoo generate --rsa --rsa-bits 3072
|
||||||
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
||||||
|
stegasoo generate --rsa --qr key.png
|
||||||
|
stegasoo generate --rsa --qr-ascii
|
||||||
stegasoo generate --no-pin --rsa
|
stegasoo generate --no-pin --rsa
|
||||||
"""
|
"""
|
||||||
if not pin and not rsa:
|
if not pin and not rsa:
|
||||||
raise click.UsageError("Must enable at least one of --pin or --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:
|
if output and not password:
|
||||||
raise click.UsageError("--password is required when saving RSA key to file")
|
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(creds.rsa_key_pem)
|
||||||
click.echo()
|
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.secho("─── SECURITY ───", fg="green")
|
||||||
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
||||||
if creds.pin:
|
if creds.pin:
|
||||||
|
|||||||
@@ -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)
|
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:
|
Args:
|
||||||
data: String data to encode
|
data: String data to encode
|
||||||
compress: Whether to compress data first
|
compress: Whether to compress data first
|
||||||
error_correction: QR error correction level (default: auto)
|
error_correction: QR error correction level (default: auto)
|
||||||
|
output_format: Image format - 'png' or 'jpg'/'jpeg'
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PNG image bytes
|
Image bytes in requested format
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If qrcode library not available
|
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")
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
|
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")
|
img.save(buf, format="PNG")
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf.getvalue()
|
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:
|
def read_qr_code(image_data: bytes) -> str | None:
|
||||||
"""
|
"""
|
||||||
Read QR code from image data.
|
Read QR code from image data.
|
||||||
|
|||||||
Reference in New Issue
Block a user