diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 3256503..be9c5cb 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -1774,6 +1774,173 @@ const rpc = BrowserView.defineRPC({ auth.revoke(params.apiKey) }, + // ── Mobile pairing (relay via vault.keepkey.com) ───────── + generateMobilePairing: async () => { + if (!engine.wallet) throw new Error('No device connected') + const wallet = engine.wallet as any + + // Get device info + const features = await wallet.getFeatures() + const deviceId = features?.deviceId || features?.device_id || engine.getDeviceState().deviceId || 'keepkey-device' + const deviceLabel = features?.label || engine.getDeviceState().label || 'My KeepKey' + const context = `keepkey:${deviceLabel}.json` + + // Helper: convert hardened BIP44 path array to string + const pathToString = (p: number[]) => 'm/' + p.map((n: number) => n >= 0x80000000 ? `${n - 0x80000000}'` : String(n)).join('/') + // Helper: account-level path (first 3 elements) + const accountPath = (p: number[]) => p.slice(0, 3) + + // Only use built-in CHAINS (not custom chains — those may lack rpc methods) + const fwVersion = engine.getDeviceState().firmwareVersion + const builtinChains = CHAINS.filter(c => isChainSupported(c, fwVersion) && !c.hidden) + + const pubkeys: any[] = [] + + // ── BTC: all 3 script types ── + const btcScripts = [ + { scriptType: 'p2pkh', purpose: 44, type: 'xpub', note: 'Bitcoin Legacy' }, + { scriptType: 'p2sh-p2wpkh', purpose: 49, type: 'ypub', note: 'Bitcoin SegWit' }, + { scriptType: 'p2wpkh', purpose: 84, type: 'zpub', note: 'Bitcoin Native SegWit' }, + ] + const btcChain = builtinChains.find(c => c.id === 'bitcoin') + const btcNetwork = btcChain?.networkId || 'bip122:000000000019d6689c085ae165831e93' + for (const s of btcScripts) { + try { + const addressNList = [s.purpose + 0x80000000, 0x80000000, 0x80000000] + const addressNListMaster = [...addressNList, 0, 0] + const result = await wallet.getPublicKeys([{ + addressNList, coin: 'Bitcoin', scriptType: s.scriptType, curve: 'secp256k1', + }]) + const xpub = result?.[0]?.xpub + if (xpub && typeof xpub === 'string') { + pubkeys.push({ + type: s.type, pubkey: xpub, master: xpub, + address: xpub, // SDK expects address field + path: pathToString(addressNList), + pathMaster: pathToString(addressNListMaster), + scriptType: s.scriptType, + available_scripts_types: ['p2pkh', 'p2sh', 'p2wpkh', 'p2sh-p2wpkh'], + note: s.note, context, + networks: [btcNetwork], + addressNList, addressNListMaster, + }) + } + } catch (e: any) { console.warn(`[mobilePairing] BTC ${s.scriptType} failed:`, e.message) } + } + + // ── Non-BTC UTXO chains: batch xpub derivation ── + const utxoChains = builtinChains.filter(c => c.chainFamily === 'utxo' && c.id !== 'bitcoin') + if (utxoChains.length > 0) { + try { + const xpubResults = await wallet.getPublicKeys(utxoChains.map(c => ({ + addressNList: accountPath(c.defaultPath), coin: c.coin, + scriptType: c.scriptType, curve: 'secp256k1', + }))) || [] + for (let i = 0; i < utxoChains.length; i++) { + const xpub = xpubResults?.[i]?.xpub + if (xpub && typeof xpub === 'string') { + const chain = utxoChains[i] + const addressNList = accountPath(chain.defaultPath) + const addressNListMaster = [...addressNList, 0, 0] + pubkeys.push({ + type: 'xpub', pubkey: xpub, master: xpub, + address: xpub, + path: pathToString(addressNList), + pathMaster: pathToString(addressNListMaster), + scriptType: chain.scriptType, + available_scripts_types: [chain.scriptType || 'p2pkh'], + note: `${chain.symbol} Default path`, context, + networks: [chain.networkId], + addressNList, addressNListMaster, + }) + } + } + } catch (e: any) { console.warn('[mobilePairing] UTXO xpub batch failed:', e.message) } + } + + // ── EVM chains: derive ONCE, emit with all EVM networkIds + wildcard ── + const evmChains = builtinChains.filter(c => c.chainFamily === 'evm') + if (evmChains.length > 0) { + try { + const addressNList = [0x8000002C, 0x8000003C, 0x80000000] + const addressNListMaster = [0x8000002C, 0x8000003C, 0x80000000, 0, 0] + const result = await wallet.ethGetAddress({ addressNList: addressNListMaster, showDisplay: false, coin: 'Ethereum' }) + const address = typeof result === 'string' ? result : result?.address + if (address && typeof address === 'string') { + const evmNetworks = [...evmChains.map(c => c.networkId), 'eip155:*'] + pubkeys.push({ + type: 'address', pubkey: address, master: address, address, + path: pathToString(addressNList), + pathMaster: pathToString(addressNListMaster), + note: 'ETH primary (default)', context, + networks: evmNetworks, + addressNList, addressNListMaster, + }) + } + } catch (e: any) { console.warn('[mobilePairing] EVM address failed:', e.message) } + } + + // ── Non-EVM, non-UTXO chains: individual address derivation ── + const otherChains = builtinChains.filter(c => + c.chainFamily !== 'utxo' && c.chainFamily !== 'evm' && c.chainFamily !== 'zcash-shielded' + ) + for (const chain of otherChains) { + try { + const addrParams: any = { addressNList: chain.defaultPath, showDisplay: false, coin: chain.coin } + if (chain.scriptType) addrParams.scriptType = chain.scriptType + if (chain.chainFamily === 'ton') addrParams.bounceable = false + const method = chain.id === 'ripple' ? 'rippleGetAddress' : chain.rpcMethod + if (typeof wallet[method] !== 'function') { + console.warn(`[mobilePairing] ${chain.coin}: wallet.${method} not found, skipping`) + continue + } + const result = await wallet[method](addrParams) + const address = typeof result === 'string' ? result : result?.address + if (address && typeof address === 'string') { + pubkeys.push({ + type: 'address', pubkey: address, master: address, address, + path: pathToString(chain.defaultPath), + pathMaster: pathToString(chain.defaultPath), + scriptType: chain.scriptType || chain.chainFamily, + note: `Default ${chain.symbol} path`, context, + networks: [chain.networkId], + addressNList: chain.defaultPath, + addressNListMaster: chain.defaultPath, + }) + } + } catch (e: any) { console.warn(`[mobilePairing] ${chain.coin} address failed:`, e.message) } + } + + // Final safety: strip any entry with missing required fields + const validPubkeys = pubkeys.filter(p => + p.pubkey && typeof p.pubkey === 'string' && + p.pathMaster && typeof p.pathMaster === 'string' && + Array.isArray(p.networks) && p.networks.length > 0 + ) + + if (validPubkeys.length === 0) throw new Error('No pubkeys could be derived from device') + console.log(`[mobilePairing] ${validPubkeys.length} valid pubkeys (${pubkeys.length - validPubkeys.length} dropped)`) + + // POST to vault.keepkey.com relay + const RELAY_URL = 'https://vault.keepkey.com/api/pairing' + const resp = await fetch(RELAY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceId, label: deviceLabel, pubkeys: validPubkeys }), + }) + if (!resp.ok) { + const body = await resp.text().catch(() => '') + throw new Error(`Pairing relay returned ${resp.status}: ${body}`) + } + const data = await resp.json() as { success: boolean; code: string; expiresAt: number; expiresIn: number } + if (!data.success || !data.code) throw new Error('Invalid response from pairing relay') + + const qrPayload = JSON.stringify({ code: data.code, url: 'https://vault.keepkey.com' }) + console.log(`[mobilePairing] Code ${data.code} — ${validPubkeys.length} pubkeys sent to relay`) + + return { code: data.code, expiresAt: data.expiresAt, expiresIn: data.expiresIn, qrPayload } + }, + // ── App Settings ───────────────────────────────────────── getAppSettings: async () => { return getAppSettings() diff --git a/projects/keepkey-vault/src/mainview/App.tsx b/projects/keepkey-vault/src/mainview/App.tsx index 2508a9c..0a1b419 100644 --- a/projects/keepkey-vault/src/mainview/App.tsx +++ b/projects/keepkey-vault/src/mainview/App.tsx @@ -8,6 +8,8 @@ import { PairingApproval } from "./components/device/PairingApproval" import { SigningApproval } from "./components/device/SigningApproval" import { ApiAuditLog } from "./components/ApiAuditLog" import { PairedAppsPanel } from "./components/PairedAppsPanel" +import { MobilePairingDialog } from "./components/MobilePairingDialog" +import { MobilePanel } from "./components/MobilePanel" import { WalletConnectPanel } from "./components/WalletConnectPanel" import { FirmwareDropZone } from "./components/FirmwareDropZone" import { SplashScreen } from "./components/SplashScreen" @@ -230,6 +232,10 @@ function App() { // ── Paired Apps panel ─────────────────────────────────────────── const [pairedAppsOpen, setPairedAppsOpen] = useState(false) + // ── Mobile panel + pairing dialog ─────────────────────────────── + const [mobilePanelOpen, setMobilePanelOpen] = useState(false) + const [mobilePairingOpen, setMobilePairingOpen] = useState(false) + // ── API Audit Log ─────────────────────────────────────────────── const [auditLogOpen, setAuditLogOpen] = useState(false) const [auditLogEntries, setAuditLogEntries] = useState([]) @@ -651,7 +657,9 @@ function App() { needsFirmwareUpdate={deviceState.needsFirmwareUpdate} latestFirmware={deviceState.latestFirmware} onSettingsToggle={() => setSettingsOpen((o) => !o)} + onMobileToggle={() => setMobilePanelOpen((o) => !o)} settingsOpen={settingsOpen} + mobileOpen={mobilePanelOpen} activeTab={activeTab} onTabChange={handleTabChange} passphraseActive={deviceState.passphraseProtection} @@ -680,9 +688,14 @@ function App() { appVersion={appVersion} onOpenAuditLog={() => setAuditLogOpen(true)} onOpenPairedApps={() => setPairedAppsOpen(true)} + onOpenMobilePairing={() => setMobilePairingOpen(true)} onRestApiChanged={setRestApiEnabled} onWordCountChange={setRecoveryWordCount} /> + setMobilePairingOpen(false)} + /> setPairedAppsOpen(false)} /> + setMobilePanelOpen(false)} + deviceReady={deviceState.state === "ready"} + onOpenPairing={() => { setMobilePanelOpen(false); setMobilePairingOpen(true) }} + /> void onOpenPairedApps?: () => void + onOpenMobilePairing?: () => void onRestApiChanged?: (enabled: boolean) => void onWordCountChange?: (count: 12 | 18 | 24) => void } @@ -138,7 +139,7 @@ function VerificationBadge({ verified, t }: { verified?: boolean; t: (key: strin // ── Main Component ────────────────────────────────────────────────── -export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpdate, onDownloadUpdate, onApplyUpdate, updatePhase, updateVersion, appVersion, onOpenAuditLog, onOpenPairedApps, onRestApiChanged, onWordCountChange }: DeviceSettingsDrawerProps) { +export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpdate, onDownloadUpdate, onApplyUpdate, updatePhase, updateVersion, appVersion, onOpenAuditLog, onOpenPairedApps, onOpenMobilePairing, onRestApiChanged, onWordCountChange }: DeviceSettingsDrawerProps) { const { t } = useTranslation("settings") const [features, setFeatures] = useState(null) const [featuresError, setFeaturesError] = useState(false) @@ -961,6 +962,37 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd )} + {/* ── Mobile Pairing ────── */} + + + + + {t("mobilePairing.title")} + + + {t("mobilePairing.securityNote")} + + + { if (deviceState.state === "ready") onOpenMobilePairing?.() }} + > + {t("mobilePairing.button")} + + + + {/* ── App Version + Update Check ────── */} diff --git a/projects/keepkey-vault/src/mainview/components/MobilePairingDialog.tsx b/projects/keepkey-vault/src/mainview/components/MobilePairingDialog.tsx new file mode 100644 index 0000000..baf3158 --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/MobilePairingDialog.tsx @@ -0,0 +1,213 @@ +import { useState, useEffect, useCallback } from "react" +import { Box, Flex, Text, VStack, Button } from "@chakra-ui/react" +import { useTranslation } from "react-i18next" +import { rpcRequest } from "../lib/rpc" +import { generateQRSvg } from "../lib/qr" +import { Z } from "../lib/z-index" + +interface MobilePairingDialogProps { + open: boolean + onClose: () => void +} + +interface PairingResult { + code: string + expiresAt: number + expiresIn: number + qrPayload: string +} + +export function MobilePairingDialog({ open, onClose }: MobilePairingDialogProps) { + const { t } = useTranslation("settings") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [result, setResult] = useState(null) + const [timeLeft, setTimeLeft] = useState("") + + const generate = useCallback(async () => { + setLoading(true) + setError("") + setResult(null) + try { + const data = await rpcRequest("generateMobilePairing", undefined, 120000) + setResult(data) + } catch (e: any) { + setError(e.message || "Failed to generate pairing code") + } + setLoading(false) + }, []) + + // Generate on open + useEffect(() => { + if (open) generate() + }, [open, generate]) + + // Countdown timer + useEffect(() => { + if (!result?.expiresAt) { setTimeLeft(""); return } + const tick = () => { + const diff = result.expiresAt - Date.now() + if (diff <= 0) { setTimeLeft(t("mobilePairing.expired")); return } + const min = Math.floor(diff / 60000) + const sec = Math.floor((diff % 60000) / 1000) + setTimeLeft(`${min}:${sec.toString().padStart(2, "0")}`) + } + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [result?.expiresAt, t]) + + // Escape to close + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { e.preventDefault(); onClose() } + } + document.addEventListener("keydown", handler) + return () => document.removeEventListener("keydown", handler) + }, [open, onClose]) + + if (!open) return null + + const qrSvg = result?.qrPayload ? generateQRSvg(result.qrPayload, 5, 4) : "" + + return ( + { if (e.target === e.currentTarget) onClose() }} + > + e.stopPropagation()} + > + {/* Header */} + + + + + + + + {t("mobilePairing.title")} + + + + + + + + + + + {/* Loading */} + {loading && ( + + + {t("mobilePairing.generating")} + + + {t("mobilePairing.derivingKeys")} + + + )} + + {/* Error */} + {error && ( + + {error} + + )} + + {/* Success — QR + Code */} + {!loading && !error && result && ( + <> + {/* Pairing code */} + + + {t("mobilePairing.codeLabel")} + + + {result.code} + + {timeLeft && ( + + {timeLeft === t("mobilePairing.expired") ? timeLeft : t("mobilePairing.expiresIn", { time: timeLeft })} + + )} + + + {/* QR code */} + + + {t("mobilePairing.scanQr")} + + + + + {/* Instructions */} + + + {t("mobilePairing.instructions")} + + + 1. {t("mobilePairing.step1")} + 2. {t("mobilePairing.step2")} + 3. {t("mobilePairing.step3")} + + + + {/* Security note */} + + {t("mobilePairing.security")} + {t("mobilePairing.securityNote")} + + + )} + + {/* Actions */} + + {!loading && (error || result) && ( + + )} + + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx b/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx new file mode 100644 index 0000000..413012b --- /dev/null +++ b/projects/keepkey-vault/src/mainview/components/MobilePanel.tsx @@ -0,0 +1,196 @@ +import { Box, Flex, Text, VStack, IconButton } from "@chakra-ui/react" +import { useTranslation } from "react-i18next" +import { generateQRSvg } from "../lib/qr" +import { Z } from "../lib/z-index" +import { rpcRequest } from "../lib/rpc" + +const APP_STORE_URL = "https://apps.apple.com/us/app/keepkey-mobile/id6755204956" + +interface MobilePanelProps { + open: boolean + onClose: () => void + deviceReady?: boolean + onOpenPairing?: () => void +} + +export function MobilePanel({ open, onClose, deviceReady, onOpenPairing }: MobilePanelProps) { + const { t } = useTranslation("settings") + const appStoreQr = generateQRSvg(APP_STORE_URL, 4, 3) + + if (!open) return null + + return ( + + {/* Header */} + + + + + + + + {t("mobile.panelTitle")} + + + + + + + + + + + + + {/* ── Get the App ───────────────────────────────── */} + + + + + + + + + {t("mobile.getTheApp")} + + + + + {t("mobile.getTheAppDescription")} + + + {/* QR Code */} + + + + {t("mobile.scanToDownload")} + + + {/* Open in browser button */} + rpcRequest("openUrl", { url: APP_STORE_URL })} + > + {t("mobile.openAppStore")} + + + + {/* ── Pair Device ───────────────────────────────── */} + + + + + + + + {t("mobile.pairDevice")} + + + + + {t("mobile.pairDescription")} + + + { if (deviceReady && onOpenPairing) onOpenPairing() }} + > + {deviceReady ? t("mobile.pairNow") : t("mobile.connectDevice")} + + + + {/* ── About Mobile ──────────────────────────────── */} + + + {t("mobile.about")} + + + + 1. + {t("mobile.feature1")} + + + 2. + {t("mobile.feature2")} + + + 3. + {t("mobile.feature3")} + + + + + {t("mobile.watchOnlyNote")} + + + + + + + ) +} diff --git a/projects/keepkey-vault/src/mainview/components/TopNav.tsx b/projects/keepkey-vault/src/mainview/components/TopNav.tsx index e701d5a..91965d9 100644 --- a/projects/keepkey-vault/src/mainview/components/TopNav.tsx +++ b/projects/keepkey-vault/src/mainview/components/TopNav.tsx @@ -24,7 +24,9 @@ interface TopNavProps { needsFirmwareUpdate?: boolean latestFirmware?: string onSettingsToggle: () => void + onMobileToggle?: () => void settingsOpen?: boolean + mobileOpen?: boolean activeTab: NavTab onTabChange: (tab: NavTab) => void watchOnly?: boolean @@ -95,7 +97,7 @@ export function SplashNav() { ) } -export function TopNav({ label, connected, firmwareVersion, firmwareVerified, needsFirmwareUpdate, latestFirmware, onSettingsToggle, settingsOpen, activeTab, onTabChange, watchOnly, passphraseActive }: TopNavProps) { +export function TopNav({ label, connected, firmwareVersion, firmwareVerified, needsFirmwareUpdate, latestFirmware, onSettingsToggle, onMobileToggle, settingsOpen, mobileOpen, activeTab, onTabChange, watchOnly, passphraseActive }: TopNavProps) { const { t } = useTranslation("nav") const windowDrag = useWindowDrag() @@ -236,8 +238,23 @@ export function TopNav({ label, connected, firmwareVersion, firmwareVerified, ne })} - {/* Right: settings gear */} - + {/* Right: mobile + settings gear */} + + {onMobileToggle && ( + + + + + + + )}