Skip to content

Commit 5038039

Browse files
committed
Address PR review feedback from @piyalbasu
Six concrete code issues raised on PR #2802: 1. signOut handler now dispatches lockHardwareWallet() alongside logOut() so HW sessions are locked on explicit sign-out. logOut resets state to initialState (isHardwareWalletLocked: false) but KEY_ID (hw: prefix) is intentionally not cleared, so without this the HW branch of buildHasPrivateKeySelector would report UNLOCKED after sign-out. 2. loginToAllAccounts's two catch blocks now rethrow after clearSession(). Previously execution fell through to startSession() + broadcastSessionState(SESSION_UNLOCKED), arming an idle alarm and telling every Freighter surface the wallet was unlocked even though it had just been cleared. 3. importHardwareWallet now arms the idle auto-lock alarm. popupMessageListener threads sessionTimer into the handler so HW-only sessions imported via this path are subject to the same auto-lock guarantees as hot-wallet sessions. 4. SessionLockListener wraps the SESSION_UNLOCKED fire-and-forget loadAccount in try/catch, and guards against an undefined response. Previously a rejection or undefined result could crash the surface with 'Cannot destructure property hasPrivateKey of undefined'. 5. SessionLockListener now dispatches lockAccount() unconditionally on SESSION_LOCKED; only the navigate is suppressed when the surface is already on /unlock-account. Without this, a surface parked on /unlock-account kept hasPrivateKey: true in redux while the background was locked, and ActivityTracker kept sending pings. 6. saveSettings now uses a precise SaveSettingsResponse type end-to-end (handler return, @shared/api client, popup duck thunk), removing the 'as unknown as typeof response' cast at the client boundary. sendMessageToBackground is generic over the response type so handler boundaries can be typed without per-call coercion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7793c28 commit 5038039

14 files changed

Lines changed: 322 additions & 38 deletions

File tree

@shared/api/helpers/extensionMessaging.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,19 @@ export const sendMessageToContentScript = (msg: Msg): Promise<Response> => {
7171
});
7272
};
7373

