/** * WireGuard invite redemption routes — DC-150. * * PUBLIC, unauthenticated mount (the invite token IS the proof, spec §8.5): * * GET /network/wg/redeem/:token → self-contained redemption page * (browser X25519 keygen, spec §8.2) * POST /network/wg/redeem/:token → redeem API: validates the one-time * token, registers the browser-generated * PUBLIC key, returns the client config * * Key-lifecycle invariants (NORMATIVE, spec §8.2): * - The client keypair is generated in the invitee's BROWSER via WebCrypto * X25519; the server receives ONLY the base64 public key. * - The private key never transits or rests on the server: it lives in the * page session, rendered into the config text + QR CLIENT-side. * - This module must never reference, accept, or emit a private key. * * The provider contract used here (DC-149, src/network/providers/wireguard.js): * - provider.redeemInvite(token) — single-use + 7d TTL, marks the token * used on success, throws on unknown/used/expired tokens. * (Note: the DC-149 provider has no separate getInvite peek; redeemInvite * is the validation primitive. Any failure there maps to 410 Gone.) * - provider.addPeer({ personId, deviceId, publicKey, name }) → * { address, allowedIps, dns, hubPublicKey, hubAddress, listenPort, ... } * * Middleware registration (NOT done here — src/utilities/middleware.js is * drift-test guarded): export PUBLIC_REDEEM_PATH_PREFIX and add * { path: PUBLIC_REDEEM_PATH_PREFIX, prefix: true } → PUBLIC_ROUTES * plus the CSRF excludedPaths entry for the POST. * The CSRF skip is safe because the token is single-use, 7-day TTL, and the * page is served cross-origin cold (no session cookie exists yet). */ 'use strict'; const crypto = require('crypto'); /** Public mount prefix — also the constant middleware registration needs. */ const PUBLIC_REDEEM_PATH_PREFIX = '/network/wg/redeem/'; /** WireGuard public/private key format: 43 base64 chars + '=' pad → 32 bytes. */ const WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/; const MAX_DEVICE_NAME = 100; /** * Deterministic personId for a redeemed invite. The DC-149 redeemInvite * returns { role, email } only (no person record), so the route derives a * stable id: invite-carried personId > invite email > hash of the token. * Deterministic-on-token means a retried redemption (e.g. addPeer failed * after the token burned) maps to the same person, not a duplicate. */ function derivePersonId(invite, token) { if (invite && typeof invite.personId === 'string' && invite.personId) { return invite.personId; } if (invite && typeof invite.email === 'string' && invite.email.includes('@')) { return 'wge:' + invite.email; } return ( 'wgi:' + crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 24) ); } function validDeviceName(s) { return typeof s === 'string' && s.trim().length > 0 && s.trim().length <= MAX_DEVICE_NAME; } function validPublicKey(s) { if (typeof s !== 'string' || !WG_KEY_RE.test(s)) return false; try { return Buffer.from(s, 'base64').length === 32; } catch (_) { return false; } } /** * The redemption page. Self-contained: no build step, no server-side * keygen, no server-side QR dependency (CDN qrcode with a graceful * copyable-text + download fallback when the CDN is unreachable). */ function renderRedemptionPage() { return '\n' + '\n' + '\n' + '\n' + '\n' + '\n' + 'DashCaddy Private Network — Device Setup\n' + '\n' + '\n' + '\n' + '
\n' + '

Join the DashCaddy Private Network

\n' + '

Your device keys are generated in this browser and never leave it — the server only receives your public key.

\n' + '
\n' + ' \n' + ' \n' + ' \n' + '
\n' + ' \n' + ' \n' + '
\n' + '\n' + '\n' + '\n' + '\n'; } /** * Build the redemption router. * * @param {object} deps * @param {object} deps.provider - WireGuard NetworkProvider (DC-149): * redeemInvite(token) + addPeer({...}) are required. * @param {string} [deps.endpoint] - hub endpoint host:port for the client * config. Defaults to the request's own host + the port addPeer reports * (behind NAT the setup wizard supplies the explicit endpoint). * @param {Function} [deps.asyncHandler] - prod wiring; test fallback wraps. * @param {object} [deps.log] - logger; MUST NEVER receive the token value. */ module.exports = function networkWgRedeemRoutesFactory({ provider, endpoint, asyncHandler, log = { info() {}, warn() {}, error() {} }, } = {}) { const router = require('express').Router(); // eslint-disable-line global-require if (!provider || typeof provider.redeemInvite !== 'function' || typeof provider.addPeer !== 'function') { throw new Error('networkWgRedeemRoutes requires a WireGuard provider (redeemInvite + addPeer)'); } if (!asyncHandler) { if (process.env.NODE_ENV !== 'test') { throw new Error('networkWgRedeemRoutes requires asyncHandler'); } asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // eslint-disable-line no-param-reassign } /** Derive the client-facing endpoint for THIS request if none injected. * The host's HTTP port is NOT the WG UDP port — always rebuild as * hostname:listenPort. */ function endpointFor(req, listenPort) { if (endpoint) return endpoint; const fwd = req.headers['x-forwarded-host']; const rawHost = (typeof fwd === 'string' && fwd.split(',')[0].trim()) || req.get('host') || req.hostname; const hostname = String(rawHost).split(':')[0]; const port = listenPort || 51820; return hostname + ':' + port; } // ── GET /:token — the redemption page (static; token validated at POST) ── router.get('/:token', (req, res) => { res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'no-store'); // page embeds the keygen flow — never cache res.set('X-Frame-Options', 'DENY'); res.set('Referrer-Policy', 'no-referrer'); res.send(renderRedemptionPage()); }); // ── POST /:token — redeem (public API, rate-limit friendly: one store // read + one store write, no crypto on this path) ────────────────────── router.post('/:token', asyncHandler(async (req, res) => { const token = req.params.token; const { deviceName, publicKey } = req.body || {}; // 1. Input validation FIRST — never burn a one-time token on a bad body. if (!validDeviceName(deviceName)) { return res.status(400).json({ success: false, error: 'deviceName is required (1-100 characters)', code: 'DC-400', }); } if (!validPublicKey(publicKey)) { return res.status(400).json({ success: false, error: 'publicKey must be a base64 WireGuard public key (44 chars, decodes to 32 bytes)', code: 'DC-400', }); } // 2. Token validation + single-use burn (DC-149 redeemInvite: 7d TTL, // marks used on success, throws on unknown/used/expired). Any failure // here is a dead token → 410 Gone. The token value is never logged. let invite; try { invite = await provider.redeemInvite(token); } catch (err) { log.warn && log.warn('network:wg:redeem', 'invite token rejected (not logged)', { reason: (err && err.code) || 'unknown', }); return res.status(410).json({ success: false, error: 'This invite link has expired or was already used.', code: 'DC-410', }); } // 3. Register the browser-generated PUBLIC key as a hub peer. const personId = derivePersonId(invite, token); const deviceId = 'wgd_' + crypto.randomBytes(8).toString('hex'); let added; try { added = await provider.addPeer({ personId, deviceId, publicKey, name: deviceName.trim() }); } catch (err) { if (err && err.code === 'bad_input') { return res.status(400).json({ success: false, error: err.message || 'peer rejected', code: 'DC-400', }); } throw err; // provider/infra failure → app error handler (5xx) } log.info && log.info('network:wg:redeem', 'device joined via invite', { personId, deviceId, }); // 4. Client config. NO private key anywhere in this response — the // browser composes the full config (with its session-local private // key) client-side per spec §8.2. const cfg = added || {}; return res.status(200).json({ success: true, deviceName: deviceName.trim(), deviceId, config: { address: cfg.address, dns: cfg.dns || '10.53.0.1', // spec §8.3: Technitium on the hub IP hubPublicKey: cfg.hubPublicKey, allowedIps: cfg.allowedIps || '10.53.0.0/16', // spec §8.1: split tunnel endpoint: endpointFor(req, cfg.listenPort), }, }); }, 'network-wg-redeem')); return router; }; module.exports.PUBLIC_REDEEM_PATH_PREFIX = PUBLIC_REDEEM_PATH_PREFIX; module.exports.renderRedemptionPage = renderRedemptionPage; module.exports.derivePersonId = derivePersonId;