@protontech/drive-sdk 0.0.10 → 0.0.12

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 (117) hide show
  1. package/dist/crypto/driveCrypto.d.ts +11 -0
  2. package/dist/crypto/driveCrypto.js +16 -0
  3. package/dist/crypto/driveCrypto.js.map +1 -1
  4. package/dist/crypto/interface.d.ts +2 -0
  5. package/dist/crypto/openPGPCrypto.d.ts +2 -0
  6. package/dist/crypto/openPGPCrypto.js +8 -0
  7. package/dist/crypto/openPGPCrypto.js.map +1 -1
  8. package/dist/interface/index.d.ts +3 -3
  9. package/dist/interface/index.js +2 -2
  10. package/dist/interface/index.js.map +1 -1
  11. package/dist/interface/nodes.d.ts +9 -0
  12. package/dist/interface/nodes.js.map +1 -1
  13. package/dist/interface/sharing.d.ts +22 -2
  14. package/dist/interface/telemetry.d.ts +10 -8
  15. package/dist/interface/telemetry.js +7 -7
  16. package/dist/interface/telemetry.js.map +1 -1
  17. package/dist/internal/apiService/errors.js +1 -1
  18. package/dist/internal/apiService/errors.js.map +1 -1
  19. package/dist/internal/apiService/errors.test.js +7 -0
  20. package/dist/internal/apiService/errors.test.js.map +1 -1
  21. package/dist/internal/download/interface.d.ts +2 -2
  22. package/dist/internal/download/telemetry.js +7 -5
  23. package/dist/internal/download/telemetry.js.map +1 -1
  24. package/dist/internal/download/telemetry.test.js +10 -6
  25. package/dist/internal/download/telemetry.test.js.map +1 -1
  26. package/dist/internal/nodes/cache.js +25 -1
  27. package/dist/internal/nodes/cache.js.map +1 -1
  28. package/dist/internal/nodes/cache.test.js +33 -0
  29. package/dist/internal/nodes/cache.test.js.map +1 -1
  30. package/dist/internal/nodes/cryptoService.d.ts +8 -3
  31. package/dist/internal/nodes/cryptoService.js +11 -11
  32. package/dist/internal/nodes/cryptoService.js.map +1 -1
  33. package/dist/internal/nodes/cryptoService.test.js +2 -2
  34. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  35. package/dist/internal/nodes/index.d.ts +1 -1
  36. package/dist/internal/nodes/interface.d.ts +2 -2
  37. package/dist/internal/nodes/nodesManagement.js +1 -1
  38. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  39. package/dist/internal/nodes/nodesManagement.test.js +1 -1
  40. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  41. package/dist/internal/shares/cryptoService.js +4 -4
  42. package/dist/internal/shares/cryptoService.js.map +1 -1
  43. package/dist/internal/shares/cryptoService.test.js +2 -2
  44. package/dist/internal/shares/cryptoService.test.js.map +1 -1
  45. package/dist/internal/shares/manager.d.ts +2 -2
  46. package/dist/internal/shares/manager.js +2 -2
  47. package/dist/internal/shares/manager.js.map +1 -1
  48. package/dist/internal/sharing/apiService.d.ts +2 -2
  49. package/dist/internal/sharing/apiService.js +1 -1
  50. package/dist/internal/sharing/apiService.js.map +1 -1
  51. package/dist/internal/sharing/cryptoService.d.ts +12 -3
  52. package/dist/internal/sharing/cryptoService.js +110 -1
  53. package/dist/internal/sharing/cryptoService.js.map +1 -1
  54. package/dist/internal/sharing/cryptoService.test.d.ts +1 -0
  55. package/dist/internal/sharing/cryptoService.test.js +132 -0
  56. package/dist/internal/sharing/cryptoService.test.js.map +1 -0
  57. package/dist/internal/sharing/index.js +1 -1
  58. package/dist/internal/sharing/index.js.map +1 -1
  59. package/dist/internal/sharing/interface.d.ts +4 -0
  60. package/dist/internal/sharing/sharingAccess.d.ts +3 -3
  61. package/dist/internal/sharing/sharingAccess.js +29 -4
  62. package/dist/internal/sharing/sharingAccess.js.map +1 -1
  63. package/dist/internal/sharing/sharingAccess.test.js +65 -0
  64. package/dist/internal/sharing/sharingAccess.test.js.map +1 -1
  65. package/dist/internal/sharing/sharingManagement.js +2 -2
  66. package/dist/internal/sharing/sharingManagement.js.map +1 -1
  67. package/dist/internal/sharing/sharingManagement.test.js +3 -3
  68. package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
  69. package/dist/internal/upload/interface.d.ts +2 -2
  70. package/dist/internal/upload/telemetry.js +7 -5
  71. package/dist/internal/upload/telemetry.js.map +1 -1
  72. package/dist/internal/upload/telemetry.test.js +10 -6
  73. package/dist/internal/upload/telemetry.test.js.map +1 -1
  74. package/dist/protonDriveClient.d.ts +16 -1
  75. package/dist/protonDriveClient.js +23 -2
  76. package/dist/protonDriveClient.js.map +1 -1
  77. package/dist/transformers.d.ts +1 -1
  78. package/dist/transformers.js +16 -2
  79. package/dist/transformers.js.map +1 -1
  80. package/package.json +1 -1
  81. package/src/crypto/driveCrypto.ts +30 -0
  82. package/src/crypto/interface.ts +6 -0
  83. package/src/crypto/openPGPCrypto.ts +13 -0
  84. package/src/interface/index.ts +3 -3
  85. package/src/interface/nodes.ts +9 -0
  86. package/src/interface/sharing.ts +24 -2
  87. package/src/interface/telemetry.ts +8 -7
  88. package/src/internal/apiService/errors.test.ts +8 -0
  89. package/src/internal/apiService/errors.ts +1 -1
  90. package/src/internal/download/interface.ts +2 -2
  91. package/src/internal/download/telemetry.test.ts +18 -14
  92. package/src/internal/download/telemetry.ts +8 -5
  93. package/src/internal/nodes/cache.test.ts +41 -2
  94. package/src/internal/nodes/cache.ts +31 -3
  95. package/src/internal/nodes/cryptoService.test.ts +3 -3
  96. package/src/internal/nodes/cryptoService.ts +14 -13
  97. package/src/internal/nodes/index.ts +1 -1
  98. package/src/internal/nodes/interface.ts +2 -2
  99. package/src/internal/nodes/nodesManagement.test.ts +6 -6
  100. package/src/internal/nodes/nodesManagement.ts +2 -2
  101. package/src/internal/shares/cryptoService.test.ts +2 -2
  102. package/src/internal/shares/cryptoService.ts +7 -7
  103. package/src/internal/shares/manager.ts +4 -4
  104. package/src/internal/sharing/apiService.ts +10 -10
  105. package/src/internal/sharing/cryptoService.test.ts +148 -0
  106. package/src/internal/sharing/cryptoService.ts +137 -3
  107. package/src/internal/sharing/index.ts +1 -1
  108. package/src/internal/sharing/interface.ts +4 -0
  109. package/src/internal/sharing/sharingAccess.test.ts +74 -0
  110. package/src/internal/sharing/sharingAccess.ts +29 -5
  111. package/src/internal/sharing/sharingManagement.test.ts +3 -3
  112. package/src/internal/sharing/sharingManagement.ts +2 -2
  113. package/src/internal/upload/interface.ts +2 -2
  114. package/src/internal/upload/telemetry.test.ts +10 -6
  115. package/src/internal/upload/telemetry.ts +8 -5
  116. package/src/protonDriveClient.ts +27 -2
  117. package/src/transformers.ts +31 -5
