Created
December 29, 2025 01:32
-
-
Save nrichards/4e28792b8963ff9f3006040386cf2a36 to your computer and use it in GitHub Desktop.
TOTP QR Code Generator from Proton Authenticator JSON - HTML app, and Python script - Claude.AI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| TOTP QR Code Generator for YubiKey | |
| Reads a Proton Authenticator JSON export and generates QR code images | |
| for each TOTP entry. You can then scan these into Yubico Authenticator. | |
| Usage: | |
| python totp_qr_generator.py proton_export.json | |
| Requires: | |
| pip install qrcode pillow | |
| """ | |
| import json | |
| import sys | |
| import os | |
| from urllib.parse import urlparse, parse_qs, unquote | |
| try: | |
| import qrcode | |
| except ImportError: | |
| print("Error: qrcode library not installed") | |
| print("Run: pip install qrcode pillow") | |
| sys.exit(1) | |
| def parse_otpauth_uri(uri: str) -> dict: | |
| """Parse an otpauth:// URI into its components.""" | |
| parsed = urlparse(uri) | |
| params = parse_qs(parsed.query) | |
| # Extract account from path (after /totp/) | |
| path = unquote(parsed.path) | |
| account = path.lstrip('/').replace('totp/', '', 1) | |
| return { | |
| 'account': account, | |
| 'secret': params.get('secret', [''])[0], | |
| 'issuer': params.get('issuer', [''])[0], | |
| 'algorithm': params.get('algorithm', ['SHA1'])[0], | |
| 'digits': params.get('digits', ['6'])[0], | |
| 'period': params.get('period', ['30'])[0], | |
| } | |
| def sanitize_filename(name: str) -> str: | |
| """Make a string safe for use as a filename.""" | |
| # Replace problematic characters | |
| for char in ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '@']: | |
| name = name.replace(char, '_') | |
| return name[:50] # Limit length | |
| def generate_qr_codes(json_path: str, output_dir: str = 'qr_codes'): | |
| """Generate QR code images from a Proton Authenticator JSON export.""" | |
| # Read and parse JSON | |
| with open(json_path, 'r') as f: | |
| data = json.load(f) | |
| entries = data.get('entries', []) | |
| if not entries: | |
| print("No entries found in JSON file") | |
| return | |
| # Create output directory | |
| os.makedirs(output_dir, exist_ok=True) | |
| print(f"Found {len(entries)} entries") | |
| print(f"Generating QR codes in '{output_dir}/' ...\n") | |
| # Generate QR for each entry | |
| for i, entry in enumerate(entries, 1): | |
| uri = entry.get('content', {}).get('uri', '') | |
| name = entry.get('content', {}).get('name', f'entry_{i}') | |
| if not uri: | |
| print(f" [{i}] Skipping - no URI found") | |
| continue | |
| parsed = parse_otpauth_uri(uri) | |
| issuer = parsed['issuer'] or 'Unknown' | |
| account = parsed['account'] | |
| # Generate filename | |
| filename = f"{i:02d}_{sanitize_filename(issuer)}_{sanitize_filename(account)}.png" | |
| filepath = os.path.join(output_dir, filename) | |
| # Create QR code | |
| qr = qrcode.QRCode( | |
| version=1, | |
| error_correction=qrcode.constants.ERROR_CORRECT_M, | |
| box_size=10, | |
| border=4, | |
| ) | |
| qr.add_data(uri) | |
| qr.make(fit=True) | |
| img = qr.make_image(fill_color="black", back_color="white") | |
| img.save(filepath) | |
| print(f" [{i:2d}] {issuer}: {account}") | |
| print(f" → {filepath}") | |
| print(f"\n✓ Generated {len(entries)} QR codes in '{output_dir}/'") | |
| print("\nNext steps:") | |
| print(" 1. Open each image and scan with Yubico Authenticator") | |
| print(" 2. Remember to add each code to BOTH YubiKeys before moving to the next") | |
| print(" 3. Delete the QR images when done (they contain your secrets!)") | |
| def print_table(json_path: str): | |
| """Print a table of all entries for manual entry.""" | |
| with open(json_path, 'r') as f: | |
| data = json.load(f) | |
| entries = data.get('entries', []) | |
| print("\n" + "=" * 80) | |
| print("TOTP ENTRIES FOR MANUAL ENTRY") | |
| print("=" * 80) | |
| for i, entry in enumerate(entries, 1): | |
| uri = entry.get('content', {}).get('uri', '') | |
| parsed = parse_otpauth_uri(uri) | |
| print(f"\n[{i}] {parsed['issuer'] or 'Unknown'}") | |
| print(f" Account: {parsed['account']}") | |
| print(f" Secret: {parsed['secret']}") | |
| print(f" Algorithm: {parsed['algorithm']}") | |
| print(f" Digits: {parsed['digits']}") | |
| print(f" Period: {parsed['period']}s") | |
| print("-" * 40) | |
| if __name__ == '__main__': | |
| if len(sys.argv) < 2: | |
| print("Usage: python totp_qr_generator.py <proton_export.json> [--table]") | |
| print("\nOptions:") | |
| print(" --table Print entries as text table (for manual entry)") | |
| sys.exit(1) | |
| json_path = sys.argv[1] | |
| if not os.path.exists(json_path): | |
| print(f"Error: File not found: {json_path}") | |
| sys.exit(1) | |
| if '--table' in sys.argv: | |
| print_table(json_path) | |
| else: | |
| generate_qr_codes(json_path) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TOTP QR Code Generator</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| background: #1a1a1a; | |
| color: #e0e0e0; | |
| } | |
| h1 { | |
| color: #fff; | |
| margin-bottom: 10px; | |
| } | |
| .subtitle { | |
| color: #888; | |
| margin-bottom: 30px; | |
| } | |
| .drop-zone { | |
| border: 2px dashed #444; | |
| border-radius: 8px; | |
| padding: 40px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: border-color 0.2s, background 0.2s; | |
| margin-bottom: 20px; | |
| } | |
| .drop-zone:hover, .drop-zone.dragover { | |
| border-color: #666; | |
| background: #252525; | |
| } | |
| .drop-zone input { | |
| display: none; | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 10px; | |
| margin-bottom: 20px; | |
| flex-wrap: wrap; | |
| } | |
| button { | |
| background: #333; | |
| color: #e0e0e0; | |
| border: 1px solid #444; | |
| padding: 10px 20px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| transition: background 0.2s; | |
| } | |
| button:hover { | |
| background: #444; | |
| } | |
| button:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| button.primary { | |
| background: #2563eb; | |
| border-color: #2563eb; | |
| } | |
| button.primary:hover { | |
| background: #1d4ed8; | |
| } | |
| .view-toggle { | |
| margin-left: auto; | |
| } | |
| .entry { | |
| background: #252525; | |
| border-radius: 8px; | |
| padding: 20px; | |
| margin-bottom: 15px; | |
| display: flex; | |
| gap: 20px; | |
| align-items: center; | |
| } | |
| .entry.single-view { | |
| flex-direction: column; | |
| text-align: center; | |
| padding: 40px; | |
| } | |
| .entry.single-view .qr-container { | |
| order: -1; | |
| } | |
| .entry.single-view .info { | |
| text-align: center; | |
| } | |
| .entry-number { | |
| font-size: 12px; | |
| color: #666; | |
| margin-bottom: 5px; | |
| } | |
| .issuer { | |
| font-size: 18px; | |
| font-weight: 600; | |
| color: #fff; | |
| margin-bottom: 4px; | |
| } | |
| .account { | |
| color: #888; | |
| font-size: 14px; | |
| word-break: break-all; | |
| } | |
| .qr-container { | |
| background: #fff; | |
| padding: 10px; | |
| border-radius: 6px; | |
| flex-shrink: 0; | |
| } | |
| .single-view .qr-container { | |
| padding: 15px; | |
| } | |
| .info { | |
| flex-grow: 1; | |
| min-width: 0; | |
| } | |
| .details { | |
| margin-top: 10px; | |
| font-size: 12px; | |
| color: #666; | |
| } | |
| .secret { | |
| font-family: monospace; | |
| background: #1a1a1a; | |
| padding: 8px 12px; | |
| border-radius: 4px; | |
| margin-top: 10px; | |
| word-break: break-all; | |
| font-size: 13px; | |
| color: #aaa; | |
| } | |
| .navigation { | |
| display: flex; | |
| justify-content: center; | |
| gap: 15px; | |
| margin-bottom: 20px; | |
| } | |
| .counter { | |
| color: #888; | |
| font-size: 14px; | |
| align-self: center; | |
| } | |
| #entries { | |
| display: none; | |
| } | |
| .hidden { | |
| display: none !important; | |
| } | |
| .warning { | |
| background: #422; | |
| border: 1px solid #633; | |
| color: #faa; | |
| padding: 15px; | |
| border-radius: 6px; | |
| margin-bottom: 20px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>TOTP QR Code Generator</h1> | |
| <p class="subtitle">Import your Proton Authenticator JSON to generate scannable QR codes for YubiKey</p> | |
| <div class="warning"> | |
| ⚠️ This runs entirely in your browser. Your secrets never leave your machine. | |
| For extra safety, disconnect from the internet before loading your JSON. | |
| </div> | |
| <div class="drop-zone" id="dropZone"> | |
| <p>Drop your Proton Authenticator JSON file here, or click to browse</p> | |
| <input type="file" id="fileInput" accept=".json"> | |
| </div> | |
| <div id="controls" class="controls hidden"> | |
| <button id="prevBtn" disabled>← Previous</button> | |
| <button id="nextBtn">Next →</button> | |
| <span class="counter"><span id="current">1</span> / <span id="total">0</span></span> | |
| <button id="toggleView" class="view-toggle">Show All</button> | |
| </div> | |
| <div id="entries"></div> | |
| <script> | |
| let entries = []; | |
| let currentIndex = 0; | |
| let showAll = false; | |
| const dropZone = document.getElementById('dropZone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const entriesContainer = document.getElementById('entries'); | |
| const controls = document.getElementById('controls'); | |
| const prevBtn = document.getElementById('prevBtn'); | |
| const nextBtn = document.getElementById('nextBtn'); | |
| const toggleView = document.getElementById('toggleView'); | |
| const currentSpan = document.getElementById('current'); | |
| const totalSpan = document.getElementById('total'); | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.add('dragover'); | |
| }); | |
| dropZone.addEventListener('dragleave', () => { | |
| dropZone.classList.remove('dragover'); | |
| }); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('dragover'); | |
| const file = e.dataTransfer.files[0]; | |
| if (file) handleFile(file); | |
| }); | |
| fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (file) handleFile(file); | |
| }); | |
| function handleFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const data = JSON.parse(e.target.result); | |
| if (data.entries && Array.isArray(data.entries)) { | |
| entries = data.entries; | |
| renderEntries(); | |
| dropZone.classList.add('hidden'); | |
| controls.classList.remove('hidden'); | |
| entriesContainer.style.display = 'block'; | |
| totalSpan.textContent = entries.length; | |
| } else { | |
| alert('Invalid format: expected "entries" array'); | |
| } | |
| } catch (err) { | |
| alert('Failed to parse JSON: ' + err.message); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function parseUri(uri) { | |
| try { | |
| const url = new URL(uri); | |
| const params = new URLSearchParams(url.search); | |
| const path = decodeURIComponent(url.pathname.replace(/^\/\/totp\//, '')); | |
| return { | |
| account: path, | |
| secret: params.get('secret') || '', | |
| issuer: params.get('issuer') || '', | |
| algorithm: params.get('algorithm') || 'SHA1', | |
| digits: params.get('digits') || '6', | |
| period: params.get('period') || '30' | |
| }; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function renderEntries() { | |
| entriesContainer.innerHTML = ''; | |
| entries.forEach((entry, index) => { | |
| const parsed = parseUri(entry.content.uri); | |
| if (!parsed) return; | |
| const div = document.createElement('div'); | |
| div.className = 'entry' + (showAll ? '' : ' single-view'); | |
| div.id = `entry-${index}`; | |
| if (!showAll && index !== currentIndex) { | |
| div.classList.add('hidden'); | |
| } | |
| div.innerHTML = ` | |
| <div class="qr-container" id="qr-${index}"></div> | |
| <div class="info"> | |
| <div class="entry-number">#${index + 1}</div> | |
| <div class="issuer">${escapeHtml(parsed.issuer || entry.content.name)}</div> | |
| <div class="account">${escapeHtml(parsed.account)}</div> | |
| <div class="details"> | |
| ${parsed.algorithm} · ${parsed.digits} digits · ${parsed.period}s | |
| </div> | |
| <div class="secret">${escapeHtml(parsed.secret)}</div> | |
| </div> | |
| `; | |
| entriesContainer.appendChild(div); | |
| new QRCode(document.getElementById(`qr-${index}`), { | |
| text: entry.content.uri, | |
| width: showAll ? 120 : 200, | |
| height: showAll ? 120 : 200, | |
| colorDark: '#000000', | |
| colorLight: '#ffffff', | |
| correctLevel: QRCode.CorrectLevel.M | |
| }); | |
| }); | |
| updateNavigation(); | |
| } | |
| function updateNavigation() { | |
| currentSpan.textContent = currentIndex + 1; | |
| prevBtn.disabled = currentIndex === 0; | |
| nextBtn.disabled = currentIndex === entries.length - 1; | |
| } | |
| function showEntry(index) { | |
| document.querySelectorAll('.entry').forEach((el, i) => { | |
| el.classList.toggle('hidden', i !== index); | |
| }); | |
| currentIndex = index; | |
| updateNavigation(); | |
| } | |
| prevBtn.addEventListener('click', () => { | |
| if (currentIndex > 0) showEntry(currentIndex - 1); | |
| }); | |
| nextBtn.addEventListener('click', () => { | |
| if (currentIndex < entries.length - 1) showEntry(currentIndex + 1); | |
| }); | |
| toggleView.addEventListener('click', () => { | |
| showAll = !showAll; | |
| toggleView.textContent = showAll ? 'Show One' : 'Show All'; | |
| prevBtn.classList.toggle('hidden', showAll); | |
| nextBtn.classList.toggle('hidden', showAll); | |
| document.querySelector('.counter').classList.toggle('hidden', showAll); | |
| renderEntries(); | |
| }); | |
| document.addEventListener('keydown', (e) => { | |
| if (showAll) return; | |
| if (e.key === 'ArrowLeft' && currentIndex > 0) { | |
| showEntry(currentIndex - 1); | |
| } else if (e.key === 'ArrowRight' && currentIndex < entries.length - 1) { | |
| showEntry(currentIndex + 1); | |
| } | |
| }); | |
| function escapeHtml(str) { | |
| const div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| </script> | |
| </body> | |
| </html> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The HTML single-page app works for me.
I have not tested the Python script.
Use-case: Migrating ON TO Yubikey from Proton Authenticator JSON export.