End-to-end encrypted P2P chat, right in your browser. No server, no accounts, no stored history.
by Hardlint Cybersecurity Team
This project is distributed for educational and security research purposes. It does not guarantee network-level anonymity: it protects the content of conversations, not necessarily who is connecting. Read the Attack Surface and Known Limitations section before using it for sensitive communications.
Use of a trustworthy VPN on both devices is strongly recommended.
- 🔐 End-to-end encryption — AES-GCM 256-bit, key derived via PBKDF2 (100,000 iterations)
- 🌐 True P2P connection — direct WebRTC link between the two devices, no central server relaying messages
- 🚫 Zero persistence — no cookies, no localStorage, no database: close the tab and nothing remains
- 🔑 Single shared secret — a randomly generated Room Key (100 characters), no manual technical configuration required
- 🧹 Panic Purge — one button instantly wipes keys, connection state, and visible chat history
- 📡 Reliable connectivity — 18 STUN/TURN servers configured as fallbacks to work even behind restrictive NATs (4G/5G, corporate networks)
- Open the page (must be served over HTTPS — e.g. via GitHub Pages, not opened as a local file)
- Host: click
[1] INITIALIZE ROOM→ copy the generated Room Key - Send the Room Key to your contact through a different channel (in person, voice call, another encrypted app)
- Guest: click
[2] CONNECT TO ROOM→ paste the received Room Key - Wait for the connection (usually a few seconds) → the chat opens
- If the connection isn't established within 2 minutes, the Room Key expires automatically: generate a new one with the dedicated button
- Modern browser with WebRTC and Web Crypto API support (recent Chrome, Firefox, Edge, Safari)
- Internet access on both devices
- The page must be served over HTTPS (Secure Context is required for Web Crypto API and WebRTC) — it does not work when opened as a local file
- Both parties must have the page open at the same time during the connection attempt
- The Room Key must be copied in full, exactly 100 characters, with no extra spaces or line breaks
Zero-Trace Terminal is a static web application (HTML/CSS/JS, no proprietary backend) that allows two devices to establish a direct peer-to-peer connection via WebRTC, exchanging end-to-end encrypted text messages.
Main components:
| Component | Role | Technology |
|---|---|---|
| User interface | Retro terminal UI | Plain HTML/CSS |
| Signaling | Makes the two peers "find" each other | PeerJS (public cloud broker) |
| Data transport | Encrypted P2P channel | WebRTC DataChannel |
| NAT traversal | Punching through firewalls/NAT | STUN + TURN (ICE) |
| Encryption | Message content protection | AES-GCM 256-bit + PBKDF2 |
| Hosting | Code distribution | GitHub Pages (static) |
There is no proprietary application server: the code runs entirely in each user's browser. The only external infrastructure involved is used to "introduce" the two devices to each other (signaling) and, if needed, to relay traffic when a direct connection isn't possible (TURN).
When a user clicks "INITIALIZE ROOM (HOST)":
- A random 100-character string is generated (
generate100CharCode()), usingcrypto.getRandomValues()— a cryptographically secure random number generator (notMath.random(), which is unsuitable for cryptographic purposes). - The character set includes uppercase/lowercase letters, digits, and special symbols (
-_!@#$%^&*), maximizing entropy within 100 characters.
This string (the Room Key) is the only shared secret the two parties need to exchange, out-of-band (e.g. voice message, in person, another encrypted channel).
Two independent values, each with a different purpose, are derived from the Room Key:
A. Message encryption key (PBKDF2 → AES-GCM)
PBKDF2(
password = Room Key,
salt = "p2p-zero-trace-salt-v1" (fixed, hardcoded),
iterations = 100,000,
hash = SHA-256
) → 256-bit AES-GCM key
B. PeerJS identifier (truncated SHA-256)
SHA-256(Room Key) → first 32 hex characters, prefixed with "ztt-"
This ID is used solely so that Host and Guest can "find" each other on the PeerJS signaling broker, without exchanging anything beyond the Room Key. It plays no cryptographic role.
Note on the fixed salt: the PBKDF2 salt is hardcoded and identical across all sessions. This is acceptable because the "password" (Room Key) already has very high entropy (100 random characters) — a fixed salt only weakens security in scenarios involving weak, reused passwords, which does not apply here.
- The Host creates a
Peerobject, registering with the public PeerJS cloud broker using the ID derived from the Room Key. - The Guest, after pasting the same Room Key, computes the same ID and calls
peer.connect(id). - The PeerJS broker only mediates this initial exchange (who wants to talk to whom) — it never sees or transmits message content, which by that point travels over a separate WebRTC channel.
Once the two Peers have "introduced" themselves, WebRTC starts ICE negotiation to find a valid network path:
- Host candidates — the device's local IP addresses
- Server-reflexive (srflx) candidates — public IP discovered via STUN
- Relay candidates — allocated via TURN, used only if a direct connection fails
Configured ICE servers (in priority order):
- Dedicated Metered.ca TURN (own credentials, not shared) —
stun.relay.metered.ca/global.relay.metered.ca - 7 public STUN fallbacks (Google ×3, Cloudflare, Twilio, Nextcloud, stunprotocol.org, freestun)
- 10 additional public TURN fallback endpoints (OpenRelay, freestun, numb.viagenie, ExpressTurn) — used only if the dedicated TURN also fails
The browser automatically tries every combination and selects the first one that establishes a working channel (standard ICE algorithm, handled internally by WebRTC).
- If the connection isn't established within 120 seconds, the session is considered expired:
- The
PeerandDataConnectionare destroyed (peer.destroy(),conn.close()) - The status shows
[EXPIRED] Room Key no longer valid - A button appears to generate a new Room Key (Host) or enter a new one (Guest)
- The
- This prevents a Room Key from remaining "listening" indefinitely on the public broker.
Every message is individually encrypted before being sent over the DataChannel:
1. Generate a random 12-byte IV (crypto.getRandomValues)
2. ciphertext = AES-GCM-Encrypt(key, IV, plaintext)
3. payload = IV || ciphertext (concatenated, IV in plaintext at the front)
4. Send payload as a Uint8Array via conn.send()
On receipt:
1. Extract the first 12 bytes as the IV
2. The rest is the ciphertext (includes the 16-byte GCM authentication tag at the end)
3. plaintext = AES-GCM-Decrypt(key, IV, ciphertext)
Security properties guaranteed by AES-GCM:
- Confidentiality — nobody without the key can read the content
- Integrity/authenticity — any tampering with the packet in transit causes decryption to fail (
[ERR: DECRYPTION_FAILED]), rather than silently producing corrupted output
What this scheme does NOT cover:
- Forward secrecy across sessions — if the same Room Key were reused across multiple sessions (not the normal flow, which generates a new one every time), all those sessions would share the same derived key
- Peer identity authentication — anyone who knows the Room Key can connect; there is no cryptographic verification of "who" is on the other end beyond possession of the shared key
| Data | Persistence | Notes |
|---|---|---|
| Room Key | None | Only in a JS variable, gone on close/reload |
| Derived AES-GCM key | None | Same, never written to disk |
| Chat messages | None | Only live in the DOM/RAM, no localStorage/IndexedDB |
| Cookies | None | The project uses none at all |
| Application logs | Local DevTools console only | Never sent anywhere, gone when the tab closes |
The "PANIC: PURGE SESSION" button explicitly forces:
- Closure of the
PeerConnection/DataConnection - Zeroing of the encryption key in memory
- Wiping of all UI fields and the displayed message history
Key point to understand: encryption protects content, not connection metadata.
| Service | What it can see | What it CANNOT see |
|---|---|---|
| GitHub Pages | IP and timestamp of whoever loads the page | Message content, Room Key |
| PeerJS broker (public cloud) | IP of Host and Guest, when they connect, their Peer ID (a hash of the Room Key, not the Key itself) | Message content |
| Metered.ca TURN (if used as relay) | Source/destination IP, ports, amount of data transferred | Message content (already travels encrypted) |
| Each user's ISP | That a connection is being made to github.io / metered.ca / a PeerJS server | Message content |
Recommended mitigation (outside the code): use a trustworthy VPN (e.g. Mullvad, with an anonymously created account) on both devices, to avoid exposing real IP addresses to these third-party services. The project displays an explicit warning to this effect on the splash screen.
| Risk | Description | Mitigated? |
|---|---|---|
| Message content interception | MITM on TURN/network traffic | ✅ Yes — end-to-end AES-GCM |
| Message tampering in transit | Packet manipulation | ✅ Yes — GCM authentication tag |
| Deanonymization via IP metadata | IP↔identity correlation through third-party logs | |
| Metered.ca account compromise | TURN credentials are in the public code (base64-obfuscated, not encrypted) | |
| Dependency on third-party services | GitHub Pages, PeerJS broker, Metered TURN — if suspended, the app stops working | |
| Room Key reuse | Would compromise forward secrecy across sessions that reuse it | ✅ Not applicable in normal flow (a new Key every session) |
- Frontend: HTML5, CSS3 (no framework)
- Cryptography: Browser-native Web Crypto API (
crypto.subtle) — PBKDF2, AES-GCM, SHA-256 - P2P/Signaling: PeerJS v1.5.4 (a wrapper library over native WebRTC), loaded from a public CDN (unpkg.com)
- NAT Traversal: WebRTC ICE (STUN/TURN) — 18 endpoints configured in total
- Hosting: GitHub Pages (static, automatic HTTPS)
- Browser requirements: WebRTC support, Web Crypto API, ES6+ — requires a secure context (HTTPS); does not work from
file://orcontent://
- v1 — Native WebRTC with manual SDP exchange (copy/paste offer/answer)
- v2 — Migrated to PeerJS, automatic connection based on the Room Key, removed manual SDP fields
- v3 — Added dedicated Metered.ca TURN + multiple public STUN/TURN fallbacks
- v4 — Detailed ICE diagnostics (candidate logging, connection states) — fixed a bug that overwrote PeerJS's internal event handlers
- v5 — Timeout extended to 120s, Room Key expiry system with manual regeneration, VPN warning on splash screen, TURN credential obfuscation
Hard-Chat is 100% free, open-source, and maintained by the Hardlint Cybersecurity Team. We don't run ads and we don't sell data. If you believe in our mission and want to help us fund our future self-hosted infrastructure (custom STUN/TURN servers), consider supporting us!
Solana (SOL) donation address:
GSsqZCtDC7rf53U6gC5cJ4weAYYT9g7twxz9t15mfRDV
This software is provided "as is", without warranties of any kind. The developers are not responsible for any improper or illegal use of this tool. Users are solely responsible for complying with applicable laws in their jurisdiction.
This document is provided for informational and technical documentation purposes only. It does not constitute legal advice regarding regulatory compliance, privacy, or liability for use.
Hardlint Cybersecurity Team