Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / crypto / pass-crypto.spec.ts
blob3f05abd8c1bc8f9b6090683b48ea7c2e60f9fa6c
1 import { CryptoProxy } from '@proton/crypto';
2 import type { ItemRevisionContentsResponse, ShareGetResponse, ShareKeyResponse } from '@proton/pass/types';
3 import { ContentFormatVersion, ItemState, PassEncryptionTag, ShareRole, ShareType } from '@proton/pass/types';
4 import { ADDRESS_RECEIVE, ADDRESS_SEND, ADDRESS_STATUS } from '@proton/shared/lib/constants';
5 import { base64StringToUint8Array } from '@proton/shared/lib/helpers/encoding';
6 import type { Address, DecryptedKey, Key, User } from '@proton/shared/lib/interfaces';
8 import { PassCrypto, exposePassCrypto } from './index';
9 import { createPassCrypto } from './pass-crypto';
10 import * as processes from './processes';
11 import { decryptData } from './utils/crypto-helpers';
12 import { PassCryptoHydrationError, PassCryptoNotHydratedError, PassCryptoShareError } from './utils/errors';
13 import {
14     TEST_KEY_PASSWORD,
15     TEST_USER_KEY_ID,
16     createRandomKey,
17     createRandomShareResponses,
18     randomContents,
19     releaseCryptoProxy,
20     setupCryptoProxyForTesting,
21 } from './utils/testing';
23 describe('PassCrypto', () => {
24     let user: User;
25     let userKey: DecryptedKey;
26     const address = {
27         ID: `addressId-${Math.random()}`,
28         Status: ADDRESS_STATUS.STATUS_ENABLED,
29         Receive: ADDRESS_RECEIVE.RECEIVE_YES,
30         Send: ADDRESS_SEND.SEND_YES,
31     } as Address;
33     beforeAll(async () => {
34         await setupCryptoProxyForTesting();
36         userKey = await createRandomKey();
37         const PrivateKey = await CryptoProxy.exportPrivateKey({
38             privateKey: userKey.privateKey,
39             passphrase: TEST_KEY_PASSWORD,
40         });
42         user = {
43             Keys: [
44                 {
45                     ID: TEST_USER_KEY_ID,
46                     PrivateKey,
47                     Version: 3,
48                     Active: 1,
49                 } as Key,
50             ],
51         } as User;
53         exposePassCrypto(createPassCrypto());
54     });
56     afterAll(async () => releaseCryptoProxy());
58     describe('PassCrypto::hydrate', () => {
59         afterEach(() => PassCrypto.clear());
61         test('should throw if no user keys', async () => {
62             await expect(
63                 PassCrypto.hydrate({
64                     user: { Keys: [] } as unknown as User,
65                     addresses: [address],
66                     keyPassword: TEST_KEY_PASSWORD,
67                 })
68             ).rejects.toThrow(PassCryptoHydrationError);
69         });
71         test('should throw if no active address keys', async () => {
72             await expect(
73                 PassCrypto.hydrate({
74                     user,
75                     addresses: [{ ...address, Status: ADDRESS_STATUS.STATUS_DISABLED }],
76                     keyPassword: TEST_KEY_PASSWORD,
77                 })
78             ).rejects.toThrow(PassCryptoHydrationError);
79         });
81         test('should hydrate correctly', async () => {
82             await expect(
83                 PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD })
84             ).resolves.not.toThrow();
85         });
87         test('should throw if no active user keys', async () => {
88             const userKey = user.Keys[0];
89             const inactiveKey = await createRandomKey();
90             const inactiveUserKey = await CryptoProxy.exportPrivateKey({
91                 privateKey: inactiveKey.privateKey,
92                 passphrase: `${TEST_KEY_PASSWORD}-inactive`,
93             });
95             const userReset = {
96                 ...user,
97                 Keys: [
98                     {
99                         ...userKey,
100                         ID: `${TEST_USER_KEY_ID}-inactive-1`,
101                         Active: 0,
102                         PrivateKey: inactiveUserKey,
103                     },
104                 ],
105             } as User;
107             await expect(
108                 PassCrypto.hydrate({ user: userReset, addresses: [address], keyPassword: TEST_KEY_PASSWORD })
109             ).rejects.toThrow(PassCryptoHydrationError);
110         });
112         test('should only track  active user keys', async () => {
113             const userKey = user.Keys[0];
114             const inactiveKey = await createRandomKey();
115             const inactiveUserKey = await CryptoProxy.exportPrivateKey({
116                 privateKey: inactiveKey.privateKey,
117                 passphrase: `${TEST_KEY_PASSWORD}-inactive`,
118             });
120             const userReset = {
121                 ...user,
122                 Keys: [
123                     userKey,
124                     {
125                         ...userKey,
126                         ID: `${TEST_USER_KEY_ID}-inactive-1`,
127                         Active: 0,
128                         PrivateKey: inactiveUserKey,
129                     },
130                 ],
131             } as User;
133             await PassCrypto.hydrate({ user: userReset, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
134             expect(PassCrypto.getContext().userKeys!.length).toEqual(1);
135             expect(PassCrypto.getContext().userKeys?.[0].ID).toEqual(TEST_USER_KEY_ID);
136         });
137     });
139     describe('PassCrypto::createVault', () => {
140         afterEach(() => PassCrypto.clear());
142         test('should throw if PassCrypto not hydrated', async () => {
143             await expect(PassCrypto.createVault(randomContents())).rejects.toThrow(PassCryptoNotHydratedError);
144         });
146         test('should call createVault with primary user key and primary addressId', async () => {
147             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
149             const content = randomContents();
150             const vault = await PassCrypto.createVault(content);
152             expect(vault.AddressID).toEqual(address.ID);
153             expect(vault.ContentFormatVersion).toEqual(ContentFormatVersion.Share);
155             const vaultKey = await processes.openVaultKey({
156                 userKeys: [userKey],
157                 shareKey: {
158                     Key: vault.EncryptedVaultKey,
159                     KeyRotation: 1,
160                     CreateTime: 0,
161                     UserKeyID: 'test_user_key_id',
162                 },
163             });
165             const decryptedContent = await decryptData(
166                 vaultKey.key,
167                 base64StringToUint8Array(vault.Content),
168                 PassEncryptionTag.VaultContent
169             );
171             expect(decryptedContent).toStrictEqual(content);
172         });
173     });
175     describe('PassCrypto::updateVault', () => {
176         afterEach(() => PassCrypto.clear());
178         test('should throw if PassCrypto not hydrated', async () => {
179             await expect(PassCrypto.updateVault({} as any)).rejects.toThrow(PassCryptoNotHydratedError);
180         });
182         test('should call updateVault with latest vaultKey', async () => {
183             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
185             const vault = await PassCrypto.createVault(randomContents());
187             const shareKey: ShareKeyResponse = {
188                 Key: vault.EncryptedVaultKey,
189                 KeyRotation: 42,
190                 CreateTime: 0,
191                 UserKeyID: TEST_USER_KEY_ID,
192             };
194             const encryptedShare: ShareGetResponse = {
195                 AddressID: vault.AddressID,
196                 Content: vault.Content,
197                 ContentFormatVersion: ContentFormatVersion.Share,
198                 ContentKeyRotation: 42,
199                 CreateTime: 0,
200                 ExpireTime: 0,
201                 NewUserInvitesReady: 0,
202                 Owner: true,
203                 PendingInvites: 0,
204                 Permission: 1,
205                 Primary: false,
206                 Shared: false,
207                 ShareID: `shareId-${Math.random()}`,
208                 ShareRoleID: ShareRole.ADMIN,
209                 TargetID: `targetId-${Math.random()}`,
210                 TargetMaxMembers: 2,
211                 TargetMembers: 0,
212                 TargetType: ShareType.Vault,
213                 VaultID: `vaultId-${Math.random()}`,
214                 CanAutoFill: true,
215             };
217             /* register the share */
218             const share = await PassCrypto.openShare({ encryptedShare, shareKeys: [shareKey] });
219             const contentUpdate = randomContents();
220             const vaultUpdate = await PassCrypto.updateVault({ shareId: share!.shareId, content: contentUpdate });
221             const vaultKey = PassCrypto.getShareManager(share!.shareId).getVaultKey(42);
223             const decryptedContent = await decryptData(
224                 vaultKey.key,
225                 base64StringToUint8Array(vaultUpdate.Content),
226                 PassEncryptionTag.VaultContent
227             );
229             expect(decryptedContent).toStrictEqual(contentUpdate);
230             expect(vaultUpdate.ContentFormatVersion).toEqual(ContentFormatVersion.Share);
231             expect(vaultUpdate.KeyRotation).toEqual(42);
232         });
233     });
235     describe('PassCrypto::openShare', () => {
236         afterEach(() => PassCrypto.clear());
238         test('should throw if PassCrypto not hydrated', async () => {
239             await expect(PassCrypto.openShare({} as any)).rejects.toThrow(PassCryptoNotHydratedError);
240         });
242         test('should create a new share manager and add new vault keys on manager', async () => {
243             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
245             /* create a vault */
246             const content = randomContents();
247             const vault = await PassCrypto.createVault(content);
248             const shareKey: ShareKeyResponse = {
249                 Key: vault.EncryptedVaultKey,
250                 KeyRotation: 1,
251                 CreateTime: 0,
252                 UserKeyID: TEST_USER_KEY_ID,
253             };
255             /* mock response */
256             const encryptedShare: ShareGetResponse = {
257                 AddressID: vault.AddressID,
258                 Content: vault.Content,
259                 ContentFormatVersion: ContentFormatVersion.Share,
260                 ContentKeyRotation: 1,
261                 CreateTime: 0,
262                 ExpireTime: 0,
263                 NewUserInvitesReady: 0,
264                 Owner: true,
265                 PendingInvites: 0,
266                 Permission: 1,
267                 Primary: false,
268                 Shared: false,
269                 ShareID: `shareId-${Math.random()}`,
270                 ShareRoleID: ShareRole.ADMIN,
271                 TargetID: `targetId-${Math.random()}`,
272                 TargetMaxMembers: 2,
273                 TargetMembers: 0,
274                 TargetType: ShareType.Vault,
275                 VaultID: `vaultId-${Math.random()}`,
276                 CanAutoFill: true,
277             };
279             const share = await PassCrypto.openShare({ encryptedShare, shareKeys: [shareKey] });
280             const shareManager = PassCrypto.getShareManager(encryptedShare.ShareID);
282             expect(share!.content).toEqual(content);
283             expect(PassCrypto.getShareManager(encryptedShare.ShareID)).toBeDefined();
284             expect(shareManager.getShare()).toStrictEqual(share);
285             expect(shareManager.hasVaultKey(1)).toBe(true);
287             /* simulate new vault keys being added */
288             const encryptedShareUpdate: ShareGetResponse = {
289                 ...encryptedShare,
290                 ContentKeyRotation: 2,
291             };
293             const newShareKey: ShareKeyResponse = {
294                 Key: vault.EncryptedVaultKey,
295                 KeyRotation: 2,
296                 CreateTime: 0,
297                 UserKeyID: TEST_USER_KEY_ID,
298             };
300             jest.spyOn(shareManager, 'addVaultKey');
302             const shareUpdate = await PassCrypto.openShare({
303                 encryptedShare: encryptedShareUpdate,
304                 shareKeys: [shareKey, newShareKey],
305             });
307             expect(shareManager.getShare()).toStrictEqual(shareUpdate);
308             expect(shareManager.hasVaultKey(1)).toBe(true);
309             expect(shareManager.hasVaultKey(2)).toBe(true);
310         });
312         test('should throw if share keys list is empty', async () => {
313             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
315             const content = randomContents();
316             const vault = await PassCrypto.createVault(content);
318             const encryptedShare: ShareGetResponse = {
319                 AddressID: vault.AddressID,
320                 Content: vault.Content,
321                 ContentFormatVersion: ContentFormatVersion.Share,
322                 ContentKeyRotation: 2,
323                 CreateTime: 0,
324                 ExpireTime: 0,
325                 NewUserInvitesReady: 0,
326                 Owner: true,
327                 PendingInvites: 0,
328                 Permission: 1,
329                 Primary: false,
330                 Shared: false,
331                 ShareID: `shareId-${Math.random()}`,
332                 ShareRoleID: ShareRole.ADMIN,
333                 TargetID: `targetId-${Math.random()}`,
334                 TargetMaxMembers: 2,
335                 TargetMembers: 0,
336                 TargetType: ShareType.Vault,
337                 VaultID: `vaultId-${Math.random()}`,
338                 CanAutoFill: true,
339             };
341             await expect(PassCrypto.openShare({ encryptedShare, shareKeys: [] })).rejects.toThrow(
342                 new PassCryptoShareError(`Empty share keys`)
343             );
344         });
346         test('should throw if there are no share keys for current share rotation', async () => {
347             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
349             const content = randomContents();
350             const vault = await PassCrypto.createVault(content);
352             const shareKey: ShareKeyResponse = {
353                 Key: vault.EncryptedVaultKey,
354                 KeyRotation: 1,
355                 CreateTime: 0,
356                 UserKeyID: TEST_USER_KEY_ID,
357             };
359             const encryptedShare: ShareGetResponse = {
360                 AddressID: vault.AddressID,
361                 Content: vault.Content,
362                 ContentFormatVersion: ContentFormatVersion.Share,
363                 ContentKeyRotation: 2,
364                 CreateTime: 0,
365                 ExpireTime: 0,
366                 NewUserInvitesReady: 0,
367                 Owner: true,
368                 PendingInvites: 0,
369                 Permission: 1,
370                 Primary: false,
371                 Shared: false,
372                 ShareID: `shareId-${Math.random()}`,
373                 ShareRoleID: ShareRole.ADMIN,
374                 TargetID: `targetId-${Math.random()}`,
375                 TargetMaxMembers: 2,
376                 TargetMembers: 0,
377                 TargetType: ShareType.Vault,
378                 VaultID: `vaultId-${Math.random()}`,
379                 CanAutoFill: true,
380             };
382             await expect(PassCrypto.openShare({ encryptedShare, shareKeys: [shareKey] })).rejects.toThrow(
383                 new PassCryptoShareError(`Missing vault key for rotation 2`)
384             );
385         });
387         test('should return null if no user key can decrypt current share', async () => {
388             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
390             const content = randomContents();
391             const vault = await PassCrypto.createVault(content);
392             const shareKey: ShareKeyResponse = {
393                 Key: vault.EncryptedVaultKey,
394                 KeyRotation: 1,
395                 CreateTime: 0,
396                 UserKeyID: `${TEST_USER_KEY_ID}-inactive`,
397             };
399             const encryptedShare: ShareGetResponse = {
400                 AddressID: vault.AddressID,
401                 Content: vault.Content,
402                 ContentFormatVersion: ContentFormatVersion.Share,
403                 ContentKeyRotation: 1,
404                 CreateTime: 0,
405                 ExpireTime: 0,
406                 NewUserInvitesReady: 0,
407                 Owner: true,
408                 PendingInvites: 0,
409                 Permission: 1,
410                 Primary: false,
411                 Shared: false,
412                 ShareID: `shareId-${Math.random()}`,
413                 ShareRoleID: ShareRole.ADMIN,
414                 TargetID: `targetId-${Math.random()}`,
415                 TargetMaxMembers: 2,
416                 TargetMembers: 0,
417                 TargetType: ShareType.Vault,
418                 VaultID: `vaultId-${Math.random()}`,
419                 CanAutoFill: true,
420             };
422             expect(await PassCrypto.openShare({ encryptedShare, shareKeys: [shareKey] })).toEqual(null);
423         });
424     });
426     describe('PassCrypto::updateShare', () => {
427         afterEach(() => PassCrypto.clear());
429         test('should throw if PassCrypto not hydrated', async () => {
430             await expect(PassCrypto.openShare({} as any)).rejects.toThrow(PassCryptoNotHydratedError);
431         });
433         test('should register only new vaults keys on shareManager', async () => {
434             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
436             const [encryptedShare, shareKey] = await createRandomShareResponses(userKey, address.ID);
437             const shareId = encryptedShare.ShareID;
438             await PassCrypto.openShare({ encryptedShare, shareKeys: [shareKey] });
440             const shareManager = PassCrypto.getShareManager(encryptedShare.ShareID);
442             jest.spyOn(shareManager, 'addVaultKey');
444             const content = randomContents();
445             const vault = await processes.createVault({ content, userKey, addressId: address.ID });
446             const newShareKey: ShareKeyResponse = {
447                 Key: vault.EncryptedVaultKey,
448                 KeyRotation: 2,
449                 CreateTime: 0,
450                 UserKeyID: TEST_USER_KEY_ID,
451             };
453             await PassCrypto.updateShareKeys({ shareId, shareKeys: [shareKey] });
454             expect(shareManager.addVaultKey).toHaveBeenCalledTimes(0);
456             await PassCrypto.updateShareKeys({ shareId, shareKeys: [shareKey, newShareKey] });
457             expect(shareManager.addVaultKey).toHaveBeenCalledTimes(1);
458         });
459     });
461     describe('PassCrypto::openItem', () => {
462         afterEach(() => PassCrypto.clear());
464         test('should throw if PassCrypto not hydrated', async () => {
465             await expect(PassCrypto.openItem({} as any)).rejects.toThrow(PassCryptoNotHydratedError);
466         });
468         test('should throw if vault share has not been registered yet', async () => {
469             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
471             const encryptedItem: ItemRevisionContentsResponse = {
472                 Content: 'base64encoded',
473                 ContentFormatVersion: ContentFormatVersion.Item,
474                 CreateTime: 0,
475                 Flags: 0,
476                 ItemID: `itemId-${Math.random()}`,
477                 ItemKey: 'base64encoded',
478                 KeyRotation: 1,
479                 LastUseTime: 0,
480                 ModifyTime: 0,
481                 Pinned: false,
482                 Revision: 1,
483                 RevisionTime: 0,
484                 State: ItemState.Active,
485             };
487             await expect(
488                 PassCrypto.openItem({
489                     shareId: 'somerandom',
490                     encryptedItem,
491                 })
492             ).rejects.toThrow(PassCryptoShareError);
493         });
495         test('should decrypt item with shareManager vault key', async () => {
496             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
498             const [encryptedShare, shareKey] = await createRandomShareResponses(userKey, address.ID);
499             await PassCrypto.openShare({ encryptedShare, shareKeys: [shareKey] });
500             const vaultKey = await processes.openVaultKey({ shareKey, userKeys: [userKey] });
502             /* create item */
503             const itemContent = randomContents();
504             const item = await processes.createItem({ content: itemContent, vaultKey });
505             const encryptedItem: ItemRevisionContentsResponse = {
506                 Content: item.Content,
507                 ContentFormatVersion: ContentFormatVersion.Item,
508                 CreateTime: 0,
509                 Flags: 0,
510                 ItemID: `itemId-${Math.random()}`,
511                 ItemKey: item.ItemKey,
512                 KeyRotation: 1,
513                 LastUseTime: 0,
514                 ModifyTime: 0,
515                 Pinned: false,
516                 Revision: 1,
517                 RevisionTime: 0,
518                 State: ItemState.Active,
519             };
521             const openedItem = await PassCrypto.openItem({ shareId: encryptedShare.ShareID, encryptedItem });
523             expect(openedItem.itemId).toEqual(encryptedItem.ItemID);
524             expect(openedItem.content).toEqual(itemContent);
525             expect(openedItem.revision).toEqual(encryptedItem.Revision);
526             expect(openedItem.state).toEqual(encryptedItem.State);
527         });
528     });
530     describe('PassCrypto::moveItem', () => {
531         afterEach(() => PassCrypto.clear());
533         test('should throw if PassCrypto not hydrated', async () => {
534             await expect(PassCrypto.moveItem({} as any)).rejects.toThrow(PassCryptoNotHydratedError);
535         });
537         test('should re-encrypt item and create new item key with correct destination vault key', async () => {
538             await PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD });
540             const content = randomContents();
541             const [targetShare, targetVaultKey] = await createRandomShareResponses(userKey, address.ID);
542             await PassCrypto.openShare({ encryptedShare: targetShare, shareKeys: [targetVaultKey] });
544             const { Item } = await PassCrypto.moveItem({ content, destinationShareId: targetShare.ShareID });
546             expect(Item.ContentFormatVersion).toEqual(ContentFormatVersion.Item);
547             expect(Item.KeyRotation).toEqual(targetVaultKey.KeyRotation);
549             const encryptedItem: ItemRevisionContentsResponse = {
550                 Content: Item.Content,
551                 ContentFormatVersion: ContentFormatVersion.Item,
552                 CreateTime: 0,
553                 Flags: 0,
554                 ItemID: `itemId-${Math.random()}`,
555                 ItemKey: Item.ItemKey,
556                 KeyRotation: 1,
557                 LastUseTime: 0,
558                 ModifyTime: 0,
559                 Pinned: false,
560                 Revision: 1,
561                 RevisionTime: 0,
562                 State: ItemState.Active,
563             };
565             const movedItem = await PassCrypto.openItem({ shareId: targetShare.ShareID, encryptedItem });
566             expect(movedItem.content).toEqual(content);
567         });
568     });