Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / crypto / pass-crypto.ts
blobc23e00b593268e1d19b4eefcfd976a53e80a4d7b
1 import { CryptoProxy } from '@proton/crypto';
2 import { authStore } from '@proton/pass/lib/auth/store';
3 import type {
4     PassCryptoManagerContext,
5     PassCryptoWorker,
6     SerializedCryptoContext,
7     ShareContext,
8     ShareGetResponse,
9     ShareKeyResponse,
10     ShareManager,
11     TypedOpenedShare,
12 } from '@proton/pass/types';
13 import { ShareType } from '@proton/pass/types';
14 import { unwrap } from '@proton/pass/utils/fp/promises';
15 import { logId, logger } from '@proton/pass/utils/logger';
16 import { entriesMap } from '@proton/pass/utils/object/map';
17 import type { DecryptedAddressKey } from '@proton/shared/lib/interfaces';
18 import { getDecryptedAddressKeysHelper, getDecryptedUserKeysHelper } from '@proton/shared/lib/keys';
20 import * as processes from './processes';
21 import { createShareManager } from './share-manager';
22 import { getSupportedAddresses } from './utils/addresses';
23 import {
24     PassCryptoError,
25     PassCryptoHydrationError,
26     PassCryptoNotHydratedError,
27     PassCryptoShareError,
28     isPassCryptoError,
29 } from './utils/errors';
31 function assertHydrated(ctx: PassCryptoManagerContext): asserts ctx is Required<PassCryptoManagerContext> {
32     if (
33         ctx.user === undefined ||
34         ctx.addresses === undefined ||
35         ctx.primaryAddress === undefined ||
36         ctx.userKeys === undefined ||
37         ctx.primaryUserKey === undefined
38     ) {
39         throw new PassCryptoNotHydratedError('Pass crypto manager incorrectly hydrated');
40     }
43 export const createPassCrypto = (): PassCryptoWorker => {
44     const context: PassCryptoManagerContext = {
45         user: undefined,
46         userKeys: [],
47         addresses: [],
48         primaryUserKey: undefined,
49         primaryAddress: undefined,
50         shareManagers: new Map(),
51     };
53     const hasShareManager = (shareId: string): boolean => context.shareManagers.has(shareId);
55     const getShareManager = (shareId: string): ShareManager => {
56         if (!hasShareManager(shareId)) throw new PassCryptoShareError(`Unknown shareId : cannot resolve share manager`);
57         return context.shareManagers.get(shareId)!;
58     };
60     const unregisterInactiveShares = () => {
61         context.shareManagers.forEach((shareManager, shareId) => {
62             if (!shareManager.isActive(context.userKeys)) {
63                 logger.info(`[PassCrypto] Unregistering share ${logId(shareId)} (inactive)`);
64                 context.shareManagers.delete(shareId);
65             }
66         });
67     };
69     const getDecryptedAddressKeys = async (addressId: string): Promise<DecryptedAddressKey[]> => {
70         assertHydrated(context);
72         const address = context.addresses.find((address) => address.ID === addressId);
73         if (address === undefined) throw new PassCryptoError(`Could not find address with ID ${logId(addressId)}`);
74         return getDecryptedAddressKeysHelper(address.Keys, context.user, context.userKeys, authStore.getPassword()!);
75     };
77     /** Resolves the decrypted address key reference */
78     const getPrimaryAddressKeyById = async (addressId: string): Promise<DecryptedAddressKey> => {
79         const [primaryAddressKey] = await getDecryptedAddressKeys(addressId);
80         return primaryAddressKey;
81     };
83     const worker: PassCryptoWorker = {
84         get ready() {
85             try {
86                 assertHydrated(context);
87                 return true;
88             } catch {
89                 return false;
90             }
91         },
93         getContext: () => context,
95         async hydrate({ user, addresses, keyPassword, snapshot, clear }) {
96             logger.info('[PassCrypto] Hydrating crypto state');
98             if (clear) worker.clear();
100             try {
101                 const userKeys = await getDecryptedUserKeysHelper(user, keyPassword);
102                 const activeAddresses = addresses.filter(getSupportedAddresses);
104                 if (userKeys.length === 0) throw new PassCryptoHydrationError('No user keys found');
105                 if (activeAddresses.length === 0) throw new PassCryptoHydrationError('No active user addresses found');
107                 context.user = user;
108                 context.addresses = addresses;
109                 context.primaryAddress = activeAddresses[0];
110                 context.userKeys = userKeys;
111                 context.primaryUserKey = userKeys[0];
113                 if (snapshot) {
114                     const entries = snapshot.shareManagers as [string, SerializedCryptoContext<ShareContext>][];
115                     const shareManagers = await unwrap(entriesMap(entries)(createShareManager.fromSnapshot));
116                     context.shareManagers = new Map(shareManagers);
117                     logger.info('[PassCrypto] Hydrated from local snapshot');
118                 }
119             } catch (err) {
120                 logger.warn('[PassCrypto] Hydration failed', err);
121                 const message = err instanceof Error ? err.message : 'unknown error';
122                 throw new PassCryptoHydrationError(`Hydration failure (${message})`);
123             }
125             unregisterInactiveShares();
126         },
128         clear() {
129             logger.info('[PassCrypto] Clearing state');
130             context.user = undefined;
131             context.userKeys = [];
132             context.addresses = [];
133             context.primaryAddress = undefined;
134             context.primaryUserKey = undefined;
135             context.shareManagers = new Map();
136         },
138         getShareManager,
140         getDecryptedAddressKeys,
142         /* Creating a vault does not register a share manager :
143          * call PassCrypto::openShare to register it */
144         async createVault(content) {
145             assertHydrated(context);
147             return processes.createVault({
148                 content,
149                 userKey: context.primaryUserKey,
150                 addressId: context.primaryAddress.ID,
151             });
152         },
154         /* Updating a vault does not register a share manager :
155          * call PassCrypto::openShare to register it.
156          *
157          * ⚠️ Key rotation : We're assuming the shareManager will
158          * always hold the latest rotation keys - In order to future
159          * -proof this, each vault update should be preceded by a
160          * call to retrieve the latest shareKeys */
161         async updateVault({ shareId, content }) {
162             assertHydrated(context);
164             const shareManager = getShareManager(shareId);
165             const latestRotation = shareManager.getLatestRotation();
166             const vaultKey = shareManager.getVaultKey(latestRotation);
168             return processes.updateVault({ vaultKey, content });
169         },
171         canOpenShare(shareId) {
172             try {
173                 return worker.getShareManager(shareId).isActive(context.userKeys);
174             } catch (_) {
175                 return false;
176             }
177         },
179         /* Opening a new share has the side-effect of registering a
180          * shareManager for this share. When opening a pre-registered
181          * share (most likely hydrated from a snapshot) - filter the
182          * vault keys to only open those we haven't already processed.
183          * This can happen during a vault  share content update or during
184          * a full data sync. */
185         openShare: async <T extends ShareType = ShareType>(data: {
186             encryptedShare: ShareGetResponse;
187             shareKeys: ShareKeyResponse[];
188         }) => {
189             assertHydrated(context);
191             try {
192                 const { encryptedShare, shareKeys } = data;
194                 if (shareKeys.length === 0) throw new PassCryptoShareError('Empty share keys');
196                 /* before processing the current encryptedShare - ensure the
197                  * latest rotation key can be decrypted with an active userKey */
198                 const latestKey = shareKeys.reduce((acc, curr) => (curr.KeyRotation > acc.KeyRotation ? curr : acc));
199                 const canOpenShare = context.userKeys.some(({ ID }) => ID === latestKey.UserKeyID);
201                 if (!canOpenShare) return null;
203                 const shareId = encryptedShare.ShareID;
204                 const maybeShareManager = hasShareManager(shareId) ? getShareManager(shareId) : undefined;
206                 const share = await (async () => {
207                     switch (encryptedShare.TargetType) {
208                         case ShareType.Vault: {
209                             const vaultKeys = await Promise.all(
210                                 shareKeys.map((shareKey) =>
211                                     maybeShareManager?.hasVaultKey(shareKey.KeyRotation)
212                                         ? maybeShareManager.getVaultKey(shareKey.KeyRotation)
213                                         : processes.openVaultKey({ shareKey, userKeys: context.userKeys })
214                                 )
215                             );
217                             const rotation = encryptedShare.ContentKeyRotation!;
218                             const vaultKey = vaultKeys.find((key) => key.rotation === rotation);
219                             if (vaultKey === undefined) {
220                                 throw new PassCryptoShareError(`Missing vault key for rotation ${rotation}`);
221                             }
222                             return processes.openShare({ type: ShareType.Vault, encryptedShare, vaultKey });
223                         }
225                         case ShareType.Item:
226                             return processes.openShare({ type: ShareType.Item, encryptedShare });
228                         default:
229                             throw new PassCryptoShareError('Unsupported share type');
230                     }
231                 })();
233                 const shareManager = maybeShareManager ?? createShareManager(share);
235                 context.shareManagers.set(shareId, shareManager);
236                 shareManager.setShare(share); /* handle update when recyling */
237                 await worker.updateShareKeys({ shareId, shareKeys });
239                 return shareManager.getShare() as TypedOpenedShare<T>;
240             } catch (err: any) {
241                 throw isPassCryptoError(err) ? err : new PassCryptoError(err);
242             }
243         },
245         /* FIXME: add support for itemKeys when we
246          * support ItemShares */
247         async updateShareKeys({ shareId, shareKeys }) {
248             assertHydrated(context);
250             const shareManager = getShareManager(shareId);
251             const newVaultKeys = await Promise.all(
252                 shareKeys
253                     .filter(({ KeyRotation }) => !shareManager.hasVaultKey(KeyRotation))
254                     .map((shareKey) => processes.openVaultKey({ shareKey, userKeys: context.userKeys }))
255             );
257             newVaultKeys.forEach((vaultKey) => shareManager.addVaultKey(vaultKey));
258         },
260         removeShare: (shareId) => context.shareManagers.delete(shareId),
262         /* Resolve the latest rotation for this share
263          * and use the vault key for that rotation */
264         async createItem({ shareId, content }) {
265             assertHydrated(context);
267             const shareManager = getShareManager(shareId);
268             const latestRotation = shareManager.getLatestRotation();
269             const vaultKey = shareManager.getVaultKey(latestRotation);
271             return processes.createItem({ content, vaultKey });
272         },
274         async openItem({ shareId, encryptedItem }) {
275             assertHydrated(context);
277             const shareManager = getShareManager(shareId);
278             const vaultKey = shareManager.getVaultKey(encryptedItem.KeyRotation!);
280             return processes.openItem({ encryptedItem, vaultKey });
281         },
283         /* We're assuming that every call to PassCrypto::updateItem will
284          * be preceded by a request to resolve the latest encrypted item
285          * key for future-proofing */
286         async updateItem({ shareId, content, latestItemKey, lastRevision }) {
287             assertHydrated(context);
289             const shareManager = getShareManager(shareId);
290             const vaultKey = shareManager.getVaultKey(latestItemKey.KeyRotation);
291             const itemKey = await processes.openItemKey({ encryptedItemKey: latestItemKey, vaultKey });
293             return processes.updateItem({ itemKey, content, lastRevision });
294         },
296         async moveItem({ destinationShareId, content }) {
297             assertHydrated(context);
299             const shareManager = getShareManager(destinationShareId);
300             const latestRotation = shareManager.getLatestRotation();
301             const destinationVaultKey = shareManager.getVaultKey(latestRotation);
303             return processes.moveItem({ destinationShareId, destinationVaultKey, content });
304         },
306         async createVaultInvite({ shareId, invitedPublicKey, email, role }) {
307             assertHydrated(context);
309             const shareManager = getShareManager(shareId);
310             const share = shareManager.getShare();
311             const inviteKeys = await processes.createInviteKeys({
312                 targetKeys: shareManager.getVaultKeys(),
313                 invitedPublicKey: await CryptoProxy.importPublicKey({ armoredKey: invitedPublicKey }),
314                 inviterPrivateKey: (await getPrimaryAddressKeyById(share.addressId)).privateKey,
315             });
317             return { Keys: inviteKeys, Email: email, ShareRoleID: role, TargetType: ShareType.Vault };
318         },
320         async createNewUserVaultInvite({ shareId, email, role }) {
321             assertHydrated(context);
323             const shareManager = getShareManager(shareId);
324             const share = shareManager.getShare();
326             const signature = await processes.createNewUserSignature({
327                 inviterPrivateKey: (await getPrimaryAddressKeyById(share.addressId)).privateKey,
328                 invitedEmail: email,
329                 vaultKey: shareManager.getVaultKey(shareManager.getLatestRotation()),
330             });
332             return {
333                 Email: email,
334                 ShareRoleID: role,
335                 Signature: signature,
336                 TargetType: ShareType.Vault,
337             };
338         },
340         async promoteInvite({ shareId, invitedPublicKey }) {
341             assertHydrated(context);
343             const shareManager = getShareManager(shareId);
344             const share = shareManager.getShare();
345             const inviteKeys = await processes.createInviteKeys({
346                 targetKeys: shareManager.getVaultKeys(),
347                 invitedPublicKey: await CryptoProxy.importPublicKey({ armoredKey: invitedPublicKey }),
348                 inviterPrivateKey: (await getPrimaryAddressKeyById(share.addressId)).privateKey,
349             });
351             return { Keys: inviteKeys };
352         },
354         async acceptVaultInvite({ inviteKeys, invitedAddressId, inviterPublicKeys }) {
355             assertHydrated(context);
357             const vaultKeys = await processes.reencryptInviteKeys({
358                 userKey: context.primaryUserKey,
359                 inviteKeys,
360                 invitedPrivateKey: (await getPrimaryAddressKeyById(invitedAddressId)).privateKey,
361                 inviterPublicKeys: await Promise.all(
362                     inviterPublicKeys.map((armoredKey) => CryptoProxy.importPublicKey({ armoredKey }))
363                 ),
364             });
366             return { Keys: vaultKeys };
367         },
369         async readVaultInvite({ inviteKey, invitedAddressId, encryptedVaultContent, inviterPublicKeys }) {
370             assertHydrated(context);
372             return processes.readVaultInviteContent({
373                 inviteKey,
374                 encryptedVaultContent,
375                 invitedPrivateKey: (await getPrimaryAddressKeyById(invitedAddressId)).privateKey,
376                 inviterPublicKeys: await Promise.all(
377                     inviterPublicKeys.map((armoredKey) => CryptoProxy.importPublicKey({ armoredKey }))
378                 ),
379             });
380         },
382         async createSecureLink({ shareId, latestItemKey }) {
383             assertHydrated(context);
385             const vaultKey = getShareManager(shareId).getVaultKey(latestItemKey.KeyRotation);
386             const itemKey = await processes.openItemKey({ encryptedItemKey: latestItemKey, vaultKey });
387             return processes.createSecureLink({ itemKey, vaultKey });
388         },
390         async openSecureLink({ linkKey, publicLinkContent }) {
391             if (!publicLinkContent.ItemKey || !publicLinkContent.Contents) {
392                 throw new Error('Missing data when retrieving secure link content');
393             }
395             return processes.openSecureLink({
396                 encryptedItemKey: publicLinkContent.ItemKey,
397                 content: publicLinkContent.Contents,
398                 linkKey,
399             });
400         },
402         async openLinkKey({ encryptedLinkKey, linkKeyShareKeyRotation, shareId }) {
403             assertHydrated(context);
405             const vaultKey = getShareManager(shareId).getVaultKey(linkKeyShareKeyRotation);
406             return processes.openLinkKey({ encryptedLinkKey, shareKey: vaultKey.key });
407         },
409         serialize: () => ({
410             shareManagers: [...context.shareManagers.entries()].map(([shareId, shareManager]) => [
411                 shareId,
412                 shareManager.serialize(),
413             ]),
414         }),
415     };
417     return worker;