@protontech/drive-sdk 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/crypto/driveCrypto.js +1 -1
  2. package/dist/crypto/driveCrypto.js.map +1 -1
  3. package/dist/crypto/interface.d.ts +3 -1
  4. package/dist/crypto/openPGPCrypto.d.ts +4 -1
  5. package/dist/crypto/openPGPCrypto.js +2 -1
  6. package/dist/crypto/openPGPCrypto.js.map +1 -1
  7. package/dist/interface/account.d.ts +6 -0
  8. package/dist/internal/apiService/driveTypes.d.ts +197 -22
  9. package/dist/internal/nodes/apiService.d.ts +1 -1
  10. package/dist/internal/nodes/apiService.js +2 -2
  11. package/dist/internal/nodes/apiService.js.map +1 -1
  12. package/dist/internal/nodes/cryptoService.d.ts +1 -0
  13. package/dist/internal/nodes/cryptoService.js +28 -4
  14. package/dist/internal/nodes/cryptoService.js.map +1 -1
  15. package/dist/internal/nodes/cryptoService.test.js +70 -2
  16. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  17. package/dist/internal/nodes/nodesManagement.d.ts +1 -1
  18. package/dist/internal/nodes/nodesManagement.js +1 -1
  19. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  20. package/dist/internal/shares/apiService.js +2 -0
  21. package/dist/internal/shares/apiService.js.map +1 -1
  22. package/dist/internal/sharingPublic/nodes.d.ts +1 -1
  23. package/dist/internal/sharingPublic/nodes.js +2 -2
  24. package/dist/internal/sharingPublic/nodes.js.map +1 -1
  25. package/dist/protonDriveClient.d.ts +1 -1
  26. package/dist/protonDriveClient.js +2 -2
  27. package/dist/protonDriveClient.js.map +1 -1
  28. package/dist/protonDrivePhotosClient.js +1 -1
  29. package/dist/protonDrivePhotosClient.js.map +1 -1
  30. package/dist/protonDrivePublicLinkClient.d.ts +3 -1
  31. package/dist/protonDrivePublicLinkClient.js +4 -2
  32. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/crypto/driveCrypto.ts +1 -0
  35. package/src/crypto/interface.ts +1 -0
  36. package/src/crypto/openPGPCrypto.ts +3 -0
  37. package/src/interface/account.ts +6 -0
  38. package/src/internal/apiService/driveTypes.ts +197 -22
  39. package/src/internal/nodes/apiService.ts +13 -6
  40. package/src/internal/nodes/cryptoService.test.ts +113 -2
  41. package/src/internal/nodes/cryptoService.ts +53 -8
  42. package/src/internal/nodes/nodesManagement.ts +1 -1
  43. package/src/internal/shares/apiService.ts +3 -1
  44. package/src/internal/sharingPublic/nodes.ts +2 -2
  45. package/src/protonDriveClient.ts +2 -2
  46. package/src/protonDrivePhotosClient.ts +1 -1
  47. package/src/protonDrivePublicLinkClient.ts +4 -2
@@ -71,13 +71,20 @@ type PutRestoreNodesRequest = Extract<
71
71
  type PutRestoreNodesResponse =
72
72
  drivePaths['/drive/v2/volumes/{volumeID}/trash/restore_multiple']['put']['responses']['200']['content']['application/json'];
73
73
 
74
- type PostDeleteNodesRequest = Extract<
74
+ type PostDeleteTrashedNodesRequest = Extract<
75
75
  drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['requestBody'],
76
76
  { content: object }
77
77
  >['content']['application/json'];
78
- type PostDeleteNodesResponse =
78
+ type PostDeleteTrashedNodesResponse =
79
79
  drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['responses']['200']['content']['application/json'];
80
80
 
