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';
20 associateAuthDeviceConfig,
21 deleteAuthDeviceConfig,
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';
29 deviceSecret: 'account.device-secret',
32 export enum AuthDeviceState {
35 PendingActivation = 2,
36 PendingAdminActivation = 3,
41 export interface DeviceSecretData {
43 serializedData: string;
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;
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);
75 export class AuthDeviceInvalidError extends Error {
80 constructor(deviceID: string, context: string) {
81 super('AuthDeviceInvalidError');
82 this.deviceID = deviceID;
83 this.context = context;
84 Object.setPrototypeOf(this, AuthDeviceInvalidError.prototype);
88 export class AuthDeviceNonExistingError extends Error {}
90 type DevicePlatform = 'Web' | 'Windows' | 'macOS' | 'Linux' | 'Android' | 'AndroidTV' | 'iOS' | 'AppleTV';
92 export interface AuthDeviceOutput {
94 State: AuthDeviceState;
96 LocalizedClientName: string;
97 Platform: DevicePlatform;
99 ActivateTime?: number;
101 LastActivityTime: number;
102 ActivationToken?: string;
103 ActivationAddressID?: string;
107 export interface MemberAuthDeviceOutput extends AuthDeviceOutput {
111 export interface AuthDevicesOutput {
112 AuthDevices: AuthDeviceOutput[];
115 export interface AssociateAuthDeviceOutput {
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 (
135 serializedData: string
136 ): Promise<DeviceSecretData> => {
138 const data = deserializeAuthDeviceSecret(serializedData);
139 const key = await importKey(data);
144 confirmationCode: await getAuthDeviceSecretConfirmationCode(serializedData),
147 throw new AuthDeviceInvalidError(deviceID, 'Unable to deserialize');
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);
160 confirmationCode: await getAuthDeviceSecretConfirmationCode(serializedData),
164 const getDeviceName = () => {
165 const browserVendor = getBrowser();
166 const osVendor = getOs();
167 let osVendorName = osVendor.name;
168 if (osVendorName === 'Mac OS') {
169 osVendorName = 'macOS';
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);
186 const encryptAuthDeviceActivationToken = async ({
190 primaryAddressKey: AddressKey;
191 deviceSecretData: DeviceSecretData;
193 const publicKey = await CryptoProxy.importPublicKey({ armoredKey: primaryAddressKey.PrivateKey });
194 const { message } = await CryptoProxy.encryptMessage({
195 encryptionKeys: [publicKey],
196 textData: deviceSecretData.serializedData,
198 await CryptoProxy.clearKey({ key: publicKey });
202 export const decryptAuthDeviceActivationToken = async ({
208 decryptionKeys: PrivateKeyReference[];
209 armoredMessage: string;
210 }): Promise<DeviceSecretData> => {
211 const { data } = await CryptoProxy.decryptMessage({
215 return deserializeAuthDeviceSecretData(deviceID, data);
218 export const createAuthDeviceToActivate = async ({
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({
232 ActivationToken: activationToken,
234 ).then(({ AuthDevice }) => AuthDevice);
242 export const encryptAuthDeviceSecret = async ({
247 deviceSecretData: DeviceSecretData;
249 const encryptedSecret = await encryptData(
250 deviceSecretData.key,
251 stringToUtf8Array(keyPassword),
252 stringToUtf8Array(AesContext.deviceSecret)
254 return uint8ArrayToBase64String(encryptedSecret);
257 export const getDecryptedAuthDeviceSecret = async ({
259 deviceDataSerialized,
261 encryptedSecret: string;
262 deviceDataSerialized: DeviceDataSerialized;
265 const decryptedSecret = await decryptData(
266 deviceDataSerialized.deviceSecretData.key,
267 base64StringToUint8Array(encryptedSecret),
268 stringToUtf8Array(AesContext.deviceSecret)
270 return utf8ArrayToString(decryptedSecret);
272 throw new AuthDeviceInvalidError(deviceDataSerialized.serializedDeviceData.id, 'Unable to decrypt');
276 const getStorageKey = (userID: string) => {
277 return `ds-${userID}`;
280 interface SerializedAuthDeviceData {
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(),
294 return JSON.stringify(serializedData);
297 const deserializeAuthDeviceData = (data: string | null | undefined): SerializedAuthDeviceData | undefined => {
302 const parsedJson: SerializedAuthDeviceData = JSON.parse(data);
303 if (parsedJson.token && parsedJson.secret && parsedJson.id) {
306 token: parsedJson.token,
307 secret: parsedJson.secret,
308 persistedAt: Number(parsedJson.persistedAt),
314 const getEncryptedAuthDeviceSecret = async ({
316 deviceDataSerialized,
319 deviceDataSerialized: DeviceDataSerialized;
323 AuthDevice: { EncryptedSecret },
324 } = await api<{ AuthDevice: AssociateAuthDeviceOutput }>(
325 associateAuthDeviceConfig({
326 DeviceID: deviceDataSerialized.serializedDeviceData.id,
327 DeviceToken: deviceDataSerialized.serializedDeviceData.token,
330 return EncryptedSecret;
332 const { status, code } = getApiError(e);
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)
339 throw new AuthDeviceInvalidError(deviceDataSerialized.serializedDeviceData.id, 'API invalid');
342 if (code === AuthDeviceErrorCodes.AUTH_DEVICE_NOT_ACTIVE) {
343 throw new AuthDeviceInactiveError(deviceDataSerialized);
350 export const getPersistedAuthDeviceDataByUser = ({ user }: { user: User }) => {
352 return deserializeAuthDeviceData(getItem(getStorageKey(user.ID)));
356 export const setPersistedAuthDeviceDataByUser = async ({
361 deviceData: DeviceData;
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));
375 export const getDeserializedDeviceSecretData = async (
376 serializedDeviceData: SerializedAuthDeviceData
377 ): Promise<DeviceDataSerialized> => {
378 const deviceSecretData = await deserializeAuthDeviceSecretData(
379 serializedDeviceData.id,
380 serializedDeviceData.secret
384 serializedDeviceData,
388 export const getDeviceSecretDataByUser = async ({ user }: { user: User }) => {
389 const serializedDeviceData = getPersistedAuthDeviceDataByUser({ user });
390 if (!serializedDeviceData) {
391 throw new AuthDeviceNonExistingError();
393 const deviceDataSerialized = await getDeserializedDeviceSecretData(serializedDeviceData);
394 return deviceDataSerialized.deviceSecretData;
397 export const getAuthDeviceDataByUser = async ({
404 refreshUser?: boolean;
405 }): Promise<DeviceSecretUser> => {
406 const serializedDeviceData = getPersistedAuthDeviceDataByUser({ user: cachedUser });
407 if (!serializedDeviceData) {
408 throw new AuthDeviceNonExistingError();
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,
423 if (!primaryPrivateKey) {
424 throw new AuthDeviceInvalidError(deviceDataSerialized.serializedDeviceData.id, 'Unable to decrypt primary key');
428 serializedDeviceData,
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 = ({
446 addresses: Pick<Address, 'ID' | 'Email'>[];
447 pendingAuthDevice: AuthDeviceOutput;
449 const { ActivationAddressID: activationAddressID, ActivationToken: activationToken } = pendingAuthDevice;
450 if (!activationAddressID || !activationToken) {
453 const address = addresses.find(({ ID }) => ID === activationAddressID);
459 token: activationToken,
463 export const getLocalizedDeviceState = (state: AuthDeviceState) => {
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`;
478 return c('sso: auth device state').t`Unknown`;
481 export const getAllAuthDevices = async ({
485 user: User | null | undefined;
487 }): Promise<AuthDeviceOutput[]> => {
488 if (user && getIsGlobalSSOAccount(user)) {
490 const { AuthDevices } = await api<AuthDevicesOutput>({
492 ...getAuthDevicesConfig(),
502 export const deleteAuthDevice = async ({
507 // If the deletion of this auth device should happen locally too
513 removePersistedAuthDeviceDataByUser({ user, deviceID });
515 return api(deleteAuthDeviceConfig(deviceID));