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' + + 'Your device keys are generated in this browser and never leave it — the server only receives your public key.
\n' + + '