Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3578408
Replace 24h absolute auto-lock with configurable idle timer
Copilot May 21, 2026
54aa0b8
Address PR review feedback from Copilot reviewer
Copilot May 21, 2026
e710fa1
Address PR review feedback: treat shortened-timeout save as activity;…
Copilot May 22, 2026
0c1d6ca
Strengthen shortened-timeout regression test
Copilot May 22, 2026
b00599b
Flip popup to unlock screen when idle auto-lock fires
Copilot May 26, 2026
ecf489b
Preserve interrupted route context across auto-lock
Copilot May 26, 2026
6b22e3a
Address PR review feedback: idle auto-lock cross-surface consistency
Copilot May 27, 2026
f3e4598
Address internal review: require hasPrivateKey in useGetAppData cache…
Copilot May 27, 2026
2013aad
Make SessionLockListener handler synchronous to avoid claiming runtim…
Copilot May 27, 2026
ece5971
Move post-unlock navigation from SessionLockListener to UnlockAccount
Copilot May 27, 2026
a5ed79d
Reset idle alarm when a new Freighter surface mounts unlocked
Copilot May 27, 2026
b3868f9
Fire surface-mount activity ping unconditionally; gate on lock state …
Copilot May 27, 2026
69ab958
TEMP: instrument auto-lock path for diagnosis (will be reverted)
Copilot May 27, 2026
c0f2e16
Accept extension-origin tab senders in popupMessageListener gate
Copilot May 27, 2026
7b3e4dd
Drop surface-mount USER_ACTIVITY ping; only user input resets the idl…
Copilot May 27, 2026
7793c28
Address review feedback: scope-drift cleanup and small hardening
Copilot May 27, 2026
5038039
Address PR review feedback from @piyalbasu
Copilot May 27, 2026
c9487e6
Drop loadSettings cast using generic sendMessageToBackground
Copilot May 27, 2026
8a1376f
Add dedicated Auto-Lock Timer settings page under Security
Copilot May 29, 2026
191f6cf
Change 4h option to 6h, add 12h option, default to 12h
Copilot May 29, 2026
bc00233
Restore saveSettings error envelope check
Copilot Jun 2, 2026
4c3ca0b
Merge branch 'master' into plan-do-review/issue-2082
piyalbasu Jun 17, 2026
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
21 changes: 17 additions & 4 deletions @shared/api/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
} from "stellar-sdk";
import BigNumber from "bignumber.js";
import { INDEXER_URL, INDEXER_V2_URL } from "@shared/constants/mercury";
import {
AutoLockTimeoutMinutes,
DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
} from "@shared/constants/autoLock";
import {
AssetListResponse,
AssetsListItem,
Expand Down Expand Up @@ -1618,13 +1622,15 @@ export const saveSettings = async ({
isMemoValidationEnabled,
isHideDustEnabled,
isOpenSidebarByDefault,
autoLockTimeoutMinutes,
}: {
activePublicKey: string;
isDataSharingAllowed: boolean;
isMemoValidationEnabled: boolean;
isHideDustEnabled: boolean;
isOpenSidebarByDefault: boolean;
}): Promise<Settings & IndexerSettings> => {
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
}): Promise<Settings & IndexerSettings & { wasLocked?: boolean }> => {
let response = {
allowList: DEFAULT_ALLOW_LIST,
isDataSharingAllowed: false,
Expand All @@ -1638,19 +1644,22 @@ export const saveSettings = async ({
isNonSSLEnabled: false,
isHideDustEnabled: true,
isOpenSidebarByDefault: false,
autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
error: "",
hiddenAssets: {},
wasLocked: false,
};

try {
response = await sendMessageToBackground({
response = (await sendMessageToBackground({
activePublicKey,
isDataSharingAllowed,
isMemoValidationEnabled,
isHideDustEnabled,
isOpenSidebarByDefault,
autoLockTimeoutMinutes,
type: SERVICE_TYPES.SAVE_SETTINGS,
});
})) as unknown as typeof response;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this type of casting is a code smell. address this type properly

} catch (e) {
console.error(e);
}
Expand Down Expand Up @@ -1866,7 +1875,11 @@ export const loadSettings = (): Promise<
sendMessageToBackground({
activePublicKey: null,
type: SERVICE_TYPES.LOAD_SETTINGS,
});
}) as unknown as Promise<
Settings &
IndexerSettings &
ExperimentalFeatures & { assetsLists: AssetsLists }
>;

export const loadBackendSettings = async (): Promise<{
isSorobanPublicEnabled: boolean;
Expand Down
9 changes: 8 additions & 1 deletion @shared/api/types/message-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CollectibleKey,
} from "./types";
import { AssetsListItem } from "@shared/constants/soroban/asset-list";
import { AutoLockTimeoutMinutes } from "@shared/constants/autoLock";

export interface TokenToAdd {
domain: string;
Expand Down Expand Up @@ -282,6 +283,11 @@ export interface SaveSettingsMessage extends BaseMessage {
isMemoValidationEnabled: boolean;
isDataSharingAllowed: boolean;
isOpenSidebarByDefault: boolean;
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
}

export interface UserActivityMessage extends BaseMessage {
type: SERVICE_TYPES.USER_ACTIVITY;
}

export interface SaveExperimentalFeaturesMessage extends BaseMessage {
Expand Down Expand Up @@ -547,4 +553,5 @@ export type ServiceMessageRequest =
| GetHiddenCollectiblesMessage
| MarkQueueActiveMessage
| OpenSidebarMessage
| RejectSigningRequestMessage;
| RejectSigningRequestMessage
| UserActivityMessage;
2 changes: 2 additions & 0 deletions @shared/api/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
AssetsLists,
AssetsListItem,
} from "../../constants/soroban/asset-list";
import { AutoLockTimeoutMinutes } from "../../constants/autoLock";

export enum ActionStatus {
IDLE = "IDLE",
Expand Down Expand Up @@ -189,6 +190,7 @@ export interface Preferences {
networksList: NetworkDetails[];
isHideDustEnabled: boolean;
isOpenSidebarByDefault: boolean;
autoLockTimeoutMinutes: AutoLockTimeoutMinutes;
error: string;
}

Expand Down
27 changes: 27 additions & 0 deletions @shared/constants/autoLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Single source of truth for the idle auto-lock timeout feature.
*
* The browser session is locked after this many minutes of user
* inactivity across all extension surfaces (popup, sidebar, standalone
* signing windows, grant-access windows). Any user interaction inside
* an extension page resets the timer.
*/
export const VALID_AUTO_LOCK_TIMEOUT_MINUTES = [1, 5, 15, 30, 60] as const;

export type AutoLockTimeoutMinutes =
(typeof VALID_AUTO_LOCK_TIMEOUT_MINUTES)[number];

export const DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES: AutoLockTimeoutMinutes = 15;
Comment thread
JakeUrban marked this conversation as resolved.
Outdated

export const isValidAutoLockTimeoutMinutes = (
value: unknown,
): value is AutoLockTimeoutMinutes =>
typeof value === "number" &&
(VALID_AUTO_LOCK_TIMEOUT_MINUTES as readonly number[]).includes(value);

export const coerceAutoLockTimeoutMinutes = (
value: unknown,
): AutoLockTimeoutMinutes =>
isValidAutoLockTimeoutMinutes(value)
? value
: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES;
1 change: 1 addition & 0 deletions @shared/constants/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export enum SERVICE_TYPES {
CLEAR_RECENT_PROTOCOLS = "CLEAR_RECENT_PROTOCOLS",
GET_DISCOVER_WELCOME_SEEN = "GET_DISCOVER_WELCOME_SEEN",
DISMISS_DISCOVER_WELCOME = "DISMISS_DISCOVER_WELCOME",
USER_ACTIVITY = "USER_ACTIVITY",
}

// SIDEBAR_NAVIGATE is a plain string constant (not in an enum) because it is
Expand Down
32 changes: 31 additions & 1 deletion extension/src/background/ducks/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const initialState: InitialState = {
},
allAccounts: [] as Account[],
migratedMnemonicPhrase: "",
isHardwareWalletLocked: false,
};

interface UiData {
Expand All @@ -70,6 +71,12 @@ interface AppData {
privateKey?: string;
hashKey?: { key: string };
password?: string;
// True once the idle auto-lock alarm fires on a hardware-wallet-active
// session. Hot-wallet (mnemonic) sessions are gated by `hashKey`
// instead, which `timeoutAccountAccess` already clears. HW sessions
// need a dedicated flag because `getIsHardwareWalletActive` is stored
// in `localStore` and is not cleared by the lock path.
isHardwareWalletLocked?: boolean;
}

export const sessionSlice = createSlice({
Expand Down Expand Up @@ -108,6 +115,19 @@ export const sessionSlice = createSlice({
},
password: "",
}),
// Idle auto-lock for hardware-wallet sessions. `timeoutAccountAccess`
// clears the hot-wallet `hashKey`, but hardware-wallet "unlocked"
// state is read off `localStore.isHardwareWalletActive` and is
// unaffected by that — so without this flag, the idle alarm firing
// on an HW-only session would be a silent no-op.
lockHardwareWallet: (state) => ({
...state,
isHardwareWalletLocked: true,
}),
unlockHardwareWallet: (state) => ({
...state,
isHardwareWalletLocked: false,
}),
updateAccountName: (
state,
action: { payload: { publicKey: string; updatedAccountName: string } },
Expand Down Expand Up @@ -155,6 +175,8 @@ export const {
logOut,
setActiveHashKey,
timeoutAccountAccess,
lockHardwareWallet,
unlockHardwareWallet,
setMigratedMnemonicPhrase,
updateAccountName,
},
Expand All @@ -178,9 +200,17 @@ export const buildHasPrivateKeySelector = (localStore: DataStorageAccess) =>
const isHardwareWalletActive = await getIsHardwareWalletActive({
localStore,
});
return isHardwareWalletActive || !!session?.hashKey?.key;
if (isHardwareWalletActive && !session?.isHardwareWalletLocked) {
return true;
}
return !!session?.hashKey?.key;
});

export const isHardwareWalletLockedSelector = createSelector(
sessionSelector,
(session) => !!session?.isHardwareWalletLocked,
);

export const hashKeySelector = createSelector(
sessionSelector,
(session) => session.hashKey,
Expand Down
84 changes: 84 additions & 0 deletions extension/src/background/helpers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import {
deriveKeyFromString,
encryptHashString,
decryptHashString,
SessionTimer,
SESSION_ALARM_NAME,
} from "../session";
import browser from "webextension-polyfill";
import { AUTO_LOCK_TIMEOUT_MINUTES_ID } from "constants/localStorageTypes";
import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock";

describe("session", () => {
it("should be able to encrypt and decrypt a string", async () => {
Expand Down Expand Up @@ -85,3 +90,82 @@ describe("session", () => {
expect(areEqual).toBe(false);
});
});

describe("SessionTimer", () => {
const createMock = jest.fn().mockResolvedValue(undefined);
const clearMock = jest.fn().mockResolvedValue(undefined);

beforeEach(() => {
createMock.mockClear();
clearMock.mockClear();
(browser as any).alarms = { create: createMock, clear: clearMock };
});

const makeLocalStore = (stored: unknown) =>
({
getItem: jest.fn().mockImplementation((key: string) => {
if (key === AUTO_LOCK_TIMEOUT_MINUTES_ID) return Promise.resolve(stored);
return Promise.resolve(null);
}),
setItem: jest.fn(),
remove: jest.fn(),
}) as any;

it("resetSession arms the alarm using the stored timeout", async () => {
const timer = new SessionTimer(makeLocalStore(30));
await timer.resetSession();
expect(createMock).toHaveBeenCalledWith(SESSION_ALARM_NAME, {
delayInMinutes: 30,
});
});

it("startSession is an alias for resetSession", async () => {
const timer = new SessionTimer(makeLocalStore(5));
await timer.startSession();
expect(createMock).toHaveBeenCalledWith(SESSION_ALARM_NAME, {
delayInMinutes: 5,
});
});

it("falls back to the default when no timeout is persisted", async () => {
const timer = new SessionTimer(makeLocalStore(null));
await timer.resetSession();
expect(createMock).toHaveBeenCalledWith(SESSION_ALARM_NAME, {
delayInMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
});
});

it("falls back to the default when the persisted value is invalid", async () => {
const timer = new SessionTimer(makeLocalStore(7));
await timer.resetSession();
expect(createMock).toHaveBeenCalledWith(SESSION_ALARM_NAME, {
delayInMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
});
});

it("re-reads the persisted timeout on every reset", async () => {
const localStore = {
getItem: jest
.fn()
.mockResolvedValueOnce(15)
.mockResolvedValueOnce(60),
setItem: jest.fn(),
remove: jest.fn(),
} as any;
const timer = new SessionTimer(localStore);
await timer.resetSession();
await timer.resetSession();
expect(createMock).toHaveBeenNthCalledWith(1, SESSION_ALARM_NAME, {
delayInMinutes: 15,
});
expect(createMock).toHaveBeenNthCalledWith(2, SESSION_ALARM_NAME, {
delayInMinutes: 60,
});
});

it("stopSession clears the alarm", async () => {
const timer = new SessionTimer(makeLocalStore(15));
await timer.stopSession();
expect(clearMock).toHaveBeenCalledWith(SESSION_ALARM_NAME);
});
});
66 changes: 55 additions & 11 deletions extension/src/background/helpers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,65 @@ import {
hashKeySelector,
SessionState,
timeoutAccountAccess,
lockHardwareWallet,
} from "../ducks/session";
import { DataStorageAccess } from "./dataStorageAccess";
import { TEMPORARY_STORE_ID } from "../../constants/localStorageTypes";
import {
AUTO_LOCK_TIMEOUT_MINUTES_ID,
TEMPORARY_STORE_ID,
} from "../../constants/localStorageTypes";
import {
AutoLockTimeoutMinutes,
coerceAutoLockTimeoutMinutes,
} from "@shared/constants/autoLock";
import { encode, decode } from "./base64-arraybuffer";

// 24 hours
const SESSION_LENGTH = 60 * 24;
export const SESSION_ALARM_NAME = "session-timer";

/**
* Idle-based auto-lock timer.
*
* The browser session is locked after a configurable number of minutes
* of user inactivity. Any genuine user interaction in an extension page
* pings the background, which calls `resetSession()` to rearm the alarm
* at `now + configured timeout`. The alarm is implemented as a named
* `browser.alarms` entry, so creating it with the same name replaces
* any in-flight deadline atomically — no separate clear step needed.
*/
export class SessionTimer {
duration = 1000 * 60 * SESSION_LENGTH;
runningTimeout: null | ReturnType<typeof setTimeout> = null;
constructor(duration?: number) {
this.duration = duration || this.duration;
private readonly localStore: DataStorageAccess;

constructor(localStore: DataStorageAccess) {
this.localStore = localStore;
}

startSession() {
browser?.alarms.create(SESSION_ALARM_NAME, {
delayInMinutes: SESSION_LENGTH,
});
private async getTimeoutMinutes(): Promise<AutoLockTimeoutMinutes> {
const stored = await this.localStore.getItem(AUTO_LOCK_TIMEOUT_MINUTES_ID);
return coerceAutoLockTimeoutMinutes(stored);
}

/**
* (Re)arm the auto-lock alarm. Reads the persisted timeout on every
* call so that settings changes take effect immediately.
*/
async resetSession() {
const delayInMinutes = await this.getTimeoutMinutes();
await browser?.alarms.create(SESSION_ALARM_NAME, { delayInMinutes });
}

/**
* Alias kept so unlock paths can read naturally
* (`sessionTimer.startSession()`).
*/
async startSession() {
await this.resetSession();
}

/**
* Cancel any pending auto-lock alarm. Used by explicit sign-out.
*/
async stopSession() {
await browser?.alarms.clear(SESSION_ALARM_NAME);
}
}

Expand Down Expand Up @@ -266,5 +305,10 @@ export const clearSession = async ({
sessionStore,
}: ClearSession) => {
sessionStore.dispatch(timeoutAccountAccess());
// Locks hardware-wallet sessions too. `timeoutAccountAccess` clears
// the hot-wallet hashKey, but HW unlocked-state is read from
// `localStore.isHardwareWalletActive` and is unaffected — so without
// this dispatch the idle alarm would be a no-op on HW-only sessions.
sessionStore.dispatch(lockHardwareWallet());
await localStore.remove(TEMPORARY_STORE_ID);
};
Loading
Loading