Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / keys / device.ts
blob43d31441ccda4d43d1845d5ffd0de49d4c43871e
1 import { base32crockford } from '@scure/base';
2 import { c } from 'ttag';
4 import { CryptoProxy, type PrivateKeyReference } from '@proton/crypto';
5 import { decryptData, encryptData, importKey } from '@proton/crypto/lib/subtle/aesGcm';
6 import { stringToUtf8Array, utf8ArrayToString } from '@proton/crypto/lib/utils';
7 import { getApiError } from '@proton/shared/lib/api/helpers/apiErrorHelper';
8 import { getUser } from '@proton/shared/lib/api/user';
9 import { API_CODES } from '@proton/shared/lib/constants';
10 import { HTTP_ERROR_CODES } from '@proton/shared/lib/errors';
11 import { getBrowser, getOs } from '@proton/shared/lib/helpers/browser';
12 import { getSHA256String } from '@proton/shared/lib/helpers/crypto';
13 import { getItem, removeItem, setItem } from '@proton/shared/lib/helpers/storage';
14 import { getIsGlobalSSOAccount } from '@proton/shared/lib/keys/setupAddress';
15 import noop from '@proton/utils/noop';
17 import {
18     AuthDeviceErrorCodes,
19     addAuthDeviceConfig,
20     associateAuthDeviceConfig,
21     deleteAuthDeviceConfig,
22     getAuthDevicesConfig,
23     getPendingMemberAuthDevicesConfig,
24 } from '../api/authDevice';
25 import { base64StringToUint8Array, uint8ArrayToBase64String } from '../helpers/encoding';
26 import type { Address, AddressKey, Api, User, User as tsUser } from '../interfaces';
28 const AesContext = {
29     deviceSecret: 'account.device-secret',
32 export enum AuthDeviceState {
33     Inactive = 0,
34     Active = 1,
35     PendingActivation = 2,
36     PendingAdminActivation = 3,
37     Rejected = 4,
38     NoSession = 5,
41 export interface DeviceSecretData {
42     data: Uint8Array;
43     serializedData: string;
44     key: CryptoKey;
45     confirmationCode: string;
48 export interface DeviceDataSerialized {
49     deviceSecretData: DeviceSecretData;
50     serializedDeviceData: SerializedAuthDeviceData;
53 export interface DeviceData {
54     deviceSecretData: DeviceSecretData;
55     deviceOutput: AuthDeviceOutput;
58 export interface DeviceSecretUser {
59     deviceSecretData: DeviceSecretData;
60     keyPassword: string;
61     user: User;
62     serializedDeviceData: SerializedAuthDeviceData;
65 export class AuthDeviceInactiveError extends Error {
66     deviceDataSerialized: DeviceDataSerialized;
68     constructor(deviceDataSerialized: DeviceDataSerialized) {
69         super('AuthDeviceInactiveError');
70         this.deviceDataSerialized = deviceDataSerialized;
71         Object.setPrototypeOf(this, AuthDeviceInactiveError.prototype);
72     }
75 export class AuthDeviceInvalidError extends Error {
76     deviceID: string;
78     context: string;
80     constructor(deviceID: string, context: string) {
81         super('AuthDeviceInvalidError');
82         this.deviceID = deviceID;
83         this.context = context;
84         Object.setPrototypeOf(this, AuthDeviceInvalidError.prototype);
85     }
88 export class AuthDeviceNonExistingError extends Error {}
90 type DevicePlatform = 'Web' | 'Windows' | 'macOS' | 'Linux' | 'Android' | 'AndroidTV' | 'iOS' | 'AppleTV';
92 export interface AuthDeviceOutput {
93     ID: string;
94     State: AuthDeviceState;
95     Name: string;
96     LocalizedClientName: string;
97     Platform: DevicePlatform;
98     CreateTime: number;
99     ActivateTime?: number;
100     RejectTime: number;
101     LastActivityTime: number;
102     ActivationToken?: string;
103     ActivationAddressID?: string;
104     DeviceToken: string;
107 export interface MemberAuthDeviceOutput extends AuthDeviceOutput {
108     MemberID: string;
111 export interface AuthDevicesOutput {
112     AuthDevices: AuthDeviceOutput[];
115 export interface AssociateAuthDeviceOutput {
116     ID: string;
117     EncryptedSecret: string;
120 export const deserializeAuthDeviceSecret = (value: string) => {
121     return base64StringToUint8Array(value);
124 const serializeAuthDeviceSecret = (value: Uint8Array) => {
125     return uint8ArrayToBase64String(value);
128 export const getAuthDeviceSecretConfirmationCode = async (data: string) => {
129     const sha256DeviceSecret = await getSHA256String(data);
130     return base32crockford.encode(stringToUtf8Array(sha256DeviceSecret)).slice(0, 4);
133 export const deserializeAuthDeviceSecretData = async (
134     deviceID: string,
135     serializedData: string
136 ): Promise<DeviceSecretData> => {
137     try {
138         const data = deserializeAuthDeviceSecret(serializedData);
139         const key = await importKey(data);
140         return {
141             data,
142             key,
143             serializedData,
144             confirmationCode: await getAuthDeviceSecretConfirmationCode(serializedData),
145         };
146     } catch {
147         throw new AuthDeviceInvalidError(deviceID, 'Unable to deserialize');
148     }
151 export const generateAuthDeviceSecretData = async (): Promise<DeviceSecretData> => {
152     const data = crypto.getRandomValues(new Uint8Array(32));
153     const serializedData = serializeAuthDeviceSecret(data);
154     const key = await importKey(data);
156     return {
157         data,
158         serializedData,
159         key,
160         confirmationCode: await getAuthDeviceSecretConfirmationCode(serializedData),
161     };
164 const getDeviceName = () => {
165     const browserVendor = getBrowser();
166     const osVendor = getOs();
167     let osVendorName = osVendor.name;
168     if (osVendorName === 'Mac OS') {
169         osVendorName = 'macOS';
170     }
171     return [browserVendor.name, osVendorName].filter(Boolean).join(', ') || 'Browser';
174 export const createAuthDevice = async ({ api }: { api: Api }): Promise<DeviceData> => {
175     const deviceSecretData = await generateAuthDeviceSecretData();
176     const name = getDeviceName();
177     const deviceOutput = await api<{
178         AuthDevice: AuthDeviceOutput;
179     }>(addAuthDeviceConfig({ Name: name })).then(({ AuthDevice }) => AuthDevice);
180     return {
181         deviceSecretData,
182         deviceOutput,
183     };
186 const encryptAuthDeviceActivationToken = async ({
187     primaryAddressKey,
188     deviceSecretData,
189 }: {
190     primaryAddressKey: AddressKey;
191     deviceSecretData: DeviceSecretData;
192 }) => {
193     const publicKey = await CryptoProxy.importPublicKey({ armoredKey: primaryAddressKey.PrivateKey });
194     const { message } = await CryptoProxy.encryptMessage({
195         encryptionKeys: [publicKey],
196         textData: deviceSecretData.serializedData,
197     });
198     await CryptoProxy.clearKey({ key: publicKey });
199     return message;
202 export const decryptAuthDeviceActivationToken = async ({
203     deviceID,
204     decryptionKeys,
205     armoredMessage,
206 }: {
207     deviceID: string;
208     decryptionKeys: PrivateKeyReference[];
209     armoredMessage: string;
210 }): Promise<DeviceSecretData> => {
211     const { data } = await CryptoProxy.decryptMessage({
212         decryptionKeys,
213         armoredMessage,
214     });
215     return deserializeAuthDeviceSecretData(deviceID, data);
218 export const createAuthDeviceToActivate = async ({
219     api,
220     primaryAddressKey,
221 }: {
222     api: Api;
223     primaryAddressKey: AddressKey;
224 }): Promise<DeviceData> => {
225     const deviceSecretData = await generateAuthDeviceSecretData();
226     const name = getDeviceName();
227     const activationToken = await encryptAuthDeviceActivationToken({ primaryAddressKey, deviceSecretData });
229     const deviceOutput = await api<{ AuthDevice: AuthDeviceOutput }>(
230         addAuthDeviceConfig({
231             Name: name,
232             ActivationToken: activationToken,
233         })
234     ).then(({ AuthDevice }) => AuthDevice);
236     return {
237         deviceSecretData,
238         deviceOutput,
239     };
242 export const encryptAuthDeviceSecret = async ({
243     keyPassword,
244     deviceSecretData,
245 }: {
246     keyPassword: string;
247     deviceSecretData: DeviceSecretData;
248 }) => {
249     const encryptedSecret = await encryptData(
250         deviceSecretData.key,
251         stringToUtf8Array(keyPassword),
252         stringToUtf8Array(AesContext.deviceSecret)
253     );
254     return uint8ArrayToBase64String(encryptedSecret);
257 export const getDecryptedAuthDeviceSecret = async ({
258     encryptedSecret,
259     deviceDataSerialized,
260 }: {
261     encryptedSecret: string;
262     deviceDataSerialized: DeviceDataSerialized;
263 }) => {
264     try {
265         const decryptedSecret = await decryptData(
266             deviceDataSerialized.deviceSecretData.key,
267             base64StringToUint8Array(encryptedSecret),
268             stringToUtf8Array(AesContext.deviceSecret)
269         );
270         return utf8ArrayToString(decryptedSecret);
271     } catch {
272         throw new AuthDeviceInvalidError(deviceDataSerialized.serializedDeviceData.id, 'Unable to decrypt');
273     }
276 const getStorageKey = (userID: string) => {
277     return `ds-${userID}`;
280 interface SerializedAuthDeviceData {
281     id: string;
282     token: string;
283     secret: string;
284     persistedAt: number;
287 const serializeAuthDeviceData = (deviceData: DeviceData): string => {
288     const serializedData: SerializedAuthDeviceData = {
289         id: deviceData.deviceOutput.ID,
290         token: deviceData.deviceOutput.DeviceToken,
291         secret: deviceData.deviceSecretData.serializedData,
292         persistedAt: Date.now(),
293     };
294     return JSON.stringify(serializedData);
297 const deserializeAuthDeviceData = (data: string | null | undefined): SerializedAuthDeviceData | undefined => {
298     if (!data) {
299         return;
300     }
301     try {
302         const parsedJson: SerializedAuthDeviceData = JSON.parse(data);
303         if (parsedJson.token && parsedJson.secret && parsedJson.id) {
304             return {
305                 id: parsedJson.id,
306                 token: parsedJson.token,
307                 secret: parsedJson.secret,
308                 persistedAt: Number(parsedJson.persistedAt),
309             };
310         }
311     } catch {}
314 const getEncryptedAuthDeviceSecret = async ({
315     api,
316     deviceDataSerialized,
317 }: {
318     api: Api;
319     deviceDataSerialized: DeviceDataSerialized;
320 }) => {
321     try {
322         const {
323             AuthDevice: { EncryptedSecret },
324         } = await api<{ AuthDevice: AssociateAuthDeviceOutput }>(
325             associateAuthDeviceConfig({
326                 DeviceID: deviceDataSerialized.serializedDeviceData.id,
327                 DeviceToken: deviceDataSerialized.serializedDeviceData.token,
328             })
329         );
330         return EncryptedSecret;
331     } catch (e) {
332         const { status, code } = getApiError(e);
333         if (
334             code === AuthDeviceErrorCodes.AUTH_DEVICE_NOT_FOUND ||
335             code === AuthDeviceErrorCodes.AUTH_DEVICE_TOKEN_INVALID ||
336             code === AuthDeviceErrorCodes.AUTH_DEVICE_REJECTED ||
337             (status === HTTP_ERROR_CODES.UNPROCESSABLE_ENTITY && code === API_CODES.NOT_ALLOWED_ERROR)
338         ) {
339             throw new AuthDeviceInvalidError(deviceDataSerialized.serializedDeviceData.id, 'API invalid');
340         }
342         if (code === AuthDeviceErrorCodes.AUTH_DEVICE_NOT_ACTIVE) {
343             throw new AuthDeviceInactiveError(deviceDataSerialized);
344         }
346         throw e;
347     }
350 export const getPersistedAuthDeviceDataByUser = ({ user }: { user: User }) => {
351     try {
352         return deserializeAuthDeviceData(getItem(getStorageKey(user.ID)));
353     } catch {}
356 export const setPersistedAuthDeviceDataByUser = async ({
357     user,
358     deviceData,
359 }: {
360     user: User;
361     deviceData: DeviceData;
362 }) => {
363     setItem(getStorageKey(user.ID), serializeAuthDeviceData(deviceData));
366 export const removePersistedAuthDeviceDataByUser = ({ user, deviceID }: { user: User; deviceID: string }) => {
367     const persistedAuthDevice = getPersistedAuthDeviceDataByUser({ user });
368     // We're only storing one device per user, and to avoid race conditions we need to verify that the persisted device id
369     // is the same one (or missing) that we want to remove.
370     if (!persistedAuthDevice || persistedAuthDevice.id === deviceID) {
371         removeItem(getStorageKey(user.ID));
372     }
375 export const getDeserializedDeviceSecretData = async (
376     serializedDeviceData: SerializedAuthDeviceData
377 ): Promise<DeviceDataSerialized> => {
378     const deviceSecretData = await deserializeAuthDeviceSecretData(
379         serializedDeviceData.id,
380         serializedDeviceData.secret
381     );
382     return {
383         deviceSecretData,
384         serializedDeviceData,
385     };
388 export const getDeviceSecretDataByUser = async ({ user }: { user: User }) => {
389     const serializedDeviceData = getPersistedAuthDeviceDataByUser({ user });
390     if (!serializedDeviceData) {
391         throw new AuthDeviceNonExistingError();
392     }
393     const deviceDataSerialized = await getDeserializedDeviceSecretData(serializedDeviceData);
394     return deviceDataSerialized.deviceSecretData;
397 export const getAuthDeviceDataByUser = async ({
398     user: cachedUser,
399     api,
400     refreshUser,
401 }: {
402     user: User;
403     api: Api;
404     refreshUser?: boolean;
405 }): Promise<DeviceSecretUser> => {
406     const serializedDeviceData = getPersistedAuthDeviceDataByUser({ user: cachedUser });
407     if (!serializedDeviceData) {
408         throw new AuthDeviceNonExistingError();
409     }
411     const deviceDataSerialized = await getDeserializedDeviceSecretData(serializedDeviceData);
412     const { deviceSecretData } = deviceDataSerialized;
413     const encryptedSecret = await getEncryptedAuthDeviceSecret({ api, deviceDataSerialized });
414     const keyPassword = await getDecryptedAuthDeviceSecret({ encryptedSecret, deviceDataSerialized });
416     const user = !refreshUser ? cachedUser : await api<{ User: tsUser }>(getUser()).then(({ User }) => User);
417     const armoredPrimaryPrivateKey = user.Keys[0]?.PrivateKey;
418     const primaryPrivateKey = await CryptoProxy.importPrivateKey({
419         armoredKey: armoredPrimaryPrivateKey,
420         passphrase: keyPassword,
421     }).catch(noop);
423     if (!primaryPrivateKey) {
424         throw new AuthDeviceInvalidError(deviceDataSerialized.serializedDeviceData.id, 'Unable to decrypt primary key');
425     }
427     return {
428         serializedDeviceData,
429         deviceSecretData,
430         keyPassword,
431         user,
432     };
435 export const getPendingMemberAuthDevices = async ({ api }: { api: Api }) => {
436     const { MemberAuthDevices } = await api<{
437         MemberAuthDevices: MemberAuthDeviceOutput[];
438     }>(getPendingMemberAuthDevicesConfig());
439     return MemberAuthDevices;
442 export const getValidActivation = ({
443     addresses,
444     pendingAuthDevice,
445 }: {
446     addresses: Pick<Address, 'ID' | 'Email'>[];
447     pendingAuthDevice: AuthDeviceOutput;
448 }) => {
449     const { ActivationAddressID: activationAddressID, ActivationToken: activationToken } = pendingAuthDevice;
450     if (!activationAddressID || !activationToken) {
451         return null;
452     }
453     const address = addresses.find(({ ID }) => ID === activationAddressID);
454     if (!address) {
455         return null;
456     }
457     return {
458         address,
459         token: activationToken,
460     };
463 export const getLocalizedDeviceState = (state: AuthDeviceState) => {
464     switch (state) {
465         case AuthDeviceState.Active:
466             return c('sso: auth device state').t`Active`;
467         case AuthDeviceState.Inactive:
468             return c('sso: auth device state').t`Inactive`;
469         case AuthDeviceState.NoSession:
470             return c('sso: auth device state').t`Signed out`;
471         case AuthDeviceState.PendingActivation:
472             return c('sso: auth device state').t`Pending activation`;
473         case AuthDeviceState.PendingAdminActivation:
474             return c('sso: auth device state').t`Pending admin activation`;
475         case AuthDeviceState.Rejected:
476             return c('sso: auth device state').t`Rejected`;
477     }
478     return c('sso: auth device state').t`Unknown`;
481 export const getAllAuthDevices = async ({
482     user,
483     api,
484 }: {
485     user: User | null | undefined;
486     api: Api;
487 }): Promise<AuthDeviceOutput[]> => {
488     if (user && getIsGlobalSSOAccount(user)) {
489         try {
490             const { AuthDevices } = await api<AuthDevicesOutput>({
491                 silence: true,
492                 ...getAuthDevicesConfig(),
493             });
494             return AuthDevices;
495         } catch {
496             return [];
497         }
498     }
499     return [];
502 export const deleteAuthDevice = async ({
503     user,
504     api,
505     deviceID,
506 }: {
507     // If the deletion of this auth device should happen locally too
508     user?: User;
509     api: Api;
510     deviceID: string;
511 }) => {
512     if (user) {
513         removePersistedAuthDeviceDataByUser({ user, deviceID });
514     }
515     return api(deleteAuthDeviceConfig(deviceID));