openvibers/openvibe.connect Commits
Add Connect party desktop shares and member entry (#74)
Changes
@@@ -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=nnew file mode 100644@@@ -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';@@@ -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 || '',@@@ -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' : ''}`;@@@ -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 = nureturn 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 = nuif (!/^[!#$%&'*+.^_`|~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 = nuout['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 = nuconst 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 = nureturn 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) {@@@ -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.nnew file mode 100644@@@ -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 };@@@ -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) {nnew file mode 100644@@@ -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); });
@@ -51,3 +51,5 @@ CONNECT_PING_MS=25000
@@ -51,3 +51,5 @@ CONNECT_PING_MS=25000
# Drill mode (restore drills): no Events subscriptions.
# Drill mode (restore drills): no Events subscriptions.
CONNECT_DRILL=
CONNECT_DRILL=
+# Party desktop grants from Chat. Set to the same random value as CHAT_PARTY_CONNECT_SECRET.
+#CONNECT_PARTY_SHARED_SECRET=
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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';
@@ -43,6 +43,7 @@ function load(env = process.env) {
@@ -43,6 +43,7 @@ function load(env = process.env) {
audience: env.CONNECT_AUDIENCE || 'openvibe.website',
audience: env.CONNECT_AUDIENCE || 'openvibe.website',
// Old binaries still request the Website audience during the transition.
// Old binaries still request the Website audience during the transition.
nodeAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],
nodeAudiences: [...new Set([env.CONNECT_AUDIENCE || 'openvibe.link', 'openvibe.link', 'openvibe.website'])],
+ partySharedSecret: env.CONNECT_PARTY_SHARED_SECRET || '',
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'connect',
clientId: env.OV_OAUTH_CLIENT_ID || 'connect',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
@@ -55,9 +55,10 @@ function createGate({ config, now = () => Date.now() }) {
@@ -55,9 +55,10 @@ function createGate({ config, now = () => Date.now() }) {
return { subject: c.u, username: c.n };
return { subject: c.u, username: c.n };
}
}
- function cookieHeader(value) {
+ function cookieHeader(value, { embedded = false } = {}) {
const maxAge = config.shares.sessionHours * 3600;
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' : ''}`;
const clearCookieHeader = () => `${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${config.cookies.secure !== false ? '; Secure' : ''}`;
@@ -22,6 +22,7 @@ const { parseHost } = require('../shares');
@@ -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 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 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;
const SHARE_CACHE_MS = 5000;
/**
/**
@@ -129,7 +130,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -129,7 +130,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
return out;
return out;
}
}
- function responseHeaders(pairs) {
+ function responseHeaders(pairs, share = null) {
const out = {};
const out = {};
const cookies = [];
const cookies = [];
let cacheControl = null;
let cacheControl = null;
@@ -140,7 +141,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -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 (!/^[!#$%&'*+.^_`|~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 (HOP.has(name) || name === 'cloudflare-cdn-cache-control' || name === 'cdn-cache-control' || name === 'x-robots-tag') continue;
if (name === 'set-cookie') {
if (name === 'set-cookie') {
- const c = scrubCookie(value);
+ const c = scrubCookie(value, !!(share && share.party_slug));
if (c) cookies.push(c);
if (c) cookies.push(c);
continue;
continue;
}
}
@@ -152,17 +153,23 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -152,17 +153,23 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
out['cache-control'] = privateCache(cacheControl);
out['cache-control'] = privateCache(cacheControl);
out['cloudflare-cdn-cache-control'] = 'no-store';
out['cloudflare-cdn-cache-control'] = 'no-store';
out['x-robots-tag'] = 'noindex, nofollow';
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;
return out;
}
}
- function scrubCookie(value) {
+ function scrubCookie(value, party = false) {
const parts = value.split(';');
const parts = value.split(';');
const name = parts[0].split('=')[0].trim();
const name = parts[0].split('=')[0].trim();
if (name === gate.cookieName || name === '__Host-ovc' || name === 'ovc') return null;
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) ──
// ── 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
@@ -284,6 +291,28 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
const url = String(req.url || '/');
const url = String(req.url || '/');
const path = url.split('?')[0];
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') {
if (path === '/__ovc/callback') {
const q = new URL(url, 'http://x').searchParams;
const q = new URL(url, 'http://x').searchParams;
const v = gate.redeemCode(q.get('code'), share.id, share.host);
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
@@ -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.');
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;
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;
return undefined;
},
},
onData(buf) {
onData(buf) {
@@ -11,12 +11,16 @@
@@ -11,12 +11,16 @@
* The tunnel token is in this answer only, and is never logged.
* The tunnel token is in this answer only, and is never logged.
*/
*/
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
+const express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
+const { verify } = require('../party-grants');
+const { ShareError } = require('../shares');
function createNodeApi(ctx) {
function createNodeApi(ctx) {
- const { config, shares, nodeAuth } = ctx;
+ const { config, shares, nodeAuth, tunnels, proxy } = ctx;
const host = new URL(config.baseUrl).host;
const host = new URL(config.baseUrl).host;
const r = asyncRouter();
const r = asyncRouter();
+ r.use(express.json({ limit: '4kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
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 });
const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
@@ -33,6 +37,42 @@ function createNodeApi(ctx) {
@@ -33,6 +37,42 @@ function createNodeApi(ctx) {
r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));
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) => {
r.post('/shares/:id/tunnel-token', async (req, res) => {
const share = await shares.get(req.params.id);
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.
// Another person's share and a share this computer does not serve look the same: not found.
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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 };
@@ -153,6 +153,7 @@ function createShares(s, { config }) {
@@ -153,6 +153,7 @@ function createShares(s, { config }) {
url: urlOf(row),
url: urlOf(row),
local_port: Number(row.local_port),
local_port: Number(row.local_port),
mode: row.mode,
mode: row.mode,
+ party_slug: row.party_slug || null,
named: Array.isArray(row.named) ? row.named : [],
named: Array.isArray(row.named) ? row.named : [],
status: row.status,
status: row.status,
policy_version: Number(row.policy_version),
policy_version: Number(row.policy_version),
@@ -239,6 +240,7 @@ function createShares(s, { config }) {
@@ -239,6 +240,7 @@ function createShares(s, { config }) {
async function setPolicy(owner, id, input = {}) {
async function setPolicy(owner, id, input = {}) {
const share = await owned(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.');
if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
const mode = input.mode == null ? share.mode : String(input.mode);
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.');
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 }) {
@@ -268,6 +270,7 @@ function createShares(s, { config }) {
async function rotateKey(owner, id) {
async function rotateKey(owner, id) {
const share = await owned(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.');
if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
const key = newKey();
const key = newKey();
// A new key means the key serves it: a computer bound as OpenVibe Link is let go (CON-4).
// 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 }) {
@@ -283,6 +286,7 @@ function createShares(s, { config }) {
*/
*/
async function bindNode(owner, id, machine) {
async function bindNode(owner, id, machine) {
const share = await owned(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.');
if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
const key = newKey();
const key = newKey();
if (machine) {
if (machine) {
@@ -300,6 +304,44 @@ function createShares(s, { config }) {
@@ -300,6 +304,44 @@ function createShares(s, { config }) {
return rows.map(view);
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) {
async function revoke(owner, id) {
const share = await owned(owner, id);
const share = await owned(owner, id);
if (share.status === 'revoked') return share;
if (share.status === 'revoked') return share;
@@ -406,7 +448,7 @@ function createShares(s, { config }) {
@@ -406,7 +448,7 @@ function createShares(s, { config }) {
return false;
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) {
function iso(v) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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); });