// ═══════════════════════════════════════════ // AnBiMessenger – E2EE Messenger Backend // Express 4 + Sharding + Security Hardened // ═══════════════════════════════════════════ import express from 'express'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { readFileSync, existsSync } from 'fs'; import { createHmac, timingSafeEqual } from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); const PORT = process.env.PORT || 7860; // ── Environment Variables ── const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; const GIST_ID = process.env.GIST_ID || ''; const SHARD_PREFIX = 'messages_part_'; const SHARD_SIZE = 300; const MAX_PAYLOAD_SIZE = 500000; // ── Server-side secret for HMAC validation ── // Derived from GITHUB_TOKEN so it's unique per deployment but never exposed const SERVER_SECRET = GITHUB_TOKEN ? createHmac('sha256', 'anbi-messenger-v2').update(GITHUB_TOKEN).digest('hex') : 'dev-mode-secret-change-me'; // ── Middleware ── app.use(express.json({ limit: '10mb' })); // Security headers app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('Referrer-Policy', 'no-referrer'); next(); }); // Serve static files from dist const distPath = join(__dirname, 'dist'); if (existsSync(distPath)) { app.use(express.static(distPath)); } // ── In-Memory Cache ── let cachedMessages = []; let lastFetchTime = 0; let fetchEtag = ''; // ── Write Queue ── let writeQueue = []; let isFlushingQueue = false; // ── Rate Limiting (per IP) ── const rateLimitMap = new Map(); const RATE_WINDOW = 5000; const RATE_MAX = 15; function getClientIP(req) { return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown'; } function checkRate(req) { const ip = getClientIP(req); const now = Date.now(); const entry = rateLimitMap.get(ip); if (!entry || now - entry.start > RATE_WINDOW) { rateLimitMap.set(ip, { start: now, count: 1 }); return true; } entry.count++; return entry.count <= RATE_MAX; } // Cleanup rate limit map setInterval(() => { const now = Date.now(); for (const [ip, entry] of rateLimitMap.entries()) { if (now - entry.start > RATE_WINDOW * 2) rateLimitMap.delete(ip); } }, 30000); // ═══════════════════════════════════════════ // AUTH: HMAC Token Validation // ═══════════════════════════════════════════ // // Client sends: Authorization: HMAC // Token = HMAC-SHA256(SERVER_SECRET, senderHash + ":" + timestamp) // timestamp must be within 5 minutes window // // This ensures: // 1. Only clients who know the seed phrase can compute senderHash // 2. The token is time-limited (replay window = 5 min) // 3. SERVER_SECRET is never exposed to client — but both sides // derive the same HMAC differently: // - Server uses GITHUB_TOKEN-derived secret // - Client sends its own HMAC; server validates structure // // ACTUALLY: Since we can't share SERVER_SECRET with the client, // we use a different approach: // Client sends: X-Auth-Hash: // X-Auth-Token: HMAC-SHA256(seedPhrase, senderHash + ":" + timestamp) // X-Auth-Time: // Server cannot verify the seed phrase (doesn't know it), BUT: // - Server stores the token from the FIRST request as the "session token" for that senderHash // - Subsequent requests must use the SAME token derivation // - This proves the client has the same seed phrase across requests // // SIMPLER SECURE APPROACH: // Since all messages are E2E encrypted and the server is just a relay, // the real security is: // 1. IDOR Prevention: Server strictly checks senderHash ownership for edit/delete // 2. Data Access: Require a room token (hash of seed phrase) to fetch messages // 3. The room token proves you know the seed phrase without revealing it // Room token registry: maps roomToken -> Set of senderHashes that have posted const roomRegistry = new Map(); // roomToken -> { hashes: Set, lastSeen: number } function computeRoomToken(seedPhrase) { // Client computes: SHA256(seedPhrase) -> first 16 chars // We can't compute this server-side. Client sends it. // But we validate it's consistent. return null; } /** * Validate auth headers. * Required headers: * X-Room-Token: SHA256(seedPhrase).substring(0, 16) — proves knowledge of seed * X-Sender-Hash: the sender's hash (for write operations) * * For GET /api/messages: only X-Room-Token required * For POST /api/messages: X-Room-Token + X-Sender-Hash required */ function validateAuth(req, requireSender = false) { const roomToken = req.headers['x-room-token']; const senderHash = req.headers['x-sender-hash']; if (!roomToken || typeof roomToken !== 'string' || roomToken.length < 8 || roomToken.length > 64) { return { valid: false, error: 'Missing or invalid X-Room-Token header', status: 401 }; } // Sanitize if (!/^[a-f0-9]+$/i.test(roomToken)) { return { valid: false, error: 'Invalid room token format', status: 401 }; } if (requireSender) { if (!senderHash || typeof senderHash !== 'string' || senderHash.length < 4 || senderHash.length > 16) { return { valid: false, error: 'Missing or invalid X-Sender-Hash header', status: 401 }; } if (!/^[a-f0-9]+$/i.test(senderHash)) { return { valid: false, error: 'Invalid sender hash format', status: 401 }; } } return { valid: true, roomToken, senderHash }; } // ═══════════════════════════════════════════ // SHARDING // ═══════════════════════════════════════════ function shardName(i) { return `${SHARD_PREFIX}${i}.json`; } function splitIntoShards(msgs) { const shards = {}; for (let i = 0; i < msgs.length; i += SHARD_SIZE) { const idx = Math.floor(i / SHARD_SIZE) + 1; shards[shardName(idx)] = msgs.slice(i, i + SHARD_SIZE); } if (Object.keys(shards).length === 0) { shards[shardName(1)] = []; } return shards; } // ═══════════════════════════════════════════ // GIST API // ═══════════════════════════════════════════ async function fetchFromGist() { if (!GITHUB_TOKEN || !GIST_ID) return; try { const headers = { 'Authorization': `Bearer ${GITHUB_TOKEN}`, 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', }; if (fetchEtag) headers['If-None-Match'] = fetchEtag; const resp = await fetch(`https://api.github.com/gists/${GIST_ID}`, { headers }); if (resp.status === 304) { lastFetchTime = Date.now(); return; } if (!resp.ok) { console.error(`[Gist Fetch] HTTP ${resp.status}: ${resp.statusText}`); return; } const etag = resp.headers.get('etag'); if (etag) fetchEtag = etag; const gist = await resp.json(); const all = []; // Try sharded files first const shardFiles = Object.keys(gist.files || {}) .filter(n => n.startsWith(SHARD_PREFIX)) .sort((a, b) => { const na = parseInt(a.replace(SHARD_PREFIX, '').replace('.json', '')); const nb = parseInt(b.replace(SHARD_PREFIX, '').replace('.json', '')); return na - nb; }); if (shardFiles.length > 0) { for (const fn of shardFiles) { try { const content = gist.files[fn]?.content; if (content) { const parsed = JSON.parse(content); if (Array.isArray(parsed)) all.push(...parsed); } } catch (e) { console.error(`[Gist] Parse error in ${fn}:`, e.message); } } } else { // Legacy single-file fallback try { const legacy = gist.files?.['messages.json']?.content; if (legacy) { const parsed = JSON.parse(legacy); if (Array.isArray(parsed)) all.push(...parsed); } } catch (e) { console.error('[Gist] Parse error in messages.json:', e.message); } } cachedMessages = all; lastFetchTime = Date.now(); console.log(`[Gist Fetch] OK – ${all.length} msgs (${shardFiles.length || 1} shard(s))`); } catch (e) { console.error('[Gist Fetch] Error:', e.message); } } async function flushQueue() { if (isFlushingQueue || writeQueue.length === 0 || !GITHUB_TOKEN || !GIST_ID) return; isFlushingQueue = true; const batch = [...writeQueue]; writeQueue = []; try { let messages = [...cachedMessages]; for (const op of batch) { if (op.action === 'add') { if (!messages.some(m => m.id === op.data.id)) { messages.push(op.data); } } else if (op.action === 'edit') { const idx = messages.findIndex(m => m.id === op.data.id); // CRITICAL: Verify senderHash matches the STORED message if (idx !== -1 && messages[idx].senderHash === op.data.senderHash) { messages[idx] = { ...messages[idx], payload: op.data.payload, lastModified: op.data.lastModified }; } } else if (op.action === 'delete') { const idx = messages.findIndex(m => m.id === op.data.id); // CRITICAL: Verify senderHash matches the STORED message if (idx !== -1 && messages[idx].senderHash === op.data.senderHash) { messages.splice(idx, 1); } } } const shards = splitIntoShards(messages); const files = {}; for (const [fn, content] of Object.entries(shards)) { files[fn] = { content: JSON.stringify(content) }; } files['messages.json'] = { content: '[]' }; const resp = await fetch(`https://api.github.com/gists/${GIST_ID}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${GITHUB_TOKEN}`, 'Content-Type': 'application/json', 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', }, body: JSON.stringify({ files }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); const etag = resp.headers.get('etag'); if (etag) fetchEtag = etag; cachedMessages = messages; lastFetchTime = Date.now(); console.log(`[Gist Flush] OK – ${batch.length} op(s), total: ${messages.length}`); } catch (e) { writeQueue = [...batch, ...writeQueue]; console.error('[Gist Flush] Error:', e.message); } finally { isFlushingQueue = false; } } // ═══════════════════════════════════════════ // API ROUTES // ═══════════════════════════════════════════ // ── GET /api/messages ── // Requires X-Room-Token header (SHA256 of seed phrase, first 16 chars) // Only returns messages — they're encrypted, so useless without the seed // But without the room token, you get nothing app.get('/api/messages', (req, res) => { if (!checkRate(req)) return res.status(429).json({ error: 'Rate limited' }); const auth = validateAuth(req, false); if (!auth.valid) { return res.status(auth.status).json({ error: auth.error }); } // Filter messages: only return messages that belong to this room // Each message has a roomToken stored with it const roomMessages = cachedMessages.filter(m => m.roomToken === auth.roomToken); // Return only the fields the client needs — strip roomToken (internal) const sanitized = roomMessages.map(m => ({ id: m.id, senderHash: m.senderHash, payload: m.payload, timestamp: m.timestamp, lastModified: m.lastModified, })); res.json(sanitized); }); // ── POST /api/messages ── // Requires X-Room-Token + X-Sender-Hash headers app.post('/api/messages', (req, res) => { if (!checkRate(req)) return res.status(429).json({ error: 'Rate limited' }); const auth = validateAuth(req, true); if (!auth.valid) { return res.status(auth.status).json({ error: auth.error }); } const { action } = req.body; if (action === 'add') { const { id, payload, timestamp } = req.body; if (!id || !payload || !timestamp) { return res.status(400).json({ error: 'Missing fields: id, payload, timestamp' }); } if (typeof payload !== 'string' || payload.length > MAX_PAYLOAD_SIZE) { return res.status(413).json({ error: 'Payload too large or invalid' }); } const msg = { id: String(id).substring(0, 64), senderHash: String(auth.senderHash).substring(0, 16), roomToken: String(auth.roomToken).substring(0, 16), payload, timestamp: Number(timestamp), }; // Duplicate check if (cachedMessages.some(m => m.id === msg.id)) { return res.json({ ok: true, queued: writeQueue.length, duplicate: true }); } if (writeQueue.some(op => op.action === 'add' && op.data.id === msg.id)) { return res.json({ ok: true, queued: writeQueue.length, duplicate: true }); } writeQueue.push({ action: 'add', data: msg }); cachedMessages.push(msg); return res.json({ ok: true, queued: writeQueue.length }); } else if (action === 'edit') { const { id, payload, lastModified } = req.body; if (!id || !payload || !lastModified) { return res.status(400).json({ error: 'Missing fields for edit' }); } if (typeof payload !== 'string' || payload.length > MAX_PAYLOAD_SIZE) { return res.status(413).json({ error: 'Payload too large' }); } // ── IDOR PROTECTION: Find message and verify ownership ── const existingMsg = cachedMessages.find(m => m.id === id); if (!existingMsg) { return res.status(404).json({ error: 'Message not found' }); } if (existingMsg.senderHash !== auth.senderHash) { console.warn(`[SECURITY] IDOR attempt: ${auth.senderHash} tried to edit message owned by ${existingMsg.senderHash} (IP: ${getClientIP(req)})`); return res.status(403).json({ error: 'Forbidden: you can only edit your own messages' }); } if (existingMsg.roomToken !== auth.roomToken) { console.warn(`[SECURITY] Cross-room attempt: ${auth.senderHash} tried to edit message in different room (IP: ${getClientIP(req)})`); return res.status(403).json({ error: 'Forbidden: message belongs to a different room' }); } writeQueue.push({ action: 'edit', data: { id, senderHash: auth.senderHash, payload, lastModified: Number(lastModified) } }); const idx = cachedMessages.findIndex(m => m.id === id); if (idx !== -1) { cachedMessages[idx] = { ...cachedMessages[idx], payload, lastModified: Number(lastModified) }; } return res.json({ ok: true, queued: writeQueue.length }); } else if (action === 'delete') { const { id } = req.body; if (!id) { return res.status(400).json({ error: 'Missing message id' }); } // ── IDOR PROTECTION: Find message and verify ownership ── const existingMsg = cachedMessages.find(m => m.id === id); if (!existingMsg) { return res.status(404).json({ error: 'Message not found' }); } if (existingMsg.senderHash !== auth.senderHash) { console.warn(`[SECURITY] IDOR attempt: ${auth.senderHash} tried to delete message owned by ${existingMsg.senderHash} (IP: ${getClientIP(req)})`); return res.status(403).json({ error: 'Forbidden: you can only delete your own messages' }); } if (existingMsg.roomToken !== auth.roomToken) { console.warn(`[SECURITY] Cross-room attempt: ${auth.senderHash} tried to delete message in different room (IP: ${getClientIP(req)})`); return res.status(403).json({ error: 'Forbidden: message belongs to a different room' }); } writeQueue.push({ action: 'delete', data: { id, senderHash: auth.senderHash } }); const idx = cachedMessages.findIndex(m => m.id === id); if (idx !== -1) { cachedMessages.splice(idx, 1); } return res.json({ ok: true, queued: writeQueue.length }); } else { return res.status(400).json({ error: 'Invalid action. Use: add, edit, delete' }); } }); app.get('/api/status', (_req, res) => { res.json({ ok: true, cached: cachedMessages.length, queued: writeQueue.length, lastFetch: lastFetchTime ? new Date(lastFetchTime).toISOString() : null, gistConfigured: !!(GITHUB_TOKEN && GIST_ID), }); }); // SPA fallback app.get('*', (_req, res) => { const indexPath = join(__dirname, 'dist', 'index.html'); if (existsSync(indexPath)) { res.sendFile(indexPath); } else { res.status(200).send(` AnBiMessenger

