/** * messages.js — End-to-End Encrypted Private Messaging * * Encryption: ECDH P-256 key exchange → AES-GCM message encryption * Key storage: localStorage (private key as JWK) * Key backup: PBKDF2-wrapped export file (.f0ckkey) for multi-device support * Everything runs in the browser; the server only sees ciphertext. * * INIT GUARD: this IIFE checks window.__dmLoaded to prevent double-init * when the script is accidentally loaded twice (e.g. during development). */ if (window.__dmLoaded) { console.warn('[DM] messages.js already loaded — skipping duplicate init'); } else { window.__dmLoaded = true; (function () { 'use strict'; const DM_KEY_NAME = 'f0ck_dm_privkey'; const DM_PUBKEY_NAME = 'f0ck_dm_pubkey'; const DM_KEY_VERSION = 'f0ck_dm_keyver'; const CURRENT_VERSION = 3; // v3 = force-wipe all pre-vault localStorage keys // ───────────────────────────────────────────────────────────────────────── // Crypto helpers // ───────────────────────────────────────────────────────────────────────── const subtle = crypto.subtle; async function generateKeyPair() { return subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']); } async function exportKey(key) { return JSON.stringify(await subtle.exportKey('jwk', key)); } async function importPublicKey(jwkString) { return subtle.importKey('jwk', typeof jwkString === 'string' ? JSON.parse(jwkString) : jwkString, { name: 'ECDH', namedCurve: 'P-256' }, true, []); } async function importPrivateKey(jwkString) { return subtle.importKey('jwk', typeof jwkString === 'string' ? JSON.parse(jwkString) : jwkString, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']); } async function deriveSharedKey(privateKey, theirPublicKey) { return subtle.deriveKey({ name: 'ECDH', public: theirPublicKey }, privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } async function encryptMessage(sharedKey, plaintext) { const iv = crypto.getRandomValues(new Uint8Array(12)); const buf = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, new TextEncoder().encode(plaintext)); return { ciphertext: toB64u(buf), iv: toB64u(iv) }; } async function decryptMessage(sharedKey, ciphertext, iv) { const buf = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64u(iv) }, sharedKey, fromB64u(ciphertext)); return new TextDecoder().decode(buf); } // ── Seed-phrase vault crypto ─────────────────────────────────────────────── function generateMnemonic() { const list = window.DM_WORDLIST; if (!list || list.length < 2) throw new Error('Wordlist not loaded'); const indices = new Uint32Array(12); crypto.getRandomValues(indices); return Array.from(indices).map(i => list[i % list.length]); } async function deriveWrapKey(mnemonic, saltBytes, usage) { const enc = new TextEncoder(); const base = await subtle.importKey('raw', enc.encode(mnemonic.join(' ')), 'PBKDF2', false, ['deriveKey']); return subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' }, base, { name: 'AES-GCM', length: 256 }, false, [usage] ); } async function encryptPrivkeyForVault(privkeyJwk, mnemonic) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const wrapKey = await deriveWrapKey(mnemonic, salt, 'encrypt'); const cipher = await subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new TextEncoder().encode(privkeyJwk)); return { salt: toB64u(salt), iv: toB64u(iv), ciphertext: toB64u(cipher) }; } async function decryptPrivkeyFromVault(vault, mnemonic) { const saltBytes = new Uint8Array(fromB64u(vault.salt)); const wrapKey = await deriveWrapKey(mnemonic, saltBytes, 'decrypt'); const plain = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64u(vault.iv) }, wrapKey, fromB64u(vault.ciphertext)); return new TextDecoder().decode(plain); } // ── base64url ───────────────────────────────────────────────────────────── function toB64u(buf) { const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf); let s = ''; for (const b of u8) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } function fromB64u(s) { const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(b64); const buf = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i); return buf.buffer; } async function sha256Hex(str) { const buf = await subtle.digest('SHA-256', new TextEncoder().encode(str)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } // ───────────────────────────────────────────────────────────────────────── // Key management // ───────────────────────────────────────────────────────────────────────── let _privateKey = null; let _publicKeyJwk = null; /** * Load key from localStorage (v2 only). * Returns: 'local' | 'vault' | 'new' * 'local' — key is ready in memory * 'vault' — server has a vault blob, caller must show recovery modal * 'new' — no key anywhere, fresh pair generated, caller must show setup modal */ async function loadOrCreateKeyPair() { // ── Version gate: wipe any pre-v2 key material ────────────────────── const ver = parseInt(localStorage.getItem(DM_KEY_VERSION) || '0', 10); if (ver < CURRENT_VERSION) { localStorage.removeItem(DM_KEY_NAME); localStorage.removeItem(DM_PUBKEY_NAME); localStorage.removeItem(DM_KEY_VERSION); } // ── Try localStorage first (fast path) ────────────────────────────── const storedPriv = localStorage.getItem(DM_KEY_NAME); const storedPub = localStorage.getItem(DM_PUBKEY_NAME); if (storedPriv && storedPub) { try { _privateKey = await importPrivateKey(storedPriv); _publicKeyJwk = storedPub; return 'local'; } catch { localStorage.removeItem(DM_KEY_NAME); localStorage.removeItem(DM_PUBKEY_NAME); } } // ── Check server vault ─────────────────────────────────────────────── try { const vdata = await dmFetch('GET', '/api/dm/keyvault'); if (vdata.success && vdata.vault) return 'vault'; } catch { /* offline or no vault */ } // ── Generate fresh key pair ────────────────────────────────────────── const pair = await generateKeyPair(); const privJwk = await exportKey(pair.privateKey); const pubJwk = await exportKey(pair.publicKey); localStorage.setItem(DM_KEY_NAME, privJwk); localStorage.setItem(DM_PUBKEY_NAME, pubJwk); localStorage.setItem(DM_KEY_VERSION, String(CURRENT_VERSION)); _privateKey = pair.privateKey; _publicKeyJwk = pubJwk; return 'new'; } async function uploadPublicKey() { if (!_publicKeyJwk) return; const fingerprint = await sha256Hex(_publicKeyJwk); await dmFetch('POST', '/api/dm/pubkey', { pubkey: _publicKeyJwk, fingerprint }); } /** Store key in localStorage from a raw JWK string (after vault recovery). */ async function storeKeyFromJwk(privJwk) { const privObj = JSON.parse(privJwk); const pubJwk = JSON.stringify({ kty: privObj.kty, crv: privObj.crv, x: privObj.x, y: privObj.y, key_ops: [], ext: true }); _privateKey = await importPrivateKey(privJwk); _publicKeyJwk = pubJwk; localStorage.setItem(DM_KEY_NAME, privJwk); localStorage.setItem(DM_PUBKEY_NAME, pubJwk); localStorage.setItem(DM_KEY_VERSION, String(CURRENT_VERSION)); pubkeyCache.clear(); } function hasKey() { return _privateKey !== null; } // ───────────────────────────────────────────────────────────────────────── // API helpers // ───────────────────────────────────────────────────────────────────────── const csrfToken = () => window.f0ckSession?.csrf_token || ''; async function dmFetch(method, url, body = null) { const opts = { method, headers: { 'X-CSRF-Token': csrfToken() } }; if (body && method !== 'GET') { opts.headers['Content-Type'] = 'application/x-www-form-urlencoded'; opts.body = new URLSearchParams(body).toString(); } const res = await fetch(url, opts); return res.json(); } // ───────────────────────────────────────────────────────────────────────── // Public key cache // ───────────────────────────────────────────────────────────────────────── const pubkeyCache = new Map(); // userId → CryptoKey (public) async function getRemotePublicKey(userId) { if (pubkeyCache.has(userId)) return pubkeyCache.get(userId); const data = await dmFetch('GET', `/api/dm/pubkey/${userId}`); if (!data.success) return null; // null = no key yet const key = await importPublicKey(data.pubkey); pubkeyCache.set(userId, key); return key; } // ───────────────────────────────────────────────────────────────────────── // Inbox Page // ───────────────────────────────────────────────────────────────────────── async function initInbox() { const container = document.getElementById('dm-inbox-list'); if (!container) return; await renderInbox(container); } async function renderInbox(container) { container.innerHTML = '
${escHtml(plaintext)}`;
}
const cs = window.commentSystem;
if (cs && typeof cs.renderCommentContent === 'function') {
return cs.renderCommentContent(plaintext);
}
if (typeof marked === 'undefined') {
return renderDmEmojis(escHtml(plaintext));
}
try {
// 1. Pre-normalize: If the line is JUST a YouTube URL, wrap it in a markdown link
let normalized = plaintext.split('\n').map(line => {
const trimmed = line.trim();
const ytMatch = trimmed.match(/^https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^\s<"]*&)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^\s<"]*$/);
if (ytMatch) return `[${trimmed}](${trimmed})`;
return line;
}).join('\n');
// 2. Initial escaping using native method. Restore > for markdown markers.
let escaped = escHtml(normalized).replace(/>/g, ">");
// 3. Mentions
const mentionRegex = /(?${displayText}${extraSuffix}`;
}
return `${displayText}${extraSuffix}`;
};
// 5. Blockquote
renderer.blockquote = function (quote) {
let text = (typeof quote === 'string') ? quote : (quote.text || '');
text = text.replace(/|<\/p>/g, ''); return text.split('\n').map(line => { if (!line.trim()) return ''; return `>${line}`; }).join('\n'); }; // 6. Line-by-line rendering to avoid paragraph collapsing and recursion const renderedLines = escaped.split('\n').map(line => { const trimmed = line.trimStart(); if (trimmed.startsWith('>')) { const quoteContent = line.substring(line.indexOf('>') + 1); return `>${quoteContent}`; } // Per-line limit if (line.length > 10000) return line; if (!line.trim()) return ' '; let processedLine = line.replace(mentionRegex, (match, g1, g2) => { const user = g1 || g2; return `@${user}`; }); const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const rawVideoRegex = new RegExp(`(? { let fullUrl = url; if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url; return `[video](${fullUrl})`; }); const escapedAsterisks = processedLine.replace(/\*/g, '\\*'); let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/
|<\/p>/g, ''); return rendered; }); let html = renderedLines.join('\n'); // Handle spoilers [spoiler]text[/spoiler] (supports nesting) let lastHtml; let iterations = 0; const spoilerRegex = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi; do { lastHtml = html; html = html.replace(spoilerRegex, (match, content) => { return `${content}`; }); iterations++; } while (html !== lastHtml && iterations < 10); // Handle blur [blur]text[/blur] (supports nesting) const blurRegex = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi; iterations = 0; do { lastHtml = html; html = html.replace(blurRegex, (match, content) => { return `${content}`; }); iterations++; } while (html !== lastHtml && iterations < 10); // 7. YouTube embed logic const ytEmbedRegex = /(?: