diff --git a/bun.lock b/bun.lock
index 24cbf50..ebcf2f2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "seedpgp-web",
"dependencies": {
+ "html5-qrcode": "^2.3.8",
"lucide-react": "^0.462.0",
"openpgp": "^6.3.0",
"qrcode": "^1.5.4",
@@ -195,7 +196,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
- "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
+ "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -223,7 +224,7 @@
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
@@ -231,7 +232,7 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
- "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
+ "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
@@ -297,6 +298,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+ "html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="],
+
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
@@ -459,12 +462,8 @@
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
- "@types/qrcode/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="],
-
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
- "bun-types/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="],
-
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -472,9 +471,5 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
-
- "@types/qrcode/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
-
- "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}
diff --git a/package.json b/package.json
index db56fb9..6d7184a 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "html5-qrcode": "^2.3.8",
"lucide-react": "^0.462.0",
"openpgp": "^6.3.0",
"qrcode": "^1.5.4",
diff --git a/src/App.tsx b/src/App.tsx
index 4684668..b58d463 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -14,17 +14,22 @@ import {
} from 'lucide-react';
import { PgpKeyInput } from './components/PgpKeyInput';
import { QrDisplay } from './components/QrDisplay';
+import QRScanner from './components/QRScanner';
import { validateBip39Mnemonic } from './lib/bip39';
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
import type { SeedPgpPlaintext } from './lib/types';
import * as openpgp from 'openpgp';
+
console.log("OpenPGP.js version:", openpgp.config.versionString);
function App() {
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
const [mnemonic, setMnemonic] = useState('');
- const [messagePassword, setMessagePassword] = useState('');
- const [pgpKeyInput, setPgpKeyInput] = useState('');
+ const [backupMessagePassword, setBackupMessagePassword] = useState('');
+ const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
+
+ const [publicKeyInput, setPublicKeyInput] = useState('');
+ const [privateKeyInput, setPrivateKeyInput] = useState('');
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
const [qrPayload, setQrPayload] = useState('');
@@ -35,6 +40,7 @@ function App() {
const [loading, setLoading] = useState(false);
const [showMnemonic, setShowMnemonic] = useState(false);
const [copied, setCopied] = useState(false);
+ const [showQRScanner, setShowQRScanner] = useState(false);
const copyToClipboard = async (text: string) => {
try {
@@ -42,7 +48,6 @@ function App() {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
- // Fallback for environments where Clipboard API is blocked
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
@@ -57,7 +62,6 @@ function App() {
}
};
-
const handleBackup = async () => {
setLoading(true);
setError('');
@@ -74,8 +78,8 @@ function App() {
const result = await encryptToSeedPgp({
plaintext,
- publicKeyArmored: pgpKeyInput || undefined,
- messagePassword: messagePassword || undefined,
+ publicKeyArmored: publicKeyInput || undefined,
+ messagePassword: backupMessagePassword || undefined, // Changed
});
setQrPayload(result.framed);
@@ -97,11 +101,12 @@ function App() {
try {
const result = await decryptSeedPgp({
frameText: restoreInput,
- privateKeyArmored: pgpKeyInput || undefined,
+ privateKeyArmored: privateKeyInput || undefined,
privateKeyPassphrase: privateKeyPassphrase || undefined,
- messagePassword: messagePassword || undefined,
+ messagePassword: restoreMessagePassword || undefined, // Changed
});
+
setRestoredData(result);
} catch (e) {
setError(e instanceof Error ? e.message : 'Decryption failed');
@@ -110,296 +115,322 @@ function App() {
}
};
+
return (
-
-
+ <>
+
+
- {/* Header */}
-
-
-
-
-
-
-
- SeedPGP v1.1
-
-
OpenPGP-secured BIP39 backup
-
-
-
-
-
-
-
-
-
- {/* Error Display */}
- {error && (
-
-
+ {/* Header */}
+
+
+
+
+
-
Error
-
{error}
+
+ SeedPGP v1.2
+
+
OpenPGP-secured BIP39 backup
- )}
-
- {/* Info Banner */}
- {recipientFpr && activeTab === 'backup' && (
-
-
-
- Recipient Key: {recipientFpr}
-
+
+
+
- )}
+
- {/* Main Content Grid */}
-
-
- {activeTab === 'backup' ? (
- <>
-
-
-
+
+ {/* Error Display */}
+ {error && (
+
+ )}
-
- >
- ) : (
- <>
-
-
-
+ {/* Info Banner */}
+ {recipientFpr && activeTab === 'backup' && (
+
+
+
+ Recipient Key: {recipientFpr}
+
+
+ )}
-
-
- {pgpKeyInput && (
+ {/* Main Content Grid */}
+
+
+ {activeTab === 'backup' ? (
+ <>
-
-
-
- setPrivateKeyPassphrase(e.target.value)}
- />
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {privateKeyInput && (
+
+
+
+
+ setPrivateKeyPassphrase(e.target.value)}
+ />
+
+ )}
+ >
+ )}
+
+
+ {/* Security Panel */}
+
+
+
+ Security Options
+
+
+
+
+
+
+ activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
+ />
+
+
Symmetric encryption password (SKESK)
+
+
+
+ {activeTab === 'backup' && (
+
+
)}
- >
- )}
-
-
- {/* Security Panel */}
-
-
-
- Security Options
-
-
-
-
-
-
- setMessagePassword(e.target.value)}
- />
-
-
Symmetric encryption password (SKESK)
- {activeTab === 'backup' && (
-
-
-
+ {/* Action Button */}
+ {activeTab === 'backup' ? (
+
+ ) : (
+
)}
-
- {/* Action Button */}
- {activeTab === 'backup' ? (
-
- ) : (
-
- )}
-
- {/* QR Output */}
- {qrPayload && activeTab === 'backup' && (
-
-
-
-
-
-
-
-
-
+ {/* QR Output */}
+ {qrPayload && activeTab === 'backup' && (
+
+
+
+
+
+
-
+
+
-
- )}
-
- {/* Restored Mnemonic */}
- {restoredData && activeTab === 'restore' && (
-
-
-
-
- Mnemonic Recovered
-
-
-
-
-
-
- {restoredData.pp === 1 && (
-
-
- BIP39 Passphrase Required (25th Word)
-
-
- )}
-
- {restoredData.fpr && restoredData.fpr.length > 0 && (
-
-
- Encrypted for keys: {restoredData.fpr.join(', ')}
-
-
- )}
-
- )}
+ )}
+
+ {/* Restored Mnemonic */}
+ {restoredData && activeTab === 'restore' && (
+
+
+
+
+ Mnemonic Recovered
+
+
+
+
+
+
+ {restoredData.pp === 1 && (
+
+
+ BIP39 Passphrase Required (25th Word)
+
+
+ )}
+
+ {restoredData.fpr && restoredData.fpr.length > 0 && (
+
+
+ Encrypted for keys: {restoredData.fpr.join(', ')}
+
+
+ )}
+
+
+ )}
+
+
+
+ {/* Footer */}
+
+
SeedPGP v1.2 • OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE
+
Never share your private keys or seed phrases. Always verify on an airgapped device.
- {/* Footer */}
-
-
SeedPGP v1.1 • OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE
-
Never share your private keys or seed phrases. Always verify on an airgapped device.
-
-
+ {/* QR Scanner Modal */}
+ {showQRScanner && (
+
{
+ setRestoreInput(scannedText);
+ setShowQRScanner(false);
+ setError('');
+ }}
+ onClose={() => setShowQRScanner(false)}
+ />
+ )}
+ >
);
+
}
export default App;
diff --git a/src/components/QRScanner.tsx b/src/components/QRScanner.tsx
new file mode 100644
index 0000000..731207e
--- /dev/null
+++ b/src/components/QRScanner.tsx
@@ -0,0 +1,224 @@
+import { useState, useRef } from 'react';
+import { Camera, Upload, X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
+import { Html5Qrcode } from 'html5-qrcode';
+
+interface QRScannerProps {
+ onScanSuccess: (scannedText: string) => void;
+ onClose: () => void;
+}
+
+export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
+ const [scanMode, setScanMode] = useState<'camera' | 'file' | null>(null);
+ const [scanning, setScanning] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const html5QrCodeRef = useRef(null);
+ const fileInputRef = useRef(null);
+
+ const startCamera = async () => {
+ setError('');
+ setScanMode('camera');
+ setScanning(true);
+
+ // Wait for DOM to render the #qr-reader div
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ try {
+ // Check if we're on HTTPS or localhost
+ if (window.location.protocol !== 'https:' && !window.location.hostname.includes('localhost')) {
+ throw new Error('Camera requires HTTPS or localhost. Use: bun run dev');
+ }
+
+ const html5QrCode = new Html5Qrcode('qr-reader');
+ html5QrCodeRef.current = html5QrCode;
+
+ await html5QrCode.start(
+ { facingMode: 'environment' },
+ {
+ fps: 10,
+ qrbox: { width: 250, height: 250 },
+ aspectRatio: 1.0,
+ },
+ (decodedText) => {
+ if (decodedText.startsWith('SEEDPGP1:')) {
+ setSuccess(true);
+ onScanSuccess(decodedText);
+ stopCamera();
+ } else {
+ setError('QR code found, but not a valid SEEDPGP1 frame');
+ }
+ },
+ () => {
+ // Ignore frequent scanning errors
+ }
+ );
+ } catch (err: any) {
+ console.error('Camera error:', err);
+ setError(`Camera failed: ${err.message || 'Permission denied or not available'}`);
+ setScanning(false);
+ setScanMode(null);
+ }
+ };
+
+
+ const stopCamera = async () => {
+ if (html5QrCodeRef.current) {
+ try {
+ await html5QrCodeRef.current.stop();
+ html5QrCodeRef.current.clear();
+ } catch (err) {
+ console.error('Error stopping camera:', err);
+ }
+ html5QrCodeRef.current = null;
+ }
+ setScanning(false);
+ setScanMode(null);
+ };
+
+ const handleFileUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setError('');
+ setScanMode('file');
+ setScanning(true);
+
+ try {
+ const html5QrCode = new Html5Qrcode('qr-reader-file');
+
+ // Try scanning with verbose mode
+ const decodedText = await html5QrCode.scanFile(file, true);
+
+ if (decodedText.startsWith('SEEDPGP1:')) {
+ setSuccess(true);
+ onScanSuccess(decodedText);
+ html5QrCode.clear();
+ } else {
+ setError(`Found QR code, but not SEEDPGP format: ${decodedText.substring(0, 30)}...`);
+ }
+ } catch (err: any) {
+ console.error('File scan error:', err);
+
+ // Provide helpful error messages
+ if (err.message?.includes('No MultiFormat')) {
+ setError('Could not detect QR code in image. Try: 1) Taking a clearer photo, 2) Ensuring good lighting, 3) Screenshot from the Backup tab');
+ } else {
+ setError(`Scan failed: ${err.message || 'Unknown error'}`);
+ }
+ } finally {
+ setScanning(false);
+ // Reset file input so same file can be selected again
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ }
+ };
+
+ const handleClose = async () => {
+ await stopCamera();
+ onClose();
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
Scan QR Code
+
+
+
+
+ {/* Content */}
+
+ {/* Error Display */}
+ {error && (
+
+ )}
+
+ {/* Success Display */}
+ {success && (
+
+
+
QR code scanned successfully!
+
+ )}
+
+ {/* Mode Selection */}
+ {!scanMode && (
+
+
+
+
+
+
+
+ {/* Info Box */}
+
+
+
+
+
Camera: Requires HTTPS or localhost
+
Upload: Screenshot QR from Backup tab for testing
+
+
+
+
+ )}
+
+ {/* Camera View */}
+ {scanMode === 'camera' && scanning && (
+
+ )}
+
+ {/* File Processing View */}
+ {scanMode === 'file' && scanning && (
+
+
+
Processing image...
+
+ )}
+
+ {/* Hidden div for file scanning */}
+
+
+
+
+ );
+}
diff --git a/src/components/QrDisplay.tsx b/src/components/QrDisplay.tsx
index 47f44a4..069c507 100644
--- a/src/components/QrDisplay.tsx
+++ b/src/components/QrDisplay.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { Download } from 'lucide-react';
import QRCode from 'qrcode';
interface QrDisplayProps {
@@ -11,21 +12,61 @@ export const QrDisplay: React.FC = ({ value }) => {
useEffect(() => {
if (value) {
QRCode.toDataURL(value, {
- errorCorrectionLevel: 'Q',
+ errorCorrectionLevel: 'M',
type: 'image/png',
width: 512,
- margin: 2,
+ margin: 4,
+ color: {
+ dark: '#000000',
+ light: '#FFFFFF'
+ }
})
.then(setDataUrl)
.catch(console.error);
}
}, [value]);
+ const handleDownload = () => {
+ if (!dataUrl) return;
+
+ // Generate filename: SeedPGP_YYYY-MM-DD_HHMMSS.png
+ const now = new Date();
+ const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
+ const time = now.toTimeString().split(' ')[0].replace(/:/g, ''); // HHMMSS
+ const filename = `SeedPGP_${date}_${time}.png`;
+
+ // Create download link
+ const link = document.createElement('a');
+ link.href = dataUrl;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
if (!dataUrl) return null;
return (
-
-

+
+
+

+
+
+
+
+
+ Downloads as: SeedPGP_2026-01-28_231645.png
+
);
};