From 18ffccf202560ff911f7df9c3e464e3c26b0d5be Mon Sep 17 00:00:00 2001 From: ABHIRAM-CREATOR06 Date: Thu, 9 Apr 2026 15:06:05 +0530 Subject: [PATCH 1/6] Implement ECDH X25519 --- package-lock.json | 169 +++++++++++++------------ service/README.md | 126 ++----------------- service/package.json | 3 +- service/src/crypto.test.ts | 244 +++++------------------------------- service/src/cryptoAES.ts | 110 ++++++++++------ service/src/public/types.ts | 10 ++ service/src/sdk.ts | 50 ++++---- service/src/webrtc.ts | 18 +-- 8 files changed, 245 insertions(+), 485 deletions(-) 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..c8c4a70 100644 --- a/service/README.md +++ b/service/README.md @@ -83,123 +83,20 @@ 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' }); - -// Explicit strategy via factory -const chat = createChatInstance(config, EncryptionFactory.create({ symmetric: 'AES-GCM' })); -``` - ---- - -## 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' }) - -// Override both -EncryptionFactory.create({ symmetric: 'ChaCha20', asymmetric: 'X25519' }) -``` - -Requesting an unregistered name throws immediately: -> `Unknown symmetric strategy: "ChaCha20". Register it first with EncryptionFactory.registerSymmetric().` - -#### `EncryptionFactory.registerSymmetric(name, factory)` -#### `EncryptionFactory.registerAsymmetric(name, factory)` +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. -Register a custom implementation under a name. Both methods return `this` for chaining. - -```typescript -EncryptionFactory - .registerSymmetric('ChaCha20', () => new ChaCha20Encryption()) - .registerAsymmetric('X25519', () => new X25519Exchange()); -``` - -### Implementing a custom strategy - -#### `ISymmetricEncryption` - -```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 */ } -} -``` - -#### `IAsymmetricEncryption` - -```typescript -import type { IAsymmetricEncryption } from '@chat-e2ee/service'; +```javascript +import { createChatInstance, AesGcmEncryption } from '@chat-e2ee/service'; -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 { /* … */ } -} +const chat = createChatInstance({ + baseUrl: 'https://your-api.example.com', + settings: { disableLog: true }, + encryptionProtocol: new AesGcmEncryption(), // optional custom implementation +}); ``` - -#### Full example - -```typescript -import { createChatInstance, EncryptionFactory } from '@chat-e2ee/service'; - -// 1. Register at app startup -EncryptionFactory - .registerSymmetric('ChaCha20', () => new ChaCha20Encryption()) - .registerAsymmetric('X25519', () => new X25519Exchange()); - -// 2. Use by name -const chat = createChatInstance(config, EncryptionFactory.create({ - symmetric: 'ChaCha20', - asymmetric: 'X25519', -})); - -await chat.init(); ``` --- @@ -208,7 +105,8 @@ 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. 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..768c9dc 100644 --- a/service/src/crypto.test.ts +++ b/service/src/crypto.test.ts @@ -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.getRawAesKeyToExport(); + await aes.init(); + const key2Export = await aes.getRawAesKeyToExport(); - // 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,11 +91,9 @@ 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. - const exportedKey = await aes.exportKey(); - await aes.importRemoteKey(exportedKey); + // Export the local ECDH public key and set it as the "remote" key to derive shared key for loopback test + const exportedKey = await aes.getRawAesKeyToExport(); + await aes.setRemoteAesKey(exportedKey); const originalText = 'AES test payload 🔒'; const originalData = new TextEncoder().encode(originalText).buffer; @@ -111,230 +110,51 @@ 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 when remote key has not been set', async () => { const aes = new AesGcmEncryption(); await aes.init(); - const { encryptedData, iv } = await aes.encryptData( + // No setRemoteAesKey() call → should throw for encrypt + await expect(aes.encryptData( new TextEncoder().encode('data').buffer - ); + )).rejects.toThrow('Shared AES key not derived.'); - // No importRemoteKey() call → should throw - await expect(aes.decryptData(encryptedData, iv)).rejects.toThrow( - 'Remote AES key not set.' + // No setRemoteAesKey() call → should throw for decrypt + await expect(aes.decryptData(new ArrayBuffer(10), new Uint8Array(12))).rejects.toThrow( + 'Shared AES key not derived.' ); }); }); // --------------------------------------------------------------------------- -// 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.getRawAesKeyToExport(); - // Simulate Alice (sender): generate an AES key + // Simulate Alice (sender) const aliceAes = new AesGcmEncryption(); await aliceAes.init(); + const aliceEcdhKeyJwk = await aliceAes.getRawAesKeyToExport(); - // 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 + // Exchange keys (in plaintext over the wire) + await aliceAes.setRemoteAesKey(bobEcdhKeyJwk); + await bobAes.setRemoteAesKey(aliceEcdhKeyJwk); - // 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 - - // 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/cryptoAES.ts b/service/src/cryptoAES.ts index 6e14488..cfee648 100644 --- a/service/src/cryptoAES.ts +++ b/service/src/cryptoAES.ts @@ -1,7 +1,3 @@ -/** - * Interface for pluggable symmetric encryption strategies. - * Implement this to swap in any symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305). - */ export interface ISymmetricEncryption { /** Generate / initialise the local encryption key. Idempotent. */ init(): Promise; @@ -16,64 +12,106 @@ export interface ISymmetricEncryption { } /** - * AES-256-GCM implementation of ISymmetricEncryption. - * Used for encrypting Audio/Video WebRTC streams. + * AES-GCM encryption using ECDH for key exchange. + * Implements ISymmetricEncryptionProtocol for WebRTC media encryption. */ export class AesGcmEncryption implements ISymmetricEncryption { - private aesKeyLocal?: CryptoKey; - private aesKeyRemote?: CryptoKey; + private ecdhPrivateKey?: CryptoKey; + private ecdhPublicKey?: CryptoKey; + private sharedAesKey?: CryptoKey; public async init(): Promise { - if (this.aesKeyLocal) { + if (this.ecdhPrivateKey) { return; } - this.aesKeyLocal = await window.crypto.subtle.generateKey( - { name: "AES-GCM", length: 256 }, - true, - ["encrypt", "decrypt"] - ); + // Generate ECDH key pair + const keyPair = await window.crypto.subtle.generateKey( + { name: "X25519" }, + true, // extractable + ["deriveKey", "deriveBits"] + ) as CryptoKeyPair; + + this.ecdhPrivateKey = keyPair.privateKey; + this.ecdhPublicKey = keyPair.publicKey; + } + + public getRemoteAesKey(): CryptoKey { + if (!this.sharedAesKey) { + throw new Error('Shared AES key not derived'); + } + return this.sharedAesKey; } - public async exportKey(): Promise { - if (!this.aesKeyLocal) { - throw new Error('AES key not generated'); + public async getRawAesKeyToExport(): Promise { + if (!this.ecdhPublicKey) { + throw new Error('ECDH keys not generated'); } - const jsonWebKey = await crypto.subtle.exportKey("jwk", this.aesKeyLocal); + const jsonWebKey = await window.crypto.subtle.exportKey("jwk", this.ecdhPublicKey); return JSON.stringify(jsonWebKey); } - public async importRemoteKey(key: string): Promise { + /** Satisfies ISymmetricEncryption interface — delegates to getRawAesKeyToExport */ + public exportKey(): Promise { + return this.getRawAesKeyToExport(); + } + + public async setRemoteAesKey(key: string): Promise { + if (!this.ecdhPrivateKey) { + throw new Error('Local ECDH private key not generated'); + } const jsonWebKey = JSON.parse(key); - this.aesKeyRemote = await crypto.subtle.importKey( + const remotePublicKey = await window.crypto.subtle.importKey( "jwk", jsonWebKey, - { name: "AES-GCM" }, + { name: "X25519" }, true, - ["decrypt"] + [] // public keys don't require key usages for derivation + ); + + // Derive shared AES-GCM key + this.sharedAesKey = await window.crypto.subtle.deriveKey( + { name: "X25519", public: remotePublicKey }, + this.ecdhPrivateKey, + { name: "AES-GCM", length: 256 }, + false, // AES key is never extracted/transmitted + ["encrypt", "decrypt"] ); } + /** Satisfies ISymmetricEncryption interface — delegates to setRemoteAesKey */ + public importRemoteKey(key: string): Promise { + return this.setRemoteAesKey(key); + } + public async encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array; iv: Uint8Array }> { - if (!this.aesKeyLocal) { - throw new Error('Local AES key not generated.'); - } - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encryptedData = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - this.aesKeyLocal, - data + if (!this.sharedAesKey) { + throw new Error('Shared AES key not derived.') + }; + // Generate an Initialization Vector (IV) for AES-GCM (12 bytes) + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + // Encrypt the frame data using AES-GCM + const encryptedData = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv + }, + this.sharedAesKey, // Symmetric key for encryption + data // The frame data to be encrypted ); return { encryptedData: new Uint8Array(encryptedData), iv }; } public async decryptData(data: BufferSource, iv: BufferSource): Promise { - if (!this.aesKeyRemote) { - throw new Error('Remote AES key not set.'); + if (!this.sharedAesKey) { + throw new Error('Shared AES key not derived.') } - return crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - this.aesKeyRemote, - data + return window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv + }, + this.sharedAesKey, // Symmetric key for decryption + data // The encrypted frame data ); } } diff --git a/service/src/public/types.ts b/service/src/public/types.ts index f05b611..4dca7f7 100644 --- a/service/src/public/types.ts +++ b/service/src/public/types.ts @@ -35,6 +35,15 @@ export interface IChatE2EE { activeCall: E2ECall | null } +export interface ISymmetricEncryptionProtocol { + init(): Promise; + getRemoteAesKey(): CryptoKey; + getRawAesKeyToExport(): Promise; + setRemoteAesKey(key: string): Promise; + encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array, iv: Uint8Array }>; + decryptData(data: BufferSource, iv: BufferSource): Promise; +} + export interface IUtils { decryptMessage(ciphertext: string, privateKey: string): Promise, generateUUID(): string, @@ -56,6 +65,7 @@ export type configType = { disableLog: boolean, }, baseUrl?: string, + encryptionProtocol?: ISymmetricEncryptionProtocol, } export type SetConfigType = (config: Partial) => void; diff --git a/service/src/sdk.ts b/service/src/sdk.ts index c665065..7900159 100644 --- a/service/src/sdk.ts +++ b/service/src/sdk.ts @@ -1,12 +1,11 @@ -import { type ISymmetricEncryption } from './cryptoAES'; +import { AesGcmEncryption } from './cryptoAES'; +export { AesGcmEncryption }; 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 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,8 +53,8 @@ class ChatE2EE implements IChatE2EE { private call?: WebRTCCall; private iceCandidates: any[] = []; - private symEncryption: ISymmetricEncryption; - private asymEncryption: IAsymmetricEncryption; + private symEncryption: import('./public/types').ISymmetricEncryptionProtocol; + private asymEncryption = cryptoUtils; private setupCallSubs(call: WebRTCCall): void { call.on('state-changed', (state) => { @@ -65,11 +64,9 @@ class ChatE2EE implements IChatE2EE { } }) } - 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; + this.symEncryption = config?.encryptionProtocol || new AesGcmEncryption(); } public async init(): Promise { @@ -86,9 +83,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(); } }) @@ -130,9 +127,9 @@ class ChatE2EE implements IChatE2EE { }); - 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; } @@ -159,9 +156,9 @@ class ChatE2EE implements IChatE2EE { 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; } @@ -268,18 +265,16 @@ class ChatE2EE implements IChatE2EE { 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); + // ECDH public key is sent unencrypted in the aesKey field + await this.symEncryption.setRemoteAesKey(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.getRawAesKeyToExport(); + await sharePublicKey({ aesKey: publicEcdhKeyJwk, publicKey: this.publicKey, sender: this.userId, channelId: this.channelId }); } private createSocketSubcription(): void { @@ -306,5 +301,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..8a317e0 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 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 subCtx: () => Map>, + private encryption: ISymmetricEncryptionProtocol, + private sender: string, + private channel: string, + private 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)({ From 309213b55596b5668b3a964baf5316e70782d259 Mon Sep 17 00:00:00 2001 From: ABHIRAM-CREATOR06 Date: Thu, 9 Apr 2026 15:40:51 +0530 Subject: [PATCH 2/6] fix(service): replace AES key transmission with ECDH X25519 key exchange --- service/src/cryptoAES.ts | 46 +++++++++++++++++----------------------- service/src/sdk.ts | 7 +++--- service/src/webrtc.ts | 12 +++++------ 3 files changed, 29 insertions(+), 36 deletions(-) diff --git a/service/src/cryptoAES.ts b/service/src/cryptoAES.ts index cfee648..f93c1f0 100644 --- a/service/src/cryptoAES.ts +++ b/service/src/cryptoAES.ts @@ -12,8 +12,8 @@ export interface ISymmetricEncryption { } /** - * AES-GCM encryption using ECDH for key exchange. - * Implements ISymmetricEncryptionProtocol for WebRTC media encryption. + * AES-GCM encryption using ECDH X25519 for key exchange. + * Both peers derive the same AES-256-GCM key independently — the raw key is never transmitted. */ export class AesGcmEncryption implements ISymmetricEncryption { private ecdhPrivateKey?: CryptoKey; @@ -25,7 +25,7 @@ export class AesGcmEncryption implements ISymmetricEncryption { return; } // Generate ECDH key pair - const keyPair = await window.crypto.subtle.generateKey( + const keyPair = await globalThis.crypto.subtle.generateKey( { name: "X25519" }, true, // extractable ["deriveKey", "deriveBits"] @@ -46,7 +46,7 @@ export class AesGcmEncryption implements ISymmetricEncryption { if (!this.ecdhPublicKey) { throw new Error('ECDH keys not generated'); } - const jsonWebKey = await window.crypto.subtle.exportKey("jwk", this.ecdhPublicKey); + const jsonWebKey = await globalThis.crypto.subtle.exportKey("jwk", this.ecdhPublicKey); return JSON.stringify(jsonWebKey); } @@ -60,7 +60,7 @@ export class AesGcmEncryption implements ISymmetricEncryption { throw new Error('Local ECDH private key not generated'); } const jsonWebKey = JSON.parse(key); - const remotePublicKey = await window.crypto.subtle.importKey( + const remotePublicKey = await globalThis.crypto.subtle.importKey( "jwk", jsonWebKey, { name: "X25519" }, @@ -68,12 +68,12 @@ export class AesGcmEncryption implements ISymmetricEncryption { [] // public keys don't require key usages for derivation ); - // Derive shared AES-GCM key - this.sharedAesKey = await window.crypto.subtle.deriveKey( + // Derive shared AES-GCM key — never leaves the device + this.sharedAesKey = await globalThis.crypto.subtle.deriveKey( { name: "X25519", public: remotePublicKey }, this.ecdhPrivateKey, { name: "AES-GCM", length: 256 }, - false, // AES key is never extracted/transmitted + false, // AES key is never extractable/transmitted ["encrypt", "decrypt"] ); } @@ -85,33 +85,27 @@ export class AesGcmEncryption implements ISymmetricEncryption { public async encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array; iv: Uint8Array }> { if (!this.sharedAesKey) { - throw new Error('Shared AES key not derived.') - }; + throw new Error('Shared AES key not derived.'); + } // Generate an Initialization Vector (IV) for AES-GCM (12 bytes) - const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); // Encrypt the frame data using AES-GCM - const encryptedData = await window.crypto.subtle.encrypt( - { - name: "AES-GCM", - iv: iv - }, - this.sharedAesKey, // Symmetric key for encryption - data // The frame data to be encrypted + const encryptedData = await globalThis.crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + this.sharedAesKey, + data ); return { encryptedData: new Uint8Array(encryptedData), iv }; } public async decryptData(data: BufferSource, iv: BufferSource): Promise { if (!this.sharedAesKey) { - throw new Error('Shared AES key not derived.') + throw new Error('Shared AES key not derived.'); } - return window.crypto.subtle.decrypt( - { - name: "AES-GCM", - iv - }, - this.sharedAesKey, // Symmetric key for decryption - data // The encrypted frame data + return globalThis.crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + this.sharedAesKey, + data ); } } diff --git a/service/src/sdk.ts b/service/src/sdk.ts index 7900159..364eda0 100644 --- a/service/src/sdk.ts +++ b/service/src/sdk.ts @@ -1,5 +1,4 @@ -import { AesGcmEncryption } from './cryptoAES'; -export { AesGcmEncryption }; +export { AesGcmEncryption } from './cryptoAES'; import { setConfig } from './configContext'; import { cryptoUtils } from './cryptoRSA'; import deleteLink from './deleteLink'; @@ -53,8 +52,8 @@ class ChatE2EE implements IChatE2EE { private call?: WebRTCCall; private iceCandidates: any[] = []; - private symEncryption: import('./public/types').ISymmetricEncryptionProtocol; - private asymEncryption = cryptoUtils; + private readonly symEncryption: ISymmetricEncryptionProtocol; + private readonly asymEncryption = cryptoUtils; private setupCallSubs(call: WebRTCCall): void { call.on('state-changed', (state) => { diff --git a/service/src/webrtc.ts b/service/src/webrtc.ts index 8a317e0..a10825c 100644 --- a/service/src/webrtc.ts +++ b/service/src/webrtc.ts @@ -63,7 +63,7 @@ export class WebRTCCall { } } - constructor(encryption: ISymmetricEncryptionProtocol, 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, @@ -108,11 +108,11 @@ class Peer { private localStreamAcquisatonPromise?: Promise constructor( - private subCtx: () => Map>, - private encryption: ISymmetricEncryptionProtocol, - private sender: string, - private channel: string, - private logger: Logger + 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. From 1dbf59fb1fdcf76033e189005747de2363ebb2e3 Mon Sep 17 00:00:00 2001 From: ABHIRAM-CREATOR06 Date: Thu, 9 Apr 2026 20:14:42 +0530 Subject: [PATCH 3/6] feat: implement pluggable ECDH-X25519 encryption and refactor SDK --- service/README.md | 50 +++++++- service/src/crypto.test.ts | 29 ++--- service/src/cryptoAES.ts | 94 +++++---------- service/src/cryptoecdh.test.ts | 197 +++++++++++++++++++++++++++++++ service/src/cryptoecdh.ts | 118 ++++++++++++++++++ service/src/encryptionFactory.ts | 4 +- service/src/global.d.ts | 16 +++ service/src/public/types.ts | 11 +- service/src/sdk.ts | 12 +- service/tsconfig.json | 1 + 10 files changed, 437 insertions(+), 95 deletions(-) create mode 100644 service/src/cryptoecdh.test.ts create mode 100644 service/src/cryptoecdh.ts create mode 100644 service/src/global.d.ts diff --git a/service/README.md b/service/README.md index c8c4a70..4d6bd48 100644 --- a/service/README.md +++ b/service/README.md @@ -90,13 +90,59 @@ Custom encryption protocol allows you to implement your own symmetric encryption ```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 supports pluggable symmetric encryption strategies via the `EncryptionFactory`. This allows you to swap the underlying encryption logic used for WebRTC media streams. + +### Available Strategies + +- **`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. + +### Switching Strategies + +You can specify the encryption strategy when creating a chat instance: + +```javascript +import { createChatInstance, EncryptionFactory } from '@chat-e2ee/service'; + +// Use the high-security ECDH-X25519 strategy +const strategy = EncryptionFactory.create({ symmetric: 'ECDH-X25519' }); + +const chat = createChatInstance({ + encryptionProtocol: strategy.symmetric +}); +``` + +### Registering Custom Strategies + +You can register your own implementation by implementing the `ISymmetricEncryption` interface: + +```javascript +import { EncryptionFactory } from '@chat-e2ee/service'; + +class MySymmetricCipher { + async init() { ... } + async exportKey() { ... } + async importRemoteKey(key) { ... } + async encryptData(data) { ... } + async decryptData(data, iv) { ... } +} + +EncryptionFactory.registerSymmetric('MY-CIPHER', () => new MySymmetricCipher()); + +const chat = createChatInstance({ + encryptionProtocol: EncryptionFactory.create({ symmetric: 'MY-CIPHER' }).symmetric +}); ``` --- @@ -109,8 +155,6 @@ Initializes the instance: - 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/src/crypto.test.ts b/service/src/crypto.test.ts index 768c9dc..2b07144 100644 --- a/service/src/crypto.test.ts +++ b/service/src/crypto.test.ts @@ -76,9 +76,9 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => { it('init() generates ECDH key pair and is idempotent on subsequent calls', async () => { const aes = new AesGcmEncryption(); await aes.init(); - const key1Export = await aes.getRawAesKeyToExport(); + const key1Export = await aes.exportKey(); await aes.init(); - const key2Export = await aes.getRawAesKeyToExport(); + const key2Export = await aes.exportKey(); expect(key1Export).toBeDefined(); // Second call should return the same cached instance @@ -92,8 +92,8 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => { await aes.init(); // Export the local ECDH public key and set it as the "remote" key to derive shared key for loopback test - const exportedKey = await aes.getRawAesKeyToExport(); - await aes.setRemoteAesKey(exportedKey); + const exportedKey = await aes.exportKey(); + await aes.importRemoteKey(exportedKey); const originalText = 'AES test payload 🔒'; const originalData = new TextEncoder().encode(originalText).buffer; @@ -110,18 +110,19 @@ describe('AesGcmEncryption (AES-GCM) – real Web Crypto', () => { expect(decryptedText).toBe(originalText); }); - it('encryptData() and decryptData() throw 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(); - // No setRemoteAesKey() call → should throw for encrypt + // No init() call → should throw for encrypt await expect(aes.encryptData( new TextEncoder().encode('data').buffer - )).rejects.toThrow('Shared AES key not derived.'); + )).rejects.toThrow('Local AES key not generated.'); + + await aes.init(); - // No setRemoteAesKey() call → should throw for decrypt + // No importRemoteKey() call → should throw for decrypt await expect(aes.decryptData(new ArrayBuffer(10), new Uint8Array(12))).rejects.toThrow( - 'Shared AES key not derived.' + 'Remote AES key not set.' ); }); }); @@ -134,16 +135,16 @@ describe('ECDH symmetric key exchange', () => { // Simulate Bob (receiver) const bobAes = new AesGcmEncryption(); await bobAes.init(); - const bobEcdhKeyJwk = await bobAes.getRawAesKeyToExport(); + const bobEcdhKeyJwk = await bobAes.exportKey(); // Simulate Alice (sender) const aliceAes = new AesGcmEncryption(); await aliceAes.init(); - const aliceEcdhKeyJwk = await aliceAes.getRawAesKeyToExport(); + const aliceEcdhKeyJwk = await aliceAes.exportKey(); // Exchange keys (in plaintext over the wire) - await aliceAes.setRemoteAesKey(bobEcdhKeyJwk); - await bobAes.setRemoteAesKey(aliceEcdhKeyJwk); + await aliceAes.importRemoteKey(bobEcdhKeyJwk); + await bobAes.importRemoteKey(aliceEcdhKeyJwk); // Alice encrypts some data with her derived AES key const originalText = 'Secret message over ECDH derived AES-GCM 🔐'; diff --git a/service/src/cryptoAES.ts b/service/src/cryptoAES.ts index f93c1f0..6e14488 100644 --- a/service/src/cryptoAES.ts +++ b/service/src/cryptoAES.ts @@ -1,3 +1,7 @@ +/** + * Interface for pluggable symmetric encryption strategies. + * Implement this to swap in any symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305). + */ export interface ISymmetricEncryption { /** Generate / initialise the local encryption key. Idempotent. */ init(): Promise; @@ -12,99 +16,63 @@ export interface ISymmetricEncryption { } /** - * AES-GCM encryption using ECDH X25519 for key exchange. - * Both peers derive the same AES-256-GCM key independently — the raw key is never transmitted. + * AES-256-GCM implementation of ISymmetricEncryption. + * Used for encrypting Audio/Video WebRTC streams. */ export class AesGcmEncryption implements ISymmetricEncryption { - private ecdhPrivateKey?: CryptoKey; - private ecdhPublicKey?: CryptoKey; - private sharedAesKey?: CryptoKey; + private aesKeyLocal?: CryptoKey; + private aesKeyRemote?: CryptoKey; public async init(): Promise { - if (this.ecdhPrivateKey) { + if (this.aesKeyLocal) { return; } - // Generate ECDH key pair - const keyPair = await globalThis.crypto.subtle.generateKey( - { name: "X25519" }, - true, // extractable - ["deriveKey", "deriveBits"] - ) as CryptoKeyPair; - - this.ecdhPrivateKey = keyPair.privateKey; - this.ecdhPublicKey = keyPair.publicKey; - } - - public getRemoteAesKey(): CryptoKey { - if (!this.sharedAesKey) { - throw new Error('Shared AES key not derived'); - } - return this.sharedAesKey; + this.aesKeyLocal = await window.crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); } - public async getRawAesKeyToExport(): Promise { - if (!this.ecdhPublicKey) { - throw new Error('ECDH keys not generated'); + public async exportKey(): Promise { + if (!this.aesKeyLocal) { + throw new Error('AES key not generated'); } - const jsonWebKey = await globalThis.crypto.subtle.exportKey("jwk", this.ecdhPublicKey); + const jsonWebKey = await crypto.subtle.exportKey("jwk", this.aesKeyLocal); return JSON.stringify(jsonWebKey); } - /** Satisfies ISymmetricEncryption interface — delegates to getRawAesKeyToExport */ - public exportKey(): Promise { - return this.getRawAesKeyToExport(); - } - - public async setRemoteAesKey(key: string): Promise { - if (!this.ecdhPrivateKey) { - throw new Error('Local ECDH private key not generated'); - } + public async importRemoteKey(key: string): Promise { const jsonWebKey = JSON.parse(key); - const remotePublicKey = await globalThis.crypto.subtle.importKey( + this.aesKeyRemote = await crypto.subtle.importKey( "jwk", jsonWebKey, - { name: "X25519" }, + { name: "AES-GCM" }, true, - [] // public keys don't require key usages for derivation + ["decrypt"] ); - - // Derive shared AES-GCM key — never leaves the device - this.sharedAesKey = await globalThis.crypto.subtle.deriveKey( - { name: "X25519", public: remotePublicKey }, - this.ecdhPrivateKey, - { name: "AES-GCM", length: 256 }, - false, // AES key is never extractable/transmitted - ["encrypt", "decrypt"] - ); - } - - /** Satisfies ISymmetricEncryption interface — delegates to setRemoteAesKey */ - public importRemoteKey(key: string): Promise { - return this.setRemoteAesKey(key); } public async encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array; iv: Uint8Array }> { - if (!this.sharedAesKey) { - throw new Error('Shared AES key not derived.'); + if (!this.aesKeyLocal) { + throw new Error('Local AES key not generated.'); } - // Generate an Initialization Vector (IV) for AES-GCM (12 bytes) - const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); - // Encrypt the frame data using AES-GCM - const encryptedData = await globalThis.crypto.subtle.encrypt( + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encryptedData = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, - this.sharedAesKey, + this.aesKeyLocal, data ); return { encryptedData: new Uint8Array(encryptedData), iv }; } public async decryptData(data: BufferSource, iv: BufferSource): Promise { - if (!this.sharedAesKey) { - throw new Error('Shared AES key not derived.'); + if (!this.aesKeyRemote) { + throw new Error('Remote AES key not set.'); } - return globalThis.crypto.subtle.decrypt( + return crypto.subtle.decrypt( { name: "AES-GCM", iv }, - this.sharedAesKey, + this.aesKeyRemote, data ); } diff --git a/service/src/cryptoecdh.test.ts b/service/src/cryptoecdh.test.ts new file mode 100644 index 0000000..c66b224 --- /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 'crypto'; + +if (!globalThis.crypto) { + (globalThis as any).crypto = webcrypto; +} + +if (typeof 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 4dca7f7..2dda1b4 100644 --- a/service/src/public/types.ts +++ b/service/src/public/types.ts @@ -35,14 +35,7 @@ export interface IChatE2EE { activeCall: E2ECall | null } -export interface ISymmetricEncryptionProtocol { - init(): Promise; - getRemoteAesKey(): CryptoKey; - getRawAesKeyToExport(): Promise; - setRemoteAesKey(key: string): Promise; - encryptData(data: ArrayBuffer): Promise<{ encryptedData: Uint8Array, iv: Uint8Array }>; - decryptData(data: BufferSource, iv: BufferSource): Promise; -} +export interface ISymmetricEncryptionProtocol extends ISymmetricEncryption {} export interface IUtils { decryptMessage(ciphertext: string, privateKey: string): Promise, @@ -65,7 +58,7 @@ export type configType = { disableLog: boolean, }, baseUrl?: string, - encryptionProtocol?: ISymmetricEncryptionProtocol, + encryptionProtocol?: ISymmetricEncryption; } export type SetConfigType = (config: Partial) => void; diff --git a/service/src/sdk.ts b/service/src/sdk.ts index 364eda0..271726a 100644 --- a/service/src/sdk.ts +++ b/service/src/sdk.ts @@ -1,10 +1,11 @@ export { AesGcmEncryption } from './cryptoAES'; import { setConfig } from './configContext'; import { cryptoUtils } from './cryptoRSA'; +import { EncryptionFactory } from './encryptionFactory'; import deleteLink from './deleteLink'; import getLink from './getLink'; import getUsersInChannel from './getUsersInChannel'; -import { configType, 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'; @@ -52,7 +53,7 @@ class ChatE2EE implements IChatE2EE { private call?: WebRTCCall; private iceCandidates: any[] = []; - private readonly symEncryption: ISymmetricEncryptionProtocol; + private readonly symEncryption: ISymmetricEncryption; private readonly asymEncryption = cryptoUtils; private setupCallSubs(call: WebRTCCall): void { @@ -65,7 +66,8 @@ class ChatE2EE implements IChatE2EE { } constructor(config?: Partial) { config && setConfig(config); - this.symEncryption = config?.encryptionProtocol || new AesGcmEncryption(); + // 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 { @@ -265,14 +267,14 @@ class ChatE2EE implements IChatE2EE { this.receiverPublicKey = receiverPublicKey?.publicKey; if(receiverPublicKey.aesKey) { // ECDH public key is sent unencrypted in the aesKey field - await this.symEncryption.setRemoteAesKey(receiverPublicKey.aesKey); + await this.symEncryption.importRemoteKey(receiverPublicKey.aesKey); } return; } // Share ECDH public key unencrypted in the aesKey field private async shareSymmetricKeyMaterial(): Promise { - const publicEcdhKeyJwk = await this.symEncryption.getRawAesKeyToExport(); + const publicEcdhKeyJwk = await this.symEncryption.exportKey(); await sharePublicKey({ aesKey: publicEcdhKeyJwk, publicKey: this.publicKey, sender: this.userId, channelId: this.channelId }); } 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 From 905c3446c593753a73e5eb2caa474c373ab42244 Mon Sep 17 00:00:00 2001 From: ABHIRAM-CREATOR06 Date: Thu, 9 Apr 2026 20:17:32 +0530 Subject: [PATCH 4/6] chore: address minor linting issues in test polyfills --- service/src/crypto.test.ts | 4 ++-- service/src/cryptoecdh.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/service/src/crypto.test.ts b/service/src/crypto.test.ts index 2b07144..043fde9 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 (typeof globalThis.window === 'undefined') { (globalThis as any).window = globalThis; } diff --git a/service/src/cryptoecdh.test.ts b/service/src/cryptoecdh.test.ts index c66b224..86cdb31 100644 --- a/service/src/cryptoecdh.test.ts +++ b/service/src/cryptoecdh.test.ts @@ -5,13 +5,13 @@ * in both Node.js and browser environments. */ -import { webcrypto } from 'crypto'; +import { webcrypto } from 'node:crypto'; if (!globalThis.crypto) { (globalThis as any).crypto = webcrypto; } -if (typeof window === 'undefined') { +if (typeof globalThis.window === 'undefined') { (globalThis as any).window = globalThis; } From 8cdcce3cc384bfb5af40feb0a9f21ff4c0d5d10b Mon Sep 17 00:00:00 2001 From: ABHIRAM-CREATOR06 Date: Thu, 9 Apr 2026 20:20:25 +0530 Subject: [PATCH 5/6] style: use direct undefined comparison for window check --- service/src/crypto.test.ts | 2 +- service/src/cryptoecdh.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/service/src/crypto.test.ts b/service/src/crypto.test.ts index 043fde9..b3d6541 100644 --- a/service/src/crypto.test.ts +++ b/service/src/crypto.test.ts @@ -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 globalThis.window === 'undefined') { +if (globalThis.window === undefined) { (globalThis as any).window = globalThis; } diff --git a/service/src/cryptoecdh.test.ts b/service/src/cryptoecdh.test.ts index 86cdb31..b681669 100644 --- a/service/src/cryptoecdh.test.ts +++ b/service/src/cryptoecdh.test.ts @@ -11,7 +11,7 @@ if (!globalThis.crypto) { (globalThis as any).crypto = webcrypto; } -if (typeof globalThis.window === 'undefined') { +if (globalThis.window === undefined) { (globalThis as any).window = globalThis; } From 4cc65ec8c299590235caf0f21bc22b3ee91e88f1 Mon Sep 17 00:00:00 2001 From: ABHIRAM-CREATOR06 Date: Thu, 9 Apr 2026 20:23:21 +0530 Subject: [PATCH 6/6] docs: add implementation details and architecture comments --- service/src/public/types.ts | 5 +++++ service/src/sdk.ts | 36 ++++++++++++++++++------------------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/service/src/public/types.ts b/service/src/public/types.ts index 2dda1b4..af83b1b 100644 --- a/service/src/public/types.ts +++ b/service/src/public/types.ts @@ -35,6 +35,11 @@ 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 { diff --git a/service/src/sdk.ts b/service/src/sdk.ts index 271726a..acfb77d 100644 --- a/service/src/sdk.ts +++ b/service/src/sdk.ts @@ -58,7 +58,7 @@ class ChatE2EE implements IChatE2EE { 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(); } @@ -101,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)); @@ -113,15 +113,15 @@ 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); } } @@ -136,7 +136,7 @@ class ChatE2EE implements IChatE2EE { } public get activeCall(): E2ECall | null { - if(!this.call) { + if (!this.call) { return null; } return new E2ECall(this.call); @@ -149,13 +149,13 @@ 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 symmetric key material if (this.receiverPublicKey) { @@ -205,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)) { @@ -241,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(); @@ -256,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 @@ -265,7 +265,7 @@ 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) { + if (receiverPublicKey.aesKey) { // ECDH public key is sent unencrypted in the aesKey field await this.symEncryption.importRemoteKey(receiverPublicKey.aesKey); } @@ -284,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()'); } }