// RM Circle — wallet-verified member messaging. // Identity = the wallet that owns a position (proved by a free personal_sign, // verified server-side via vendored keccak256 + secp256k1 recovery — the two // files in ./vendor are pinned; see the commit that added them). // Permission = the matrix: you may message your own downline (direct or // broadcast) and your own upline chain. Nothing else. Admin can review all // messages (disclosed in the UI). 'use strict'; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const secp = require('./vendor/secp256k1.js'); const sha3 = require('./vendor/sha3.js'); const keccak256 = sha3.keccak256; secp.utils.hmacSha256Sync = (key, ...msgs) => { const h = crypto.createHmac('sha256', Buffer.from(key)); msgs.forEach(m => h.update(Buffer.from(m))); return Uint8Array.from(h.digest()); }; const SESSION_TTL = 30 * 24 * 3600 * 1000; const CHALLENGE_TTL = 10 * 60 * 1000; const MAX_BODY = 1500; const MAX_STORE = 5000; const DAILY_DIRECT = 30, DAILY_BROADCAST = 3; let DATA_DIR = null, chain = null, IS_PROD = false; let sessions = new Map(); // token -> {address, id, expires} const challenges = new Map(); // addressLower -> {message, exp} const MSG_FILE = () => path.join(DATA_DIR, 'messages.json'); const SESS_FILE = () => path.join(DATA_DIR, 'msg-sessions.json'); function readJson(f, fb) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return fb; } } function writeJson(f, v) { fs.writeFileSync(f, JSON.stringify(v)); } function loadSessions() { const o = readJson(SESS_FILE(), {}); sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now())); } function saveSessions() { writeJson(SESS_FILE(), Object.fromEntries(sessions)); } function getMessages() { return readJson(MSG_FILE(), []); } function saveMessages(m) { writeJson(MSG_FILE(), m.slice(-MAX_STORE)); } function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd; loadSessions(); } // ---- crypto ---- function personalDigest(msg) { const m = Buffer.from(msg, 'utf8'); const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8'); return Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex'); } function recoverAddress(msg, signature) { const raw = Buffer.from(String(signature).replace(/^0x/, ''), 'hex'); if (raw.length !== 65) throw new Error('Bad signature length'); let v = raw[64]; if (v >= 27) v -= 27; if (v !== 0 && v !== 1) throw new Error('Bad signature recovery byte'); const pub = secp.recoverPublicKey(personalDigest(msg), raw.slice(0, 64), v, false); return '0x' + keccak256(Buffer.from(pub.slice(1))).slice(-40); } // ---- auth ---- const ADDR_RE = /^0x[0-9a-fA-F]{40}$/; // EIP-55 checksum (needed for the SIWE message format; wallets render // EIP-4361-formatted requests with their friendly "Sign-in" UI instead of a // raw-signature warning — matters for member trust in MetaMask) function checksumAddress(address) { const a = address.toLowerCase().replace(/^0x/, ''); const h = keccak256(a); let out = '0x'; for (let i = 0; i < a.length; i++) out += parseInt(h[i], 16) >= 8 ? a[i].toUpperCase() : a[i]; return out; } function makeChallenge(address) { const a = address.toLowerCase(); const nonce = crypto.randomBytes(16).toString('hex'); const message = `rmcircle.team wants you to sign in with your Ethereum account:\n${checksumAddress(a)}\n\nRM Circle member sign-in for on-site messaging. This signature is free and cannot move funds or approve anything.\n\nURI: https://rmcircle.team\nVersion: 1\nChain ID: 137\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`; challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL }); return message; } function verifyChallenge(address, signature) { const a = address.toLowerCase(); const ch = challenges.get(a); if (!ch || ch.exp < Date.now()) return { error: 'Challenge expired - tap sign-in again.' }; let rec; try { rec = recoverAddress(ch.message, signature); } catch (e) { return { error: 'Invalid signature: ' + e.message }; } if (rec !== a) return { error: 'Signature does not match this wallet.' }; challenges.delete(a); const id = chain.memberIdByAccount(a); if (!id) return { error: 'No RM Circle position is registered to this wallet.' }; const token = crypto.randomBytes(32).toString('hex'); sessions.set(token, { address: a, id, expires: Date.now() + SESSION_TTL }); saveSessions(); return { token, id }; } // Mini App sessions: identity was already proved once (wallet-verified // Telegram link), and Telegram re-proves the chat via signed initData — so a // session can be minted without a fresh wallet signature. No address attached. function mintSession(id) { if (!Number.isInteger(id) || id < 1) return null; const token = crypto.randomBytes(32).toString('hex'); sessions.set(token, { address: null, id, expires: Date.now() + SESSION_TTL, via: 'tg' }); saveSessions(); return token; } function authFromCookie(req) { const m = /(?:^|;\s*)ctb\.msid=([^;]+)/.exec(req.headers.cookie || ''); if (!m) return null; const s = sessions.get(decodeURIComponent(m[1])); if (!s || s.expires < Date.now()) return null; return s; } function sessionCookie(token) { return `ctb.msid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`; } // ---- permissions ---- function canMessage(fromId, toId) { if (fromId === toId) return false; return chain.isInTeam(toId, fromId) || chain.isInTeam(fromId, toId); } function isRecipient(msg, myId) { if (msg.toId === myId) return true; if (msg.org && msg.fromId !== myId && chain.isInTeam(myId, msg.fromId)) return true; return false; } // ---- operations ---- function sanitizeBody(b) { return String(b || '').replace(/\r/g, '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').trim().slice(0, MAX_BODY); } function send(sess, body) { const text = sanitizeBody(body.body); if (!text) return { error: 'Write a message first.' }; const org = !!body.org; const msgs = getMessages(); const dayAgo = Date.now() - 24 * 3600 * 1000; const mine = msgs.filter(m => m.fromId === sess.id && m.ts > dayAgo); if (org && mine.filter(m => m.org).length >= DAILY_BROADCAST) return { error: `Broadcast limit reached (${DAILY_BROADCAST}/day).` }; if (!org && mine.filter(m => !m.org).length >= DAILY_DIRECT) return { error: `Daily message limit reached (${DAILY_DIRECT}/day).` }; let toId = 0; if (!org) { toId = Number(body.toId); if (!Number.isInteger(toId) || toId < 1) return { error: 'Enter the member ID to send to.' }; if (!canMessage(sess.id, toId)) return { error: `#${toId} is not in your team or upline - messaging follows your matrix lines only.` }; } const rec = { mid: crypto.randomBytes(8).toString('hex'), fromId: sess.id, toId, org, body: text, ts: Date.now(), read: {} }; msgs.push(rec); saveMessages(msgs); return { ok: true, mid: rec.mid }; } function inbox(sess) { const msgs = getMessages(); const forMe = [], sent = []; for (const m of msgs) { if (m.fromId === sess.id) sent.push(m); else if (isRecipient(m, sess.id)) forMe.push(m); } const shape = (m, mine) => ({ mid: m.mid, fromId: m.fromId, toId: m.toId || null, org: !!m.org, body: m.body, ts: m.ts, read: mine ? undefined : !!(m.read && m.read[sess.id]), mine }); return { id: sess.id, inbox: forMe.slice(-100).reverse().map(m => shape(m, false)), sent: sent.slice(-30).reverse().map(m => shape(m, true)) }; } function markRead(sess, mids) { const msgs = getMessages(); let changed = false; for (const m of msgs) { if (mids.includes(m.mid) && isRecipient(m, sess.id) && !(m.read && m.read[sess.id])) { m.read = m.read || {}; m.read[sess.id] = Date.now(); changed = true; } } if (changed) saveMessages(msgs); return { ok: true }; } function unreadCount(id) { if (!Number.isInteger(id) || id < 1) return 0; return getMessages().filter(m => isRecipient(m, id) && !(m.read && m.read[id])).length; } function adminList() { return getMessages().slice(-300).reverse().map(m => ({ mid: m.mid, fromId: m.fromId, toId: m.toId || null, org: !!m.org, body: m.body, ts: m.ts, readCount: Object.keys(m.read || {}).length })); } module.exports = { init, makeChallenge, verifyChallenge, mintSession, authFromCookie, sessionCookie, send, inbox, markRead, unreadCount, adminList, ADDR_RE };