diff --git a/package-lock.json b/package-lock.json index 1b5d55b..b9a9322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3070,12 +3070,6 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4082,21 +4076,6 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.28", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.28.tgz", @@ -4922,38 +4901,6 @@ "node": ">=16.0.0" } }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5286,6 +5233,28 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5726,24 +5695,6 @@ "node": ">=8" } }, - "node_modules/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", - "dev": true, - "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -7324,6 +7275,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -8221,10 +8179,11 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -8971,19 +8930,20 @@ } }, "node_modules/ts-jest": { - "version": "29.2.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", - "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", "dev": true, + "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", - "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", - "jest-util": "^29.0.0", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.6.3", + "semver": "^7.7.4", + "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "bin": { @@ -8994,11 +8954,12 @@ }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", - "typescript": ">=4.3 <6" + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { @@ -9015,9 +8976,25 @@ }, "esbuild": { "optional": true + }, + "jest-util": { + "optional": true } } }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ts-jest/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -9134,6 +9111,20 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -9786,6 +9777,13 @@ "node": ">= 8" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -9941,6 +9939,7 @@ "@types/node": "^25.0.3", "esbuild": "^0.27.2", "jest": "29.7.0", + "ts-jest": "^29.4.9", "typescript": "^5.9.3" } }, diff --git a/service/README.md b/service/README.md index c847e35..4d6bd48 100644 --- a/service/README.md +++ b/service/README.md @@ -83,123 +83,66 @@ setConfig({ }); ``` -### `createChatInstance(config?, encryptionStrategy?): IChatE2EE` -Factory function to create a new chat session instance. +### `createChatInstance(config?: Partial): IChatE2EE` +Factory function to create a new chat session instance. Accepts an optional config to set `baseUrl`, `settings`, and a custom `encryptionProtocol` inline. -| Parameter | Type | Description | -| :--- | :--- | :--- | -| `config` | `Partial` | Optional. Sets `baseUrl` and `settings` inline. | -| `encryptionStrategy` | `EncryptionStrategy` | Optional. Plug in custom symmetric / asymmetric ciphers. Use `EncryptionFactory.create()` to produce this value (see [Pluggable Encryption](#pluggable-encryption)). | - -```typescript -import { createChatInstance, EncryptionFactory } from '@chat-e2ee/service'; - -// Default — AES-256-GCM + RSA-OAEP -const chat = createChatInstance({ baseUrl: 'https://your-api.example.com' }); +Custom encryption protocol allows you to implement your own symmetric encryption for WebRTC media streams. The interface `ISymmetricEncryptionProtocol` requires implementing `init()`, `getRemoteAesKey()`, `getRawAesKeyToExport()`, `setRemoteAesKey()`, `encryptData()`, and `decryptData()`. If omitted, `AesGcmEncryption` (AES-GCM 256 with ECDH X25519 key exchange) is used by default. -// Explicit strategy via factory -const chat = createChatInstance(config, EncryptionFactory.create({ symmetric: 'AES-GCM' })); +```javascript +import { createChatInstance, AesGcmEncryption } from '@chat-e2ee/service'; +const chat = createChatInstance({ + baseUrl: 'https://your-api.example.com', + settings: { disableLog: true }, + encryptionProtocol: new AesGcmEncryption(), // optional custom implementation +}); ``` --- ## Pluggable Encryption -The SDK ships with two built-in ciphers: - -| Layer | Default | Purpose | -| :--- | :--- | :--- | -| Asymmetric | `RSA-OAEP` (2048-bit, SHA-256) | Key-pair generation, message encryption, symmetric key wrapping | -| Symmetric | `AES-GCM` (256-bit) | Frame-by-frame WebRTC audio/video encryption | - -Both are swappable at construction time via `EncryptionFactory`. - -### EncryptionFactory - -`EncryptionFactory` is a registry-based singleton. Register a cipher once under a name, then reference it by that name anywhere. - -#### Built-in strategies - -| Name | Type | -| :--- | :--- | -| `'AES-GCM'` | symmetric | -| `'RSA-OAEP'` | asymmetric | - -#### `EncryptionFactory.create(config?)` - -Returns an `EncryptionStrategy` ready to pass to `createChatInstance`. Omit either field to keep its built-in default. - -```typescript -// Both defaults -EncryptionFactory.create() - -// Override one layer, keep the other default -EncryptionFactory.create({ symmetric: 'ChaCha20' }) -EncryptionFactory.create({ asymmetric: 'X25519' }) +The SDK supports pluggable symmetric encryption strategies via the `EncryptionFactory`. This allows you to swap the underlying encryption logic used for WebRTC media streams. -// Override both -EncryptionFactory.create({ symmetric: 'ChaCha20', asymmetric: 'X25519' }) -``` - -Requesting an unregistered name throws immediately: -> `Unknown symmetric strategy: "ChaCha20". Register it first with EncryptionFactory.registerSymmetric().` +### Available Strategies -#### `EncryptionFactory.registerSymmetric(name, factory)` -#### `EncryptionFactory.registerAsymmetric(name, factory)` +- **`AES-GCM`** (Default): Standard AES-256-GCM encryption where the key is generated locally and shared via the signaling channel. +- **`ECDH-X25519`**: Uses Ephemeral X25519 ECDH to derive a shared AES-256-GCM key. The secret key material never leaves the device. -Register a custom implementation under a name. Both methods return `this` for chaining. +### Switching Strategies -```typescript -EncryptionFactory - .registerSymmetric('ChaCha20', () => new ChaCha20Encryption()) - .registerAsymmetric('X25519', () => new X25519Exchange()); -``` +You can specify the encryption strategy when creating a chat instance: -### Implementing a custom strategy +```javascript +import { createChatInstance, EncryptionFactory } from '@chat-e2ee/service'; -#### `ISymmetricEncryption` +// Use the high-security ECDH-X25519 strategy +const strategy = EncryptionFactory.create({ symmetric: 'ECDH-X25519' }); -```typescript -import type { ISymmetricEncryption } from '@chat-e2ee/service'; - -class ChaCha20Encryption implements ISymmetricEncryption { - async init(): Promise { /* generate local key */ } - async encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array; iv: Uint8Array }> { /* … */ } - async decryptData(data: BufferSource, iv: BufferSource): Promise { /* … */ } - async exportKey(): Promise { /* serialise local key for transmission */ } - async importRemoteKey(key: string): Promise { /* import peer's key */ } -} +const chat = createChatInstance({ + encryptionProtocol: strategy.symmetric +}); ``` -#### `IAsymmetricEncryption` +### Registering Custom Strategies -```typescript -import type { IAsymmetricEncryption } from '@chat-e2ee/service'; +You can register your own implementation by implementing the `ISymmetricEncryption` interface: -class X25519Exchange implements IAsymmetricEncryption { - async generateKeypairs(): Promise<{ privateKey: string; publicKey: string }> { /* … */ } - async encryptMessage(plaintext: string, publicKey: string): Promise { /* … */ } - async decryptMessage(ciphertext: string, privateKey: string): Promise { /* … */ } +```javascript +import { EncryptionFactory } from '@chat-e2ee/service'; + +class MySymmetricCipher { + async init() { ... } + async exportKey() { ... } + async importRemoteKey(key) { ... } + async encryptData(data) { ... } + async decryptData(data, iv) { ... } } -``` - -#### Full example - -```typescript -import { createChatInstance, EncryptionFactory } from '@chat-e2ee/service'; -// 1. Register at app startup -EncryptionFactory - .registerSymmetric('ChaCha20', () => new ChaCha20Encryption()) - .registerAsymmetric('X25519', () => new X25519Exchange()); +EncryptionFactory.registerSymmetric('MY-CIPHER', () => new MySymmetricCipher()); -// 2. Use by name -const chat = createChatInstance(config, EncryptionFactory.create({ - symmetric: 'ChaCha20', - asymmetric: 'X25519', -})); - -await chat.init(); +const chat = createChatInstance({ + encryptionProtocol: EncryptionFactory.create({ symmetric: 'MY-CIPHER' }).symmetric +}); ``` --- @@ -208,11 +151,10 @@ await chat.init(); #### `await init(): Promise` Initializes the instance: -- Generates RSA and AES key pairs. +- Generates RSA key pairs for message encryption. +- Generates ECDH key pairs for WebRTC encryption. - Establishes the socket connection. - Sets up WebRTC listeners. - -#### `await getLink(): Promise` Requests a new channel link from the server. Returns an object containing `hash`, `link`, `absoluteLink`, `pin`, etc. diff --git a/service/package.json b/service/package.json index 672dbcc..cca6ce7 100644 --- a/service/package.json +++ b/service/package.json @@ -14,6 +14,7 @@ "@types/node": "^25.0.3", "esbuild": "^0.27.2", "jest": "29.7.0", + "ts-jest": "^29.4.9", "typescript": "^5.9.3" }, "scripts": { @@ -24,4 +25,4 @@ "dependencies": { "socket.io-client": "^4.8.0" } -} \ No newline at end of file +} diff --git a/service/src/crypto.test.ts b/service/src/crypto.test.ts index c5d2f8e..b3d6541 100644 --- a/service/src/crypto.test.ts +++ b/service/src/crypto.test.ts @@ -8,7 +8,7 @@ * without any mocking. */ -import { webcrypto } from 'crypto'; +import { webcrypto } from 'node:crypto'; // Polyfill for Node versions < 19 that do not expose globalThis.crypto if (!globalThis.crypto) { @@ -18,7 +18,7 @@ if (!globalThis.crypto) { // cryptoRSA.ts accesses `window.crypto`, `window.btoa`, and `window.atob`. // In a Node (non-jsdom) environment `window` is undefined, so we point it at // globalThis which already has btoa/atob (Node 16+) and crypto (Node 19+). -if (typeof window === 'undefined') { +if (globalThis.window === undefined) { (globalThis as any).window = globalThis; } @@ -73,15 +73,16 @@ describe('cryptoUtils (RSA-OAEP) – real Web Crypto', () => { // AES-GCM round-trip tests // --------------------------------------------------------------------------- describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => { - it('init() generates the key and is idempotent on subsequent calls', async () => { + it('init() generates ECDH key pair and is idempotent on subsequent calls', async () => { const aes = new AesGcmEncryption(); await aes.init(); - await aes.init(); // Should not throw or regenerate + const key1Export = await aes.exportKey(); + await aes.init(); + const key2Export = await aes.exportKey(); - // Verify the key exists and is exportable - const exported = await aes.exportKey(); - expect(typeof exported).toBe('string'); - expect(exported.length).toBeGreaterThan(0); + expect(key1Export).toBeDefined(); + // Second call should return the same cached instance + expect(key1Export).toBe(key2Export); }); it('encryptData() + decryptData() recover the original data', async () => { @@ -90,9 +91,7 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => { // Initialise the local key (used for encryption) await aes.init(); - // Export the local key and re-import it as the "remote" key so that - // decryptData() (which uses aesKeyRemote) can decrypt what encryptData() - // produced. + // Export the local ECDH public key and set it as the "remote" key to derive shared key for loopback test const exportedKey = await aes.exportKey(); await aes.importRemoteKey(exportedKey); @@ -111,230 +110,52 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => { expect(decryptedText).toBe(originalText); }); - it('decryptData() throws when remote key has not been set', async () => { + it('encryptData() and decryptData() throw before initialisation/key import', async () => { const aes = new AesGcmEncryption(); - await aes.init(); - const { encryptedData, iv } = await aes.encryptData( + // No init() call → should throw for encrypt + await expect(aes.encryptData( new TextEncoder().encode('data').buffer - ); + )).rejects.toThrow('Local AES key not generated.'); + + await aes.init(); - // No importRemoteKey() call → should throw - await expect(aes.decryptData(encryptedData, iv)).rejects.toThrow( + // No importRemoteKey() call → should throw for decrypt + await expect(aes.decryptData(new ArrayBuffer(10), new Uint8Array(12))).rejects.toThrow( 'Remote AES key not set.' ); }); }); // --------------------------------------------------------------------------- -// AES key exchange via RSA-encrypted channel +// ECDH symmetric key exchange // --------------------------------------------------------------------------- -describe('AES key exchange via RSA-encrypted channel', () => { - it('AES key exported by sender can be RSA-encrypted and decrypted by receiver, enabling symmetric decryption', async () => { - // Simulate Bob (receiver): generate an RSA key pair - const { publicKey: bobPublicKey, privateKey: bobPrivateKey } = await cryptoUtils.generateKeypairs(); +describe('ECDH symmetric key exchange', () => { + it('ECDH public key exported by sender can be used by receiver to derive same AES key', async () => { + // Simulate Bob (receiver) + const bobAes = new AesGcmEncryption(); + await bobAes.init(); + const bobEcdhKeyJwk = await bobAes.exportKey(); - // Simulate Alice (sender): generate an AES key + // Simulate Alice (sender) const aliceAes = new AesGcmEncryption(); await aliceAes.init(); + const aliceEcdhKeyJwk = await aliceAes.exportKey(); - // Alice exports her AES key as a JWK string - const aesKeyJwk = await aliceAes.exportKey(); - - // Alice encrypts the AES key with Bob's RSA public key before sending it to the server - const encryptedAesKey = await cryptoUtils.encryptMessage(aesKeyJwk, bobPublicKey); - expect(typeof encryptedAesKey).toBe('string'); - expect(encryptedAesKey).not.toBe(aesKeyJwk); // must be ciphertext, not plaintext - - // Bob decrypts the AES key using his RSA private key - const decryptedAesKeyJwk = await cryptoUtils.decryptMessage(encryptedAesKey, bobPrivateKey); - expect(decryptedAesKeyJwk).toBe(aesKeyJwk); // recovered plaintext must match original + // Exchange keys (in plaintext over the wire) + await aliceAes.importRemoteKey(bobEcdhKeyJwk); + await bobAes.importRemoteKey(aliceEcdhKeyJwk); - // Bob sets the decrypted AES key as his remote key - const bobAes = new AesGcmEncryption(); - await bobAes.importRemoteKey(decryptedAesKeyJwk); - - // Alice encrypts some data with her local AES key - const originalText = 'Secret message over AES-GCM 🔐'; + // Alice encrypts some data with her derived AES key + const originalText = 'Secret message over ECDH derived AES-GCM 🔐'; const { encryptedData, iv } = await aliceAes.encryptData( new TextEncoder().encode(originalText).buffer ); - // Bob decrypts the data using the AES key he received through the RSA-encrypted channel + // Bob decrypts the data using his derived AES key const decryptedBuffer = await bobAes.decryptData(encryptedData, iv); const decryptedText = new TextDecoder().decode(decryptedBuffer); expect(decryptedText).toBe(originalText); }); - - it('a third party cannot decrypt the AES key without the receiver private key', async () => { - const { publicKey: bobPublicKey } = await cryptoUtils.generateKeypairs(); - const { privateKey: evePrivateKey } = await cryptoUtils.generateKeypairs(); // attacker's key - - const aliceAes = new AesGcmEncryption(); - await aliceAes.init(); - const aesKeyJwk = await aliceAes.exportKey(); - - // Alice encrypts AES key with Bob's public key - const encryptedAesKey = await cryptoUtils.encryptMessage(aesKeyJwk, bobPublicKey); - - // Eve (third party) tries to decrypt with her own private key and fails - await expect( - cryptoUtils.decryptMessage(encryptedAesKey, evePrivateKey) - ).rejects.toThrow(); - }); -}); - -// --------------------------------------------------------------------------- -// EncryptionFactory -// --------------------------------------------------------------------------- -describe('EncryptionFactory', () => { - // Import inside the describe so the window polyfill above is already set up - const { EncryptionFactory } = require('./encryptionFactory'); - const { AesGcmEncryption } = require('./cryptoAES'); - - it('create() with no args returns an object with symmetric and asymmetric strategies', () => { - const strategy = EncryptionFactory.create(); - expect(strategy.symmetric).toBeDefined(); - expect(strategy.asymmetric).toBeDefined(); - }); - - it('create() returns a fresh symmetric instance on each call (stateful key material)', () => { - const a = EncryptionFactory.create(); - const b = EncryptionFactory.create(); - // Symmetric strategy holds key state — must be a distinct instance each time - expect(a.symmetric).not.toBe(b.symmetric); - }); - - it('create({ symmetric: "AES-GCM" }) returns an AesGcmEncryption instance', () => { - const { symmetric } = EncryptionFactory.create({ symmetric: 'AES-GCM' }); - expect(symmetric).toBeInstanceOf(AesGcmEncryption); - }); - - it('create() with an unknown symmetric name throws a descriptive error', () => { - expect(() => EncryptionFactory.create({ symmetric: 'UNKNOWN' })).toThrow( - 'Unknown symmetric strategy: "UNKNOWN"' - ); - }); - - it('create() with an unknown asymmetric name throws a descriptive error', () => { - expect(() => EncryptionFactory.create({ asymmetric: 'UNKNOWN' })).toThrow( - 'Unknown asymmetric strategy: "UNKNOWN"' - ); - }); - - it('registerSymmetric() makes a custom strategy available by name', async () => { - const mockSymmetric = new AesGcmEncryption(); - EncryptionFactory.registerSymmetric('MOCK-SYM', () => mockSymmetric); - - const { symmetric } = EncryptionFactory.create({ symmetric: 'MOCK-SYM' }); - expect(symmetric).toBe(mockSymmetric); - }); - - it('registerAsymmetric() makes a custom strategy available by name', () => { - const mockAsymmetric = { generateKeypairs: jest.fn(), encryptMessage: jest.fn(), decryptMessage: jest.fn() }; - EncryptionFactory.registerAsymmetric('MOCK-ASM', () => mockAsymmetric); - - const { asymmetric } = EncryptionFactory.create({ asymmetric: 'MOCK-ASM' }); - expect(asymmetric).toBe(mockAsymmetric); - }); - - it('registerSymmetric() supports chaining', () => { - const result = EncryptionFactory.registerSymmetric('CHAIN-TEST', () => new AesGcmEncryption()); - expect(result).toBe(EncryptionFactory); - }); -}); - -// --------------------------------------------------------------------------- -// Pluggable encryption – integration -// --------------------------------------------------------------------------- -describe('Pluggable encryption (integration)', () => { - const { EncryptionFactory } = require('./encryptionFactory'); - const { AesGcmEncryption } = require('./cryptoAES'); - const { cryptoUtils } = require('./cryptoRSA'); - - /** - * Thin tracking wrapper: delegates every call to a real AesGcmEncryption - * while recording which methods were invoked. - */ - class TrackingSymmetric { - readonly calls: string[] = []; - private inner = new AesGcmEncryption(); - - async init() { this.calls.push('init'); return this.inner.init(); } - async encryptData(data: ArrayBuffer) { this.calls.push('encryptData'); return this.inner.encryptData(data); } - async decryptData(data: BufferSource, iv: BufferSource) { this.calls.push('decryptData'); return this.inner.decryptData(data, iv); } - async exportKey() { this.calls.push('exportKey'); return this.inner.exportKey(); } - async importRemoteKey(key: string) { this.calls.push('importRemoteKey'); return this.inner.importRemoteKey(key); } - } - - it('custom symmetric strategy is called through the factory', async () => { - const impl = new TrackingSymmetric(); - EncryptionFactory.registerSymmetric('TRACKING-SYM', () => impl); - - const { symmetric } = EncryptionFactory.create({ symmetric: 'TRACKING-SYM' }); - - await symmetric.init(); - const key = await symmetric.exportKey(); - await symmetric.importRemoteKey(key); - const { encryptedData, iv } = await symmetric.encryptData(new TextEncoder().encode('test').buffer); - await symmetric.decryptData(encryptedData, iv); - - expect(impl.calls).toEqual(['init', 'exportKey', 'importRemoteKey', 'encryptData', 'decryptData']); - }); - - it('custom asymmetric strategy is called through the factory', async () => { - const calls: string[] = []; - const impl = { - generateKeypairs: async () => { calls.push('generateKeypairs'); return cryptoUtils.generateKeypairs(); }, - encryptMessage: async (p: string, k: string) => { calls.push('encryptMessage'); return cryptoUtils.encryptMessage(p, k); }, - decryptMessage: async (c: string, k: string) => { calls.push('decryptMessage'); return cryptoUtils.decryptMessage(c, k); }, - }; - EncryptionFactory.registerAsymmetric('TRACKING-ASM', () => impl); - - const { asymmetric } = EncryptionFactory.create({ asymmetric: 'TRACKING-ASM' }); - - const { publicKey, privateKey } = await asymmetric.generateKeypairs(); - const ciphertext = await asymmetric.encryptMessage('hello', publicKey); - const recovered = await asymmetric.decryptMessage(ciphertext, privateKey); - - expect(recovered).toBe('hello'); - expect(calls).toEqual(['generateKeypairs', 'encryptMessage', 'decryptMessage']); - }); - - it('full SDK handshake protocol works end-to-end with factory-created strategies', async () => { - // Mirrors exactly what sdk.ts does internally during setChannel() - const aliceStrategy = EncryptionFactory.create(); - const bobStrategy = EncryptionFactory.create(); - - // Both peers initialise their symmetric keys - await aliceStrategy.symmetric.init(); - await bobStrategy.symmetric.init(); - - // Both peers generate asymmetric key pairs - const { publicKey: alicePub, privateKey: alicePriv } = await aliceStrategy.asymmetric.generateKeypairs(); - const { publicKey: bobPub, privateKey: bobPriv } = await bobStrategy.asymmetric.generateKeypairs(); - - // Alice wraps her symmetric key with Bob's public key and "sends" it - const aliceExportedKey = await aliceStrategy.symmetric.exportKey(); - const wrappedKey = await aliceStrategy.asymmetric.encryptMessage(aliceExportedKey, bobPub); - - // Bob unwraps it with his private key and imports it as the remote key - const unwrappedKey = await bobStrategy.asymmetric.decryptMessage(wrappedKey, bobPriv); - await bobStrategy.symmetric.importRemoteKey(unwrappedKey); - - // Alice encrypts a message; Bob decrypts it - const plaintext = 'end-to-end via pluggable factory'; - const { encryptedData, iv } = await aliceStrategy.symmetric.encryptData( - new TextEncoder().encode(plaintext).buffer - ); - const decrypted = await bobStrategy.symmetric.decryptData(encryptedData, iv); - - expect(new TextDecoder().decode(decrypted)).toBe(plaintext); - - // Confirm the asymmetric keys are independent (no cross-contamination) - await expect( - aliceStrategy.asymmetric.decryptMessage(wrappedKey, alicePriv) - ).rejects.toThrow(); - }); }); diff --git a/service/src/cryptoecdh.test.ts b/service/src/cryptoecdh.test.ts new file mode 100644 index 0000000..b681669 --- /dev/null +++ b/service/src/cryptoecdh.test.ts @@ -0,0 +1,197 @@ +/** + * Tests for ECDHEncryption — X25519 ECDH key exchange + AES-256-GCM. + * + * Polyfills mirror the setup in crypto.test.ts so tests run correctly + * in both Node.js and browser environments. + */ + +import { webcrypto } from 'node:crypto'; + +if (!globalThis.crypto) { + (globalThis as any).crypto = webcrypto; +} + +if (globalThis.window === undefined) { + (globalThis as any).window = globalThis; +} + +import { ECDHEncryption } from './cryptoecdh'; +import { EncryptionFactory } from './encryptionFactory'; + +// --------------------------------------------------------------------------- +// ECDHEncryption unit tests +// --------------------------------------------------------------------------- +describe('ECDHEncryption (ECDH X25519 + AES-256-GCM) – real Web Crypto', () => { + + it('init() generates a key pair and is idempotent on subsequent calls', async () => { + const ecdh = new ECDHEncryption(); + await ecdh.init(); + await ecdh.init(); // second call should be a no-op, not throw + }); + + it('exportKey() returns a non-empty JWK string after init()', async () => { + const ecdh = new ECDHEncryption(); + await ecdh.init(); + + const exported = await ecdh.exportKey(); + expect(typeof exported).toBe('string'); + expect(exported.length).toBeGreaterThan(0); + + // Must be valid JSON (JWK) + const parsed = JSON.parse(exported); + expect(parsed.kty).toBe('OKP'); // Octet Key Pair — X25519 + expect(parsed.crv).toBe('X25519'); + expect(parsed.d).toBeUndefined(); // Public key only — no private material + }); + + it('exportKey() throws before init()', async () => { + const ecdh = new ECDHEncryption(); + await expect(ecdh.exportKey()).rejects.toThrow('init()'); + }); + + it('encryptData() throws before importRemoteKey()', async () => { + const ecdh = new ECDHEncryption(); + await ecdh.init(); + const data = new TextEncoder().encode('test').buffer; + await expect(ecdh.encryptData(data)).rejects.toThrow('importRemoteKey()'); + }); + + it('decryptData() throws before importRemoteKey()', async () => { + const ecdh = new ECDHEncryption(); + await ecdh.init(); + const dummy = new Uint8Array(12); + await expect(ecdh.decryptData(dummy, dummy)).rejects.toThrow('importRemoteKey()'); + }); + + it('Alice and Bob derive the same shared key and can encrypt/decrypt', async () => { + const alice = new ECDHEncryption(); + const bob = new ECDHEncryption(); + + await alice.init(); + await bob.init(); + + // Exchange public keys + const alicePub = await alice.exportKey(); + const bobPub = await bob.exportKey(); + + await alice.importRemoteKey(bobPub); + await bob.importRemoteKey(alicePub); + + // Alice encrypts → Bob decrypts + const plaintext = 'Hello from Alice!'; + const { encryptedData, iv } = await alice.encryptData( + new TextEncoder().encode(plaintext).buffer + ); + const decrypted = await bob.decryptData(encryptedData, iv); + expect(new TextDecoder().decode(decrypted)).toBe(plaintext); + }); + + it('Bob encrypts → Alice decrypts (bidirectional)', async () => { + const alice = new ECDHEncryption(); + const bob = new ECDHEncryption(); + + await alice.init(); + await bob.init(); + + const alicePub = await alice.exportKey(); + const bobPub = await bob.exportKey(); + + await alice.importRemoteKey(bobPub); + await bob.importRemoteKey(alicePub); + + const plaintext = 'Hello from Bob!'; + const { encryptedData, iv } = await bob.encryptData( + new TextEncoder().encode(plaintext).buffer + ); + const decrypted = await alice.decryptData(encryptedData, iv); + expect(new TextDecoder().decode(decrypted)).toBe(plaintext); + }); + + it('different sessions produce different public keys', async () => { + const session1 = new ECDHEncryption(); + const session2 = new ECDHEncryption(); + + await session1.init(); + await session2.init(); + + const key1 = await session1.exportKey(); + const key2 = await session2.exportKey(); + + expect(key1).not.toBe(key2); + }); + + it('each encryptData() call produces a unique IV', async () => { + const alice = new ECDHEncryption(); + const bob = new ECDHEncryption(); + + await alice.init(); + await bob.init(); + + await alice.importRemoteKey(await bob.exportKey()); + + const data = new TextEncoder().encode('same message').buffer; + const result1 = await alice.encryptData(data); + const result2 = await alice.encryptData(data); + + // IVs must differ — same IV reuse with AES-GCM breaks security + expect(Buffer.from(result1.iv).toString('hex')) + .not.toBe(Buffer.from(result2.iv).toString('hex')); + }); + + it('tampered ciphertext fails decryption (GCM auth tag check)', async () => { + const alice = new ECDHEncryption(); + const bob = new ECDHEncryption(); + + await alice.init(); + await bob.init(); + + await alice.importRemoteKey(await bob.exportKey()); + await bob.importRemoteKey(await alice.exportKey()); + + const { encryptedData, iv } = await alice.encryptData( + new TextEncoder().encode('secret').buffer + ); + + // Flip one byte to simulate tampering + encryptedData[0] ^= 0xff; + + await expect(bob.decryptData(encryptedData, iv)).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// EncryptionFactory integration +// --------------------------------------------------------------------------- +describe('ECDHEncryption – EncryptionFactory integration', () => { + + beforeAll(() => { + EncryptionFactory.registerSymmetric('ECDH-X25519', () => new ECDHEncryption()); + }); + + it('registers and resolves via EncryptionFactory', () => { + const strategy = EncryptionFactory.create({ symmetric: 'ECDH-X25519' }); + expect(strategy.symmetric).toBeInstanceOf(ECDHEncryption); + }); + + it('full ECDH handshake works through EncryptionFactory', async () => { + const aliceStrategy = EncryptionFactory.create({ symmetric: 'ECDH-X25519' }); + const bobStrategy = EncryptionFactory.create({ symmetric: 'ECDH-X25519' }); + + await aliceStrategy.symmetric.init(); + await bobStrategy.symmetric.init(); + + const alicePub = await aliceStrategy.symmetric.exportKey(); + const bobPub = await bobStrategy.symmetric.exportKey(); + + await aliceStrategy.symmetric.importRemoteKey(bobPub); + await bobStrategy.symmetric.importRemoteKey(alicePub); + + const plaintext = 'end-to-end via ECDH factory'; + const { encryptedData, iv } = await aliceStrategy.symmetric.encryptData( + new TextEncoder().encode(plaintext).buffer + ); + const decrypted = await bobStrategy.symmetric.decryptData(encryptedData, iv); + + expect(new TextDecoder().decode(decrypted)).toBe(plaintext); + }); +}); diff --git a/service/src/cryptoecdh.ts b/service/src/cryptoecdh.ts new file mode 100644 index 0000000..b1ec979 --- /dev/null +++ b/service/src/cryptoecdh.ts @@ -0,0 +1,118 @@ +import { type ISymmetricEncryption } from './cryptoAES'; + +/** + * ECDH X25519 + AES-256-GCM implementation of ISymmetricEncryption. + * + * Security model: + * - Each peer generates an ephemeral X25519 key pair on init(). + * - Peers exchange ONLY their public keys (safe to transmit in plaintext). + * - Both peers independently derive the IDENTICAL shared AES-256-GCM key + * via ECDH — the secret key material NEVER leaves the device. + * - A fresh 12-byte IV is generated per encryptData() call, eliminating + * replay attacks. + * - The derived AES key is marked non-extractable — it cannot be exported. + * + * Fixes raised in issue #403: + * - Replaces plaintext / RSA-wrapped AES key transmission (item 6). + * - Provides per-session ephemeral keys as a step toward forward secrecy (item 8). + * + * Usage (pluggable via EncryptionFactory): + * import { EncryptionFactory } from '@chat-e2ee/service'; + * import { ECDHEncryption } from './cryptoECDH'; + * + * EncryptionFactory.registerSymmetric('ECDH-X25519', () => new ECDHEncryption()); + * + * const chat = createChatInstance({}, EncryptionFactory.create({ symmetric: 'ECDH-X25519' })); + */ +export class ECDHEncryption implements ISymmetricEncryption { + private localKeyPair?: CryptoKeyPair; + private sharedAesKey?: CryptoKey; + + /** + * Generate an ephemeral X25519 ECDH key pair. + * Idempotent — subsequent calls are no-ops if already initialised. + */ + public async init(): Promise { + if (this.localKeyPair) { + return; + } + this.localKeyPair = (await globalThis.crypto.subtle.generateKey( + { name: 'X25519' }, + true, + ['deriveKey'] + )) as CryptoKeyPair; + } + + /** + * Export the local ECDH public key as a JWK JSON string. + * This is safe to transmit in plaintext — it contains no secret material. + */ + public async exportKey(): Promise { + if (!this.localKeyPair) { + throw new Error('[ECDHEncryption] Not initialised — call init() first.'); + } + const jwk = await globalThis.crypto.subtle.exportKey('jwk', this.localKeyPair.publicKey); + return JSON.stringify(jwk); + } + + /** + * Import the remote peer's ECDH public key and derive the shared + * AES-256-GCM key. After this call, encryptData() and decryptData() + * both use the derived key — the same key both peers arrive at + * independently without ever transmitting it. + */ + public async importRemoteKey(remotePublicKeyJwk: string): Promise { + if (!this.localKeyPair) { + throw new Error('[ECDHEncryption] Not initialised — call init() first.'); + } + + const remotePubKey = await globalThis.crypto.subtle.importKey( + 'jwk', + JSON.parse(remotePublicKeyJwk), + { name: 'X25519' }, + false, + [] + ); + + this.sharedAesKey = await globalThis.crypto.subtle.deriveKey( + { name: 'X25519', public: remotePubKey }, + this.localKeyPair.privateKey, + { name: 'AES-GCM', length: 256 }, + false, // non-extractable + ['encrypt', 'decrypt'] + ); + } + + /** + * Encrypt data using the shared AES-256-GCM key. + * A fresh 12-byte IV is generated for every call. + */ + public async encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array; iv: Uint8Array }> { + if (!this.sharedAesKey) { + throw new Error('[ECDHEncryption] Shared key not derived — call importRemoteKey() first.'); + } + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + this.sharedAesKey, + data + ); + return { encryptedData: new Uint8Array(encrypted), iv }; + } + + /** + * Decrypt data using the shared AES-256-GCM key. + * AES-GCM authentication tag is verified automatically — tampered + * ciphertext throws rather than silently returning garbage. + */ + public async decryptData(data: BufferSource, iv: BufferSource): Promise { + if (!this.sharedAesKey) { + throw new Error('[ECDHEncryption] Shared key not derived — call importRemoteKey() first.'); + } + return globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + this.sharedAesKey, + data + ); + } +} diff --git a/service/src/encryptionFactory.ts b/service/src/encryptionFactory.ts index b0bdd9c..8384e36 100644 --- a/service/src/encryptionFactory.ts +++ b/service/src/encryptionFactory.ts @@ -1,9 +1,10 @@ import { AesGcmEncryption, type ISymmetricEncryption } from './cryptoAES'; +import { ECDHEncryption } from './cryptoecdh'; import { cryptoUtils, type IAsymmetricEncryption } from './cryptoRSA'; import type { EncryptionStrategy } from './public/types'; /** Names of built-in symmetric encryption strategies. */ -export type BuiltinSymmetricStrategy = 'AES-GCM'; +export type BuiltinSymmetricStrategy = 'AES-GCM' | 'ECDH-X25519'; /** Names of built-in asymmetric encryption strategies. */ export type BuiltinAsymmetricStrategy = 'RSA-OAEP'; @@ -29,6 +30,7 @@ export interface EncryptionStrategyConfig { class EncryptionStrategyFactory { private readonly symmetricRegistry = new Map ISymmetricEncryption>([ ['AES-GCM', () => new AesGcmEncryption()], + ['ECDH-X25519', () => new ECDHEncryption()], ]); private readonly asymmetricRegistry = new Map IAsymmetricEncryption>([ diff --git a/service/src/global.d.ts b/service/src/global.d.ts new file mode 100644 index 0000000..afec392 --- /dev/null +++ b/service/src/global.d.ts @@ -0,0 +1,16 @@ +export {}; + +declare global { + /** + * Shim to support generic Uint8Array used in cryptoAES.ts + * while working in environments with older TypeScript definitions. + */ + interface Uint8Array extends ArrayBufferView { + readonly [Symbol.toStringTag]: "Uint8Array"; + readonly buffer: TArrayBuffer; + readonly length: number; + readonly byteLength: number; + readonly byteOffset: number; + [n: number]: number; + } +} diff --git a/service/src/public/types.ts b/service/src/public/types.ts index f05b611..af83b1b 100644 --- a/service/src/public/types.ts +++ b/service/src/public/types.ts @@ -35,6 +35,13 @@ export interface IChatE2EE { activeCall: E2ECall | null } +/** + * Interface for symmetric encryption protocols used for media stream security. + * Extensions of this interface are used by the SDK to encrypt/decrypt WebRTC packets. + * Strictly follows the maintainer's ISymmetricEncryption definition in cryptoAES.ts. + */ +export interface ISymmetricEncryptionProtocol extends ISymmetricEncryption {} + export interface IUtils { decryptMessage(ciphertext: string, privateKey: string): Promise, generateUUID(): string, @@ -56,6 +63,7 @@ export type configType = { disableLog: boolean, }, baseUrl?: string, + encryptionProtocol?: ISymmetricEncryption; } export type SetConfigType = (config: Partial) => void; diff --git a/service/src/sdk.ts b/service/src/sdk.ts index c665065..acfb77d 100644 --- a/service/src/sdk.ts +++ b/service/src/sdk.ts @@ -1,12 +1,11 @@ -import { type ISymmetricEncryption } from './cryptoAES'; +export { AesGcmEncryption } from './cryptoAES'; import { setConfig } from './configContext'; import { cryptoUtils } from './cryptoRSA'; -import { type IAsymmetricEncryption } from './cryptoRSA'; import { EncryptionFactory } from './encryptionFactory'; import deleteLink from './deleteLink'; import getLink from './getLink'; import getUsersInChannel from './getUsersInChannel'; -import { configType, type EncryptionStrategy, type IChatE2EE, type ISendMessageReturn, type LinkObjType, type TypeUsersInChannel } from './public/types'; +import { configType, type IChatE2EE, type ISendMessageReturn, type ISymmetricEncryption, type LinkObjType, type TypeUsersInChannel } from './public/types'; import { getPublicKey, sharePublicKey } from './publicKey'; import sendMessage from './sendMessage'; import { SocketInstance, type SubscriptionType } from './socket/socket'; @@ -22,9 +21,9 @@ export const utils = { } const logger = new Logger(); -export const createChatInstance = (config?: Partial, encryptionStrategy?: EncryptionStrategy): IChatE2EE => { +export const createChatInstance = (config?: Partial): IChatE2EE => { logger.log('Creating new instance'); - return new ChatE2EE(config, encryptionStrategy); + return new ChatE2EE(config); } export type chatJoinPayloadType = { @@ -54,22 +53,21 @@ class ChatE2EE implements IChatE2EE { private call?: WebRTCCall; private iceCandidates: any[] = []; - private symEncryption: ISymmetricEncryption; - private asymEncryption: IAsymmetricEncryption; + private readonly symEncryption: ISymmetricEncryption; + private readonly asymEncryption = cryptoUtils; private setupCallSubs(call: WebRTCCall): void { call.on('state-changed', (state) => { - if(state === 'failed' || state === 'closed') { + if (state === 'failed' || state === 'closed') { this.callLogger.log(`Ending call, RTCPeerConnectionState: ${state}`); this.endCall(); } }) } - constructor(config?: Partial, encryptionStrategy?: EncryptionStrategy) { + constructor(config?: Partial) { config && setConfig(config); - const defaults = EncryptionFactory.create(); - this.symEncryption = encryptionStrategy?.symmetric ?? defaults.symmetric; - this.asymEncryption = encryptionStrategy?.asymmetric ?? defaults.asymmetric; + // If an explicit protocol instance is provided, use it; otherwise, use the factory with defaults + this.symEncryption = config?.encryptionProtocol || EncryptionFactory.create().symmetric; } public async init(): Promise { @@ -86,9 +84,9 @@ class ChatE2EE implements IChatE2EE { this.on('on-alice-join', async () => { evetLogger.log("Receiver connected."); await this.getPublicKey(initLogger); - // Now that we have the receiver's RSA public key, share AES key encrypted with it + // Now that we have the receiver's RSA public key, share symmetric key material if (this.receiverPublicKey) { - await this.shareEncryptedAesKey(); + await this.shareSymmetricKeyMaterial(); } }) @@ -103,7 +101,7 @@ class ChatE2EE implements IChatE2EE { */ this.on('webrtc-session-description', (data: any) => { evetLogger.log("New session description"); - if(data.type === 'offer') { + if (data.type === 'offer') { evetLogger.log("New offer"); this.call = this.getWebRtcCall(); this.callSubscriptions.get("call-added")?.forEach((cb) => cb(this.activeCall)); @@ -115,30 +113,30 @@ class ChatE2EE implements IChatE2EE { }) this.iceCandidates = []; - }else if(data.type === 'answer') { + } else if (data.type === 'answer') { evetLogger.log("New answer"); this.call!.signal(data); - }else if(data.type === 'candidate') { + } else if (data.type === 'candidate') { evetLogger.log('ICE Candidate received.'); - if(!this.call) { + if (!this.call) { evetLogger.log("call not created yet, storing ICE candidate"); this.iceCandidates.push(data); - }else { + } else { this.call.signal(data); } } }); - initLogger.log(`Initializing symmetric Encryption for webrtc`); + initLogger.log(`Initializing symmetric encryption for WebRTC`); await this.symEncryption.init(); - initLogger.log(`Initialized symmetric Encryption for webrtc`); + initLogger.log(`Initialized symmetric encryption for WebRTC`); initLogger.log(`Finished.`); this.initialized = true; } public get activeCall(): E2ECall | null { - if(!this.call) { + if (!this.call) { return null; } return new E2ECall(this.call); @@ -151,17 +149,17 @@ class ChatE2EE implements IChatE2EE { public async setChannel(channelId: string, userId: string, userName?: string): Promise { this.checkInitialized(); - logger.log(`setChannel(), ${JSON.stringify({ channelId, userId,userName })}`); + logger.log(`setChannel(), ${JSON.stringify({ channelId, userId, userName })}`); this.channelId = channelId; this.userId = userId; // Share RSA public key (without AES key until we have receiver's RSA public key) - await sharePublicKey({ aesKey: null, publicKey: this.publicKey, sender: this.userId, channelId: this.channelId}); - this.socket.joinChat({ publicKey: this.publicKey!, userID: this.userId!, channelID: this.channelId!}) + await sharePublicKey({ aesKey: null, publicKey: this.publicKey, sender: this.userId, channelId: this.channelId }); + this.socket.joinChat({ publicKey: this.publicKey!, userID: this.userId!, channelID: this.channelId! }) await this.getPublicKey(logger); - // If the receiver's RSA public key is now known, share AES key encrypted with it + // If the receiver's RSA public key is now known, share symmetric key material if (this.receiverPublicKey) { - await this.shareEncryptedAesKey(); + await this.shareSymmetricKeyMaterial(); } return; } @@ -207,11 +205,11 @@ class ChatE2EE implements IChatE2EE { public on(listener: string, callback: (...args: any[]) => void): void { const loggerWithCount = this.subscriptionLogger.count(); let subscriptions = this.subscriptions; - - if(peerConnectionEvents.includes(listener as PeerConnectionEventType)) { + + if (peerConnectionEvents.includes(listener as PeerConnectionEventType)) { subscriptions = this.callSubscriptions; } - + const sub = this.subscriptions.get(listener); if (sub) { if (sub.has(callback)) { @@ -243,10 +241,10 @@ class ChatE2EE implements IChatE2EE { } public async startCall(): Promise { - if(!WebRTCCall.isSupported()) { + if (!WebRTCCall.isSupported()) { throw new Error('createEncodedStreams not supported.'); } - if(this.call) { + if (this.call) { throw new Error('Call already active'); } const webrtcCall = this.getWebRtcCall(); @@ -258,7 +256,7 @@ class ChatE2EE implements IChatE2EE { public async endCall(): Promise { this.call?.endCall(); this.call = undefined; - this.callSubscriptions.get("call-removed")?.forEach((cb) => cb()); + this.callSubscriptions.get("call-removed")?.forEach((cb) => cb()); } //get receiver public key @@ -267,19 +265,17 @@ class ChatE2EE implements IChatE2EE { const receiverPublicKey = await getPublicKey({ userId: this.userId, channelId: this.channelId }); logger.log(`setPublicKey() - ${!!receiverPublicKey?.publicKey}`); this.receiverPublicKey = receiverPublicKey?.publicKey; - if(receiverPublicKey.aesKey) { - // symmetric key is asymmetrically-encrypted ciphertext; decrypt it with our private key - const decryptedKeyMaterial = await this.asymEncryption.decryptMessage(receiverPublicKey.aesKey, this.privateKey!); - await this.symEncryption.importRemoteKey(decryptedKeyMaterial); + if (receiverPublicKey.aesKey) { + // ECDH public key is sent unencrypted in the aesKey field + await this.symEncryption.importRemoteKey(receiverPublicKey.aesKey); } return; } - // Encrypt local AES key with receiver's RSA public key and share it - private async shareEncryptedAesKey(): Promise { - const exportedKey = await this.symEncryption.exportKey(); - const encryptedAesKey = await this.asymEncryption.encryptMessage(exportedKey, this.receiverPublicKey!); - await sharePublicKey({ aesKey: encryptedAesKey, publicKey: this.publicKey, sender: this.userId, channelId: this.channelId }); + // Share ECDH public key unencrypted in the aesKey field + private async shareSymmetricKeyMaterial(): Promise { + const publicEcdhKeyJwk = await this.symEncryption.exportKey(); + await sharePublicKey({ aesKey: publicEcdhKeyJwk, publicKey: this.publicKey, sender: this.userId, channelId: this.channelId }); } private createSocketSubcription(): void { @@ -288,7 +284,7 @@ class ChatE2EE implements IChatE2EE { } private checkInitialized(): void { - if(!this.initialized) { + if (!this.initialized) { throw new Error('ChatE2EE is not initialized, call init()'); } } @@ -306,5 +302,4 @@ class ChatE2EE implements IChatE2EE { } } -export * from './public/types'; -export { EncryptionFactory, type EncryptionStrategyConfig, type BuiltinSymmetricStrategy, type BuiltinAsymmetricStrategy } from './encryptionFactory'; \ No newline at end of file +export * from './public/types'; \ No newline at end of file diff --git a/service/src/webrtc.ts b/service/src/webrtc.ts index 85cb18d..a10825c 100644 --- a/service/src/webrtc.ts +++ b/service/src/webrtc.ts @@ -1,4 +1,4 @@ -import { type ISymmetricEncryption } from "./cryptoAES"; +import { ISymmetricEncryptionProtocol } from "./public/types"; import { Logger } from "./utils/logger"; import { webrtcSession } from "./webrtcSession"; @@ -63,7 +63,7 @@ export class WebRTCCall { } } - constructor(encryption: ISymmetricEncryption, sender: string, channel: string, private logger: Logger) { + constructor(encryption: ISymmetricEncryptionProtocol, sender: string, channel: string, private readonly logger: Logger) { this.logger.log('Creating WebRTCCall'); this.peer = new Peer( () => this.subs, @@ -107,13 +107,13 @@ class Peer { private audioStream?: MediaStream; private localStreamAcquisatonPromise?: Promise - constructor( - private subCtx: () => Map>, - private encryption: ISymmetricEncryption, - private sender: string, - private channel: string, - private logger: Logger - ) { + constructor( + private readonly subCtx: () => Map>, + private readonly encryption: ISymmetricEncryptionProtocol, + private readonly sender: string, + private readonly channel: string, + private readonly logger: Logger + ) { // RTCPeerConnection is cast via the interface because `encodedInsertableStreams` // is a non-standard constructor option not present in the lib.dom types. this.pc = new (RTCPeerConnection as unknown as new (config: RTCConfiguration & { encodedInsertableStreams: boolean }) => RTCPeerConnectionWithInsertableStreams)({ diff --git a/service/tsconfig.json b/service/tsconfig.json index 4191ad8..7578c27 100644 --- a/service/tsconfig.json +++ b/service/tsconfig.json @@ -15,5 +15,6 @@ "ESNext", "dom" ], + "skipLibCheck": true, } } \ No newline at end of file