openvibers/openvibe.extensions Commits

Add Companion blind fill approval (#19)

OP OpenVibers committed 4 days ago · 283912dc5e · 8 files, +185 −8

* Add Companion blind fill approval

* Validate HTTPS origins before sealing

* Bind blind fill grants to pending requests and Network keys

Changes

ddiff --git a/browser/background.js b/browser/background.js
iindex 4ec305e..25a780a 100644
---- a/browser/background.js
++++ b/browser/background.js
@@@ -14,6 +14,7 @@
import './lib/config.js';
import './lib/platform.js';
import './lib/auth.js';
++import './lib/network-api.js';
import './lib/mask.js';
import './lib/mask-service.js';
import './lib/mail-api.js';
@@@ -56,8 +57,10 @@ function isOwnPage(sender) {
const router = self.OVMaskRouter.createRouter({ rules: self.OVMask, service: service, isOwnPage: isOwnPage });
const lockerApi = self.OVLockerApi.create({ origin: config.LOCKER_ORIGIN, fetch: self.fetch.bind(self), getToken: () => auth.accessToken() });
++const fillApi = self.OVNetwork.createFillClient({ networkOrigin: config.NETWORK_ORIGIN, runOrigin: config.RUN_ORIGIN, fetch: self.fetch.bind(self), getToken: () => auth.accessToken() });
const vault = self.OVLockerVault.create({
core: self.OVLockerCore, payload: self.LockerPayload, passwords: self.LockerPasswords, api: lockerApi,
++ fillApi,
storage: platform.storage, tabs: platform.tabs, fill: platform.scripting.fillLogin,
clipboard: platform.clipboard,
account: async () => { const a = await auth.account(); return a?.sub || (await auth.status()).mode; },
ddiff --git a/browser/lib/config.js b/browser/lib/config.js
iindex 57c581d..72adc80 100644
---- a/browser/lib/config.js
++++ b/browser/lib/config.js
@@@ -44,7 +44,8 @@
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// unregistered redirect.
OAUTH_CLIENT_ID: 'companion',
-- OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage', 'locker.login.manage']),
++ OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage', 'locker.login.manage', 'run.fill.approve']),
++ RUN_ORIGIN: 'https://openvibe.run',
OAUTH_REDIRECT_URIS: Object.freeze([
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
ddiff --git a/browser/lib/locker-panel.js b/browser/lib/locker-panel.js
iindex dc044af..d9cc8f4 100644
---- a/browser/lib/locker-panel.js
++++ b/browser/lib/locker-panel.js
@@@ -7,7 +7,8 @@
const body = make('div');
const heading = make('div', '', 'locker-heading');
heading.append(make('h2', 'Passwords'), make('span', 'Locker', 'status'));
-- container.append(heading, body, note);
++ const requests = make('section', '', 'locker-fill-requests');
++ container.append(heading, requests, body, note);
function button(label, action, cls = 'btn') {
const b = make('button', label, cls); b.type = 'button';
b.addEventListener('click', (e) => { if (e.isTrusted) run(b, action); }); return b;
@@@ -27,6 +28,47 @@
input.name = name; input.type = type; input.autocomplete = 'off'; input.spellcheck = false;
wrap.append(input); form.append(wrap); return input;
}
++ async function loadFillRequests() {
++ const data = await request('fill-list');
++ requests.replaceChildren();
++ if (!data.requests.length) return;
++ requests.append(make('h3', 'Sign-in requests'));
++ for (const fill of data.requests) {
++ const card = make('article', '', 'locker-login');
++ card.append(make('h3', 'Approve a sign-in'), make('p', `${fill.agent} on ${fill.computer}`, 'locker-origin'), make('p', fill.origin, 'locker-origin'));
++ const review = button('Review and choose login', async () => {
++ if (!isUnlocked) throw new Error('Unlock Locker to review this sign-in request.');
++ const targets = await request('fill-targets', { clicked: true });
++ const target = targets.machines.find((x) => x.id === fill.machine_node_id);
++ if (!target) throw new Error('That computer is not one of yours, so nothing can be sent to it.');
++ const logins = await request('fill-site-list', { origin: fill.origin, clicked: true });
++ if (!logins.items.length) throw new Error('No saved login matches this exact origin.');
++ const chooser = make('div');
++ chooser.append(make('p', 'This will type the username and password into the top-level page.', 'note muted'));
++ const keyLabel = make('p', `Computer key thumbprint: ${target.thumbprint}`, 'locker-origin');
++ chooser.append(keyLabel);
++ const label = make('label', 'Saved login', 'locker-field'); const select = make('select');
++ for (const item of logins.items) { const option = make('option', item.username || 'No username'); option.value = item.id; select.append(option); }
++ label.append(select); chooser.append(label);
++ const selected = () => logins.items.find((x) => x.id === select.value);
++ const code = make('input'); code.type = 'checkbox'; code.checked = !!selected()?.hasTotp;
++ const codeLabel = make('label', 'Include a TOTP code computed now', 'locker-field'); codeLabel.prepend(code);
++ codeLabel.hidden = !selected()?.hasTotp; select.addEventListener('change', () => { codeLabel.hidden = !selected()?.hasTotp; code.checked = !!selected()?.hasTotp; });
++ chooser.append(codeLabel);
++ const submit = make('input'); submit.type = 'checkbox';
++ const submitLabel = make('label', 'Submit the sign-in form after filling', 'locker-field'); submitLabel.prepend(submit); chooser.append(submitLabel);
++ const approve = button('Seal and approve', async () => {
++ const result = await request('seal-fill', { clicked: true, request_id: fill.id, origin: fill.origin, machine_id: target.id, item_id: select.value, include_code: code.checked, submit: submit.checked });
++ keyLabel.textContent = `Sealed to computer key thumbprint: ${result.thumbprint}`;
++ const completed = await request('fill-grant', { clicked: true, request_id: fill.id, envelope: result.envelope });
++ chooser.replaceChildren(make('p', completed.outcome === 'ok' ? 'Sign-in filled.' : 'The computer could not complete the sign-in.', 'note'));
++ await loadFillRequests();
++ }, 'btn primary');
++ chooser.append(approve); card.append(chooser);
++ });
++ card.append(review); requests.append(card);
++ }
++ }
for (const event of ['pointerdown', 'keydown']) container.addEventListener(event, (e) => {
if (!e.isTrusted || !isUnlocked || Date.now() - lastActivity < 60000) return;
lastActivity = Date.now(); ask({ type: 'locker:activity', clicked: true });
@@@ -87,6 +129,8 @@
const tab = await platform.tabs.active();
try { const u = new URL(tab.url); if (['http:', 'https:'].includes(u.protocol)) context = { tabId: tab.id, site: u.origin }; } catch { /* internal tab */ }
const state = await request('status'); if (state.locked) locked(); else await unlocked();
++ await loadFillRequests().catch((e) => { note.textContent = e.message; });
++ setInterval(() => loadFillRequests().catch(() => {}), 5000);
})().catch(() => { locked(); note.textContent = 'Unlock Locker to continue.'; });
}
root.OVLockerPanel = { mount };
ddiff --git a/browser/lib/locker-vault.js b/browser/lib/locker-vault.js
iindex 0582fe6..90f6d81 100644
---- a/browser/lib/locker-vault.js
++++ b/browser/lib/locker-vault.js
@@@ -4,10 +4,12 @@
})(globalThis, function () {
'use strict';
const IDLE = 15 * 60 * 1000, KEY = 'lockerUnlock';
++ const EXACT_HTTPS_ORIGIN = /^https:\/\/[a-z0-9.-]+(:[0-9]{1,5})?$/;
++ const FILL_REQUEST_ID = /^fr_[A-Za-z0-9_-]{16,64}$/;
function origin(url) {
try { const u = new URL(url); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password ? u.origin : null; } catch { return null; }
}
-- function create({ core, payload, passwords, api, storage, scheduleLock, cancelLock, tabs, fill, clipboard, account, now = Date.now }) {
++ function create({ core, payload, passwords, api, fillApi = null, storage, scheduleLock, cancelLock, tabs, fill, clipboard, account, now = Date.now }) {
let generation = 0, mutations = Promise.resolve();
function persist(saved, version) {
mutations = mutations.catch(() => {}).then(async () => {
@@@ -62,6 +64,58 @@
if (m.clicked !== true && action !== 'list') return { ok: false };
if (action === 'activity') return { ok: true, locked: !(await state(true)) };
if (action === 'lock') return { ok: true, ...await lock() };
++ if (action === 'fill-list') {
++ if (!fillApi) throw new Error('Sign-in approvals are unavailable.');
++ const data = await fillApi.requests();
++ return { ok: true, requests: Array.isArray(data.requests) ? data.requests : [] };
++ }
++ if (action === 'fill-targets') {
++ if (!fillApi) throw new Error('Sign-in approvals are unavailable.');
++ const data = await fillApi.targets();
++ return { ok: true, machines: Array.isArray(data.machines) ? data.machines : [] };
++ }
++ if (action === 'fill-grant') {
++ if (!fillApi || typeof m.request_id !== 'string' || typeof m.envelope !== 'string' || m.clicked !== true) throw new Error('Choose a sign-in request first.');
++ const data = await fillApi.grant(m.request_id, m.envelope);
++ return { ok: true, outcome: data.outcome === 'ok' ? 'ok' : 'failed', reason: data.reason || null };
++ }
++ if (action === 'fill-site-list') {
++ if (m.clicked !== true || typeof m.origin !== 'string' || !EXACT_HTTPS_ORIGIN.test(m.origin) || origin(m.origin) !== m.origin) throw new Error('Choose a valid site.');
++ const s = await unlocked();
++ const items = [];
++ for (const item of await api.list()) {
++ const login = await read(item.id, s);
++ if (login.site === m.origin) items.push({ id: item.id, username: login.username, hasTotp: !!login.totp });
++ login.username = ''; login.password = ''; login.totp = '';
++ }
++ await still(s);
++ return { ok: true, items };
++ }
++ if (action === 'seal-fill') {
++ if (!fillApi || m.clicked !== true || !FILL_REQUEST_ID.test(String(m.request_id || '')) || typeof m.origin !== 'string' || !EXACT_HTTPS_ORIGIN.test(m.origin) || origin(m.origin) !== m.origin
++ || !/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(m.machine_id || '')) || typeof m.item_id !== 'string') throw new Error('Choose a valid sign-in request.');
++ const s = await unlocked();
++ const requests = await fillApi.requests();
++ const pending = (requests.requests || []).find((r) => r.id === m.request_id && r.origin === m.origin && r.machine_node_id === m.machine_id && r.expires_at > now());
++ if (!pending) throw new Error('That sign-in request is no longer waiting.');
++ const targets = await fillApi.targets();
++ const target = (targets.machines || []).find((machine) => machine.id === m.machine_id);
++ if (!target || typeof target.public_key !== 'object' || typeof target.thumbprint !== 'string') throw new Error('That computer is not one of yours, so nothing can be sent to it.');
++ const login = await read(m.item_id, s);
++ if (login.site !== m.origin) throw new Error('This saved login belongs to a different site.');
++ const grant = { request_id: m.request_id, origin: m.origin, username: login.username, password: login.password, submit: m.submit === true, exp: now() + 60_000 };
++ if (m.include_code === true && login.totp) grant.code = await passwords.totp(login.totp);
++ const plain = new TextEncoder().encode(JSON.stringify(grant));
++ try {
++ const envelope = await core.seal(plain, { lock: 'people', keys: [{ subject: { type: 'node', id: m.machine_id }, publicKey: target.public_key }] });
++ await still(s);
++ let binary = ''; for (const b of envelope.bytes) binary += String.fromCharCode(b);
++ return { ok: true, envelope: btoa(binary), thumbprint: target.thumbprint };
++ } finally {
++ plain.fill(0); grant.username = ''; grant.password = ''; grant.code = '';
++ login.username = ''; login.password = ''; login.totp = '';
++ }
++ }
if (action === 'unlock') {
await lock(); const g = generation;
const owner = await account();
ddiff --git a/browser/lib/network-api.js b/browser/lib/network-api.js
iindex 70474b7..e936429 100644
---- a/browser/lib/network-api.js
++++ b/browser/lib/network-api.js
@@@ -64,5 +64,27 @@
};
}
-- return { createNetworkClient: createNetworkClient };
++ function createFillClient(opts) {
++ const networkOrigin = String(opts.networkOrigin || '').replace(/\/+$/, '');
++ const runOrigin = String(opts.runOrigin || '').replace(/\/+$/, '');
++ const doFetch = opts.fetch;
++ const getToken = opts.getToken || async function () { return null; };
++ async function request(origin, path, method = 'GET', body) {
++ let res;
++ try {
++ res = await doFetch(origin + path, { method, headers: { Accept: 'application/json', Authorization: 'Bearer ' + await getToken(), ...(body ? { 'Content-Type': 'application/json' } : {}) },
++ ...(body ? { body: JSON.stringify(body) } : {}), credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error', signal: AbortSignal.timeout(15000) });
++ } catch { throw new Error('could not reach the sign-in approval service'); }
++ if (res.status === 401 || res.status === 403) throw new Error('Sign in again to approve a sign-in from Companion.');
++ if (!res.ok) throw new Error('The sign-in request is no longer available.');
++ return res.json();
++ }
++ return {
++ requests: () => request(runOrigin, '/api/v1/fill-requests'),
++ targets: () => request(networkOrigin, '/api/v1/me/keys/fill-targets'),
++ grant: (id, envelope) => request(runOrigin, '/api/v1/fill-requests/' + encodeURIComponent(id) + '/grant', 'POST', { request_id: id, envelope }),
++ };
++ }
++
++ return { createNetworkClient: createNetworkClient, createFillClient: createFillClient };
});
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex 15ca4d2..129879a 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -65,7 +65,7 @@ function fetcher(responses) {
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('response_type'), 'code');
-- assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view mail.mask.manage locker.login.manage');
++ assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view mail.mask.manage locker.login.manage run.fill.approve');
assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
issuedChallenge = u.searchParams.get('code_challenge');
@@@ -73,7 +73,8 @@ function fetcher(responses) {
return Promise.resolve(`${CHROME}?code=code123&state=${encodeURIComponent(state)}`);
},
});
-- const f = fetcher([{ status: 200, body: { access_token: token(), refresh_token: 'r'.repeat(96), expires_in: 3600, scope: 'dock activity chat.read' } }]);
++ const granted = 'dock activity chat.read agent.view mail.mask.manage locker.login.manage run.fill.approve';
++ const f = fetcher([{ status: 200, body: { access_token: token(), refresh_token: 'r'.repeat(96), expires_in: 3600, scope: granted } }]);
const auth = createAuth({ platform: p, config, fetch: f });
const acc = await auth.signIn();
assert.strictEqual(acc.token, token());
@@@ -92,7 +93,7 @@ function fetcher(responses) {
assert.strictEqual(p.session.get(config.DOCK_ACCESS_KEY).expires_at > Date.now(), true);
assert.strictEqual(p.local.get(config.DOCK_REFRESH_KEY).token, 'r'.repeat(96));
assert.deepStrictEqual(p.local.get(config.DOCK_ACCOUNT_KEY), { sub: 7, username: 'ada', display_name: 'Ada' });
-- assert.deepStrictEqual(await auth.scopes(), ['dock', 'activity', 'chat.read']);
++ assert.deepStrictEqual(await auth.scopes(), granted.split(' '));
assert.strictEqual((await auth.status()).mode, 'oauth');
});
ddiff --git a/test/locker.test.js b/test/locker.test.js
iindex a9c11b6..2fac22c 100644
---- a/test/locker.test.js
++++ b/test/locker.test.js
@@@ -24,7 +24,7 @@ async function fixture() {
content: async (id) => ciphertexts.get(id), save: async (bytes) => { ciphertexts.set(String(ciphertexts.size + 1), bytes); return { id: '1' }; },
};
const storage = { getSession: async (k) => memory.get(k), setSession: async (obj) => { writes.push(obj); for (const [k, v] of Object.entries(obj)) memory.set(k, v); }, removeSession: async (keys) => keys.forEach((k) => memory.delete(k)) };
-- const options = { core, payload, passwords, api, storage, scheduleLock: async () => {}, cancelLock: async () => {}, tabs: { active: async () => active }, fill: async (...args) => { fills.push(args); return { ok: true }; }, clipboard: { write: async (v) => clipboard.push(v) }, account: async () => account, now: () => clock };
++ const options = { core, payload, passwords, api, fillApi: { requests: async () => ({ requests: [{ id: 'fr_01JAB2C3D4E5F6G7H8J9K0MNPQ', origin: 'https://example.test', machine_node_id: 'nod_01JAB2C3D4E5F6G7H8J9K0MNPQ', expires_at: 60_000 }] }), targets: async () => ({ machines: [{ id: 'nod_01JAB2C3D4E5F6G7H8J9K0MNPQ', public_key: options.nodeKey, thumbprint: 'network-thumbprint' }] }), grant: async () => ({ outcome: 'ok' }) }, storage, scheduleLock: async () => {}, cancelLock: async () => {}, tabs: { active: async () => active }, fill: async (...args) => { fills.push(args); return { ok: true }; }, clipboard: { write: async (v) => clipboard.push(v) }, account: async () => account, now: () => clock };
const vault = create(options);
const ask = (type, values = {}, sender = { url: POPUP }) => vault.handle({ type: 'locker:' + type, tabId: 1, site: 'https://example.test', ...values }, sender, POPUP);
return { vault, ask, memory, fills, clipboard, api, options, writes, pass, recovery, ciphertexts,
@@@ -87,6 +87,39 @@ test('locking during a pending decrypt cancels the fill', async () => {
await assert.rejects(pending, /locked/); assert.equal(f.fills.length, 0);
});
++test('Companion seals a selected login to one machine and returns ciphertext only', async () => {
++ const f = await fixture(); await f.ask('unlock', { clicked: true, secret: f.pass });
++ await f.ask('save', { clicked: true, username: 'alice', password: 'fixture password', totp: 'otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' });
++ const node = await core.generatePersonKeys(), machine = 'nod_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ f.options.nodeKey = node.ecdhPublic;
++ await assert.rejects(f.ask('seal-fill', { clicked: true, request_id: 'fr_01JAB2C3D4E5F6G7H8J9K0MNPQ', origin: 'http://example.test', machine_id: machine, public_key: node.ecdhPublic, item_id: '1', include_code: true, submit: false }), /valid sign-in request/);
++ const other = await core.generatePersonKeys();
++ const sealed = await f.ask('seal-fill', { clicked: true, request_id: 'fr_01JAB2C3D4E5F6G7H8J9K0MNPQ', origin: 'https://example.test', machine_id: machine, public_key: other.ecdhPublic, item_id: '1', include_code: true, submit: false });
++ assert.equal(sealed.ok, true);
++ assert.equal(sealed.thumbprint, 'network-thumbprint');
++ await assert.rejects(core.open(Uint8Array.from(Buffer.from(sealed.envelope, 'base64')), { privateKey: other.ecdh.privateKey, subject: { type: 'node', id: machine } }));
++ assert.ok(!sealed.envelope.includes('fixture password'));
++ const plain = await core.open(Uint8Array.from(Buffer.from(sealed.envelope, 'base64')), { privateKey: node.ecdh.privateKey, subject: { type: 'node', id: machine } });
++ const grant = JSON.parse(new TextDecoder().decode(plain));
++ assert.deepEqual({ request_id: grant.request_id, origin: grant.origin, username: grant.username, password: grant.password, submit: grant.submit }, {
++ request_id: 'fr_01JAB2C3D4E5F6G7H8J9K0MNPQ', origin: 'https://example.test', username: 'alice', password: 'fixture password', submit: false,
++ });
++ assert.match(grant.code, /^\d{6}$/);
++ assert.ok(grant.exp > 1000 && grant.exp <= 61_000);
++});
++
++test('seal-fill rechecks pending request and owned target before creating an envelope', async () => {
++ const f = await fixture(); await f.ask('unlock', { clicked: true, secret: f.pass });
++ await f.ask('save', { clicked: true, username: 'alice', password: 'fixture password' });
++ const machine = 'nod_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ f.options.nodeKey = (await core.generatePersonKeys()).ecdhPublic;
++ f.options.fillApi.requests = async () => ({ requests: [] });
++ await assert.rejects(f.ask('seal-fill', { clicked: true, request_id: 'fr_01JAB2C3D4E5F6G7H8J9K0MNPQ', origin: 'https://example.test', machine_id: machine, item_id: '1' }), /no longer waiting/);
++ f.options.fillApi.requests = async () => ({ requests: [{ id: 'fr_01JAB2C3D4E5F6G7H8J9K0MNPQ', origin: 'https://example.test', machine_node_id: machine, expires_at: 60_000 }] });
++ f.options.fillApi.targets = async () => ({ machines: [] });
++ await assert.rejects(f.ask('seal-fill', { clicked: true, request_id: 'fr_01JAB2C3D4E5F6G7H8J9K0MNPQ', origin: 'https://example.test', machine_id: machine, item_id: '1' }), /not one of yours/);
++});
++
function page({ site = 'https://example.test', frame = false, clicked = true, submit = false, hidden = false, transparentParent = false, navigate = false } = {}) {
const calls = []; const form = { querySelectorAll: () => [username, password] };
class Input { set value(v) { calls.push(v); } }
ddiff --git a/test/network-api.test.js b/test/network-api.test.js
nnew file mode 100644
iindex 0000000..21599dc
---- /dev/null
++++ b/test/network-api.test.js
@@@ -0,0 +1,19 @@
++'use strict';
++const test = require('node:test');
++const assert = require('node:assert/strict');
++const { createFillClient } = require('../browser/lib/network-api');
++
++test('Companion fill client sends only the scoped token and encrypted grant to Run', async () => {
++ const calls = [];
++ const client = createFillClient({ networkOrigin: 'https://openvibe.network', runOrigin: 'https://openvibe.run', getToken: async () => 'dock-token', fetch: async (url, init) => {
++ calls.push({ url, init });
++ return new Response(JSON.stringify(url.includes('fill-targets') ? { machines: [] } : url.endsWith('/fill-requests') ? { requests: [] } : { outcome: 'ok' }), { status: 200 });
++ } });
++ assert.deepEqual(await client.requests(), { requests: [] });
++ assert.deepEqual(await client.targets(), { machines: [] });
++ assert.deepEqual(await client.grant('fill_1', 'sealed-envelope'), { outcome: 'ok' });
++ assert.deepEqual(calls.map((x) => new URL(x.url).origin), ['https://openvibe.run', 'https://openvibe.network', 'https://openvibe.run']);
++ assert.ok(calls.every((x) => x.init.credentials === 'omit' && x.init.headers.Authorization === 'Bearer dock-token'));
++ assert.deepEqual(JSON.parse(calls[2].init.body), { request_id: 'fill_1', envelope: 'sealed-envelope' });
++ assert.ok(!JSON.stringify(calls).includes('password'));
++});