openvibers/openvibe.network Commits

A person's machine may buy a node token for OpenVibe Connect (CON-4) (#538)

OP OpenVibers committed 10 days ago · 751ef55c54 · 3 files, +21 −6

ADR-057 decision 4 (CON-4): Connect binds a share to one machine and checks its node token at every tunnel hello.
Until now a node token was for Network or the service that paired the machine (Bot, Codes), so a machine paired
for Bot could not share. The token for openvibe.website carries no capability; Connect authorises the node by its
own share binding. Project-owned machines stay as they were.

Changes

ddiff --git a/docs/t2-cells-and-node-principal.md b/docs/t2-cells-and-node-principal.md
iindex edaa8861..d3767c8d 100644
---- a/docs/t2-cells-and-node-principal.md
++++ b/docs/t2-cells-and-node-principal.md
@@@ -353,7 +353,8 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
node is absent from `GET /api/v1/nodes`; home cell from `region`, else `wnam-1`; Host's report naming a paired
`node_id` is refused `409 registry.node_not_platform` (`cells.js:43`).
- **N4c `test/node-tokens.test.js`:** credential → token whose claims validate as `identity.service-token-claims@1`
-- with `sub node:nod_…`; audience `openvibe.bot` OK, `openvibe.chat` 400; revoked / wrong secret 401 with one message;
++ with `sub node:nod_…`; audience `openvibe.bot` OK, `openvibe.website` OK for any person's machine (OpenVibe Connect,
++ CON-4, 2026-11-02; no capability), `openvibe.chat` 400; revoked / wrong secret 401 with one message;
rotation: new works, old works < 60 s and fails after (clock injected), revoke kills both; capabilities PUT with
another `node_id` 409; node A's valid token cannot change node B's capabilities or credential (B's rows unchanged);
a revoked node's still-unexpired token on either self route 401 `registry.node_revoked`; a `svc:` token holding
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
iindex 580c3536..7815a050 100644
---- a/server/registry/node-principals.js
++++ b/server/registry/node-principals.js
@@@ -367,6 +367,9 @@ function userRouter(requireAuth, now = () => Date.now()) {
return router;
}
++/** Audiences every person's machine may buy a token for, whichever service paired it (CON-4). */
++const NODE_WIDE_AUDIENCES = Object.freeze(['openvibe.website']);
++
/** Every refusal of a node's credential, whatever the reason: one status, one body. */
const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });
@@@ -386,8 +389,12 @@ async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey
const prevLive = !!(p && p.prev_valid_until && Date.parse(p.prev_valid_until) > now);
if (!p || p.status !== 'active' || !p.credential_hash || !(current || (previous && prevLive))) return INVALID_CLIENT;
const aud = typeof audience === 'string' ? audience.trim() : '';
-- if (aud !== SELF_AUDIENCE && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {
-- return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${SELF_AUDIENCE}${p.paired_by_service ? ` or openvibe.${p.paired_by_service}` : ''}` } };
++ // A person's machine may also speak to the services any of their computers can use, whoever paired it: OpenVibe
++ // Connect (openvibe.website) binds a share to one machine and checks the node token at every tunnel hello (ADR-057
++ // decision 4, CON-4). The token carries no capability: Connect authorises the node by its own share binding.
++ const anyNode = p.owner_kind === 'user' && NODE_WIDE_AUDIENCES.includes(aud);
++ if (aud !== SELF_AUDIENCE && !anyNode && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {
++ return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${SELF_AUDIENCE}${p.paired_by_service ? ` or openvibe.${p.paired_by_service}` : ''}${p.owner_kind === 'user' ? `, or ${NODE_WIDE_AUDIENCES.join(', ')}` : ''}` } };
}
const iat = Math.floor(now / 1000);
const claims = {
@@@ -524,4 +531,4 @@ function routers({ guard, now = () => Date.now() }) {
return { internal, pairing };
}
--module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
++module.exports = { CODE_TTL_MS, MAX_TRIES, NODE_WIDE_AUDIENCES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
ddiff --git a/test/node-tokens.test.js b/test/node-tokens.test.js
iindex 8ddd69ce..e4b075f5 100644
---- a/test/node-tokens.test.js
++++ b/test/node-tokens.test.js
@@@ -107,13 +107,20 @@ try {
assert.strictEqual((await row(A.principal)).last_seen_at, seen, 'not rewritten within 60 s');
});
-- await check('the audience is Network or the pairing service, nothing else', async () => {
++ await check('the audience is Network, the pairing service or Connect, nothing else', async () => {
const r = await tokenCall(A.principal, A.credential, 'openvibe.bot');
assert.strictEqual(r.status, 200, r.text);
const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.bot' });
assert.ok(v.ok, v.reason);
assert.deepStrictEqual([v.claims.aud, v.claims.cap], [['openvibe.bot'], []], 'Bot authorises the node by its own binding');
-- for (const aud of ['openvibe.chat', 'openvibe.network.evil', '', undefined]) {
++ // Any person's machine may speak to OpenVibe Connect, whoever paired it (CON-4), with no capability.
++ const w = await tokenCall(A.principal, A.credential, 'openvibe.website');
++ assert.strictEqual(w.status, 200, w.text);
++ const vw = serviceAuth.verifyServiceToken(w.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.website' });
++ assert.ok(vw.ok, vw.reason);
++ assert.deepStrictEqual([vw.claims.sub, vw.claims.aud, vw.claims.cap], [`node:${A.principal}`, ['openvibe.website'], []]);
++ assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), bearer(w.body.access_token))).status, 401, 'no use on Network');
++ for (const aud of ['openvibe.chat', 'openvibe.network.evil', 'openvibe.websites', 'connect.openvibe.website', '', undefined]) {
const bad = await tokenCall(A.principal, A.credential, aud);
assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));
}