Meefik's Blog

Freedom and Open Source

Serverless WebRTC conferencing with E2E encryption

05 Oct 2025 | webrtc javascript

Building reliable, privacy-respecting peer-to-peer conferencing can be surprisingly simple when you split responsibilities cleanly: media transport (WebRTC) and signaling (a tiny transport for exchanging SDP and ICE). I built a minimal library to demonstrate that split and to enable serverless workflows using whatever signaling channel you prefer — from in-memory drivers for demos to NATS-based pub/sub for distributed apps.

This post describes the library’s purpose, core design, how to use it, and a practical example of a NATS signaling driver with end-to-end encryption using the browser Web Crypto API.

demo

Just try it out: live demo | source code

Why this library

The library focuses on three goals:

Design overview

At its core, p2p is small:

Signaling expectations are intentionally simple: drivers produce messages scoped to namespaces (arrays/keys). The library uses namespaces such as [“sender”, room], [“receiver”, room, id], etc. Messages include typed payloads (invoke, offer, answer, candidate, sync, dispose).

Quick start

1. Install the library:

npm install p2p

2. Implement a signaling driver that supports on/off/emit.

Here’s a minimal conceptual example:

class MyDriver {
  on(namespace, handler) { /* ... */ }
  off(namespace, handler) { /* ... */ }
  emit(namespace, message) { /* ... */ }
}

Instantiate your driver:

const driver = new MyDriver();

3. Start a Receiver in the same room to discover and receive streams.

Instantiate Receiver with the same driver:

const receiver = new Receiver({ driver });
receiver.addEventListener('stream', (e) => {
  const { id, stream } = e.detail;
  // handle incoming MediaStream
});
receiver.addEventListener('message', (e) => {
  const { id, message } = e.detail;
  // handle incoming data message
});
receiver.addEventListener('dispose', (e) => {
  const { id } = e.detail;
  // handle peer disconnection
});

Start the receiver in the same room:

receiver.start({ room: 'demo-room' });

4. Create and start a Sender to broadcast local media.

Instantiate Sender with your driver and options:

const sender = new Sender({ driver });

Start the sender with the stream and a room name:

const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
});
sender.start({ stream, room: 'demo-room' });

NATS as a signaling transport with E2E encryption

NATS is a great lightweight pub/sub for distributed signaling. The demo repository includes a full driver implementation at demo/driver/nats.js; below is the compact approach and key ideas used there.

Here’s a simple driver implementation using the nats.ws module:

import { connect, StringCodec } from 'nats.ws';

const sc = StringCodec();

class NatsDriver extends Map {
  constructor({ servers } = {}) {
    super();
    this.servers = servers || ['wss://demo.nats.io:8443'];
  }

  async open() {
    this.nc = await connect({ servers: this.servers, noEcho: true });
  }

  async close() {
    await this.nc.drain();
  }

  on(namespace, handler) {
    const ns = namespace.join(':');
    const sub = this.nc.subscribe(ns, {
      callback: async (err, msg) => {
        if (err) return console.error(err);
        const payload = JSON.parse(sc.decode(msg.data));
        handler(payload);
      },
    });
    if (!this.has(ns)) {
      this.set(ns, new Map());
    }
    this.get(ns).set(handler, sub);
  }

  off(namespace, handler) {
    const ns = namespace.join(':');
    const sub = this.get(ns)?.get(handler);
    if (sub) {
      sub.unsubscribe();
      this.get(ns).delete(handler);
    }
    if (!this.get(ns)?.size) {
      this.delete(ns);
    }
  }

  async emit(namespace, message) {
    const ns = namespace.join(':');
    if (this.nc) {
      const data = sc.encode(JSON.stringify(message));
      this.nc.publish(ns, data);
    }
  }
}

If you want the signaling payloads to be encrypted end-to-end (so the NATS server only sees opaque blobs), you can apply symmetric encryption on top of the driver.

High-level strategy:

Here’s a compact encryption helper (browser) using Web Crypto API.

Derive AES-GCM CryptoKey from passphrase via SHA-256:

async function createEncryptionKey(secret) {
  const secretHash = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(secret),
  );
  return await crypto.subtle.importKey(
    'raw',
    secretHash,
    { name: 'AES-GCM' },
    false,
    ['encrypt', 'decrypt'],
  );
}

Prepend a 12-byte IV + ciphertext:

async function encrypt(payload, cryptoKey) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = new Uint8Array(
    await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, payload),
  );
  const data = new Uint8Array(iv.byteLength + ciphertext.byteLength);
  data.set(iv, 0);
  data.set(ciphertext, iv.byteLength);
  return data;
}

Extract IV and decrypt:

async function decrypt(data, cryptoKey) {
  const iv = data.slice(0, 12);
  const ct = data.slice(12);
  const payload = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, ct);
  return payload;
}

Example usage (conceptual snippet):

// Create a key from a human passphrase
const key = await createEncryptionKey('room-secret-passphrase');

// Encrypt message (payload is Uint8Array)
const payload = new TextEncoder().encode('Secret message');
const data = await encrypt(payload, key);

// returns Uint8Array with IV+ciphertext
console.log('encrypted data', data);

// Decrypt message
const decryptedBytes = await decrypt(data, key);
const message = new TextDecoder().decode(decryptedBytes);

// returns original message
console.log('decrypted message', message);

Integrate encryption into the NATS driver by wrapping emit/on methods to encrypt/decrypt payloads. Here is diff of the modified methods:

-  async open() {
+  async open(secret) {
     this.nc = await connect({ servers: this.servers, noEcho: true });
+    if (secret) {
+      this.cryptoKey = await createEncryptionKey(secret);
+    }
   }
 
   async close() {
     const sub = this.nc.subscribe(ns, {
       callback: async (err, msg) => {
         if (err) return console.error(err);
-        const payload = JSON.parse(sc.decode(msg.data));
+        let data = msg.data;
+        if (this.cryptoKey) {
+          data = await decrypt(data, this.cryptoKey);
+        }
+        const payload = JSON.parse(sc.decode(data));
         handler(payload);
       },
     });
    if (!this.has(ns)) {
      this.set(ns, new Map());
    }
    this.get(ns).set(handler, sub);
  }

   async emit(namespace, message) {
     const ns = namespace.join(':');
     if (this.nc) {
-      const data = sc.encode(JSON.stringify(message));
+      let data = sc.encode(JSON.stringify(message));
+      if (this.cryptoKey) {
+        data = await encrypt(data, this.cryptoKey);
+      }
       this.nc.publish(ns, data);
     }
   }

Operational considerations:

Conclusion

This project shows how a small, well-factored library can enable flexible, serverless peer-to-peer conferencing while giving you control over signaling and privacy. The NATS driver with E2E encryption is a practical option for distributed systems where you want to keep signaling private without a heavy backend.

See the live demo for runnable examples and the full NATS driver implementation: demo/driver/nats.js.

Comments