74-
export const sendMessageToBackground = async (msg: Msg): Promise<Response> => {
74+
export const sendMessageToBackground = async <T = Response>(
75+
msg: Msg,
76+
): Promise<T> => {
7577
let res;
7678

7779
if (DEV_SERVER) {
7880
// treat this as an external call because we're making the call from the browser, not the popup
7981
res = await sendMessageToContentScript(msg);
8082
} else {
81-
res = (await browser.runtime.sendMessage(msg)) as Response;
83+
res = await browser.runtime.sendMessage(msg);
8284
}
8385

84-
return res as Response;
86+
return res as T;
8587
};
8688

8789
export const FreighterApiNodeError = {

@shared/api/internal.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
CollectibleContract,
6565
DiscoverData,
6666
RecentProtocolEntry,
67+
SaveSettingsResponse,
6768
} from "./types";
6869
import {
6970
AccountBalancesInterface,
@@ -1630,43 +1631,35 @@ export const saveSettings = async ({
16301631
isHideDustEnabled: boolean;
16311632
isOpenSidebarByDefault: boolean;
16321633
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
1633-
}): Promise<Settings & IndexerSettings> => {
1634-
let response = {
1634+
}): Promise<SaveSettingsResponse> => {
1635+
let response: SaveSettingsResponse = {
16351636
allowList: DEFAULT_ALLOW_LIST,
16361637
isDataSharingAllowed: false,
16371638
networkDetails: MAINNET_NETWORK_DETAILS,
16381639
networksList: DEFAULT_NETWORKS,
16391640
isMemoValidationEnabled: true,
16401641
isRpcHealthy: false,
1641-
userNotification: { enabled: false, message: "" },
1642-
settingsState: SettingsState.IDLE,
16431642
isSorobanPublicEnabled: false,
16441643
isNonSSLEnabled: false,
16451644
isHideDustEnabled: true,
16461645
isOpenSidebarByDefault: false,
16471646
autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
1648-
error: "",
1649-
hiddenAssets: {},
16501647
};
16511648

16521649
try {
1653-
response = (await sendMessageToBackground({
1650+
response = await sendMessageToBackground<SaveSettingsResponse>({
16541651
activePublicKey,
16551652
isDataSharingAllowed,
16561653
isMemoValidationEnabled,
16571654
isHideDustEnabled,
16581655
isOpenSidebarByDefault,
16591656
autoLockTimeoutMinutes,
16601657
type: SERVICE_TYPES.SAVE_SETTINGS,
1661-
})) as unknown as typeof response;
1658+
});
16621659
} catch (e) {
16631660
console.error(e);
16641661
}
16651662

1666-
if (response.error) {
1667-
throw new Error(response.error);
1668-
}
1669-
16701663
return response;
16711664
};
16721665

@shared/api/types/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,20 @@ export interface IndexerSettings {
222222
userNotification: UserNotification;
223223
}
224224

225+
export type SaveSettingsResponse = {
226+
allowList: AllowList;
227+
isDataSharingAllowed: boolean;
228+
isMemoValidationEnabled: boolean;
229+
networkDetails: NetworkDetails;
230+
networksList: NetworkDetails[];
231+
isRpcHealthy: boolean;
232+
isSorobanPublicEnabled: boolean;
233+
isNonSSLEnabled: boolean;
234+
isHideDustEnabled: boolean;
235+
isOpenSidebarByDefault: boolean;
236+
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
237+
};
238+
225239
export type Settings = {
226240
allowList: AllowList;
227241
networkDetails: NetworkDetails;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { importHardwareWallet } from "../handlers/importHardwareWallet";
2+
3+
const mockStoreHardwareWalletAccount = jest.fn().mockResolvedValue(undefined);
4+
const mockGetBipPath = jest.fn().mockResolvedValue("m/44'/148'/0'");
5+
6+
jest.mock("../helpers/store-hardware-wallet", () => ({
7+
storeHardwareWalletAccount: (...args: unknown[]) =>
8+
mockStoreHardwareWalletAccount(...args),
9+
}));
10+
11+
jest.mock("background/helpers/account", () => ({
12+
getBipPath: (...args: unknown[]) => mockGetBipPath(...args),
13+
getIsHardwareWalletActive: jest.fn().mockResolvedValue(true),
14+
}));
15+
16+
describe("importHardwareWallet handler", () => {
17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
});
20+
21+
// Regression: HW-only imports flip the session into the "HW-active"
22+
// state where `buildHasPrivateKeySelector` reports the wallet as
23+
// unlocked, but the handler previously did not arm the idle
24+
// auto-lock alarm. HW-only sessions imported via this path had no
25+
// auto-lock at all, contradicting the PR's stated security goal.
26+
it("arms the idle auto-lock alarm after storing the HW account", async () => {
27+
const sessionStore = {
28+
dispatch: jest.fn(),
29+
getState: jest.fn().mockReturnValue({
30+
session: { publicKey: "GBHW", allAccounts: [], isHardwareWalletLocked: false },
31+
}),
32+
} as any;
33+
const localStore = {
34+
getItem: jest.fn().mockResolvedValue("hw:GBHW"),
35+
} as any;
36+
const sessionTimer = {
37+
startSession: jest.fn().mockResolvedValue(undefined),
38+
} as any;
39+
40+
await importHardwareWallet({
41+
request: {
42+
publicKey: "GBHW",
43+
hardwareWalletType: "Ledger",
44+
bipPath: "m/44'/148'/0'",
45+
} as any,
46+
sessionStore,
47+
localStore,
48+
sessionTimer,
49+
});
50+
51+
expect(mockStoreHardwareWalletAccount).toHaveBeenCalledTimes(1);
52+
expect(sessionTimer.startSession).toHaveBeenCalledTimes(1);
53+
// Account is stored before the timer arms so the alarm fires
54+
// relative to an already-active HW session, not a future one.
55+
expect(
56+
mockStoreHardwareWalletAccount.mock.invocationCallOrder[0],
57+
).toBeLessThan(sessionTimer.startSession.mock.invocationCallOrder[0]);
58+
});
59+
});

extension/src/background/messageListener/__tests__/login-all-accounts.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,80 @@ describe("loginToAllAccounts", () => {
113113
mockFlushSessionStore.mock.invocationCallOrder[0],
114114
).toBeLessThan(mockBroadcastSessionState.mock.invocationCallOrder[0]);
115115
});
116+
117+
// Regression: the two catch blocks call `clearSession()` (which wipes
118+
// hashKey, sets `isHardwareWalletLocked=true`, removes
119+
// TEMPORARY_STORE_ID), but previously did not return/throw. Execution
120+
// continued to the unconditional `startSession()` + `flushSessionStore()`
121+
// + `broadcastSessionState(SESSION_UNLOCKED)` at the bottom, telling
122+
// every Freighter surface the wallet was unlocked even though it had
123+
// just been cleared. Both catches must rethrow so the caller knows the
124+
// unlock failed and the unlock broadcast does not fire.
125+
it("rethrows and does not broadcast SESSION_UNLOCKED when storing the active mnemonic fails", async () => {
126+
mockStoreEncryptedTemporaryData.mockReset();
127+
mockStoreEncryptedTemporaryData.mockRejectedValueOnce(
128+
new Error("storage offline"),
129+
);
130+
131+
const localStore = {
132+
getItem: jest.fn().mockResolvedValue(""),
133+
remove: jest.fn().mockResolvedValue(undefined),
134+
} as any;
135+
const sessionStore = {
136+
dispatch: jest.fn().mockResolvedValue(undefined),
137+
getState: jest
138+
.fn()
139+
.mockReturnValue({ session: { publicKey: "", allAccounts: [] } }),
140+
} as any;
141+
const sessionTimer = {
142+
startSession: jest.fn().mockResolvedValue(undefined),
143+
} as any;
144+
145+
await expect(
146+
loginToAllAccounts(
147+
"password",
148+
localStore,
149+
sessionStore,
150+
{} as any,
151+
sessionTimer,
152+
),
153+
).rejects.toThrow("storage offline");
154+
155+
expect(mockClearSession).toHaveBeenCalledTimes(1);
156+
expect(sessionTimer.startSession).not.toHaveBeenCalled();
157+
expect(mockBroadcastSessionState).not.toHaveBeenCalled();
158+
});
159+
160+
it("rethrows and does not broadcast SESSION_UNLOCKED when storing the active hash key fails", async () => {
161+
mockStoreActiveHashKey.mockReset();
162+
mockStoreActiveHashKey.mockRejectedValueOnce(new Error("hash key write"));
163+
164+
const localStore = {
165+
getItem: jest.fn().mockResolvedValue(""),
166+
remove: jest.fn().mockResolvedValue(undefined),
167+
} as any;
168+
const sessionStore = {
169+
dispatch: jest.fn().mockResolvedValue(undefined),
170+
getState: jest
171+
.fn()
172+
.mockReturnValue({ session: { publicKey: "", allAccounts: [] } }),
173+
} as any;
174+
const sessionTimer = {
175+
startSession: jest.fn().mockResolvedValue(undefined),
176+
} as any;
177+
178+
await expect(
179+
loginToAllAccounts(
180+
"password",
181+
localStore,
182+
sessionStore,
183+
{} as any,
184+
sessionTimer,
185+
),
186+
).rejects.toThrow("hash key write");
187+
188+
expect(mockClearSession).toHaveBeenCalledTimes(1);
189+
expect(sessionTimer.startSession).not.toHaveBeenCalled();
190+
expect(mockBroadcastSessionState).not.toHaveBeenCalled();
191+
});
116192
});

