mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
fix(qr): resolve scanner race condition and crashes
This commit addresses several issues related to the QR code scanner: - Fixes a build failure by defining stable handlers (`handleRestoreClose`, `handleRestoreError`) for the QRScanner component in `App.tsx` using `useCallback`. - Resolves a race condition that caused an `AbortError` when the scanner was initialized, particularly in React Strict Mode. This was fixed by ensuring all props passed to the scanner are stable. - Implements more robust error handling within the `QRScanner` component to prevent crashes when `null` or `undefined` errors are caught. - Updates documentation (`README.md`, `GEMINI.md`) to version 1.4.5.
This commit is contained in:
13
GEMINI.md
13
GEMINI.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**SeedPGP v1.4.4**: Client-side BIP39 mnemonic encryption webapp
|
**SeedPGP v1.4.5**: Client-side BIP39 mnemonic encryption webapp
|
||||||
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
|
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
|
||||||
**Deploy**: Cloudflare Pages (private repo: `seedpgp-web`)
|
**Deploy**: Cloudflare Pages (private repo: `seedpgp-web`)
|
||||||
**Live URL**: <https://seedpgp-web.pages.dev/>
|
**Live URL**: <https://seedpgp-web.pages.dev/>
|
||||||
@@ -300,13 +300,12 @@ await window.runSessionCryptoTest()
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Version: v1.4.4
|
## Current Version: v1.4.5
|
||||||
|
|
||||||
**Recent Changes (v1.4.4):**
|
**Recent Changes (v1.4.5):**
|
||||||
- Enhanced security documentation with explicit threat model
|
- Fixed QR Scanner bugs related to camera initialization and race conditions.
|
||||||
- Improved README with simple examples and best practices
|
- Improved error handling in the scanner to prevent crashes and provide better feedback.
|
||||||
- Better air-gapped usage guidance for maximum security
|
- Stabilized component props to prevent unnecessary re-renders and fix `AbortError`.
|
||||||
- Version bump with security audit improvements
|
|
||||||
|
|
||||||
**Known Limitations (Critical):**
|
**Known Limitations (Critical):**
|
||||||
1. **Browser extensions** can read DOM, memory, keystrokes - use dedicated browser
|
1. **Browser extensions** can read DOM, memory, keystrokes - use dedicated browser
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# SeedPGP v1.4.4
|
# SeedPGP v1.4.5
|
||||||
|
|
||||||
**Secure BIP39 mnemonic backup using PGP encryption and QR codes**
|
**Secure BIP39 mnemonic backup using PGP encryption and QR codes**
|
||||||
|
|
||||||
@@ -378,6 +378,11 @@ seedpgp-web/
|
|||||||
|
|
||||||
## 🔄 Version History
|
## 🔄 Version History
|
||||||
|
|
||||||
|
### v1.4.5 (2026-02-07)
|
||||||
|
- ✅ **Fixed QR Scanner bugs** related to camera initialization and race conditions.
|
||||||
|
- ✅ **Improved error handling** in the scanner to prevent crashes and provide better feedback.
|
||||||
|
- ✅ **Stabilized component props** to prevent unnecessary re-renders and fix `AbortError`.
|
||||||
|
|
||||||
### v1.4.4 (2026-02-03)
|
### v1.4.4 (2026-02-03)
|
||||||
- ✅ **Enhanced security documentation** with explicit threat model
|
- ✅ **Enhanced security documentation** with explicit threat model
|
||||||
- ✅ **Improved README** with simple examples and best practices
|
- ✅ **Improved README** with simple examples and best practices
|
||||||
|
|||||||
39
src/App.tsx
39
src/App.tsx
@@ -403,14 +403,21 @@ function App() {
|
|||||||
setShowQRScanner(false);
|
setShowQRScanner(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRestoreError = useCallback((error: string) => {
|
const handleRestoreError = useCallback((err: string) => {
|
||||||
setError(error);
|
setError(err);
|
||||||
|
setShowQRScanner(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-800 text-slate-100">
|
<div className="min-h-screen bg-slate-800 text-slate-100">
|
||||||
<Header
|
<Header
|
||||||
@@ -517,7 +524,7 @@ function App() {
|
|||||||
|
|
||||||
{privateKeyInput && (
|
{privateKeyInput && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
|
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">Private Key Passphrase</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||||
<input
|
<input
|
||||||
@@ -555,18 +562,18 @@ function App() {
|
|||||||
|
|
||||||
{/* Encryption Mode Toggle */}
|
{/* Encryption Mode Toggle */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Encryption Mode</label>
|
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">Encryption Mode</label>
|
||||||
<select
|
<select
|
||||||
value={encryptionMode}
|
value={encryptionMode}
|
||||||
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux' | 'seedqr')}
|
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux' | 'seedqr')}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
|
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
|
||||||
>
|
>
|
||||||
<option value="pgp">PGP (Asymmetric)</option>
|
<option value="pgp">PGP (Asymmetric)</option>
|
||||||
<option value="krux">Krux KEF (Passphrase)</option>
|
<option value="krux">Krux KEF (Passphrase)</option>
|
||||||
<option value="seedqr">SeedQR (Unencrypted)</option>
|
<option value="seedqr">SeedQR (Unencrypted)</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">
|
<p className="text-[10px] text-slate-800 mt-1">
|
||||||
{encryptionMode === 'pgp'
|
{encryptionMode === 'pgp'
|
||||||
? 'Uses PGP keys or password'
|
? 'Uses PGP keys or password'
|
||||||
: 'Uses passphrase only (Krux compatible)'}
|
: 'Uses passphrase only (Krux compatible)'}
|
||||||
@@ -576,17 +583,17 @@ function App() {
|
|||||||
{/* SeedQR Format Toggle */}
|
{/* SeedQR Format Toggle */}
|
||||||
{encryptionMode === 'seedqr' && (
|
{encryptionMode === 'seedqr' && (
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">SeedQR Format</label>
|
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">SeedQR Format</label>
|
||||||
<select
|
<select
|
||||||
value={seedQrFormat}
|
value={seedQrFormat}
|
||||||
onChange={(e) => setSeedQrFormat(e.target.value as 'standard' | 'compact')}
|
onChange={(e) => setSeedQrFormat(e.target.value as 'standard' | 'compact')}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
|
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
|
||||||
>
|
>
|
||||||
<option value="standard">Standard (Numeric)</option>
|
<option value="standard">Standard (Numeric)</option>
|
||||||
<option value="compact">Compact (Binary)</option>
|
<option value="compact">Compact (Binary)</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">
|
<p className="text-[10px] text-slate-800 mt-1">
|
||||||
{seedQrFormat === 'standard'
|
{seedQrFormat === 'standard'
|
||||||
? 'Numeric format, human-readable.'
|
? 'Numeric format, human-readable.'
|
||||||
: 'Compact binary format, smaller QR code.'}
|
: 'Compact binary format, smaller QR code.'}
|
||||||
@@ -598,7 +605,7 @@ function App() {
|
|||||||
{encryptionMode === 'krux' && activeTab === 'backup' && (
|
{encryptionMode === 'krux' && activeTab === 'backup' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Krux Label</label>
|
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">Krux Label</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -610,11 +617,11 @@ function App() {
|
|||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">Label for identification (max 252 bytes)</p>
|
<p className="text-[10px] text-slate-800 mt-1">Label for identification (max 252 bytes)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">PBKDF2 Iterations</label>
|
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">PBKDF2 Iterations</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -628,13 +635,13 @@ function App() {
|
|||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">Higher = more secure but slower (default: 200,000)</p>
|
<p className="text-[10px] text-slate-800 mt-1">Higher = more secure but slower (default: 200,000)</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
|
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">Message Password</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||||
<input
|
<input
|
||||||
@@ -647,7 +654,7 @@ function App() {
|
|||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">
|
<p className="text-[10px] text-slate-800 mt-1">
|
||||||
{encryptionMode === 'krux'
|
{encryptionMode === 'krux'
|
||||||
? 'Required passphrase for Krux encryption'
|
? 'Required passphrase for Krux encryption'
|
||||||
: 'Symmetric encryption password (SKESK)'}
|
: 'Symmetric encryption password (SKESK)'}
|
||||||
@@ -713,7 +720,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">
|
||||||
Raw payload (copy for backup)
|
Raw payload (copy for backup)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -70,22 +70,14 @@ export default function QRScanner({ onScanSuccess, onClose, onError }: QRScanner
|
|||||||
// jsQR gives us raw bytes!
|
// jsQR gives us raw bytes!
|
||||||
const rawBytes = code.binaryData;
|
const rawBytes = code.binaryData;
|
||||||
|
|
||||||
console.log('🔍 Raw QR bytes:', rawBytes);
|
|
||||||
console.log(' - Length:', rawBytes.length);
|
|
||||||
console.log(' - Hex:', Array.from(rawBytes).map((b: number) => b.toString(16).padStart(2, '0')).join(''));
|
|
||||||
|
|
||||||
// Detect binary (16 or 32 bytes with non-printable chars)
|
// Detect binary (16 or 32 bytes with non-printable chars)
|
||||||
const isBinary = (rawBytes.length === 16 || rawBytes.length === 32) &&
|
const isBinary = (rawBytes.length === 16 || rawBytes.length === 32) &&
|
||||||
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]/.test(String.fromCharCode(...Array.from(rawBytes)));
|
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]/.test(String.fromCharCode(...Array.from(rawBytes)));
|
||||||
|
|
||||||
console.log('📊 Is binary?', isBinary);
|
|
||||||
|
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
console.log('✅ Passing Uint8Array');
|
|
||||||
onScanSuccess(new Uint8Array(rawBytes));
|
onScanSuccess(new Uint8Array(rawBytes));
|
||||||
} else {
|
} else {
|
||||||
// Text QR - use the text property
|
// Text QR - use the text property
|
||||||
console.log('✅ Passing string:', code.data.slice(0, 50));
|
|
||||||
onScanSuccess(code.data);
|
onScanSuccess(code.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +86,33 @@ export default function QRScanner({ onScanSuccess, onClose, onError }: QRScanner
|
|||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
// IMPORTANT: Check for null/undefined err object first.
|
||||||
|
if (!err) {
|
||||||
|
console.error('Caught a null or undefined error inside QRScanner.');
|
||||||
|
return; // Exit if error is falsy
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled || err.name === 'AbortError') {
|
||||||
|
console.log('Camera operation was cancelled or aborted, which is expected on unmount.');
|
||||||
|
return; // Ignore abort errors, they are expected on cleanup
|
||||||
|
}
|
||||||
|
|
||||||
console.error('Camera error:', err);
|
console.error('Camera error:', err);
|
||||||
setHasPermission(false);
|
setHasPermission(false);
|
||||||
const errorMsg = 'Camera access was denied.';
|
|
||||||
|
let errorMsg = 'An unknown camera error occurred.';
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
errorMsg = 'Camera access was denied. Please grant permission in your browser settings.';
|
||||||
|
} else if (err.name === 'NotFoundError') {
|
||||||
|
errorMsg = 'No camera found on this device.';
|
||||||
|
} else if (err.name === 'NotReadableError') {
|
||||||
|
errorMsg = 'Cannot access the camera. It may be in use by another application or browser tab.';
|
||||||
|
} else if (err.name === 'OverconstrainedError') {
|
||||||
|
errorMsg = 'The camera does not meet the required constraints.';
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
errorMsg = `Camera error: ${err.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
setInternalError(errorMsg);
|
setInternalError(errorMsg);
|
||||||
onError?.(errorMsg);
|
onError?.(errorMsg);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user