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';
17 createRandomShareResponses,
20 setupCryptoProxyForTesting,
21 } from './utils/testing';
23 describe('PassCrypto', () => {
25 let userKey: DecryptedKey;
27 ID: `addressId-${Math.random()}`,
28 Status: ADDRESS_STATUS.STATUS_ENABLED,
29 Receive: ADDRESS_RECEIVE.RECEIVE_YES,
30 Send: ADDRESS_SEND.SEND_YES,
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,
53 exposePassCrypto(createPassCrypto());
56 afterAll(async () => releaseCryptoProxy());
58 describe('PassCrypto::hydrate', () => {
59 afterEach(() => PassCrypto.clear());
61 test('should throw if no user keys', async () => {
64 user: { Keys: [] } as unknown as User,
66 keyPassword: TEST_KEY_PASSWORD,
68 ).rejects.toThrow(PassCryptoHydrationError);
71 test('should throw if no active address keys', async () => {
75 addresses: [{ ...address, Status: ADDRESS_STATUS.STATUS_DISABLED }],
76 keyPassword: TEST_KEY_PASSWORD,
78 ).rejects.toThrow(PassCryptoHydrationError);
81 test('should hydrate correctly', async () => {
83 PassCrypto.hydrate({ user, addresses: [address], keyPassword: TEST_KEY_PASSWORD })
84 ).resolves.not.toThrow();
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`,
100 ID: `${TEST_USER_KEY_ID}-inactive-1`,
102 PrivateKey: inactiveUserKey,
108 PassCrypto.hydrate({ user: userReset, addresses: [address], keyPassword: TEST_KEY_PASSWORD })
109 ).rejects.toThrow(PassCryptoHydrationError);
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`,
126 ID: `${TEST_USER_KEY_ID}-inactive-1`,
128 PrivateKey: inactiveUserKey,
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);
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);
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({
158 Key: vault.EncryptedVaultKey,
161 UserKeyID: 'test_user_key_id',
165 const decryptedContent = await decryptData(
167 base64StringToUint8Array(vault.Content),
168 PassEncryptionTag.VaultContent
171 expect(decryptedContent).toStrictEqual(content);
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);
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,
191 UserKeyID: TEST_USER_KEY_ID,
194 const encryptedShare: ShareGetResponse = {
195 AddressID: vault.AddressID,
196 Content: vault.Content,
197 ContentFormatVersion: ContentFormatVersion.Share,
198 ContentKeyRotation: 42,
201 NewUserInvitesReady: 0,
207 ShareID: `shareId-${Math.random()}`,
208 ShareRoleID: ShareRole.ADMIN,
209 TargetID: `targetId-${Math.random()}`,
212 TargetType: ShareType.Vault,
213 VaultID: `vaultId-${Math.random()}`,
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(
225 base64StringToUint8Array(vaultUpdate.Content),
226 PassEncryptionTag.VaultContent
229 expect(decryptedContent).toStrictEqual(contentUpdate);
230 expect(vaultUpdate.ContentFormatVersion).toEqual(ContentFormatVersion.Share);
231 expect(vaultUpdate.KeyRotation).toEqual(42);
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);
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 });
246 const content = randomContents();
247 const vault = await PassCrypto.createVault(content);
248 const shareKey: ShareKeyResponse = {
249 Key: vault.EncryptedVaultKey,
252 UserKeyID: TEST_USER_KEY_ID,
256 const encryptedShare: ShareGetResponse = {
257 AddressID: vault.AddressID,
258 Content: vault.Content,
259 ContentFormatVersion: ContentFormatVersion.Share,
260 ContentKeyRotation: 1,
263 NewUserInvitesReady: 0,
269 ShareID: `shareId-${Math.random()}`,
270 ShareRoleID: ShareRole.ADMIN,
271 TargetID: `targetId-${Math.random()}`,
274 TargetType: ShareType.Vault,
275 VaultID: `vaultId-${Math.random()}`,
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 = {
290 ContentKeyRotation: 2,
293 const newShareKey: ShareKeyResponse = {
294 Key: vault.EncryptedVaultKey,
297 UserKeyID: TEST_USER_KEY_ID,
300 jest.spyOn(shareManager, 'addVaultKey');
302 const shareUpdate = await PassCrypto.openShare({
303 encryptedShare: encryptedShareUpdate,
304 shareKeys: [shareKey, newShareKey],
307 expect(shareManager.getShare()).toStrictEqual(shareUpdate);
308 expect(shareManager.hasVaultKey(1)).toBe(true);
309 expect(shareManager.hasVaultKey(2)).toBe(true);
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,
325 NewUserInvitesReady: 0,
331 ShareID: `shareId-${Math.random()}`,
332 ShareRoleID: ShareRole.ADMIN,
333 TargetID: `targetId-${Math.random()}`,
336 TargetType: ShareType.Vault,
337 VaultID: `vaultId-${Math.random()}`,
341 await expect(PassCrypto.openShare({ encryptedShare, shareKeys: [] })).rejects.toThrow(
342 new PassCryptoShareError(`Empty share keys`)
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,
356 UserKeyID: TEST_USER_KEY_ID,
359 const encryptedShare: ShareGetResponse = {
360 AddressID: vault.AddressID,
361 Content: vault.Content,
362 ContentFormatVersion: ContentFormatVersion.Share,
363 ContentKeyRotation: 2,
366 NewUserInvitesReady: 0,
372 ShareID: `shareId-${Math.random()}`,
373 ShareRoleID: ShareRole.ADMIN,
374 TargetID: `targetId-${Math.random()}`,
377 TargetType: ShareType.Vault,
378 VaultID: `vaultId-${Math.random()}`,
382 await expect(PassCrypto.openShare({ encryptedShare, shareKeys: [shareKey] })).rejects.toThrow(
383 new PassCryptoShareError(`Missing vault key for rotation 2`)
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,
396 UserKeyID: `${TEST_USER_KEY_ID}-inactive`,
399 const encryptedShare: ShareGetResponse = {
400 AddressID: vault.AddressID,
401 Content: vault.Content,
402 ContentFormatVersion: ContentFormatVersion.Share,
403 ContentKeyRotation: 1,
406 NewUserInvitesReady: 0,
412 ShareID: `shareId-${Math.random()}`,
413 ShareRoleID: ShareRole.ADMIN,
414 TargetID: `targetId-${Math.random()}`,
417 TargetType: ShareType.Vault,
418 VaultID: `vaultId-${Math.random()}`,
422 expect(await PassCrypto.openShare({ encryptedShare, shareKeys: [shareKey] })).toEqual(null);
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);
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,
450 UserKeyID: TEST_USER_KEY_ID,
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);
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);
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,
476 ItemID: `itemId-${Math.random()}`,
477 ItemKey: 'base64encoded',
484 State: ItemState.Active,
488 PassCrypto.openItem({
489 shareId: 'somerandom',
492 ).rejects.toThrow(PassCryptoShareError);
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] });
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,
510 ItemID: `itemId-${Math.random()}`,
511 ItemKey: item.ItemKey,
518 State: ItemState.Active,
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);
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);
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,
554 ItemID: `itemId-${Math.random()}`,
555 ItemKey: Item.ItemKey,
562 State: ItemState.Active,
565 const movedItem = await PassCrypto.openItem({ shareId: targetShare.ShareID, encryptedItem });
566 expect(movedItem.content).toEqual(content);