openvibers/openvibe.connect Commits

A11: relay authenticated Link browsing sessions (#76)

OP OpenVibers committed 4 days ago · 5d46ece9d9 · 8 files, +182 −3

* Carry authenticated Link browsing through Connect shares

* Exercise authenticated Link egress through the Connect relay

Changes

ddiff --git a/migrations/0017_link_egress.sql b/migrations/0017_link_egress.sql
nnew file mode 100644
iindex 0000000..ef6dbfc
---- /dev/null
++++ b/migrations/0017_link_egress.sql
@@@ -0,0 +1,3 @@
++-- phase: expand
++ALTER TABLE shares ADD COLUMN link_egress boolean NOT NULL DEFAULT false;
++CREATE UNIQUE INDEX shares_link_egress_node ON shares(node_id) WHERE link_egress AND status = 'active';
ddiff --git a/server/app.js b/server/app.js
iindex ac5dacb..73d62d2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -102,7 +102,7 @@ async function createApp(opts = {}) {
// API keys for a share (CON-16 v0): a program passes the gate with one; it opens the share's API, never its pages.
const apiKeys = createApiKeys(s, { now: s.now });
const oauth = createOAuth(s, { shares, scheme: config.shares.scheme, now: s.now });
-- const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, oauth, log, now: s.now });
++ const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, oauth, log, now: s.now, fetchImpl });
const accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex 3af936d..be3a746 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -40,7 +40,7 @@ function privateCache(value) {
const AUDIT_MAX = 64 * 1024;
const PRM = '/.well-known/oauth-protected-resource';
--function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now() }) {
++function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now(), fetchImpl = globalThis.fetch }) {
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
const inspector = require('./inspector').createInspector({ now });
const domain = config.shares.domains || [config.shares.domain];
@@@ -204,6 +204,18 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
/** The share, the person and the tunnel for a request, or an answer already sent. */
async function admit(req, res, share, parsedPath) {
++ if (share.link_egress) {
++ if (!await require('../link-egress').createEgressAuth({ config, fetchImpl }).open(share, req)) {
++ sendProblem(req, res, 403, 'egress.refused', 'Connection refused', 'This session cannot use that computer.');
++ return null;
++ }
++ const tunnel = tunnels.get(share.id);
++ if (!tunnel || tunnel.busy) {
++ sendProblem(req, res, 503, 'egress.offline', 'Computer unavailable', 'Your computer is offline or busy.');
++ return null;
++ }
++ return { who: { subject: share.owner_subject, username: '' }, tunnel };
++ }
// An API key (CON-16) passes without signing in; a wrong or revoked one is refused, never sent to sign in.
const bearer = /^Bearer\s+(ovsk_\S+)$/i.exec(String(req.headers.authorization || ''));
let who = null;
ddiff --git a/server/http/node-api.js b/server/http/node-api.js
iindex 40597bf..2a8ff9b 100644
---- a/server/http/node-api.js
++++ b/server/http/node-api.js
@@@ -37,6 +37,14 @@ function createNodeApi(ctx) {
r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));
++ r.post('/egress-share', async (req, res) => {
++ const identity = await require('../link-egress').createEgressAuth({ config }).identity(req.body?.network_token);
++ if (!identity || identity.node_id !== req.node) return fail(res, req, 403, 'egress.owner', 'Network did not confirm this computer');
++ const share = await shares.createEgress({ node: req.node, owner: identity.owner, name: identity.name, port: req.body.port });
++ proxy.forget(share.host);
++ return res.json({ share: brief(share) });
++ });
++
r.post('/party-shares', async (req, res) => {
const claim = verify(req.body && req.body.grant, config.partySharedSecret);
if (!claim || claim.type !== 'create' || claim.node !== req.node)
ddiff --git a/server/link-egress.js b/server/link-egress.js
nnew file mode 100644
iindex 0000000..8747641
---- /dev/null
++++ b/server/link-egress.js
@@@ -0,0 +1,24 @@
++'use strict';
++
++// This route carries ONLY authenticated session traffic. A share cookie, API key or owner sign-in cannot open it.
++function createEgressAuth({ config, fetchImpl = globalThis.fetch }) {
++ async function network(path, body, token) {
++ try {
++ const r = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: body ? 'POST' : 'GET',
++ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
++ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000), redirect: 'error' });
++ return r.ok ? await r.json() : null;
++ } catch { return null; }
++ }
++ return {
++ identity: token => network('/api/v1/node/self/egress/identity', null, token),
++ async open(share, req) {
++ if (req.url !== '/__ov_egress' || req.method !== 'GET' || String(req.headers.upgrade).toLowerCase() !== 'websocket') return false;
++ const session = req.headers['x-ov-session'], credential = req.headers['x-ov-egress'];
++ if (typeof session !== 'string' || typeof credential !== 'string' || credential.length !== 43) return false;
++ const result = await network('/api/v1/link-egress/open', { session_id: session, credential, node_id: share.node?.id });
++ return !!result && result.node_id === share.node?.id && result.session_id === session;
++ },
++ };
++}
++module.exports = { createEgressAuth };
ddiff --git a/server/shares.js b/server/shares.js
iindex 7fae4aa..5c52808 100644
---- a/server/shares.js
++++ b/server/shares.js
@@@ -154,6 +154,7 @@ function createShares(s, { config }) {
local_port: Number(row.local_port),
mode: row.mode,
party_slug: row.party_slug || null,
++ ...(row.link_egress ? { link_egress: true } : {}),
named: Array.isArray(row.named) ? row.named : [],
status: row.status,
policy_version: Number(row.policy_version),
@@@ -304,6 +305,24 @@ function createShares(s, { config }) {
return rows.map(view);
}
++ async function createEgress({ node, owner, name, port }) {
++ if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(node) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner) ||
++ !Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'egress.invalid', 'Invalid computer');
++ const old = await db.maybe("SELECT * FROM shares WHERE node_id = $1 AND link_egress AND status = 'active'", [node]);
++ if (old) {
++ if (old.owner_subject !== owner) throw new ShareError(403, 'egress.owner_mismatch', 'Computer owner changed');
++ await db.exec('UPDATE shares SET local_port = $2, updated_at = now() WHERE id = $1', [old.id, port]);
++ return get(old.id);
++ }
++ const id = s.newId('shr');
++ const handle = node.slice(4).toLowerCase();
++ const hash = sha256(crypto.randomBytes(32).toString('hex'));
++ await db.exec(`INSERT INTO shares(id, owner_subject, owner_username, label, host, local_port, mode,
++ key_prefix, key_hash, node_id, node_name, link_egress) VALUES ($1,$2,$3,'egress',$4,$5,'owner','',$6,$7,$8,true)`,
++ [id, owner, handle, hostFor('egress', handle, domain), port, hash, node, String(name || 'My computer').slice(0, 100)]);
++ return get(id);
++ }
++
async function createParty({ party, owner, username, node, port }) {
if (!/^party-[a-f0-9]{8}$/.test(String(party || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ||
!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(node || ''))) throw new ShareError(422, 'party.invalid', 'Invalid party or machine');
@@@ -448,7 +467,7 @@ function createShares(s, { config }) {
return false;
}
-- return { create, createParty, partyMembers, closeParty, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };
++ return { create, createEgress, createParty, partyMembers, closeParty, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };
}
function iso(v) {
ddiff --git a/test/link-egress-relay.test.js b/test/link-egress-relay.test.js
nnew file mode 100644
iindex 0000000..ecc899f
---- /dev/null
++++ b/test/link-egress-relay.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++const assert = require('assert/strict');
++const http = require('http');
++const net = require('net');
++const crypto = require('crypto');
++const WebSocket = require('ws');
++const { once } = require('events');
++
++(async () => {
++ const fetchOriginal = globalThis.fetch;
++ const credential = crypto.randomBytes(32).toString('base64url');
++ let identity, checks = 0, allowed = true;
++ globalThis.fetch = async (url, options = {}) => {
++ if (String(url).endsWith('/api/v1/node/self/egress/identity')) return new Response(JSON.stringify(identity));
++ if (String(url).endsWith('/api/v1/link-egress/open')) {
++ checks++;
++ const b = JSON.parse(options.body);
++ return allowed && b.credential === credential && b.node_id === identity.node_id
++ ? new Response(JSON.stringify({ node_id: b.node_id, session_id: b.session_id })) : new Response(null, { status: 403 });
++ }
++ return fetchOriginal(url, options);
++ };
++ const { boot } = require('./helpers/boot');
++ const t = await boot({ env: { CONNECT_SHARE_DOMAIN: 'openvibe.link', CONNECT_SHARE_DOMAINS: 'openvibe.link' } });
++ const endpoint = http.createServer();
++ const streams = new WebSocket.Server({ noServer: true });
++ endpoint.on('upgrade', (req, socket, head) => {
++ if (req.headers['x-ov-egress'] !== credential || req.headers['x-ov-session'] !== 'ses_test') { socket.destroy(); return; }
++ streams.handleUpgrade(req, socket, head, ws => ws.on('message', data => ws.send(data)));
++ });
++ await new Promise(resolve => endpoint.listen(0, '127.0.0.1', resolve));
++ let tunnel;
++ const sockets = new Map();
++ try {
++ const owner = t.network.addUser('egressowner');
++ const node = t.network.addNode(owner, { name: 'Home PC' });
++ identity = { node_id: node.id, owner: owner.subject, name: 'Home PC' };
++ const made = await t.get('/api/v1/node/egress-share', { method: 'POST', bearer: t.network.nodeToken(node),
++ json: { network_token: 'test-network-token', port: endpoint.address().port } });
++ assert.equal(made.status, 200, 'Node creates its dedicated share after Network confirms ownership');
++ const share = made.json().share;
++ const ticket = await t.get(`/api/v1/node/shares/${share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(node) });
++ tunnel = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);
++ const packet = (kind, id, data = Buffer.alloc(0)) => { const h = Buffer.alloc(5); h[0] = kind; h.writeUInt32BE(id, 1); return Buffer.concat([h, data]); };
++ const ready = new Promise((resolve, reject) => {
++ const timer = setTimeout(() => reject(Error('relay did not connect')), 5000);
++ tunnel.on('message', (data, binary) => {
++ if (binary) {
++ const socket = sockets.get(data.readUInt32BE(1));
++ if (socket) { if (data[0] === 1) socket.write(data.subarray(5)); else socket.end(); }
++ return;
++ }
++ const frame = JSON.parse(data);
++ if (frame.t === 'ready') { clearTimeout(timer); resolve(); }
++ if (frame.t === 'raw') {
++ const socket = net.connect(endpoint.address().port, '127.0.0.1', () => socket.write(frame.head));
++ sockets.set(frame.id, socket);
++ socket.on('data', b => { if (tunnel.readyState === WebSocket.OPEN) tunnel.send(packet(1, frame.id, b)); });
++ socket.on('end', () => { if (tunnel.readyState === WebSocket.OPEN) tunnel.send(packet(2, frame.id)); });
++ socket.on('error', () => socket.destroy());
++ }
++ });
++ tunnel.on('error', reject);
++ });
++ await once(tunnel, 'open');
++ tunnel.send(JSON.stringify({ t: 'hello', v: 1, client: 'egress-test', share: share.id,
++ node_token: t.network.nodeToken(node), tunnel: ticket.json().tunnel_token, port: endpoint.address().port }));
++ await ready;
++ const connect = () => new WebSocket(`ws://127.0.0.1:${t.port}/__ov_egress`, { headers: { Host: share.host, 'X-OV-Session': 'ses_test', 'X-OV-Egress': credential } });
++ const visitor = connect(); await once(visitor, 'open');
++ const returned = once(visitor, 'message'); visitor.send(Buffer.from('through the existing relay'));
++ assert.equal((await returned)[0].toString(), 'through the existing relay'); visitor.terminate();
++ allowed = false;
++ const refused = connect();
++ const status = await new Promise((resolve, reject) => { refused.on('unexpected-response', (_, res) => { res.resume(); resolve(res.statusCode); refused.terminate(); }); refused.on('error', e => { if (!String(e.message).includes('closed before')) reject(e); }); });
++ assert.equal(status, 403, 'a later owner refusal stops the next tunnel');
++ assert.equal(checks, 2, 'each tunnel opens through Network');
++ assert.ok(!String(t.logs()).includes(credential), 'credentials do not enter logs');
++ process.stdout.write('Link egress relay: authenticated Node share, binary round-trip and fresh owner refusal passed\n');
++ } finally {
++ globalThis.fetch = fetchOriginal;
++ if (tunnel) tunnel.terminate();
++ for (const socket of sockets.values()) socket.destroy();
++ for (const ws of streams.clients) ws.terminate();
++ streams.close(); endpoint.close(); await t.close();
++ }
++})().catch(e => { process.stderr.write(`${e.stack}\n`); process.exitCode = 1; });
ddiff --git a/test/link-egress.test.js b/test/link-egress.test.js
nnew file mode 100644
iindex 0000000..ea8565d
---- /dev/null
++++ b/test/link-egress.test.js
@@@ -0,0 +1,26 @@
++'use strict';
++const assert = require('assert/strict');
++const { createEgressAuth } = require('../server/link-egress');
++(async () => {
++ const seen = [];
++ let allowed = true;
++ const gate = createEgressAuth({ config: { networkInternalUrl: 'http://network' }, fetchImpl: async (url, options) => {
++ seen.push({ url, options });
++ const b = JSON.parse(options.body);
++ return { ok: allowed, json: async () => ({ node_id: b.node_id, session_id: b.session_id }) };
++ } });
++ const share = { node: { id: 'nod_mine' } };
++ const req = { method: 'GET', url: '/__ov_egress', headers: { upgrade: 'websocket', 'x-ov-session': 'ses_mine', 'x-ov-egress': 'a'.repeat(43) } };
++ assert.equal(await gate.open(share, req), true);
++ allowed = false;
++ assert.equal(await gate.open(share, req), false, 'successful ownership checks are never cached');
++ assert.equal(seen.length, 2);
++ assert.equal(JSON.parse(seen[0].options.body).node_id, 'nod_mine');
++ assert.equal(await gate.open(share, { ...req, headers: { cookie: 'owner-session' } }), false, 'owner cookie is insufficient');
++ assert.equal(await gate.open(share, { ...req, url: '/' }), false);
++ assert.equal(await gate.open(share, { ...req, url: '/__ov_egress?credential=secret' }), false);
++ assert.equal(await gate.open(share, { ...req, method: 'CONNECT' }), false);
++ const offline = createEgressAuth({ config: { networkInternalUrl: 'http://network' }, fetchImpl: async () => { throw Error(); } });
++ assert.equal(await offline.open(share, req), false, 'Network unavailable fails closed');
++ console.log('Link egress gate: every-open authorization and fail-closed checks passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });