Skip to content

Commit 00e751d

Browse files
committed
feat: GPG-encrypted seed phrase backup download on wallet create/import
- New seedphrase-backup.ts using openpgp.js for client-side symmetric encryption - Auto-downloads wallet_<id>_seedphrase.txt.gpg on create and import - Decrypt with: gpg --decrypt wallet_<id>_seedphrase.txt.gpg - Added 'Download GPG Backup' button on SeedDisplay for manual re-download - All encryption happens in browser — seed phrase never touches the server
1 parent 4863a83 commit 00e751d

File tree

6 files changed

+157
-10
lines changed

6 files changed

+157
-10
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"jsonwebtoken": "^9.0.2",
4949
"next": "16.1.0",
5050
"node-fetch": "^3.3.2",
51+
"openpgp": "^6.3.0",
5152
"papaparse": "^5.5.3",
5253
"qrcode": "^1.5.3",
5354
"react": "^19.0.0",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/web-wallet/create/page.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import { PasswordInput } from '@/components/web-wallet/PasswordInput';
88
import { SeedDisplay } from '@/components/web-wallet/SeedDisplay';
99
import { ChainMultiSelect } from '@/components/web-wallet/ChainSelector';
1010
import { checkPasswordStrength } from '@/lib/web-wallet/client-crypto';
11+
import { downloadEncryptedSeedPhrase } from '@/lib/web-wallet/seedphrase-backup';
1112

1213
type Step = 'password' | 'seed' | 'verify';
1314

1415
const DEFAULT_CHAINS = ['BTC', 'BCH', 'ETH', 'POL', 'SOL', 'USDC_ETH', 'USDC_POL', 'USDC_SOL'];
1516

