openvibers/openvibe.locker Commits

Add owner-only encrypted login API for Companion (#5)

OP OpenVibers committed 4 days ago · 4b4772113f · 4 files, +104 −0

Changes

ddiff --git a/README.md b/README.md
iindex 15bb76a..52817d1 100644
---- a/README.md
++++ b/README.md
@@@ -102,3 +102,21 @@ ADR-033 export provides an opaque manifest with IDs and download routes; each en
while the account exists. Network owns the wrapped-key export. Account deletion denies all access immediately,
queues ciphertext erasure, cancels pending deliveries and removes memberships. Append-only access/moderation
history and an account deletion marker are retained as security evidence; they contain no plaintext or keys.
++
++### Companion password access
++
++`/api/v1/companion` accepts only a Bearer token which Network validates live at
++`GET /api/v1/me/keys/companion` (the Companion needs `locker.login.manage`). Deploy
++that Network scope/endpoint first; existing Companion grants require a new sign-in.
++No extension origin CORS wildcard or cookie authentication is added.
++
++- `GET /keys`: the caller's subject and wrapped key backups, never plaintext keys.
++- `GET /logins?before=…`: owner-only encrypted login metadata with the normal cursor.
++- `GET /logins/:id/content`: only that owner's `kind=login` ciphertext.
++- `POST /logins`: an SDK envelope, `application/octet-stream`, at most 2 MiB; the
++ item kind is fixed to `login`. Site, username, password, TOTP and notes belong in
++ the sealed payload. Normal upload quota, age and envelope validation apply.
++
++The scoped token cannot reach the general file API, key updates or blind fill.
++Every request revalidates Network's live token family, including content downloads.
++Run `node --test test/companion.test.js` for isolation and ciphertext-only checks.
ddiff --git a/server/app.js b/server/app.js
iindex 4fcded7..193e9e6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -39,6 +39,7 @@ async function createApp(options = {}) {
app.use('/internal/events', accountData.consumer({ secrets: [config.eventsSecret].filter(Boolean),
send: options.sendAccountData || (config.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.clientId, clientSecret: config.clientSecret })
: async () => { throw new Error('Account delivery is not configured.'); }) }));
++ app.use('/api/v1/companion', require('./companion').companionRouter({ network, service }));
app.use('/auth', sso.router(express));
app.use(sso.optionalAuth());
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-locker', version: require('../package.json').version }));
ddiff --git a/server/companion.js b/server/companion.js
nnew file mode 100644
iindex 0000000..462ba68
---- /dev/null
++++ b/server/companion.js
@@@ -0,0 +1,45 @@
++'use strict';
++const express = require('express');
++const { Readable } = require('node:stream');
++const { pipeline } = require('node:stream/promises');
++const { fail, wrap } = require('./errors');
++const { userId, ID } = require('./service');
++
++// Deliberately separate from the full Locker API: the scoped Companion token can only
++// read its owner's wrapped keys and read/create that owner's encrypted login items.
++function companionRouter({ network, service }) {
++ const router = express.Router();
++ router.use((req, res, next) => { (async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const token = /^Bearer (\S+)$/.exec(req.headers.authorization || '')?.[1];
++ if (!token) fail(401, 'locker.auth.required', 'Sign in to Companion.');
++ const keys = await network.json({ path: '/api/v1/me/keys/companion', token, preserveProblem: true });
++ req.companionKeys = keys;
++ req.user = { subject_id: keys.subject };
++ userId(req.user);
++ next();
++ })().catch(next); });
++ router.get('/keys', (req, res) => res.json(req.companionKeys));
++ router.get('/logins', wrap(async (req, res) => {
++ // Paginate the underlying owner-only list, preserving its cursor even if this page has no logins.
++ const page = await service.list(req.user, 'file', req.query);
++ res.json({ ...page, items: page.items.filter((item) => item.kind === 'login') });
++ }));
++ router.get('/logins/:id/content', wrap(async (req, res) => {
++ if (!ID.test(req.params.id)) fail(404, 'locker.not_found', 'Login not found.');
++ const row = await service.owned(req.user, req.params.id);
++ if (row.kind !== 'login') fail(404, 'locker.not_found', 'Login not found.');
++ const { response } = await service.download(req.user, row.id);
++ res.set({ 'Content-Type': 'application/octet-stream', 'Content-Length': String(row.envelope_bytes) });
++ await pipeline(Readable.fromWeb(response.body), res);
++ }));
++ router.post('/logins', wrap(async (req, res) => {
++ if (req.headers['content-type'] !== 'application/octet-stream') fail(415, 'locker.envelope.required', 'Send an encrypted envelope.');
++ if (Object.keys(req.query).length) fail(400, 'locker.metadata.private', 'Keep login details encrypted.');
++ const bytes = Number(req.headers['content-length']);
++ if (!Number.isSafeInteger(bytes) || bytes < 8 || bytes > 2 * 1024 ** 2) fail(413, 'locker.length', 'Login envelope must be at most 2 MiB.');
++ res.status(201).json(await service.create(req.user, req, bytes, { kind: 'login' }));
++ }));
++ return router;
++}
++module.exports = { companionRouter };
ddiff --git a/test/companion.test.js b/test/companion.test.js
nnew file mode 100644
iindex 0000000..a489a41
---- /dev/null
++++ b/test/companion.test.js
@@@ -0,0 +1,40 @@
++'use strict';
++const test = require('node:test');
++const assert = require('node:assert/strict');
++const { boot, SUBJECT, OTHER } = require('./helpers');
++
++test('Companion is owner-only, login-only, ciphertext-only and checks live Network scope', async () => {
++ const ctx = await boot();
++ try {
++ const json = ctx.network.json.bind(ctx.network);
++ let calls = 0, revoked = false;
++ ctx.network.json = async (opts) => {
++ if (opts.path !== '/api/v1/me/keys/companion') return json(opts);
++ calls++;
++ if (opts.token !== 'scoped-companion' || revoked) throw Object.assign(new Error('Missing scope or revoked'), { status: 403, code: 'insufficient_scope' });
++ return { subject: SUBJECT, backups: { passphrase: { envelope: 'ciphertext' } } };
++ };
++ const ask = (method, path, opts = {}) => ctx.request(method, '/api/v1/companion' + path, { user: 'scoped-companion', ...opts });
++ assert.equal((await ask('GET', '/keys', { user: null })).status, 401);
++ assert.equal((await ask('GET', '/keys', { user: 'dock-only' })).status, 403);
++ assert.equal((await (await ask('GET', '/keys')).json()).subject, SUBJECT);
++ const sealed = await ctx.envelope('encrypted login');
++ const save = await ask('POST', '/logins', { body: sealed.bytes });
++ assert.equal(save.status, 201);
++ const item = await save.json(); assert.equal(item.kind, 'login');
++ const plain = await ask('POST', '/logins', { json: { password: 'must not be accepted' } });
++ assert.equal(plain.status, 415);
++ const file = await (await ctx.request('POST', '/api/v1/files', { body: sealed.bytes })).json();
++ const otherSealed = await ctx.envelope('other', { owner: OTHER });
++ const other = await (await ctx.request('POST', '/api/v1/files?kind=login', { user: OTHER, body: otherSealed.bytes })).json();
++ const list = await (await ask('GET', '/logins')).json();
++ assert.deepEqual(list.items.map((r) => r.id), [item.id]);
++ assert.equal((await ask('GET', `/logins/${file.id}/content`)).status, 404);
++ assert.equal((await ask('GET', `/logins/${other.id}/content`)).status, 404);
++ assert.deepEqual(new Uint8Array(await (await ask('GET', `/logins/${item.id}/content`)).arrayBuffer()), sealed.bytes);
++ assert.equal((await ctx.request('GET', '/api/v1/files', { user: 'scoped-companion' })).status, 401);
++ revoked = true;
++ assert.equal((await ask('GET', `/logins/${item.id}/content`)).status, 403);
++ assert.ok(calls >= 8);
++ } finally { await ctx.close(); }
++});