mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
feat(v1.2.0): add QR scanner with camera/upload support
- Add QRScanner component with camera and image upload - Add QR code download button with auto-naming (SeedPGP_DATE_TIME.png) - Split state for backup/restore (separate public/private keys and passwords) - Improve QR generation settings (margin: 4, errorCorrection: M) - Fix Safari camera permissions and Continuity Camera support - Add React timing fix for Html5Qrcode initialization Features: - Camera scanning with live preview - Image file upload scanning - Automatic SEEDPGP1 validation - User-friendly error messages - 512x512px high-quality QR generation
This commit is contained in:
17
bun.lock
17
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "seedpgp-web",
|
"name": "seedpgp-web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"openpgp": "^6.3.0",
|
"openpgp": "^6.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"readdirp/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=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"openpgp": "^6.3.0",
|
"openpgp": "^6.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
67
src/App.tsx
67
src/App.tsx
@@ -14,17 +14,22 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PgpKeyInput } from './components/PgpKeyInput';
|
import { PgpKeyInput } from './components/PgpKeyInput';
|
||||||
import { QrDisplay } from './components/QrDisplay';
|
import { QrDisplay } from './components/QrDisplay';
|
||||||
|
import QRScanner from './components/QRScanner';
|
||||||
import { validateBip39Mnemonic } from './lib/bip39';
|
import { validateBip39Mnemonic } from './lib/bip39';
|
||||||
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
|
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
|
||||||
import type { SeedPgpPlaintext } from './lib/types';
|
import type { SeedPgpPlaintext } from './lib/types';
|
||||||
import * as openpgp from 'openpgp';
|
import * as openpgp from 'openpgp';
|
||||||
|
|
||||||
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
|
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
|
||||||
const [mnemonic, setMnemonic] = useState('');
|
const [mnemonic, setMnemonic] = useState('');
|
||||||
const [messagePassword, setMessagePassword] = useState('');
|
const [backupMessagePassword, setBackupMessagePassword] = useState('');
|
||||||
const [pgpKeyInput, setPgpKeyInput] = useState('');
|
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
|
||||||
|
|
||||||
|
const [publicKeyInput, setPublicKeyInput] = useState('');
|
||||||
|
const [privateKeyInput, setPrivateKeyInput] = useState('');
|
||||||
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
|
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
|
||||||
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
|
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
|
||||||
const [qrPayload, setQrPayload] = useState('');
|
const [qrPayload, setQrPayload] = useState('');
|
||||||
@@ -35,6 +40,7 @@ function App() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showMnemonic, setShowMnemonic] = useState(false);
|
const [showMnemonic, setShowMnemonic] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showQRScanner, setShowQRScanner] = useState(false);
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -42,7 +48,6 @@ function App() {
|
|||||||
setCopied(true);
|
setCopied(true);
|
||||||
window.setTimeout(() => setCopied(false), 1500);
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback for environments where Clipboard API is blocked
|
|
||||||
const ta = document.createElement("textarea");
|
const ta = document.createElement("textarea");
|
||||||
ta.value = text;
|
ta.value = text;
|
||||||
ta.style.position = "fixed";
|
ta.style.position = "fixed";
|
||||||
@@ -57,7 +62,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleBackup = async () => {
|
const handleBackup = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -74,8 +78,8 @@ function App() {
|
|||||||
|
|
||||||
const result = await encryptToSeedPgp({
|
const result = await encryptToSeedPgp({
|
||||||
plaintext,
|
plaintext,
|
||||||
publicKeyArmored: pgpKeyInput || undefined,
|
publicKeyArmored: publicKeyInput || undefined,
|
||||||
messagePassword: messagePassword || undefined,
|
messagePassword: backupMessagePassword || undefined, // Changed
|
||||||
});
|
});
|
||||||
|
|
||||||
setQrPayload(result.framed);
|
setQrPayload(result.framed);
|
||||||
@@ -97,11 +101,12 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const result = await decryptSeedPgp({
|
const result = await decryptSeedPgp({
|
||||||
frameText: restoreInput,
|
frameText: restoreInput,
|
||||||
privateKeyArmored: pgpKeyInput || undefined,
|
privateKeyArmored: privateKeyInput || undefined,
|
||||||
privateKeyPassphrase: privateKeyPassphrase || undefined,
|
privateKeyPassphrase: privateKeyPassphrase || undefined,
|
||||||
messagePassword: messagePassword || undefined,
|
messagePassword: restoreMessagePassword || undefined, // Changed
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
setRestoredData(result);
|
setRestoredData(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Decryption failed');
|
setError(e instanceof Error ? e.message : 'Decryption failed');
|
||||||
@@ -110,7 +115,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8">
|
||||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
|
||||||
|
|
||||||
@@ -122,7 +129,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.1</span>
|
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.2</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
|
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,12 +207,22 @@ function App() {
|
|||||||
label="PGP Public Key (Optional)"
|
label="PGP Public Key (Optional)"
|
||||||
icon={FileKey}
|
icon={FileKey}
|
||||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- Paste or drag & drop your public key..."
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- Paste or drag & drop your public key..."
|
||||||
value={pgpKeyInput}
|
value={publicKeyInput}
|
||||||
onChange={setPgpKeyInput}
|
onChange={setPublicKeyInput}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQRScanner(true)}
|
||||||
|
className="flex-1 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-purple-700 hover:to-purple-800 transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<QrCode size={18} />
|
||||||
|
Scan QR Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
|
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -220,11 +237,11 @@ function App() {
|
|||||||
label="PGP Private Key (Optional)"
|
label="PGP Private Key (Optional)"
|
||||||
icon={FileKey}
|
icon={FileKey}
|
||||||
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
||||||
value={pgpKeyInput}
|
value={privateKeyInput}
|
||||||
onChange={setPgpKeyInput}
|
onChange={setPrivateKeyInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pgpKeyInput && (
|
{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-500 uppercase tracking-wider">Private Key Passphrase</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -258,13 +275,14 @@ function App() {
|
|||||||
type="password"
|
type="password"
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
placeholder="Optional password..."
|
placeholder="Optional password..."
|
||||||
value={messagePassword}
|
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
|
||||||
onChange={(e) => setMessagePassword(e.target.value)}
|
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
|
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{activeTab === 'backup' && (
|
{activeTab === 'backup' && (
|
||||||
<div className="pt-3 border-t border-slate-300">
|
<div className="pt-3 border-t border-slate-300">
|
||||||
<label className="flex items-center gap-2 cursor-pointer group">
|
<label className="flex items-center gap-2 cursor-pointer group">
|
||||||
@@ -345,7 +363,6 @@ function App() {
|
|||||||
Tip: click the box to select all, or use Copy.
|
Tip: click the box to select all, or use Copy.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -395,11 +412,25 @@ function App() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-8 text-center text-xs text-slate-500">
|
<div className="mt-8 text-center text-xs text-slate-500">
|
||||||
<p>SeedPGP v1.1 • OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p>
|
<p>SeedPGP v1.2 • OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p>
|
||||||
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
|
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* QR Scanner Modal */}
|
||||||
|
{showQRScanner && (
|
||||||
|
<QRScanner
|
||||||
|
onScanSuccess={(scannedText) => {
|
||||||
|
setRestoreInput(scannedText);
|
||||||
|
setShowQRScanner(false);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
onClose={() => setShowQRScanner(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
224
src/components/QRScanner.tsx
Normal file
224
src/components/QRScanner.tsx
Normal file
@@ -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<string>('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in zoom-in-95">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-4 text-white flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Camera size={20} />
|
||||||
|
<h2 className="font-bold text-lg">Scan QR Code</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border-l-4 border-red-500 rounded-r-lg flex gap-2 text-red-800 text-xs leading-relaxed">
|
||||||
|
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Display */}
|
||||||
|
{success && (
|
||||||
|
<div className="p-3 bg-green-50 border-l-4 border-green-500 rounded-r-lg flex gap-2 text-green-800 text-sm">
|
||||||
|
<CheckCircle2 size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<p>QR code scanned successfully!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
{!scanMode && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={startCamera}
|
||||||
|
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<Camera size={20} />
|
||||||
|
Use Camera
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-full py-4 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-slate-800 hover:to-slate-900 transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<Upload size={20} />
|
||||||
|
Upload Image
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="pt-4 border-t border-slate-200">
|
||||||
|
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
|
||||||
|
<Info size={14} className="shrink-0 mt-0.5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
|
||||||
|
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Camera View */}
|
||||||
|
{scanMode === 'camera' && scanning && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div id="qr-reader" className="rounded-lg overflow-hidden border-2 border-slate-200"></div>
|
||||||
|
<button
|
||||||
|
onClick={stopCamera}
|
||||||
|
className="w-full py-3 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Stop Camera
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File Processing View */}
|
||||||
|
{scanMode === 'file' && scanning && (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-blue-600"></div>
|
||||||
|
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden div for file scanning */}
|
||||||
|
<div id="qr-reader-file" className="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
interface QrDisplayProps {
|
interface QrDisplayProps {
|
||||||
@@ -11,21 +12,61 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (value) {
|
||||||
QRCode.toDataURL(value, {
|
QRCode.toDataURL(value, {
|
||||||
errorCorrectionLevel: 'Q',
|
errorCorrectionLevel: 'M',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
width: 512,
|
width: 512,
|
||||||
margin: 2,
|
margin: 4,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then(setDataUrl)
|
.then(setDataUrl)
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [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;
|
if (!dataUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="flex items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-200">
|
<div className="flex items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-200">
|
||||||
<img src={dataUrl} alt="SeedPGP QR Code" className="w-64 h-64" />
|
<img
|
||||||
|
src={dataUrl}
|
||||||
|
alt="SeedPGP QR Code"
|
||||||
|
className="w-80 h-80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition-all shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
Download QR Code
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 text-center max-w-sm">
|
||||||
|
Downloads as: SeedPGP_2026-01-28_231645.png
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user