f053c1befa
Zero-dependency Node server on the RM Circle pattern. Chain config lives in the volume so the same code runs the Amoy dress rehearsal and mainnet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
121 lines
5.3 KiB
JavaScript
121 lines
5.3 KiB
JavaScript
// 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);
|
|
let memberId = 0;
|
|
try { memberId = await chain.memberIdByAccount(a); } catch (e) { /* chain read down: session still valid */ }
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
sessions.set(token, { address: a, memberId, expires: Date.now() + SESSION_TTL });
|
|
saveSessions();
|
|
return { token, address: a, memberId };
|
|
}
|
|
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
|
|
try {
|
|
const id = await chain.memberIdByAccount(sess.address);
|
|
if (id && id !== sess.memberId) { sess.memberId = id; sessions.set(sess.token, {
|
|
address: sess.address, memberId: id, expires: sess.expires }); saveSessions(); }
|
|
return id;
|
|
} catch (e) { return sess.memberId; }
|
|
}
|
|
function logout(req) {
|
|
const s = fromRequest(req);
|
|
if (s) { sessions.delete(s.token); saveSessions(); }
|
|
}
|
|
|
|
module.exports = { init, makeChallenge, verifyChallenge, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout };
|