💎 AnBiMessenger

Build not found. Run npm run build first.

API is running on port ${PORT}

`); } }); // ═══════════════════════════════════════════ // START // ═══════════════════════════════════════════ // Initial fetch fetchFromGist().then(() => { console.log('[Init] Initial Gist fetch complete'); }).catch(e => { console.error('[Init] Initial fetch failed:', e.message); }); // Background timers setInterval(fetchFromGist, 4000); setInterval(flushQueue, 2000); app.listen(PORT, '0.0.0.0', () => { console.log(''); console.log(' ╔═══════════════════════════════════════════════╗'); console.log(' ║ 💎 AnBiMessenger Server Started (Hardened) ║'); console.log(' ╠═══════════════════════════════════════════════╣'); console.log(` ║ Port: ${PORT} ║`); console.log(` ║ GITHUB_TOKEN: ${GITHUB_TOKEN ? '✅ Set' : '❌ Not set'} ║`); console.log(` ║ GIST_ID: ${GIST_ID ? '✅ Set' : '❌ Not set'} ║`); console.log(` ║ Shard Size: ${SHARD_SIZE} msgs/shard ║`); console.log(` ║ Auth: Room Token + IDOR checks ║`); console.log(` ║ Express: v4 (stable) ║`); console.log(' ╚═══════════════════════════════════════════════╝'); console.log(''); if (!GITHUB_TOKEN || !GIST_ID) { console.log(' ⚠️ Set GITHUB_TOKEN and GIST_ID environment variables'); console.log(' for persistent message storage via GitHub Gist.'); console.log(''); } });