898 lines
40 KiB
HTML
898 lines
40 KiB
HTML
<!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> |