Skip to content

Commit 1dbf59f

Browse files
feat: implement pluggable ECDH-X25519 encryption and refactor SDK
1 parent 309213b commit 1dbf59f

10 files changed

Lines changed: 437 additions & 95 deletions

File tree

service/README.md

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,59 @@ Custom encryption protocol allows you to implement your own symmetric encryption
9090

9191
```javascript
9292
import { createChatInstance, AesGcmEncryption } from '@chat-e2ee/service';
93-
9493
const chat = createChatInstance({
9594
baseUrl: 'https://your-api.example.com',
9695
settings: { disableLog: true },
9796
encryptionProtocol: new AesGcmEncryption(), // optional custom implementation
9897
});
9998
```
99+
100+
---
101+
102+
## Pluggable Encryption
103+
104+
The SDK supports pluggable symmetric encryption strategies via the `EncryptionFactory`. This allows you to swap the underlying encryption logic used for WebRTC media streams.
105+
106+
### Available Strategies
107+
108+
- **`AES-GCM`** (Default): Standard AES-256-GCM encryption where the key is generated locally and shared via the signaling channel.
109+
- **`ECDH-X25519`**: Uses Ephemeral X25519 ECDH to derive a shared AES-256-GCM key. The secret key material never leaves the device.
110+
111+
### Switching Strategies
112+
113+
You can specify the encryption strategy when creating a chat instance:
114+
115+
```javascript
116+
import { createChatInstance, EncryptionFactory } from '@chat-e2ee/service';
117+
118+
// Use the high-security ECDH-X25519 strategy
119+
const strategy = EncryptionFactory.create({ symmetric: 'ECDH-X25519' });
120+
121+
const chat = createChatInstance({
122+
encryptionProtocol: strategy.symmetric
123+
});
124+
```
125+
126+
### Registering Custom Strategies
127+
128+
You can register your own implementation by implementing the `ISymmetricEncryption` interface:
129+
130+
```javascript
131+
import { EncryptionFactory } from '@chat-e2ee/service';
132+
133+
class MySymmetricCipher {
134+
async init() { ... }
135+
async exportKey() { ... }
136+
async importRemoteKey(key) { ... }
137+
async encryptData(data) { ... }
138+
async decryptData(data, iv) { ... }
139+
}
140+
141+
EncryptionFactory.registerSymmetric('MY-CIPHER', () => new MySymmetricCipher());
142+
143+
const chat = createChatInstance({
144+
encryptionProtocol: EncryptionFactory.create({ symmetric: 'MY-CIPHER' }).symmetric
145+
});
100146
```
101147

102148
---
@@ -109,8 +155,6 @@ Initializes the instance:
109155
- Generates ECDH key pairs for WebRTC encryption.
110156
- Establishes the socket connection.
111157
- Sets up WebRTC listeners.
112-
113-
#### `await getLink(): Promise<LinkObjType>`
114158
Requests a new channel link from the server.
115159
Returns an object containing `hash`, `link`, `absoluteLink`, `pin`, etc.
116160

service/src/crypto.test.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => {
7676
it('init() generates ECDH key pair and is idempotent on subsequent calls', async () => {
7777
const aes = new AesGcmEncryption();
7878
await aes.init();
79-
const key1Export = await aes.getRawAesKeyToExport();
79+
const key1Export = await aes.exportKey();
8080
await aes.init();
81-
const key2Export = await aes.getRawAesKeyToExport();
81+
const key2Export = await aes.exportKey();
8282

8383
expect(key1Export).toBeDefined();
8484
// Second call should return the same cached instance
@@ -92,8 +92,8 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => {
9292
await aes.init();
9393

9494
// Export the local ECDH public key and set it as the "remote" key to derive shared key for loopback test
95-
const exportedKey = await aes.getRawAesKeyToExport();
96-
await aes.setRemoteAesKey(exportedKey);
95+
const exportedKey = await aes.exportKey();
96+
await aes.importRemoteKey(exportedKey);
9797

9898
const originalText = 'AES test payload 🔒';
9999
const originalData = new TextEncoder().encode(originalText).buffer;
@@ -110,18 +110,19 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => {
110110
expect(decryptedText).toBe(originalText);
111111
});
112112

113-
it('encryptData() and decryptData() throw when remote key has not been set', async () => {
113+
it('encryptData() and decryptData() throw before initialisation/key import', async () => {
114114
const aes = new AesGcmEncryption();
115-
await aes.init();
116115

117-
// No setRemoteAesKey() call → should throw for encrypt
116+
// No init() call → should throw for encrypt
118117
await expect(aes.encryptData(
119118
new TextEncoder().encode('data').buffer
120-
)).rejects.toThrow('Shared AES key not derived.');
119+
)).rejects.toThrow('Local AES key not generated.');
120+
121+
await aes.init();
121122

122-
// No setRemoteAesKey() call → should throw for decrypt
123+
// No importRemoteKey() call → should throw for decrypt
123124
await expect(aes.decryptData(new ArrayBuffer(10), new Uint8Array(12))).rejects.toThrow(
124-
'Shared AES key not derived.'
125+
'Remote AES key not set.'
125126
);
126127
});
127128
});
@@ -134,16 +135,16 @@ describe('ECDH symmetric key exchange', () => {
134135
// Simulate Bob (receiver)
135136
const bobAes = new AesGcmEncryption();
136137
await bobAes.init();
137-
const bobEcdhKeyJwk = await bobAes.getRawAesKeyToExport();
138+
const bobEcdhKeyJwk = await bobAes.exportKey();
138139

139140
// Simulate Alice (sender)
140141
const aliceAes = new AesGcmEncryption();
141142
await aliceAes.init();
142-
const aliceEcdhKeyJwk = await aliceAes.getRawAesKeyToExport();
143+
const aliceEcdhKeyJwk = await aliceAes.exportKey();
143144

144145
// Exchange keys (in plaintext over the wire)
145-
await aliceAes.setRemoteAesKey(bobEcdhKeyJwk);
146-
await bobAes.setRemoteAesKey(aliceEcdhKeyJwk);
146+
await aliceAes.importRemoteKey(bobEcdhKeyJwk);
147+
await bobAes.importRemoteKey(aliceEcdhKeyJwk);
147148

148149
// Alice encrypts some data with her derived AES key
149150
const originalText = 'Secret message over ECDH derived AES-GCM 🔐';

service/src/cryptoAES.ts

Lines changed: 31 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/**
2+
* Interface for pluggable symmetric encryption strategies.
3+
* Implement this to swap in any symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305).
4+
*/
15
export interface ISymmetricEncryption {
26
/** Generate / initialise the local encryption key. Idempotent. */
37
init(): Promise<void>;
@@ -12,99 +16,63 @@ export interface ISymmetricEncryption {
1216
}
1317

1418
/**
15-
* AES-GCM encryption using ECDH X25519 for key exchange.
16-
* Both peers derive the same AES-256-GCM key independently — the raw key is never transmitted.
19+
* AES-256-GCM implementation of ISymmetricEncryption.
20+
* Used for encrypting Audio/Video WebRTC streams.
1721
*/
1822
export class AesGcmEncryption implements ISymmetricEncryption {
19-
private ecdhPrivateKey?: CryptoKey;
20-
private ecdhPublicKey?: CryptoKey;
21-
private sharedAesKey?: CryptoKey;
23+
private aesKeyLocal?: CryptoKey;
24+
private aesKeyRemote?: CryptoKey;
2225

2326
public async init(): Promise<void> {
24-
if (this.ecdhPrivateKey) {
27+
if (this.aesKeyLocal) {
2528
return;
2629
}
27-
// Generate ECDH key pair
28-
const keyPair = await globalThis.crypto.subtle.generateKey(
29-
{ name: "X25519" },
30-
true, // extractable
31-
["deriveKey", "deriveBits"]
32-
) as CryptoKeyPair;
33-
34-
this.ecdhPrivateKey = keyPair.privateKey;
35-
this.ecdhPublicKey = keyPair.publicKey;
36-
}
37-
38-
public getRemoteAesKey(): CryptoKey {
39-
if (!this.sharedAesKey) {
40-
throw new Error('Shared AES key not derived');
41-
}
42-
return this.sharedAesKey;
30+
this.aesKeyLocal = await window.crypto.subtle.generateKey(
31+
{ name: "AES-GCM", length: 256 },
32+
true,
33+
["encrypt", "decrypt"]
34+
);
4335
}
4436

45-
public async getRawAesKeyToExport(): Promise<string> {
46-
if (!this.ecdhPublicKey) {
47-
throw new Error('ECDH keys not generated');
37+
public async exportKey(): Promise<string> {
38+
if (!this.aesKeyLocal) {
39+
throw new Error('AES key not generated');
4840
}
49-
const jsonWebKey = await globalThis.crypto.subtle.exportKey("jwk", this.ecdhPublicKey);
41+
const jsonWebKey = await crypto.subtle.exportKey("jwk", this.aesKeyLocal);
5042
return JSON.stringify(jsonWebKey);
5143
}
5244

53-
/** Satisfies ISymmetricEncryption interface — delegates to getRawAesKeyToExport */
54-
public exportKey(): Promise<string> {
55-
return this.getRawAesKeyToExport();
56-
}
57-
58-
public async setRemoteAesKey(key: string): Promise<void> {
59-
if (!this.ecdhPrivateKey) {
60-
throw new Error('Local ECDH private key not generated');
61-
}
45+
public async importRemoteKey(key: string): Promise<void> {
6246
const jsonWebKey = JSON.parse(key);
63-
const remotePublicKey = await globalThis.crypto.subtle.importKey(
47+
this.aesKeyRemote = await crypto.subtle.importKey(
6448
"jwk",
6549
jsonWebKey,
66-
{ name: "X25519" },
50+
{ name: "AES-GCM" },
6751
true,
68-
[] // public keys don't require key usages for derivation
52+
["decrypt"]
6953
);
70-
71-
// Derive shared AES-GCM key — never leaves the device
72-
this.sharedAesKey = await globalThis.crypto.subtle.deriveKey(
73-
{ name: "X25519", public: remotePublicKey },
74-
this.ecdhPrivateKey,
75-
{ name: "AES-GCM", length: 256 },
76-
false, // AES key is never extractable/transmitted
77-
["encrypt", "decrypt"]
78-
);
79-
}
80-
81-
/** Satisfies ISymmetricEncryption interface — delegates to setRemoteAesKey */
82-
public importRemoteKey(key: string): Promise<void> {
83-
return this.setRemoteAesKey(key);
8454
}
8555

8656
public async encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array<ArrayBuffer>; iv: Uint8Array<ArrayBuffer> }> {
87-
if (!this.sharedAesKey) {
88-
throw new Error('Shared AES key not derived.');
57+
if (!this.aesKeyLocal) {
58+
throw new Error('Local AES key not generated.');
8959
}
90-
// Generate an Initialization Vector (IV) for AES-GCM (12 bytes)
91-
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
92-
// Encrypt the frame data using AES-GCM
93-
const encryptedData = await globalThis.crypto.subtle.encrypt(
60+
const iv = crypto.getRandomValues(new Uint8Array(12));
61+
const encryptedData = await crypto.subtle.encrypt(
9462
{ name: "AES-GCM", iv },
95-
this.sharedAesKey,
63+
this.aesKeyLocal,
9664
data
9765
);
9866
return { encryptedData: new Uint8Array(encryptedData), iv };
9967
}
10068

10169
public async decryptData(data: BufferSource, iv: BufferSource): Promise<ArrayBuffer> {
102-
if (!this.sharedAesKey) {
103-
throw new Error('Shared AES key not derived.');
70+
if (!this.aesKeyRemote) {
71+
throw new Error('Remote AES key not set.');
10472
}
105-
return globalThis.crypto.subtle.decrypt(
73+
return crypto.subtle.decrypt(
10674
{ name: "AES-GCM", iv },
107-
this.sharedAesKey,
75+
this.aesKeyRemote,
10876
data
10977
);
11078
}

0 commit comments

Comments
 (0)