// Missions, tokens and proof codes. // // A mission: { id, site, host, name, brief, url, dwell, reward: {min, max}, budget, active, created } // host the origin the embed must call from (e.g. instantadpay.com); the proof only ever // answers a request whose Origin header is that host // brief what the hunter is told ("Open the ledger and find your code next to the newest payout") // url where the mission link sends them; the token is appended as ?ph= // dwell seconds on the site before the code will render // budget completions this mission will still pay (0 = unlimited) // // A token is issued when a hunter starts a mission: { t, memberId, missionId, iat, exp }. The // code they must type is derived from the token with the server secret, so it is unique to that // member and that sitting, worthless to anyone else, and different tomorrow. The hiding place // (which page / which slot the embed renders in) is also derived, from the day and the member. 'use strict'; const crypto = require('crypto'); const store = require('./store'); const SECRET = String(process.env.HUNT_CODE_SECRET || process.env.HUNT_SSO_SECRET || '').trim(); const TOKEN_TTL = 20 * 60000; const h = s => crypto.createHmac('sha256', SECRET).update(s).digest(); const ctDay = t => new Date(t === undefined ? Date.now() : t).toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); function list() { return store.read('missions', []); } function get(id) { return list().find(m => m.id === id) || null; } function save(m) { return store.update('missions', [], all => { const i = all.findIndex(x => x.id === m.id); if (i >= 0) all[i] = Object.assign(all[i], m); else all.push(Object.assign({ created: Date.now(), active: true }, m)); return all; }); } function remove(id) { return store.update('missions', [], all => all.filter(m => m.id !== id)); } // each hunter sees the active missions in their own order, so two people comparing notes are // not looking at the same list; the order is stable for a member within a day function forMember(memberId) { const day = ctDay(); return list().filter(m => m.active).map(m => ({ m, k: h(day + ':' + memberId + ':' + m.id).readUInt32BE(0) })) .sort((a, b) => a.k - b.k).map(x => x.m); } // ---- tokens ------------------------------------------------------------------------------ function issue(memberId, missionId) { const t = crypto.randomBytes(16).toString('hex'); const rec = { t, memberId: Number(memberId), missionId, iat: Date.now(), exp: Date.now() + TOKEN_TTL }; store.update('tokens', {}, all => { for (const k of Object.keys(all)) if (all[k].exp < Date.now() - 3600000) delete all[k]; all[t] = rec; return all; }); return rec; } function token(t) { const rec = store.read('tokens', {})[String(t || '')]; return rec && rec.exp > Date.now() ? rec : null; } // the proof: six characters from HMAC(secret, token); shown by the embed, typed by the hunter function codeFor(rec) { return h('code:' + rec.t + ':' + rec.memberId + ':' + rec.missionId).toString('hex').slice(0, 6).toUpperCase(); } // the hiding place: which slot on the page the embed renders in, by day and member function slotFor(rec, slots) { const n = Math.max(1, Number(slots) || 1); return h('slot:' + ctDay() + ':' + rec.memberId + ':' + rec.missionId).readUInt32BE(0) % n; } // The embed asks for the code. Answered only when: the token is live, the request's Origin is the // mission's host, and the dwell has passed since the token was issued. Anything else is silence. function codeForEmbed(t, origin) { const rec = token(t); if (!rec) return { error: 'expired' }; const m = get(rec.missionId); if (!m || !m.active) return { error: 'gone' }; const host = String(origin || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, '').toLowerCase(); // Origin carries a port; the mission host never does const allowed = [m.host, 'www.' + m.host].map(x => x.toLowerCase()); if (!allowed.includes(host)) return { error: 'origin' }; const waited = (Date.now() - rec.iat) / 1000; if (waited < (m.dwell || 30)) return { wait: Math.ceil((m.dwell || 30) - waited) }; return { code: codeFor(rec), slot: slotFor(rec, m.slots || 1) }; } // The hunter submits what they found. function check(t, memberId, typed) { const rec = token(t); if (!rec) return { error: 'That mission timed out. Start it again from your board.' }; if (rec.memberId !== Number(memberId)) return { error: 'That mission belongs to a different account.' }; const want = codeFor(rec); const got = String(typed || '').trim().toUpperCase(); if (got.length !== 6) return { error: 'Codes are 6 characters and you typed ' + got.length + '. Look at the site again: the code sits in six boxes, and a tap on it copies all six.' }; if (got !== want) return { error: 'That is not the code. It is on the site, and it is yours alone.' }; return { ok: true, rec }; } module.exports = { list, get, save, remove, forMember, issue, token, codeForEmbed, check, ctDay };