Skip to content

Commit c42c8b2

Browse files
committed
Added a new 'require password on launch' checkbox that is independent from the 'unlock with' feature. That enforces a password request on startup of your browser for the extension and startup of the pass desktop application
1 parent c6af39b commit c42c8b2

17 files changed

Lines changed: 323 additions & 37 deletions

File tree

applications/pass-extension/src/app/worker/services/activation.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { ForkType } from '@proton/shared/lib/authentication/fork/constants';
3838
import { APPS, SSO_PATHS } from '@proton/shared/lib/constants';
3939
import noop from '@proton/utils/noop';
4040

41-
import { shouldForceLock } from './auth/auth.utils';
41+
import { getForceLockOptions } from './auth/auth.utils';
4242

4343
type ActivationServiceState = {
4444
updateAvailable: MaybeNull<string>;
@@ -92,7 +92,8 @@ export const createActivationService = () => {
9292
/* set `forceLock` flag for subsequent authentication inits to
9393
* account for startup artifically force locking the session */
9494
await ctx.service.storage.local.setItem('forceLock', true);
95-
const loggedIn = await ctx.service.auth.init({ forceLock: true, retryable: true });
95+
96+
const loggedIn = await ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: true });
9697

9798
if (ENV === 'development' && RESUME_FALLBACK) {
9899
if (!loggedIn) {
@@ -123,7 +124,7 @@ export const createActivationService = () => {
123124
void ctx.service.injection.updateScripts();
124125
ctx.service.spotlight.onUpdate();
125126

126-
return ctx.service.auth.init({ forceLock: await shouldForceLock(), retryable: true });
127+
return ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: true });
127128
}
128129

129130
/** NOTE: Safari might trigger the `install` event when clearing the
@@ -225,7 +226,7 @@ export const createActivationService = () => {
225226
})();
226227

227228
/** NOTE: `retryable: false` -> don't start resume chain from client inits */
228-
if (shouldResume) void ctx.service.auth.init({ forceLock: await shouldForceLock(), retryable: false });
229+
if (shouldResume) void ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: false });
229230

