chatly-sdk 0.0.8 → 2.0.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.
- package/IMPROVEMENTS.md +1 -1
- package/README.md +53 -13
- package/dist/index.d.ts +74 -4
- package/dist/index.js +292 -96
- package/package.json +2 -1
- package/src/chat/ChatSession.ts +88 -12
- package/src/chat/GroupSession.ts +35 -7
- package/src/crypto/e2e.ts +9 -0
- package/src/crypto/utils.ts +3 -1
- package/src/index.ts +11 -6
- package/src/models/mediaTypes.ts +5 -1
- package/src/storage/adapters.ts +36 -0
- package/src/storage/localStorage.ts +49 -0
- package/src/storage/s3Storage.ts +84 -0
- package/src/stores/adapters.ts +2 -0
- package/src/stores/memory/messageStore.ts +8 -0
- package/test/crypto.test.ts +17 -17
package/src/chat/ChatSession.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { User } from "../models/user.js";
|
|
2
2
|
import type { Message } from "../models/message.js";
|
|
3
3
|
import type { MediaAttachment } from "../models/mediaTypes.js";
|
|
4
|
-
import { deriveSharedSecret, encryptMessage, decryptMessage } from "../crypto/e2e.js";
|
|
4
|
+
import { deriveSharedSecret, deriveLegacySharedSecret, encryptMessage, decryptMessage } from "../crypto/e2e.js";
|
|
5
5
|
import type { KeyPair } from "../crypto/keys.js";
|
|
6
6
|
import { generateUUID } from "../crypto/uuid.js";
|
|
7
|
+
import type { StorageProvider } from "../storage/adapters.js";
|
|
8
|
+
import { logger } from "../utils/logger.js";
|
|
7
9
|
|
|
8
10
|
export class ChatSession {
|
|
9
11
|
private sharedSecret: Buffer | null = null;
|
|
@@ -12,7 +14,8 @@ export class ChatSession {
|
|
|
12
14
|
constructor(
|
|
13
15
|
public readonly id: string,
|
|
14
16
|
public readonly userA: User,
|
|
15
|
-
public readonly userB: User
|
|
17
|
+
public readonly userB: User,
|
|
18
|
+
private storageProvider?: StorageProvider
|
|
16
19
|
) {}
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -41,6 +44,13 @@ export class ChatSession {
|
|
|
41
44
|
privateKey: user.privateKey,
|
|
42
45
|
};
|
|
43
46
|
|
|
47
|
+
logger.debug(`[ChatSession] Initializing for user ${user.id}`, {
|
|
48
|
+
hasLocalPriv: !!user.privateKey,
|
|
49
|
+
privType: typeof user.privateKey,
|
|
50
|
+
hasRemotePub: !!otherUser.publicKey,
|
|
51
|
+
pubType: typeof otherUser.publicKey
|
|
52
|
+
});
|
|
53
|
+
|
|
44
54
|
this.sharedSecret = deriveSharedSecret(localKeyPair, otherUser.publicKey);
|
|
45
55
|
}
|
|
46
56
|
|
|
@@ -88,9 +98,9 @@ export class ChatSession {
|
|
|
88
98
|
// Encrypt the message text (could be caption)
|
|
89
99
|
const { ciphertext, iv } = encryptMessage(plaintext, this.sharedSecret);
|
|
90
100
|
|
|
91
|
-
// Encrypt the media data
|
|
92
|
-
const { ciphertext: encryptedMediaData } = encryptMessage(
|
|
93
|
-
media.data,
|
|
101
|
+
// Encrypt the media data with its own IV
|
|
102
|
+
const { ciphertext: encryptedMediaData, iv: mediaIv } = encryptMessage(
|
|
103
|
+
media.data || "",
|
|
94
104
|
this.sharedSecret
|
|
95
105
|
);
|
|
96
106
|
|
|
@@ -98,8 +108,24 @@ export class ChatSession {
|
|
|
98
108
|
const encryptedMedia: MediaAttachment = {
|
|
99
109
|
...media,
|
|
100
110
|
data: encryptedMediaData,
|
|
111
|
+
iv: mediaIv,
|
|
101
112
|
};
|
|
102
113
|
|
|
114
|
+
// If storage provider is available, upload the encrypted data
|
|
115
|
+
if (this.storageProvider) {
|
|
116
|
+
const filename = `${this.id}/${generateUUID()}-${media.metadata.filename}`;
|
|
117
|
+
const uploadResult = await this.storageProvider.upload(
|
|
118
|
+
encryptedMediaData,
|
|
119
|
+
filename,
|
|
120
|
+
media.metadata.mimeType
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
encryptedMedia.storage = this.storageProvider.name as 'local' | 's3';
|
|
124
|
+
encryptedMedia.storageKey = uploadResult.storageKey;
|
|
125
|
+
encryptedMedia.url = uploadResult.url;
|
|
126
|
+
encryptedMedia.data = undefined; // Remove data from attachment if stored remotely
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
return {
|
|
104
130
|
id: generateUUID(),
|
|
105
131
|
senderId,
|
|
@@ -127,7 +153,31 @@ export class ChatSession {
|
|
|
127
153
|
throw new Error("Failed to initialize session");
|
|
128
154
|
}
|
|
129
155
|
|
|
130
|
-
|
|
156
|
+
try {
|
|
157
|
+
return decryptMessage(message.ciphertext, message.iv, this.sharedSecret);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// Fallback for legacy messages (before salt logic)
|
|
160
|
+
const legacySecret = this.deriveLegacySecret(user);
|
|
161
|
+
try {
|
|
162
|
+
return decryptMessage(message.ciphertext, message.iv, legacySecret);
|
|
163
|
+
} catch (innerError) {
|
|
164
|
+
throw error; // Throw original error if fallback also fails
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private deriveLegacySecret(user: User): Buffer {
|
|
170
|
+
const otherUser = user.id === this.userA.id ? this.userB : this.userA;
|
|
171
|
+
logger.debug(`[ChatSession] Deriving legacy secret for user ${user.id}`, {
|
|
172
|
+
hasPriv: !!user.privateKey,
|
|
173
|
+
privType: typeof user.privateKey,
|
|
174
|
+
remotePubType: typeof otherUser.publicKey
|
|
175
|
+
});
|
|
176
|
+
const localKeyPair: KeyPair = {
|
|
177
|
+
publicKey: user.publicKey,
|
|
178
|
+
privateKey: user.privateKey,
|
|
179
|
+
};
|
|
180
|
+
return deriveLegacySharedSecret(localKeyPair, otherUser.publicKey);
|
|
131
181
|
}
|
|
132
182
|
|
|
133
183
|
/**
|
|
@@ -151,12 +201,38 @@ export class ChatSession {
|
|
|
151
201
|
// Decrypt the message text
|
|
152
202
|
const text = decryptMessage(message.ciphertext, message.iv, this.sharedSecret);
|
|
153
203
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
this.
|
|
159
|
-
|
|
204
|
+
let encryptedMediaData = message.media.data;
|
|
205
|
+
|
|
206
|
+
// If data is missing but storageKey is present, download it
|
|
207
|
+
if (!encryptedMediaData && message.media.storageKey && this.storageProvider) {
|
|
208
|
+
encryptedMediaData = await this.storageProvider.download(message.media.storageKey);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Decrypt the media data using its own IV
|
|
212
|
+
if (!message.media.iv && !encryptedMediaData) {
|
|
213
|
+
throw new Error("Media data or IV missing");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let decryptedMediaData: string;
|
|
217
|
+
try {
|
|
218
|
+
decryptedMediaData = decryptMessage(
|
|
219
|
+
encryptedMediaData || "",
|
|
220
|
+
message.media.iv || message.iv,
|
|
221
|
+
this.sharedSecret
|
|
222
|
+
);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
// Fallback for legacy media
|
|
225
|
+
const legacySecret = this.deriveLegacySecret(user);
|
|
226
|
+
try {
|
|
227
|
+
decryptedMediaData = decryptMessage(
|
|
228
|
+
encryptedMediaData || "",
|
|
229
|
+
message.media.iv || message.iv,
|
|
230
|
+
legacySecret
|
|
231
|
+
);
|
|
232
|
+
} catch (innerError) {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
160
236
|
|
|
161
237
|
// Create decrypted media attachment
|
|
162
238
|
const decryptedMedia: MediaAttachment = {
|
package/src/chat/GroupSession.ts
CHANGED
|
@@ -5,11 +5,12 @@ import { deriveGroupKey } from "../crypto/group.js";
|
|
|
5
5
|
import { encryptMessage, decryptMessage } from "../crypto/e2e.js";
|
|
6
6
|
import { base64ToBuffer } from "../crypto/utils.js";
|
|
7
7
|
import { generateUUID } from "../crypto/uuid.js";
|
|
8
|
+
import type { StorageProvider } from "../storage/adapters.js";
|
|
8
9
|
|
|
9
10
|
export class GroupSession {
|
|
10
11
|
private groupKey: Buffer | null = null;
|
|
11
12
|
|
|
12
|
-
constructor(public readonly group: Group) {}
|
|
13
|
+
constructor(public readonly group: Group, private storageProvider?: StorageProvider) {}
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Initialize the session by deriving the group key
|
|
@@ -63,9 +64,9 @@ export class GroupSession {
|
|
|
63
64
|
// Encrypt the message text (could be caption)
|
|
64
65
|
const { ciphertext, iv } = encryptMessage(plaintext, this.groupKey);
|
|
65
66
|
|
|
66
|
-
// Encrypt the media data
|
|
67
|
-
const { ciphertext: encryptedMediaData } = encryptMessage(
|
|
68
|
-
media.data,
|
|
67
|
+
// Encrypt the media data with its own IV
|
|
68
|
+
const { ciphertext: encryptedMediaData, iv: mediaIv } = encryptMessage(
|
|
69
|
+
media.data || "",
|
|
69
70
|
this.groupKey
|
|
70
71
|
);
|
|
71
72
|
|
|
@@ -73,8 +74,24 @@ export class GroupSession {
|
|
|
73
74
|
const encryptedMedia: MediaAttachment = {
|
|
74
75
|
...media,
|
|
75
76
|
data: encryptedMediaData,
|
|
77
|
+
iv: mediaIv,
|
|
76
78
|
};
|
|
77
79
|
|
|
80
|
+
// If storage provider is available, upload the encrypted data
|
|
81
|
+
if (this.storageProvider) {
|
|
82
|
+
const filename = `groups/${this.group.id}/${generateUUID()}-${media.metadata.filename}`;
|
|
83
|
+
const uploadResult = await this.storageProvider.upload(
|
|
84
|
+
encryptedMediaData,
|
|
85
|
+
filename,
|
|
86
|
+
media.metadata.mimeType
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
encryptedMedia.storage = this.storageProvider.name as 'local' | 's3';
|
|
90
|
+
encryptedMedia.storageKey = uploadResult.storageKey;
|
|
91
|
+
encryptedMedia.url = uploadResult.url;
|
|
92
|
+
encryptedMedia.data = undefined; // Remove data from attachment if stored remotely
|
|
93
|
+
}
|
|
94
|
+
|
|
78
95
|
return {
|
|
79
96
|
id: generateUUID(),
|
|
80
97
|
senderId,
|
|
@@ -121,10 +138,21 @@ export class GroupSession {
|
|
|
121
138
|
// Decrypt the message text
|
|
122
139
|
const text = decryptMessage(message.ciphertext, message.iv, this.groupKey);
|
|
123
140
|
|
|
124
|
-
|
|
141
|
+
let encryptedMediaData = message.media.data;
|
|
142
|
+
|
|
143
|
+
// If data is missing but storageKey is present, download it
|
|
144
|
+
if (!encryptedMediaData && message.media.storageKey && this.storageProvider) {
|
|
145
|
+
encryptedMediaData = await this.storageProvider.download(message.media.storageKey);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Decrypt the media data using its own IV
|
|
149
|
+
if (!message.media.iv && !encryptedMediaData) {
|
|
150
|
+
throw new Error("Media data or IV missing");
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
const decryptedMediaData = decryptMessage(
|
|
126
|
-
|
|
127
|
-
message.iv,
|
|
154
|
+
encryptedMediaData || "",
|
|
155
|
+
message.media.iv || message.iv, // Fallback to message IV for backward compatibility
|
|
128
156
|
this.groupKey
|
|
129
157
|
);
|
|
130
158
|
|
package/src/crypto/e2e.ts
CHANGED
|
@@ -33,6 +33,15 @@
|
|
|
33
33
|
return derivedKey;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Legacy secret derivation without salt (for backward compatibility)
|
|
38
|
+
*/
|
|
39
|
+
export function deriveLegacySharedSecret(local: KeyPair, remotePublicKey: string): Buffer {
|
|
40
|
+
const ecdh = createECDH(SUPPORTED_CURVE);
|
|
41
|
+
ecdh.setPrivateKey(base64ToBuffer(local.privateKey));
|
|
42
|
+
return ecdh.computeSecret(base64ToBuffer(remotePublicKey));
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
/**
|
|
37
46
|
* Encrypt a message using AES-GCM
|
|
38
47
|
*/
|
package/src/crypto/utils.ts
CHANGED
|
@@ -2,6 +2,8 @@ export function bufferToBase64(buffer: Buffer): string {
|
|
|
2
2
|
return buffer.toString("base64");
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
export function base64ToBuffer(data: string): Buffer {
|
|
5
|
+
export function base64ToBuffer(data: string | Buffer): Buffer {
|
|
6
|
+
if (Buffer.isBuffer(data)) return data;
|
|
7
|
+
if (!data) return Buffer.alloc(0);
|
|
6
8
|
return Buffer.from(data, "base64");
|
|
7
9
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
GroupStoreAdapter,
|
|
10
10
|
} from "./stores/adapters.js";
|
|
11
11
|
import type { TransportAdapter } from "./transport/adapters.js";
|
|
12
|
+
import type { StorageProvider } from "./storage/adapters.js";
|
|
12
13
|
import { ChatSession } from "./chat/ChatSession.js";
|
|
13
14
|
import { GroupSession } from "./chat/GroupSession.js";
|
|
14
15
|
import { generateIdentityKeyPair } from "./crypto/keys.js";
|
|
@@ -23,6 +24,7 @@ export interface ChatSDKConfig {
|
|
|
23
24
|
userStore: UserStoreAdapter;
|
|
24
25
|
messageStore: MessageStoreAdapter;
|
|
25
26
|
groupStore: GroupStoreAdapter;
|
|
27
|
+
storageProvider?: StorageProvider;
|
|
26
28
|
transport?: TransportAdapter;
|
|
27
29
|
logLevel?: LogLevel;
|
|
28
30
|
}
|
|
@@ -213,7 +215,7 @@ export class ChatSDK extends EventEmitter {
|
|
|
213
215
|
const sessionId = `${ids[0]}-${ids[1]}`;
|
|
214
216
|
|
|
215
217
|
try {
|
|
216
|
-
const session = new ChatSession(sessionId, userA, userB);
|
|
218
|
+
const session = new ChatSession(sessionId, userA, userB, this.config.storageProvider);
|
|
217
219
|
await session.initialize();
|
|
218
220
|
logger.info('Chat session created', { sessionId, users: [userA.id, userB.id] });
|
|
219
221
|
this.emit(EVENTS.SESSION_CREATED, session);
|
|
@@ -245,7 +247,7 @@ export class ChatSDK extends EventEmitter {
|
|
|
245
247
|
|
|
246
248
|
try {
|
|
247
249
|
await this.config.groupStore.create(group);
|
|
248
|
-
const session = new GroupSession(group);
|
|
250
|
+
const session = new GroupSession(group, this.config.storageProvider);
|
|
249
251
|
await session.initialize();
|
|
250
252
|
logger.info('Group created', { groupId: group.id, name: group.name, memberCount: members.length });
|
|
251
253
|
this.emit(EVENTS.GROUP_CREATED, session);
|
|
@@ -272,7 +274,7 @@ export class ChatSDK extends EventEmitter {
|
|
|
272
274
|
throw new SessionError(`Group not found: ${id}`, { groupId: id });
|
|
273
275
|
}
|
|
274
276
|
|
|
275
|
-
const session = new GroupSession(group);
|
|
277
|
+
const session = new GroupSession(group, this.config.storageProvider);
|
|
276
278
|
await session.initialize();
|
|
277
279
|
logger.debug('Group loaded', { groupId: id });
|
|
278
280
|
return session;
|
|
@@ -437,7 +439,7 @@ export class ChatSDK extends EventEmitter {
|
|
|
437
439
|
// Create consistent session ID
|
|
438
440
|
const ids = [user.id, otherUser.id].sort();
|
|
439
441
|
const sessionId = `${ids[0]}-${ids[1]}`;
|
|
440
|
-
const session = new ChatSession(sessionId, user, otherUser);
|
|
442
|
+
const session = new ChatSession(sessionId, user, otherUser, this.config.storageProvider);
|
|
441
443
|
await session.initializeForUser(user);
|
|
442
444
|
return await session.decrypt(message, user);
|
|
443
445
|
}
|
|
@@ -467,7 +469,7 @@ export class ChatSDK extends EventEmitter {
|
|
|
467
469
|
if (!group) {
|
|
468
470
|
throw new SessionError(`Group not found: ${message.groupId}`, { groupId: message.groupId });
|
|
469
471
|
}
|
|
470
|
-
const session = new GroupSession(group);
|
|
472
|
+
const session = new GroupSession(group, this.config.storageProvider);
|
|
471
473
|
await session.initialize();
|
|
472
474
|
return await session.decryptMedia(message);
|
|
473
475
|
} else {
|
|
@@ -486,7 +488,7 @@ export class ChatSDK extends EventEmitter {
|
|
|
486
488
|
// Create consistent session ID
|
|
487
489
|
const ids = [user.id, otherUser.id].sort();
|
|
488
490
|
const sessionId = `${ids[0]}-${ids[1]}`;
|
|
489
|
-
const session = new ChatSession(sessionId, user, otherUser);
|
|
491
|
+
const session = new ChatSession(sessionId, user, otherUser, this.config.storageProvider);
|
|
490
492
|
await session.initializeForUser(user);
|
|
491
493
|
return await session.decryptMedia(message, user);
|
|
492
494
|
}
|
|
@@ -671,4 +673,7 @@ export * from "./utils/errors.js";
|
|
|
671
673
|
export * from "./utils/logger.js";
|
|
672
674
|
export * from "./utils/validation.js";
|
|
673
675
|
export * from "./utils/mediaUtils.js";
|
|
676
|
+
export * from "./storage/adapters.js";
|
|
677
|
+
export * from "./storage/localStorage.js";
|
|
678
|
+
export * from "./storage/s3Storage.js";
|
|
674
679
|
export * from "./constants.js";
|
package/src/models/mediaTypes.ts
CHANGED
|
@@ -26,8 +26,12 @@ export interface MediaMetadata {
|
|
|
26
26
|
*/
|
|
27
27
|
export interface MediaAttachment {
|
|
28
28
|
type: MediaType;
|
|
29
|
-
data
|
|
29
|
+
data?: string | undefined; // Base64 encoded file data (optional if stored remotely)
|
|
30
|
+
iv?: string | undefined; // Separate IV for media data encryption
|
|
30
31
|
metadata: MediaMetadata;
|
|
32
|
+
storage?: 'local' | 's3' | undefined; // Storage provider used
|
|
33
|
+
storageKey?: string | undefined; // Key/path in the storage provider
|
|
34
|
+
url?: string | undefined; // Public URL if available
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface StorageUploadResult {
|
|
2
|
+
storageKey: string;
|
|
3
|
+
url?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface StorageProvider {
|
|
7
|
+
/**
|
|
8
|
+
* Name of the storage provider (e.g., 'local', 's3')
|
|
9
|
+
*/
|
|
10
|
+
readonly name: string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Upload data to storage
|
|
14
|
+
* @param data Base64 encoded data or Buffer
|
|
15
|
+
* @param filename Desired filename or path
|
|
16
|
+
* @param mimeType MIME type of the file
|
|
17
|
+
*/
|
|
18
|
+
upload(
|
|
19
|
+
data: string | Buffer,
|
|
20
|
+
filename: string,
|
|
21
|
+
mimeType: string
|
|
22
|
+
): Promise<StorageUploadResult>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Download data from storage
|
|
26
|
+
* @param storageKey Key/path of the file in storage
|
|
27
|
+
* @returns Base64 encoded data
|
|
28
|
+
*/
|
|
29
|
+
download(storageKey: string): Promise<string>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Delete data from storage
|
|
33
|
+
* @param storageKey Key/path of the file in storage
|
|
34
|
+
*/
|
|
35
|
+
delete(storageKey: string): Promise<void>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { StorageProvider, StorageUploadResult } from './adapters.js';
|
|
4
|
+
|
|
5
|
+
export class LocalStorageProvider implements StorageProvider {
|
|
6
|
+
public readonly name = 'local';
|
|
7
|
+
private storageDir: string;
|
|
8
|
+
|
|
9
|
+
constructor(storageDir: string = './storage') {
|
|
10
|
+
this.storageDir = path.resolve(storageDir);
|
|
11
|
+
// Ensure storage directory exists
|
|
12
|
+
fs.mkdir(this.storageDir, { recursive: true }).catch(() => {});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async upload(
|
|
16
|
+
data: string | Buffer,
|
|
17
|
+
filename: string,
|
|
18
|
+
mimeType: string
|
|
19
|
+
): Promise<StorageUploadResult> {
|
|
20
|
+
const buffer = typeof data === 'string' ? Buffer.from(data, 'base64') : data;
|
|
21
|
+
const filePath = path.join(this.storageDir, filename);
|
|
22
|
+
|
|
23
|
+
// Ensure parent directories exist
|
|
24
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
25
|
+
await fs.writeFile(filePath, buffer);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
storageKey: filename,
|
|
29
|
+
url: `file://${filePath}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async download(storageKey: string): Promise<string> {
|
|
34
|
+
const filePath = path.join(this.storageDir, storageKey);
|
|
35
|
+
const buffer = await fs.readFile(filePath);
|
|
36
|
+
return buffer.toString('base64');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async delete(storageKey: string): Promise<void> {
|
|
40
|
+
const filePath = path.join(this.storageDir, storageKey);
|
|
41
|
+
try {
|
|
42
|
+
await fs.unlink(filePath);
|
|
43
|
+
} catch (error: any) {
|
|
44
|
+
if (error.code !== 'ENOENT') {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|
2
|
+
import { StorageProvider, StorageUploadResult } from './adapters.js';
|
|
3
|
+
|
|
4
|
+
export interface S3Config {
|
|
5
|
+
region: string;
|
|
6
|
+
bucket: string;
|
|
7
|
+
credentials?: {
|
|
8
|
+
accessKeyId: string;
|
|
9
|
+
secretAccessKey: string;
|
|
10
|
+
};
|
|
11
|
+
endpoint?: string;
|
|
12
|
+
forcePathStyle?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class S3StorageProvider implements StorageProvider {
|
|
16
|
+
public readonly name = 's3';
|
|
17
|
+
private client: S3Client;
|
|
18
|
+
private bucket: string;
|
|
19
|
+
|
|
20
|
+
constructor(config: S3Config) {
|
|
21
|
+
const s3Config: any = {
|
|
22
|
+
region: config.region,
|
|
23
|
+
forcePathStyle: config.forcePathStyle,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (config.credentials) {
|
|
27
|
+
s3Config.credentials = config.credentials;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (config.endpoint) {
|
|
31
|
+
s3Config.endpoint = config.endpoint;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.client = new S3Client(s3Config);
|
|
35
|
+
this.bucket = config.bucket;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async upload(
|
|
39
|
+
data: string | Buffer,
|
|
40
|
+
filename: string,
|
|
41
|
+
mimeType: string
|
|
42
|
+
): Promise<StorageUploadResult> {
|
|
43
|
+
const body = typeof data === 'string' ? Buffer.from(data, 'base64') : data;
|
|
44
|
+
|
|
45
|
+
await this.client.send(
|
|
46
|
+
new PutObjectCommand({
|
|
47
|
+
Bucket: this.bucket,
|
|
48
|
+
Key: filename,
|
|
49
|
+
Body: body,
|
|
50
|
+
ContentType: mimeType,
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
storageKey: filename,
|
|
56
|
+
url: `https://${this.bucket}.s3.amazonaws.com/${filename}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async download(storageKey: string): Promise<string> {
|
|
61
|
+
const response = await this.client.send(
|
|
62
|
+
new GetObjectCommand({
|
|
63
|
+
Bucket: this.bucket,
|
|
64
|
+
Key: storageKey,
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (!response.Body) {
|
|
69
|
+
throw new Error('S3 download failed: empty body');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const bytes = await response.Body.transformToByteArray();
|
|
73
|
+
return Buffer.from(bytes).toString('base64');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async delete(storageKey: string): Promise<void> {
|
|
77
|
+
await this.client.send(
|
|
78
|
+
new DeleteObjectCommand({
|
|
79
|
+
Bucket: this.bucket,
|
|
80
|
+
Key: storageKey,
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/stores/adapters.ts
CHANGED
|
@@ -11,8 +11,10 @@ export interface UserStoreAdapter {
|
|
|
11
11
|
|
|
12
12
|
export interface MessageStoreAdapter {
|
|
13
13
|
create(message: Message): Promise<Message>;
|
|
14
|
+
findById(id: string): Promise<Message | undefined>;
|
|
14
15
|
listByUser(userId: string): Promise<Message[]>;
|
|
15
16
|
listByGroup(groupId: string): Promise<Message[]>;
|
|
17
|
+
delete(id: string): Promise<void>;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface GroupStoreAdapter {
|
|
@@ -9,6 +9,10 @@ export class InMemoryMessageStore implements MessageStoreAdapter {
|
|
|
9
9
|
return message;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
async findById(id: string): Promise<Message | undefined> {
|
|
13
|
+
return this.messages.find((msg) => msg.id === id);
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
async listByUser(userId: string): Promise<Message[]> {
|
|
13
17
|
return this.messages.filter(
|
|
14
18
|
(msg) => msg.senderId === userId || msg.receiverId === userId
|
|
@@ -18,4 +22,8 @@ export class InMemoryMessageStore implements MessageStoreAdapter {
|
|
|
18
22
|
async listByGroup(groupId: string): Promise<Message[]> {
|
|
19
23
|
return this.messages.filter((msg) => msg.groupId === groupId);
|
|
20
24
|
}
|
|
25
|
+
|
|
26
|
+
async delete(id: string): Promise<void> {
|
|
27
|
+
this.messages = this.messages.filter((msg) => msg.id !== id);
|
|
28
|
+
}
|
|
21
29
|
}
|
package/test/crypto.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { encryptMessage, decryptMessage, deriveSharedSecret } from '../src/crypto/e2e';
|
|
2
|
-
import { generateIdentityKeyPair } from '../src/crypto/keys';
|
|
1
|
+
import { encryptMessage, decryptMessage, deriveSharedSecret } from '../src/crypto/e2e.js';
|
|
2
|
+
import { generateIdentityKeyPair } from '../src/crypto/keys.js';
|
|
3
3
|
|
|
4
4
|
describe('End-to-End Encryption', () => {
|
|
5
5
|
describe('Key Generation', () => {
|
|
@@ -28,10 +28,10 @@ describe('End-to-End Encryption', () => {
|
|
|
28
28
|
const aliceKeys = generateIdentityKeyPair();
|
|
29
29
|
const bobKeys = generateIdentityKeyPair();
|
|
30
30
|
|
|
31
|
-
const aliceShared = await deriveSharedSecret(aliceKeys
|
|
32
|
-
const bobShared = await deriveSharedSecret(bobKeys
|
|
31
|
+
const aliceShared = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
32
|
+
const bobShared = await deriveSharedSecret(bobKeys, aliceKeys.publicKey);
|
|
33
33
|
|
|
34
|
-
expect(aliceShared).
|
|
34
|
+
expect(aliceShared).toEqual(bobShared);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
it('should derive different secrets for different key pairs', async () => {
|
|
@@ -39,10 +39,10 @@ describe('End-to-End Encryption', () => {
|
|
|
39
39
|
const bobKeys = generateIdentityKeyPair();
|
|
40
40
|
const charlieKeys = generateIdentityKeyPair();
|
|
41
41
|
|
|
42
|
-
const aliceBobSecret = await deriveSharedSecret(aliceKeys
|
|
43
|
-
const aliceCharlieSecret = await deriveSharedSecret(aliceKeys
|
|
42
|
+
const aliceBobSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
43
|
+
const aliceCharlieSecret = await deriveSharedSecret(aliceKeys, charlieKeys.publicKey);
|
|
44
44
|
|
|
45
|
-
expect(aliceBobSecret).not.
|
|
45
|
+
expect(aliceBobSecret).not.toEqual(aliceCharlieSecret);
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -50,7 +50,7 @@ describe('End-to-End Encryption', () => {
|
|
|
50
50
|
it('should encrypt and decrypt a message correctly', async () => {
|
|
51
51
|
const aliceKeys = generateIdentityKeyPair();
|
|
52
52
|
const bobKeys = generateIdentityKeyPair();
|
|
53
|
-
const sharedSecret = await deriveSharedSecret(aliceKeys
|
|
53
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
54
54
|
|
|
55
55
|
const plaintext = 'Hello, Bob!';
|
|
56
56
|
const encrypted = await encryptMessage(plaintext, sharedSecret);
|
|
@@ -67,7 +67,7 @@ describe('End-to-End Encryption', () => {
|
|
|
67
67
|
it('should produce different ciphertexts for the same plaintext', async () => {
|
|
68
68
|
const aliceKeys = generateIdentityKeyPair();
|
|
69
69
|
const bobKeys = generateIdentityKeyPair();
|
|
70
|
-
const sharedSecret = await deriveSharedSecret(aliceKeys
|
|
70
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
71
71
|
|
|
72
72
|
const plaintext = 'Hello, Bob!';
|
|
73
73
|
const encrypted1 = await encryptMessage(plaintext, sharedSecret);
|
|
@@ -82,21 +82,21 @@ describe('End-to-End Encryption', () => {
|
|
|
82
82
|
const bobKeys = generateIdentityKeyPair();
|
|
83
83
|
const charlieKeys = generateIdentityKeyPair();
|
|
84
84
|
|
|
85
|
-
const aliceBobSecret = await deriveSharedSecret(aliceKeys
|
|
86
|
-
const aliceCharlieSecret = await deriveSharedSecret(aliceKeys
|
|
85
|
+
const aliceBobSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
86
|
+
const aliceCharlieSecret = await deriveSharedSecret(aliceKeys, charlieKeys.publicKey);
|
|
87
87
|
|
|
88
88
|
const plaintext = 'Secret message';
|
|
89
89
|
const encrypted = await encryptMessage(plaintext, aliceBobSecret);
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
expect(() =>
|
|
92
92
|
decryptMessage(encrypted.ciphertext, encrypted.iv, aliceCharlieSecret)
|
|
93
|
-
).
|
|
93
|
+
).toThrow(/Unsupported state or unable to authenticate data/);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
it('should handle empty messages', async () => {
|
|
97
97
|
const aliceKeys = generateIdentityKeyPair();
|
|
98
98
|
const bobKeys = generateIdentityKeyPair();
|
|
99
|
-
const sharedSecret = await deriveSharedSecret(aliceKeys
|
|
99
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
100
100
|
|
|
101
101
|
const plaintext = '';
|
|
102
102
|
const encrypted = await encryptMessage(plaintext, sharedSecret);
|
|
@@ -108,7 +108,7 @@ describe('End-to-End Encryption', () => {
|
|
|
108
108
|
it('should handle long messages', async () => {
|
|
109
109
|
const aliceKeys = generateIdentityKeyPair();
|
|
110
110
|
const bobKeys = generateIdentityKeyPair();
|
|
111
|
-
const sharedSecret = await deriveSharedSecret(aliceKeys
|
|
111
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
112
112
|
|
|
113
113
|
const plaintext = 'A'.repeat(10000);
|
|
114
114
|
const encrypted = await encryptMessage(plaintext, sharedSecret);
|
|
@@ -120,7 +120,7 @@ describe('End-to-End Encryption', () => {
|
|
|
120
120
|
it('should handle special characters and emojis', async () => {
|
|
121
121
|
const aliceKeys = generateIdentityKeyPair();
|
|
122
122
|
const bobKeys = generateIdentityKeyPair();
|
|
123
|
-
const sharedSecret = await deriveSharedSecret(aliceKeys
|
|
123
|
+
const sharedSecret = await deriveSharedSecret(aliceKeys, bobKeys.publicKey);
|
|
124
124
|
|
|
125
125
|
const plaintext = 'Hello 👋 World! 🌍 Special chars: @#$%^&*()';
|
|
126
126
|
const encrypted = await encryptMessage(plaintext, sharedSecret);
|