Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/desktop/__tests__/operator-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { verifyJwt } from '@wavegrid/server';
import { openStore } from '@wavegrid/settings';

import { embeddedUrl, operatorToken } from '../src/main/operator-session';

// `openStore()` with no base dir is the developer's own ~/.wavegrid. These tests
// create projects, generate secrets and can flip the active project, so without
// an isolated store a test run rewrites the machine's real setup — a laptop
// mid-show ends up pointed at a fixture. Pin the store to this run's temp dir.
process.env.APPSTASH_BASE_DIR = mkdtempSync(join(tmpdir(), 'wavegrid-operator-session-'));

const PROJECT = 'desk-auth';

beforeAll(() => {
Expand Down Expand Up @@ -41,6 +51,28 @@ describe('desktop operator session', () => {
expect(embeddedUrl('http://127.0.0.1:3000', 'no-users')).toBe('http://127.0.0.1:3000');
});

it('omits the sid when the project joins a remote brain', () => {
// A joined brain validates `sid` against its own session store, so a
// locally-minted session id gets the token rejected outright. The token
// has to stand on its signature alone.
const store = openStore();
const remote = 'joined-brain';
store.createProject(remote, { preset: 'ring-6' });
store.generateSecrets(remote);
store.addUser(remote, 'dan', 'hunter2', 'admin');
store.saveProjectConfig(remote, {
layout: { preset: 'ring-6' },
receiver: { server: 'wss://grace.hipzap.com' }
});

const payload = verifyJwt(operatorToken(remote)!);
expect(payload?.sub).toBe('dan');
expect(payload?.role).toBe('admin');
expect(payload?.sid).toBeUndefined();
// No phantom session left behind for a brain that never saw it.
expect(store.listSessions(remote)).toHaveLength(0);
});

it('hands the token off in the fragment, never the query', () => {
const url = embeddedUrl('http://127.0.0.1:3000', PROJECT);
expect(url.startsWith('http://127.0.0.1:3000#wg_token=')).toBe(true);
Expand Down
7 changes: 7 additions & 0 deletions packages/desktop/__tests__/receiver-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import { applyReceiverEnv, resolveProjectConfig } from '@/main/receiver-env';

const root = mkdtempSync(join(tmpdir(), 'wavegrid-receiver-env-'));

// The loader's `userStash` layer is a real file — ~/.wavegrid/config/config.json,
// the active project's mirror. Without an isolated base dir these tests read
// whoever's machine they run on, and a developer whose active project has joined
// a brain sees `SIMULATOR_URL` come back as that brain instead of the local
// default. Pin the store to this run's temp dir so the layer is empty.
process.env.APPSTASH_BASE_DIR = root;

/** Just the store surface the receiver env needs. */
const store = {
requireSecret: () => 'receiver-key',
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = {
]
},
transformIgnorePatterns: [`/node_modules/*`],
setupFiles: ['<rootDir>/jest.setup.js'],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
Expand Down
8 changes: 8 additions & 0 deletions packages/desktop/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Every desktop suite gets an isolated settings store. Without this, openStore()
// and the config loader resolve to the developer's real ~/.wavegrid and a test
// run can create projects there or flip the active one.
const { mkdtempSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { join } = require('node:path');

process.env.APPSTASH_BASE_DIR = mkdtempSync(join(tmpdir(), 'wavegrid-desktop-test-'));
21 changes: 19 additions & 2 deletions packages/desktop/src/main/operator-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
* read its secrets, add users and mint access keys — so requiring them to
* re-type a password into their own machine protects nothing.
*
* The session is a real store session with a recognisable user agent, so it
* appears in Access → Sessions and can be revoked like any other.
* Against a local brain the session is a real store session with a recognisable
* user agent, so it appears in Access → Sessions and can be revoked like any
* other. Against a joined remote brain there is no session to create — see
* `operatorToken` — so the token carries no `sid` and expires rather than being
* revoked.
*/
import { signJwt } from '@wavegrid/server';
import { openStore } from '@wavegrid/settings';
Expand All @@ -33,6 +36,20 @@ export function operatorToken(project: string): string | null {
// the first start is signed with the same project's secret.
process.env.WG_JWT_SECRET = store.requireSecret(project, 'jwtSecret');

// Receiver-only mode: the brain is someone else's process, and it validates
// `sid` against *its* session store. A session we create here exists only in
// this laptop's store, so embedding its id gets the token rejected with
// "Session expired or revoked" — matching secrets are not enough. Both the
// brain's `/api/me` and its WebSocket upgrade skip the session lookup when
// there is no `sid`, so a sid-less token is the one that authenticates.
// It cannot be revoked from Access → Sessions; the TTL is what bounds it.
if (store.getProjectConfig(project)?.receiver?.server) {
return signJwt(account.username, {
role: account.role,
ttlSec: Math.floor(TTL_MS / 1000)
});
}
Comment on lines +46 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 security · medium

Non-revocable 12h admin operator token

In receiver mode operatorToken mints a role: admin token with no sid against a joined brain (packages/desktop/src/main/operator-session.ts:46-50), and the brain's WebSocket upgrade accepts a sid-less JWT by signature alone with no live-session, role, or user-existence check (packages/server/src/server.ts:314-329).

Revoking the operator's sessions or demoting/removing the account does not kill an already-distributed token, which stays valid for the full 12h TTL_MS; a leaked token therefore grants admin show control that cannot be cut off server-side, unlike the session-bound local path.

📋 Prompt for AI Agents

In packages/desktop/src/main/operator-session.ts lines 46-51, the receiver-mode branch mints a sid-less role:admin JWT valid for TTL_MS (12h) that the remote brain's WebSocket upgrade accepts by signature alone (server.ts:314-329) with no session/role/user check. Reduce the blast radius by shortening ttlSec for this branch (e.g. re-mint on each embedded page load) and add a comment recording that this token cannot be revoked from Access→Sessions nor invalidated by demoting/removing the operator, so it is bounded only by expiry.


const session = store.createSession(project, {
username: account.username,
role: account.role,
Expand Down
12 changes: 11 additions & 1 deletion packages/settings/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';

import { openStore } from '../src';
import { openStore, projectSecretsFile } from '../src';

function tmpBase(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'wg-store-'));
Expand Down Expand Up @@ -49,6 +49,16 @@ describe('SettingsStore projects', () => {
expect(store.getActiveProject()).toBe('b');
expect(store.listProjects()).toEqual(['b']);
});

it('deleting a project removes its secrets too', () => {
const store = openStore({ baseDir: tmpBase() });
store.createProject('p', {});
store.generateSecrets('p');
const file = projectSecretsFile(store.paths, 'p');
expect(fs.existsSync(file)).toBe(true);
store.deleteProject('p');
expect(fs.existsSync(file)).toBe(false);
});
});

describe('SettingsStore secrets', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/settings/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import fs from 'fs';
import {
projectConfigFile,
projectDir,
projectSecretsFile,
readJsonFile,
type StorePaths,
writeFileAtomic
Expand Down Expand Up @@ -105,6 +106,7 @@ export function deleteProject(paths: StorePaths, name: string): boolean {
writeRegistry(paths, reg);
try {
fs.rmSync(projectDir(paths, name), { recursive: true, force: true });
fs.rmSync(projectSecretsFile(paths, name), { force: true });
} catch {
/* best effort */
}
Comment on lines +109 to 112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 security · medium

Best-effort secret purge can silently leak keys

The new fs.rmSync(projectSecretsFile(...)) runs inside the same catch {} that already swallowed the project-dir removal, and it executes after the registry entry is dropped — so deleteProject returns true even when the secret purge is skipped or fails (packages/settings/src/projects.ts:107-112).

Impact: a "deleted" project's jwtSecret/receiverKey can remain at config/secrets/<project>.json and be resurrected if the same-named project is re-created, since generateSecrets only back-fills missing values.

📋 Prompt for AI Agents

In packages/settings/src/projects.ts deleteProject() (lines 101-115), the secrets file purge (line 109) is inside the same try/catch as the project-dir removal and runs after the registry write, so a failed or short-circuited removal is silently reported as success. Move the secrets removal out of the best-effort block and before writeRegistry, and on failure log/rethrow so callers know the signing key was not purged.

Expand Down
Loading