81
+ type PostDeleteMyNodesRequest = Extract<
82
+ drivePaths['/drive/v2/volumes/{volumeID}/remove-mine']['post']['requestBody'],
83
+ { content: object }
84
+ >['content']['application/json'];
85
+ type PostDeleteMyNodesResponse =
86
+ drivePaths['/drive/v2/volumes/{volumeID}/remove-mine']['post']['responses']['200']['content']['application/json'];
87
+
81
88
  type PostCreateFolderRequest = Extract<
82
89
  drivePaths['/drive/v2/volumes/{volumeID}/folders']['post']['requestBody'],
83
90
  { content: object }
@@ -446,7 +453,7 @@ export abstract class NodeAPIServiceBase<
446
453
 
447
454
  async *deleteTrashedNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
448
455
  for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) {
449
- const response = await this.apiService.post<PostDeleteNodesRequest, PostDeleteNodesResponse>(
456
+ const response = await this.apiService.post<PostDeleteTrashedNodesRequest, PostDeleteTrashedNodesResponse>(
450
457
  `drive/v2/volumes/${volumeId}/trash/delete_multiple`,
451
458
  {
452
459
  LinkIDs: batchNodeIds,
@@ -459,10 +466,10 @@ export abstract class NodeAPIServiceBase<
459
466
  }
460
467
  }
461
468
 
462
- async *deleteExistingNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
469
+ async *deleteMyNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
463
470
  for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) {
464
- const response = await this.apiService.post<PostDeleteNodesRequest, PostDeleteNodesResponse>(
465
- `drive/v2/volumes/${volumeId}/delete_multiple`,
471
+ const response = await this.apiService.post<PostDeleteMyNodesRequest, PostDeleteMyNodesResponse>(
472
+ `drive/v2/volumes/${volumeId}/remove-mine`,
466
473
  {
467
474
  LinkIDs: batchNodeIds,
468
475
  },
@@ -20,6 +20,9 @@ describe('nodesCryptoService', () => {
20
20
 
21
21
  let cryptoService: NodesCryptoService;
22
22
 
23
+ const publicAddressKey = { _idx: 21312 };
24
+ const ownPrivateAddressKey = { id: 'id', key: 'key' as unknown as PrivateKey };
25
+
23
26
  beforeEach(() => {
24
27
  jest.clearAllMocks();
25
28
 
@@ -71,7 +74,15 @@ describe('nodesCryptoService', () => {
71
74
  };
72
75
  account = {
73
76
  // @ts-expect-error No need to implement all methods for mocking
74
- getPublicKeys: jest.fn(async () => [{ _idx: 21312 }]),
77
+ getPublicKeys: jest.fn(async () => [publicAddressKey]),
78
+ getOwnAddresses: jest.fn(async () => [
79
+ {
80
+ email: 'email',
81
+ addressId: 'addressId',
82
+ primaryKeyIndex: 0,
83
+ keys: [ownPrivateAddressKey],
84
+ },
85
+ ]),
75
86
  };
76
87
  // @ts-expect-error No need to implement all methods for mocking
77
88
  sharesService = {
@@ -576,6 +587,7 @@ describe('nodesCryptoService', () => {
576
587
  armoredNodePassphraseSignature: 'armoredNodePassphraseSignature',
577
588
  file: {
578
589
  base64ContentKeyPacket: 'base64ContentKeyPacket',
590
+ armoredContentKeyPacketSignature: 'armoredContentKeyPacketSignature',
579
591
  },
580
592
  activeRevision: {
581
593
  uid: 'revisionUid',
@@ -764,7 +776,7 @@ describe('nodesCryptoService', () => {
764
776
  });
765
777
  });
766
778
 
767
- it('on content key packet', async () => {
779
+ it('on content key packet without fallback verification', async () => {
768
780
  driveCrypto.decryptAndVerifySessionKey = jest.fn(
769
781
  async () =>
770
782
  Promise.resolve({
@@ -789,6 +801,105 @@ describe('nodesCryptoService', () => {
789
801
  error: 'verification error',
790
802
  });
791
803
  });
804
+
805
+ it('on content key packet with successful fallback verification', async () => {
806
+ driveCrypto.decryptAndVerifySessionKey = jest
807
+ .fn()
808
+ .mockImplementationOnce(
809
+ async () =>
810
+ Promise.resolve({
811
+ sessionKey: 'contentKeyPacketSessionKey',
812
+ verified: VERIFICATION_STATUS.SIGNED_AND_INVALID,
813
+ verificationErrors: [new Error('verification error')],
814
+ }) as any,
815
+ )
816
+ .mockImplementationOnce(
817
+ async () =>
818
+ Promise.resolve({
819
+ sessionKey: 'contentKeyPacketSessionKey',
820
+ verified: VERIFICATION_STATUS.SIGNED_AND_VALID,
821
+ }) as any,
822
+ );
823
+
824
+ const result = await cryptoService.decryptNode(
825
+ {
826
+ ...encryptedNode,
827
+ creationTime: new Date('2022-01-01'),
828
+ },
829
+ parentKey,
830
+ );
831
+ verifyResult(result);
832
+ expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledTimes(2);
833
+ expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith(
834
+ 'base64ContentKeyPacket',
835
+ 'armoredContentKeyPacketSignature',
836
+ 'decryptedKey',
837
+ ['decryptedKey', publicAddressKey],
838
+ );
839
+ expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith(
840
+ 'base64ContentKeyPacket',
841
+ 'armoredContentKeyPacketSignature',
842
+ 'decryptedKey',
843
+ [ownPrivateAddressKey.key],
844
+ );
845
+ expect(telemetry.recordMetric).not.toHaveBeenCalled();
846
+ });
847
+
848
+ it('on content key packet with failed fallback verification', async () => {
849
+ driveCrypto.decryptAndVerifySessionKey = jest
850
+ .fn()
851
+ .mockImplementationOnce(
852
+ async () =>
853
+ Promise.resolve({
854
+ sessionKey: 'contentKeyPacketSessionKey',
855
+ verified: VERIFICATION_STATUS.SIGNED_AND_INVALID,
856
+ verificationErrors: [new Error('verification error')],
857
+ }) as any,
858
+ )
859
+ .mockImplementationOnce(
860
+ async () =>
861
+ Promise.resolve({
862
+ sessionKey: 'contentKeyPacketSessionKey',
863
+ verified: VERIFICATION_STATUS.SIGNED_AND_INVALID,
864
+ verificationErrors: [new Error('fallback verification error')],
865
+ }) as any,
866
+ );
867
+
868
+ const result = await cryptoService.decryptNode(
869
+ {
870
+ ...encryptedNode,
871
+ creationTime: new Date('2022-01-01'),
872
+ },
873
+ parentKey,
874
+ );
875
+ verifyResult(result, {
876
+ keyAuthor: {
877
+ ok: false,
878
+ error: {
879
+ claimedAuthor: 'signatureEmail',
880
+ error: 'Signature verification for content key failed: verification error',
881
+ },
882
+ },
883
+ });
884
+ expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledTimes(2);
885
+ expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith(
886
+ 'base64ContentKeyPacket',
887
+ 'armoredContentKeyPacketSignature',
888
+ 'decryptedKey',
889
+ ['decryptedKey', publicAddressKey],
890
+ );
891
+ expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith(
892
+ 'base64ContentKeyPacket',
893
+ 'armoredContentKeyPacketSignature',
894
+ 'decryptedKey',
895
+ [ownPrivateAddressKey.key],
896
+ );
897
+ verifyLogEventVerificationError({
898
+ field: 'nodeContentKey',
899
+ error: 'verification error',
900
+ fromBefore2024: true,
901
+ });
902
+ });
792
903
  });
793
904
 
794
905
  describe('should decrypt with decryption issues', () => {
@@ -25,6 +25,7 @@ import {
25
25
  EncryptedRevision,
26
26
  DecryptedUnparsedRevision,
27
27
  NodeSigningKeys,
28
+ EncryptedNodeFileCrypto,
28
29
  } from './interface';
29
30
 
30
31
  export interface NodesCryptoReporter {
@@ -200,14 +201,7 @@ export class NodesCryptoService {
200
201
  if ('file' in node.encryptedCrypto) {
201
202
  const [activeRevisionPromise, contentKeyPacketSessionKeyPromise] = [
202
203
  this.decryptRevision(node.uid, node.encryptedCrypto.activeRevision, key),
203
- this.driveCrypto.decryptAndVerifySessionKey(
204
- node.encryptedCrypto.file.base64ContentKeyPacket,
205
- node.encryptedCrypto.file.armoredContentKeyPacketSignature,
206
- key,
207
- // Content key packet is signed with the node key, but
208
- // in the past some clients signed with the address key.
209
- [key, ...keyVerificationKeys],
210
- ),
204
+ this.decryptContentKeyPacket(node, node.encryptedCrypto, key, keyVerificationKeys),
211
205
  ];
212
206
 
213
207
  try {
@@ -502,6 +496,57 @@ export class NodesCryptoService {
502
496
  };
503
497
  }
504
498
 
499
+ private async decryptContentKeyPacket(
500
+ node: EncryptedNode,
501
+ encryptedCrypto: EncryptedNodeFileCrypto,
502
+ key: PrivateKey,
503
+ keyVerificationKeys: PublicKey[],
504
+ ): Promise<{
505
+ sessionKey: SessionKey;
506
+ verified?: VERIFICATION_STATUS;
507
+ verificationErrors?: Error[];
508
+ }> {
509
+ const result = await this.driveCrypto.decryptAndVerifySessionKey(
510
+ encryptedCrypto.file.base64ContentKeyPacket,
511
+ encryptedCrypto.file.armoredContentKeyPacketSignature,
512
+ key,
513
+ // Content key packet is signed with the node key, but
514
+ // in the past some clients signed with the address key.
515
+ [key, ...keyVerificationKeys],
516
+ );
517
+
518
+ // Return right away if the verification is signed or not signed.
519
+ // If the verification is failing and the file is before 2023, try
520
+ // to decrypt with all owners keys. Because of the old nodes signed
521
+ // with address key instead of node key, when the node was renamed
522
+ // or moved, it could change the address but without updating the
523
+ // content key packet, which is now failing.
524
+ if (result.verified !== VERIFICATION_STATUS.SIGNED_AND_INVALID || node.creationTime > new Date(2023, 0, 1)) {
525
+ return result;
526
+ }
527
+
528
+ const allAddresses = await this.account.getOwnAddresses();
529
+ const allKeys = allAddresses.flatMap((address) => address.keys.map(({ key }) => key));
530
+
531
+ const resultWithAllKeys = await this.driveCrypto.decryptAndVerifySessionKey(
532
+ encryptedCrypto.file.base64ContentKeyPacket,
533
+ encryptedCrypto.file.armoredContentKeyPacketSignature,
534
+ key,
535
+ // Content key packet is signed with the node key, but
536
+ // in the past some clients signed with the address key.
537
+ allKeys,
538
+ );
539
+
540
+ // Return original result with original error if the fallback verification also fails.
541
+ if (resultWithAllKeys.verified === VERIFICATION_STATUS.SIGNED_AND_VALID) {
542
+ this.logger.warn(
543
+ 'Content key packet signature verification failed, but fallback to all addresses succeeded',
544
+ );
545
+ return resultWithAllKeys;
546
+ }
547
+ return result;
548
+ }
549
+
505
550
  private async decryptExtendedAttributes(
506
551
  node: { uid: string; creationTime: Date },
507
552
  encryptedExtendedAttributes: string | undefined,
@@ -293,7 +293,7 @@ export abstract class NodesManagementBase<
293
293
  }
294
294
  }
295
295
 
296
- async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
296
+ async *deleteTrashedNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
297
297
  for await (const result of this.apiService.deleteTrashedNodes(nodeUids, signal)) {
298
298
  if (result.ok) {
299
299
  await this.nodesAccess.notifyNodeDeleted(result.uid);
@@ -173,7 +173,7 @@ function convertSharePayload(response: GetShareResponse): EncryptedShare {
173
173
  };
174
174
  }
175
175
 
176
- function convertShareTypeNumberToEnum(type: 1 | 2 | 3 | 4): ShareType {
176
+ function convertShareTypeNumberToEnum(type: 1 | 2 | 3 | 4 | 5): ShareType {
177
177
  switch (type) {
178
178
  case 1:
179
179
  return ShareType.Main;
@@ -183,5 +183,7 @@ function convertShareTypeNumberToEnum(type: 1 | 2 | 3 | 4): ShareType {
183
183
  return ShareType.Device;
184
184
  case 4:
185
185
  return ShareType.Photo;
186
+ case 5:
187
+ throw new Error('Organization shares are not supported yet');
186
188
  }
187
189
  }
@@ -87,10 +87,10 @@ export class SharingPublicNodesManagement extends NodesManagement {
87
87
  super(apiService, cryptoCache, cryptoService, nodesAccess);
88
88
  }
89
89
 
90
- async *deleteNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
90
+ async *deleteMyNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
91
91
  // Public link does not support trashing and deleting trashed nodes.
92
92
  // Instead, if user is owner, API allows directly deleting existing nodes.
93
- for await (const result of this.apiService.deleteExistingNodes(nodeUids, signal)) {
93
+ for await (const result of this.apiService.deleteMyNodes(nodeUids, signal)) {
94
94
  if (result.ok) {
95
95
  await this.nodesAccess.notifyNodeDeleted(result.uid);
96
96
  }
@@ -495,7 +495,7 @@ export class ProtonDriveClient {
495
495
  }
496
496
 
497
497
  /**
498
- * Delete the nodes permanently.
498
+ * Delete the trashed nodes permanently. Only the owner can do that.
499
499
  *
500
500
  * The operation is performed in batches and the results are yielded
501
501
  * as they are available. Order of the results is not guaranteed.
@@ -509,7 +509,7 @@ export class ProtonDriveClient {
509
509
  */
510
510
  async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
511
511
  this.logger.info(`Deleting ${nodeUids.length} nodes`);
512
- yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal);
512
+ yield* this.nodes.management.deleteTrashedNodes(getUids(nodeUids), signal);
513
513
  }
514
514
 
515
515
  async emptyTrash(): Promise<void> {
@@ -295,7 +295,7 @@ export class ProtonDrivePhotosClient {
295
295
  */
296
296
  async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
297
297
  this.logger.info(`Deleting ${nodeUids.length} nodes`);
298
- yield* this.nodes.management.deleteNodes(getUids(nodeUids), signal);
298
+ yield * this.nodes.management.deleteTrashedNodes(getUids(nodeUids), signal);
299
299
  }
300
300
 
301
301
  /**
@@ -233,13 +233,15 @@ export class ProtonDrivePublicLinkClient {
233
233
  }
234
234
 
235
235
  /**
236
- * Delete the nodes permanently.
236
+ * Delete own nodes permanently. It skips the trash and allows to delete
237
+ * only nodes that are owned by the user. For anonymous files, this method
238
+ * allows to delete them only in the same session.
237
239
  *
238
240
  * See `ProtonDriveClient.deleteNodes` for more information.
239
241
  */
240
242
  async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
241
243
  this.logger.info(`Deleting ${nodeUids.length} nodes`);
242
- yield* this.sharingPublic.nodes.management.deleteNodes(getUids(nodeUids), signal);
244
+ yield* this.sharingPublic.nodes.management.deleteMyNodes(getUids(nodeUids), signal);
243
245
  }
244
246
 
245
247
  /**