Initial
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
# 🦀 🔮 CrabChat v1.0: The Equestrian & Goetic E2EE Grimoire
|
||||||
|
|
||||||
|
Welcome to **CrabChat**, a peer-to-peer (P2P) secure messaging web application forged in the sacred subterranean mushroom rings of Princess Pi and blessed by Prince Stolas's astral grimoires. Built for fuzzy paws, digital equestrians, and privacy-paranoid creatures alike, CrabChat establishes a zero-trust, end-to-end encrypted (E2EE) mesh network directly in your browser without central server snooping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Features & The Magic Within
|
||||||
|
|
||||||
|
* **Zero-Knowledge Architecture:** No chat logs, databases, or transcripts touch the server. Messages flow strictly through encrypted WebRTC data channels peer-to-peer.
|
||||||
|
* **Argon2id Key Derivation:** Your cryptographic identity is protected by memory-hard Argon2id hashing, ensuring brute-force attempts bounce off your security barriers like armor on a royal guard pony.
|
||||||
|
* **Perfect Forward Secrecy (PFS):** Every peer connection negotiates fresh ephemeral keys signed by Ed25519 identity keys, ensuring past or future chats remain safe even if a key is ever compromised.
|
||||||
|
* **Encrypted File Transports:** Seamlessly drag and drop files up to 100MB, encrypted client-side with secretbox primitives before crossing the wire.
|
||||||
|
* **TURN Relay Fallback:** Easily plug in a TURN server configuration to punch through stubborn corporate firewalls or symmetric NATs when direct peer hole-punching fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚖️ Pros and Cons
|
||||||
|
|
||||||
|
### Pros
|
||||||
|
|
||||||
|
* **Absolute Data Sovereignty:** Your keys never leave your device (stored safely via local storage or exported as JSON).
|
||||||
|
* **Decentralized Mesh:** Once peers connect via the lightweight Node.js signaling server, communication is entirely browser-to-browser.
|
||||||
|
* **Whimsical UX:** Fully colorized ANSI terminal background art, dynamic glowing favicons, and custom pastel themes fit for any royal court or den.
|
||||||
|
|
||||||
|
### Cons
|
||||||
|
|
||||||
|
* **Volatile Memory:** If you clear your browser data or lose your passphrase without exporting your identity key, your identity vanishes into the ether like a frightened forest faun.
|
||||||
|
* **Network Restrictions:** Strict enterprise or school firewalls may block direct P2P connections unless a dedicated TURN server is provided in the login panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Technical Details
|
||||||
|
|
||||||
|
* **Cryptographic Engine:** `libsodium.js` (Ed25519 signature keys, X25519 boxes, secretbox stream encryption).
|
||||||
|
* **Key Derivation Function:** Argon2id (with an automatic fallback to WebCrypto PBKDF2-SHA256 if needed).
|
||||||
|
* **Transport Layer:** WebRTC `RTCDataChannel` running over SCTP, orchestrated via a minimal Node.js and Socket.io signaling backbone.
|
||||||
|
* **State Management:** Single-page application architecture featuring dynamic DOM manipulation and real-time packet verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐾 How to Use the Web App
|
||||||
|
|
||||||
|
1. **Awaken Your Identity:** When you first open the portal, input a robust passphrase (minimum 20 characters, including uppercase, lowercase, numbers, and special symbols) to forge a new cryptographic identity under Princess Pi’s watch. If you already have a key file, use the **Import Key** button.
|
||||||
|
2. **Configure TURN (Optional):** If you or your peers are connecting across different external networks behind aggressive NAT routers, expand the TURN configuration panel and enter your relay server details (`turn:your-server.com:3478`, username, and credential).
|
||||||
|
3. **Unlock & Enter Room:** Click **Unlock / Generate** to enter the main chat interface. A unique room ID will be automatically generated, or you can specify one in the landing input.
|
||||||
|
4. **Invite Your Pack:** Click **Copy Link** to share the secure invite URL with your fellow creatures.
|
||||||
|
5. **Chat & Share Files:** Once the PFS cryptographic handshake completes successfully in the status feed, type encrypted messages or click **Send File** to beam files securely across the mesh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐧 Installing the Node Server on a Linux Box
|
||||||
|
|
||||||
|
Deploying your own CrabChat signaling node on a Debian-like Linux server (or Kali WSL environment) takes only a few moments. Follow these steps:
|
||||||
|
|
||||||
|
### 1. Update System & Install Node.js
|
||||||
|
|
||||||
|
Ensure your system packages are fresh and install Node.js and npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
sudo apt install nodejs npm -y
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create the Project Directory
|
||||||
|
|
||||||
|
Set up a clean home for your server and libsodium library dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/crabchat/public
|
||||||
|
cd /opt/crabchat
|
||||||
|
npm init -y
|
||||||
|
npm install socket.io
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Obtain Libsodium Assets
|
||||||
|
|
||||||
|
Copy or download the required libsodium browser distribution files (`libsodium.js` and `libsodium-wrappers.js`) and place them directly into your `public/` directory alongside your `index.html` file.
|
||||||
|
|
||||||
|
### 4. Create the Signaling Server (`server.js`)
|
||||||
|
|
||||||
|
Create a basic Express or HTTP socket server to handle room signaling. Create `server.js` in `/opt/crabchat/`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = new Server(server);
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('join-room', (room) => {
|
||||||
|
socket.join(room);
|
||||||
|
socket.to(room).emit('user-joined', socket.id);
|
||||||
|
|
||||||
|
socket.on('offer', (data) => {
|
||||||
|
io.to(data.target).emit('offer', { sender: socket.id, offer: data.offer });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('answer', (data) => {
|
||||||
|
io.to(data.target).emit('answer', { sender: socket.id, answer: data.answer });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ice-candidate', (data) => {
|
||||||
|
io.to(data.target).emit('ice-candidate', { sender: socket.id, candidate: data.candidate });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
socket.to(room).emit('user-disconnected', socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[Stolas Grimoire Node] CrabChat server active on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Launch the Server
|
||||||
|
|
||||||
|
Start your server directly or manage it with a process manager like PM2 to keep it running continuously in the background:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo npm install -g pm2
|
||||||
|
pm2 start server.js --name "crabchat"
|
||||||
|
pm2 startup
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Your private, E2EE equestrian chat node is now online and ready to welcome encrypted connections!
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install npm wget -y
|
||||||
|
mkdir -p ~/CrabChat/public
|
||||||
|
cd ~/CrabChat
|
||||||
|
npm init -y
|
||||||
|
npm install express socket.io selfsigned
|
||||||
|
cd ~/CrabChat; openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=10.0.0.87"
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,898 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>CrabChat - Secure E2EE P2P Mesh (Equestrian Node)</title>
|
||||||
|
<link id="dynamic-favicon" rel="icon" href="/favicon.ico">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Comic Code Regular', "Comic Sans MS", "Comic Sans", cursive, sans-serif;
|
||||||
|
background: transparent;
|
||||||
|
color: #4a3b5c;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#ascii-bg {
|
||||||
|
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||||
|
z-index: -1; font-family: monospace; font-size: 11px; line-height: 1.05; opacity: 0.55;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #ffffffdd; border: 2px solid #e2d4f0; border-radius: 16px;
|
||||||
|
padding: 24px; width: 100%; max-width: 650px; box-shadow: 0 8px 24px rgba(210, 190, 230, 0.3);
|
||||||
|
box-sizing: border-box; margin-bottom: 20px; backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
h1, h2 { color: #6a4c93; margin-top: 0; text-align: center; }
|
||||||
|
input, button, select {
|
||||||
|
background: #f7f1fb; color: #4a3b5c; border: 2px solid #d8c8e8;
|
||||||
|
border-radius: 10px; padding: 12px; margin: 8px 0; width: 100%; box-sizing: border-box;
|
||||||
|
font-family: inherit; font-size: 14px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: linear-gradient(135deg, #ffd1dc 0%, #e0c3fc 50%, #c1d3fe 100%);
|
||||||
|
font-weight: bold; cursor: pointer; transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
button.danger {
|
||||||
|
background: linear-gradient(135deg, #ffccd5 0%, #ffb3c1 100%);
|
||||||
|
color: #721c24;
|
||||||
|
border-color: #f5c6cb;
|
||||||
|
}
|
||||||
|
button:hover { opacity: 0.9; }
|
||||||
|
button:disabled, select:disabled { background: #e8e8e8; color: #aaa; border-color: #ddd; cursor: not-allowed; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
#chat-box { width: 100%; height: 300px; border: 2px solid #d8c8e8; border-radius: 10px; overflow-y: scroll; padding: 12px; margin-bottom: 12px; background: #ffffff; box-sizing: border-box; }
|
||||||
|
.msg { margin-bottom: 8px; word-wrap: break-word; }
|
||||||
|
.msg.system { color: #8c78a0; font-style: italic; }
|
||||||
|
.msg.private { color: #d81b60; font-weight: bold; }
|
||||||
|
#status { font-weight: bold; color: #7a6095; margin-bottom: 10px; }
|
||||||
|
.flex-row { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.file-input-wrapper { position: relative; overflow: hidden; display: inline-block; width: 100%; margin: 0; }
|
||||||
|
.file-input-wrapper input[type=file] { font-size: 100px; position: absolute; left: 0; top: 0; opacity: 0; cursor: pointer; }
|
||||||
|
progress { width: 100%; height: 16px; margin: 5px 0; }
|
||||||
|
.turn-config { background: #fdfbf7; border: 1px dashed #d8c8e8; padding: 12px; border-radius: 10px; margin: 10px 0; }
|
||||||
|
</style>
|
||||||
|
<script src="/libsodium.js"></script>
|
||||||
|
<script src="/libsodium-wrappers.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<pre id="ascii-bg"> .. .....
|
||||||
|
....------..
|
||||||
|
..:--------.. .......
|
||||||
|
..----------.. ....---..
|
||||||
|
...---------=------::....:----:..
|
||||||
|
..-----=+**************=-----:...
|
||||||
|
.----+****************=-----...
|
||||||
|
.--=****************=-----=**+:::--:....
|
||||||
|
.-=#*********##**++=---====++**+-----:.
|
||||||
|
..:==********#=.+@%-==++===++===++*=----...
|
||||||
|
.:=::******%%+-::=++====+==++====+**=--...
|
||||||
|
.:+..+*****--@===-:-%--====-=+====+*#=:..
|
||||||
|
.-=.-****#=.#+===--=+===----======+*+..
|
||||||
|
.:=:+****#@#+=====-**-----*+====++**:.
|
||||||
|
.. .=****=--======-..%-----%+==++***+-..
|
||||||
|
..=++***+-.----======..*-----#+==+***#%#+..
|
||||||
|
.. .:-..---=====--------=-+==+**-@*. ............::::::....
|
||||||
|
..=:..--===+-:-------*%+==*+#%**=. .....:-------------------------:.
|
||||||
|
..............-:..--===+-..:-------+==+==.:... ...:----------------------------::.
|
||||||
|
......:----------::=:...--==+--:.:-------+==+=:.... ....--------------------------:.....
|
||||||
|
..:::----------------=-::.:--===---:.:------=--:.... ...:---------------------------...
|
||||||
|
..:------------------=--:..:--=+=--------::.:+-..... ...------------------------------..
|
||||||
|
...:-----------------:....----==-------:...=%*.......:::--------------------------------:..
|
||||||
|
...--------------:+......---------------:+%%#.----------------------------:.......-----:..
|
||||||
|
.:----===-------:-.. .:--------------*%%%%=--------------========------:.......-------:...
|
||||||
|
..-:.:------------....-.=---------=#%%%%##%------------=-----------------------------------..
|
||||||
|
.:--:....:--------:##*=:.-------=#%%%%%%#%+-----------=-------------------------------------:
|
||||||
|
..:-----------------::*%%#-------=###%%%%##=--------------------------------------:............
|
||||||
|
..:-------------------:.-#%%##+==+%%%%%%#%+------------------------...:::--------------::::::..
|
||||||
|
...:----------------------..-#%#%%%%%%%%*+#%=-------------------------=-:....:----------------:...
|
||||||
|
--------------------------:.-#%%%%%%%%+#@%%%--------------------------=++=-::---------------:...
|
||||||
|
---------------------------...+%%%%%]%%%%#---------------------------------------------:....
|
||||||
|
............------.....----:....-#%%%%%%%*=------------------------------::+=---------.......
|
||||||
|
..:-... ....-----.. ....-=-------------------------------------.=*+=.......
|
||||||
|
..---:....:-----:.......:--------------------------:-------------==+=-......
|
||||||
|
.:-------------..........:---------------------------..:-------------==..........
|
||||||
|
..-------------:... ...--------:...----------------....:-----------==-...........
|
||||||
|
..:----==------:..-.... ..--------:...:---------------.....:----------=+=.....
|
||||||
|
..------==+=++==-..+--:. .:--------.....-----------=*%=.. ..----------=+=......
|
||||||
|
....------------........:... ..:--------.....-------+#%%#@%.. ...----------=+=......
|
||||||
|
....---------===:............... .:----=+*#=. ..--+#**+%@@@#%+. ..:-:..=----=*=:.....
|
||||||
|
:............:=::+=:--:=.=-.::=... ..*@*-+%@@@:.....-@@#%@*#@@@#%:.. ..-:..---:-#**+-.........
|
||||||
|
.. ...::::....::-.:.::-.. ..*%:..*@@@*.. ..-%#%@@+%@@@@=.. ...::.=#=..-%*++=:... ...
|
||||||
|
...... .......... ..**...*#=*%:. .-*#@%+#%@@@@+... ..*%@%+...+%+=*+=-...
|
||||||
|
..*#:.-#+..+-. ..-*#**%%*%@@%=.. ..-%@@*:..+@%=:+**+=:..
|
||||||
|
..*@#*%%=..==. .-*%@#%@##@@@#-. ...#@@%*-=%@@#:..-=++=-
|
||||||
|
.:#@*-:++..==. .=#%@%*@@*%*=+*:....:.+@*::+%@@@%=......-*
|
||||||
|
.-%+...=%##%-. .+@*@%*%@##*-:*+..:-==*@=..:#@+:=*:.......
|
||||||
|
.:*#:..:*@@@%:. .-%@*%@#*%*#=..-%-....:+#=-.:#%-.:+-.......
|
||||||
|
.+@#=.-*@@@@*.. ....-%@@@@@@=..+%*.. .-%--=+#%-.:*-. ....
|
||||||
|
.-=++++++++=... .:***+++==::::... .-%#=-*#%#**#-. </pre>
|
||||||
|
|
||||||
|
<div id="auth-section" class="card">
|
||||||
|
<h1 id="main-title" style="font-size: 28px;">🦀 CrabChat</h1>
|
||||||
|
<h2 id="auth-title">Awaiting Princess Pi's Cryptographic Grimoire...</h2>
|
||||||
|
|
||||||
|
<input type="text" id="room-input" placeholder="Room ID (Leave blank to generate an invite-only room)">
|
||||||
|
<input type="text" id="username" placeholder="Username (Optional)">
|
||||||
|
<input type="password" id="passphrase" placeholder="Passphrase (Min 20 chars, Upper, Lower, Digit, Special)">
|
||||||
|
|
||||||
|
<div class="turn-config">
|
||||||
|
<div style="font-size: 12px; font-weight: bold; margin-bottom: 5px; color: #6a4c93;">Optional TURN Server (Fixes ICE/Firewall Connection Failures):</div>
|
||||||
|
<input type="text" id="turn-url" placeholder="turn:your-turn-server.com:3478" style="margin: 4px 0; font-size: 12px; padding: 8px;">
|
||||||
|
<input type="text" id="turn-username" placeholder="TURN Username" style="margin: 4px 0; font-size: 12px; padding: 8px;">
|
||||||
|
<input type="password" id="turn-credential" placeholder="TURN Password / Secret" style="margin: 4px 0; font-size: 12px; padding: 8px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="auth-btn">Unlock / Generate</button>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px; border-top: 1px dashed #d8c8e8; padding-top: 15px; display: flex; gap: 10px; align-items: center;">
|
||||||
|
<button id="export-key-btn" style="flex: 1; margin: 0; font-size: 13px; padding: 8px;">💾 Export Key</button>
|
||||||
|
<div class="file-input-wrapper" style="flex: 1; margin: 0;">
|
||||||
|
<button style="width: 100%; margin: 0; font-size: 13px; padding: 8px;">📁 Import Key</button>
|
||||||
|
<input type="file" id="import-key-file" accept=".json,.key,.txt" style="margin: 0;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<button id="destroy-identity-auth-btn" class="danger" style="font-size: 13px; padding: 8px; margin: 0;">⚠️ Destroy Identity</button>
|
||||||
|
</div>
|
||||||
|
<p id="auth-status" style="text-align: center; color: #8c78a0; font-size: 13px; margin-top: 10px;"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chat-section" class="card hidden">
|
||||||
|
<h1 id="main-title-chat" style="font-size: 28px;">🦀 CrabChat</h1>
|
||||||
|
|
||||||
|
<div class="header-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; font-size: 14px; flex-wrap: wrap; gap: 10px;">
|
||||||
|
<div><strong>Identity:</strong> <span id="my-identity"></span></div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
|
<strong>Room:</strong> <span id="current-room-display"></span>
|
||||||
|
<button id="copy-room-btn" style="width:auto; padding: 4px 8px; margin: 0;">📋 Copy Link</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 5px;">
|
||||||
|
<button id="chat-export-key-btn" style="width: auto; padding: 4px 8px; margin: 0; font-size: 12px;">💾 Export</button>
|
||||||
|
<div class="file-input-wrapper" style="display: inline-block; width: auto; margin: 0;">
|
||||||
|
<button style="width: auto; padding: 4px 8px; margin: 0; font-size: 12px;">📁 Import</button>
|
||||||
|
<input type="file" id="chat-import-key-file" accept=".json,.key,.txt" style="margin: 0;" />
|
||||||
|
</div>
|
||||||
|
<button id="destroy-identity-chat-btn" class="danger" style="width: auto; padding: 4px 8px; margin: 0; font-size: 12px;">⚠️ Destroy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status">Status: Connecting to signaling server...</div>
|
||||||
|
<div id="chat-box"></div>
|
||||||
|
|
||||||
|
<!-- File Transfer & Message Controls -->
|
||||||
|
<div style="margin-bottom: 10px; display: flex; gap: 10px; align-items: center;">
|
||||||
|
<div class="file-input-wrapper" style="flex: 1;">
|
||||||
|
<button style="margin: 0; font-size: 13px; padding: 8px;">📎 Send File (Max 100MB)</button>
|
||||||
|
<input type="file" id="file-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="file-progress-container" class="hidden">
|
||||||
|
<span id="file-progress-label" style="font-size: 12px; color: #6a4c93;">Uploading file...</span>
|
||||||
|
<progress id="file-progress" value="0" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-row">
|
||||||
|
<select id="recipient-select" style="width: 30%; margin:0;" disabled>
|
||||||
|
<option value="all">Everyone</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="message-input" placeholder="Type an encrypted message (max 2000 chars)..." maxlength="2000" disabled style="margin:0; width: 50%;" />
|
||||||
|
<button id="send-btn" disabled style="width: 20%; margin:0;">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script>
|
||||||
|
/* =========================================================================
|
||||||
|
ARGON2ID & PFS SETUP CONFIGURATIONS
|
||||||
|
========================================================================= */
|
||||||
|
const ARGON2_OPSLIMIT = window.sodium?.crypto_pwhash_OPSLIMIT_MODERATE || 3;
|
||||||
|
const ARGON2_MEMLIMIT = window.sodium?.crypto_pwhash_MEMLIMIT_MODERATE || 268435456; // 256MB
|
||||||
|
const MAX_MESSAGE_LENGTH = 2000;
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||||
|
const CHUNK_SIZE = 64 * 1024; // 64KB chunks for WebRTC DataChannel
|
||||||
|
|
||||||
|
let myKeyPair = null;
|
||||||
|
let myPublicKeyHex = null;
|
||||||
|
let myUsername = "Anonymous";
|
||||||
|
let currentRoom = null;
|
||||||
|
|
||||||
|
// PFS Session Store: maps userId -> ephemeral precalculated shared secret session key
|
||||||
|
const sessionKeys = {};
|
||||||
|
const incomingFiles = {};
|
||||||
|
|
||||||
|
const socket = io({ autoConnect: false });
|
||||||
|
const peerConnections = {};
|
||||||
|
const dataChannels = {};
|
||||||
|
const peerIdentities = {};
|
||||||
|
|
||||||
|
const rawStunServers = [
|
||||||
|
"stun.nextcloud.com:443",
|
||||||
|
"stun.services.mozilla.com:3478",
|
||||||
|
"stun.stunprotocol.org:3478",
|
||||||
|
"stun.freeswitch.org:3478"
|
||||||
|
];
|
||||||
|
|
||||||
|
const pastelColors = ['#ffd1dc', '#ffb7b2', '#ffdac1', '#e2f0cb', '#b5ead7', '#c7ceea', '#e0c3fc', '#f3c6ff', '#d8b4fe', '#bae1ff', '#ffffba'];
|
||||||
|
|
||||||
|
function setFavicon(state) {
|
||||||
|
const favicon = document.getElementById('dynamic-favicon');
|
||||||
|
if (!favicon) return;
|
||||||
|
if (state === 'good') favicon.href = '/favicon-good.ico';
|
||||||
|
else if (state === 'bad') favicon.href = '/favicon-bad.ico';
|
||||||
|
else favicon.href = '/favicon.ico';
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerGoodFaviconPulse() {
|
||||||
|
setFavicon('good');
|
||||||
|
setTimeout(() => setFavicon('normal'), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateAsciiBg() {
|
||||||
|
const asciiEl = document.getElementById('ascii-bg');
|
||||||
|
if (!asciiEl) return;
|
||||||
|
asciiEl.style.color = pastelColors[Math.floor(Math.random() * pastelColors.length)];
|
||||||
|
setTimeout(animateAsciiBg, Math.floor(Math.random() * (600 - 5 + 1)) + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
animateAsciiBg();
|
||||||
|
try {
|
||||||
|
await sodium.ready;
|
||||||
|
init();
|
||||||
|
} catch (e) {
|
||||||
|
setFavicon('bad');
|
||||||
|
document.getElementById('auth-title').innerText = "Libsodium Failed to Load!";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initHeaderAnimation(elementId) {
|
||||||
|
const headerEl = document.getElementById(elementId);
|
||||||
|
if (!headerEl) return;
|
||||||
|
const text = headerEl.textContent;
|
||||||
|
headerEl.innerHTML = '';
|
||||||
|
const pastelAnimColors = ['#ffd1dc', '#ffb7b2', '#ffdac1', '#e2f0cb', '#b5ead7', '#c7ceea', '#e0c3fc', '#f3c6ff', '#d8b4fe'];
|
||||||
|
|
||||||
|
text.split('').forEach(char => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = char;
|
||||||
|
headerEl.appendChild(span);
|
||||||
|
function randomize() {
|
||||||
|
span.style.color = pastelAnimColors[Math.floor(Math.random() * pastelAnimColors.length)];
|
||||||
|
setTimeout(randomize, Math.floor(Math.random() * 595) + 5);
|
||||||
|
}
|
||||||
|
randomize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validatePassphrase(passphrase) {
|
||||||
|
if (passphrase.length < 20) return "Passphrase must be at least 20 characters long.";
|
||||||
|
if (!/[a-z]/.test(passphrase)) return "Passphrase must contain at least one lowercase letter.";
|
||||||
|
if (!/[A-Z]/.test(passphrase)) return "Passphrase must contain at least one uppercase letter.";
|
||||||
|
if (!/[0-9]/.test(passphrase)) return "Passphrase must contain at least one digit.";
|
||||||
|
if (!/[^A-Za-z0-9]/.test(passphrase)) return "Passphrase must contain at least one special character.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
initHeaderAnimation('main-title');
|
||||||
|
initHeaderAnimation('main-title-chat');
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const roomParam = urlParams.get('room');
|
||||||
|
if (roomParam) document.getElementById('room-input').value = roomParam;
|
||||||
|
|
||||||
|
// Load saved TURN config if any
|
||||||
|
const savedTurn = localStorage.getItem('crabchat_turn');
|
||||||
|
if (savedTurn) {
|
||||||
|
try {
|
||||||
|
const tConfig = JSON.parse(savedTurn);
|
||||||
|
document.getElementById('turn-url').value = tConfig.url || '';
|
||||||
|
document.getElementById('turn-username').value = tConfig.username || '';
|
||||||
|
document.getElementById('turn-credential').value = tConfig.credential || '';
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authBtn = document.getElementById('auth-btn');
|
||||||
|
const authTitle = document.getElementById('auth-title');
|
||||||
|
const savedData = localStorage.getItem('crabchat_identity');
|
||||||
|
|
||||||
|
if (savedData) {
|
||||||
|
authTitle.innerText = "Encrypted Identity Found in LocalStorage";
|
||||||
|
authBtn.innerText = "Decrypt & Login";
|
||||||
|
authBtn.onclick = () => unlockKey(JSON.parse(savedData));
|
||||||
|
} else {
|
||||||
|
authTitle.innerText = "Generate New Identity (Argon2id Protected)";
|
||||||
|
authBtn.onclick = generateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('export-key-btn').onclick = exportKey;
|
||||||
|
document.getElementById('import-key-file').onchange = handleKeyImport;
|
||||||
|
document.getElementById('chat-export-key-btn').onclick = exportKey;
|
||||||
|
document.getElementById('chat-import-key-file').onchange = handleKeyImport;
|
||||||
|
document.getElementById('file-input').onchange = handleFileUpload;
|
||||||
|
|
||||||
|
// Destroy Identity hooks
|
||||||
|
document.getElementById('destroy-identity-auth-btn').onclick = destroyIdentity;
|
||||||
|
document.getElementById('destroy-identity-chat-btn').onclick = destroyIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTurnConfig() {
|
||||||
|
const url = document.getElementById('turn-url').value.trim();
|
||||||
|
const username = document.getElementById('turn-username').value.trim();
|
||||||
|
const credential = document.getElementById('turn-credential').value.trim();
|
||||||
|
if (url) {
|
||||||
|
localStorage.setItem('crabchat_turn', JSON.stringify({ url, username, credential }));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('crabchat_turn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyIdentity() {
|
||||||
|
if (confirm("⚠️ Are you sure you want to destroy your cryptographic identity? This will permanently delete your keys from local storage, close all connections, and reset CrabChat.")) {
|
||||||
|
localStorage.removeItem('crabchat_identity');
|
||||||
|
localStorage.removeItem('crabchat_turn');
|
||||||
|
myKeyPair = null;
|
||||||
|
myPublicKeyHex = null;
|
||||||
|
for (const pid in peerConnections) {
|
||||||
|
try { peerConnections[pid].close(); } catch(e) {}
|
||||||
|
}
|
||||||
|
if (socket.connected) socket.disconnect();
|
||||||
|
window.location.href = window.location.pathname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ROBUST ARGON2ID KEY DERIVATION WITH WEBCRYPTO FALLBACK */
|
||||||
|
async function deriveKeyFromPassphrase(passphrase, saltBytes) {
|
||||||
|
if (window.sodium && typeof window.sodium.crypto_pwhash === 'function') {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const passwdBytes = enc.encode(passphrase);
|
||||||
|
return window.sodium.crypto_pwhash(
|
||||||
|
32,
|
||||||
|
passwdBytes,
|
||||||
|
saltBytes,
|
||||||
|
ARGON2_OPSLIMIT,
|
||||||
|
ARGON2_MEMLIMIT,
|
||||||
|
window.sodium.crypto_pwhash_ALG_ARGON2ID
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const baseKey = await window.crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
enc.encode(passphrase),
|
||||||
|
{ name: "PBKDF2" },
|
||||||
|
false,
|
||||||
|
["deriveBits"]
|
||||||
|
);
|
||||||
|
const derivedBits = await window.crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt: saltBytes,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256"
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
return new Uint8Array(derivedBits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportKey() {
|
||||||
|
if (!myKeyPair) {
|
||||||
|
const passphrase = document.getElementById('passphrase').value;
|
||||||
|
if (!passphrase) return alert("No active identity loaded. Unlock or provide a valid passphrase to export.");
|
||||||
|
const err = await validatePassphrase(passphrase);
|
||||||
|
if (err) return alert(err);
|
||||||
|
myKeyPair = window.sodium.crypto_sign_keypair();
|
||||||
|
myPublicKeyHex = window.sodium.to_hex(myKeyPair.publicKey);
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
publicKey: window.sodium.to_hex(myKeyPair.publicKey),
|
||||||
|
privateKey: window.sodium.to_hex(myKeyPair.privateKey),
|
||||||
|
metadata: "Exported under Princess Pi & Prince Stolas protection (Argon2id + PFS)"
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'crabchat-identity.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
triggerGoodFaviconPulse();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeyImport(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.target.result);
|
||||||
|
const privKeyBytes = window.sodium.from_hex(parsed.privateKey);
|
||||||
|
|
||||||
|
let pubKey;
|
||||||
|
if (window.sodium && typeof window.sodium.crypto_sign_ed25519_sk_to_pk === 'function') {
|
||||||
|
pubKey = window.sodium.crypto_sign_ed25519_sk_to_pk(privKeyBytes);
|
||||||
|
} else if (privKeyBytes.length === 64) {
|
||||||
|
pubKey = privKeyBytes.subarray(32, 64);
|
||||||
|
} else if (privKeyBytes.length === 32) {
|
||||||
|
const kp = window.sodium.crypto_sign_seed_keypair(privKeyBytes);
|
||||||
|
pubKey = kp.publicKey;
|
||||||
|
myKeyPair = kp;
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid private key length.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!myKeyPair) {
|
||||||
|
myKeyPair = { privateKey: privKeyBytes, publicKey: pubKey };
|
||||||
|
}
|
||||||
|
myPublicKeyHex = window.sodium.to_hex(myKeyPair.publicKey);
|
||||||
|
await promptAndEncryptAndStoreImportedKey();
|
||||||
|
} catch (err) {
|
||||||
|
setFavicon('bad');
|
||||||
|
alert("Key import failed: " + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptAndEncryptAndStoreImportedKey() {
|
||||||
|
const passphrase = prompt("Enter encryption passphrase (Min 20 chars, Upper, Lower, Digit, Special):");
|
||||||
|
if (!passphrase) return;
|
||||||
|
const err = await validatePassphrase(passphrase);
|
||||||
|
if (err) { alert(err); return; }
|
||||||
|
|
||||||
|
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const derivedKey = await deriveKeyFromPassphrase(passphrase, salt);
|
||||||
|
const nonce = window.sodium.randombytes_buf(window.sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
const encryptedPrivKey = window.sodium.crypto_secretbox_easy(myKeyPair.privateKey, nonce, derivedKey);
|
||||||
|
|
||||||
|
const identityData = {
|
||||||
|
publicKey: myPublicKeyHex,
|
||||||
|
encryptedPrivKey: window.sodium.to_hex(encryptedPrivKey),
|
||||||
|
salt: Array.from(salt),
|
||||||
|
nonce: window.sodium.to_hex(nonce)
|
||||||
|
};
|
||||||
|
localStorage.setItem('crabchat_identity', JSON.stringify(identityData));
|
||||||
|
saveTurnConfig();
|
||||||
|
alert("Imported identity successfully encrypted and stored!");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateKey() {
|
||||||
|
const passphrase = document.getElementById('passphrase').value;
|
||||||
|
const errorMsg = await validatePassphrase(passphrase);
|
||||||
|
if (errorMsg) { alert(errorMsg); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keypair = window.sodium.crypto_sign_keypair();
|
||||||
|
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const derivedKey = await deriveKeyFromPassphrase(passphrase, salt);
|
||||||
|
const nonce = window.sodium.randombytes_buf(window.sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
const encryptedPrivKey = window.sodium.crypto_secretbox_easy(keypair.privateKey, nonce, derivedKey);
|
||||||
|
|
||||||
|
const identityData = {
|
||||||
|
publicKey: window.sodium.to_hex(keypair.publicKey),
|
||||||
|
encryptedPrivKey: window.sodium.to_hex(encryptedPrivKey),
|
||||||
|
salt: Array.from(salt),
|
||||||
|
nonce: window.sodium.to_hex(nonce)
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('crabchat_identity', JSON.stringify(identityData));
|
||||||
|
saveTurnConfig();
|
||||||
|
myKeyPair = keypair;
|
||||||
|
myPublicKeyHex = identityData.publicKey;
|
||||||
|
myUsername = document.getElementById('username').value || "Princess Pi's Admirer";
|
||||||
|
enterChat();
|
||||||
|
} catch (err) {
|
||||||
|
setFavicon('bad');
|
||||||
|
alert("Key generation failed: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlockKey(data) {
|
||||||
|
const passphrase = document.getElementById('passphrase').value;
|
||||||
|
if (!passphrase) return alert("Passphrase required to unlock your key.");
|
||||||
|
try {
|
||||||
|
const salt = new Uint8Array(data.salt);
|
||||||
|
const derivedKey = await deriveKeyFromPassphrase(passphrase, salt);
|
||||||
|
const nonce = window.sodium.from_hex(data.nonce);
|
||||||
|
const encryptedPrivKey = window.sodium.from_hex(data.encryptedPrivKey);
|
||||||
|
|
||||||
|
myKeyPair = {
|
||||||
|
publicKey: window.sodium.from_hex(data.publicKey),
|
||||||
|
privateKey: window.sodium.crypto_secretbox_open_easy(encryptedPrivKey, nonce, derivedKey)
|
||||||
|
};
|
||||||
|
myPublicKeyHex = data.publicKey;
|
||||||
|
myUsername = document.getElementById('username').value || "Princess Pi's Admirer";
|
||||||
|
saveTurnConfig();
|
||||||
|
enterChat();
|
||||||
|
} catch (e) {
|
||||||
|
setFavicon('bad');
|
||||||
|
alert("Incorrect passphrase or corrupted grimoire key.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PERFECT FORWARD SECRECY (PFS) ENCRYPTION / DECRYPTION */
|
||||||
|
function encryptForPeer(plaintext, userId) {
|
||||||
|
const sessionKey = sessionKeys[userId];
|
||||||
|
if (!sessionKey) throw new Error("No active PFS session key established with peer.");
|
||||||
|
const nonce = window.sodium.randombytes_buf(window.sodium.crypto_box_NONCEBYTES);
|
||||||
|
const ciphertext = window.sodium.crypto_box_easy_afternm(plaintext, nonce, sessionKey);
|
||||||
|
return { ciphertext: window.sodium.to_hex(ciphertext), nonce: window.sodium.to_hex(nonce) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptFromPeer(encryptedPayload, userId) {
|
||||||
|
const sessionKey = sessionKeys[userId];
|
||||||
|
if (!sessionKey) throw new Error("No active PFS session key established with peer.");
|
||||||
|
const plaintextBytes = window.sodium.crypto_box_open_easy_afternm(
|
||||||
|
window.sodium.from_hex(encryptedPayload.ciphertext),
|
||||||
|
window.sodium.from_hex(encryptedPayload.nonce),
|
||||||
|
sessionKey
|
||||||
|
);
|
||||||
|
return window.sodium.to_string(plaintextBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterChat() {
|
||||||
|
document.getElementById('auth-section').classList.add('hidden');
|
||||||
|
document.getElementById('chat-section').classList.remove('hidden');
|
||||||
|
document.getElementById('my-identity').innerText = `${myUsername} (@${myPublicKeyHex.substring(0, 8)})`;
|
||||||
|
|
||||||
|
currentRoom = document.getElementById('room-input').value.trim() || window.sodium.to_hex(window.sodium.randombytes_buf(8));
|
||||||
|
window.history.pushState({}, '', '?room=' + currentRoom);
|
||||||
|
document.getElementById('current-room-display').innerText = currentRoom;
|
||||||
|
|
||||||
|
document.getElementById('copy-room-btn').onclick = () => {
|
||||||
|
navigator.clipboard.writeText(window.location.origin + window.location.pathname + '?room=' + currentRoom);
|
||||||
|
alert('Secure invite link copied!');
|
||||||
|
};
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
const chatBox = document.getElementById('chat-box');
|
||||||
|
const messageInput = document.getElementById('message-input');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const recipientSelect = document.getElementById('recipient-select');
|
||||||
|
|
||||||
|
function appendMessage(sender, text, isSystem = false, isPrivate = false) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = isSystem ? 'msg system' : (isPrivate ? 'msg private' : 'msg');
|
||||||
|
div.textContent = isSystem ? text : `${sender}: ${text}`;
|
||||||
|
chatBox.appendChild(div);
|
||||||
|
chatBox.scrollTop = chatBox.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFileMessage(sender, fileName, fileBlob, isPrivate = false) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = isPrivate ? 'msg private' : 'msg';
|
||||||
|
const url = URL.createObjectURL(fileBlob);
|
||||||
|
div.innerHTML = `${sender} sent a file: <a href="${url}" download="${fileName}">${fileName}</a>`;
|
||||||
|
chatBox.appendChild(div);
|
||||||
|
chatBox.scrollTop = chatBox.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActivePeers() {
|
||||||
|
const activeKeys = Object.keys(sessionKeys).length;
|
||||||
|
if (activeKeys > 0) {
|
||||||
|
statusDiv.textContent = `Status: PFS E2EE Tunnel Active with ${activeKeys} peer(s).`;
|
||||||
|
messageInput.disabled = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
recipientSelect.disabled = false;
|
||||||
|
} else {
|
||||||
|
statusDiv.textContent = `Status: In Room '${currentRoom}'. Waiting for peers...`;
|
||||||
|
messageInput.disabled = true;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
recipientSelect.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecipientOption(userId, label) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = userId;
|
||||||
|
opt.textContent = label;
|
||||||
|
opt.id = `opt-${userId}`;
|
||||||
|
recipientSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRecipientOption(userId) {
|
||||||
|
const opt = document.getElementById(`opt-${userId}`);
|
||||||
|
if (opt) {
|
||||||
|
opt.remove();
|
||||||
|
if (recipientSelect.value === userId) recipientSelect.value = 'all';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPeerConnection(userId, isInitiator = false) {
|
||||||
|
if (peerConnections[userId]) peerConnections[userId].close();
|
||||||
|
|
||||||
|
const currentStun = 'stun:' + rawStunServers[Math.floor(Math.random() * rawStunServers.length)];
|
||||||
|
const iceServers = [{ urls: currentStun }];
|
||||||
|
|
||||||
|
// Include custom TURN server if configured
|
||||||
|
const savedTurn = localStorage.getItem('crabchat_turn');
|
||||||
|
if (savedTurn) {
|
||||||
|
try {
|
||||||
|
const t = JSON.parse(savedTurn);
|
||||||
|
if (t.url) {
|
||||||
|
const turnServer = { urls: t.url };
|
||||||
|
if (t.username) turnServer.username = t.username;
|
||||||
|
if (t.credential) turnServer.credential = t.credential;
|
||||||
|
iceServers.push(turnServer);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({ iceServers });
|
||||||
|
peerConnections[userId] = pc;
|
||||||
|
|
||||||
|
pc.onicecandidate = (e) => { if (e.candidate) socket.emit('ice-candidate', { target: userId, candidate: e.candidate }); };
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDataChannelEvents(dc, userId) {
|
||||||
|
dataChannels[userId] = dc;
|
||||||
|
|
||||||
|
dc.onopen = () => {
|
||||||
|
const ephKey = window.sodium.crypto_box_keypair();
|
||||||
|
const sig = window.sodium.crypto_sign_detached(ephKey.publicKey, myKeyPair.privateKey);
|
||||||
|
|
||||||
|
dc.send(JSON.stringify({
|
||||||
|
type: 'pfs-handshake',
|
||||||
|
pubKey: myPublicKeyHex,
|
||||||
|
username: myUsername,
|
||||||
|
ephPubKey: window.sodium.to_hex(ephKey.publicKey),
|
||||||
|
signature: window.sodium.to_hex(sig)
|
||||||
|
}));
|
||||||
|
dc.ephKey = ephKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
dc.onclose = () => {
|
||||||
|
delete dataChannels[userId];
|
||||||
|
delete sessionKeys[userId];
|
||||||
|
delete peerIdentities[userId];
|
||||||
|
removeRecipientOption(userId);
|
||||||
|
updateActivePeers();
|
||||||
|
};
|
||||||
|
|
||||||
|
dc.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'pfs-handshake') {
|
||||||
|
const valid = window.sodium.crypto_sign_verify_detached(
|
||||||
|
window.sodium.from_hex(data.signature),
|
||||||
|
window.sodium.from_hex(data.ephPubKey),
|
||||||
|
window.sodium.from_hex(data.pubKey)
|
||||||
|
);
|
||||||
|
if (!valid) throw new Error("Invalid ephemeral key signature!");
|
||||||
|
|
||||||
|
peerIdentities[userId] = { pubKey: data.pubKey, username: data.username };
|
||||||
|
|
||||||
|
const peerEphPubBytes = window.sodium.from_hex(data.ephPubKey);
|
||||||
|
const sharedSecret = window.sodium.crypto_box_beforenm(peerEphPubBytes, dc.ephKey.privateKey);
|
||||||
|
sessionKeys[userId] = sharedSecret;
|
||||||
|
|
||||||
|
const shortId = data.pubKey.substring(0, 8);
|
||||||
|
const label = `${data.username} (@${shortId})`;
|
||||||
|
addRecipientOption(userId, label);
|
||||||
|
appendMessage('', `[Network] ${label} verified with Perfect Forward Secrecy.`, true);
|
||||||
|
triggerGoodFaviconPulse();
|
||||||
|
updateActivePeers();
|
||||||
|
|
||||||
|
} else if (data.type === 'chat') {
|
||||||
|
const identity = peerIdentities[userId];
|
||||||
|
const decryptedMsg = decryptFromPeer(data.payload, userId);
|
||||||
|
const shortId = identity.pubKey.substring(0, 8);
|
||||||
|
const prefix = data.isPrivate ? '[DM] ' : '';
|
||||||
|
appendMessage(`${identity.username} (@${shortId})`, prefix + decryptedMsg, false, data.isPrivate);
|
||||||
|
triggerGoodFaviconPulse();
|
||||||
|
|
||||||
|
} else if (data.type === 'file-meta') {
|
||||||
|
incomingFiles[data.fileId] = {
|
||||||
|
name: data.fileName,
|
||||||
|
size: data.fileSize,
|
||||||
|
key: data.fileKey,
|
||||||
|
nonce: data.fileNonce,
|
||||||
|
chunks: [],
|
||||||
|
receivedBytes: 0,
|
||||||
|
sender: userId,
|
||||||
|
isPrivate: data.isPrivate
|
||||||
|
};
|
||||||
|
} else if (data.type === 'file-chunk') {
|
||||||
|
const fileMeta = incomingFiles[data.fileId];
|
||||||
|
if (fileMeta) {
|
||||||
|
fileMeta.chunks[data.chunkIndex] = window.sodium.from_hex(data.chunkData);
|
||||||
|
fileMeta.receivedBytes += fileMeta.chunks[data.chunkIndex].length;
|
||||||
|
if (fileMeta.receivedBytes >= fileMeta.size) {
|
||||||
|
const fullEncrypted = new Uint8Array(fileMeta.size);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of fileMeta.chunks) {
|
||||||
|
fullEncrypted.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decryptedFileBytes = window.sodium.crypto_secretbox_open_easy(
|
||||||
|
fullEncrypted,
|
||||||
|
window.sodium.from_hex(fileMeta.nonce),
|
||||||
|
window.sodium.from_hex(fileMeta.key)
|
||||||
|
);
|
||||||
|
const blob = new Blob([decryptedFileBytes]);
|
||||||
|
const identity = peerIdentities[userId];
|
||||||
|
const prefix = fileMeta.isPrivate ? '[DM File] ' : '[File] ';
|
||||||
|
appendFileMessage(identity.username, prefix + fileMeta.name, blob, fileMeta.isPrivate);
|
||||||
|
} catch (err) {
|
||||||
|
appendMessage('', '[Security Alert] Failed to decrypt received file.', true);
|
||||||
|
}
|
||||||
|
delete incomingFiles[data.fileId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setFavicon('bad');
|
||||||
|
appendMessage('', `[Security Alert] Protocol error or decryption failure`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initiateCall(userId) {
|
||||||
|
const pc = createPeerConnection(userId, true);
|
||||||
|
const dc = pc.createDataChannel("chat");
|
||||||
|
setupDataChannelEvents(dc, userId);
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
socket.emit('offer', { target: userId, offer });
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.emit('join-room', currentRoom);
|
||||||
|
updateActivePeers();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('user-joined', (userId) => {
|
||||||
|
triggerGoodFaviconPulse();
|
||||||
|
initiateCall(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('offer', async ({ sender, offer }) => {
|
||||||
|
const pc = createPeerConnection(sender, false);
|
||||||
|
pc.ondatachannel = (event) => setupDataChannelEvents(event.channel, sender);
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
socket.emit('answer', { target: sender, answer });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('answer', async ({ sender, answer }) => {
|
||||||
|
if (peerConnections[sender]) await peerConnections[sender].setRemoteDescription(new RTCSessionDescription(answer));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ice-candidate', async ({ sender, candidate }) => {
|
||||||
|
if (peerConnections[sender]) await peerConnections[sender].addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('user-disconnected', (userId) => {
|
||||||
|
if (peerConnections[userId]) { peerConnections[userId].close(); delete peerConnections[userId]; }
|
||||||
|
if (peerIdentities[userId]) {
|
||||||
|
const ident = peerIdentities[userId];
|
||||||
|
appendMessage('', `[Network] ${ident.username} disconnected.`, true);
|
||||||
|
delete peerIdentities[userId];
|
||||||
|
}
|
||||||
|
delete dataChannels[userId];
|
||||||
|
delete sessionKeys[userId];
|
||||||
|
removeRecipientOption(userId);
|
||||||
|
updateActivePeers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* FILE TRANSFER HANDLING (UP TO 100MB) */
|
||||||
|
async function handleFileUpload(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
alert("File exceeds maximum size limit of 100MB.");
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = recipientSelect.value;
|
||||||
|
const isPrivate = targetUserId !== 'all';
|
||||||
|
const fileId = window.sodium.to_hex(window.sodium.randombytes_buf(8));
|
||||||
|
|
||||||
|
const progressContainer = document.getElementById('file-progress-container');
|
||||||
|
const progressBar = document.getElementById('file-progress');
|
||||||
|
progressContainer.classList.remove('hidden');
|
||||||
|
progressBar.value = 0;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const fileBytes = new Uint8Array(e.target.result);
|
||||||
|
const fileKey = window.sodium.randombytes_buf(window.sodium.crypto_secretbox_KEYBYTES);
|
||||||
|
const fileNonce = window.sodium.randombytes_buf(window.sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
|
||||||
|
const encryptedFile = window.sodium.crypto_secretbox_easy(fileBytes, fileNonce, fileKey);
|
||||||
|
|
||||||
|
for (const userId in dataChannels) {
|
||||||
|
if (isPrivate && userId !== targetUserId) continue;
|
||||||
|
const dc = dataChannels[userId];
|
||||||
|
if (dc.readyState === 'open') {
|
||||||
|
dc.send(JSON.stringify({
|
||||||
|
type: 'file-meta',
|
||||||
|
fileId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: encryptedFile.length,
|
||||||
|
fileKey: window.sodium.to_hex(fileKey),
|
||||||
|
fileNonce: window.sodium.to_hex(fileNonce),
|
||||||
|
isPrivate
|
||||||
|
}));
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
let chunkIndex = 0;
|
||||||
|
while (offset < encryptedFile.length) {
|
||||||
|
const chunk = encryptedFile.subarray(offset, offset + CHUNK_SIZE);
|
||||||
|
dc.send(JSON.stringify({
|
||||||
|
type: 'file-chunk',
|
||||||
|
fileId,
|
||||||
|
chunkIndex,
|
||||||
|
chunkData: window.sodium.to_hex(chunk)
|
||||||
|
}));
|
||||||
|
offset += CHUNK_SIZE;
|
||||||
|
chunkIndex++;
|
||||||
|
progressBar.value = Math.round((offset / encryptedFile.length) * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progressContainer.classList.add('hidden');
|
||||||
|
appendFileMessage('You', (isPrivate ? '[DM File] ' : '[File] ') + file.name, new Blob([fileBytes]), isPrivate);
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MESSAGE SENDING WITH LENGTH GUARDRAILS */
|
||||||
|
sendBtn.onclick = () => {
|
||||||
|
const message = messageInput.value;
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
if (message.length > MAX_MESSAGE_LENGTH) {
|
||||||
|
alert(`Message exceeds maximum limit of ${MAX_MESSAGE_LENGTH} characters.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = recipientSelect.value;
|
||||||
|
const isPrivate = targetUserId !== 'all';
|
||||||
|
let sentToAnyone = false;
|
||||||
|
|
||||||
|
for (const userId in dataChannels) {
|
||||||
|
if (isPrivate && userId !== targetUserId) continue;
|
||||||
|
const dc = dataChannels[userId];
|
||||||
|
const identity = peerIdentities[userId];
|
||||||
|
|
||||||
|
if (dc.readyState === 'open' && identity) {
|
||||||
|
try {
|
||||||
|
const encryptedPayload = encryptForPeer(message, userId);
|
||||||
|
dc.send(JSON.stringify({ type: 'chat', isPrivate, payload: encryptedPayload }));
|
||||||
|
sentToAnyone = true;
|
||||||
|
} catch (err) {
|
||||||
|
setFavicon('bad');
|
||||||
|
console.error("Encryption failed for user", userId, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentToAnyone) {
|
||||||
|
appendMessage('You', isPrivate ? `[DM] ${message}` : message, false, isPrivate);
|
||||||
|
messageInput.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
messageInput.onkeypress = (e) => { if (e.key === 'Enter') sendBtn.click(); };
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const HOST = '10.0.0.51';
|
||||||
|
const PORT = 443;
|
||||||
|
|
||||||
|
// Guardrail: Max allowed size for signaling metadata payloads (64KB)
|
||||||
|
const MAX_SIGNAL_PAYLOAD_SIZE = 64 * 1024;
|
||||||
|
|
||||||
|
function startServer() {
|
||||||
|
let privateKey, certificate;
|
||||||
|
|
||||||
|
try {
|
||||||
|
privateKey = fs.readFileSync(path.join(__dirname, 'key.pem'), 'utf8');
|
||||||
|
certificate = fs.readFileSync(path.join(__dirname, 'cert.pem'), 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load local certificates. Ensure key.pem and cert.pem exist.', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = https.createServer({
|
||||||
|
key: privateKey,
|
||||||
|
cert: certificate
|
||||||
|
}, app);
|
||||||
|
|
||||||
|
// Guardrail: Enforce max HTTP/WebSocket buffer size on Socket.io (1MB limit for signaling)
|
||||||
|
const io = new Server(server, {
|
||||||
|
maxHttpBufferSize: 1e6
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('join-room', (roomId) => {
|
||||||
|
if (typeof roomId !== 'string' || roomId.length > 128) return;
|
||||||
|
socket.join(roomId);
|
||||||
|
socket.roomId = roomId;
|
||||||
|
socket.to(roomId).emit('user-joined', socket.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signaling guardrails: Validate payload sizes before broadcasting
|
||||||
|
socket.on('offer', ({ target, offer }) => {
|
||||||
|
if (JSON.stringify(offer).length > MAX_SIGNAL_PAYLOAD_SIZE) return;
|
||||||
|
io.to(target).emit('offer', { sender: socket.id, offer });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('answer', ({ target, answer }) => {
|
||||||
|
if (JSON.stringify(answer).length > MAX_SIGNAL_PAYLOAD_SIZE) return;
|
||||||
|
io.to(target).emit('answer', { sender: socket.id, answer });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ice-candidate', ({ target, candidate }) => {
|
||||||
|
if (candidate && JSON.stringify(candidate).length > MAX_SIGNAL_PAYLOAD_SIZE) return;
|
||||||
|
io.to(target).emit('ice-candidate', { sender: socket.id, candidate });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
if (socket.roomId) {
|
||||||
|
socket.to(socket.roomId).emit('user-disconnected', socket.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`CrabChat signaling server running securely at https://${HOST}:${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
Reference in New Issue
Block a user