Files
rm-circle-team-router/messages.js
T
martbost 49a0778a53 Post-join sign-in actually works: live position lookup for a cold index, ordered calls, redirect no longer races the signature
#787 registered at 18:35 CT, 22 minutes after the first join-flow fix, and still had no profile. Two
causes, both fixed:

1. messages.verifyChallenge resolved the wallet through chain.memberIdByAccount, which reads the
   CACHED index. Seconds after a registration that wallet is not in it, so the signature was rejected
   with "No RM Circle position is registered to this wallet". It now accepts an idHint (the position
   id from the member's own registration receipt) and, on a cache miss, reads that id live from the
   contract via chain.verifyMember, minting only when the contract says this exact wallet owns it.
   That is a stronger proof than the cache, not a weaker one. Now async; the single call site awaits.

2. join-now.js fired the sign-in and a 4.5s redirect in parallel, so the page could navigate away
   while the wallet was still showing the signature prompt, and it did not wait for submit-id (which
   runs the live verifyMember server-side that seeds the index). It now awaits the report, passes the
   receipt id, and redirects only once the signature settles, with a 120s bailout.

qa/signin-fallback.mjs (7 assertions) proves the cold-index path with real secp256k1 signatures and
covers the abuse cases: a hint for a position the wallet does not own is refused, and a signature from
another wallet is refused. Existing suites still pass: profiles-unit 28, gate-e2e 47.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:49:33 -05:00

215 lines
10 KiB
JavaScript

// 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;
}
// idHint: the position id a brand-new member just got from their registration
// receipt. memberIdByAccount reads the CACHED index, which the indexer has not
// refreshed yet seconds after a join (#787, 2026-09-16: signed in 22 minutes after
// the join-flow fix shipped and was still rejected with "no position registered").
// On a cache miss we read that id straight off the chain and accept it only when
// the contract says this exact wallet owns it, so the proof is stronger, not weaker.
async function verifyChallenge(address, signature, idHint) {
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: 'Your wallet signed with a different account than the page is using. '
+ 'The page asked for ' + a.slice(0, 6) + '…' + a.slice(-4) + ' but the signature came from '
+ rec.slice(0, 6) + '…' + rec.slice(-4) + '. Switch your wallet to the account that owns this '
+ 'position, reload the page, then tap sign-in again.', signer: rec, expected: a };
challenges.delete(a);
let id = chain.memberIdByAccount(a);
if (!id && idHint) {
const hint = Number(idHint);
if (Number.isInteger(hint) && hint > 0) {
try {
const m = await chain.verifyMember(hint); // live contract read; also seeds the index
if (m && m.registered && String(m.account || '').toLowerCase() === a) id = hint;
} catch (e) { console.error('verifyChallenge live lookup', e.message); }
}
}
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 }));
}
// Drop a session. The sign-in cookie is persistent (30 days), so closing the
// browser does NOT end it - a member holding several positions who connects a
// different wallet keeps getting served the position they first signed in as.
function clearSession(req) {
// authFromCookie returns the session VALUE, not its key - read the token
// straight off the cookie so the server-side entry really goes away.
try {
const m = /(?:^|;\s*)ctb\.msid=([^;]+)/.exec(req.headers.cookie || '');
if (m) sessions.delete(decodeURIComponent(m[1]));
} catch (e) {}
}
function clearCookie() {
return `ctb.msid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${IS_PROD ? '; Secure' : ''}`;
}
module.exports = { init, makeChallenge, verifyChallenge, mintSession, authFromCookie, sessionCookie, clearSession, clearCookie, send, inbox, markRead, unreadCount, adminList, ADDR_RE };