From b4e683a7fc98cb4ba92471bf9321aee1d974cb4e Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 21:40:09 -0700 Subject: [PATCH] DC-150: WG invite redemption page (browser X25519 keygen, client-side config+QR) + public redeem API + tests --- .../network-wg-redeem.routes.test.js | 317 +++++++++++++++ dashcaddy-api/routes/network-wg-redeem.js | 370 ++++++++++++++++++ 2 files changed, 687 insertions(+) create mode 100644 dashcaddy-api/__tests__/network-wg-redeem.routes.test.js create mode 100644 dashcaddy-api/routes/network-wg-redeem.js diff --git a/dashcaddy-api/__tests__/network-wg-redeem.routes.test.js b/dashcaddy-api/__tests__/network-wg-redeem.routes.test.js new file mode 100644 index 0000000..641c494 --- /dev/null +++ b/dashcaddy-api/__tests__/network-wg-redeem.routes.test.js @@ -0,0 +1,317 @@ +/** + * Route tests for WireGuard invite redemption (DC-150). + * + * Coverage: + * - GET serves the redemption HTML (keygen script, X25519, pkcs8 references) + * - POST happy path: peer added, config returned, NO privateKey anywhere + * - used / expired / unknown token → 410 Gone + * - invalid publicKey (length, charset) → 400 + * - single-use enforced: second POST → 410 + * - deviceName validation (missing, blank, too long) → 400 + * - bad inputs never burn the token (validation precedes redemption) + * - token value is NEVER logged (log mock assertions) + * Hermetic — supertest against an in-process express app, mocked provider. + */ + +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const crypto = require('crypto'); +const redeemRoutes = require('../routes/network-wg-redeem'); + +const TOKEN = 'tok_' + 'a'.repeat(20); +const PUB_KEY = Buffer.alloc(32, 7).toString('base64'); // 44 chars, '=' pad + +function makeProvider({ failRedeem, failAddPeerCode } = {}) { + const calls = { redeemInvite: [], addPeer: [] }; + let used = false; + return { + calls, + // eslint-disable-next-line require-await + async redeemInvite(token) { + calls.redeemInvite.push(token); + if (failRedeem) { + const err = new Error(typeof failRedeem === 'string' ? failRedeem : 'redeemInvite: token expired'); + err.code = 'not_found'; + throw err; + } + if (used) { + const err = new Error('redeemInvite: token already used'); + err.code = 'bad_input'; + throw err; + } + used = true; + return { role: 'member', email: 'newbie@example.com' }; + }, + // eslint-disable-next-line require-await + async addPeer(args) { + calls.addPeer.push(args); + if (failAddPeerCode) { + const err = new Error('addPeer: rejected'); + err.code = failAddPeerCode; + throw err; + } + return { + deviceId: args.deviceId, + publicKey: args.publicKey, + address: '10.53.0.2/32', + allowedIps: '10.53.0.0/16', + dns: '10.53.0.1', + hubPublicKey: 'HUB' + 'P'.repeat(39) + '=', + hubAddress: '10.53.0.1/16', + listenPort: 51820, + }; + }, + }; +} + +function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +function createApp(opts = {}) { + const endpoint = 'endpoint' in opts ? opts.endpoint : 'hub.example.com:51820'; + const provider = opts.provider || makeProvider(); + const logCalls = []; + const logger = opts.log || { + info: (...a) => logCalls.push(['info', a]), + warn: (...a) => logCalls.push(['warn', a]), + error: (...a) => logCalls.push(['error', a]), + }; + const router = redeemRoutes({ + provider, + endpoint, + asyncHandler, + log: logger, + }); + const app = express(); + app.use(express.json()); + app.use('/network/wg/redeem', router); + app.use((err, _req, res, _next) => { + const status = err.statusCode || 500; + res.status(status).json({ success: false, error: err.message, code: err.code || null }); + }); + return { app, logCalls }; +} + +afterEach(() => { jest.restoreAllMocks(); }); + +describe('DC-150 WireGuard invite redemption routes', () => { + describe('GET /network/wg/redeem/:token (redemption page)', () => { + it('serves HTML containing the browser-keygen script with X25519 + pkcs8 references', async () => { + const { app } = createApp(); + const res = await request(app).get('/network/wg/redeem/' + TOKEN); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.text).toContain('crypto.subtle.generateKey'); + expect(res.text).toContain('X25519'); + expect(res.text).toContain('pkcs8'); + expect(res.text).toContain('exportKey'); + // fixed PKCS#8 prefix bytes (30 2e 02 01 00 30 05 06 03 2b 65 6e 04 22 04 20) + expect(res.text).toContain('0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x6e,0x04,0x22,0x04,0x20'); + // private key warning + client-side QR + expect(res.text).toMatch(/save this config now/i); + expect(res.text).toContain('QRCode'); + }); + + it('serves the same page for any token (validation happens at POST)', async () => { + const { app } = createApp(); + const res = await request(app).get('/network/wg/redeem/anything-at-all'); + expect(res.status).toBe(200); + expect(res.text).toContain('X25519'); + }); + }); + + describe('POST /network/wg/redeem/:token (happy path)', () => { + it('adds a peer and returns the client config with NO private key anywhere', async () => { + const provider = makeProvider(); + const { app, logCalls } = createApp({ provider }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'sam-laptop', publicKey: PUB_KEY }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.deviceName).toBe('sam-laptop'); + expect(res.body.config).toEqual({ + address: '10.53.0.2/32', + dns: '10.53.0.1', + hubPublicKey: 'HUB' + 'P'.repeat(39) + '=', + allowedIps: '10.53.0.0/16', + endpoint: 'hub.example.com:51820', + }); + // NO privateKey field at any depth; no /private/i anywhere in the body. + expect(JSON.stringify(res.body)).not.toMatch(/private/i); + // provider got ONLY the public key + expect(provider.calls.addPeer).toHaveLength(1); + const addArgs = provider.calls.addPeer[0]; + expect(addArgs.publicKey).toBe(PUB_KEY); + expect(Object.keys(addArgs)).not.toContain('privateKey'); + expect(addArgs.personId).toBeTruthy(); + expect(addArgs.deviceId).toMatch(/^wgd_[0-9a-f]{16}$/); + expect(addArgs.name).toBe('sam-laptop'); + // log mock NEVER receives the token value + const flat = JSON.stringify(logCalls); + expect(flat).not.toContain(TOKEN); + }); + + it('falls back to request host + listenPort when no endpoint injected', async () => { + const { app } = createApp({ endpoint: undefined }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .set('Host', 'box.internal:8443') + .send({ deviceName: 'phone', publicKey: PUB_KEY }); + expect(res.status).toBe(200); + expect(res.body.config.endpoint).toBe('box.internal:51820'); + }); + + it('defaults dns and allowedIps per spec when provider omits them', async () => { + const provider = { + // eslint-disable-next-line require-await + redeemInvite: async () => ({ role: 'member', email: null }), + // eslint-disable-next-line require-await + addPeer: async () => ({ address: '10.53.0.9/32', hubPublicKey: 'H', listenPort: 51821 }), + }; + const { app } = createApp({ provider }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'x', publicKey: PUB_KEY }); + expect(res.body.config.dns).toBe('10.53.0.1'); + expect(res.body.config.allowedIps).toBe('10.53.0.0/16'); + }); + }); + + describe('token lifecycle (410 Gone)', () => { + it('unknown / expired token → 410 with a clear message', async () => { + const { app, logCalls } = createApp({ provider: makeProvider({ failRedeem: true }) }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'sam-laptop', publicKey: PUB_KEY }); + expect(res.status).toBe(410); + expect(res.body.error).toMatch(/expired|already used/i); + // never addPeer on a dead token + expect(JSON.stringify(logCalls)).not.toContain(TOKEN); + }); + + it('single-use enforced: second POST with the same token → 410', async () => { + const provider = makeProvider(); + const { app } = createApp({ provider }); + const key2 = crypto.randomBytes(32).toString('base64'); + const first = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'dev1', publicKey: PUB_KEY }); + expect(first.status).toBe(200); + const second = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'dev2', publicKey: key2 }); + expect(second.status).toBe(410); + expect(provider.calls.addPeer).toHaveLength(1); // dev2 never registered + }); + }); + + describe('publicKey validation (400)', () => { + const badKeys = [ + ['', 'missing'], + [Buffer.alloc(31).toString('base64'), 'wrong decoded length (31 bytes)'], + [Buffer.alloc(33).toString('base64'), 'wrong decoded length (33 bytes)'], + ['A'.repeat(43) + '!', 'bad charset (punctuation)'], + ['A'.repeat(43) + '_', 'bad charset (urlsafe char)'], + [Buffer.alloc(32).toString('hex'), 'hex, not base64'], + [12345, 'not a string'], + ]; + for (const [key, label] of badKeys) { + it('rejects ' + label + ' → 400', async () => { + const provider = makeProvider(); + const { app } = createApp({ provider }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'sam-laptop', publicKey: key }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/publicKey/i); + // bad input must NOT burn the one-time token + expect(provider.calls.redeemInvite).toHaveLength(0); + }); + } + + it('accepts a real 44-char base64 key decoding to 32 bytes', async () => { + const realKey = crypto.randomBytes(32).toString('base64'); // 44 chars incl '=' + expect(realKey).toHaveLength(44); + const { app } = createApp({}); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'ok', publicKey: realKey }); + expect(res.status).toBe(200); + }); + }); + + describe('deviceName validation (400)', () => { + const badNames = [ + [undefined, 'missing'], + [null, 'null'], + ['', 'empty'], + [' ', 'whitespace-only'], + ['x'.repeat(101), 'over 100 chars'], + [42, 'not a string'], + ]; + for (const [name, label] of badNames) { + it('rejects ' + label + ' → 400', async () => { + const provider = makeProvider(); + const { app } = createApp({ provider }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: name, publicKey: PUB_KEY }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/deviceName/i); + expect(provider.calls.redeemInvite).toHaveLength(0); + }); + } + + it('trims a valid name before storing', async () => { + const provider = makeProvider(); + const { app } = createApp({ provider }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: ' pad-book ', publicKey: PUB_KEY }); + expect(res.status).toBe(200); + expect(res.body.deviceName).toBe('pad-book'); + expect(provider.calls.addPeer[0].name).toBe('pad-book'); + }); + }); + + describe('provider failure mapping', () => { + it('addPeer bad_input → 400 (token already burned — retry impossible, surface clearly)', async () => { + const { app } = createApp({ provider: makeProvider({ failAddPeerCode: 'bad_input' }) }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'dup', publicKey: PUB_KEY }); + expect(res.status).toBe(400); + }); + + it('addPeer infrastructure failure → 500 via error handler (no config leak)', async () => { + const { app } = createApp({ provider: makeProvider({ failAddPeerCode: 'provider_error' }) }); + const res = await request(app) + .post('/network/wg/redeem/' + TOKEN) + .send({ deviceName: 'dup', publicKey: PUB_KEY }); + expect(res.status).toBe(500); + expect(JSON.stringify(res.body)).not.toMatch(/private/i); + }); + }); + + describe('module surface', () => { + it('exports the public mount prefix constant for middleware registration', () => { + expect(redeemRoutes.PUBLIC_REDEEM_PATH_PREFIX).toBe('/network/wg/redeem/'); + }); + + it('derivePersonId prefers invite personId, then email, then token hash', () => { + expect(redeemRoutes.derivePersonId({ personId: 'p1' }, 't')).toBe('p1'); + expect(redeemRoutes.derivePersonId({ email: 'a@b.c' }, 't')).toBe('wge:a@b.c'); + expect(redeemRoutes.derivePersonId({ role: 'member' }, 'tok')).toMatch(/^wgi:[0-9a-f]{24}$/); + }); + + it('factory throws without a provider (redeemInvite/addPeer required)', () => { + expect(() => redeemRoutes({})).toThrow(/provider/); + expect(() => redeemRoutes({ provider: { redeemInvite: async () => {} } })).toThrow(/provider/); + }); + }); +}); diff --git a/dashcaddy-api/routes/network-wg-redeem.js b/dashcaddy-api/routes/network-wg-redeem.js new file mode 100644 index 0000000..e85d1cd --- /dev/null +++ b/dashcaddy-api/routes/network-wg-redeem.js @@ -0,0 +1,370 @@ +/** + * 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;