openvibers/openvibe.extensions Commits
Add Companion blind fill approval (#19)
Changes
@@@ -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; },@@@ -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/',@@@ -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 };@@@ -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();@@@ -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 };});@@@ -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');});@@@ -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); } }nnew file mode 100644@@@ -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'));++});
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
import './lib/config.js';
import './lib/config.js';
import './lib/platform.js';
import './lib/platform.js';
import './lib/auth.js';
import './lib/auth.js';
+import './lib/network-api.js';
import './lib/mask.js';
import './lib/mask.js';
import './lib/mask-service.js';
import './lib/mask-service.js';
import './lib/mail-api.js';
import './lib/mail-api.js';
@@ -56,8 +57,10 @@ function isOwnPage(sender) {
@@ -56,8 +57,10 @@ function isOwnPage(sender) {
const router = self.OVMaskRouter.createRouter({ rules: self.OVMask, service: service, isOwnPage: isOwnPage });
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 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({
const vault = self.OVLockerVault.create({
core: self.OVLockerCore, payload: self.LockerPayload, passwords: self.LockerPasswords, api: lockerApi,
core: self.OVLockerCore, payload: self.LockerPayload, passwords: self.LockerPasswords, api: lockerApi,
+ fillApi,
storage: platform.storage, tabs: platform.tabs, fill: platform.scripting.fillLogin,
storage: platform.storage, tabs: platform.tabs, fill: platform.scripting.fillLogin,
clipboard: platform.clipboard,
clipboard: platform.clipboard,
account: async () => { const a = await auth.account(); return a?.sub || (await auth.status()).mode; },
account: async () => { const a = await auth.account(); return a?.sub || (await auth.status()).mode; },
@@ -44,7 +44,8 @@
@@ -44,7 +44,8 @@
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// unregistered redirect.
// unregistered redirect.
OAUTH_CLIENT_ID: 'companion',
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([
OAUTH_REDIRECT_URIS: Object.freeze([
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
const body = make('div');
const body = make('div');
const heading = make('div', '', 'locker-heading');
const heading = make('div', '', 'locker-heading');
heading.append(make('h2', 'Passwords'), make('span', 'Locker', 'status'));
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') {
function button(label, action, cls = 'btn') {
const b = make('button', label, cls); b.type = 'button';
const b = make('button', label, cls); b.type = 'button';
b.addEventListener('click', (e) => { if (e.isTrusted) run(b, action); }); return b;
b.addEventListener('click', (e) => { if (e.isTrusted) run(b, action); }); return b;
@@ -27,6 +28,47 @@
@@ -27,6 +28,47 @@
input.name = name; input.type = type; input.autocomplete = 'off'; input.spellcheck = false;
input.name = name; input.type = type; input.autocomplete = 'off'; input.spellcheck = false;
wrap.append(input); form.append(wrap); return input;
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) => {
for (const event of ['pointerdown', 'keydown']) container.addEventListener(event, (e) => {
if (!e.isTrusted || !isUnlocked || Date.now() - lastActivity < 60000) return;
if (!e.isTrusted || !isUnlocked || Date.now() - lastActivity < 60000) return;
lastActivity = Date.now(); ask({ type: 'locker:activity', clicked: true });
lastActivity = Date.now(); ask({ type: 'locker:activity', clicked: true });
@@ -87,6 +129,8 @@
@@ -87,6 +129,8 @@
const tab = await platform.tabs.active();
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 */ }
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();
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.'; });
})().catch(() => { locked(); note.textContent = 'Unlock Locker to continue.'; });
}
}
root.OVLockerPanel = { mount };
root.OVLockerPanel = { mount };
@@ -4,10 +4,12 @@
@@ -4,10 +4,12 @@
})(globalThis, function () {
})(globalThis, function () {
'use strict';
'use strict';
const IDLE = 15 * 60 * 1000, KEY = 'lockerUnlock';
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) {
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; }
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();
let generation = 0, mutations = Promise.resolve();
function persist(saved, version) {
function persist(saved, version) {
mutations = mutations.catch(() => {}).then(async () => {
mutations = mutations.catch(() => {}).then(async () => {
@@ -62,6 +64,58 @@
@@ -62,6 +64,58 @@
if (m.clicked !== true && action !== 'list') return { ok: false };
if (m.clicked !== true && action !== 'list') return { ok: false };
if (action === 'activity') return { ok: true, locked: !(await state(true)) };
if (action === 'activity') return { ok: true, locked: !(await state(true)) };
if (action === 'lock') return { ok: true, ...await lock() };
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') {
if (action === 'unlock') {
await lock(); const g = generation;
await lock(); const g = generation;
const owner = await account();
const owner = await account();
@@ -64,5 +64,27 @@
@@ -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 };
});
});
@@ -65,7 +65,7 @@ function fetcher(responses) {
@@ -65,7 +65,7 @@ function fetcher(responses) {
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('response_type'), 'code');
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.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
issuedChallenge = u.searchParams.get('code_challenge');
issuedChallenge = u.searchParams.get('code_challenge');
@@ -73,7 +73,8 @@ function fetcher(responses) {
@@ -73,7 +73,8 @@ function fetcher(responses) {
return Promise.resolve(`${CHROME}?code=code123&state=${encodeURIComponent(state)}`);
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 auth = createAuth({ platform: p, config, fetch: f });
const acc = await auth.signIn();
const acc = await auth.signIn();
assert.strictEqual(acc.token, token());
assert.strictEqual(acc.token, token());
@@ -92,7 +93,7 @@ function fetcher(responses) {
@@ -92,7 +93,7 @@ function fetcher(responses) {
assert.strictEqual(p.session.get(config.DOCK_ACCESS_KEY).expires_at > Date.now(), true);
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.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(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');
assert.strictEqual((await auth.status()).mode, 'oauth');
});
});
@@ -24,7 +24,7 @@ async function fixture() {
@@ -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' }; },
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 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 vault = create(options);
const ask = (type, values = {}, sender = { url: POPUP }) => vault.handle({ type: 'locker:' + type, tabId: 1, site: 'https://example.test', ...values }, sender, POPUP);
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,
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 () => {
@@ -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);
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 } = {}) {
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] };
const calls = []; const form = { querySelectorAll: () => [username, password] };
class Input { set value(v) { calls.push(v); } }
class Input { set value(v) { calls.push(v); } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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'));
+});