Skip to content

Commit 090db3f

Browse files
muke1908claude
andcommitted
refactor(service): make encryption layer pluggable via strategy pattern
- Add ISymmetricEncryption and IAsymmetricEncryption interfaces - Rename AesGcmEncryption methods to algorithm-agnostic names (int→init, getRawAesKeyToExport→exportKey, setRemoteAesKey→importRemoteKey) - Add EncryptionStrategyFactory with a named registry; pre-registered with AES-GCM and RSA-OAEP as built-in defaults - Inject strategies into ChatE2EE via createChatInstance second arg; sdk.ts uses EncryptionFactory.create() for its own defaults - webrtc.ts accepts ISymmetricEncryption instead of concrete class - Export EncryptionFactory, EncryptionStrategyConfig, and both interfaces from the public API surface - Add integration tests: custom strategy call tracking, full SDK handshake protocol end-to-end via factory-created strategies - Update README with factory API, interface contracts, and examples Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6f569da commit 090db3f

8 files changed

Lines changed: 472 additions & 95 deletions

File tree

service/README.md

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,123 @@ setConfig({
8383
});
8484
```
8585

86-
### `createChatInstance(config?: Partial<ConfigType>): IChatE2EE`
87-
Factory function to create a new chat session instance. Accepts an optional config to set `baseUrl` and `settings` inline, as an alternative to calling `setConfig()` separately.
86+
### `createChatInstance(config?, encryptionStrategy?): IChatE2EE`
87+
Factory function to create a new chat session instance.
8888

89-
```javascript
90-
const chat = createChatInstance({
91-
baseUrl: 'https://your-api.example.com',
92-
settings: { disableLog: true },
93-
});
89+
| Parameter | Type | Description |
90+
| :--- | :--- | :--- |
91+
| `config` | `Partial<ConfigType>` | Optional. Sets `baseUrl` and `settings` inline. |
92+
| `encryptionStrategy` | `EncryptionStrategy` | Optional. Plug in custom symmetric / asymmetric ciphers. Use `EncryptionFactory.create()` to produce this value (see [Pluggable Encryption](#pluggable-encryption)). |
93+
94+
```typescript
95+
import { createChatInstance, EncryptionFactory } from '@chat-e2ee/service';
96+
97+
// Default — AES-256-GCM + RSA-OAEP
98+
const chat = createChatInstance({ baseUrl: 'https://your-api.example.com' });
99+
100+
// Explicit strategy via factory
101+
const chat = createChatInstance(config, EncryptionFactory.create({ symmetric: 'AES-GCM' }));
102+
```
103+
104+
---
105+
106+
## Pluggable Encryption
107+
108+
The SDK ships with two built-in ciphers:
109+
110+
| Layer | Default | Purpose |
111+
| :--- | :--- | :--- |
112+
| Asymmetric | `RSA-OAEP` (2048-bit, SHA-256) | Key-pair generation, message encryption, symmetric key wrapping |
113+
| Symmetric | `AES-GCM` (256-bit) | Frame-by-frame WebRTC audio/video encryption |
114+
115+
Both are swappable at construction time via `EncryptionFactory`.
116+
117+
### EncryptionFactory
118+
119+
`EncryptionFactory` is a registry-based singleton. Register a cipher once under a name, then reference it by that name anywhere.
120+
121+
#### Built-in strategies
122+
123+
| Name | Type |
124+
| :--- | :--- |
125+
| `'AES-GCM'` | symmetric |
126+
| `'RSA-OAEP'` | asymmetric |
127+
128+
#### `EncryptionFactory.create(config?)`
129+
130+
Returns an `EncryptionStrategy` ready to pass to `createChatInstance`. Omit either field to keep its built-in default.
131+
132+
```typescript
133+
// Both defaults
134+
EncryptionFactory.create()
135+
136+
// Override one layer, keep the other default
137+
EncryptionFactory.create({ symmetric: 'ChaCha20' })
138+
EncryptionFactory.create({ asymmetric: 'X25519' })
139+
140+
// Override both
141+
EncryptionFactory.create({ symmetric: 'ChaCha20', asymmetric: 'X25519' })
142+
```
143+
144+
Requesting an unregistered name throws immediately:
145+
> `Unknown symmetric strategy: "ChaCha20". Register it first with EncryptionFactory.registerSymmetric().`
146+
147+
#### `EncryptionFactory.registerSymmetric(name, factory)`
148+
#### `EncryptionFactory.registerAsymmetric(name, factory)`
149+
150+
Register a custom implementation under a name. Both methods return `this` for chaining.
151+
152+
```typescript
153+
EncryptionFactory
154+
.registerSymmetric('ChaCha20', () => new ChaCha20Encryption())
155+
.registerAsymmetric('X25519', () => new X25519Exchange());
156+
```
157+
158+
### Implementing a custom strategy
159+
160+
#### `ISymmetricEncryption`
161+
162+
```typescript
163+
import type { ISymmetricEncryption } from '@chat-e2ee/service';
164+
165+
class ChaCha20Encryption implements ISymmetricEncryption {
166+
async init(): Promise<void> { /* generate local key */ }
167+
async encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array<ArrayBuffer>; iv: Uint8Array<ArrayBuffer> }> { /**/ }
168+
async decryptData(data: BufferSource, iv: BufferSource): Promise<ArrayBuffer> { /**/ }
169+
async exportKey(): Promise<string> { /* serialise local key for transmission */ }
170+
async importRemoteKey(key: string): Promise<void> { /* import peer's key */ }
171+
}
172+
```
173+
174+
#### `IAsymmetricEncryption`
175+
176+
```typescript
177+
import type { IAsymmetricEncryption } from '@chat-e2ee/service';
178+
179+
class X25519Exchange implements IAsymmetricEncryption {
180+
async generateKeypairs(): Promise<{ privateKey: string; publicKey: string }> { /**/ }
181+
async encryptMessage(plaintext: string, publicKey: string): Promise<string> { /**/ }
182+
async decryptMessage(ciphertext: string, privateKey: string): Promise<string> { /**/ }
183+
}
184+
```
185+
186+
#### Full example
187+
188+
```typescript
189+
import { createChatInstance, EncryptionFactory } from '@chat-e2ee/service';
190+
191+
// 1. Register at app startup
192+
EncryptionFactory
193+
.registerSymmetric('ChaCha20', () => new ChaCha20Encryption())
194+
.registerAsymmetric('X25519', () => new X25519Exchange());
195+
196+
// 2. Use by name
197+
const chat = createChatInstance(config, EncryptionFactory.create({
198+
symmetric: 'ChaCha20',
199+
asymmetric: 'X25519',
200+
}));
201+
202+
await chat.init();
94203
```
95204

96205
---

service/src/crypto.test.ts

Lines changed: 172 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -73,27 +73,28 @@ describe('cryptoUtils (RSA-OAEP) – real Web Crypto', () => {
7373
// AES-GCM round-trip tests
7474
// ---------------------------------------------------------------------------
7575
describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => {
76-
it('int() generates a CryptoKey and returns it on subsequent calls', async () => {
76+
it('init() generates the key and is idempotent on subsequent calls', async () => {
7777
const aes = new AesGcmEncryption();
78-
const key1 = await aes.int();
79-
const key2 = await aes.int();
78+
await aes.init();
79+
await aes.init(); // Should not throw or regenerate
8080

81-
expect(key1).toBeDefined();
82-
// Second call should return the same cached instance
83-
expect(key1).toBe(key2);
81+
// Verify the key exists and is exportable
82+
const exported = await aes.exportKey();
83+
expect(typeof exported).toBe('string');
84+
expect(exported.length).toBeGreaterThan(0);
8485
});
8586

8687
it('encryptData() + decryptData() recover the original data', async () => {
8788
const aes = new AesGcmEncryption();
8889

8990
// Initialise the local key (used for encryption)
90-
await aes.int();
91+
await aes.init();
9192

9293
// Export the local key and re-import it as the "remote" key so that
9394
// decryptData() (which uses aesKeyRemote) can decrypt what encryptData()
9495
// produced.
95-
const exportedKey = await aes.getRawAesKeyToExport();
96-
await aes.setRemoteAesKey(exportedKey);
96+
const exportedKey = await aes.exportKey();
97+
await aes.importRemoteKey(exportedKey);
9798

9899
const originalText = 'AES test payload 🔒';
99100
const originalData = new TextEncoder().encode(originalText).buffer;
@@ -112,13 +113,13 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => {
112113

113114
it('decryptData() throws when remote key has not been set', async () => {
114115
const aes = new AesGcmEncryption();
115-
await aes.int();
116+
await aes.init();
116117

117118
const { encryptedData, iv } = await aes.encryptData(
118119
new TextEncoder().encode('data').buffer
119120
);
120121

121-
// No setRemoteAesKey() call → should throw
122+
// No importRemoteKey() call → should throw
122123
await expect(aes.decryptData(encryptedData, iv)).rejects.toThrow(
123124
'Remote AES key not set.'
124125
);
@@ -135,10 +136,10 @@ describe('AES key exchange via RSA-encrypted channel', () => {
135136

136137
// Simulate Alice (sender): generate an AES key
137138
const aliceAes = new AesGcmEncryption();
138-
await aliceAes.int();
139+
await aliceAes.init();
139140

140-
// Alice exports her AES key as a JWK string (this is what getRawAesKeyToExport returns)
141-
const aesKeyJwk = await aliceAes.getRawAesKeyToExport();
141+
// Alice exports her AES key as a JWK string
142+
const aesKeyJwk = await aliceAes.exportKey();
142143

143144
// Alice encrypts the AES key with Bob's RSA public key before sending it to the server
144145
const encryptedAesKey = await cryptoUtils.encryptMessage(aesKeyJwk, bobPublicKey);
@@ -151,7 +152,7 @@ describe('AES key exchange via RSA-encrypted channel', () => {
151152

152153
// Bob sets the decrypted AES key as his remote key
153154
const bobAes = new AesGcmEncryption();
154-
await bobAes.setRemoteAesKey(decryptedAesKeyJwk);
155+
await bobAes.importRemoteKey(decryptedAesKeyJwk);
155156

156157
// Alice encrypts some data with her local AES key
157158
const originalText = 'Secret message over AES-GCM 🔐';
@@ -171,8 +172,8 @@ describe('AES key exchange via RSA-encrypted channel', () => {
171172
const { privateKey: evePrivateKey } = await cryptoUtils.generateKeypairs(); // attacker's key
172173

173174
const aliceAes = new AesGcmEncryption();
174-
await aliceAes.int();
175-
const aesKeyJwk = await aliceAes.getRawAesKeyToExport();
175+
await aliceAes.init();
176+
const aesKeyJwk = await aliceAes.exportKey();
176177

177178
// Alice encrypts AES key with Bob's public key
178179
const encryptedAesKey = await cryptoUtils.encryptMessage(aesKeyJwk, bobPublicKey);
@@ -183,3 +184,157 @@ describe('AES key exchange via RSA-encrypted channel', () => {
183184
).rejects.toThrow();
184185
});
185186
});
187+
188+
// ---------------------------------------------------------------------------
189+
// EncryptionFactory
190+
// ---------------------------------------------------------------------------
191+
describe('EncryptionFactory', () => {
192+
// Import inside the describe so the window polyfill above is already set up
193+
const { EncryptionFactory } = require('./encryptionFactory');
194+
const { AesGcmEncryption } = require('./cryptoAES');
195+
196+
it('create() with no args returns an object with symmetric and asymmetric strategies', () => {
197+
const strategy = EncryptionFactory.create();
198+
expect(strategy.symmetric).toBeDefined();
199+
expect(strategy.asymmetric).toBeDefined();
200+
});
201+
202+
it('create() returns a fresh symmetric instance on each call (stateful key material)', () => {
203+
const a = EncryptionFactory.create();
204+
const b = EncryptionFactory.create();
205+
// Symmetric strategy holds key state — must be a distinct instance each time
206+
expect(a.symmetric).not.toBe(b.symmetric);
207+
});
208+
209+
it('create({ symmetric: "AES-GCM" }) returns an AesGcmEncryption instance', () => {
210+
const { symmetric } = EncryptionFactory.create({ symmetric: 'AES-GCM' });
211+
expect(symmetric).toBeInstanceOf(AesGcmEncryption);
212+
});
213+
214+
it('create() with an unknown symmetric name throws a descriptive error', () => {
215+
expect(() => EncryptionFactory.create({ symmetric: 'UNKNOWN' })).toThrow(
216+
'Unknown symmetric strategy: "UNKNOWN"'
217+
);
218+
});
219+
220+
it('create() with an unknown asymmetric name throws a descriptive error', () => {
221+
expect(() => EncryptionFactory.create({ asymmetric: 'UNKNOWN' })).toThrow(
222+
'Unknown asymmetric strategy: "UNKNOWN"'
223+
);
224+
});
225+
226+
it('registerSymmetric() makes a custom strategy available by name', async () => {
227+
const mockSymmetric = new AesGcmEncryption();
228+
EncryptionFactory.registerSymmetric('MOCK-SYM', () => mockSymmetric);
229+
230+
const { symmetric } = EncryptionFactory.create({ symmetric: 'MOCK-SYM' });
231+
expect(symmetric).toBe(mockSymmetric);
232+
});
233+
234+
it('registerAsymmetric() makes a custom strategy available by name', () => {
235+
const mockAsymmetric = { generateKeypairs: jest.fn(), encryptMessage: jest.fn(), decryptMessage: jest.fn() };
236+
EncryptionFactory.registerAsymmetric('MOCK-ASM', () => mockAsymmetric);
237+
238+
const { asymmetric } = EncryptionFactory.create({ asymmetric: 'MOCK-ASM' });
239+
expect(asymmetric).toBe(mockAsymmetric);
240+
});
241+
242+
it('registerSymmetric() supports chaining', () => {
243+
const result = EncryptionFactory.registerSymmetric('CHAIN-TEST', () => new AesGcmEncryption());
244+
expect(result).toBe(EncryptionFactory);
245+
});
246+
});
247+
248+
// ---------------------------------------------------------------------------
249+
// Pluggable encryption – integration
250+
// ---------------------------------------------------------------------------
251+
describe('Pluggable encryption (integration)', () => {
252+
const { EncryptionFactory } = require('./encryptionFactory');
253+
const { AesGcmEncryption } = require('./cryptoAES');
254+
const { cryptoUtils } = require('./cryptoRSA');
255+
256+
/**
257+
* Thin tracking wrapper: delegates every call to a real AesGcmEncryption
258+
* while recording which methods were invoked.
259+
*/
260+
class TrackingSymmetric {
261+
readonly calls: string[] = [];
262+
private inner = new AesGcmEncryption();
263+
264+
async init() { this.calls.push('init'); return this.inner.init(); }
265+
async encryptData(data: ArrayBuffer) { this.calls.push('encryptData'); return this.inner.encryptData(data); }
266+
async decryptData(data: BufferSource, iv: BufferSource) { this.calls.push('decryptData'); return this.inner.decryptData(data, iv); }
267+
async exportKey() { this.calls.push('exportKey'); return this.inner.exportKey(); }
268+
async importRemoteKey(key: string) { this.calls.push('importRemoteKey'); return this.inner.importRemoteKey(key); }
269+
}
270+
271+
it('custom symmetric strategy is called through the factory', async () => {
272+
const impl = new TrackingSymmetric();
273+
EncryptionFactory.registerSymmetric('TRACKING-SYM', () => impl);
274+
275+
const { symmetric } = EncryptionFactory.create({ symmetric: 'TRACKING-SYM' });
276+
277+
await symmetric.init();
278+
const key = await symmetric.exportKey();
279+
await symmetric.importRemoteKey(key);
280+
const { encryptedData, iv } = await symmetric.encryptData(new TextEncoder().encode('test').buffer);
281+
await symmetric.decryptData(encryptedData, iv);
282+
283+
expect(impl.calls).toEqual(['init', 'exportKey', 'importRemoteKey', 'encryptData', 'decryptData']);
284+
});
285+
286+
it('custom asymmetric strategy is called through the factory', async () => {
287+
const calls: string[] = [];
288+
const impl = {
289+
generateKeypairs: async () => { calls.push('generateKeypairs'); return cryptoUtils.generateKeypairs(); },
290+
encryptMessage: async (p: string, k: string) => { calls.push('encryptMessage'); return cryptoUtils.encryptMessage(p, k); },
291+
decryptMessage: async (c: string, k: string) => { calls.push('decryptMessage'); return cryptoUtils.decryptMessage(c, k); },
292+
};
293+
EncryptionFactory.registerAsymmetric('TRACKING-ASM', () => impl);
294+
295+
const { asymmetric } = EncryptionFactory.create({ asymmetric: 'TRACKING-ASM' });
296+
297+
const { publicKey, privateKey } = await asymmetric.generateKeypairs();
298+
const ciphertext = await asymmetric.encryptMessage('hello', publicKey);
299+
const recovered = await asymmetric.decryptMessage(ciphertext, privateKey);
300+
301+
expect(recovered).toBe('hello');
302+
expect(calls).toEqual(['generateKeypairs', 'encryptMessage', 'decryptMessage']);
303+
});
304+
305+
it('full SDK handshake protocol works end-to-end with factory-created strategies', async () => {
306+
// Mirrors exactly what sdk.ts does internally during setChannel()
307+
const aliceStrategy = EncryptionFactory.create();
308+
const bobStrategy = EncryptionFactory.create();
309+
310+
// Both peers initialise their symmetric keys
311+
await aliceStrategy.symmetric.init();
312+
await bobStrategy.symmetric.init();
313+
314+
// Both peers generate asymmetric key pairs
315+
const { publicKey: alicePub, privateKey: alicePriv } = await aliceStrategy.asymmetric.generateKeypairs();
316+
const { publicKey: bobPub, privateKey: bobPriv } = await bobStrategy.asymmetric.generateKeypairs();
317+
318+
// Alice wraps her symmetric key with Bob's public key and "sends" it
319+
const aliceExportedKey = await aliceStrategy.symmetric.exportKey();
320+
const wrappedKey = await aliceStrategy.asymmetric.encryptMessage(aliceExportedKey, bobPub);
321+
322+
// Bob unwraps it with his private key and imports it as the remote key
323+
const unwrappedKey = await bobStrategy.asymmetric.decryptMessage(wrappedKey, bobPriv);
324+
await bobStrategy.symmetric.importRemoteKey(unwrappedKey);
325+
326+
// Alice encrypts a message; Bob decrypts it
327+
const plaintext = 'end-to-end via pluggable factory';
328+
const { encryptedData, iv } = await aliceStrategy.symmetric.encryptData(
329+
new TextEncoder().encode(plaintext).buffer
330+
);
331+
const decrypted = await bobStrategy.symmetric.decryptData(encryptedData, iv);
332+
333+
expect(new TextDecoder().decode(decrypted)).toBe(plaintext);
334+
335+
// Confirm the asymmetric keys are independent (no cross-contamination)
336+
await expect(
337+
aliceStrategy.asymmetric.decryptMessage(wrappedKey, alicePriv)
338+
).rejects.toThrow();
339+
});
340+
});

0 commit comments

Comments
 (0)