371 lines
18 KiB
JavaScript
371 lines
18 KiB
JavaScript
/**
|
|
* 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;
|