diff --git a/frontend/electron/main.cjs b/frontend/electron/main.cjs index 5ae00b6..eb32b63 100644 --- a/frontend/electron/main.cjs +++ b/frontend/electron/main.cjs @@ -25,6 +25,29 @@ function ensureWalletDir(kind) { return walletDir } +function normalizeFilename(filename) { + if (typeof filename !== 'string') { + throw new Error('Filename must be a string.') + } + const trimmed = filename.trim() + const safeName = path.basename(trimmed) + if (!safeName || safeName !== trimmed) { + throw new Error('Invalid filename.') + } + return safeName +} + +function resolveWalletFile(kind, filename) { + const walletDir = ensureWalletDir(kind) + const safeName = normalizeFilename(filename) + const walletDirAbs = path.resolve(walletDir) + const filePath = path.resolve(walletDir, safeName) + if (!filePath.startsWith(walletDirAbs + path.sep)) { + throw new Error('Invalid file path.') + } + return filePath +} + // ── Resolve Python executable ────────────────────────────────────────────────── function findPython() { const repoRoot = path.join(__dirname, '..', '..') @@ -62,14 +85,48 @@ ipcMain.handle('p2sh', (_, args) => callPython('p2sh', args)) ipcMain.handle('p2wpkh', (_, args) => callPython('p2wpkh', args)) ipcMain.handle('p2tr', (_, args) => callPython('p2tr', args)) ipcMain.handle('single-encrypt', (_, args) => callPython('single_encrypt', args)) +ipcMain.handle('single-decrypt', (_, args) => callPython('single_decrypt', args)) ipcMain.handle('save-wallet', (_, { filename, data, kind }) => { - const walletDir = ensureWalletDir(kind) - const filePath = path.join(walletDir, filename) + const filePath = resolveWalletFile(kind, filename) fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') return filePath }) +ipcMain.handle('list-wallets', () => { + const out = { hd: [], single: [] } + for (const kind of Object.keys(WALLET_DIRS)) { + const walletDir = ensureWalletDir(kind) + const files = fs.readdirSync(walletDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.json')) + .map(entry => { + const filePath = path.join(walletDir, entry.name) + const stat = fs.statSync(filePath) + return { + name: entry.name, + size: stat.size, + mtimeMs: stat.mtimeMs, + } + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + out[kind] = files + } + return out +}) + +ipcMain.handle('read-wallet', (_, { kind, filename }) => { + const filePath = resolveWalletFile(kind, filename) + if (!fs.existsSync(filePath)) { + throw new Error('Wallet file not found.') + } + try { + const content = fs.readFileSync(filePath, 'utf-8') + return JSON.parse(content) + } catch (err) { + throw new Error(`Invalid wallet JSON: ${err.message}`) + } +}) + ipcMain.handle('get-wallet-dir', () => { fs.mkdirSync(WALLET_ROOT_DIR, { recursive: true }) return WALLET_ROOT_DIR diff --git a/frontend/electron/preload.cjs b/frontend/electron/preload.cjs index 4c62610..a148b77 100644 --- a/frontend/electron/preload.cjs +++ b/frontend/electron/preload.cjs @@ -10,6 +10,9 @@ contextBridge.exposeInMainWorld('electronAPI', { p2wpkh: (args) => ipcRenderer.invoke('p2wpkh', args), p2tr: (args) => ipcRenderer.invoke('p2tr', args), singleEncrypt:(args) => ipcRenderer.invoke('single-encrypt', args), + singleDecrypt:(args) => ipcRenderer.invoke('single-decrypt', args), + listWallets: () => ipcRenderer.invoke('list-wallets'), + readWallet: (args) => ipcRenderer.invoke('read-wallet', args), saveWallet: (filename, data, kind) => ipcRenderer.invoke('save-wallet', { filename, data, kind }), getWalletDir: () => ipcRenderer.invoke('get-wallet-dir'), }) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index af365fb..89b7248 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { useState } from 'react' import Sidebar from './components/Sidebar' import HDWallet from './components/HDWallet' import SingleAddress from './components/SingleAddress' +import WalletViewer from './components/WalletViewer' export default function App() { const [page, setPage] = useState('hd') @@ -9,6 +10,7 @@ export default function App() { const renderPage = () => { if (page === 'hd') return if (page === 'single') return + if (page === 'viewer') return return null } diff --git a/frontend/src/components/HDWallet.jsx b/frontend/src/components/HDWallet.jsx index 70f2d4d..24cff0a 100644 --- a/frontend/src/components/HDWallet.jsx +++ b/frontend/src/components/HDWallet.jsx @@ -14,7 +14,10 @@ export default function HDWallet() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [seedVisible, setSeedVisible] = useState(false) + const [saveFilename, setSaveFilename] = useState('') const [savePassword, setSavePassword] = useState('') + const [savePasswordConfirm, setSavePasswordConfirm] = useState('') + const [showSavePassword, setShowSavePassword] = useState(false) const [saving, setSaving] = useState(false) const [saveMsg, setSaveMsg] = useState(null) @@ -25,6 +28,10 @@ export default function HDWallet() { setError(null) setWallet(null) setSeedVisible(false) + setSaveFilename('') + setSavePassword('') + setSavePasswordConfirm('') + setShowSavePassword(false) try { const data = await window.electronAPI.hdGenerate({ network: form.network, @@ -44,6 +51,21 @@ export default function HDWallet() { const saveWallet = async () => { if (!wallet) return + if (!saveFilename.trim()) { + setSaveMsg('Error: Filename is required.') + return + } + if (savePassword.trim() || savePasswordConfirm.trim()) { + if (!savePassword.trim() || !savePasswordConfirm.trim()) { + setSaveMsg('Error: Enter encryption password in both fields.') + return + } + if (savePassword !== savePasswordConfirm) { + setSaveMsg('Error: Encryption passwords do not match.') + return + } + } + setSaving(true) setSaveMsg(null) try { @@ -51,7 +73,9 @@ export default function HDWallet() { if (savePassword.trim()) { data = await window.electronAPI.hdEncrypt({ wallet, password: savePassword }) } - const filename = `wallet_${form.network}_${form.bip_type}_${Date.now()}.json` + const filename = saveFilename.trim().endsWith('.json') + ? saveFilename.trim() + : `${saveFilename.trim()}.json` const savedPath = await window.electronAPI.saveWallet(filename, data, 'hd') const encrypted = savePassword ? ' (encrypted)' : '' setSaveMsg(`Saved${encrypted}: ${savedPath}`) @@ -191,21 +215,50 @@ export default function HDWallet() {
Save Wallet
+
+ + setSaveFilename(e.target.value)} + /> + .json · saved to ~/.wallet-generator/hdwallet/ +
setSavePassword(e.target.value)} /> +
+
+ + setSavePasswordConfirm(e.target.value)} + /> AES-256-CBC · Electrum-compatible
-
+
+ setShowSavePassword(e.target.checked)} + /> + +
+
{saveMsg && ( diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 9da988e..dc15b51 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,6 +1,7 @@ const PAGES = [ { id: 'hd', label: 'HD Wallet' }, { id: 'single', label: 'Single Addresses' }, + { id: 'viewer', label: 'Wallet Viewer' }, ] export default function Sidebar({ active, onSelect }) { diff --git a/frontend/src/components/SingleAddress.jsx b/frontend/src/components/SingleAddress.jsx index e89ac18..fd39bf1 100644 --- a/frontend/src/components/SingleAddress.jsx +++ b/frontend/src/components/SingleAddress.jsx @@ -20,6 +20,8 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) { const [error, setError] = useState(null) const [filename, setFilename] = useState('') const [savePassword, setSavePassword] = useState('') + const [savePasswordConfirm, setSavePasswordConfirm] = useState('') + const [showSavePassword, setShowSavePassword] = useState(false) const [saving, setSaving] = useState(false) const [saveMsg, setSaveMsg] = useState(null) @@ -38,6 +40,8 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) { setResult(data) setFilename('') setSavePassword('') + setSavePasswordConfirm('') + setShowSavePassword(false) } catch (e) { setError(e.message) } finally { @@ -51,6 +55,16 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) { setSaveMsg('Error: Filename is required.') return } + if (savePassword.trim() || savePasswordConfirm.trim()) { + if (!savePassword.trim() || !savePasswordConfirm.trim()) { + setSaveMsg('Error: Enter encryption password in both fields.') + return + } + if (savePassword !== savePasswordConfirm) { + setSaveMsg('Error: Encryption passwords do not match.') + return + } + } setSaving(true) setSaveMsg(null) @@ -78,7 +92,7 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
{TABS.map(t => ( - ))} @@ -147,17 +161,38 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) { setSavePassword(e.target.value)} /> +
+
+ + setSavePasswordConfirm(e.target.value)} + /> Encrypts private keys in JSON
- +
+ setShowSavePassword(e.target.checked)} + /> + +
+
+ +
{saveMsg && (
{saveMsg} diff --git a/frontend/src/components/WalletViewer.jsx b/frontend/src/components/WalletViewer.jsx new file mode 100644 index 0000000..578ae28 --- /dev/null +++ b/frontend/src/components/WalletViewer.jsx @@ -0,0 +1,342 @@ +import { useEffect, useMemo, useState } from 'react' +import CopyButton from './CopyButton' + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function formatDate(ms) { + return new Date(ms).toLocaleString() +} + +function isHDWallet(wallet) { + return Boolean(wallet?.keystore && wallet?.addresses) +} + +function buildSingleRows(result) { + const rows = [ + ['Network', result.network], + ['Script type', result.script_type], + ] + if (result.address) rows.push(['Address', result.address]) + if (result.public_key_hex) rows.push(['Public key (hex)', result.public_key_hex]) + if (result.internal_pubkey_x_hex) rows.push(['Internal pubkey (x)', result.internal_pubkey_x_hex]) + if (result.private_key_wif) rows.push(['Private key (WIF)', result.private_key_wif]) + if (result.private_key_hex) rows.push(['Private key (hex)', result.private_key_hex]) + return rows +} + +export default function WalletViewer() { + const [files, setFiles] = useState({ hd: [], single: [] }) + const [loadingFiles, setLoadingFiles] = useState(false) + const [loadingWallet, setLoadingWallet] = useState(false) + const [loadingDecrypt, setLoadingDecrypt] = useState(false) + const [selected, setSelected] = useState(null) + const [wallet, setWallet] = useState(null) + const [decryptedWallet, setDecryptedWallet] = useState(null) + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + + const visibleWallet = decryptedWallet || wallet + const walletKind = useMemo(() => (isHDWallet(visibleWallet) ? 'hd' : 'single'), [visibleWallet]) + + const refreshWallets = async () => { + setLoadingFiles(true) + setError(null) + try { + const data = await window.electronAPI.listWallets() + setFiles({ hd: data.hd || [], single: data.single || [] }) + } catch (e) { + setError(e.message) + } finally { + setLoadingFiles(false) + } + } + + useEffect(() => { + refreshWallets() + }, []) + + const openWallet = async (kind, filename) => { + setLoadingWallet(true) + setLoadingDecrypt(false) + setError(null) + setPassword('') + setDecryptedWallet(null) + setWallet(null) + setSelected({ kind, filename }) + try { + const data = await window.electronAPI.readWallet({ kind, filename }) + setWallet(data) + if (!data?.use_encryption) { + setDecryptedWallet(data) + } + } catch (e) { + setError(e.message) + } finally { + setLoadingWallet(false) + } + } + + const decryptWallet = async () => { + if (!wallet?.use_encryption || !selected) return + setLoadingDecrypt(true) + setError(null) + try { + const fn = selected.kind === 'hd' ? window.electronAPI.hdDecrypt : window.electronAPI.singleDecrypt + const data = await fn({ wallet, password }) + setDecryptedWallet(data) + } catch (e) { + setError(e.message) + } finally { + setLoadingDecrypt(false) + } + } + + return ( +
+
Wallet Viewer
+
Read and decrypt HD and Single wallet JSON files
+ +
+
+ Wallet Files + +
+ +
+ + +
+
+ + {error &&
{error}
} + + {selected && ( +
+
Selected Wallet
+
+ File + {selected.filename} +
+
+ Source + {selected.kind === 'hd' ? 'HD folder' : 'Single folder'} +
+
+ Encrypted + {wallet?.use_encryption ? 'yes' : 'no'} +
+ {loadingWallet &&
Loading wallet…
} +
+ )} + + {wallet?.use_encryption && !decryptedWallet && ( +
+
Decrypt Wallet
+
+ + setPassword(e.target.value)} + onKeyDown={e => e.key === 'Enter' && decryptWallet()} + /> +
+ +
+ )} + + {visibleWallet && !loadingWallet && ( + walletKind === 'hd' + ? + : + )} +
+ ) +} + +function FileColumn({ title, files, kind, selected, onOpen }) { + return ( +
+ + {files.length === 0 && ( + No wallet files found. + )} + {files.map(file => { + const isActive = selected?.kind === kind && selected?.filename === file.name + return ( + + ) + })} +
+ ) +} + +function HDWalletView({ wallet }) { + const [seedVisible, setSeedVisible] = useState(false) + const ks = wallet?.keystore + + if (!ks) return null + + return ( + <> +
+
+ Seed Phrase +
+ + +
+
+
+ {String(ks.seed || '').split(' ').map((word, idx) => ( + + {idx + 1}.{word} + + ))} +
+
+ +
+
Keystore
+ {[ + ['Derivation', ks.derivation], + ['Root fingerprint', ks.root_fingerprint], + ['xpub', ks.xpub], + ['xprv', ks.xprv], + ].map(([label, value]) => ( +
+ {label} + {value} + +
+ ))} +
+ +
+
Receiving Addresses
+ + + + + + + + + + + + {(wallet.addresses?.receiving || []).map(addr => ( + + + + + + + + ))} + +
#PathAddressWIF
{addr.index}{addr.path}{addr.address} + {addr.private_key_wif} + +
+ + +
+
+
+ + ) +} + +function SingleWalletView({ wallet }) { + const isP2SH = Array.isArray(wallet?.participants) + + return ( +
+
Wallet Data
+ {isP2SH ? ( + <> + {[ + ['Network', wallet.network], + ['Script type', wallet.script_type], + ['Configuration', `${wallet.m}-of-${wallet.n}`], + ['Address', wallet.address], + ['Redeem script', wallet.redeem_script_hex], + ].map(([label, value]) => ( +
+ {label} + {value} + +
+ ))} +
+
Participants
+ {wallet.participants.map((p, idx) => ( +
+
Key {idx + 1}
+ {[ + ['Public key', p.public_key_hex], + ['Private key (WIF)', p.private_key_wif], + ['Private key (hex)', p.private_key_hex], + ].map(([label, value]) => ( +
+ {label} + {value} + +
+ ))} +
+ ))} + + ) : ( + buildSingleRows(wallet).map(([label, value]) => ( +
+ {label} + {value} + +
+ )) + )} +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index dc1cc6a..bf8ca8b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -255,6 +255,10 @@ html, body, #root { flex-wrap: wrap; } +.save-actions { + justify-content: flex-end; +} + /* ── Key/value rows ───────────────────────────────────────────────────────── */ .kv-row { display: flex; diff --git a/src/cli.py b/src/cli.py index 34fb25d..be11715 100644 --- a/src/cli.py +++ b/src/cli.py @@ -14,7 +14,7 @@ try: from src.p2sh import generate_p2sh_multisig from src.p2wpkh import generate_segwit_address from src.p2tr import generate_p2tr_address - from src.single_wallet import encrypt_single_wallet + from src.single_wallet import encrypt_single_wallet, decrypt_single_wallet except ImportError: from hd_wallet import generate_hd_wallet, encrypt_wallet, decrypt_wallet from p2pk import generate_p2pk @@ -22,7 +22,7 @@ except ImportError: from p2sh import generate_p2sh_multisig from p2wpkh import generate_segwit_address from p2tr import generate_p2tr_address - from single_wallet import encrypt_single_wallet + from single_wallet import encrypt_single_wallet, decrypt_single_wallet COMMANDS = { 'hd_generate': lambda a: generate_hd_wallet(**a), @@ -34,6 +34,7 @@ COMMANDS = { 'p2wpkh': lambda a: generate_segwit_address(**a), 'p2tr': lambda a: generate_p2tr_address(**a), 'single_encrypt': lambda a: encrypt_single_wallet(a['wallet'], a['password']), + 'single_decrypt': lambda a: decrypt_single_wallet(a['wallet'], a['password']), } if __name__ == '__main__': diff --git a/src/single_wallet.py b/src/single_wallet.py index 5e6ee29..9891dc4 100644 --- a/src/single_wallet.py +++ b/src/single_wallet.py @@ -2,9 +2,9 @@ import copy from typing import Any, Dict try: - from src.crypto import pw_encode + from src.crypto import pw_encode, pw_decode except ImportError: - from crypto import pw_encode + from crypto import pw_encode, pw_decode SENSITIVE_FIELDS = {'private_key_hex', 'private_key_wif'} @@ -24,6 +24,20 @@ def _encrypt_sensitive(data: Any, password: str) -> Any: return data +def _decrypt_sensitive(data: Any, password: str) -> Any: + if isinstance(data, dict): + out = {} + for key, value in data.items(): + if key in SENSITIVE_FIELDS and isinstance(value, str): + out[key] = pw_decode(value, password) + else: + out[key] = _decrypt_sensitive(value, password) + return out + if isinstance(data, list): + return [_decrypt_sensitive(item, password) for item in data] + return data + + def encrypt_single_wallet(wallet: Dict[str, Any], password: str) -> Dict[str, Any]: if not password: raise ValueError("Password is required for encryption.") @@ -32,3 +46,16 @@ def encrypt_single_wallet(wallet: Dict[str, Any], password: str) -> Dict[str, An if isinstance(encrypted, dict): encrypted['use_encryption'] = True return encrypted + + +def decrypt_single_wallet(wallet: Dict[str, Any], password: str) -> Dict[str, Any]: + if not wallet.get('use_encryption'): + return copy.deepcopy(wallet) + + if not password: + raise ValueError("Password is required for decryption.") + + decrypted = _decrypt_sensitive(copy.deepcopy(wallet), password) + if isinstance(decrypted, dict): + decrypted['use_encryption'] = False + return decrypted