Files
rm-circle-team-router/messages.js
T
martbost 3620a72338 Wallet diagnostics: a check page, and an error that names the signer
Two sign-in failures with different causes and no way to tell them apart from
the outside, so stop guessing and get the data off the member's device.

/wallet-check reports what the page can actually see: whether window.ethereum
exists, which wallet flags are set, which EIP-6963 wallets announced, the
accounts and chain the wallet returns — and critically, any CSP violation.
Our script-src is 'self' with no 'unsafe-inline', and a wallet browser that
injects its provider via a script tag would be blocked silently, producing
symptoms identical to "no wallet installed". The company dApp sends no CSP at
all, which is a plausible reason it connects where we do not. This page will
confirm or kill that theory rather than us theorising further.

"Signature does not match this wallet" was true but useless — it never said
WHICH wallet signed. It now names both the address the page asked for and the
address that actually signed, which identifies the classic multi-account case
(wallet signs with the selected account, not the one the page picked up) in
one glance.

Verified for the case in hand: 0x20E0…2248 IS the wallet registered to
position #52 on-chain, so ownership was never the problem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:53:34 -05:00

185 lines
8.6 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;
}
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: '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);
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 };