Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions projects/keepkey-vault/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1774,6 +1774,173 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
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()
Expand Down
19 changes: 19 additions & 0 deletions projects/keepkey-vault/src/mainview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<ApiLogEntry[]>([])
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -680,9 +688,14 @@ function App() {
appVersion={appVersion}
onOpenAuditLog={() => setAuditLogOpen(true)}
onOpenPairedApps={() => setPairedAppsOpen(true)}
onOpenMobilePairing={() => setMobilePairingOpen(true)}
onRestApiChanged={setRestApiEnabled}
onWordCountChange={setRecoveryWordCount}
/>
<MobilePairingDialog
open={mobilePairingOpen}
onClose={() => setMobilePairingOpen(false)}
/>
<ApiAuditLog
open={auditLogOpen}
entries={auditLogEntries}
Expand All @@ -693,6 +706,12 @@ function App() {
open={pairedAppsOpen}
onClose={() => setPairedAppsOpen(false)}
/>
<MobilePanel
open={mobilePanelOpen}
onClose={() => setMobilePanelOpen(false)}
deviceReady={deviceState.state === "ready"}
onOpenPairing={() => { setMobilePanelOpen(false); setMobilePairingOpen(true) }}
/>
<WalletConnectPanel
open={wcPanelOpen}
wcUri={wcUri}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface DeviceSettingsDrawerProps {
appVersion?: { version: string; channel: string } | null
onOpenAuditLog?: () => void
onOpenPairedApps?: () => void
onOpenMobilePairing?: () => void
onRestApiChanged?: (enabled: boolean) => void
onWordCountChange?: (count: 12 | 18 | 24) => void
}
Expand Down Expand Up @@ -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<DeviceFeatures | null>(null)
const [featuresError, setFeaturesError] = useState(false)
Expand Down Expand Up @@ -961,6 +962,37 @@ export function DeviceSettingsDrawer({ open, onClose, deviceState, onCheckForUpd
)}
</Box>

{/* ── Mobile Pairing ────── */}
<Box pt="3" borderTop="1px solid" borderColor="rgba(255,255,255,0.06)">
<Flex justify="space-between" align="center">
<Box>
<Text fontSize="md" color="kk.textPrimary" fontWeight="500">
{t("mobilePairing.title")}
</Text>
<Text fontSize="xs" color="kk.textSecondary" mt="0.5">
{t("mobilePairing.securityNote")}
</Text>
</Box>
<Box
as="button"
px="3"
py="1.5"
borderRadius="full"
bg="rgba(192,168,96,0.12)"
color="kk.gold"
fontSize="xs"
fontWeight="500"
cursor={deviceState.state !== "ready" ? "not-allowed" : "pointer"}
opacity={deviceState.state !== "ready" ? 0.4 : 1}
_hover={deviceState.state === "ready" ? { bg: "rgba(192,168,96,0.22)" } : {}}
transition="all 0.15s"
onClick={() => { if (deviceState.state === "ready") onOpenMobilePairing?.() }}
>
{t("mobilePairing.button")}
</Box>
</Flex>
</Box>

{/* ── App Version + Update Check ────── */}
<Box pt="3" borderTop="1px solid" borderColor="rgba(255,255,255,0.06)">
<Flex justify="space-between" align="center">
Expand Down
Loading
Loading