diff --git a/applications/pass-extension/src/app/worker/services/activation.spec.ts b/applications/pass-extension/src/app/worker/services/activation.spec.ts index d8adcc48688..9a5fe6e4f59 100644 --- a/applications/pass-extension/src/app/worker/services/activation.spec.ts +++ b/applications/pass-extension/src/app/worker/services/activation.spec.ts @@ -34,6 +34,7 @@ describe('Activation service - `CLIENT_INIT`', () => { authStore = createAuthStore(createMemoryStore()); authStore.setLocalID(123); + authStore.setLockPasswordOnLaunch(false); exposeAuthStore(authStore); connectivity = { @@ -153,10 +154,12 @@ describe('Activation service - `CLIENT_INIT`', () => { test('should pass `retryable: false`: CLIENT_INIT must never bootstrap the alarm chain', async () => { ctx.status = AppStatus.IDLE; await handler(initMessage('popup'), {}); - expect(ctx.service.auth.init).toHaveBeenCalledWith({ - forceLock: expect.any(Boolean), - retryable: false, - }); + expect(ctx.service.auth.init).toHaveBeenCalledWith( + expect.objectContaining({ + forceLock: expect.any(Boolean), + retryable: false, + }) + ); }); }); diff --git a/applications/pass-extension/src/app/worker/services/activation.ts b/applications/pass-extension/src/app/worker/services/activation.ts index 9c4862f5f43..e277faff88b 100644 --- a/applications/pass-extension/src/app/worker/services/activation.ts +++ b/applications/pass-extension/src/app/worker/services/activation.ts @@ -38,7 +38,7 @@ import { ForkType } from '@proton/shared/lib/authentication/fork/constants'; import { APPS, SSO_PATHS } from '@proton/shared/lib/constants'; import noop from '@proton/utils/noop'; -import { shouldForceLock } from './auth/auth.utils'; +import { getForceLockOptions } from './auth/auth.utils'; type ActivationServiceState = { updateAvailable: MaybeNull; @@ -92,7 +92,8 @@ export const createActivationService = () => { /* set `forceLock` flag for subsequent authentication inits to * account for startup artifically force locking the session */ await ctx.service.storage.local.setItem('forceLock', true); - const loggedIn = await ctx.service.auth.init({ forceLock: true, retryable: true }); + + const loggedIn = await ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: true }); if (ENV === 'development' && RESUME_FALLBACK) { if (!loggedIn) { @@ -123,7 +124,7 @@ export const createActivationService = () => { void ctx.service.injection.updateScripts(); ctx.service.spotlight.onUpdate(); - return ctx.service.auth.init({ forceLock: await shouldForceLock(), retryable: true }); + return ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: true }); } /** NOTE: Safari might trigger the `install` event when clearing the @@ -225,7 +226,7 @@ export const createActivationService = () => { })(); /** NOTE: `retryable: false` -> don't start resume chain from client inits */ - if (shouldResume) void ctx.service.auth.init({ forceLock: await shouldForceLock(), retryable: false }); + if (shouldResume) void ctx.service.auth.init({ ...(await getForceLockOptions()), retryable: false }); /** Dispatch a wakeup action for client app receivers. Tracking the wakeup's request metadata * can be consumed in the UI to infer wakeup result - see `wakeup.saga.ts` no need for any redux diff --git a/applications/pass-extension/src/app/worker/services/auth/auth.service.spec.ts b/applications/pass-extension/src/app/worker/services/auth/auth.service.spec.ts index 7fbd6d57893..7fa8267a2d6 100644 --- a/applications/pass-extension/src/app/worker/services/auth/auth.service.spec.ts +++ b/applications/pass-extension/src/app/worker/services/auth/auth.service.spec.ts @@ -40,6 +40,7 @@ describe('Extension AuthService', () => { api.subscribe = jest.fn(); api.idle = jest.fn().mockResolvedValue(undefined); authStore = createAuthStore(createMemoryStore()); + authStore.setLockPasswordOnLaunch(false); connectivity = { online: true, @@ -736,6 +737,7 @@ describe('Extension AuthService', () => { }); jest.spyOn(auth.alarms, 'scheduleAutoResume').mockResolvedValue(undefined); auth.init = jest.fn().mockResolvedValue(true); + ctx.service.auth = auth; authStore.setLocalID(123); auth.listen(); WorkerMessageBroker.ports.query.mockReturnValue([]); @@ -786,11 +788,13 @@ describe('Extension AuthService', () => { await alarmListener(); expect(ctx.service.store.dispatch).not.toHaveBeenCalled(); - expect(auth.init).toHaveBeenCalledWith({ - forceLock: expect.any(Boolean), - retryable: true, - silence: true, - }); + expect(auth.init).toHaveBeenCalledWith( + expect.objectContaining({ + forceLock: expect.any(Boolean), + retryable: true, + silence: true, + }) + ); }); test('should call `auth.init` when client is ERRORED', async () => { @@ -842,11 +846,13 @@ describe('Extension AuthService', () => { connectivity.status = ConnectivityStatus.ONLINE; }); await alarmListener(); - expect(auth.init).toHaveBeenCalledWith({ - forceLock: expect.any(Boolean), - retryable: true, - silence: true, - }); + expect(auth.init).toHaveBeenCalledWith( + expect.objectContaining({ + forceLock: expect.any(Boolean), + retryable: true, + silence: true, + }) + ); expect(auth.alarms.scheduleAutoResume).not.toHaveBeenCalled(); }); }); diff --git a/applications/pass-extension/src/app/worker/services/auth/auth.service.ts b/applications/pass-extension/src/app/worker/services/auth/auth.service.ts index 0b92056057f..253a82b366e 100644 --- a/applications/pass-extension/src/app/worker/services/auth/auth.service.ts +++ b/applications/pass-extension/src/app/worker/services/auth/auth.service.ts @@ -64,7 +64,7 @@ import noop from '@proton/utils/noop'; import type { AuthAlarms } from './auth.alarms'; import { createAuthAlarms } from './auth.alarms'; -import { isOfflineModeEnabled, shouldForceLock, validateExtensionForkPayload } from './auth.utils'; +import { getForceLockOptions, isOfflineModeEnabled, shouldForceLock, validateExtensionForkPayload } from './auth.utils'; export interface ExtensionAuthService extends AuthService { /** Starts extension specific listeners. Moved outside @@ -385,7 +385,7 @@ export const createAuthService = (api: Api, authStore: AuthStore) => { }) as ExtensionAuthService; const handleInit = withContext>(async (ctx, { options }) => { - options.forceLock = await shouldForceLock(); + Object.assign(options, await getForceLockOptions()); await ctx.service.auth.init(options); return ctx.getState(); }); @@ -565,8 +565,7 @@ export const createAuthService = (api: Api, authStore: AuthStore) => { } if (forceResume) { - const forceLock = await shouldForceLock(); - return authService.init({ forceLock, retryable: true, silence: true }); + return authService.init({ ...(await getForceLockOptions()), retryable: true, silence: true }); } logger.debug(`[AuthService] dropped auto resume [${status}]`); diff --git a/applications/pass-extension/src/app/worker/services/auth/auth.utils.spec.ts b/applications/pass-extension/src/app/worker/services/auth/auth.utils.spec.ts index cef9e3ba176..07f871275e0 100644 --- a/applications/pass-extension/src/app/worker/services/auth/auth.utils.spec.ts +++ b/applications/pass-extension/src/app/worker/services/auth/auth.utils.spec.ts @@ -4,7 +4,7 @@ import type { WorkerContextInterface } from 'proton-pass-extension/app/worker/co import { PassFeature } from '@proton/pass/types/api/features'; import { epochToMs, getEpoch } from '@proton/pass/utils/time/epoch'; -import { isOfflineModeEnabled, shouldForceLock } from './auth.utils'; +import { getForceLockOptions, isOfflineModeEnabled, shouldForceLock } from './auth.utils'; describe('auth.utils', () => { let ctx: WorkerContextInterface; @@ -127,4 +127,61 @@ describe('auth.utils', () => { expect(await shouldForceLock()).toBe(false); }); }); + + describe('`getForceLockOptions`', () => { + const setup = (persistedSession: any, forceLock = false) => { + ctx = { + authStore: { + getLocalID: jest.fn().mockReturnValue(0), + getLockPasswordOnLaunch: jest.fn().mockReturnValue(undefined), + getLockLastExtendTime: jest.fn().mockReturnValue(undefined), + getLockTTL: jest.fn().mockReturnValue(undefined), + }, + service: { + auth: { + config: { getPersistedSession: jest.fn().mockResolvedValue(persistedSession) }, + alarms: { autoLockAlarm: { when: jest.fn().mockResolvedValue(undefined) } }, + }, + storage: { + local: { + getItem: jest.fn(async (key) => (key === 'forceLock' ? forceLock : undefined)), + setItem: jest.fn(), + }, + }, + }, + } as any; + WorkerContext.set(ctx); + }; + + test.each([ + { + name: 'password launch lock is flagged', + persistedSession: { + lockPasswordOnLaunch: true, + offlineConfig: {}, + offlineVerifier: 'offline-verifier', + }, + forceLock: true, + expected: { forceLock: true, forcePasswordLock: true }, + }, + { + name: 'password launch lock state is unknown', + persistedSession: null, + expected: { forceLock: true, forcePasswordLock: true }, + }, + { + name: 'password launch lock is explicitly disabled', + persistedSession: { lockPasswordOnLaunch: false }, + expected: { forceLock: false }, + }, + { + name: 'launch blob exists even if the outer flag is disabled', + persistedSession: { launchPasswordBlob: 'password-blob', lockPasswordOnLaunch: false }, + expected: { forceLock: true, forcePasswordLock: true }, + }, + ])('should handle $name', async ({ persistedSession, forceLock, expected }) => { + setup(persistedSession, forceLock); + expect(await getForceLockOptions()).toEqual(expected); + }); + }); }); diff --git a/applications/pass-extension/src/app/worker/services/auth/auth.utils.ts b/applications/pass-extension/src/app/worker/services/auth/auth.utils.ts index d3b3ab73ede..2ab5499b9ab 100644 --- a/applications/pass-extension/src/app/worker/services/auth/auth.utils.ts +++ b/applications/pass-extension/src/app/worker/services/auth/auth.utils.ts @@ -1,5 +1,6 @@ import { withContext } from 'proton-pass-extension/app/worker/context/inject'; +import { requiresLaunchPasswordUnlock } from '@proton/pass/lib/auth/session'; import { PassFeature } from '@proton/pass/types/api/features'; import type { RequiredProps } from '@proton/pass/types/utils'; import { epochToMs, getEpoch } from '@proton/pass/utils/time/epoch'; @@ -36,6 +37,29 @@ export const shouldForceLock = withContext<() => Promise>(async (ctx) = } }); +/** Builds auth init options from the protected session launch-lock flag. + * Unknown state is treated as enabled; only an explicit `false` disables it. */ +export const getForceLockOptions = withContext( + async (ctx): Promise<{ forceLock: boolean; forcePasswordLock?: boolean }> => { + const authStore = ctx.authStore ?? ctx.service.auth?.config?.authStore; + const localID = authStore?.getLocalID?.(); + const persistedSession = await ctx.service.auth?.config?.getPersistedSession?.(localID).catch(() => undefined); + const persistedForcePasswordLock = persistedSession + ? requiresLaunchPasswordUnlock(persistedSession) + : undefined; + const memoryForcePasswordLock = authStore?.hasOfflineComponents?.() + ? authStore.getLockPasswordOnLaunch?.() !== false + : true; + const forcePasswordLock = persistedForcePasswordLock ?? memoryForcePasswordLock; + const forceLock = (await shouldForceLock()) || forcePasswordLock; + + return { + forceLock, + ...(forcePasswordLock ? { forcePasswordLock: true } : {}), + }; + } +); + export const isOfflineModeEnabled = withContext<() => Promise>(async (ctx) => { try { const { features } = await ctx.service.featureFlags.resolve(); diff --git a/applications/pass/src/lib/auth.spec.ts b/applications/pass/src/lib/auth.spec.ts index 401d6d6459f..75b62cbb22c 100644 --- a/applications/pass/src/lib/auth.spec.ts +++ b/applications/pass/src/lib/auth.spec.ts @@ -118,6 +118,8 @@ jest.useFakeTimers(); describe('AuthService', () => { beforeEach(() => { + (global as any).DESKTOP_BUILD = false; + (global as any).EXTENSION_BUILD = false; authStore.clear(); jest.clearAllMocks(); @@ -250,6 +252,42 @@ describe('AuthService', () => { expect(app.setStatus).toHaveBeenCalledWith(AppStatus.PASSWORD_LOCKED); }); + test('desktop launch password lock uses auth session flag', async () => { + (global as any).DESKTOP_BUILD = true; + + getPersistedSession.mockImplementationOnce(async () => ({ + ...MOCK_PERSISTED_SESSION, + lockMode: LockMode.NONE, + lockPasswordOnLaunch: true, + })); + + const options: AuthOptions = {}; + const result = await authService.init(options); + + expect(result).toBe(false); + expect(options.forceLock).toBe(true); + expect(resumeSession).not.toHaveBeenCalled(); + expect(app.setStatus).toHaveBeenCalledWith(AppStatus.PASSWORD_LOCKED); + }); + + test('desktop launch password lock uses protected blob over outer flag', async () => { + (global as any).DESKTOP_BUILD = true; + + getPersistedSession.mockImplementationOnce(async () => ({ + ...MOCK_PERSISTED_SESSION, + launchPasswordBlob: 'password-blob', + lockMode: LockMode.NONE, + lockPasswordOnLaunch: false, + })); + + const options: AuthOptions = {}; + const result = await authService.init(options); + + expect(result).toBe(false); + expect(resumeSession).not.toHaveBeenCalled(); + expect(app.setStatus).toHaveBeenCalledWith(AppStatus.PASSWORD_LOCKED); + }); + test('should set `forceLock` to true with empty localID manipulation', async () => { /** When user has valid session but URL has empty/malformed localID, * clear auth store and force lock to prevent unauthorized access */ diff --git a/applications/pass/src/lib/auth.ts b/applications/pass/src/lib/auth.ts index 4b4a0449f17..d615f8eeac7 100644 --- a/applications/pass/src/lib/auth.ts +++ b/applications/pass/src/lib/auth.ts @@ -209,7 +209,14 @@ export const createAuthService = ({ const offlineEnabled = (await core.settings.resolve(localID))?.offlineEnabled ?? false; const offline = !connectivity.online; - const initialLockedStatus = getInitialLockedAppStatus(authStore, { offlineEnabled, offline }); + const initialLockedStatus = getInitialLockedAppStatus(authStore, { + offlineEnabled, + offline, + passwordOnLaunch: + DESKTOP_BUILD && + (Boolean(persistedSession?.launchPasswordBlob) || + authStore.getLockPasswordOnLaunch() !== false), + }); if (initialLockedStatus) { authStore.setPassword(undefined); diff --git a/packages/pass/components/Settings/LockSetup.tsx b/packages/pass/components/Settings/LockSetup.tsx index bc90944af76..3c0a8db8c84 100644 --- a/packages/pass/components/Settings/LockSetup.tsx +++ b/packages/pass/components/Settings/LockSetup.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { c } from 'ttag'; +import Checkbox from '@proton/components/components/input/Checkbox'; import RadioGroup from '@proton/components/components/input/RadioGroup'; import { useOnline } from '@proton/pass/components/Core/ConnectivityProvider'; import { LockTTLField } from '@proton/pass/components/Lock/LockTTLField'; @@ -15,7 +16,16 @@ type Props = { noTTL?: boolean }; export const LockSetup: FC = ({ noTTL = false }) => { const online = useOnline(); - const { setLockMode, setLockTTL, lock, biometrics, extensionBiometrics, password } = useLockSetup(); + const { + setLockMode, + setLockTTL, + setPasswordOnLaunch, + lock, + biometrics, + extensionBiometrics, + password, + passwordOnLaunch, + } = useLockSetup(); const passwordTypeSwitch = usePasswordTypeSwitch(); /** @@ -155,6 +165,16 @@ export const LockSetup: FC = ({ noTTL = false }) => { } /> + {(EXTENSION_BUILD || DESKTOP_BUILD) && ( + setPasswordOnLaunch(!passwordOnLaunch)} + > + {c('Label').t`Require password on launch`} + + )} )} diff --git a/packages/pass/hooks/auth/useLockSetup.ts b/packages/pass/hooks/auth/useLockSetup.ts index 0acfc449d35..1224aaa9d66 100644 --- a/packages/pass/hooks/auth/useLockSetup.ts +++ b/packages/pass/hooks/auth/useLockSetup.ts @@ -15,12 +15,13 @@ import { useUpselling } from '@proton/pass/components/Upsell/UpsellingProvider'; import { DEFAULT_LOCK_TTL, UpsellRef } from '@proton/pass/constants'; import { useDesktopUnlock } from '@proton/pass/hooks/auth/useDesktopUnlock'; import { useFeatureFlag } from '@proton/pass/hooks/useFeatureFlag'; -import { useActionRequest } from '@proton/pass/hooks/useRequest'; +import { useActionRequest, useRequest } from '@proton/pass/hooks/useRequest'; +import { useRerender } from '@proton/pass/hooks/useRerender'; import type { UnlockDTO } from '@proton/pass/lib/auth/lock/types'; import { LockMode } from '@proton/pass/lib/auth/lock/types'; import { ReauthAction } from '@proton/pass/lib/auth/reauth'; import { isPaidPlan } from '@proton/pass/lib/user/user.predicates'; -import { lockCreateIntent } from '@proton/pass/store/actions'; +import { lockCreateIntent, passwordOnLaunchToggle } from '@proton/pass/store/actions'; import { lockCreateRequest } from '@proton/pass/store/actions/requests'; import { selectLockMode, selectLockTTL, selectPassPlan } from '@proton/pass/store/selectors'; import type { Maybe, MaybeNull, Result } from '@proton/pass/types'; @@ -66,8 +67,10 @@ interface LockSetup { biometrics: BiometricsState; extensionBiometrics: ExtensionBiometricsState; password: PasswordState; + passwordOnLaunch: boolean; setLockMode: (mode: LockMode) => Promise; setLockTTL: (ttl: number) => Promise; + setPasswordOnLaunch: (enabled: boolean) => Promise; } export const useLockSetup = (): LockSetup => { @@ -100,6 +103,7 @@ export const useLockSetup = (): LockSetup => { * this, we use an optimistic value for the next lock. */ const [nextLock, setNextLock] = useState>(null); const [biometricsEnabled, setBiometricsEnabled] = useState(currentLockMode === LockMode.BIOMETRICS); + const [, rerenderLaunchPassword] = useRerender('password-on-launch'); const unlock = useUnlock((err) => createNotification({ type: 'error', text: err.message })); @@ -109,6 +113,7 @@ export const useLockSetup = (): LockSetup => { onFailure: () => setNextLock(null), onSuccess: () => setNextLock(null), }); + const launchPassword = useRequest(passwordOnLaunchToggle, { initial: true, onSuccess: rerenderLaunchPassword }); const setLockMode = async (mode: LockMode) => { if (isFreePlan && (mode === LockMode.BIOMETRICS || mode === LockMode.DESKTOP)) { @@ -328,11 +333,40 @@ export const useLockSetup = (): LockSetup => { } }; + /** Mutating launch-password lock requires password confirmation. + * The checkbox state is reread from `authStore` after the saga persists it. */ + const setPasswordOnLaunch = async (enabled: boolean) => { + return confirmPassword({ + onSubmit: (password) => launchPassword.dispatch({ enabled, password }), + message: passwordTypeSwitch({ + extra: enabled + ? c('Info') + .t`Please confirm your extra password in order to require it when ${PASS_APP_NAME} launches.` + : c('Info') + .t`Please confirm your extra password in order to stop requiring it when ${PASS_APP_NAME} launches.`, + sso: enabled + ? c('Info') + .t`Please confirm your backup password in order to require it when ${PASS_APP_NAME} launches.` + : c('Info') + .t`Please confirm your backup password in order to stop requiring it when ${PASS_APP_NAME} launches.`, + twoPwd: enabled + ? c('Info') + .t`Please confirm your second password in order to require it when ${PASS_APP_NAME} launches.` + : c('Info') + .t`Please confirm your second password in order to stop requiring it when ${PASS_APP_NAME} launches.`, + default: enabled + ? c('Info').t`Please confirm your password in order to require it when ${PASS_APP_NAME} launches.` + : c('Info') + .t`Please confirm your password in order to stop requiring it when ${PASS_APP_NAME} launches.`, + }), + }); + }; + useEffect(() => { /** Block reload/navigation if a lock request is on-going. * Custom `beforeunload` messages are now deprecated */ const onBeforeUnload = (evt: BeforeUnloadEvent) => { - if (createLock.loading) { + if (createLock.loading || launchPassword.loading) { evt.preventDefault(); evt.returnValue = ''; return ''; @@ -341,7 +375,7 @@ export const useLockSetup = (): LockSetup => { window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); - }, [createLock.loading]); + }, [createLock.loading, launchPassword.loading]); useEffect(() => { (async () => { @@ -354,14 +388,14 @@ export const useLockSetup = (): LockSetup => { const lock = useMemo( () => ({ orgControlled: Boolean(orgLockTTL), - loading: createLock.loading, + loading: createLock.loading || launchPassword.loading, mode: nextLock?.mode ?? currentLockMode, ttl: { value: nextLock?.ttl || orgLockTTL || lockTTL, disabled: Boolean(currentLockMode === LockMode.NONE || orgLockTTL), }, }), - [currentLockMode, nextLock, orgLockTTL, lockTTL, createLock.loading] + [currentLockMode, nextLock, orgLockTTL, lockTTL, createLock.loading, launchPassword.loading] ); const biometrics = useMemo( @@ -370,6 +404,7 @@ export const useLockSetup = (): LockSetup => { ); const password = useMemo(() => ({ enabled: !EXTENSION_BUILD }), []); + const passwordOnLaunch = authStore?.getLockPasswordOnLaunch() ?? true; const extensionBiometrics = useMemo( () => ({ @@ -389,7 +424,9 @@ export const useLockSetup = (): LockSetup => { biometrics, extensionBiometrics, password, + passwordOnLaunch, setLockMode, setLockTTL, + setPasswordOnLaunch, }; }; diff --git a/packages/pass/lib/auth/fork.ts b/packages/pass/lib/auth/fork.ts index 6d3dc19b33b..e2b459d45fd 100644 --- a/packages/pass/lib/auth/fork.ts +++ b/packages/pass/lib/auth/fork.ts @@ -1,7 +1,7 @@ -import { c } from 'ttag'; - import { ARGON2_PARAMS } from '@protontech/crypto'; import { importKey } from '@protontech/crypto/subtle/aesGcm.ts'; +import { c } from 'ttag'; + import type { ReauthActionPayload } from '@proton/pass/lib/auth/reauth'; import { encodeUserData } from '@proton/pass/lib/auth/store.utils'; import type { OfflineComponents } from '@proton/pass/lib/cache/crypto'; @@ -277,6 +277,7 @@ export const consumeFork = async (options: ConsumeForkOptions): Promise { expect(getSessionIntegrityKeys(1)).toEqual(SESSION_INTEGRITY_KEYS_V1); }); + it('should return correct keys for version 2', () => { + expect(getSessionIntegrityKeys(2)).toEqual(SESSION_INTEGRITY_KEYS_V2); + expect(getSessionIntegrityKeys(2)).toContain('lockPasswordOnLaunch'); + }); + it('should return an empty array for unknown versions', () => { expect(getSessionIntegrityKeys(-1)).toEqual([]); expect(getSessionIntegrityKeys(999)).toEqual([]); @@ -63,5 +69,12 @@ describe('Session integrity', () => { expect(digestA).not.toBe(digestB); }); + + it('should protect lockPasswordOnLaunch in version 2', async () => { + const digestA = await digestSession({ ...MOCK_SESSION, lockPasswordOnLaunch: true }, 2); + const digestB = await digestSession({ ...MOCK_SESSION, lockPasswordOnLaunch: false }, 2); + + expect(digestA).not.toBe(digestB); + }); }); }); diff --git a/packages/pass/lib/auth/integrity.ts b/packages/pass/lib/auth/integrity.ts index 280105f1a96..978cf5d74fd 100644 --- a/packages/pass/lib/auth/integrity.ts +++ b/packages/pass/lib/auth/integrity.ts @@ -1,11 +1,12 @@ -import { stringToUint8Array } from '@proton/shared/lib/helpers/encoding'; import { computeSHA256 } from '@protontech/crypto/subtle/hash.ts'; +import { stringToUint8Array } from '@proton/shared/lib/helpers/encoding'; + import type { AuthSession, EncryptedSessionKeys } from './session'; type IntegrityKey = keyof Omit; -export const SESSION_DIGEST_VERSION = 1; +export const SESSION_DIGEST_VERSION = 2; const VERSION_SEPARATOR = '.'; /** `AuthSession` keys used to verify the integrity of the session @@ -26,10 +27,14 @@ export const SESSION_INTEGRITY_KEYS_V1: IntegrityKey[] = [ 'UserID', ]; +export const SESSION_INTEGRITY_KEYS_V2: IntegrityKey[] = [...SESSION_INTEGRITY_KEYS_V1, 'lockPasswordOnLaunch']; + export const getSessionIntegrityKeys = (version: number): IntegrityKey[] => { switch (version) { case 1: return SESSION_INTEGRITY_KEYS_V1; + case 2: + return SESSION_INTEGRITY_KEYS_V2; default: return []; } @@ -55,7 +60,8 @@ export const digestSession = async ( version: number ): Promise => { const integrityKeys = getSessionIntegrityKeys(version); - const sessionDigest = integrityKeys.reduce((digest, key) => `${digest}::${session[key] || '-'}`, ''); + const serialize = (value: unknown) => (version === 1 ? value || '-' : (value ?? '-')); + const sessionDigest = integrityKeys.reduce((digest, key) => `${digest}::${serialize(session[key])}`, ''); const sessionBuffer = stringToUint8Array(sessionDigest); const digest = await computeSHA256(sessionBuffer); diff --git a/packages/pass/lib/auth/lock/session/adapter.spec.ts b/packages/pass/lib/auth/lock/session/adapter.spec.ts index 29217dcfdd3..bfff56102ba 100644 --- a/packages/pass/lib/auth/lock/session/adapter.spec.ts +++ b/packages/pass/lib/auth/lock/session/adapter.spec.ts @@ -12,6 +12,7 @@ jest.mock('./lock.requests'); jest.mock('@proton/pass/utils/time/epoch'); jest.mock('@proton/pass/lib/auth/session', () => ({ ...jest.requireActual('@proton/pass/lib/auth/session'), + decryptLaunchPasswordSessionBlob: jest.fn(), decryptSessionBlob: jest.fn(), getPersistedSessionKey: jest.fn(), })); @@ -19,6 +20,7 @@ jest.mock('@proton/pass/lib/auth/session', () => ({ const unlockSessionMock = lockRequests.unlockSession as jest.Mock; const checkSessionLockMock = lockRequests.checkSessionLock as jest.Mock; const getEpochMock = getEpoch as jest.Mock; +const decryptLaunchPasswordSessionBlobMock = authSession.decryptLaunchPasswordSessionBlob as jest.Mock; const decryptSessionBlobMock = authSession.decryptSessionBlob as jest.Mock; const getPersistedSessionKeyMock = authSession.getPersistedSessionKey as jest.Mock; @@ -45,6 +47,7 @@ describe('SessionLock adapter', () => { beforeEach(() => { getEpochMock.mockReturnValue(1000); getPersistedSessionKeyMock.mockResolvedValue('client-key'); + decryptLaunchPasswordSessionBlobMock.mockResolvedValue({ sessionLockToken: TOKEN }); decryptSessionBlobMock.mockResolvedValue({ sessionLockToken: TOKEN }); }); @@ -147,6 +150,20 @@ describe('SessionLock adapter', () => { expect(auth.logout).toHaveBeenCalledWith({ soft: false, broadcast: true }); }); + test('should read the persisted token from launch password blob when present', async () => { + const { adapter, authStore, config } = setupAdapter(); + config.getPersistedSession.mockResolvedValue({ blob: 'blob', launchPasswordBlob: 'password-blob' }); + unlockSessionMock.mockResolvedValue(TOKEN); + authStore.setOfflineKD('offline-kd'); + authStore.setLockMode(LockMode.SESSION); + + await adapter.unlock('123456'); + + expect(decryptLaunchPasswordSessionBlobMock).toHaveBeenCalledWith('offline-kd', 'password-blob'); + expect(getPersistedSessionKeyMock).not.toHaveBeenCalled(); + expect(decryptSessionBlobMock).not.toHaveBeenCalled(); + }); + test('should notify and reset lock state when API returns 400 + SESSION_LOCKED', async () => { const { adapter, authStore, config } = setupAdapter(); const apiError: any = new Error('lock removed'); diff --git a/packages/pass/lib/auth/lock/session/adapter.ts b/packages/pass/lib/auth/lock/session/adapter.ts index cd665fc3818..b7cd95a236a 100644 --- a/packages/pass/lib/auth/lock/session/adapter.ts +++ b/packages/pass/lib/auth/lock/session/adapter.ts @@ -4,7 +4,12 @@ import { PassErrorCode } from '@proton/pass/lib/api/errors'; import type { LockAdapterSession } from '@proton/pass/lib/auth/lock/types'; import { LockMode } from '@proton/pass/lib/auth/lock/types'; import type { AuthService } from '@proton/pass/lib/auth/service'; -import { SESSION_VERSION, decryptSessionBlob, getPersistedSessionKey } from '@proton/pass/lib/auth/session'; +import { + SESSION_VERSION, + decryptLaunchPasswordSessionBlob, + decryptSessionBlob, + getPersistedSessionKey, +} from '@proton/pass/lib/auth/session'; import type { Maybe } from '@proton/pass/types'; import { NotificationKey } from '@proton/pass/types/worker/notification'; import { logger } from '@proton/pass/utils/logger'; @@ -26,11 +31,22 @@ export const sessionLockAdapterFactory = (auth: AuthService): LockAdapterSession const getPersistedToken = async (localID: Maybe): Promise> => { const session = await auth.config.getPersistedSession(localID); - const clientKey = await getPersistedSessionKey(auth.config.api, authStore); const payloadVersion = session?.payloadVersion ?? SESSION_VERSION; - if (!(session?.blob && clientKey)) throw new Error('Could not verify unlock request against persisted session'); - const decryptedSession = await decryptSessionBlob(clientKey, session?.blob, payloadVersion); + if (!session?.blob) throw new Error('Could not verify unlock request against persisted session'); + + if (session.launchPasswordBlob) { + const decryptedSession = await decryptLaunchPasswordSessionBlob( + authStore.getOfflineKD(), + session.launchPasswordBlob + ); + return decryptedSession.sessionLockToken; + } + + const clientKey = await getPersistedSessionKey(auth.config.api, authStore); + if (!clientKey) throw new Error('Could not verify unlock request against persisted session'); + + const decryptedSession = await decryptSessionBlob(clientKey, session.blob, payloadVersion); return decryptedSession.sessionLockToken; }; diff --git a/packages/pass/lib/auth/password.ts b/packages/pass/lib/auth/password.ts index 5326ace8bcd..d355c2715e5 100644 --- a/packages/pass/lib/auth/password.ts +++ b/packages/pass/lib/auth/password.ts @@ -20,6 +20,7 @@ export enum PasswordVerification { export type UnsafePasswordCredentials = { password: string }; export type PasswordCredentials = { password: XorObfuscation }; export type PasswordConfirmDTO = PasswordCredentials & { mode?: PasswordVerification }; +export type PasswordLaunchLockDTO = PasswordCredentials & { enabled: boolean }; export type ExtraPasswordDTO = PasswordCredentials & { enabled: boolean }; export const getPasswordVerification = (authStore: AuthStore) => { diff --git a/packages/pass/lib/auth/service.spec.ts b/packages/pass/lib/auth/service.spec.ts index 7d9c2c5536d..3274e6e1ef9 100644 --- a/packages/pass/lib/auth/service.spec.ts +++ b/packages/pass/lib/auth/service.spec.ts @@ -19,6 +19,8 @@ describe('Core AuthService', () => { let onLockUpdate: jest.Mock; beforeEach(() => { + (global as any).DESKTOP_BUILD = false; + (global as any).EXTENSION_BUILD = true; jest.clearAllMocks(); api = jest.fn() as unknown as Api; api.subscribe = jest.fn(); @@ -45,6 +47,32 @@ describe('Core AuthService', () => { }); }); + const registerPasswordLock = () => { + const passwordLock = jest.fn().mockResolvedValue({ mode: LockMode.PASSWORD, locked: true }); + auth.registerLockAdapter({ + type: LockMode.PASSWORD, + check: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + lock: passwordLock, + unlock: jest.fn(), + }); + return passwordLock; + }; + + const offlineSession = { + AccessToken: 'access-token', + keyPassword: 'key-password', + lockMode: LockMode.NONE, + offlineConfig: { salt: 'salt', params: {} as any }, + offlineKD: 'offline-kd', + offlineVerifier: 'offline-verifier', + RefreshToken: 'refresh-token', + sso: false, + UID: 'uid', + UserID: 'user-id', + }; + describe('AuthService::resumeSession', () => { test('should halt resume when `onResumeStart` returns `false`', async () => { onResumeStart.mockResolvedValueOnce(false); @@ -63,6 +91,34 @@ describe('Core AuthService', () => { expect(result).toBe(true); expect(onResumeStart).toHaveBeenCalledWith({ hasSession: true, memorySession: { LocalID: 0 } }); }); + + test('should stop before decrypting a launch password protected session', async () => { + const passwordLock = registerPasswordLock(); + getPersistedSession.mockResolvedValueOnce({ + ...offlineSession, + blob: 'client-key-blob', + launchPasswordBlob: 'password-blob', + lockPasswordOnLaunch: false, + }); + + const result = await auth.resumeSession(0, {}); + + expect(result).toBe(false); + expect(passwordLock).toHaveBeenCalled(); + expect(api).not.toHaveBeenCalled(); + }); + }); + + describe('AuthService::login', () => { + test('should use password lock override when force-locking with offline components', async () => { + const passwordLock = registerPasswordLock(); + authStore.setLockMode(LockMode.NONE); + + const result = await auth.login(offlineSession, { forceLock: true, forcePasswordLock: true }); + + expect(result).toBe(false); + expect(passwordLock).toHaveBeenCalled(); + }); }); describe('AuthService::syncPersistedSession', () => { diff --git a/packages/pass/lib/auth/service.ts b/packages/pass/lib/auth/service.ts index 0f1936ad43d..0aeac185da7 100644 --- a/packages/pass/lib/auth/service.ts +++ b/packages/pass/lib/auth/service.ts @@ -61,6 +61,7 @@ import { encryptPersistedSessionWithKey, getPersistedSessionKey, migrateSession, + requiresLaunchPasswordUnlock, resumeSession, syncAuthSession, } from './session'; @@ -69,6 +70,8 @@ import type { AuthStore } from './store'; export type AuthOptions = { /** `forceLock` will locally lock the session upon resuming */ forceLock?: boolean; + /** Forces password lock when `forceLock` is set and offline components exist. */ + forcePasswordLock?: boolean; /** If `true`, will re-persist session on login */ forcePersist?: boolean; /** If `true`, session resuming should be retried */ @@ -233,8 +236,11 @@ export const createAuthService = (config: AuthServiceConfig) => { const lockMode = authStore.getLockMode(); - if (options?.forceLock && lockMode !== LockMode.NONE) { - await authService.lock(lockMode, { soft: true, broadcast: false }); + const targetLockMode = + options.forcePasswordLock && authStore.hasOfflineComponents() ? LockMode.PASSWORD : lockMode; + + if (options?.forceLock && targetLockMode !== LockMode.NONE) { + await authService.lock(targetLockMode, { soft: true, broadcast: false }); return false; } @@ -536,7 +542,7 @@ export const createAuthService = (config: AuthServiceConfig) => { /** Passing the `regenerateClientKey` option will generate * a new local key and update it back-end side. Ideally, this * should only happen after consuming a fork. */ - persistSession: async (options?: { regenerateClientKey: boolean }) => { + persistSession: async (options?: { regenerateClientKey?: boolean; throwOnFailure?: boolean }) => { try { const session = authStore.getSession(); if (!authStore.validSession(session)) throw new Error('Trying to persist invalid session'); @@ -567,6 +573,7 @@ export const createAuthService = (config: AuthServiceConfig) => { await config.onSessionPersist?.(encryptedSession); } catch (error) { logger.warn(`[AuthService] Persisting session failure`, error); + if (options?.throwOnFailure) throw error; } }, @@ -624,6 +631,12 @@ export const createAuthService = (config: AuthServiceConfig) => { authStore.setSession(persistedSession); await api.reset(); + if (!options.unlocked && requiresLaunchPasswordUnlock(persistedSession)) { + logger.info(`[AuthService] Launch password lock required`); + await authService.lock(LockMode.PASSWORD, { soft: true, broadcast: false }); + return false; + } + const result = await resumeSession(persistedSession, localID, config, options); logger.info(`[AuthService] Session successfully resumed`); diff --git a/packages/pass/lib/auth/session.spec.ts b/packages/pass/lib/auth/session.spec.ts index 1b470c8e612..ee390d07310 100644 --- a/packages/pass/lib/auth/session.spec.ts +++ b/packages/pass/lib/auth/session.spec.ts @@ -1,11 +1,74 @@ +import { createAuthStore } from '@proton/pass/lib/auth/store'; import { generateKey, importSymmetricKey } from '@proton/pass/lib/crypto/utils/crypto-helpers'; -import { getDecryptedBlob } from '@proton/shared/lib/authentication/sessionBlobCryptoHelper'; +import { createMemoryStore } from '@proton/pass/utils/store'; +import { generateClientKey } from '@proton/shared/lib/authentication/clientKey'; +import { getDecryptedBlob, getEncryptedBlob } from '@proton/shared/lib/authentication/sessionBlobCryptoHelper'; +import { uint8ArrayToString } from '@proton/shared/lib/helpers/encoding'; import { SESSION_DIGEST_VERSION, digestSession } from './integrity'; import { LockMode } from './lock/types'; -import { type AuthSession, encryptPersistedSessionWithKey, getSessionEncryptionTag } from './session'; +import { + type AuthSession, + decryptLaunchPasswordSessionBlob, + encryptPersistedSessionWithKey, + getSessionEncryptionTag, + requiresLaunchPasswordUnlock, + resumeSession, +} from './session'; describe('Session utilities', () => { + const session: AuthSession = { + AccessToken: '', + RefreshToken: '', + RefreshTime: -1, + keyPassword: 'keypassword-test', + lockMode: LockMode.PASSWORD, + UID: 'UID-test', + UserID: 'userID-test', + offlineKD: 'offlineKD-test', + sessionLockToken: 'sessionLockToken-test', + payloadVersion: 2, + sso: false, + }; + + const withLaunchPassword = (): AuthSession => ({ + ...session, + lockPasswordOnLaunch: true, + offlineConfig: { salt: 'salt', params: {} as any }, + offlineKD: uint8ArrayToString(generateKey()), + offlineVerifier: 'offline-verifier', + }); + + beforeEach(() => { + (global as any).DESKTOP_BUILD = false; + (global as any).EXTENSION_BUILD = true; + }); + + const encryptSession = async (authSession = session) => { + const clientKey = await importSymmetricKey(generateKey()); + const data = JSON.parse(await encryptPersistedSessionWithKey(authSession, clientKey)); + return { clientKey, data }; + }; + + const decryptClientBlob = async (clientKey: CryptoKey, blob: string) => + JSON.parse(await getDecryptedBlob(clientKey, blob, getSessionEncryptionTag(2))); + + const setupResumeSession = async () => { + const { serializedData, key: clientKey } = await generateClientKey(); + const authStore = createAuthStore(createMemoryStore()); + authStore.setClientKey(serializedData); + return { authStore, clientKey }; + }; + + const resumeConfig = (authStore: ReturnType, UserID: string) => + ({ + api: jest.fn().mockResolvedValue({ User: { ID: UserID } }), + authStore, + getPersistedSession: jest.fn(), + onInit: jest.fn(), + onSessionFailure: jest.fn(), + }) as any; + describe('`getSessionEncryptionTag`', () => { test('should return correct tag for `payloadVersion: 2`', () => { const tag = new Uint8Array([115, 101, 115, 115, 105, 111, 110]); // 'session' @@ -18,32 +81,15 @@ describe('Session utilities', () => { }); describe('`encryptPersistedSessionWithKey`', () => { - const session: AuthSession = { - AccessToken: '', - RefreshToken: '', - RefreshTime: -1, - keyPassword: 'keypassword-test', - lockMode: LockMode.PASSWORD, - UID: 'UID-test', - UserID: 'userID-test', - offlineKD: 'offlineKD-test', - sessionLockToken: 'sessionLockToken-test', - payloadVersion: 2, - sso: false, - }; - test('should encrypt sensitive components in the encrypted blob', async () => { - const clientKey = await importSymmetricKey(generateKey()); - const result = await encryptPersistedSessionWithKey(session, clientKey); - const data = JSON.parse(result); + const { clientKey, data } = await encryptSession(); expect(data.blob).toBeDefined(); expect(data.keyPassword).not.toBeDefined(); expect(data.offlineKD).not.toBeDefined(); expect(data.sessionLockToken).not.toBeDefined(); - const decrypted = await getDecryptedBlob(clientKey, data.blob, getSessionEncryptionTag(2)); - const decryptedData = JSON.parse(decrypted); + const decryptedData = await decryptClientBlob(clientKey, data.blob); expect(decryptedData.keyPassword).toEqual(session.keyPassword); expect(decryptedData.offlineKD).toEqual(session.offlineKD); @@ -52,13 +98,133 @@ describe('Session utilities', () => { }); test('should compute an integrity digest of the session data', async () => { - const clientKey = await importSymmetricKey(generateKey()); - const result = await encryptPersistedSessionWithKey(session, clientKey); - const decrypted = await getDecryptedBlob(clientKey, JSON.parse(result).blob, getSessionEncryptionTag(2)); - const decryptedData = JSON.parse(decrypted); - + const { clientKey, data } = await encryptSession(); + const decryptedData = await decryptClientBlob(clientKey, data.blob); const digest = await digestSession(session, SESSION_DIGEST_VERSION); + expect(decryptedData.digest).toEqual(digest); }); + + test('should password-wrap sensitive components for launch password lock', async () => { + const protectedSession = withLaunchPassword(); + const { clientKey, data } = await encryptSession(protectedSession); + + expect(data.launchPasswordBlob).toBeDefined(); + expect(data.lockPasswordOnLaunch).toBe(true); + + const clientBlobData = await decryptClientBlob(clientKey, data.blob); + + expect(clientBlobData.keyPassword).not.toEqual(protectedSession.keyPassword); + expect(clientBlobData.offlineKD).not.toBeDefined(); + expect(clientBlobData.sessionLockToken).not.toBeDefined(); + + const launchBlobData = await decryptLaunchPasswordSessionBlob( + protectedSession.offlineKD, + data.launchPasswordBlob + ); + + expect(launchBlobData.keyPassword).toEqual(protectedSession.keyPassword); + expect(launchBlobData.offlineKD).toEqual(protectedSession.offlineKD); + expect(launchBlobData.sessionLockToken).toEqual(protectedSession.sessionLockToken); + expect(launchBlobData.digest).toBeDefined(); + }); + + test('should default launch password protection to enabled when offline material exists', async () => { + const protectedSession = { ...withLaunchPassword(), lockPasswordOnLaunch: undefined }; + const { data } = await encryptSession(protectedSession); + + expect(data.lockPasswordOnLaunch).toBe(true); + expect(data.launchPasswordBlob).toBeDefined(); + }); + + test('should keep the normal client-key blob when launch password lock is disabled', async () => { + const protectedSession = { ...withLaunchPassword(), lockPasswordOnLaunch: false }; + const { clientKey, data } = await encryptSession(protectedSession); + + expect(data.launchPasswordBlob).not.toBeDefined(); + + const clientBlobData = await decryptClientBlob(clientKey, data.blob); + + expect(clientBlobData.keyPassword).toEqual(protectedSession.keyPassword); + expect(clientBlobData.offlineKD).toEqual(protectedSession.offlineKD); + expect(clientBlobData.sessionLockToken).toEqual(protectedSession.sessionLockToken); + }); + }); + + describe('`requiresLaunchPasswordUnlock`', () => { + test.each([ + [ + 'force password when the protected launch blob exists', + { launchPasswordBlob: 'blob', lockPasswordOnLaunch: false }, + true, + ], + [ + 'default to password when local password material exists', + { offlineConfig: {} as any, offlineVerifier: 'verifier' }, + true, + ], + [ + 'allow explicitly disabled launch password lock', + { lockPasswordOnLaunch: false, offlineConfig: {} as any, offlineVerifier: 'verifier' }, + false, + ], + ])('should %s', (_, persistedSession, expected) => { + expect(requiresLaunchPasswordUnlock(persistedSession)).toBe(expected); + }); + }); + + describe('`resumeSession`', () => { + test('should reject a protected client blob when the launch blob is missing', async () => { + const { authStore, clientKey } = await setupResumeSession(); + const protectedSession = withLaunchPassword(); + const encryptedSession = JSON.parse(await encryptPersistedSessionWithKey(protectedSession, clientKey)); + const { launchPasswordBlob, ...tamperedSession } = encryptedSession; + + authStore.setSession(tamperedSession); + + await expect( + resumeSession( + tamperedSession, + protectedSession.LocalID, + resumeConfig(authStore, protectedSession.UserID), + { + unlocked: true, + } + ) + ).rejects.toThrow('Missing launch password session blob'); + + expect(launchPasswordBlob).toBeDefined(); + }); + + test('should repersist legacy launch-password sessions without a protected blob', async () => { + const { authStore, clientKey } = await setupResumeSession(); + const legacySession = withLaunchPassword(); + const digest = await digestSession(legacySession, SESSION_DIGEST_VERSION); + const blob = await getEncryptedBlob( + clientKey, + JSON.stringify({ + keyPassword: legacySession.keyPassword, + offlineKD: legacySession.offlineKD, + sessionLockToken: legacySession.sessionLockToken, + digest, + }), + getSessionEncryptionTag(2) + ); + const { keyPassword, offlineKD, sessionLockToken, ...persistedSession } = legacySession; + + authStore.setSession(persistedSession); + + const result = await resumeSession( + { ...persistedSession, blob }, + legacySession.LocalID, + resumeConfig(authStore, legacySession.UserID), + { unlocked: true } + ); + + expect(result.repersist).toBe(true); + expect(result.session.keyPassword).toBe(keyPassword); + expect(result.session.offlineKD).toBe(offlineKD); + expect(result.session.sessionLockToken).toBe(sessionLockToken); + }); }); }); diff --git a/packages/pass/lib/auth/session.ts b/packages/pass/lib/auth/session.ts index b8251d8d00e..812c7a9c089 100644 --- a/packages/pass/lib/auth/session.ts +++ b/packages/pass/lib/auth/session.ts @@ -1,6 +1,8 @@ /* Inspired from packages/shared/lib/authentication/persistedSessionHelper.ts */ import { utf8StringToUint8Array } from '@protontech/crypto/utils'; + import { type OfflineConfig, getOfflineVerifier } from '@proton/pass/lib/cache/crypto'; +import { importSymmetricKey } from '@proton/pass/lib/crypto/utils/crypto-helpers'; import type { Api, Maybe, MaybeNull } from '@proton/pass/types'; import { getErrorMessage } from '@proton/pass/utils/errors/get-error-message'; import { prop } from '@proton/pass/utils/fp/lens'; @@ -30,6 +32,7 @@ export type AuthSession = { extraPassword?: boolean; keyPassword: string; lastUsedAt?: number; + lockPasswordOnLaunch?: boolean; LocalID?: number; lockMode: LockMode; lockTTL?: number; @@ -54,7 +57,10 @@ export type AuthSession = { /** The following values of the `AuthSession` are locally stored in * an encrypted blob using the BE local key for the user's session */ export type EncryptedSessionKeys = 'keyPassword' | 'offlineKD' | 'sessionLockToken'; -export type EncryptedAuthSession = Omit & { blob: string }; +export type EncryptedAuthSession = Omit & { + blob: string; + launchPasswordBlob?: string; +}; export type DecryptedAuthSessionBlob = Pick & { digest?: string }; export const SESSION_KEYS: (keyof AuthSession)[] = [ @@ -65,6 +71,7 @@ export const SESSION_KEYS: (keyof AuthSession)[] = [ 'extraPassword', 'keyPassword', 'lastUsedAt', + 'lockPasswordOnLaunch', 'LocalID', 'lockMode', 'lockTTL', @@ -88,17 +95,89 @@ export const SESSION_KEYS: (keyof AuthSession)[] = [ export const getSessionEncryptionTag = (version?: AuthSessionVersion): Maybe> => version === 2 ? utf8StringToUint8Array('session') : undefined; +export const getLaunchPasswordEncryptionTag = (): Uint8Array => + utf8StringToUint8Array('launch-password-session'); + +const supportsLaunchPasswordLock = (): boolean => EXTENSION_BUILD || DESKTOP_BUILD; +const LAUNCH_PASSWORD_PROTECTED_PLACEHOLDER = '__launch_password_protected__'; + +/** Launch-password protection is active when the password-wrapped blob exists, + * or when the session has local password material and was not explicitly disabled. */ +export const requiresLaunchPasswordUnlock = (session: Partial): boolean => + supportsLaunchPasswordLock() && + Boolean( + session.launchPasswordBlob || + (session.lockPasswordOnLaunch !== false && session.offlineConfig && session.offlineVerifier) + ); + +const getOfflineSessionKey = (offlineKD: string): Promise => + importSymmetricKey(stringToUint8Array(offlineKD)); + +/** Encrypts the sensitive persisted-session blob with the password-derived offline key. + * This is the launch gate: the client-key blob no longer holds these secrets when enabled. */ +export const encryptLaunchPasswordSessionBlob = async ( + blob: DecryptedAuthSessionBlob, + offlineKD: string +): Promise => { + const offlineKey = await getOfflineSessionKey(offlineKD); + return getEncryptedBlob(offlineKey, JSON.stringify(blob), getLaunchPasswordEncryptionTag()); +}; + +/** Decrypts the launch-gated session blob after password unlock has restored `offlineKD`. + * Failing this path means the protected local session is invalid or tampered with. */ +export const decryptLaunchPasswordSessionBlob = async ( + offlineKD: Maybe, + blob: string +): Promise => { + try { + if (!offlineKD) throw new Error('Missing launch password key'); + + const offlineKey = await getOfflineSessionKey(offlineKD); + const decryptedBlob = await getDecryptedBlob(offlineKey, blob, getLaunchPasswordEncryptionTag()); + const parsedValue = JSON.parse(decryptedBlob); + + if (!parsedValue.keyPassword) throw new Error('Missing `keyPassword`'); + + return { + keyPassword: parsedValue.keyPassword, + offlineKD: parsedValue.offlineKD, + sessionLockToken: parsedValue.sessionLockToken, + digest: parsedValue.digest, + }; + } catch (err) { + throw new InvalidPersistentSessionError(getErrorMessage(err)); + } +}; + /* Given a local session key, encrypts sensitive session components of * the `AuthSession` before persisting. Additionally stores a SHA-256 * integrity digest of the session data to validate when resuming */ export const encryptPersistedSessionWithKey = async (session: AuthSession, clientKey: CryptoKey): Promise => { const { keyPassword, offlineKD, payloadVersion = SESSION_VERSION, sessionLockToken, ...rest } = session; - const digest = await digestSession(session, SESSION_DIGEST_VERSION); + const launchPasswordProtected = Boolean( + supportsLaunchPasswordLock() && + session.lockPasswordOnLaunch !== false && + offlineKD && + session.offlineConfig && + session.offlineVerifier + ); + const sessionForDigest: AuthSession = { + ...session, + lockPasswordOnLaunch: launchPasswordProtected ? true : session.lockPasswordOnLaunch, + }; + const digest = await digestSession(sessionForDigest, SESSION_DIGEST_VERSION); const blob: DecryptedAuthSessionBlob = { keyPassword, offlineKD, sessionLockToken, digest }; + const clientBlob: DecryptedAuthSessionBlob = launchPasswordProtected + ? { keyPassword: LAUNCH_PASSWORD_PROTECTED_PLACEHOLDER, digest } + : blob; const value: EncryptedAuthSession = { ...rest, - blob: await getEncryptedBlob(clientKey, JSON.stringify(blob), getSessionEncryptionTag(payloadVersion)), + lockPasswordOnLaunch: launchPasswordProtected ? true : rest.lockPasswordOnLaunch, + ...(launchPasswordProtected + ? { launchPasswordBlob: await encryptLaunchPasswordSessionBlob(blob, offlineKD!) } + : {}), + blob: await getEncryptedBlob(clientKey, JSON.stringify(clientBlob), getSessionEncryptionTag(payloadVersion)), payloadVersion, }; @@ -174,7 +253,7 @@ export const resumeSession = async ( options: AuthOptions ): Promise => { const { api, authStore, onSessionInvalid } = config; - const { blob, ...session } = persistedSession; + const { blob, launchPasswordBlob, ...session } = persistedSession; const { UID } = session; const cookieUpgrade = authStore.shouldCookieUpgrade(persistedSession); @@ -188,7 +267,13 @@ export const resumeSession = async ( if (!persistedSession || persistedSession.UserID !== User.ID || !clientKey) throw InactiveSessionError(); const payloadVersion = session.payloadVersion ?? SESSION_VERSION; - const decryptedBlob = await decryptSessionBlob(clientKey, blob, payloadVersion); + const decryptedBlob = launchPasswordBlob + ? await decryptLaunchPasswordSessionBlob(authStore.getOfflineKD(), launchPasswordBlob) + : await decryptSessionBlob(clientKey, blob, payloadVersion); + + if (!launchPasswordBlob && decryptedBlob.keyPassword === LAUNCH_PASSWORD_PROTECTED_PLACEHOLDER) { + throw new InvalidPersistentSessionError('Missing launch password session blob'); + } if (decryptedBlob.digest) { const version = getSessionDigestVersion(decryptedBlob.digest); @@ -214,10 +299,11 @@ export const resumeSession = async ( * or cookie upgrade). This ensures the returned session contains the most * up-to-date authentication data. */ const syncedSession = syncAuthSession({ ...session, ...decryptedBlob }, authStore); + const shouldPersistLaunchPasswordBlob = !launchPasswordBlob && requiresLaunchPasswordUnlock(persistedSession); return { clientKey, - repersist: cookieUpgrade || !decryptedBlob.digest, + repersist: cookieUpgrade || !decryptedBlob.digest || shouldPersistLaunchPasswordBlob, session: syncedSession, }; } catch (error: unknown) { diff --git a/packages/pass/lib/auth/store.ts b/packages/pass/lib/auth/store.ts index 4877ab189c2..a27deba5f16 100644 --- a/packages/pass/lib/auth/store.ts +++ b/packages/pass/lib/auth/store.ts @@ -18,6 +18,7 @@ const PASS_CLIENT_KEY = 'pass:client_key'; const PASS_EXTRA_PWD_KEY = 'pass:extra_password'; const PASS_TWO_PWD_MODE = 'pass:two_password_mode'; const PASS_LOCAL_ID_KEY = 'pass:local_id'; +const PASS_LOCK_PASSWORD_ON_LAUNCH_KEY = 'pass:lock_password_on_launch'; const PASS_LOCK_EXTEND_TIME_KEY = 'pass:lock_extend_time'; const PASS_LOCK_MODE_KEY = 'pass:lock_mode'; const PASS_LOCK_STATE_KEY = 'pass:lock_state'; @@ -74,6 +75,7 @@ export const createAuthStore = (store: Store) => { extraPassword: authStore.getExtraPassword(), keyPassword: authStore.getPassword() ?? '', lastUsedAt: authStore.getLastUsedAt(), + lockPasswordOnLaunch: authStore.getLockPasswordOnLaunch(), LocalID: authStore.getLocalID(), lockLastExtendTime: authStore.getLockLastExtendTime(), lockMode: authStore.getLockMode(), @@ -130,6 +132,9 @@ export const createAuthStore = (store: Store) => { if (session.extraPassword) authStore.setExtraPassword(true); if (session.keyPassword) authStore.setPassword(session.keyPassword); if (session.lastUsedAt !== undefined) authStore.setLastUsedAt(session.lastUsedAt); + if (session.lockPasswordOnLaunch !== undefined) { + authStore.setLockPasswordOnLaunch(session.lockPasswordOnLaunch); + } if (session.LocalID !== undefined) authStore.setLocalID(session.LocalID); if (session.lockMode) authStore.setLockMode(session.lockMode); if (session.lockTTL) authStore.setLockTTL(session.lockTTL); @@ -193,6 +198,9 @@ export const createAuthStore = (store: Store) => { setLockMode: (mode: LockMode): void => store.set(PASS_LOCK_MODE_KEY, mode), getLockMode: (): LockMode => store.get(PASS_LOCK_MODE_KEY) ?? LockMode.NONE, + setLockPasswordOnLaunch: (enabled: Maybe): void => + store.set(PASS_LOCK_PASSWORD_ON_LAUNCH_KEY, enabled), + getLockPasswordOnLaunch: (): Maybe => store.get(PASS_LOCK_PASSWORD_ON_LAUNCH_KEY), setLocked: (status: boolean): void => store.set(PASS_LOCK_STATE_KEY, status), getLocked: (): Maybe => store.get(PASS_LOCK_STATE_KEY), setLockToken: encodedSetter(store)(PASS_LOCK_TOKEN_KEY), diff --git a/packages/pass/lib/auth/utils.spec.ts b/packages/pass/lib/auth/utils.spec.ts index 387163244a3..2fdb97fa38f 100644 --- a/packages/pass/lib/auth/utils.spec.ts +++ b/packages/pass/lib/auth/utils.spec.ts @@ -61,6 +61,13 @@ describe('getInitialLockedAppStatus', () => { describe('online', () => { const params = { offline: false, offlineEnabled: true }; + test('password-on-launch → PASSWORD_LOCKED', () => { + const store = makeAuthStore({ lockMode: LockMode.NONE }); + expect(getInitialLockedAppStatus(store, { ...params, passwordOnLaunch: true })).toBe( + AppStatus.PASSWORD_LOCKED + ); + }); + test('BIOMETRICS with encryptedOfflineKD → BIOMETRICS_LOCKED', () => { const store = makeAuthStore({ lockMode: LockMode.BIOMETRICS, encryptedOfflineKD: 'kd' }); expect(getInitialLockedAppStatus(store, params)).toBe(AppStatus.BIOMETRICS_LOCKED); diff --git a/packages/pass/lib/auth/utils.ts b/packages/pass/lib/auth/utils.ts index c9d007650f8..130bb68057c 100644 --- a/packages/pass/lib/auth/utils.ts +++ b/packages/pass/lib/auth/utils.ts @@ -38,17 +38,18 @@ export const getInvalidPasswordString = (authStore: AuthStore) => { * - Offline + offline-mode disabled → cannot unlock locally * - BIOMETRICS: prefers `BIOMETRICS_LOCKED` when `encryptedOfflineKD` exists, * otherwise falls back to `PASSWORD_LOCKED` as recovery path - * - PASSWORD: `PASSWORD_LOCKED`. + * - PASSWORD / password-on-launch: `PASSWORD_LOCKED`. * - SESSION / NONE (default): only locks when offline via `PASSWORD_LOCKED` */ export const getInitialLockedAppStatus = ( authStore: AuthStore, - params: { offlineEnabled: boolean; offline: boolean } + params: { offlineEnabled: boolean; offline: boolean; passwordOnLaunch?: boolean } ): Maybe => { const lockMode = authStore.getLockMode(); const encryptedOfflineKD = authStore.getEncryptedOfflineKD(); if (!authStore.hasOfflineComponents()) return; if (params.offline && !params.offlineEnabled) return; + if (params.passwordOnLaunch) return AppStatus.PASSWORD_LOCKED; switch (lockMode) { case LockMode.BIOMETRICS: diff --git a/packages/pass/store/actions/creators/auth.ts b/packages/pass/store/actions/creators/auth.ts index a724d69974b..042470e350e 100644 --- a/packages/pass/store/actions/creators/auth.ts +++ b/packages/pass/store/actions/creators/auth.ts @@ -3,7 +3,7 @@ import { c } from 'ttag'; import type { Lock, LockCreateDTO, UnlockDTO } from '@proton/pass/lib/auth/lock/types'; import { LockMode } from '@proton/pass/lib/auth/lock/types'; -import type { ExtraPasswordDTO, PasswordConfirmDTO } from '@proton/pass/lib/auth/password'; +import type { ExtraPasswordDTO, PasswordConfirmDTO, PasswordLaunchLockDTO } from '@proton/pass/lib/auth/password'; import { withCache } from '@proton/pass/store/actions/enhancers/cache'; import { withNotification } from '@proton/pass/store/actions/enhancers/notification'; import { withSettings } from '@proton/pass/store/actions/enhancers/settings'; @@ -148,6 +148,32 @@ export const passwordConfirm = requestActionsFactory('auth::password-on-launch::toggle')({ + intent: { + prepare: (payload) => + withNotification({ + type: 'info', + loading: true, + text: c('Info').t`Updating launch password requirement...`, + })({ payload }), + }, + success: { + prepare: (enabled) => + withNotification({ + type: 'success', + text: c('Info').t`Settings successfully updated`, + })({ payload: enabled }), + }, + failure: { + prepare: (error, payload) => + withNotification({ + type: 'error', + text: c('Error').t`Settings update failed`, + error, + })({ payload }), + }, +}); + export const extraPasswordToggle = requestActionsFactory('auth::extra-password::toggle')({ intent: { prepare: (payload) => diff --git a/packages/pass/store/sagas/auth/password-on-launch.saga.spec.ts b/packages/pass/store/sagas/auth/password-on-launch.saga.spec.ts new file mode 100644 index 00000000000..4a92ced76f6 --- /dev/null +++ b/packages/pass/store/sagas/auth/password-on-launch.saga.spec.ts @@ -0,0 +1,89 @@ +import { runSaga } from 'redux-saga'; + +import { generateOfflineComponents } from '@proton/pass/lib/cache/crypto'; +import { passwordOnLaunchToggle } from '@proton/pass/store/actions'; +import { sagaSetup } from '@proton/pass/store/sagas/testing'; +import { obfuscate } from '@proton/pass/utils/obfuscate/xor'; + +import watcher from './password-on-launch.saga'; + +jest.mock('@proton/pass/lib/cache/crypto', () => ({ + generateOfflineComponents: jest.fn(), +})); + +describe('password-on-launch saga', () => { + const components = { + offlineConfig: { salt: 'salt', params: {} }, + offlineKD: 'offline-kd', + offlineVerifier: 'offline-verifier', + }; + + const authService = { + confirmPassword: jest.fn().mockResolvedValue(true), + persistSession: jest.fn().mockResolvedValue(undefined), + }; + const authStore = { + getLockPasswordOnLaunch: jest.fn().mockReturnValue(false), + hasOfflinePassword: jest.fn().mockReturnValue(false), + setOfflineComponents: jest.fn(), + setLockPasswordOnLaunch: jest.fn(), + }; + const options = { getAuthService: () => authService, getAuthStore: () => authStore } as any; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(generateOfflineComponents).mockResolvedValue(components as any); + }); + + const runToggle = async (enabled: boolean) => { + const payload = { enabled, password: obfuscate('test-secret') }; + const intent = passwordOnLaunchToggle.intent(payload); + const saga = sagaSetup(); + const task = runSaga(saga.options, watcher, options); + + saga.options.dispatch(intent); + await saga.nextTick(); + task.cancel(); + + return { intent, payload, saga }; + }; + + test('sets up password material when enabling launch password lock', async () => { + const { intent, saga } = await runToggle(true); + const success = passwordOnLaunchToggle.success(intent.meta.request.id, true); + + expect(authService.confirmPassword).toHaveBeenCalledWith('test-secret'); + expect(generateOfflineComponents).toHaveBeenCalledWith('test-secret'); + expect(authStore.setOfflineComponents).toHaveBeenCalledWith(components); + expect(authStore.setLockPasswordOnLaunch).toHaveBeenCalledWith(true); + expect(authService.persistSession).toHaveBeenCalledWith({ throwOnFailure: true }); + expect(saga.dispatched).toContainEqual(success); + }); + + test('persists explicit false when disabling launch password lock', async () => { + authStore.hasOfflinePassword.mockReturnValueOnce(true); + + const { intent, saga } = await runToggle(false); + const success = passwordOnLaunchToggle.success(intent.meta.request.id, false); + + expect(authService.confirmPassword).toHaveBeenCalledWith('test-secret'); + expect(generateOfflineComponents).not.toHaveBeenCalled(); + expect(authStore.setLockPasswordOnLaunch).toHaveBeenCalledWith(false); + expect(authService.persistSession).toHaveBeenCalledWith({ throwOnFailure: true }); + expect(saga.dispatched).toContainEqual(success); + }); + + test('restores previous launch password state when secure persist fails', async () => { + const error = new Error('persist failed'); + + authStore.getLockPasswordOnLaunch.mockReturnValueOnce(false); + authService.persistSession.mockRejectedValueOnce(error); + + const { intent, saga } = await runToggle(true); + const failure = passwordOnLaunchToggle.failure(intent.meta.request.id, error, intent); + + expect(authStore.setLockPasswordOnLaunch).toHaveBeenCalledWith(true); + expect(authStore.setLockPasswordOnLaunch).toHaveBeenCalledWith(false); + expect(saga.dispatched).toContainEqual(failure); + }); +}); diff --git a/packages/pass/store/sagas/auth/password-on-launch.saga.ts b/packages/pass/store/sagas/auth/password-on-launch.saga.ts new file mode 100644 index 00000000000..b4aeea710b8 --- /dev/null +++ b/packages/pass/store/sagas/auth/password-on-launch.saga.ts @@ -0,0 +1,38 @@ +import { getInvalidPasswordString } from '@proton/pass/lib/auth/utils'; +import { generateOfflineComponents } from '@proton/pass/lib/cache/crypto'; +import { passwordOnLaunchToggle } from '@proton/pass/store/actions'; +import { createRequestSaga } from '@proton/pass/store/request/sagas'; +import { deobfuscate } from '@proton/pass/utils/obfuscate/xor'; + +export default createRequestSaga({ + actions: passwordOnLaunchToggle, + /** `lockPasswordOnLaunch` lives in the protected auth session. + * Enabling ensures offline password material exists for launch unlocks. */ + call: async ({ enabled, password: passwordBuff }, { getAuthService, getAuthStore }) => { + const auth = getAuthService(); + const authStore = getAuthStore(); + + const password = deobfuscate(passwordBuff, { zeroize: true }); + const previous = authStore.getLockPasswordOnLaunch(); + const verified = await auth.confirmPassword(password); + if (!verified) throw new Error(getInvalidPasswordString(authStore)); + + if (enabled && !authStore.hasOfflinePassword()) { + const components = await generateOfflineComponents(password); + authStore.setOfflineComponents(components); + } + + authStore.setLockPasswordOnLaunch(enabled); + + try { + await auth.persistSession({ + throwOnFailure: true, + }); + } catch (error) { + authStore.setLockPasswordOnLaunch(previous); + throw error; + } + + return enabled; + }, +}); diff --git a/packages/pass/store/sagas/index.ts b/packages/pass/store/sagas/index.ts index ed9faff254a..807f8dc76a4 100644 --- a/packages/pass/store/sagas/index.ts +++ b/packages/pass/store/sagas/index.ts @@ -18,6 +18,7 @@ import lockCreate from './auth/lock-create.saga'; import lock from './auth/lock.saga'; import passwordConfirm from './auth/password-confirm.saga'; import passwordExtra from './auth/password-extra.saga'; +import passwordOnLaunch from './auth/password-on-launch.saga'; import ssoSagas from './auth/sso.sagas'; import unlock from './auth/unlock.saga'; import boot from './client/boot.saga'; @@ -161,6 +162,7 @@ const COMMON_SAGAS = [ offlineResume, offlineSetup, passwordConfirm, + passwordOnLaunch, passwordExtra, reportProblem, sentinelToggle,