Skip to content

# fix(service): replace AES key transmission with ECDH X25519 key exchange#447

Open
ABHIRAM-CREATOR06 wants to merge 6 commits intomuke1908:masterfrom
ABHIRAM-CREATOR06:feature/aes-gcm-experiment
Open

# fix(service): replace AES key transmission with ECDH X25519 key exchange#447
ABHIRAM-CREATOR06 wants to merge 6 commits intomuke1908:masterfrom
ABHIRAM-CREATOR06:feature/aes-gcm-experiment

Conversation

@ABHIRAM-CREATOR06
Copy link
Copy Markdown

Continues from 309d332 — fixes items 6 (MITM/key-authenticity) and partially 8 (per-session forward secrecy) from issue #403, which that commit explicitly deferred.

Summary

The previous commit (309d332) upgraded RSA to 4096-bit and added AES-GCM for text messages. However, the AES key material was still transmitted over the wire — even if RSA-wrapped, the symmetric key itself crossed the network.

This PR eliminates that by replacing the AES key transmission model with ECDH X25519 key exchange. Both peers independently derive the identical shared AES-256-GCM key — nothing secret is ever transmitted.

Changes

service/src/cryptoAES.ts

  • X25519 ECDH keypair generated in init()
  • getRawAesKeyToExport() now exports the ECDH public key (safe to transmit)
  • setRemoteAesKey() imports peer's public key and derives shared AES-256-GCM key via crypto.subtle.deriveKey()
  • Shared AES key is non-extractable and never leaves the device
  • Fixed typo: int()init()

service/src/public/types.ts

  • ISymmetricEncryptionProtocol: renamed int()init()

service/src/webrtc.ts

  • WebRTCCall and Peer constructors now accept ISymmetricEncryptionProtocol instead of the concrete AesGcmEncryption class — makes the pluggable interface actually pluggable

service/src/sdk.ts

  • ECDH public key sent directly — no RSA wrapping needed (public keys are designed to be public)
  • Peer key passed directly to setRemoteAesKey() for local derivation

service/src/crypto.test.ts

  • Replaced RSA-wrapped AES key exchange tests with ECDH Alice ↔ Bob exchange tests
  • Added guards verifying encryptData and decryptData throw before remote key is imported
  • All 7 tests pass

service/package.json

  • Added ts-jest to devDependenciesnpm test works out of the box without manual setup

Security model

Before (309d332) After this PR
Key exchange AES key transmitted (RSA-wrapped) ECDH public keys only · AES key derived locally
Secret over wire ✅ Yes (encrypted) ❌ Never
Curve X25519 (same as Signal Protocol, TLS 1.3)
Per-session keys ❌ No ✅ Yes — ephemeral keypair per session
Remaining gap Double Ratchet (item 8) — tracked separately

Testing

All 7 tests pass:

✓ generates ECDH keypair on init
✓ exports public key as JWK string
✓ both peers derive identical shared AES key
✓ encrypts and decrypts data correctly
✓ throws before remote key is imported (encrypt)
✓ throws before remote key is imported (decrypt)
✓ different sessions produce different keypairs