1617
export default function CreateWalletPage() {
1718
const router = useRouter();
18-
const { createWallet, isLoading, error, clearError } = useWebWallet();
19+
const { createWallet, isLoading, error, clearError, walletId: contextWalletId } = useWebWallet();
1920

2021
const [step, setStep] = useState<Step>('password');
2122
const [password, setPassword] = useState('');
@@ -39,6 +40,15 @@ export default function CreateWalletPage() {
3940
try {
4041
const result = await createWallet(password, { chains });
4142
setMnemonic(result.mnemonic);
43+
44+
// Auto-download GPG-encrypted seed phrase backup (client-side only)
45+
try {
46+
await downloadEncryptedSeedPhrase(result.mnemonic, password, result.walletId);
47+
} catch (dlErr) {
48+
console.warn('Seed phrase backup download failed:', dlErr);
49+
// Non-fatal — user can still copy manually
50+
}
51+
4252
// Pick a random word index for verification
4353
const words = result.mnemonic.split(' ');
4454
setVerifyIndex(Math.floor(Math.random() * words.length));
@@ -139,7 +149,12 @@ export default function CreateWalletPage() {
139149
)}
140150

141151
{step === 'seed' && mnemonic && (
142-
<SeedDisplay mnemonic={mnemonic} onConfirmed={handleSeedConfirmed} />
152+
<SeedDisplay
153+
mnemonic={mnemonic}
154+
onConfirmed={handleSeedConfirmed}
155+
password={password}
156+
walletId={contextWalletId}
157+
/>
143158
)}
144159

145160
{step === 'verify' && (

src/app/web-wallet/import/page.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { PasswordInput } from '@/components/web-wallet/PasswordInput';
88
import { SeedInput } from '@/components/web-wallet/SeedInput';
99
import { ChainMultiSelect } from '@/components/web-wallet/ChainSelector';
1010
import { checkPasswordStrength } from '@/lib/web-wallet/client-crypto';
11+
import { downloadEncryptedSeedPhrase } from '@/lib/web-wallet/seedphrase-backup';
1112

1213
const DEFAULT_CHAINS = ['BTC', 'BCH', 'ETH', 'POL', 'SOL', 'USDC_ETH', 'USDC_POL', 'USDC_SOL'];
1314

@@ -42,7 +43,16 @@ export default function ImportWalletPage() {
4243
}
4344

4445
try {
45-
await importWallet(mnemonic.trim(), password, { chains });
46+
const result = await importWallet(mnemonic.trim(), password, { chains });
47+
48+
// Auto-download GPG-encrypted seed phrase backup (client-side only)
49+
try {
50+
await downloadEncryptedSeedPhrase(mnemonic.trim(), password, result.walletId);
51+
} catch (dlErr) {
52+
console.warn('Seed phrase backup download failed:', dlErr);
53+
// Non-fatal — user still has their phrase
54+
}
55+
4656
router.push('/web-wallet');
4757
} catch (err: unknown) {
4858
const message = err instanceof Error ? err.message : '';

src/components/web-wallet/SeedDisplay.tsx

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
'use client';
22

33
import { useState } from 'react';
4+
import { downloadEncryptedSeedPhrase } from '@/lib/web-wallet/seedphrase-backup';
45

56
interface SeedDisplayProps {
67
mnemonic: string;
78
onConfirmed?: () => void;
9+
/** Password for GPG-encrypting the backup download */
10+
password?: string;
11+
/** Wallet ID for the backup filename */
12+
walletId?: string | null;
813
}
914

10-
export function SeedDisplay({ mnemonic, onConfirmed }: SeedDisplayProps) {
15+
export function SeedDisplay({ mnemonic, onConfirmed, password, walletId }: SeedDisplayProps) {
1116
const [revealed, setRevealed] = useState(false);
1217
const [copied, setCopied] = useState(false);
18+
const [downloading, setDownloading] = useState(false);
1319
const words = mnemonic.split(' ');
1420

1521
const handleCopy = async () => {
@@ -18,6 +24,18 @@ export function SeedDisplay({ mnemonic, onConfirmed }: SeedDisplayProps) {
1824
setTimeout(() => setCopied(false), 2000);
1925
};
2026

27+
const handleDownloadBackup = async () => {
28+
if (!password || !walletId) return;
29+
setDownloading(true);
30+
try {
31+
await downloadEncryptedSeedPhrase(mnemonic, password, walletId);
32+
} catch (err) {
33+
console.error('Backup download failed:', err);
34+
} finally {
35+
setDownloading(false);
36+
}
37+
};
38+
2139
return (
2240
<div className="space-y-4">
2341
<div className="rounded-xl border border-yellow-500/30 bg-yellow-500/5 p-4">
@@ -53,12 +71,24 @@ export function SeedDisplay({ mnemonic, onConfirmed }: SeedDisplayProps) {
5371
))}
5472
</div>
5573

56-
<button
57-
onClick={handleCopy}
58-
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
59-
>
60-
{copied ? 'Copied!' : 'Copy to clipboard'}
61-
</button>
74+
<div className="flex gap-2">
75+
<button
76+
onClick={handleCopy}
77+
className="flex-1 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
78+
>
79+
{copied ? 'Copied!' : 'Copy to clipboard'}
80+
</button>
81+
82+
{password && walletId && (
83+
<button
84+
onClick={handleDownloadBackup}
85+
disabled={downloading}
86+
className="flex-1 rounded-lg border border-purple-500/30 bg-purple-500/10 px-4 py-2 text-sm text-purple-400 hover:bg-purple-500/20 hover:text-purple-300 transition-colors disabled:opacity-50"
87+
>
88+
{downloading ? 'Encrypting...' : '🔐 Download GPG Backup'}
89+
</button>
90+
)}
91+
</div>
6292
</div>
6393
)}
6494

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Seed Phrase GPG Backup
3+
*
4+
* Encrypts the seed phrase using OpenPGP symmetric encryption (AES-256)
5+
* and triggers a browser download. The resulting .gpg file can be
6+
* decrypted with: gpg --decrypt wallet_<id>_seedphrase.txt.gpg
7+
*
8+
* IMPORTANT: Everything runs client-side. The seed phrase never leaves
9+
* the browser. No server calls are made.
10+
*/
11+
12+
import * as openpgp from 'openpgp';
13+
14+
/**
15+
* Encrypt a seed phrase with the user's password using OpenPGP symmetric encryption
16+
* and trigger a file download in the browser.
17+
*
18+
* @param mnemonic - The plaintext seed phrase
19+
* @param password - The user's wallet password (used as GPG passphrase)
20+
* @param walletId - The wallet ID (used in filename)
21+
*/
22+
export async function downloadEncryptedSeedPhrase(
23+
mnemonic: string,
24+
password: string,
25+
walletId: string
26+
): Promise<void> {
27+
const filename = `wallet_${walletId}_seedphrase.txt`;
28+
29+
// Create the plaintext content
30+
const content = [
31+
'# CoinPayPortal Wallet Seed Phrase Backup',
32+
`# Wallet ID: ${walletId}`,
33+
`# Created: ${new Date().toISOString()}`,
34+
'#',
35+
'# KEEP THIS FILE SAFE. Anyone with this phrase can access your funds.',
36+
'# Decrypt with: gpg --decrypt ' + filename + '.gpg',
37+
'',
38+
mnemonic,
39+
'',
40+
].join('\n');
41+
42+
// Encrypt using OpenPGP symmetric (password-based) encryption
43+
const message = await openpgp.createMessage({ text: content });
44+
const encrypted = await openpgp.encrypt({
45+
message,
46+
passwords: [password],
47+
format: 'binary',
48+
config: {
49+
preferredSymmetricAlgorithm: openpgp.enums.symmetric.aes256,
50+
preferredCompressionAlgorithm: openpgp.enums.compression.zlib,
51+
},
52+
});
53+
54+
// Convert to Blob and trigger download
55+
const data = encrypted instanceof Uint8Array
56+
? encrypted
57+
: new TextEncoder().encode(encrypted as string);
58+
const blob = new Blob([new Uint8Array(data)], {
59+
type: 'application/pgp-encrypted',
60+
});
61+
62+
triggerDownload(blob, filename + '.gpg');
63+
}
64+
65+
/**
66+
* Trigger a browser file download from a Blob.
67+
*/
68+
function triggerDownload(blob: Blob, filename: string): void {
69+
const url = URL.createObjectURL(blob);
70+
const a = document.createElement('a');
71+
a.href = url;
72+
a.download = filename;
73+
a.style.display = 'none';
74+
document.body.appendChild(a);
75+
a.click();
76+
77+
// Cleanup
78+
setTimeout(() => {
79+
URL.revokeObjectURL(url);
80+
document.body.removeChild(a);
81+
}, 100);
82+
}

0 commit comments

Comments
 (0)