diff --git a/messages.js b/messages.js index 0f28c5c..18fa591 100644 --- a/messages.js +++ b/messages.js @@ -74,7 +74,13 @@ function makeChallenge(address) { challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL }); return message; } -function verifyChallenge(address, signature) { +// 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.' }; @@ -85,7 +91,16 @@ function verifyChallenge(address, signature) { + 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); + 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 }); diff --git a/public/join-now.js b/public/join-now.js index 9f6daa9..79c7a3a 100644 --- a/public/join-now.js +++ b/public/join-now.js @@ -81,17 +81,27 @@ fetch('/api/public/config').then(function(r){return r.json()}).then(function(c){ // it opens their inbox session so the member-profile gate can ask for a username // and email on the dashboard. Best-effort — if they decline, the join still stands // and they are asked again the first time they sign in. - async function signInNewMember(newId){ + async function signInNewMember(newId,reported){ + var go=function(){ try{ location.href='/my/'+newId; }catch(e){} }; + var bailout=setTimeout(go,120000); // wallet never answered: leave anyway try{ + // submit-id runs a LIVE verifyMember server-side, which seeds the index with + // this brand-new position. Let it finish before asking the server who we are. + try{ await reported; }catch(e){} $('resultSub').innerHTML='Welcome to the team! One free signature sets up your member profile…'; var ch=await (await fetch('/api/public/msg-challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account})})).json(); - if(!ch||!ch.message) return; + if(!ch||!ch.message){ clearTimeout(bailout); setTimeout(go,2500); return; } var hex='0x'; for(var i=0,b=new TextEncoder().encode(ch.message);i#'+newId+'. Redirecting to your dashboard…','ok'); try{ el_scroll('result'); }catch(e){} try{sessionStorage.setItem('rmc.fresh','1');}catch(e){} - signInNewMember(newId); - setTimeout(function(){ location.href='/my/'+newId; }, 4500); + signInNewMember(newId,reported); // redirects when it settles } else { log('Confirmed on-chain. Open your dashboard at /my and enter your new ID (also shown on Polygonscan).','ok'); $('joinBtn').disabled=false; @@ -209,7 +218,7 @@ fetch('/api/public/config').then(function(r){return r.json()}).then(function(c){ var name=(nameEl&&nameEl.value?nameEl.value.trim().slice(0,60):'')||('Self-enrolled #'+newId); var src=(window.ctbGetSource?window.ctbGetSource():'(direct)'); var cid=(window.ctbGetClickId?window.ctbGetClickId():''); - fetch('/api/public/submit-id',{method:'POST',headers:{'Content-Type':'application/json'}, + return fetch('/api/public/submit-id',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({newId:String(newId),memberName:name,sponsorId:String(sponsorId||'?'),source:src,clickid:cid}) }).catch(function(){}); }catch(e){} diff --git a/qa/signin-fallback.mjs b/qa/signin-fallback.mjs new file mode 100644 index 0000000..2ceee80 --- /dev/null +++ b/qa/signin-fallback.mjs @@ -0,0 +1,87 @@ +// Proves the post-registration sign-in fix: a wallet whose position is NOT yet in the +// cached chain index (the state a member is in for the first minute after joining) can +// still sign in, via a live contract read of the id from their registration receipt, +// and only when the contract agrees that wallet owns that id. +import { createRequire } from 'node:module'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +const ROOT = 'D:/Projects/HighRisk/The RM Circle/promos/'; +const require = createRequire(ROOT + 'package.json'); +const secp = require(ROOT + 'vendor/secp256k1.js'); +const { keccak256 } = require(ROOT + 'vendor/sha3.js'); +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 messages = require(ROOT + 'messages.js'); + +const DIR = 'D:/tmp/rmc-signin-test'; +fs.rmSync(DIR, { recursive: true, force: true }); fs.mkdirSync(DIR, { recursive: true }); + +// a wallet we control +const priv = Buffer.from('59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', 'hex'); +const pub = secp.getPublicKey(priv, false); +const ADDR = '0x' + keccak256(Buffer.from(pub.slice(1))).slice(-40); + +// a chain stub in the exact state that bit #787: the index has NOT seen this member yet +let liveReads = 0, seeded = false; +const chain = { + memberIdByAccount: () => (seeded ? 4242 : null), // cache miss until verifyMember seeds it + verifyMember: async (id) => { liveReads++; seeded = true; return id === 4242 ? { registered: true, account: ADDR } : { registered: false }; }, + isInTeam: () => true +}; +messages.init({ dataDir: DIR, chain, isProd: false }); + +const sign = (msg) => { + const m = Buffer.from(msg, 'utf8'); + const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8'); + const digest = Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex'); + const [sig, rec] = secp.signSync(digest, priv, { recovered: true, der: false }); + return '0x' + Buffer.from(sig).toString('hex') + (27 + rec).toString(16).padStart(2, '0'); +}; + +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; + +// 1. the old behaviour: no id hint, index cold -> refused +let ch = messages.makeChallenge(ADDR); +let r = await messages.verifyChallenge(ADDR, sign(ch)); +t('cold index with no hint is refused', !!r.error && /No RM Circle position/.test(r.error), JSON.stringify(r)); +t('no live read was attempted without a hint', liveReads === 0); + +// 2. the fix: same cold index, but the receipt id is passed +ch = messages.makeChallenge(ADDR); +r = await messages.verifyChallenge(ADDR, sign(ch), '4242'); +t('cold index WITH the receipt id signs in', !!r.token && r.id === 4242, JSON.stringify(r)); +t('it did a live contract read', liveReads === 1); + +// 3. the hint cannot be abused: a wrong id is refused because the chain disagrees +seeded = false; +ch = messages.makeChallenge(ADDR); +r = await messages.verifyChallenge(ADDR, sign(ch), '9999'); +t('a hint for a position this wallet does not own is refused', !!r.error, JSON.stringify(r)); + +// 4. a forged signature is still refused even with a valid hint +seeded = false; +ch = messages.makeChallenge(ADDR); +const otherPriv = crypto.randomBytes(32); +const forge = (msg) => { + const m = Buffer.from(msg, 'utf8'); + const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8'); + const digest = Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex'); + const [sig, rec] = secp.signSync(digest, otherPriv, { recovered: true, der: false }); + return '0x' + Buffer.from(sig).toString('hex') + (27 + rec).toString(16).padStart(2, '0'); +}; +r = await messages.verifyChallenge(ADDR, forge(ch), '4242'); +t('a signature from another wallet is refused', !!r.error && /different account/.test(r.error), JSON.stringify(r)); + +// 5. once the indexer catches up, no live read is needed +seeded = true; liveReads = 0; +ch = messages.makeChallenge(ADDR); +r = await messages.verifyChallenge(ADDR, sign(ch), '4242'); +t('warm index signs in with no live read', !!r.token && liveReads === 0); + +console.log('PASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +process.exit(bad.length ? 1 : 0); diff --git a/server.js b/server.js index 6a6015c..f3453af 100644 --- a/server.js +++ b/server.js @@ -775,7 +775,7 @@ async function handleApi(req,res,pathname){ if(req.method==='POST'&&pathname==='/api/public/msg-verify'){ const b=await bodyJson(req); if(!b||typeof b.address!=='string'||!messages.ADDR_RE.test(b.address)||typeof b.signature!=='string')return json(res,400,{error:'Invalid request.'}); - const r=messages.verifyChallenge(b.address,b.signature); + const r=await messages.verifyChallenge(b.address,b.signature,b.id); if(r.error)return json(res,401,{error:r.error}); return json(res,200,{ok:true,id:r.id},{'Set-Cookie':messages.sessionCookie(r.token)}); }