/* ============================================================================ * p2p-routes.js — device registration + holdings (docs/p2p-architecture.md * flows 3 and 5). Mounted by server.js; every route answers 404 when * P2P_ENABLED=0. * * GET /api/p2p/config { ok, enabled, staleDays } * POST /api/p2p/device { fingerprint?, profile? } → { ok, deviceId, secret } * POST /api/p2p/holdings (device) { items:[{cid,size,videoId}], share } → { ok, accepted, unknown, challenges } * POST /api/p2p/challenge (device) { answers:[{cid,offset,length,sha256}] } → { ok, passed, failed } * GET /api/p2p/holders?v=|cid= availability — added by plan 014 * * Device auth: header `X-Device: .`; only sha256(secret) is * stored. A holdings report is always the device's FULL list: anything it held * before and no longer lists is marked removed. Holder rows never expire by * time — last_verified_at is refreshed by each report (see the architecture doc). * ========================================================================== */ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; const CID_RE = /^[0-9a-f]{64}$/; const DEV_RE = /^dev_[0-9a-f]{16}$/; const MAX_ITEMS = 5000; const MAX_CHALLENGES = 5; const CHALLENGE_LEN = 64 * 1024; const sha = (s) => createHash('sha256').update(String(s)).digest('hex'); const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); }; export const peerIdOf = (deviceId) => sha('peer:' + deviceId).slice(0, 12); export function registerP2pRoutes(app, deps) { const { cfg, p2pDb, fileForCid, sha256Range, now = () => Date.now(), log = console } = deps; const pending = new Map(); // `${deviceId}|${cid}` -> { offset, length, at } const regLog = new Map(); // ip -> [ms] const gate = async (c, next) => { if (!cfg.enabled) return c.json({ ok: false, error: 'p2p disabled' }, 404); await next(); }; async function deviceOf(c) { const m = String(c.req.header('x-device') || '').match(/^(dev_[0-9a-f]{16})\.([0-9a-f]{64})$/); if (!m) return null; const d = await p2pDb.getDevice(m[1]); if (!d || !same(d.secret_hash, sha(m[2]))) return null; return d; } const requireDevice = async (c, next) => { const d = await deviceOf(c); if (!d) return c.json({ ok: false, error: 'unknown device' }, 401); c.set('device', d); await next(); }; app.get('/api/p2p/config', (c) => c.json({ ok: true, enabled: cfg.enabled, staleDays: cfg.staleDays })); app.post('/api/p2p/device', gate, async (c) => { const ip = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; const t = now(); const recent = (regLog.get(ip) || []).filter((x) => t - x < 3600_000); if (recent.length >= 20) return c.json({ ok: false, error: 'too many registrations' }, 429); recent.push(t); regLog.set(ip, recent); if (regLog.size > 10000) regLog.clear(); const body = await c.req.json().catch(() => ({})); const deviceId = 'dev_' + randomBytes(8).toString('hex'); const secret = randomBytes(32).toString('hex'); await p2pDb.createDevice({ deviceId, secretHash: sha(secret), fingerprint: String(body.fingerprint || '').slice(0, 128) || null, profile: String(body.profile || '').slice(0, 64) || null, now: t, }); return c.json({ ok: true, deviceId, secret }); }); app.post('/api/p2p/holdings', gate, requireDevice, async (c) => { const d = c.get('device'); const body = await c.req.json().catch(() => ({})); const t = now(); const share = body.share !== false; await p2pDb.touchDevice(d.device_id, { now: t, share, profile: String(body.profile || '').slice(0, 64) || undefined }); const raw = Array.isArray(body.items) ? body.items.slice(0, MAX_ITEMS) : []; const items = share ? raw.filter((x) => x && CID_RE.test(String(x.cid))) : []; const known = await p2pDb.knownCids(items.map((x) => x.cid)); const accepted = []; const unknown = []; for (const it of items) { if (!known.has(it.cid)) { unknown.push(it.cid); continue; } await p2pDb.upsertHolder({ cid: it.cid, deviceId: d.device_id, now: t }); accepted.push(it.cid); } await p2pDb.removeHoldersExcept({ deviceId: d.device_id, keep: accepted, now: t }); // Spot-check a few holdings against the server's own copy when it has one. const challenges = []; for (const cid of accepted) { if (challenges.length >= MAX_CHALLENGES) break; const f = await fileForCid(cid); if (!f || !(f.size > CHALLENGE_LEN)) continue; const offset = Math.floor(Math.random() * (f.size - CHALLENGE_LEN)); pending.set(d.device_id + '|' + cid, { offset, length: CHALLENGE_LEN, at: t }); challenges.push({ cid, offset, length: CHALLENGE_LEN }); } if (pending.size > 50000) pending.clear(); return c.json({ ok: true, accepted, unknown, challenges }); }); app.post('/api/p2p/challenge', gate, requireDevice, async (c) => { const d = c.get('device'); const body = await c.req.json().catch(() => ({})); const t = now(); const passed = []; const failed = []; for (const a of (Array.isArray(body.answers) ? body.answers : []).slice(0, MAX_CHALLENGES)) { const key = d.device_id + '|' + a.cid; const p = pending.get(key); if (!p || p.offset !== a.offset || p.length !== a.length || t - p.at > 10 * 60_000) continue; pending.delete(key); const f = await fileForCid(a.cid); if (!f) continue; const want = await sha256Range(f.path, p.offset, p.length); if (String(a.sha256) === want) { await p2pDb.setHolderTrust({ cid: a.cid, deviceId: d.device_id, trust: 'challenged', now: t }); passed.push(a.cid); } else { await p2pDb.removeHolder({ cid: a.cid, deviceId: d.device_id, now: t }); failed.push(a.cid); log.warn?.(`[p2p] ${d.device_id} failed challenge for ${a.cid.slice(0, 12)}`); } } return c.json({ ok: true, passed, failed }); }); return { deviceOf, peerIdOf, requireDevice, gate }; }