openvibers/openvibe.connect Commits

Add Connect party desktop shares and member entry (#74)

OP OpenVibers committed 5 days ago · 388f7b4317 · 9 files, +213 −12

* Add party desktop shares with signed member entry

* Merge origin/main into party desktop branch

Changes

ddiff --git a/.env.example b/.env.example
iindex 09c3f49..733f197 100644
---- a/.env.example
++++ b/.env.example
@@@ -51,3 +51,5 @@ CONNECT_PING_MS=25000
# Drill mode (restore drills): no Events subscriptions.
CONNECT_DRILL=
++# Party desktop grants from Chat. Set to the same random value as CHAT_PARTY_CONNECT_SECRET.
++#CONNECT_PARTY_SHARED_SECRET=
ddiff --git a/migrations/0016_party_desktop.sql b/migrations/0016_party_desktop.sql
nnew file mode 100644
iindex 0000000..f56289f
---- /dev/null
++++ b/migrations/0016_party_desktop.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- A party share remains a normal private share, with its party identity attached
++-- so its embed and cookie rules apply only to that share.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS party_slug text COLLATE "C";
++CREATE UNIQUE INDEX IF NOT EXISTS shares_party_slug ON shares (party_slug) WHERE party_slug IS NOT NULL AND status = 'active';
ddiff --git a/server/config.js b/server/config.js
iindex 7d3d216..1caa412 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -43,6 +43,7 @@ function load(env = process.env) {
audience: env.CONNECT_AUDIENCE || 'openvibe.website',
// Old binaries still request the Website audience during the transition.
nodeAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],
++ partySharedSecret: env.CONNECT_PARTY_SHARED_SECRET || '',
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'connect',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
ddiff --git a/server/edge/gate.js b/server/edge/gate.js
iindex cd38313..c81074a 100644
---- a/server/edge/gate.js
++++ b/server/edge/gate.js
@@@ -55,9 +55,10 @@ function createGate({ config, now = () => Date.now() }) {
return { subject: c.u, username: c.n };
}
-- function cookieHeader(value) {
++ function cookieHeader(value, { embedded = false } = {}) {
const maxAge = config.shares.sessionHours * 3600;
-- return `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${config.cookies.secure !== false ? '; Secure' : ''}`;
++ const thirdParty = embedded && config.cookies.secure !== false;
++ return `${cookieName}=${value}; Path=/; HttpOnly; SameSite=${thirdParty ? 'None' : 'Lax'}; Max-Age=${maxAge}${config.cookies.secure !== false ? '; Secure' : ''}${thirdParty ? '; Partitioned' : ''}`;
}
const clearCookieHeader = () => `${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${config.cookies.secure !== false ? '; Secure' : ''}`;
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex 7891129..3af936d 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -22,6 +22,7 @@ const { parseHost } = require('../shares');
const HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
const DROP_IN = /^(host|cookie|forwarded|x-forwarded-.*|x-real-ip|x-openvibe-.*|cf-.*|cdn-loop|true-client-ip)$/;
++const { verify: verifyPartyGrant } = require('../party-grants');
const SHARE_CACHE_MS = 5000;
/**
@@@ -129,7 +130,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
return out;
}
-- function responseHeaders(pairs) {
++ function responseHeaders(pairs, share = null) {
const out = {};
const cookies = [];
let cacheControl = null;
@@@ -140,7 +141,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
if (!/^[!#$%&'*+.^_`|~0-9a-z-]{1,100}$/.test(name) || /[\r\n\0]/.test(value)) continue;
if (HOP.has(name) || name === 'cloudflare-cdn-cache-control' || name === 'cdn-cache-control' || name === 'x-robots-tag') continue;
if (name === 'set-cookie') {
-- const c = scrubCookie(value);
++ const c = scrubCookie(value, !!(share && share.party_slug));
if (c) cookies.push(c);
continue;
}
@@@ -152,17 +153,23 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
out['cache-control'] = privateCache(cacheControl);
out['cloudflare-cdn-cache-control'] = 'no-store';
out['x-robots-tag'] = 'noindex, nofollow';
-- // A local app is not a first-party card. Keep its own CSP and add an independent framing policy.
-- out['content-security-policy'] = [].concat(out['content-security-policy'] || [], "frame-ancestors 'none'");
-- out['x-frame-options'] = 'DENY';
++ // The party client is framed by openvibe.party. Replace any upstream
++ // frame-ancestors directive; multiple CSP headers are all enforced.
++ const csp = [].concat(out['content-security-policy'] || []).map((value) =>
++ share && share.party_slug ? value.replace(/(?:^|;)\s*frame-ancestors\s+[^;]*/gi, '') : value).filter(Boolean);
++ out['content-security-policy'] = [...csp, share && share.party_slug ? 'frame-ancestors https://openvibe.party' : "frame-ancestors 'none'"];
++ if (share && share.party_slug) delete out['x-frame-options'];
++ else out['x-frame-options'] = 'DENY';
return out;
}
-- function scrubCookie(value) {
++ function scrubCookie(value, party = false) {
const parts = value.split(';');
const name = parts[0].split('=')[0].trim();
if (name === gate.cookieName || name === '__Host-ovc' || name === 'ovc') return null;
-- return parts.filter((p, i) => i === 0 || !/^\s*domain\s*=/i.test(p)).join(';');
++ if (!party) return parts.filter((p, i) => i === 0 || !/^\s*domain\s*=/i.test(p)).join(';');
++ const clean = parts.filter((p, i) => i === 0 || !/^\s*(domain|samesite|partitioned|secure)\s*(=|$)/i.test(p)).join(';');
++ return `${clean}; SameSite=None; Secure; Partitioned`;
}
// ── Pages the gate answers itself (self-contained: a share host loads nothing from anywhere else) ──
@@@ -284,6 +291,28 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
const url = String(req.url || '/');
const path = url.split('?')[0];
++ if (share.party_slug && path === '/__ovc/party') {
++ const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
++ 'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors https://openvibe.party",
++ 'referrer-policy': 'no-referrer', 'x-robots-tag': 'noindex, nofollow' };
++ if (req.method === 'GET') {
++ res.writeHead(200, headers);
++ return res.end('<!doctype html><meta charset="utf-8"><title>Opening the party computer</title><p>Opening the shared computer…</p><script>const ticket=location.hash.slice(1);history.replaceState(null,"","/__ovc/party");fetch("/__ovc/party",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({ticket}),credentials:"same-origin"}).then(r=>{if(!r.ok)throw Error();return r.json()}).then(v=>location.replace("/?embed=1&usr="+encodeURIComponent(v.username)+"&pwd=party")).catch(()=>{document.querySelector("p").textContent="This party invite has expired. Return to the party and try again."})</script>');
++ }
++ if (req.method !== 'POST' || String(req.headers.origin || '') !== `${config.shares.scheme}://${share.host}`)
++ return sendProblem(req, res, 403, 'party.origin', 'Not from this share', 'Open the party from openvibe.party.');
++ let body = '';
++ for await (const chunk of req) { body += chunk; if (body.length > 5000) return sendProblem(req, res, 413, 'party.ticket', 'Ticket too long', 'Return to the party and try again.'); }
++ let value;
++ try { value = JSON.parse(body).ticket; } catch { value = null; }
++ const claim = verifyPartyGrant(value, config.partySharedSecret);
++ if (!claim || claim.type !== 'access' || claim.share !== share.id || !shares.allows(share, { subject: claim.subject, username: claim.username }))
++ return sendProblem(req, res, 403, 'party.ticket', 'Invite expired', 'Return to the party and try again.');
++ const cookie = gate.sign({ shareId: share.id, subject: claim.subject, username: claim.username, policyVersion: share.policy_version, host: share.host });
++ res.writeHead(200, { 'set-cookie': gate.cookieHeader(cookie, { embedded: true }), 'content-type': 'application/json', 'cache-control': 'no-store' });
++ return res.end(JSON.stringify({ username: claim.username }));
++ }
++
if (path === '/__ovc/callback') {
const q = new URL(url, 'http://x').searchParams;
const v = gate.redeemCode(q.get('code'), share.id, share.host);
@@@ -347,7 +376,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
return sendProblem(req, res, 502, 'bad_response', 'The app answered something odd', 'The app on the other end sent a response OpenVibe Connect cannot pass on.');
}
rec.status = status;
-- try { res.writeHead(status, responseHeaders(m.headers)); } catch { tunnel.abort(id, 'bad_headers'); res.destroy(); }
++ try { res.writeHead(status, responseHeaders(m.headers, share)); } catch { tunnel.abort(id, 'bad_headers'); res.destroy(); }
return undefined;
},
onData(buf) {
ddiff --git a/server/http/node-api.js b/server/http/node-api.js
iindex 8a29bcc..40597bf 100644
---- a/server/http/node-api.js
++++ b/server/http/node-api.js
@@@ -11,12 +11,16 @@
* The tunnel token is in this answer only, and is never logged.
*/
const { http } = require('openvibe-contracts');
++const express = require('express');
const { asyncRouter } = require('./router');
++const { verify } = require('../party-grants');
++const { ShareError } = require('../shares');
function createNodeApi(ctx) {
-- const { config, shares, nodeAuth } = ctx;
++ const { config, shares, nodeAuth, tunnels, proxy } = ctx;
const host = new URL(config.baseUrl).host;
const r = asyncRouter();
++ r.use(express.json({ limit: '4kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
@@@ -33,6 +37,42 @@ function createNodeApi(ctx) {
r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));
++ 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)
++ return fail(res, req, 403, 'party.invalid_grant', 'Chat did not authorize this party for this machine');
++ try {
++ const share = await shares.createParty(claim);
++ return res.status(201).json({ share: brief(share) });
++ } catch (err) {
++ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
++ throw err;
++ }
++ });
++
++ r.put('/shares/:id/party-members', async (req, res) => {
++ try {
++ const share = await shares.partyMembers(await shares.get(req.params.id), req.node, req.body && req.body.members);
++ proxy.forget(share.host);
++ return res.json({ share: brief(share) });
++ } catch (err) {
++ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
++ throw err;
++ }
++ });
++
++ r.delete('/shares/:id/party', async (req, res) => {
++ try {
++ const share = await shares.closeParty(await shares.get(req.params.id), req.node);
++ tunnels.close(share.id, 4003, 'party ended');
++ proxy.forget(share.host);
++ return res.json({ share: brief(share) });
++ } catch (err) {
++ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
++ throw err;
++ }
++ });
++
r.post('/shares/:id/tunnel-token', async (req, res) => {
const share = await shares.get(req.params.id);
// Another person's share and a share this computer does not serve look the same: not found.
ddiff --git a/server/party-grants.js b/server/party-grants.js
nnew file mode 100644
iindex 0000000..80f4695
---- /dev/null
++++ b/server/party-grants.js
@@@ -0,0 +1,16 @@
++'use strict';
++const crypto = require('crypto');
++
++function verify(value, secret, now = Date.now()) {
++ if (!secret || secret.length < 32 || typeof value !== 'string' || value.length > 4096) return null;
++ const pieces = value.split('.');
++ if (pieces.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(pieces[0]) || !/^[A-Za-z0-9_-]+$/.test(pieces[1])) return null;
++ const expected = crypto.createHmac('sha256', secret).update(pieces[0]).digest('base64url');
++ if (expected.length !== pieces[1].length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(pieces[1]))) return null;
++ let claim;
++ try { claim = JSON.parse(Buffer.from(pieces[0], 'base64url').toString('utf8')); } catch { return null; }
++ if (!claim || !Number.isInteger(claim.exp) || claim.exp <= Math.floor(now / 1000) || claim.exp > Math.floor(now / 1000) + 120) return null;
++ return claim;
++}
++
++module.exports = { verify };
ddiff --git a/server/shares.js b/server/shares.js
iindex 686374b..7fae4aa 100644
---- a/server/shares.js
++++ b/server/shares.js
@@@ -153,6 +153,7 @@ function createShares(s, { config }) {
url: urlOf(row),
local_port: Number(row.local_port),
mode: row.mode,
++ party_slug: row.party_slug || null,
named: Array.isArray(row.named) ? row.named : [],
status: row.status,
policy_version: Number(row.policy_version),
@@@ -239,6 +240,7 @@ function createShares(s, { config }) {
async function setPolicy(owner, id, input = {}) {
const share = await owned(owner, id);
++ if (share.party_slug) throw new ShareError(403, 'share.party_managed', 'The party manages this share');
if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
const mode = input.mode == null ? share.mode : String(input.mode);
if (mode === 'public') throw new ShareError(422, 'share.public_not_yet', 'Public shares open once the abuse check is in.');
@@@ -268,6 +270,7 @@ function createShares(s, { config }) {
async function rotateKey(owner, id) {
const share = await owned(owner, id);
++ if (share.party_slug) throw new ShareError(403, 'share.party_managed', 'The party manages this share');
if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
const key = newKey();
// A new key means the key serves it: a computer bound as OpenVibe Link is let go (CON-4).
@@@ -283,6 +286,7 @@ function createShares(s, { config }) {
*/
async function bindNode(owner, id, machine) {
const share = await owned(owner, id);
++ if (share.party_slug) throw new ShareError(403, 'share.party_managed', 'The party manages this share');
if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
const key = newKey();
if (machine) {
@@@ -300,6 +304,44 @@ function createShares(s, { config }) {
return rows.map(view);
}
++ 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');
++ const existing = await db.maybe("SELECT * FROM shares WHERE party_slug = $1 AND status = 'active'", [party]);
++ if (existing) {
++ const current = view(existing);
++ if (current.owner_subject !== owner || !current.node || current.node.id !== node || current.local_port !== port)
++ throw new ShareError(409, 'party.in_use', 'This party is already hosted elsewhere');
++ return current;
++ }
++ const acting = { subject: owner, username, role: 'admin' };
++ const { share } = await create(acting, { label: party, port, mode: 'named', named: [username], expires_in: 86400, note: 'Shared party computer' });
++ try {
++ await bindNode(acting, share.id, { id: node, name: 'Party host' });
++ await db.exec('UPDATE shares SET party_slug = $2 WHERE id = $1', [share.id, party]);
++ return get(share.id);
++ } catch (err) {
++ await revoke(acting, share.id);
++ throw err;
++ }
++ }
++
++ async function partyMembers(share, node, members) {
++ if (!share || !share.party_slug || !share.node || share.node.id !== node || share.status !== 'active')
++ throw new ShareError(404, 'party.not_found', 'No such party share');
++ const named = normalizeNamed(members);
++ if (!named.includes(String(share.owner_username).toLowerCase())) named.push(String(share.owner_username).toLowerCase());
++ if (JSON.stringify(named) !== JSON.stringify(share.named)) {
++ await db.exec('UPDATE shares SET named = $2::jsonb, policy_version = policy_version + 1, updated_at = now() WHERE id = $1', [share.id, JSON.stringify(named)]);
++ }
++ return get(share.id);
++ }
++
++ async function closeParty(share, node) {
++ if (!share || !share.party_slug || !share.node || share.node.id !== node) throw new ShareError(404, 'party.not_found', 'No such party share');
++ return revoke({ subject: share.owner_subject }, share.id);
++ }
++
async function revoke(owner, id) {
const share = await owned(owner, id);
if (share.status === 'revoked') return share;
@@@ -406,7 +448,7 @@ function createShares(s, { config }) {
return false;
}
-- return { create, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };
++ 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 };
}
function iso(v) {
ddiff --git a/test/party-share.test.js b/test/party-share.test.js
nnew file mode 100644
iindex 0000000..5c3fb74
---- /dev/null
++++ b/test/party-share.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot } = require('./helpers/boot');
++const { check, done } = require('./helpers/check');
++
++const SECRET = 'party-test-shared-secret-01234567890123456789';
++const sign = (body) => {
++ const payload = Buffer.from(JSON.stringify({ ...body, exp: Math.floor(Date.now() / 1000) + 60 })).toString('base64url');
++ return `${payload}.${crypto.createHmac('sha256', SECRET).update(payload).digest('base64url')}`;
++};
++
++(async () => {
++ const t = await boot({ env: { CONNECT_PARTY_SHARED_SECRET: SECRET, CONNECT_SHARE_DOMAIN: 'openvibe.link', CONNECT_SHARE_DOMAINS: 'openvibe.link', COOKIE_SECURE: 'true' } });
++ const alex = t.network.addUser('alex');
++ const bob = t.network.addUser('bob');
++ const desk = t.network.addNode(alex, { name: 'Desk PC' });
++ const slug = 'party-0123abcd';
++ let share;
++
++ await check('only Chat-granted Link machine creates the named party share', async () => {
++ const claim = sign({ type: 'create', party: slug, owner: alex.subject, username: 'alex', node: desk.id, port: 18180 });
++ const noToken = await t.get('/api/v1/node/party-shares', { method: 'POST', json: { grant: claim } });
++ assert.strictEqual(noToken.status, 401);
++ const bad = await t.get('/api/v1/node/party-shares', { method: 'POST', bearer: t.network.nodeToken(desk), json: { grant: claim + 'x' } });
++ assert.strictEqual(bad.status, 403);
++ const made = await t.get('/api/v1/node/party-shares', { method: 'POST', bearer: t.network.nodeToken(desk), json: { grant: claim } });
++ assert.strictEqual(made.status, 201, made.text);
++ share = made.json().share;
++ assert.strictEqual(share.host, `${slug}--alex.openvibe.link`);
++ const stored = await t.ctx.shares.get(share.id);
++ assert.strictEqual(stored.mode, 'named');
++ assert.strictEqual(stored.party_slug, slug);
++ assert.strictEqual(stored.node.id, desk.id);
++ assert.strictEqual((await t.get(`/api/v1/shares/${share.id}`, { as: alex })).json().share.key_prefix, null);
++ });
++
++ await check('a member ticket enters the embedded share; an outsider cannot', async () => {
++ const landing = await t.share(share.host, '/__ovc/party');
++ assert.strictEqual(landing.status, 200, landing.text);
++ assert.match(landing.text, /location\.hash/);
++ assert.match(landing.headers['content-security-policy'], /frame-ancestors https:\/\/openvibe\.party/);
++ const asBob = sign({ type: 'access', share: share.id, subject: bob.subject, username: 'bob' });
++ const opts = { method: 'POST', headers: { origin: `https://${share.host}`, 'content-type': 'application/json' }, body: JSON.stringify({ ticket: asBob }) };
++ assert.strictEqual((await t.share(share.host, '/__ovc/party', opts)).status, 403);
++ const synced = await t.get(`/api/v1/node/shares/${share.id}/party-members`, { method: 'PUT', bearer: t.network.nodeToken(desk), json: { members: ['alex', 'bob'] } });
++ assert.strictEqual(synced.status, 200, synced.text);
++ const entered = await t.share(share.host, '/__ovc/party', opts);
++ assert.strictEqual(entered.status, 200, entered.text);
++ assert.strictEqual(entered.json().username, 'bob');
++ assert.match(String(entered.headers['set-cookie']), /SameSite=None.*Partitioned/);
++ const stored = await t.ctx.shares.get(share.id);
++ assert.ok(t.ctx.gate.verify(String(entered.headers['set-cookie']).split(';')[0].split('=')[1], stored));
++ });
++
++ await check('the party machine closes its share', async () => {
++ const closed = await t.get(`/api/v1/node/shares/${share.id}/party`, { method: 'DELETE', bearer: t.network.nodeToken(desk) });
++ assert.strictEqual(closed.status, 200, closed.text);
++ assert.strictEqual((await t.ctx.shares.get(share.id)).status, 'revoked');
++ assert.strictEqual((await t.share(share.host, '/__ovc/party')).status, 410);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });