Skip to content

Instantly share code, notes, and snippets.

@nrichards
Created December 29, 2025 01:32
Show Gist options
  • Select an option

  • Save nrichards/4e28792b8963ff9f3006040386cf2a36 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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)
<!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>
@nrichards
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment