openvibers/openvibe.locker Commits
Add owner-only encrypted login API for Companion (#5)
Changes
@@@ -102,3 +102,21 @@ ADR-033 export provides an opaque manifest with IDs and download routes; each enwhile 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/moderationhistory 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.@@@ -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 }));nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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(); }++});
@@ -102,3 +102,21 @@ ADR-033 export provides an opaque manifest with IDs and download routes; each en
@@ -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,
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
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.
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.
@@ -39,6 +39,7 @@ async function createApp(options = {}) {
@@ -39,6 +39,7 @@ async function createApp(options = {}) {
app.use('/internal/events', accountData.consumer({ secrets: [config.eventsSecret].filter(Boolean),
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 })
send: options.sendAccountData || (config.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.clientId, clientSecret: config.clientSecret })
: async () => { throw new Error('Account delivery is not configured.'); }) }));
: 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('/auth', sso.router(express));
app.use(sso.optionalAuth());
app.use(sso.optionalAuth());
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-locker', version: require('../package.json').version }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-locker', version: require('../package.json').version }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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(); }
+});