@@ -1,8 +1,8 @@
1
1
  import { MemoryCache } from "../../cache";
2
- import { NodeType, MemberRole } from "../../interface";
2
+ import { NodeType, MemberRole, RevisionState, resultOk, Result } from "../../interface";
3
3
  import { getMockLogger } from "../../tests/logger";
4
4
  import { CACHE_TAG_KEYS, NodesCache } from "./cache";
5
- import { DecryptedNode } from "./interface";
5
+ import { DecryptedNode, DecryptedRevision } from "./interface";
6
6
 
7
7
  function generateNode(uid: string, parentUid='root', params: Partial<DecryptedNode> & { volumeId?: string } = {}): DecryptedNode {
8
8
  return {
@@ -16,6 +16,8 @@ function generateNode(uid: string, parentUid='root', params: Partial<DecryptedNo
16
16
  trashTime: undefined,
17
17
  volumeId: "volumeId",
18
18
  isStale: false,
19
+ activeRevision: undefined,
20
+ folder: undefined,
19
21
  ...params,
20
22
  } as DecryptedNode;
21
23
  }
@@ -81,6 +83,43 @@ describe('nodesCache', () => {
81
83
  expect(result).toStrictEqual(node);
82
84
  });
83
85
 
86
+ it('should store and retrieve folder node', async () => {
87
+ const node = generateNode('node1', '', {
88
+ folder: {
89
+ claimedModificationTime: new Date('2021-01-01'),
90
+ },
91
+ });
92
+
93
+ await cache.setNode(node);
94
+ const result = await cache.getNode(node.uid);
95
+
96
+ expect(result).toStrictEqual({
97
+ ...node,
98
+ folder: {
99
+ claimedModificationTime: new Date('2021-01-01'),
100
+ },
101
+ });
102
+ });
103
+
104
+ it('should store and retrieve node with active revision', async () => {
105
+ const activeRevision: Result<DecryptedRevision, Error> = resultOk({
106
+ uid: 'revision1',
107
+ state: RevisionState.Active,
108
+ creationTime: new Date('2021-01-01'),
109
+ storageSize: 100,
110
+ contentAuthor: resultOk('test@test.com'),
111
+ });
112
+ const node = generateNode('node1', '', { activeRevision });
113
+
114
+ await cache.setNode(node);
115
+ const result = await cache.getNode(node.uid);
116
+
117
+ expect(result).toStrictEqual({
118
+ ...node,
119
+ activeRevision,
120
+ });
121
+ });
122
+
84
123
  it('should throw an error when retrieving a non-existing entity', async () => {
85
124
  try {
86
125
  await cache.getNode('nonExistingNodeUid');
@@ -1,7 +1,7 @@
1
1
  import { EntityResult } from "../../cache";
2
- import { ProtonDriveEntitiesCache, Logger } from "../../interface";
2
+ import { ProtonDriveEntitiesCache, Logger, resultOk, Result } from "../../interface";
3
3
  import { splitNodeUid } from "../uids";
4
- import { DecryptedNode } from "./interface";
4
+ import { DecryptedNode, DecryptedRevision } from "./interface";
5
5
 
6
6
  export enum CACHE_TAG_KEYS {
7
7
  ParentUid = 'nodeParentUid',
@@ -228,7 +228,9 @@ function deserialiseNode(nodeData: string): DecryptedNode {
228
228
  (typeof node.mediaType !== 'string' && node.mediaType !== undefined) ||
229
229
  typeof node.isShared !== 'boolean' ||
230
230
  !node.creationTime || typeof node.creationTime !== 'string' ||
231
- (typeof node.trashTime !== 'string' && node.trashTime !== undefined)
231
+ (typeof node.trashTime !== 'string' && node.trashTime !== undefined) ||
232
+ (typeof node.folder !== 'object' && node.folder !== undefined) ||
233
+ (typeof node.folder?.claimedModificationTime !== 'string' && node.folder?.claimedModificationTime !== undefined)
232
234
  ) {
233
235
  throw new Error(`Invalid node data: ${nodeData}`);
234
236
  }
@@ -236,5 +238,31 @@ function deserialiseNode(nodeData: string): DecryptedNode {
236
238
  ...node,
237
239
  creationTime: new Date(node.creationTime),
238
240
  trashTime: node.trashTime ? new Date(node.trashTime) : undefined,
241
+ activeRevision: node.activeRevision ? deserialiseRevision(node.activeRevision) : undefined,
242
+ folder: node.folder
243
+ ? {
244
+ ...node.folder,
245
+ claimedModificationTime: node.folder.claimedModificationTime ? new Date(node.folder.claimedModificationTime) : undefined,
246
+ }
247
+ : undefined,
239
248
  };
240
249
  }
250
+
251
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
+ function deserialiseRevision(revision: any): Result<DecryptedRevision, Error> {
253
+ if (
254
+ (typeof revision !== 'object' && revision !== undefined) ||
255
+ (typeof revision?.creationTime !== 'string' && revision?.creationTime !== undefined)
256
+ ) {
257
+ throw new Error(`Invalid revision data: ${revision}`);
258
+ }
259
+
260
+ if (revision.ok) {
261
+ return resultOk({
262
+ ...revision.value,
263
+ creationTime: new Date(revision.value.creationTime),
264
+ });
265
+ }
266
+
267
+ return revision;
268
+ }
@@ -1,5 +1,5 @@
1
1
  import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from "../../crypto";
2
- import { DegradedNode, MaybeNode, ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from "../../interface";
2
+ import { ProtonDriveAccount, ProtonDriveTelemetry, RevisionState } from "../../interface";
3
3
  import { getMockTelemetry } from "../../tests/telemetry";
4
4
  import { DecryptedNode, DecryptedNodeKeys, DecryptedUnparsedNode, EncryptedNode, SharesService } from "./interface";
5
5
  import { NodesCryptoService } from "./cryptoService";
@@ -66,7 +66,7 @@ describe("nodesCryptoService", () => {
66
66
  expect(telemetry.logEvent).toHaveBeenCalledTimes(1);
67
67
  expect(telemetry.logEvent).toHaveBeenCalledWith({
68
68
  eventName: "verificationError",
69
- context: "own_volume",
69
+ volumeType: "own_volume",
70
70
  fromBefore2024: false,
71
71
  addressMatchingDefaultShare: false,
72
72
  ...options,
@@ -77,7 +77,7 @@ describe("nodesCryptoService", () => {
77
77
  expect(telemetry.logEvent).toHaveBeenCalledTimes(1);
78
78
  expect(telemetry.logEvent).toHaveBeenCalledWith({
79
79
  eventName: "decryptionError",
80
- context: "own_volume",
80
+ volumeType: "own_volume",
81
81
  fromBefore2024: false,
82
82
  ...options,
83
83
  });
@@ -268,7 +268,7 @@ export class NodesCryptoService {
268
268
  }
269
269
  };
270
270
 
271
- async getNameSessionKey(node: DecryptedNode, parentKey: PrivateKey): Promise<SessionKey> {
271
+ async getNameSessionKey(node: { encryptedName: string }, parentKey: PrivateKey): Promise<SessionKey> {
272
272
  return this.driveCrypto.decryptSessionKey(node.encryptedName, parentKey);
273
273
  }
274
274
 
@@ -364,7 +364,6 @@ export class NodesCryptoService {
364
364
  hash,
365
365
  ] = await Promise.all([
366
366
  this.driveCrypto.generateKey([parentKeys.key], addressKey),
367
-
368
367
  this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, addressKey),
369
368
  this.driveCrypto.generateLookupHash(name, parentKeys.hashKey),
370
369
  ]);
@@ -399,9 +398,9 @@ export class NodesCryptoService {
399
398
  }
400
399
 
401
400
  async encryptNewName(
401
+ parentKeys: { key: PrivateKey, hashKey?: Uint8Array },
402
402
  nodeNameSessionKey: SessionKey,
403
403
  address: { email: string, addressKey: PrivateKey },
404
- parentHashKey: Uint8Array | undefined,
405
404
  newName: string,
406
405
  ): Promise<{
407
406
  signatureEmail: string,
@@ -409,9 +408,11 @@ export class NodesCryptoService {
409
408
  hash?: string,
410
409
  }> {
411
410
  const { email, addressKey } = address;
412
- const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, nodeNameSessionKey, undefined, addressKey);
413
- const hash = parentHashKey
414
- ? await this.driveCrypto.generateLookupHash(newName, parentHashKey)
411
+
412
+ const { armoredNodeName } = await this.driveCrypto.encryptNodeName(newName, nodeNameSessionKey, parentKeys.key, addressKey);
413
+
414
+ const hash = parentKeys.hashKey
415
+ ? await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey)
415
416
  : undefined;
416
417
  return {
417
418
  signatureEmail: email,
@@ -481,21 +482,21 @@ export class NodesCryptoService {
481
482
 
482
483
  const fromBefore2024 = node.creationTime < new Date('2024-01-01');
483
484
 
484
- let addressMatchingDefaultShare, context;
485
+ let addressMatchingDefaultShare, volumeType;
485
486
  try {
486
487
  const { volumeId } = splitNodeUid(node.uid);
487
488
  const { email } = await this.shareService.getMyFilesShareMemberEmailKey();
488
489
  addressMatchingDefaultShare = claimedAuthor ? claimedAuthor === email : undefined;
489
- context = await this.shareService.getVolumeMetricContext(volumeId);
490
+ volumeType = await this.shareService.getVolumeMetricContext(volumeId);
490
491
  } catch (error: unknown) {
491
492
  this.logger.error('Failed to check if claimed author matches default share', error);
492
493
  }
493
494
 
494
- this.logger.error(`Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`);
495
+ this.logger.warn(`Failed to verify ${field} for node ${node.uid} (from before 2024: ${fromBefore2024}, matching address: ${addressMatchingDefaultShare})`);
495
496
 
496
497
  this.telemetry.logEvent({
497
498
  eventName: 'verificationError',
498
- context,
499
+ volumeType,
499
500
  field,
500
501
  addressMatchingDefaultShare,
501
502
  fromBefore2024,
@@ -510,10 +511,10 @@ export class NodesCryptoService {
510
511
 
511
512
  const fromBefore2024 = node.creationTime < new Date('2024-01-01');
512
513
 
513
- let context;
514
+ let volumeType;
514
515
  try {
515
516
  const { volumeId } = splitNodeUid(node.uid);
516
- context = await this.shareService.getVolumeMetricContext(volumeId);
517
+ volumeType = await this.shareService.getVolumeMetricContext(volumeId);
517
518
  } catch (error: unknown) {
518
519
  this.logger.error('Failed to get metric context', error);
519
520
  }
@@ -522,7 +523,7 @@ export class NodesCryptoService {
522
523
 
523
524
  this.telemetry.logEvent({
524
525
  eventName: 'decryptionError',
525
- context,
526
+ volumeType,
526
527
  field,
527
528
  fromBefore2024,
528
529
  error,
@@ -12,7 +12,7 @@ import { NodesAccess } from "./nodesAccess";
12
12
  import { NodesManagement } from "./nodesManagement";
13
13
  import { NodesRevisons } from "./nodesRevisions";
14
14
 
15
- export type { DecryptedNode } from "./interface";
15
+ export type { DecryptedNode, DecryptedRevision } from "./interface";
16
16
  export { generateFileExtendedAttributes } from "./extendedAttributes";
17
17
 
18
18
  /**
@@ -1,5 +1,5 @@
1
1
  import { PrivateKey, SessionKey } from "../../crypto";
2
- import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType, MetricContext, Revision, RevisionState } from "../../interface";
2
+ import { NodeEntity, Result, InvalidNameError, Author, MemberRole, NodeType, ThumbnailType, MetricVolumeType, Revision, RevisionState } from "../../interface";
3
3
 
4
4
  /**
5
5
  * Internal common node interface for both encrypted or decrypted node.
@@ -148,5 +148,5 @@ export interface SharesService {
148
148
  addressKey: PrivateKey,
149
149
  addressKeyId: string,
150
150
  }>,
151
- getVolumeMetricContext(volumeId: string): Promise<MetricContext>,
151
+ getVolumeMetricContext(volumeId: string): Promise<MetricVolumeType>,
152
152
  }
@@ -113,9 +113,9 @@ describe('NodesManagement', () => {
113
113
  });
114
114
  expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('nodeUid');
115
115
  expect(cryptoService.encryptNewName).toHaveBeenCalledWith(
116
+ { key: 'parentUid-key', hashKey: 'parentUid-hashKey' },
116
117
  'nodeUid-nameSessionKey',
117
118
  { email: "root-email", addressKey: "root-key" },
118
- 'parentUid-hashKey',
119
119
  'new name',
120
120
  );
121
121
  expect(apiService.renameNode).toHaveBeenCalledWith(
@@ -149,9 +149,9 @@ describe('NodesManagement', () => {
149
149
  expect(nodesAccess.getRootNodeEmailKey).toHaveBeenCalledWith('newParentNodeUid');
150
150
  expect(cryptoService.moveNode).toHaveBeenCalledWith(
151
151
  nodes.nodeUid,
152
- expect.objectContaining({
152
+ expect.objectContaining({
153
153
  key: 'nodeUid-key',
154
- passphrase: 'nodeUid-passphrase',
154
+ passphrase: 'nodeUid-passphrase',
155
155
  passphraseSessionKey: 'nodeUid-passphraseSessionKey',
156
156
  contentKeyPacketSessionKey: 'nodeUid-contentKeyPacketSessionKey',
157
157
  nameSessionKey: 'nodeUid-nameSessionKey'
@@ -186,12 +186,12 @@ describe('NodesManagement', () => {
186
186
  cryptoService.moveNode = jest.fn().mockResolvedValue(encryptedCrypto);
187
187
 
188
188
  const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid');
189
-
189
+
190
190
  expect(cryptoService.moveNode).toHaveBeenCalledWith(
191
191
  nodes.anonymousNodeUid,
192
- expect.objectContaining({
192
+ expect.objectContaining({
193
193
  key: 'anonymousNodeUid-key',
194
- passphrase: 'anonymousNodeUid-passphrase',
194
+ passphrase: 'anonymousNodeUid-passphrase',
195
195
  passphraseSessionKey: 'anonymousNodeUid-passphraseSessionKey',
196
196
  contentKeyPacketSessionKey: 'anonymousNodeUid-contentKeyPacketSessionKey',
197
197
  nameSessionKey: 'anonymousNodeUid-nameSessionKey'
@@ -52,7 +52,7 @@ export class NodesManagement {
52
52
  signatureEmail,
53
53
  armoredNodeName,
54
54
  hash,
55
- } = await this.cryptoService.encryptNewName(nodeNameSessionKey, address, parentKeys.hashKey, newName);
55
+ } = await this.cryptoService.encryptNewName(parentKeys, nodeNameSessionKey, address, newName);
56
56
 
57
57
  // Because hash is optional, lets ensure we have it unless explicitely
58
58
  // allowed to rename root node.
@@ -141,7 +141,7 @@ export class NodesManagement {
141
141
  nodeUid,
142
142
  {
143
143
  hash: node.hash,
144
- },
144
+ },
145
145
  {
146
146
  ...keySignatureProperties,
147
147
  parentUid: newParentUid,
@@ -99,7 +99,7 @@ describe("SharesCryptoService", () => {
99
99
  expect(account.getPublicKeys).toHaveBeenCalledWith("signatureEmail");
100
100
  expect(telemetry.logEvent).toHaveBeenCalledWith({
101
101
  eventName: 'verificationError',
102
- context: 'own_volume',
102
+ volumeType: 'own_volume',
103
103
  field: 'shareKey',
104
104
  addressMatchingDefaultShare: undefined,
105
105
  fromBefore2024: undefined,
@@ -128,7 +128,7 @@ describe("SharesCryptoService", () => {
128
128
 
129
129
  expect(telemetry.logEvent).toHaveBeenCalledWith({
130
130
  eventName: 'decryptionError',
131
- context: 'own_volume',
131
+ volumeType: 'own_volume',
132
132
  field: 'shareKey',
133
133
  fromBefore2024: undefined,
134
134
  error,
@@ -1,4 +1,4 @@
1
- import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger, MetricContext } from "../../interface";
1
+ import { ProtonDriveAccount, resultOk, resultError, Result, UnverifiedAuthorError, ProtonDriveTelemetry, Logger, MetricVolumeType } from "../../interface";
2
2
  import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from "../../crypto";
3
3
  import { getVerificationMessage } from "../errors";
4
4
  import { EncryptedRootShare, DecryptedRootShare, EncryptedShareCrypto, DecryptedShareKey, ShareType } from "./interface";
@@ -91,7 +91,7 @@ export class SharesCryptoService {
91
91
  },
92
92
  }
93
93
  }
94
-
94
+
95
95
  private reportDecryptionError(share: EncryptedRootShare, error?: unknown) {
96
96
  if (this.reportedDecryptionErrors.has(share.shareId)) {
97
97
  return;
@@ -102,7 +102,7 @@ export class SharesCryptoService {
102
102
 
103
103
  this.telemetry.logEvent({
104
104
  eventName: 'decryptionError',
105
- context: shareTypeToMetricContext(share.type),
105
+ volumeType: shareTypeToMetricContext(share.type),
106
106
  field: 'shareKey',
107
107
  fromBefore2024,
108
108
  error,
@@ -120,7 +120,7 @@ export class SharesCryptoService {
120
120
 
121
121
  this.telemetry.logEvent({
122
122
  eventName: 'verificationError',
123
- context: shareTypeToMetricContext(share.type),
123
+ volumeType: shareTypeToMetricContext(share.type),
124
124
  field: 'shareKey',
125
125
  fromBefore2024,
126
126
  });
@@ -128,7 +128,7 @@ export class SharesCryptoService {
128
128
  }
129
129
  }
130
130
 
131
- function shareTypeToMetricContext(shareType: ShareType): MetricContext {
131
+ function shareTypeToMetricContext(shareType: ShareType): MetricVolumeType {
132
132
  // SDK doesn't support public sharing yet, also public sharing
133
133
  // doesn't use a share but shareURL, thus we can simplify and
134
134
  // ignore this case for now.
@@ -136,8 +136,8 @@ function shareTypeToMetricContext(shareType: ShareType): MetricContext {
136
136
  case ShareType.Main:
137
137
  case ShareType.Device:
138
138
  case ShareType.Photo:
139
- return MetricContext.OwnVolume;
139
+ return MetricVolumeType.OwnVolume;
140
140
  case ShareType.Standard:
141
- return MetricContext.Shared;
141
+ return MetricVolumeType.Shared;
142
142
  }
143
143
  }
@@ -1,4 +1,4 @@
1
- import { Logger, MetricContext, ProtonDriveAccount } from "../../interface";
1
+ import { Logger, MetricVolumeType, ProtonDriveAccount } from "../../interface";
2
2
  import { PrivateKey } from "../../crypto";
3
3
  import { NotFoundAPIError } from "../apiService";
4
4
  import { SharesAPIService } from "./apiService";
@@ -195,16 +195,16 @@ export class SharesManager {
195
195
  };
196
196
  }
197
197
 
198
- async getVolumeMetricContext(volumeId: string): Promise<MetricContext> {
198
+ async getVolumeMetricContext(volumeId: string): Promise<MetricVolumeType> {
199
199
  const { volumeId: myVolumeId } = await this.getMyFilesIDs();
200
200
 
201
201
  // SDK doesn't support public sharing yet, also public sharing
202
202
  // doesn't use a volume but shareURL, thus we can simplify and
203
203
  // ignore this case for now.
204
204
  if (volumeId === myVolumeId) {
205
- return MetricContext.OwnVolume;
205
+ return MetricVolumeType.OwnVolume;
206
206
  }
207
- return MetricContext.Shared;
207
+ return MetricVolumeType.Shared;
208
208
  }
209
209
 
210
210
  async loadEncryptedShare(shareId: string): Promise<EncryptedShare> {
@@ -51,7 +51,7 @@ type PutShareUrlResponse = drivePaths['/drive/shares/{shareID}/urls/{urlID}']['p
51
51
 
52
52
  /**
53
53
  * Provides API communication for fetching and managing sharing.
54
- *
54
+ *
55
55
  * The service is responsible for transforming local objects to API payloads
56
56
  * and vice versa. It should not contain any business logic.
57
57
  */
@@ -380,7 +380,7 @@ export class SharingAPIService {
380
380
  creatorEmail: string,
381
381
  role: MemberRole,
382
382
  includesCustomPassword: boolean,
383
- expirationDuration?: number,
383
+ expirationTime?: number,
384
384
  crypto: EncryptedPublicLinkCrypto,
385
385
  srp: SRPVerifier,
386
386
  }): Promise<{
@@ -392,8 +392,8 @@ export class SharingAPIService {
392
392
  }
393
393
 
394
394
  const result = await this.apiService.post<
395
- // TODO: Backend type wrongly requires ExpirationTime and Name.
396
- Omit<PostShareUrlRequest, 'ExpirationTime' | 'Name'>,
395
+ // TODO: Backend type wrongly requires ExpirationDuration (it should be optional) and Name (it is not used).
396
+ Omit<PostShareUrlRequest, 'ExpirationDuration' | 'Name'>,
397
397
  PostShareUrlResponse
398
398
  >(`drive/shares/${shareId}/urls`, {
399
399
  CreatorEmail: publicLink.creatorEmail,
@@ -408,7 +408,7 @@ export class SharingAPIService {
408
408
  async updatePublicLink(publicLinkUid: string, publicLink: {
409
409
  role: MemberRole,
410
410
  includesCustomPassword: boolean,
411
- expirationDuration?: number,
411
+ expirationTime?: number,
412
412
  crypto: EncryptedPublicLinkCrypto,
413
413
  srp: SRPVerifier,
414
414
  }): Promise<void> {
@@ -419,8 +419,8 @@ export class SharingAPIService {
419
419
  const { shareId, publicLinkId } = splitPublicLinkUid(publicLinkUid);
420
420
 
421
421
  await this.apiService.put<
422
- // TODO: Backend type wrongly requires ExpirationTime and Name.
423
- Omit<PutShareUrlRequest, 'ExpirationTime' | 'Name'>,
422
+ // TODO: Backend type wrongly requires ExpirationTime (it should be optional) and Name (it is not used).
423
+ Omit<PutShareUrlRequest, 'ExpirationTime' | 'Name'> & { ExpirationTime: number | null },
424
424
  PutShareUrlResponse
425
425
  >(`drive/shares/${shareId}/urls/${publicLinkId}`, this.generatePublicLinkRequestPayload(publicLink));
426
426
  }
@@ -428,16 +428,16 @@ export class SharingAPIService {
428
428
  private generatePublicLinkRequestPayload(publicLink: {
429
429
  role: MemberRole,
430
430
  includesCustomPassword: boolean,
431
- expirationDuration?: number,
431
+ expirationTime?: number,
432
432
  crypto: EncryptedPublicLinkCrypto,
433
433
  srp: SRPVerifier,
434
- }): Pick<PostShareUrlRequest, 'Permissions' | 'Flags' | 'ExpirationDuration' | 'SharePasswordSalt' | 'SharePassphraseKeyPacket' | 'Password' | 'UrlPasswordSalt' | 'SRPVerifier' | 'SRPModulusID' | 'MaxAccesses'> {
434
+ }): Pick<PostShareUrlRequest, 'Permissions' | 'Flags' | 'ExpirationTime' | 'SharePasswordSalt' | 'SharePassphraseKeyPacket' | 'Password' | 'UrlPasswordSalt' | 'SRPVerifier' | 'SRPModulusID' | 'MaxAccesses'> {
435
435
  return {
436
436
  Permissions: memberRoleToPermission(publicLink.role) as 4 | 6,
437
437
  Flags: publicLink.includesCustomPassword
438
438
  ? 3 // Random + custom password set.
439
439
  : 2, // Random password set.
440
- ExpirationDuration: publicLink.expirationDuration || null,
440
+ ExpirationTime: publicLink.expirationTime || null,
441
441
 
442
442
  SharePasswordSalt: publicLink.crypto.base64SharePasswordSalt,
443
443
  SharePassphraseKeyPacket: publicLink.crypto.base64SharePassphraseKeyPacket,
@@ -0,0 +1,148 @@
1
+ import { DriveCrypto, PrivateKey } from "../../crypto";
2
+ import { MetricVolumeType, NodeType, ProtonDriveAccount, ProtonDriveTelemetry, resultError, resultOk } from "../../interface";
3
+ import { getMockTelemetry } from "../../tests/telemetry";
4
+ import { SharesService } from "./interface";
5
+ import { SharingCryptoService } from "./cryptoService";
6
+
7
+ describe("SharingCryptoService", () => {
8
+ let telemetry: ProtonDriveTelemetry;
9
+ let driveCrypto: DriveCrypto;
10
+ let account: ProtonDriveAccount;
11
+ let sharesService: SharesService;
12
+ let cryptoService: SharingCryptoService;
13
+
14
+ beforeEach(() => {
15
+ telemetry = getMockTelemetry();
16
+ // @ts-expect-error No need to implement all methods for mocking
17
+ driveCrypto = {
18
+ decryptShareUrlPassword: jest.fn().mockResolvedValue("urlPassword"),
19
+ decryptKeyWithSrpPassword: jest.fn().mockResolvedValue({
20
+ key: "decryptedKey" as unknown as PrivateKey,
21
+ }),
22
+ decryptNodeName: jest.fn().mockResolvedValue({
23
+ name: "nodeName",
24
+ }),
25
+ };
26
+ account = {
27
+ // @ts-expect-error No need to implement full response for mocking
28
+ getOwnAddress: jest.fn(async () => ({
29
+ keys: [{ key: "addressKey" as unknown as PrivateKey }],
30
+ })),
31
+ };
32
+ // @ts-expect-error No need to implement all methods for mocking
33
+ sharesService = {
34
+ getMyFilesShareMemberEmailKey: jest.fn().mockResolvedValue({
35
+ addressId: "addressId",
36
+ }),
37
+ };
38
+ cryptoService = new SharingCryptoService(telemetry, driveCrypto, account, sharesService);
39
+ });
40
+
41
+ describe("decryptBookmark", () => {
42
+ const encryptedBookmark = {
43
+ tokenId: "tokenId",
44
+ creationTime: new Date(),
45
+ url: {
46
+ encryptedUrlPassword: "encryptedUrlPassword",
47
+ base64SharePasswordSalt: "base64SharePasswordSalt",
48
+ },
49
+ share: {
50
+ armoredKey: "armoredKey",
51
+ armoredPassphrase: "armoredPassphrase",
52
+ },
53
+ node: {
54
+ type: NodeType.File,
55
+ mediaType: "mediaType",
56
+ encryptedName: "encryptedName",
57
+ armoredKey: "armoredKey",
58
+ armoredNodePassphrase: "armoredNodePassphrase",
59
+ file: {
60
+ base64ContentKeyPacket: "base64ContentKeyPacket",
61
+ },
62
+ },
63
+ }
64
+
65
+ it("should decrypt bookmark", async () => {
66
+ const result = await cryptoService.decryptBookmark(encryptedBookmark);
67
+
68
+ expect(result).toMatchObject({
69
+ url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"),
70
+ nodeName: resultOk("nodeName"),
71
+ });
72
+ expect(driveCrypto.decryptShareUrlPassword).toHaveBeenCalledWith("encryptedUrlPassword", ["addressKey"]);
73
+ expect(driveCrypto.decryptKeyWithSrpPassword).toHaveBeenCalledWith("urlPassword", "base64SharePasswordSalt", "armoredKey", "armoredPassphrase");
74
+ expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith("encryptedName", "decryptedKey", []);
75
+ expect(telemetry.logEvent).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it("should handle undecryptable URL password", async () => {
79
+ const error = new Error("Failed to decrypt URL password");
80
+ driveCrypto.decryptShareUrlPassword = jest.fn().mockRejectedValue(error);
81
+
82
+ const result = await cryptoService.decryptBookmark(encryptedBookmark);
83
+
84
+ expect(result).toMatchObject({
85
+ url: resultError(new Error("Failed to decrypt bookmark password: Failed to decrypt URL password")),
86
+ nodeName: resultError(new Error("Failed to decrypt bookmark password: Failed to decrypt URL password")),
87
+ });
88
+ expect(telemetry.logEvent).toHaveBeenCalledWith({
89
+ eventName: 'decryptionError',
90
+ volumeType: MetricVolumeType.SharedPublic,
91
+ field: 'shareUrlPassword',
92
+ error,
93
+ });
94
+ });
95
+
96
+ it("should handle undecryptable share key", async () => {
97
+ const error = new Error("Failed to decrypt share key");
98
+ driveCrypto.decryptKeyWithSrpPassword = jest.fn().mockRejectedValue(error);
99
+
100
+ const result = await cryptoService.decryptBookmark(encryptedBookmark);
101
+
102
+ expect(result).toMatchObject({
103
+ url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"),
104
+ nodeName: resultError(new Error("Failed to decrypt bookmark key: Failed to decrypt share key")),
105
+ });
106
+ expect(telemetry.logEvent).toHaveBeenCalledWith({
107
+ eventName: 'decryptionError',
108
+ volumeType: MetricVolumeType.SharedPublic,
109
+ field: 'shareKey',
110
+ error,
111
+ });
112
+ });
113
+
114
+ it("should handle undecryptable node name", async () => {
115
+ const error = new Error("Failed to decrypt node name");
116
+ driveCrypto.decryptNodeName = jest.fn().mockRejectedValue(error);
117
+
118
+ const result = await cryptoService.decryptBookmark(encryptedBookmark);
119
+
120
+ expect(result).toMatchObject({
121
+ url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"),
122
+ nodeName: resultError(new Error("Failed to decrypt bookmark name: Failed to decrypt node name")),
123
+ });
124
+ expect(telemetry.logEvent).toHaveBeenCalledWith({
125
+ eventName: 'decryptionError',
126
+ volumeType: MetricVolumeType.SharedPublic,
127
+ field: 'nodeName',
128
+ error,
129
+ });
130
+ });
131
+
132
+ it("should handle invalid node name", async () => {
133
+ driveCrypto.decryptNodeName = jest.fn().mockResolvedValue({
134
+ name: "invalid/name",
135
+ });
136
+
137
+ const result = await cryptoService.decryptBookmark(encryptedBookmark);
138
+
139
+ expect(result).toMatchObject({
140
+ url: resultOk("https://drive.proton.me/urls/tokenId#urlPassword"),
141
+ nodeName: resultError({
142
+ name: "invalid/name",
143
+ error: "Name must not contain the character '/'",
144
+ }),
145
+ });
146
+ });
147
+ });
148
+ });