Note: Forward secrecy via Double Ratchet (issue #403 item 8) is intentionally out of scope here and will be addressed in a follow-up PR.

Related

@muke1908
Copy link
Copy Markdown
Owner

muke1908 commented Apr 9, 2026

Thanks @ABHIRAM-CREATOR06! Can you please add your encryption module in a separate class and follow the https://github.com/muke1908/chat-e2ee/tree/master/service#pluggable-encryption to register your encryption.

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 9, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to replace the prior “send an AES key (RSA-wrapped)” WebRTC media key exchange with an ECDH X25519-based exchange so peers derive a shared AES-256-GCM key locally, plus related SDK/type/doc/test updates.

Changes:

  • Added an ECDHEncryption (X25519 → derived AES-256-GCM) symmetric strategy and accompanying unit/integration tests.
  • Updated SDK WebRTC key-material sharing to send exportKey() output directly and import peer material to derive/set the shared key.
  • Updated docs/types/build tooling (README updates, ts-jest, skipLibCheck, and a global Uint8Array typing shim).

Reviewed changes

Copilot reviewed 10 out of 12 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
service/tsconfig.json Enables skipLibCheck (likely to work around new typing changes).
service/src/webrtc.ts Switches WebRTC call classes to depend on a protocol interface type.
service/src/sdk.ts Changes key-material exchange flow and the public createChatInstance API/export surface.
service/src/public/types.ts Adds ISymmetricEncryptionProtocol alias and config hook for custom protocol instance.
service/src/global.d.ts Adds a global Uint8Array generic shim.
service/src/encryptionFactory.ts Registers new built-in symmetric strategy ECDH-X25519.
service/src/cryptoecdh.ts Implements X25519 ECDH-derived AES-GCM encryption.
service/src/cryptoecdh.test.ts Adds tests for ECDH exchange + factory integration.
service/src/crypto.test.ts Updates existing crypto tests for the new exchange semantics.
service/README.md Updates public documentation to describe the new model and customization points.
service/package.json Adds ts-jest dev dependency.
package-lock.json Lockfile updates due to dependency changes.
Comments suppressed due to low confidence (1)

service/src/sdk.ts:224

  • In on(), you switch the target map to callSubscriptions for peerConnectionEvents, but you still read existing subscriptions from this.subscriptions (const sub = this.subscriptions.get(listener)). This means duplicates won’t be detected for call events and removals/updates can go to the wrong map. Use the selected subscriptions variable consistently for both get() and set().
        let subscriptions = this.subscriptions;

        if (peerConnectionEvents.includes(listener as PeerConnectionEventType)) {
            subscriptions = this.callSubscriptions;
        }

        const sub = this.subscriptions.get(listener);
        if (sub) {
            if (sub.has(callback)) {
                loggerWithCount.log(`Skpping, subscription: ${listener}`);
                return;
            }
            loggerWithCount.log(`Created +1 : ${listener}`);
            sub.add(callback);
        } else {
            loggerWithCount.log(`Created: ${listener}`);
            subscriptions.set(listener, new Set([callback]));
        }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread service/src/sdk.ts
Comment on lines +268 to 279
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<void> {
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<void> {
const publicEcdhKeyJwk = await this.symEncryption.exportKey();
await sharePublicKey({ aesKey: publicEcdhKeyJwk, publicKey: this.publicKey, sender: this.userId, channelId: this.channelId });
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shareSymmetricKeyMaterial() now sends symEncryption.exportKey() in plaintext via the aesKey field. With the current default symmetric strategy (EncryptionFactory.create().symmetricAesGcmEncryption), exportKey() serializes the raw AES key (JWK) which is secret material, so this change would leak the media key over the signaling channel. Either (a) make the default symmetric protocol the ECDH-based implementation and ensure exportKey() only returns a public key, or (b) keep AES-GCM as a strategy but continue wrapping its exported key (e.g., RSA) instead of sending it unencrypted, with the SDK branching based on the chosen protocol.

Copilot uses AI. Check for mistakes.
Comment thread service/src/sdk.ts

const logger = new Logger();
export const createChatInstance = (config?: Partial<configType>, encryptionStrategy?: EncryptionStrategy): IChatE2EE => {
export const createChatInstance = (config?: Partial<configType>): IChatE2EE => {
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the public API of createChatInstance by removing the encryptionStrategy parameter. If this package is already published/consumed, this is a breaking change (existing callers passing a second argument will fail type-checking/build). Consider keeping a backward-compatible overload (and deprecating it), or bumping the major version and updating the exported types/docs accordingly.

Suggested change
export const createChatInstance = (config?: Partial<configType>): IChatE2EE => {
export function createChatInstance(config?: Partial<configType>): IChatE2EE;
/**
* @deprecated The `encryptionStrategy` parameter is no longer used and is kept only for backward compatibility.
*/
export function createChatInstance(config: Partial<configType> | undefined, encryptionStrategy: ISymmetricEncryption): IChatE2EE;
export function createChatInstance(config?: Partial<configType>, _encryptionStrategy?: ISymmetricEncryption): IChatE2EE {

Copilot uses AI. Check for mistakes.
Comment thread service/src/sdk.ts
import { setConfig } from './configContext';
import { cryptoUtils } from './cryptoRSA';
import { type IAsymmetricEncryption } from './cryptoRSA';
import { EncryptionFactory } from './encryptionFactory';
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EncryptionFactory is no longer re-exported from the package entrypoint (exports"." points to src/sdk.ts), but the README and tests/documentation expect import { EncryptionFactory } from '@chat-e2ee/service' to work. Re-export EncryptionFactory (and any related types) from sdk.ts, or update the public docs/API to match what is actually exported.

Suggested change
import { EncryptionFactory } from './encryptionFactory';
import { EncryptionFactory } from './encryptionFactory';
export { EncryptionFactory } from './encryptionFactory';

Copilot uses AI. Check for mistakes.
Comment thread service/src/webrtc.ts
@@ -1,4 +1,4 @@
import { type ISymmetricEncryption } from "./cryptoAES";
import { ISymmetricEncryptionProtocol } from "./public/types";
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISymmetricEncryptionProtocol is only used as a type here; importing it as a value import can create an unnecessary runtime dependency (and potential circular deps since types.ts imports from webrtc.ts). Prefer import type { ISymmetricEncryptionProtocol } ... to make this type-only and robust across different transpilers/bundlers (especially since the package exports TS sources).

Suggested change
import { ISymmetricEncryptionProtocol } from "./public/types";
import type { ISymmetricEncryptionProtocol } from "./public/types";

Copilot uses AI. Check for mistakes.
Comment thread service/README.md

// 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.
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README claims ISymmetricEncryptionProtocol requires getRemoteAesKey(), getRawAesKeyToExport(), and setRemoteAesKey(), and that the default AesGcmEncryption uses “ECDH X25519 key exchange”. In the current code, ISymmetricEncryption/AesGcmEncryption expose init/exportKey/importRemoteKey/encryptData/decryptData and AesGcmEncryption exports the AES key itself (not an ECDH public key). Please update the README to match the actual public interfaces and the real default behavior (or update the implementation to match the documented API).

Suggested change
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.
Custom encryption protocol allows you to implement your own symmetric encryption for WebRTC media streams. The interface `ISymmetricEncryption` requires implementing `init()`, `exportKey()`, `importRemoteKey()`, `encryptData()`, and `decryptData()`. If omitted, `AesGcmEncryption` is used by default; it uses AES-GCM and synchronizes peers by exporting and importing the AES key material directly.

Copilot uses AI. Check for mistakes.
Comment thread service/src/cryptoecdh.ts
Comment on lines +11 to +12
* - A fresh 12-byte IV is generated per encryptData() call, eliminating
* replay attacks.
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc comment claims a fresh IV “eliminating replay attacks”, but IV uniqueness does not prevent replay; an attacker can replay the same (iv,ciphertext,tag) and it will authenticate/decrypt. Please reword to reflect what IV freshness actually guarantees (nonce reuse avoidance) and, if replay protection is desired, implement it at a higher layer (sequence numbers / counters / anti-replay window).

Suggested change
* - A fresh 12-byte IV is generated per encryptData() call, eliminating
* replay attacks.
* - A fresh 12-byte IV is generated per encryptData() call to avoid
* AES-GCM nonce reuse with the same key.
* - Replay protection is NOT provided by IV freshness alone; if needed,
* implement it at a higher layer using sequence numbers, counters,
* or an anti-replay window.

Copilot uses AI. Check for mistakes.
Comment thread service/src/cryptoecdh.ts
Comment on lines +21 to +25
* import { ECDHEncryption } from './cryptoECDH';
*
* EncryptionFactory.registerSymmetric('ECDH-X25519', () => new ECDHEncryption());
*
* const chat = createChatInstance({}, EncryptionFactory.create({ symmetric: 'ECDH-X25519' }));
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usage example in this header comment is inconsistent with the current codebase: it references ./cryptoECDH (wrong filename casing vs cryptoecdh.ts) and shows createChatInstance({}, EncryptionFactory.create({ symmetric: 'ECDH-X25519' })), but createChatInstance no longer accepts an EncryptionStrategy argument. Update the example to reflect the current public API (e.g., passing encryptionProtocol in config) so readers can actually follow it.

Suggested change
* import { ECDHEncryption } from './cryptoECDH';
*
* EncryptionFactory.registerSymmetric('ECDH-X25519', () => new ECDHEncryption());
*
* const chat = createChatInstance({}, EncryptionFactory.create({ symmetric: 'ECDH-X25519' }));
* import { ECDHEncryption } from './cryptoecdh';
*
* EncryptionFactory.registerSymmetric('ECDH-X25519', () => new ECDHEncryption());
*
* const chat = createChatInstance({ encryptionProtocol: 'ECDH-X25519' });

Copilot uses AI. Check for mistakes.
Comment on lines 6 to 8
/** Names of built-in symmetric encryption strategies. */
export type BuiltinSymmetricStrategy = 'AES-GCM';
export type BuiltinSymmetricStrategy = 'AES-GCM' | 'ECDH-X25519';

Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding 'ECDH-X25519' here is good, but note the factory’s default symmetric strategy is still 'AES-GCM' (see create()’s default in this file). With the updated SDK flow sending exportKey() material in plaintext, leaving the default as 'AES-GCM' would leak the raw AES key. Consider switching the default to 'ECDH-X25519' or making the SDK exchange conditional on the selected strategy so 'AES-GCM' continues to use a protected key-transport mechanism.

Copilot uses AI. Check for mistakes.
Comment on lines +76 to +81
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();
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test title says AesGcmEncryption “generates ECDH key pair”, but AesGcmEncryption (cryptoAES.ts) generates an AES-GCM key directly. Please rename the test description to match the implementation to avoid confusion about what is actually being exercised.

Copilot uses AI. Check for mistakes.
Comment thread service/tsconfig.json
Comment on lines 17 to +18
],
"skipLibCheck": true,
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting skipLibCheck: true will hide type errors in dependency .d.ts files (and can mask real compatibility issues, especially with the new global typed-array shim). If this was added to work around a specific type-definition problem, it’s safer to address that directly (e.g., adjust local types / remove global augmentation) rather than disabling lib checking project-wide.

Suggested change
],
"skipLibCheck": true,
]

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AES key should be encrypted before exchange

3 participants