230231
/** Dispatch a wakeup action for client app receivers. Tracking the wakeup's request metadata
231232
* can be consumed in the UI to infer wakeup result - see `wakeup.saga.ts` no need for any redux

applications/pass-extension/src/app/worker/services/auth/auth.service.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import noop from '@proton/utils/noop';
6464

6565
import type { AuthAlarms } from './auth.alarms';
6666
import { createAuthAlarms } from './auth.alarms';
67-
import { isOfflineModeEnabled, shouldForceLock, validateExtensionForkPayload } from './auth.utils';
67+
import { getForceLockOptions, isOfflineModeEnabled, shouldForceLock, validateExtensionForkPayload } from './auth.utils';
6868

6969
export interface ExtensionAuthService extends AuthService {
7070
/** Starts extension specific listeners. Moved outside
@@ -385,7 +385,7 @@ export const createAuthService = (api: Api, authStore: AuthStore) => {
385385
}) as ExtensionAuthService;
386386

387387
const handleInit = withContext<MessageHandlerCallback<WorkerMessageType.AUTH_INIT>>(async (ctx, { options }) => {
388-
options.forceLock = await shouldForceLock();
388+
Object.assign(options, await getForceLockOptions());
389389
await ctx.service.auth.init(options);
390390
return ctx.getState();
391391
});
@@ -565,8 +565,7 @@ export const createAuthService = (api: Api, authStore: AuthStore) => {
565565
}
566566

567567
if (forceResume) {
568-
const forceLock = await shouldForceLock();
569-
return authService.init({ forceLock, retryable: true, silence: true });
568+
return authService.init({ ...(await getForceLockOptions()), retryable: true, silence: true });
570569
}
571570

572571
logger.debug(`[AuthService] dropped auto resume [${status}]`);

applications/pass-extension/src/app/worker/services/auth/auth.utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { withContext } from 'proton-pass-extension/app/worker/context/inject';
22

3+
import { requiresLaunchPasswordUnlock } from '@proton/pass/lib/auth/session';
34
import { PassFeature } from '@proton/pass/types/api/features';
45
import type { RequiredProps } from '@proton/pass/types/utils';
56
import { epochToMs, getEpoch } from '@proton/pass/utils/time/epoch';
@@ -36,6 +37,29 @@ export const shouldForceLock = withContext<() => Promise<boolean>>(async (ctx) =
3637
}
3738
});
3839

40+
/** Builds auth init options from the protected session launch-lock flag.
41+
* Unknown state is treated as enabled; only an explicit `false` disables it. */
42+
export const getForceLockOptions = withContext(
43+
async (ctx): Promise<{ forceLock: boolean; forcePasswordLock?: boolean }> => {
44+
const authStore = ctx.authStore ?? ctx.service.auth?.config?.authStore;
45+
const localID = authStore?.getLocalID?.();
46+
const persistedSession = await ctx.service.auth?.config?.getPersistedSession?.(localID).catch(() => undefined);
47+
const persistedForcePasswordLock = persistedSession
48+
? requiresLaunchPasswordUnlock(persistedSession)
49+
: undefined;
50+
const memoryForcePasswordLock = authStore?.hasOfflineComponents?.()
51+
? authStore.getLockPasswordOnLaunch?.() !== false
52+
: true;
53+
const forcePasswordLock = persistedForcePasswordLock ?? memoryForcePasswordLock;
54+
const forceLock = (await shouldForceLock()) || forcePasswordLock;
55+
56+
return {
57+
forceLock,
58+
...(forcePasswordLock ? { forcePasswordLock: true } : {}),
59+
};
60+
}
61+
);
62+
3963
export const isOfflineModeEnabled = withContext<() => Promise<boolean>>(async (ctx) => {
4064
try {
4165
const { features } = await ctx.service.featureFlags.resolve();

applications/pass/src/lib/auth.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,14 @@ export const createAuthService = ({
209209

210210
const offlineEnabled = (await core.settings.resolve(localID))?.offlineEnabled ?? false;
211211
const offline = !connectivity.online;
212-
const initialLockedStatus = getInitialLockedAppStatus(authStore, { offlineEnabled, offline });
212+
const initialLockedStatus = getInitialLockedAppStatus(authStore, {
213+
offlineEnabled,
214+
offline,
215+
passwordOnLaunch:
216+
DESKTOP_BUILD &&
217+
(Boolean(persistedSession?.launchPasswordBlob) ||
218+
authStore.getLockPasswordOnLaunch() !== false),
219+
});
213220

214221
if (initialLockedStatus) {
215222
authStore.setPassword(undefined);

packages/pass/components/Settings/LockSetup.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { FC } from 'react';
22

33
import { c } from 'ttag';
44

5+
import Checkbox from '@proton/components/components/input/Checkbox';
56
import RadioGroup from '@proton/components/components/input/RadioGroup';
67
import { useOnline } from '@proton/pass/components/Core/ConnectivityProvider';
78
import { LockTTLField } from '@proton/pass/components/Lock/LockTTLField';
@@ -15,7 +16,16 @@ type Props = { noTTL?: boolean };
1516

1617
export const LockSetup: FC<Props> = ({ noTTL = false }) => {
1718
const online = useOnline();
18-
const { setLockMode, setLockTTL, lock, biometrics, extensionBiometrics, password } = useLockSetup();
19+
const {
20+
setLockMode,
21+
setLockTTL,
22+
setPasswordOnLaunch,
23+
lock,
24+
biometrics,
25+
extensionBiometrics,
26+
password,
27+
passwordOnLaunch,
28+
} = useLockSetup();
1929
const passwordTypeSwitch = usePasswordTypeSwitch();
2030

2131
/**
@@ -155,6 +165,16 @@ export const LockSetup: FC<Props> = ({ noTTL = false }) => {
155165
</>
156166
}
157167
/>
168+
{(EXTENSION_BUILD || DESKTOP_BUILD) && (
169+
<Checkbox
170+
className="mt-4"
171+
checked={passwordOnLaunch}
172+
disabled={!online || lock.loading}
173+
onChange={() => setPasswordOnLaunch(!passwordOnLaunch)}
174+
>
175+
{c('Label').t`Require password on launch`}
176+
</Checkbox>
177+
)}
158178
</>
159179
)}
160180
</>

packages/pass/hooks/auth/useLockSetup.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ import { useUpselling } from '@proton/pass/components/Upsell/UpsellingProvider';
1515
import { DEFAULT_LOCK_TTL, UpsellRef } from '@proton/pass/constants';
1616
import { useDesktopUnlock } from '@proton/pass/hooks/auth/useDesktopUnlock';
1717
import { useFeatureFlag } from '@proton/pass/hooks/useFeatureFlag';
18-
import { useActionRequest } from '@proton/pass/hooks/useRequest';
18+
import { useActionRequest, useRequest } from '@proton/pass/hooks/useRequest';
19+
import { useRerender } from '@proton/pass/hooks/useRerender';
1920
import type { UnlockDTO } from '@proton/pass/lib/auth/lock/types';
2021
import { LockMode } from '@proton/pass/lib/auth/lock/types';
2122
import { ReauthAction } from '@proton/pass/lib/auth/reauth';
2223
import { isPaidPlan } from '@proton/pass/lib/user/user.predicates';
23-
import { lockCreateIntent } from '@proton/pass/store/actions';
24+
import { lockCreateIntent, passwordOnLaunchToggle } from '@proton/pass/store/actions';
2425
import { lockCreateRequest } from '@proton/pass/store/actions/requests';
2526
import { selectLockMode, selectLockTTL, selectPassPlan } from '@proton/pass/store/selectors';
2627
import type { Maybe, MaybeNull, Result } from '@proton/pass/types';
@@ -66,8 +67,10 @@ interface LockSetup {
6667
biometrics: BiometricsState;
6768
extensionBiometrics: ExtensionBiometricsState;
6869
password: PasswordState;
70+
passwordOnLaunch: boolean;
6971
setLockMode: (mode: LockMode) => Promise<void>;
7072
setLockTTL: (ttl: number) => Promise<void>;
73+
setPasswordOnLaunch: (enabled: boolean) => Promise<void>;
7174
}
7275

7376
export const useLockSetup = (): LockSetup => {
@@ -100,6 +103,7 @@ export const useLockSetup = (): LockSetup => {
100103
* this, we use an optimistic value for the next lock. */
101104
const [nextLock, setNextLock] = useState<MaybeNull<{ ttl: number; mode: LockMode }>>(null);
102105
const [biometricsEnabled, setBiometricsEnabled] = useState(currentLockMode === LockMode.BIOMETRICS);
106+
const [, rerenderLaunchPassword] = useRerender('password-on-launch');
103107

104108
const unlock = useUnlock((err) => createNotification({ type: 'error', text: err.message }));
105109

@@ -109,6 +113,7 @@ export const useLockSetup = (): LockSetup => {
109113
onFailure: () => setNextLock(null),
110114
onSuccess: () => setNextLock(null),
111115
});
116+
const launchPassword = useRequest(passwordOnLaunchToggle, { initial: true, onSuccess: rerenderLaunchPassword });
112117

113118
const setLockMode = async (mode: LockMode) => {
114119
if (isFreePlan && (mode === LockMode.BIOMETRICS || mode === LockMode.DESKTOP)) {
@@ -328,11 +333,40 @@ export const useLockSetup = (): LockSetup => {
328333
}
329334
};
330335

336+
/** Mutating launch-password lock requires password confirmation.
337+
* The checkbox state is reread from `authStore` after the saga persists it. */
338+
const setPasswordOnLaunch = async (enabled: boolean) => {
339+
return confirmPassword({
340+
onSubmit: (password) => launchPassword.dispatch({ enabled, password }),
341+
message: passwordTypeSwitch({
342+
extra: enabled
343+
? c('Info')
344+
.t`Please confirm your extra password in order to require it when ${PASS_APP_NAME} launches.`
345+
: c('Info')
346+
.t`Please confirm your extra password in order to stop requiring it when ${PASS_APP_NAME} launches.`,
347+
sso: enabled
348+
? c('Info')
349+
.t`Please confirm your backup password in order to require it when ${PASS_APP_NAME} launches.`
350+
: c('Info')
351+
.t`Please confirm your backup password in order to stop requiring it when ${PASS_APP_NAME} launches.`,
352+
twoPwd: enabled
353+
? c('Info')
354+
.t`Please confirm your second password in order to require it when ${PASS_APP_NAME} launches.`
355+
: c('Info')
356+
.t`Please confirm your second password in order to stop requiring it when ${PASS_APP_NAME} launches.`,
357+
default: enabled
358+
? c('Info').t`Please confirm your password in order to require it when ${PASS_APP_NAME} launches.`
359+
: c('Info')
360+
.t`Please confirm your password in order to stop requiring it when ${PASS_APP_NAME} launches.`,
361+
}),
362+
});
363+
};
364+
331365
useEffect(() => {
332366
/** Block reload/navigation if a lock request is on-going.
333367
* Custom `beforeunload` messages are now deprecated */
334368
const onBeforeUnload = (evt: BeforeUnloadEvent) => {
335-
if (createLock.loading) {
369+
if (createLock.loading || launchPassword.loading) {
336370
evt.preventDefault();
337371
evt.returnValue = '';
338372
return '';
@@ -341,7 +375,7 @@ export const useLockSetup = (): LockSetup => {
341375

342376
window.addEventListener('beforeunload', onBeforeUnload);
343377
return () => window.removeEventListener('beforeunload', onBeforeUnload);
344-
}, [createLock.loading]);
378+
}, [createLock.loading, launchPassword.loading]);
345379

346380
useEffect(() => {
347381
(async () => {
@@ -354,14 +388,14 @@ export const useLockSetup = (): LockSetup => {
354388
const lock = useMemo(
355389
() => ({
356390
orgControlled: Boolean(orgLockTTL),
357-
loading: createLock.loading,
391+
loading: createLock.loading || launchPassword.loading,
358392
mode: nextLock?.mode ?? currentLockMode,
359393
ttl: {
360394
value: nextLock?.ttl || orgLockTTL || lockTTL,
361395
disabled: Boolean(currentLockMode === LockMode.NONE || orgLockTTL),
362396
},
363397
}),
364-
[currentLockMode, nextLock, orgLockTTL, lockTTL, createLock.loading]
398+
[currentLockMode, nextLock, orgLockTTL, lockTTL, createLock.loading, launchPassword.loading]
365399
);
366400

367401
const biometrics = useMemo(
@@ -370,6 +404,7 @@ export const useLockSetup = (): LockSetup => {
370404
);
371405

372406
const password = useMemo(() => ({ enabled: !EXTENSION_BUILD }), []);
407+
const passwordOnLaunch = authStore?.getLockPasswordOnLaunch() ?? true;
373408

374409
const extensionBiometrics = useMemo(
375410
() => ({
@@ -389,7 +424,9 @@ export const useLockSetup = (): LockSetup => {
389424
biometrics,
390425
extensionBiometrics,
391426
password,
427+
passwordOnLaunch,
392428
setLockMode,
393429
setLockTTL,
430+
setPasswordOnLaunch,
394431
};
395432
};

packages/pass/lib/auth/fork.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { c } from 'ttag';
2-
31
import { ARGON2_PARAMS } from '@protontech/crypto';
42
import { importKey } from '@protontech/crypto/subtle/aesGcm.ts';
3+
import { c } from 'ttag';
4+
55
import type { ReauthActionPayload } from '@proton/pass/lib/auth/reauth';
66
import { encodeUserData } from '@proton/pass/lib/auth/store.utils';
77
import type { OfflineComponents } from '@proton/pass/lib/cache/crypto';
@@ -277,6 +277,7 @@ export const consumeFork = async (options: ConsumeForkOptions): Promise<Consumed
277277
lastUsedAt: getEpoch(),
278278
AccessToken: cookies ? '' : refresh.AccessToken,
279279
RefreshToken: cookies ? '' : refresh.RefreshToken,
280+
...(EXTENSION_BUILD || DESKTOP_BUILD ? { lockPasswordOnLaunch: true } : {}),
280281
lockMode: LockMode.NONE,
281282
persistent: payload.persistent,
282283
cookies,

packages/pass/lib/auth/integrity.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { stringToUint8Array } from '@proton/shared/lib/helpers/encoding';
21
import { computeSHA256 } from '@protontech/crypto/subtle/hash.ts';
32

3+
import { stringToUint8Array } from '@proton/shared/lib/helpers/encoding';
4+
45
import type { AuthSession, EncryptedSessionKeys } from './session';
56

67
type IntegrityKey = keyof Omit<AuthSession, EncryptedSessionKeys>;
78

8-
export const SESSION_DIGEST_VERSION = 1;
9+
export const SESSION_DIGEST_VERSION = 2;
910
const VERSION_SEPARATOR = '.';
1011

1112
/** `AuthSession` keys used to verify the integrity of the session
@@ -26,10 +27,14 @@ export const SESSION_INTEGRITY_KEYS_V1: IntegrityKey[] = [
2627
'UserID',
2728
];
2829

30+
export const SESSION_INTEGRITY_KEYS_V2: IntegrityKey[] = [...SESSION_INTEGRITY_KEYS_V1, 'lockPasswordOnLaunch'];
31+
2932
export const getSessionIntegrityKeys = (version: number): IntegrityKey[] => {
3033
switch (version) {
3134
case 1:
3235
return SESSION_INTEGRITY_KEYS_V1;
36+
case 2:
37+
return SESSION_INTEGRITY_KEYS_V2;
3338
default:
3439
return [];
3540
}
@@ -55,7 +60,8 @@ export const digestSession = async (
5560
version: number
5661
): Promise<string> => {
5762
const integrityKeys = getSessionIntegrityKeys(version);
58-
const sessionDigest = integrityKeys.reduce<string>((digest, key) => `${digest}::${session[key] || '-'}`, '');
63+
const serialize = (value: unknown) => (version === 1 ? value || '-' : (value ?? '-'));
64+
const sessionDigest = integrityKeys.reduce<string>((digest, key) => `${digest}::${serialize(session[key])}`, '');
5965
const sessionBuffer = stringToUint8Array(sessionDigest);
6066
const digest = await computeSHA256(sessionBuffer);
6167

packages/pass/lib/auth/lock/session/adapter.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { PassErrorCode } from '@proton/pass/lib/api/errors';
44
import type { LockAdapterSession } from '@proton/pass/lib/auth/lock/types';
55
import { LockMode } from '@proton/pass/lib/auth/lock/types';
66
import type { AuthService } from '@proton/pass/lib/auth/service';
7-
import { SESSION_VERSION, decryptSessionBlob, getPersistedSessionKey } from '@proton/pass/lib/auth/session';
7+
import {
8+
SESSION_VERSION,
9+
decryptLaunchPasswordSessionBlob,
10+
decryptSessionBlob,
11+
getPersistedSessionKey,
12+
} from '@proton/pass/lib/auth/session';
813
import type { Maybe } from '@proton/pass/types';
914
import { NotificationKey } from '@proton/pass/types/worker/notification';
1015
import { logger } from '@proton/pass/utils/logger';
@@ -26,11 +31,22 @@ export const sessionLockAdapterFactory = (auth: AuthService): LockAdapterSession
2631

2732
const getPersistedToken = async (localID: Maybe<number>): Promise<Maybe<string>> => {
2833
const session = await auth.config.getPersistedSession(localID);
29-
const clientKey = await getPersistedSessionKey(auth.config.api, authStore);
3034
const payloadVersion = session?.payloadVersion ?? SESSION_VERSION;
3135

32-
if (!(session?.blob && clientKey)) throw new Error('Could not verify unlock request against persisted session');
33-
const decryptedSession = await decryptSessionBlob(clientKey, session?.blob, payloadVersion);
36+
if (!session?.blob) throw new Error('Could not verify unlock request against persisted session');
37+
38+
if (session.launchPasswordBlob) {
39+
const decryptedSession = await decryptLaunchPasswordSessionBlob(
40+
authStore.getOfflineKD(),
41+
session.launchPasswordBlob
42+
);
43+
return decryptedSession.sessionLockToken;
44+
}
45+
46+
const clientKey = await getPersistedSessionKey(auth.config.api, authStore);
47+
if (!clientKey) throw new Error('Could not verify unlock request against persisted session');
48+
49+
const decryptedSession = await decryptSessionBlob(clientKey, session.blob, payloadVersion);
3450
return decryptedSession.sessionLockToken;
3551
};
3652

packages/pass/lib/auth/password.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export enum PasswordVerification {
2020
export type UnsafePasswordCredentials = { password: string };
2121
export type PasswordCredentials = { password: XorObfuscation };
2222
export type PasswordConfirmDTO = PasswordCredentials & { mode?: PasswordVerification };
23+
export type PasswordLaunchLockDTO = PasswordCredentials & { enabled: boolean };
2324
export type ExtraPasswordDTO = PasswordCredentials & { enabled: boolean };
2425

2526
export const getPasswordVerification = (authStore: AuthStore) => {

0 commit comments

Comments
 (0)