extension/src/background/messageListener/__tests__/signOut.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SERVICE_TYPES } from "@shared/constants/services";
22

3+
import { lockHardwareWallet, logOut } from "background/ducks/session";
34
import { signOut } from "../handlers/signOut";
45

56
const mockFlushSessionStore = jest.fn().mockResolvedValue(undefined);
@@ -59,4 +60,47 @@ describe("signOut handler", () => {
5960
localStore.remove.mock.invocationCallOrder[0],
6061
).toBeLessThan(mockBroadcastSessionState.mock.invocationCallOrder[0]);
6162
});
63+
64+
// Regression: `logOut` resets session state to `initialState`, which
65+
// sets `isHardwareWalletLocked: false`. `KEY_ID` (`hw:…` prefix) is
66+
// intentionally not cleared on sign-out, so
67+
// `getIsHardwareWalletActive` still returns `true` afterwards — which
68+
// means `buildHasPrivateKeySelector`'s HW branch would falsely
69+
// report the wallet UNLOCKED for HW users after an explicit
70+
// sign-out. The handler must also dispatch `lockHardwareWallet()`.
71+
it("dispatches lockHardwareWallet alongside logOut so HW sessions are locked on sign-out", async () => {
72+
const localStore = {
73+
getItem: jest.fn().mockResolvedValue("MNEMONIC_PHRASE_CONFIRMED"),
74+
remove: jest.fn().mockResolvedValue(undefined),
75+
} as any;
76+
const sessionStore = {
77+
dispatch: jest.fn(),
78+
getState: jest.fn().mockReturnValue({
79+
session: { publicKey: "" },
80+
}),
81+
} as any;
82+
const sessionTimer = {
83+
stopSession: jest.fn().mockResolvedValue(undefined),
84+
} as any;
85+
86+
await signOut({ localStore, sessionStore, sessionTimer });
87+
88+
const dispatched = sessionStore.dispatch.mock.calls.map(
89+
(c: unknown[]) => c[0],
90+
);
91+
expect(dispatched).toEqual(
92+
expect.arrayContaining([logOut(), lockHardwareWallet()]),
93+
);
94+
// lockHardwareWallet must run *after* logOut so logOut's
95+
// initialState reset (which sets isHardwareWalletLocked: false)
96+
// does not undo the HW-lock flag.
97+
const logOutIdx = dispatched.findIndex(
98+
(a: { type?: string }) => a?.type === logOut.type,
99+
);
100+
const lockHwIdx = dispatched.findIndex(
101+
(a: { type?: string }) => a?.type === lockHardwareWallet.type,
102+
);
103+
expect(logOutIdx).toBeGreaterThanOrEqual(0);
104+
expect(lockHwIdx).toBeGreaterThan(logOutIdx);
105+
});
62106
});

extension/src/background/messageListener/handlers/importHardwareWallet.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@ import {
99
publicKeySelector,
1010
} from "background/ducks/session";
1111
import { getBipPath } from "background/helpers/account";
12+
import { SessionTimer } from "background/helpers/session";
1213

1314
export const importHardwareWallet = async ({
1415
request,
1516
sessionStore,
1617
localStore,
18+
sessionTimer,
1719
}: {
1820
request: ImportHardWareWalletMessage;
1921
sessionStore: Store;
2022
localStore: DataStorageAccess;
23+
sessionTimer: SessionTimer;
2124
}) => {
2225
const { publicKey, hardwareWalletType, bipPath } = request;
2326

@@ -28,6 +31,12 @@ export const importHardwareWallet = async ({
2831
sessionStore,
2932
localStore,
3033
});
34+
// Importing a hardware wallet flips the session into the "HW-active"
35+
// state where `buildHasPrivateKeySelector` reports the wallet as
36+
// unlocked. Arm the idle auto-lock alarm here so HW-only sessions
37+
// are subject to the same idle-lock guarantees as hot-wallet
38+
// sessions — otherwise an HW-only import has no auto-lock at all.
39+
await sessionTimer.startSession();
3140
const hasPrivateKeySelector = buildHasPrivateKeySelector(localStore);
3241

3342
return {

extension/src/background/messageListener/handlers/saveSettings.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Store } from "redux";
22

33
import { SaveSettingsMessage } from "@shared/api/types/message-request";
4+
import { SaveSettingsResponse } from "@shared/api/types/types";
45
import { coerceAutoLockTimeoutMinutes } from "@shared/constants/autoLock";
56
import {
67
getAllowList,
@@ -35,7 +36,7 @@ export const saveSettings = async ({
3536
localStore: DataStorageAccess;
3637
sessionStore: Store;
3738
sessionTimer: SessionTimer;
38-
}) => {
39+
}): Promise<SaveSettingsResponse> => {
3940
const {
4041
isDataSharingAllowed,
4142
isMemoValidationEnabled,

extension/src/background/messageListener/handlers/signOut.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { Store } from "redux";
22
import { SERVICE_TYPES } from "@shared/constants/services";
33

4-
import { logOut, publicKeySelector } from "background/ducks/session";
4+
import {
5+
lockHardwareWallet,
6+
logOut,
7+
publicKeySelector,
8+
} from "background/ducks/session";
59
import { DataStorageAccess } from "background/helpers/dataStorageAccess";
610
import { SessionTimer } from "background/helpers/session";
711
import { broadcastSessionState } from "../helpers/broadcast-session-state";
@@ -27,6 +31,14 @@ export const signOut = async ({
2731
// state and emit a duplicate SESSION_LOCKED broadcast.
2832
await sessionTimer.stopSession();
2933
sessionStore.dispatch(logOut());
34+
// `logOut` resets session state to `initialState`, which sets
35+
// `isHardwareWalletLocked: false`. The KEY_ID (`hw:…` prefix) is
36+
// intentionally not cleared on sign-out (so the HW account can be
37+
// re-unlocked without re-importing), which means
38+
// `getIsHardwareWalletActive` still reports `true`. Without this
39+
// dispatch, `buildHasPrivateKeySelector`'s HW branch would report
40+
// the wallet UNLOCKED for HW users immediately after sign-out.
41+
sessionStore.dispatch(lockHardwareWallet());
3042
await flushSessionStore(sessionStore);
3143
await localStore.remove(TEMPORARY_STORE_ID);
3244
await broadcastSessionState(SERVICE_TYPES.SESSION_LOCKED);

extension/src/background/messageListener/helpers/login-all-accounts.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ export const loginToAllAccounts = async (
9898
captureException(
9999
`Error storing encrypted temporary data: ${JSON.stringify(e)}`,
100100
);
101+
// Rethrow so we don't fall through to startSession() +
102+
// broadcastSessionState(SESSION_UNLOCKED) below, which would tell
103+
// every Freighter surface the wallet is unlocked even though
104+
// clearSession() just wiped it.
105+
throw e;
101106
}
102107

103108
for (let i = 0; i < keyIdList.length; i += 1) {
@@ -135,6 +140,10 @@ export const loginToAllAccounts = async (
135140
} catch (e) {
136141
await clearSession({ localStore, sessionStore });
137142
captureException(`Error storing active hash key: ${JSON.stringify(e)}`);
143+
// Rethrow so we don't fall through to startSession() +
144+
// broadcastSessionState(SESSION_UNLOCKED) below — the session was
145+
// just cleared and the wallet must not be reported as unlocked.
146+
throw e;
138147
}
139148

140149
// start the timer now that we have active private key

0 commit comments

Comments
 (0)