P7 Private Network (DC-140..DC-154 + judge fixes) #1
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 '<!DOCTYPE html>\n'
|
||||
+ '<html lang="en">\n'
|
||||
+ '<head>\n'
|
||||
+ '<meta charset="utf-8">\n'
|
||||
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
+ '<meta name="robots" content="noindex, nofollow">\n'
|
||||
+ '<title>DashCaddy Private Network — Device Setup</title>\n'
|
||||
+ '<style>\n'
|
||||
+ ' :root { color-scheme: dark; }\n'
|
||||
+ ' body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; display: flex; min-height: 100vh; align-items: center; justify-content: center; }\n'
|
||||
+ ' .card { background: #1e293b; border-radius: 12px; padding: 2rem; max-width: 640px; width: 92%; box-shadow: 0 10px 40px rgba(0,0,0,.4); }\n'
|
||||
+ ' h1 { font-size: 1.25rem; margin: 0 0 .5rem; }\n'
|
||||
+ ' p.sub { color: #94a3b8; margin-top: 0; }\n'
|
||||
+ ' label { display: block; margin: 1rem 0 .35rem; font-size: .9rem; }\n'
|
||||
+ ' input[type=text] { width: 100%; box-sizing: border-box; padding: .6rem .75rem; border-radius: 8px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: 1rem; }\n'
|
||||
+ ' button { margin-top: 1.25rem; width: 100%; padding: .7rem; border: 0; border-radius: 8px; background: #2563eb; color: #fff; font-size: 1rem; cursor: pointer; }\n'
|
||||
+ ' button:disabled { opacity: .5; cursor: wait; }\n'
|
||||
+ ' .warn { background: #451a03; border: 1px solid #b45309; color: #fbbf24; padding: .75rem; border-radius: 8px; font-size: .85rem; margin: 1rem 0; }\n'
|
||||
+ ' .err { background: #450a0a; border: 1px solid #b91c1c; color: #fca5a5; padding: .75rem; border-radius: 8px; font-size: .85rem; margin: 1rem 0; }\n'
|
||||
+ ' pre { background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 1rem; overflow-x: auto; font-size: .8rem; white-space: pre-wrap; word-break: break-all; }\n'
|
||||
+ ' canvas { background: #fff; border-radius: 8px; margin: 1rem auto; display: block; max-width: 260px; }\n'
|
||||
+ ' a.dl { display: inline-block; margin-top: .75rem; color: #60a5fa; }\n'
|
||||
+ ' .hidden { display: none; }\n'
|
||||
+ '</style>\n'
|
||||
+ '</head>\n'
|
||||
+ '<body>\n'
|
||||
+ '<div class="card">\n'
|
||||
+ ' <h1>Join the DashCaddy Private Network</h1>\n'
|
||||
+ ' <p class="sub">Your device keys are generated <strong>in this browser</strong> and never leave it — the server only receives your public key.</p>\n'
|
||||
+ ' <div id="form-area">\n'
|
||||
+ ' <label for="deviceName">Device name</label>\n'
|
||||
+ ' <input type="text" id="deviceName" maxlength="100" placeholder="e.g. sam-laptop" autocomplete="off">\n'
|
||||
+ ' <button id="go" type="button">Generate keys & join</button>\n'
|
||||
+ ' </div>\n'
|
||||
+ ' <div id="err" class="err hidden"></div>\n'
|
||||
+ ' <div id="result" class="hidden">\n'
|
||||
+ ' <div class="warn"><strong>Save this config now.</strong> The private key exists only in this page session and is shown exactly once — it is never sent to the server and cannot be recovered later.</div>\n'
|
||||
+ ' <pre id="config"></pre>\n'
|
||||
+ ' <canvas id="qr" width="260" height="260" class="hidden"></canvas>\n'
|
||||
+ ' <p id="qr-fallback" class="sub hidden">QR unavailable (offline / CDN blocked) — use the copyable config above or the download link below.</p>\n'
|
||||
+ ' <a id="dl" class="dl" download="dashcaddy-wg.conf">Download config file</a>\n'
|
||||
+ ' </div>\n'
|
||||
+ '</div>\n'
|
||||
+ '<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js" onerror="window.__qrCdnFailed=true"></script>\n'
|
||||
+ '<script>\n'
|
||||
+ '(function () {\n'
|
||||
+ ' "use strict";\n'
|
||||
+ ' // Fixed PKCS#8 DER prefix for an X25519 private key (16 bytes):\n'
|
||||
+ ' // SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.110 }, OCTET STRING (32) }\n'
|
||||
+ ' var PKCS8_X25519_PREFIX = [0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x6e,0x04,0x22,0x04,0x20];\n'
|
||||
+ ' function toB64(buf) {\n'
|
||||
+ ' var s = String.fromCharCode.apply(null, new Uint8Array(buf));\n'
|
||||
+ ' return btoa(s);\n'
|
||||
+ ' }\n'
|
||||
+ ' function fail(msg) {\n'
|
||||
+ ' var e = document.getElementById("err");\n'
|
||||
+ ' e.textContent = msg;\n'
|
||||
+ ' e.classList.remove("hidden");\n'
|
||||
+ ' document.getElementById("go").disabled = false;\n'
|
||||
+ ' }\n'
|
||||
+ ' function tokenFromPath() {\n'
|
||||
+ ' var m = location.pathname.match(/\\/network\\/wg\\/redeem\\/([^/?#]+)/);\n'
|
||||
+ ' return m ? decodeURIComponent(m[1]) : null;\n'
|
||||
+ ' }\n'
|
||||
+ ' async function generateKeypair() {\n'
|
||||
+ ' if (!window.crypto || !window.crypto.subtle || !window.crypto.subtle.generateKey) {\n'
|
||||
+ ' throw new Error("This browser has no WebCrypto (crypto.subtle) — use a modern browser over HTTPS.");\n'
|
||||
+ ' }\n'
|
||||
+ ' var kp;\n'
|
||||
+ ' try {\n'
|
||||
+ ' kp = await window.crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);\n'
|
||||
+ ' } catch (e) {\n'
|
||||
+ ' throw new Error("This browser cannot generate X25519 keys (required for WireGuard). Use an up-to-date browser.");\n'
|
||||
+ ' }\n'
|
||||
+ ' var rawPub = await window.crypto.subtle.exportKey("raw", kp.publicKey);\n'
|
||||
+ ' var pkcs8 = new Uint8Array(await window.crypto.subtle.exportKey("pkcs8", kp.privateKey));\n'
|
||||
+ ' // HARD FAIL on prefix mismatch: never slice a key of unknown layout.\n'
|
||||
+ ' if (pkcs8.length !== 48) { throw new Error("Unexpected pkcs8 length (" + pkcs8.length + ") — refusing to derive private key."); }\n'
|
||||
+ ' for (var i = 0; i < PKCS8_X25519_PREFIX.length; i++) {\n'
|
||||
+ ' if (pkcs8[i] !== PKCS8_X25519_PREFIX[i]) { throw new Error("pkcs8 prefix mismatch at byte " + i + " — refusing to derive private key."); }\n'
|
||||
+ ' }\n'
|
||||
+ ' var rawPriv = pkcs8.slice(PKCS8_X25519_PREFIX.length); // raw 32 bytes\n'
|
||||
+ ' return { publicKey: toB64(rawPub), privateKey: toB64(rawPriv) };\n'
|
||||
+ ' }\n'
|
||||
+ ' function renderConfig(cfg, deviceName, privB64) {\n'
|
||||
+ ' var lines = [\n'
|
||||
+ ' "[Interface]",\n'
|
||||
+ ' "PrivateKey = " + privB64,\n'
|
||||
+ ' "Address = " + cfg.address,\n'
|
||||
+ ' "DNS = " + cfg.dns,\n'
|
||||
+ ' "",\n'
|
||||
+ ' "[Peer]",\n'
|
||||
+ ' "PublicKey = " + cfg.hubPublicKey,\n'
|
||||
+ ' "AllowedIPs = " + cfg.allowedIps,\n'
|
||||
+ ' "Endpoint = " + cfg.endpoint,\n'
|
||||
+ ' "PersistentKeepalive = 25",\n'
|
||||
+ ' ""\n'
|
||||
+ ' ];\n'
|
||||
+ ' var text = lines.join("\\n");\n'
|
||||
+ ' var pre = document.getElementById("config");\n'
|
||||
+ ' pre.textContent = text;\n'
|
||||
+ ' var blob = new Blob([text], { type: "text/plain" });\n'
|
||||
+ ' var dl = document.getElementById("dl");\n'
|
||||
+ ' dl.href = URL.createObjectURL(blob);\n'
|
||||
+ ' dl.download = "dashcaddy-" + String(deviceName).replace(/[^A-Za-z0-9._-]+/g, "-") + ".conf";\n'
|
||||
+ ' document.getElementById("form-area").classList.add("hidden");\n'
|
||||
+ ' document.getElementById("result").classList.remove("hidden");\n'
|
||||
+ ' var canvas = document.getElementById("qr");\n'
|
||||
+ ' if (window.QRCode && typeof window.QRCode.toCanvas === "function") {\n'
|
||||
+ ' window.QRCode.toCanvas(canvas, text, { width: 260, margin: 1 }, function (err) {\n'
|
||||
+ ' if (err) { canvas.classList.add("hidden"); document.getElementById("qr-fallback").classList.remove("hidden"); }\n'
|
||||
+ ' else { canvas.classList.remove("hidden"); }\n'
|
||||
+ ' });\n'
|
||||
+ ' } else {\n'
|
||||
+ ' canvas.classList.add("hidden");\n'
|
||||
+ ' document.getElementById("qr-fallback").classList.remove("hidden");\n'
|
||||
+ ' }\n'
|
||||
+ ' }\n'
|
||||
+ ' document.getElementById("go").addEventListener("click", async function () {\n'
|
||||
+ ' var btn = this;\n'
|
||||
+ ' btn.disabled = true;\n'
|
||||
+ ' document.getElementById("err").classList.add("hidden");\n'
|
||||
+ ' var deviceName = document.getElementById("deviceName").value.trim();\n'
|
||||
+ ' if (!deviceName) { fail("Please enter a device name."); return; }\n'
|
||||
+ ' var token = tokenFromPath();\n'
|
||||
+ ' if (!token) { fail("Invalid invite link (no token in URL)."); return; }\n'
|
||||
+ ' var keys;\n'
|
||||
+ ' try { keys = await generateKeypair(); } catch (e) { fail(e.message); return; }\n'
|
||||
+ ' try {\n'
|
||||
+ ' // ONLY the public key is sent — never the private key.\n'
|
||||
+ ' var res = await fetch(location.pathname, {\n'
|
||||
+ ' method: "POST",\n'
|
||||
+ ' headers: { "Content-Type": "application/json" },\n'
|
||||
+ ' body: JSON.stringify({ token: token, deviceName: deviceName, publicKey: keys.publicKey })\n'
|
||||
+ ' });\n'
|
||||
+ ' if (res.status === 410) { fail("This invite link has expired or was already used."); return; }\n'
|
||||
+ ' if (res.status === 400) { var b = await res.json(); fail(b.error || "Invalid request."); return; }\n'
|
||||
+ ' if (!res.ok) { fail("Redemption failed (" + res.status + "). " + (await res.text()).slice(0, 120)); return; }\n'
|
||||
+ ' var body = await res.json();\n'
|
||||
+ ' renderConfig(body.config, body.deviceName || deviceName, keys.privateKey);\n'
|
||||
+ ' } catch (e) {\n'
|
||||
+ ' fail("Network error: " + e.message);\n'
|
||||
+ ' }\n'
|
||||
+ ' });\n'
|
||||
+ '})();\n'
|
||||
+ '</script>\n'
|
||||
+ '</body>\n'
|
||||
+ '</html>\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;
|
||||
Reference in new issue
Block a user