// Wallet sign-in (SIWE / EIP-4361) for InstantAdPay. // Pattern lifted from the RM Circle messages.js implementation (proven with // MetaMask's friendly sign-in UI). One free signature, cannot move funds. // // Difference from RM Circle: a wallet WITHOUT an on-chain member id still gets // a session — free members exist site-side only until their payout activation // or first purchase writes them on-chain (spec §4). const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { keccak256 } = require('./vendor/sha3'); const secp = require('./vendor/secp256k1'); let DATA_DIR = null; let chain = null; let IS_PROD = false; let SITE = 'instantadpay.com'; const CHALLENGE_TTL = 10 * 60 * 1000; const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days const challenges = new Map(); // addressLower -> {message, exp} let sessions = new Map(); // token -> {address, memberId, expires} const SESS_FILE = () => path.join(DATA_DIR, 'sessions.json'); function loadSessions() { try { const o = JSON.parse(fs.readFileSync(SESS_FILE(), 'utf8')); sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now())); } catch (e) { sessions = new Map(); } } function saveSessions() { try { const tmp = SESS_FILE() + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 }); fs.renameSync(tmp, SESS_FILE()); } catch (e) { console.error('session save failed', e.message); } } function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd; if (opts.site) SITE = opts.site; 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); } const ADDR_RE = /^0x[0-9a-fA-F]{40}$/; 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; } // ---- auth flow ---- function makeChallenge(address) { if (!ADDR_RE.test(address || '')) return { error: 'Bad address' }; const a = address.toLowerCase(); const nonce = crypto.randomBytes(16).toString('hex'); const chainId = chain.getConfig().chainId; const message = `${SITE} wants you to sign in with your Ethereum account:\n${checksumAddress(a)}\n\nInstantAdPay member sign-in. This signature is free and cannot move funds or approve anything.\n\nURI: https://${SITE}\nVersion: 1\nChain ID: ${chainId}\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`; challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL }); return { message }; } async 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 (' + rec.slice(0, 6) + '…' + rec.slice(-4) + '). Switch accounts and tap sign-in again.' }; challenges.delete(a); return { ok: true, address: a }; } // Sessions carry {email, address, memberId} — email accounts are the normal // join path; the wallet fields fill in when one is linked at purchase time. function mintSession(fields) { const token = crypto.randomBytes(32).toString('hex'); sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields, { expires: Date.now() + SESSION_TTL })); saveSessions(); return token; } function updateSession(token, fields) { const s = sessions.get(token); if (!s) return; sessions.set(token, Object.assign({}, s, fields)); saveSessions(); } function sessionCookie(token) { return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`; } function clearCookie() { return 'iap.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; } function fromRequest(req) { const m = /(?:^|;\s*)iap\.sid=([^;]+)/.exec(req.headers.cookie || ''); if (!m) return null; const token = decodeURIComponent(m[1]); const s = sessions.get(token); if (!s || s.expires < Date.now()) return null; return Object.assign({ token }, s); } async function refreshMemberId(sess) { // called after an on-chain action so the session learns its new member id if (!sess.address) return sess.memberId || 0; try { const id = await chain.memberIdByAccount(sess.address); if (id && id !== sess.memberId) updateSession(sess.token, { memberId: id }); return id || sess.memberId || 0; } catch (e) { return sess.memberId || 0; } } function logout(req) { const s = fromRequest(req); if (s) { sessions.delete(s.token); saveSessions(); } } module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout };