Skip to content

Commit c08ec7b

Browse files
fix: detect KeepKey hot-swap + drop stale cache on device change (#49)
* fix: detect KeepKey hot-swap + drop stale cache on device change The extension treated any reachable vault as a reachable device, with no validation that the device currently paired with the vault is the same one whose pubkeys we cached. That made three failure modes silent: 1. Hot-swap on same vault — checkKeepKey kept state.deviceConnected=true and never re-probed, so addresses/balances pinned to the old device indefinitely. 2. Extension reload with a new device — init() loaded the old pubkey cache without comparing deviceId against the freshly-probed one, so cached addresses survived even a full BEX reload. 3. Vault auth rejection (stale apiKey after re-pair) — fetchPubkeys caught the error and silently fell back to cached pubkeys from the previous device, masking the re-pair requirement. Four narrow changes: - wallet.init() now compares cached deviceInfo.deviceId to the freshly- probed deviceId when a device is reachable. Mismatch → clear storage before fetchPubkeys runs. (Covers case 2.) - wallet.handleDeviceSwitch() primitive clears in-memory pubkeys/paths, resets the deviceConnected flag, and wipes pubkeyStorage so the next fetch rebuilds from the new device. Keeps SDK alive — only the device-specific state is dropped. - background/index.ts checkKeepKey now runs a periodic (30s, throttled) getFeatures re-probe while deviceConnected. Compares before/after deviceId; on mismatch calls handleDeviceSwitch which clears balance cache, per-chain address caches (Solana/Tron/TON), and triggers a fresh pubkey+balance refresh. (Covers case 1.) - fetchPubkeys no longer silently falls back to cache on auth errors. Detects 401 / "unauthorized" / "not paired" shapes, clears the stored apiKey, and throws a user-facing message for the sidebar to surface. Non-auth failures (network blip, device busy) still fall back to cache as view-only — that path is correct. (Covers case 3.) No user-visible UX change on the happy path. Recovery from device swap is now automatic within ~30s on steady state, immediate on extension reload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: rebuild default paths on device switch so refreshPubkeys has a batch handleDeviceSwitch cleared state.paths to [], but the caller (background checkKeepKey) then calls wallet.refreshPubkeys() which only probes + fetches — it does not repopulate paths. fetchPubkeys maps state.paths into the batch sent to the device, so an empty paths array meant an empty batch, meaning the hot-swap recovery finished with zero pubkeys and zero balances instead of the new device's view. Reset to getDefaultPaths() on switch so refreshPubkeys has something to query against. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: re-prefetch Solana/Tron/TON pubkeys on device switch refreshPubkeys only re-runs the default-path batch via wallet.getPublicKeys — that batch covers BTC / LTC / ETH / Cosmos / etc., but *not* SOL / TRX / TON. Those three are derived dynamically at onStart via prefetchSolanaPubkey / prefetchTronPubkey / prefetchTonAddress (each calls its chain's `*GetAddress` method and stashes the result in a per-chain cache + adds a pubkey to state). After a hot-swap: - handleDeviceSwitch clears the per-chain caches (resetXState) - refreshPubkeys repopulates the default batch - but nothing re-runs the three prefetches → SOL/TRX/TON vanish from the network dropdown / asset list / balance fetch until the user reloads the extension or manually navigates to those chains, which racy-triggers the lazy getAddress in each handler. Fan out all three prefetches in parallel via Promise.allSettled after refreshPubkeys. Non-throwing by design, so if one chain's derivation fails (minFirmware mismatch, etc.) the other two still recover. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0bac405 commit c08ec7b

2 files changed

Lines changed: 179 additions & 1 deletion

File tree

chrome-extension/src/background/index.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,66 @@ function pushStateChangeEvent() {
110110
let lastDeviceProbeAt = 0;
111111
const DEVICE_PROBE_INTERVAL_MS = 15_000;
112112

113+
// Separate, longer throttle for the "already-connected, verify deviceId hasn't
114+
// changed" re-probe. Vault + device hot-swap is rare enough that 30s latency
115+
// on detection is fine; keeping this longer than the view-only probe avoids
116+
// doubling the getFeatures traffic on the steady-state path.
117+
let lastDeviceVerifyAt = 0;
118+
const DEVICE_VERIFY_INTERVAL_MS = 30_000;
119+
120+
/**
121+
* Called when we detect that the vault is now paired with a different
122+
* KeepKey than the one we had cached. Clears every piece of state keyed
123+
* to the previous device and re-fetches from the new one.
124+
*/
125+
async function handleDeviceSwitch(newDeviceInfo: any) {
126+
const tag = TAG + ' | handleDeviceSwitch | ';
127+
console.warn(tag, 'Device swap detected. Purging caches and re-fetching.');
128+
129+
// In-memory + storage cache owned by wallet.ts
130+
await wallet.handleDeviceSwitch(newDeviceInfo);
131+
132+
// Balance caches — pubkey-keyed, so they're poisoned by the old device
133+
cachedBalances = [];
134+
balancesFetchInProgress = null;
135+
136+
// Per-chain address caches (Solana/Tron/TON each keep their own lookup
137+
// cache above the pubkey layer)
138+
resetSolanaState();
139+
resetTronState();
140+
resetTonState();
141+
142+
// Re-fetch against the new device. refreshPubkeys re-probes and pulls
143+
// a fresh pubkey batch, then updates state.initialized.
144+
try {
145+
await wallet.refreshPubkeys();
146+
147+
// refreshPubkeys only hits getDefaultPaths() — the big batched
148+
// derivation. Solana, Tron, and TON addresses are *dynamically*
149+
// added at onStart via these prefetches (SOL needs solanaGetAddress,
150+
// TRX needs tronGetAddress, TON needs tonGetAddress — none of which
151+
// are in the xpub.getPublicKeys batch). Without re-running them on
152+
// a device swap, those three chains end up with stale per-chain
153+
// caches (zeroed out by resetXState above) and no pubkeys, so
154+
// they'd vanish from the network dropdown until a user manually
155+
// visited the asset or reloaded the extension.
156+
//
157+
// Fire in parallel — each is non-throwing, so an individual chain
158+
// failure won't take the others down.
159+
await Promise.allSettled([prefetchSolanaPubkey(), prefetchTronPubkey(), prefetchTonAddress()]);
160+
161+
pushStateChangeEvent();
162+
pushBalancesUpdated();
163+
// Kick a fresh balance fetch in the background so the dashboard
164+
// swaps to the new device's balances without waiting for the next
165+
// user-triggered refresh.
166+
fetchBalancesFromPioneer(true).catch(e => console.warn(tag, 'Post-switch balance fetch failed:', e));
167+
} catch (e) {
168+
console.error(tag, 'Failed to re-fetch pubkeys after device switch:', (e as Error)?.message || e);
169+
pushStateChangeEvent();
170+
}
171+
}
172+
113173
async function checkKeepKey() {
114174
const prevState = KEEPKEY_STATE;
115175
try {
@@ -139,6 +199,28 @@ async function checkKeepKey() {
139199
// First-run case: init failed earlier (no device, no cache) — retry.
140200
lastDeviceProbeAt = now;
141201
onStart();
202+
} else if (wallet.isInitialized() && wallet.isDeviceConnected()) {
203+
// Steady state: vault-up, device-connected. Periodically re-probe
204+
// features to verify the same physical device is still paired. A
205+
// hot-swap to a different KeepKey will look identical to the /docs
206+
// endpoint but returns a different device_id from getFeatures.
207+
const mayVerify = now - lastDeviceVerifyAt >= DEVICE_VERIFY_INTERVAL_MS;
208+
if (mayVerify) {
209+
lastDeviceVerifyAt = now;
210+
const beforeId = wallet.getDeviceId();
211+
wallet
212+
.probeDevice()
213+
.then(ok => {
214+
if (!ok) return; // probe failure — next tick will set state=4
215+
const afterId = wallet.getDeviceId();
216+
if (beforeId && afterId && beforeId !== afterId) {
217+
handleDeviceSwitch(wallet.getDeviceInfo()).catch(e =>
218+
console.error(TAG, 'handleDeviceSwitch failed:', e),
219+
);
220+
}
221+
})
222+
.catch(e => console.warn(TAG, 'Feature re-probe failed:', (e as Error)?.message || e));
223+
}
142224
}
143225
}
144226
} catch (error: any) {

chrome-extension/src/background/wallet.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@ export async function init(): Promise<WalletState> {
9898
if (!state.deviceInfo) {
9999
state.deviceInfo = { label: 'KeepKey', model: 'KeepKey', deviceId: 'unknown' };
100100
}
101+
} else {
102+
// Device is reachable — validate that any cached pubkeys match this
103+
// specific device. Service worker restarts and hot-swaps can both
104+
// leave the pubkey cache pointing at a previously-connected device;
105+
// reusing it would surface the wrong addresses and stale balances.
106+
try {
107+
const cached = await pubkeyStorage.loadPubkeys();
108+
const cachedId = cached?.deviceInfo?.deviceId;
109+
const currentId = state.deviceInfo?.deviceId;
110+
if (cachedId && currentId && cachedId !== 'unknown' && cachedId !== currentId) {
111+
console.warn(tag, `Device changed: cache=${cachedId} → probed=${currentId}. Invalidating cache.`);
112+
await pubkeyStorage.clearPubkeys();
113+
}
114+
} catch (e) {
115+
console.warn(tag, 'Cache validation failed:', (e as Error)?.message || e);
116+
}
101117
}
102118

103119
// Set up paths
@@ -205,8 +221,28 @@ async function fetchPubkeys(): Promise<void> {
205221
}
206222
} catch (e) {
207223
console.error(tag, 'Error fetching pubkeys from device:', e);
208-
// Device call failed — downgrade to view-only and use cache if available
209224
state.deviceConnected = false;
225+
226+
// Auth errors mean the vault's pairing with this extension is broken
227+
// (e.g. user re-paired with a different device, or vault rotated the
228+
// key). Silently falling back to cached pubkeys from the *previous*
229+
// device would mask the re-pair requirement — the user would see
230+
// stale addresses with no explanation. Throw so the sidebar can
231+
// prompt re-pair and the cached pubkeys are only used when we've
232+
// chosen view-only mode, not when auth is rejecting us.
233+
if (isAuthError(e)) {
234+
console.warn(tag, 'Auth error from vault — clearing stale API key and failing fast');
235+
try {
236+
await keepKeyApiKeyStorage.saveApiKey('');
237+
} catch {
238+
/* ignore */
239+
}
240+
throw new Error('Vault auth failed. Your KeepKey needs to be re-paired with the extension.');
241+
}
242+
243+
// Non-auth failure (network blip, device busy, etc.) — view-only via
244+
// cache is acceptable. We keep deviceConnected=false so signing paths
245+
// will re-probe.
210246
if (cachedPubkeys.length > 0) {
211247
console.warn(tag, 'Falling back to', cachedPubkeys.length, 'cached pubkeys (view-only)');
212248
state.pubkeys = cachedPubkeys;
@@ -216,6 +252,26 @@ async function fetchPubkeys(): Promise<void> {
216252
}
217253
}
218254

255+
/**
256+
* Heuristic for "the vault rejected our credentials." Covers the
257+
* error shapes we've seen from the vault-sdk client: HTTP 401 wrapped
258+
* in a fetch error, plus bare messages that include those substrings.
259+
* Conservative — misclassifying a non-auth failure as auth would just
260+
* wipe the API key and force re-pairing, which is recoverable; the
261+
* opposite direction (missing a real auth error and silently serving
262+
* stale pubkeys) is the bug we're fixing.
263+
*/
264+
function isAuthError(e: unknown): boolean {
265+
const msg = ((e as Error)?.message || String(e)).toLowerCase();
266+
return (
267+
msg.includes('401') ||
268+
msg.includes('unauthorized') ||
269+
msg.includes('auth') ||
270+
msg.includes('invalid api key') ||
271+
msg.includes('not paired')
272+
);
273+
}
274+
219275
/**
220276
* Get the SDK instance, re-initializing if needed.
221277
*/
@@ -366,6 +422,46 @@ export function getDeviceInfo() {
366422
return state.deviceInfo;
367423
}
368424

425+
/**
426+
* Current device id, or null if unknown. Used by background polling to
427+
* detect device hot-swaps without duplicating the probe logic.
428+
*/
429+
export function getDeviceId(): string | null {
430+
const id = state.deviceInfo?.deviceId;
431+
return id && id !== 'unknown' ? id : null;
432+
}
433+
434+
/**
435+
* Tear down everything keyed to the previously-connected device so the
436+
* next `refreshPubkeys()` / `fetchPubkeys()` starts clean:
437+
* - in-memory pubkeys (cleared)
438+
* - paths (rebuilt to defaults — refreshPubkeys does NOT repopulate
439+
* them, so leaving paths empty would silently send an empty batch
440+
* to the device and return zero pubkeys)
441+
* - persisted pubkey cache (wiped)
442+
* - `deviceConnected` flag (forces re-probe on next call)
443+
*
444+
* We intentionally keep `state.sdk` alive — the vault REST client can
445+
* happily serve the new device once pubkeys are re-fetched — and we
446+
* keep `state.deviceInfo` populated with the freshly-probed device's
447+
* info (passed in by the caller) so UI can show the new label
448+
* immediately without waiting for the re-fetch to finish.
449+
*/
450+
export async function handleDeviceSwitch(newDeviceInfo: WalletState['deviceInfo']): Promise<void> {
451+
const tag = TAG + ' | handleDeviceSwitch | ';
452+
console.warn(tag, 'Device switch detected — clearing caches');
453+
state.pubkeys = [];
454+
state.paths = getDefaultPaths();
455+
state.deviceInfo = newDeviceInfo;
456+
state.deviceConnected = false;
457+
state.initialized = false;
458+
try {
459+
await pubkeyStorage.clearPubkeys();
460+
} catch (e) {
461+
console.warn(tag, 'Failed to clear pubkey storage:', e);
462+
}
463+
}
464+
369465
/**
370466
* Get the full wallet state (for backward compat with APP references).
371467
*/

0 commit comments

Comments
 (0)