This commit is contained in:
2026-09-07 20:05:39 -06:00
commit 104740166a
13 changed files with 1121 additions and 0 deletions
+898
View File
@@ -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>