diff --git a/docs/per-user-linux-accounts.md b/docs/per-user-linux-accounts.md new file mode 100644 index 00000000..1e386a2c --- /dev/null +++ b/docs/per-user-linux-accounts.md @@ -0,0 +1,259 @@ +# Per-user Linux accounts + +**Status: in progress.** Stage 1 (the account and the privilege-drop mechanism) is being built now. +Agents are explicitly out of scope for the first pass. + +## What this is for + +Today every `execution` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the +**owner's OS user in the owner's home**. That is why `capabilities/registry.ts` declares them +`kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one. +The registry says so out loud: *"revisit only if per-user home confinement is ever solved — and that is a +project, not a checkbox."* + +This is that project. A member gets a real Linux account whose home is the directory the platform already +provisions for them, and the surfaces that execute code run **as that account**. The payoff is three +things at once: + +- **Isolation** — a member cannot read another member's files, because the kernel says so rather than + because a path check happened to be right. +- **Permissions** — "may they see this" becomes a mode bit, checked by the OS on every syscall, instead + of a predicate the platform has to remember to apply on every route. +- **Separable agents** — `claude` and `opencode` run as the member, with their own `~/.claude`, their own + transcripts and their own session state, because the CLI groups by HOME and cwd. + +## The target for the first test + +A member signs in and: + +- the **file browser** shows their home as the root and cannot navigate above it; +- the **terminal** lands in their home and has no permission to see anything above it. + +Nothing else changes. Agents stay owner-only until this much is solid. + +## The layout, and what each mode bit is for + +``` +data/ 711 service user traverse only — a member cannot enumerate the members +└── / 711 service user traverse only — a member cannot see their OWN siblings + ├── home/ 700 the member their real Linux home + ├── attachments/ 700 service user platform-written; unreachable even by name + ├── email_accounts/ 700 service user " + ├── dashboards/ 700 service user " + └── … 700 service user " +``` + +The important line is the second one. `data//` is traverse-only **to its own member**: they need +`x` to reach `home/`, and they must not have `r`, or they could list the platform's private tree beside +it. And because every sibling is `700 service user`, knowing a name does not help — traversal without +read gets you exactly one place, which is where they are going anyway. + +This is also what resolves the two-sided ownership problem. The platform runs as the service user and +writes attachments, email databases and dashboards into `data//`; the member owns only `home/`. +Nobody needs a shared group, a setgid bit or an ACL, and neither side can write where the other lives. + +**Members' homes stay under `DATA_PATH`** rather than moving to `/home/`. They are the platform's +data, they belong with the rest of that account's data, and the directory is already provisioned there by +`provisionUserDirs`. A move would also break `getOwnerHomeDir`'s fallback, which is the only shape the +code has ever had for a non-owner home. + +## Hard prerequisite: the secrets a shell can currently read + +**This must be fixed before any member gets a shell, and it is not optional.** + +On this machine, verified 2026-08-11: + +| path | mode | consequence | +| --- | --- | --- | +| `/home/pastilhas` | 751 | traversable by anyone (no listing) | +| `…/officer.dev` | 775 | listable by anyone | +| `…/platform/.env` | **664** | **world-readable** | + +`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with +a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than +not doing it — the capability model would be intact and completely bypassed. + +So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is +traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env` +under the project root is group- or world-readable.** A prerequisite that is merely written down is a +prerequisite that gets skipped. + +The same applies to `capabilities/` (775 today) and to the repo checkout itself: a member can read the +platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not. + +## The mechanism, and the trap in it + +### `Bun.spawn` silently ignores `uid` and `gid` + +Verified on bun 1.3.10, 2026-08-11. From uid 1000: + +```js +Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }) // exit 0, prints "1000" +``` + +It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and +script spawn in this codebase goes through `Bun.spawn`. + +Two honest qualifications, because the danger is narrower than it first looks: + +- **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by + accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all. +- What *can* reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four + sidecars are `.mjs`. + +So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through +an external wrapper, and a test pins Bun's runtime behaviour. If Bun ever implements the option, that test +fails and tells us we may simplify. **A silently absent isolation boundary is the worst possible outcome of +this project**, so it is worth a test that exists only to observe something staying broken. + +### `sudo -n setpriv`, and why both words are needed + +`runAs` builds: + +``` +sudo -n setpriv --reuid= --regid= --init-groups --reset-env -- +``` + +- `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to. +- `--init-groups` applies the account's supplementary groups. Without it the process keeps the *owner's* + groups, which is a quiet way to retain access we just took away. +- `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and + `PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`, + and on a process started by PM2 in the platform directory it contains everything Bun auto-loaded from + `.env`. + +**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with +`initgroups failed: Operation not permitted` for an unprivileged caller *even when reuid'ing to its own +account* — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n` +makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no +user will ever see. + +Verified end to end, dropping to the current account: + +``` +$ sudo -n setpriv --reuid=pastilhas --regid=pastilhas --init-groups --reset-env -- \ + sh -c 'id -u; id -G; echo HOME=$HOME; echo SECRET=${POSTGRES_URL:-unset}' +1000 +1000 4 24 27 30 46 101 988 1001 ← supplementary groups from the account, not inherited +HOME=/home/pastilhas ← from passwd, after the reset +SECRET=unset ← the platform's .env did NOT cross +``` + +That last line is the whole security property, demonstrated rather than asserted, and it is pinned by a +test (`os-user.test.ts` → "does not pass the platform environment through"). + +`sudo -u ` alone would also work and be shorter. It is not used because its environment handling is +sudoers *policy* — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's +shell" must not depend on a config file someone may have edited. + +Root is available: `scripts/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service +user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that +wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket +rule anyway. + +### The terminal is the easy one + +`sidecar/pty/sessions.mjs` uses **node-pty** under **node**, and node-pty's `spawn` genuinely honours +`uid`/`gid` (it is a native binding, not Bun's spawn). Two options; we take the second: + +1. Run the pty sidecar as root and pass `uid`/`gid` per session. +2. Keep the sidecar unprivileged and make the command `setpriv … -i`. + +(2) means no root daemon and one mechanism shared with everything else. A root daemon accepting session +requests over a socket is a bigger promise than this feature needs to make. + +## Naming + +`officer_`, where `` is `toShellUsername(username, email)` — the existing +sanitiser, which already lowercases, strips `@…`, replaces illegal characters and truncates to 32. The +combined name is truncated to 32 again. + +The prefix earns its ugliness three times: it cannot collide with a system account, it makes every +account this feature created greppable in `/etc/passwd`, and it means a member cannot pick a username +that shadows something real. + +The resolved name is **stored** on the user row (`users.os_user`) rather than re-derived. `useradd` can +adjust or refuse a name, and re-deriving would mean the platform's idea of who a member is could drift +from what is actually in `/etc/passwd`. + +## Out of scope, and honest about it + +- **This is not a sandbox.** A member with a shell is on the machine. They cannot read the owner's files + or another member's, and `sudo` is not theirs — but they can run code, see process names, and reach the + network. It isolates members from each other and from accidents, not from the host. +- **Agents come later.** `claude-manager.ts` drives turns through `query()` from + `@anthropic-ai/claude-agent-sdk`, which spawns `claude` itself and takes `env`/`cwd` but has nowhere to + put a uid. Dropping privileges has to happen *outside* the SDK, which makes a member's turn its own + process — a change of shape rather than a flag. The credential is not the problem: + `officer-anthropic-proxy` already holds it, so a member's `claude` needs only `ANTHROPIC_BASE_URL` + pointed at the proxy and no key of its own. +- **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely + on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill *every* + session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell + that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after. +- **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account + would leave its home behind under the old address. Pre-existing, unfixed, worth knowing. + +## What the first real run proved, and what it corrected + +Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `useradd`. Every property +below was **observed**, not reasoned about: + +| attempted, as the member | result | +| --- | --- | +| write in own home | OK | +| read `…//attachments/private.txt` | Permission denied | +| `ls …//` (their own account dir) | Permission denied | +| `ls $DATA_PATH` (enumerate the members) | Permission denied | +| `ls …/other-member@example.com/home` | Permission denied | +| `cd $HOME/..` | **succeeds** — see below | + +Three bugs surfaced only by running it: + +1. **`chmod` after `chown` fails forever.** `chmod` requires ownership, so once the home belongs to the + member the service user cannot set its mode. Both orderings fail unprivileged — the first on the second + run, the second immediately. Both operations now go through sudo, which is what makes the function + re-runnable. +2. **A member could read another member's home.** `provisionUserDirs` created directories at the default + umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being + unlistable is not protection when the child is world-readable and the attacker knows an email address. + The skeleton is now created closed — `711` on the account directory, `700` inside — so *unconfined* is + also *unreachable*. +3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is + the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to + start with `OFFICER_OS_USERS` on while any `.env` in the project root is group- or world-readable. + +**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd` +works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real +shell can reach `/etc`, `/usr` and anything else the system leaves world-readable, because that is what a +shell is. So: + +- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`, + enforced by the platform; +- the **terminal** cannot *read* anything above the home, but is not confined to it. Confining it would + mean a namespace or a chroot, which is a different and much larger feature. + +Say "cannot see behind it", not "cannot leave it". + +## Follow-ups this creates + +- **Deleting a member no longer removes their home.** It belongs to their uid, so the platform cannot + remove it — `rm -rf` fails with EPERM, which is how it was noticed. `deleteUserHandler` does not touch + disk today so nothing is broken, but account deletion will need `userdel` and a sudo `rm` to stop + leaving an orphaned, unremovable directory behind. +- **`confineUserTree` sets `DATA_PATH` itself to 711.** If a container ever bind-mounts a path under + `DATA_PATH` and runs as another uid, it will traverse but not list. Nothing does today. + +## Stages + +1. **The account and the mechanism.** `users.os_user`; `ensureOsUser` (useradd + chown + the mode bits + above); `runAs`; the `.env` permission gate; tests including the Bun-ignores-uid pin. **No behaviour + change** — accounts are created and nothing uses them yet. +2. **`getOwnerHomeDir` honours its email argument.** It takes an email and throws it away whenever + `HOME_DIR` is set, which is always on a real install. Seven call sites; this one change repoints the + file browser, chat, tasks, agents and the VNC password file per account. +3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` + + `isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new + security code. +4. **The terminal**, via `setpriv`, plus pty identity. One `execution` capability reopened. +5. **Agents.** Separately, later, with the SDK problem solved first. diff --git a/src/databases/officer_db/src/schema/auth.ts b/src/databases/officer_db/src/schema/auth.ts index 866af299..d5b5f8f4 100644 --- a/src/databases/officer_db/src/schema/auth.ts +++ b/src/databases/officer_db/src/schema/auth.ts @@ -35,6 +35,18 @@ export const users = pgTable( role: text('role', { enum: USER_ROLES }).notNull().default('Member'), name: text('name'), username: text('username').unique(), + /** + * The Linux account this platform account runs as, when per-user OS accounts are enabled. + * + * Stored rather than re-derived from `username`. `useradd` can adjust or refuse a name, and a derived + * value would let the platform's idea of who someone is drift from what is actually in `/etc/passwd` + * — which, for a field that decides whose uid executes a shell, is not a drift to discover later. + * + * NULL means no OS account: every account created before the feature, every account on a host where + * it is switched off, and the owner (who runs as the service user itself). + * See docs/per-user-linux-accounts.md. + */ + osUser: text('os_user').unique(), avatar: text('avatar'), passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), diff --git a/src/server.tsx b/src/server.tsx index 65848d0b..6c652330 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -3,6 +3,7 @@ import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { assertCapabilityTotality } from './servers/capabilities/totality'; +import { assertSecretsClosed } from './servers/os-user'; import { resolveAuthToken } from './servers/auth-token'; import { isWsProviderAllowed } from './servers/capabilities/authorize'; import { isTokenBlacklisted } from 'officerdb'; @@ -149,6 +150,11 @@ assertCapabilityTotality({ wsProviders: Object.keys(handlers), }); +// And, when members have real Linux accounts, that they cannot read the credentials that would make those +// accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable +// JWT_SECRET is worse than no isolation, because the model looks intact. No-op while the feature is off. +await assertSecretsClosed(process.cwd()); + async function upgradeWs( req: Request, server: any, diff --git a/src/servers/api/users/create-user.ts b/src/servers/api/users/create-user.ts index eb9b4967..dea20e22 100644 --- a/src/servers/api/users/create-user.ts +++ b/src/servers/api/users/create-user.ts @@ -1,9 +1,10 @@ import type { Handler } from 'hono'; -import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb'; +import { createUser, updateUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb'; import type { UserRole } from 'officerdb'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { provisionUserDirs } from '@@/data-path'; +import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user'; import { validatePassword } from '../auth/validate-password'; import { validateUsername } from '../auth/validate-username'; import { toPublicUser } from './manage-users'; @@ -85,5 +86,25 @@ export const createUserHandler: Handler = async function (ctx) { console.warn(`[users] created ${email} but could not provision its data directories`, ex); } - return ctx.json({ user: toPublicUser(user) }, 201); + // The Linux account, when the host is set up for it. Same posture as the directories and for the same + // reason: this is a side effect of creating a platform account, and a failed `useradd` must not undo an + // account that otherwise exists and can sign in. The row simply keeps `osUser: null`, which every + // consumer already has to handle — that is what an account made before this feature looks like. + // + // Reported back in the response rather than only logged, so the owner sees "created, but no OS account" + // at the moment they click rather than discovering it when a terminal opens in the wrong home. + let osUser: string | null = null; + let osUserError: string | null = null; + if (OS_USERS_ENABLED) { + const result = await ensureOsUser({ email, username }); + if (result.ok) { + osUser = result.osUser; + await updateUser(user.id, { osUser: result.osUser }); + } else { + osUserError = result.error; + console.warn(`[users] created ${email} but could not create its Linux account: ${result.error}`); + } + } + + return ctx.json({ user: { ...toPublicUser(user), osUser }, osUserError }, 201); }; diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index e4a0fafb..261a053d 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -1,5 +1,5 @@ import { join, resolve } from 'node:path'; -import { mkdirSync } from 'node:fs'; +import { chmodSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); @@ -64,14 +64,30 @@ export const USER_DIRS = [ ] as const; /** - * Create an account's root and its skeleton. Idempotent — an existing directory is left exactly as it is. + * Create an account's root and its skeleton, closed by default. * * Keyed on email because that is what the on-disk layout uses everywhere else (`DATA_PATH//…`). * Renaming an account's email would orphan its directory; that is pre-existing and not this function's * problem, but it is the reason nothing here derives a path from the id. + * + * ── Why the modes are set here and not only by os-user.ts ── + * + * `711` on the account directory, `700` on everything inside it. Measured while testing per-user Linux + * accounts: at the default umask these came out `755`, and a member with a shell could read ANOTHER + * member's home directory just by naming it — the parent being unlistable is not protection when the + * child itself is world-readable. "Locked unless something opens it" has to be the resting state, so it + * belongs at creation rather than in the confinement pass, which only ever runs for accounts that have an + * OS user. + * + * `chmod` explicitly rather than mkdir's `mode`, which is masked by the umask and does nothing at all for + * a directory that already exists. */ export const provisionUserDirs = (email: string): void => { - for (const dir of USER_DIRS) mkdirSync(join(DATA_PATH, email, dir), { recursive: true }); + const accountDir = join(DATA_PATH, email); + for (const dir of USER_DIRS) mkdirSync(join(accountDir, dir), { recursive: true }); + // Traversable, not listable: reaching `home` must not mean enumerating the platform's tree beside it. + chmodSync(accountDir, 0o711); + for (const dir of USER_DIRS) chmodSync(join(accountDir, dir), 0o700); }; export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp'); diff --git a/src/servers/os-user.test.ts b/src/servers/os-user.test.ts new file mode 100644 index 00000000..4048ddb1 --- /dev/null +++ b/src/servers/os-user.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from 'bun:test'; +import { chmod, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { findReadableSecrets, osUserNameFor, runAsArgv, OS_USER_PREFIX } from './os-user'; + +// The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun +// ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database +// and is exercised by hand — see docs/per-user-linux-accounts.md. + +describe('Bun.spawn uid', () => { + // THE pin. `Bun.spawn` accepts `uid`/`gid` at RUNTIME and silently ignores them, so a privilege drop + // written the obvious way runs as the parent while looking correct. This test exists so that: + // + // - nobody "simplifies" runAs into a uid option, and + // - if Bun ever implements it, this fails and tells us we may. + // + // Bun's own types do not declare `uid`, so typed code cannot reach this by accident — hence the cast, + // which stands in for the ways you WOULD reach it: a spread of untyped config, or an `as any` in a + // hurry. The runtime is what silently accepts it, and the runtime is what this pins. + // + // Skipped when running as root, where setuid would actually be permitted and the observation changes. + test.skipIf(process.getuid?.() === 0)('is ignored at runtime — this is why runAs exists', async () => { + const nobody = 65534; + expect(process.getuid?.()).not.toBe(nobody); + + const options = { uid: nobody, gid: nobody, stdout: 'pipe', stderr: 'pipe' } as unknown as { + stdout: 'pipe'; + stderr: 'pipe'; + }; + const proc = Bun.spawn(['id', '-u'], options); + const seen = (await new Response(proc.stdout).text()).trim(); + const code = await proc.exited; + + // If this ever fails, read the assertion rather than fixing it: either the child ran as `nobody` + // (Bun now honours the option) or it refused with EPERM (Bun now tries). Both are good news. + expect(code).toBe(0); + expect(seen).toBe(String(process.getuid?.())); + expect(seen).not.toBe(String(nobody)); + }); +}); + +/** Whether this machine can actually drop privileges. See the sudo note in runAsArgv. */ +const canSudo = await (async () => { + const proc = Bun.spawn(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' }); + return (await proc.exited) === 0; +})(); + +describe('runAsArgv', () => { + test('wraps the command in sudo + setpriv with real ids, groups and a reset environment', () => { + expect(runAsArgv('officer_ana', ['zsh', '-i'])).toEqual([ + 'sudo', + '-n', + 'setpriv', + '--reuid=officer_ana', + '--regid=officer_ana', + '--init-groups', + '--reset-env', + '--', + 'zsh', + '-i', + ]); + }); + + // --init-groups and --reset-env are not decoration: without the first the process keeps the owner's + // supplementary groups, and without the second it inherits everything Bun loaded from .env. + test('never omits --init-groups or --reset-env', () => { + const argv = runAsArgv('officer_ana', ['true']); + expect(argv).toContain('--init-groups'); + expect(argv).toContain('--reset-env'); + }); + + test('refuses an empty user or command rather than running as the owner', () => { + expect(() => runAsArgv('', ['true'])).toThrow(); + expect(() => runAsArgv('officer_ana', [])).toThrow(); + }); + + // Proves the argv composes and runs end to end. Targets the CURRENT account so no test user has to be + // created, which still exercises sudo, setpriv, initgroups and the env reset. + // + // Skipped where passwordless sudo is unavailable — that is a machine that cannot run this feature at + // all, and a red test there would say "the code is broken" instead of "this host is not set up". + test.skipIf(!canSudo)('runs the command as the requested account', async () => { + const me = process.getuid?.() ?? 0; + const proc = Bun.spawn(runAsArgv(String(me), ['id', '-u']), { stdout: 'pipe', stderr: 'pipe' }); + const out = (await new Response(proc.stdout).text()).trim(); + const err = (await new Response(proc.stderr).text()).trim(); + expect(await proc.exited, `setpriv failed: ${err}`).toBe(0); + expect(out).toBe(String(me)); + }); + + // The property the whole feature rests on: the platform's environment does not cross the boundary. This + // process is started by PM2 in the platform directory, so Bun has auto-loaded `.env` into it — the JWT + // secret and POSTGRES_URL are in `process.env` right now. A member's shell must not see them. + test.skipIf(!canSudo)('does not pass the platform environment through', async () => { + const me = process.getuid?.() ?? 0; + const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "[${OFFICER_LEAK_PROBE:-unset}]"']), { + env: { ...process.env, OFFICER_LEAK_PROBE: 'this-must-not-cross' }, + stdout: 'pipe', + stderr: 'pipe', + }); + const out = (await new Response(proc.stdout).text()).trim(); + expect(await proc.exited).toBe(0); + expect(out).toBe('[unset]'); + }); + + // …and HOME is the target account's, not the caller's. This is what makes a member's shell and their + // agent's config land in their own directory rather than the owner's. + test.skipIf(!canSudo)('sets HOME from the target account, not the caller', async () => { + const me = process.getuid?.() ?? 0; + const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "$HOME"']), { stdout: 'pipe', stderr: 'pipe' }); + const out = (await new Response(proc.stdout).text()).trim(); + expect(await proc.exited).toBe(0); + expect(out).toBeTruthy(); + // Read from passwd rather than inherited: `--reset-env` cleared the caller's HOME before setting it. + const passwd = Bun.spawn(['sh', '-c', `getent passwd ${me} | cut -d: -f6`], { stdout: 'pipe' }); + expect(out).toBe((await new Response(passwd.stdout).text()).trim()); + }); +}); + +describe('osUserNameFor', () => { + test('prefixes so it cannot collide with a system account', () => { + expect(osUserNameFor({ username: 'ana', email: 'ana@example.com' })).toBe(`${OS_USER_PREFIX}ana`); + }); + + test('falls back to the email local part when there is no username', () => { + expect(osUserNameFor({ username: null, email: 'Ana.Silva@example.com' })).toBe(`${OS_USER_PREFIX}ana.silva`); + }); + + test('sanitises what useradd would refuse', () => { + expect(osUserNameFor({ username: 'Ana Silva!', email: 'a@b.com' })).toBe(`${OS_USER_PREFIX}ana_silva_`); + }); + + // The prefix can push an already-32-char sanitised name over the limit, and useradd rejects the whole + // name rather than truncating it. + test('stays within the 32-character limit', () => { + const name = osUserNameFor({ username: 'a'.repeat(40), email: 'a@b.com' }); + expect(name.length).toBe(32); + expect(name.startsWith(OS_USER_PREFIX)).toBe(true); + }); + + test('a username that tries to shadow root is still prefixed', () => { + expect(osUserNameFor({ username: 'root', email: 'r@b.com' })).toBe(`${OS_USER_PREFIX}root`); + }); +}); + +describe('findReadableSecrets', () => { + test('reports a group- or world-readable .env, and nothing when it is 600', async () => { + const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-')); + const env = join(dir, '.env'); + await writeFile(env, 'JWT_SECRET=x\n'); + + await chmod(env, 0o644); + expect(await findReadableSecrets(dir)).toEqual([env]); + + // Group-only still counts: a member's supplementary groups are not ours to predict. + await chmod(env, 0o640); + expect(await findReadableSecrets(dir)).toEqual([env]); + + await chmod(env, 0o600); + expect(await findReadableSecrets(dir)).toEqual([]); + }); + + test('an absent .env is not a finding', async () => { + const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-')); + expect(await findReadableSecrets(dir)).toEqual([]); + }); +}); diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts new file mode 100644 index 00000000..3db643e9 --- /dev/null +++ b/src/servers/os-user.ts @@ -0,0 +1,292 @@ +import { chmod, mkdir, readdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path'; + +// Real Linux accounts for members, so the surfaces that execute code can run as them. +// +// Design, prerequisites and the staging plan: docs/per-user-linux-accounts.md. Read it before changing +// anything here — several of the choices below look arbitrary and are not. +// +// ── The one thing to know ── +// +// `Bun.spawn` SILENTLY IGNORES `uid` and `gid`. Verified on bun 1.3.10: from uid 1000, +// `Bun.spawn(['id','-u'], { uid: 65534 })` exits 0 and prints 1000. No throw, no warning. So a privilege +// drop written the obvious way would look like it worked while every member's process ran as the owner — +// an isolation boundary that is silently absent, which is worse than none at all because it is believed. +// +// Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever +// fixed, a test tells us we may simplify, rather than someone assuming it and being wrong. + +/** Off unless explicitly enabled: this needs root, and a light install has no sudoers entry. */ +export const OS_USERS_ENABLED = process.env.OFFICER_OS_USERS === 'true' || process.env.OFFICER_OS_USERS === '1'; + +/** Prefixed so it cannot collide with a system account, and so `/etc/passwd` shows what we created. */ +export const OS_USER_PREFIX = 'officer_'; + +const MAX_USERNAME = 32; + +/** + * The Linux account name for a platform account. + * + * Built on `toShellUsername`, which already lowercases, strips anything from `@` on, replaces illegal + * characters and truncates. Truncated again after the prefix, because the prefix can push a 32-char + * result over the limit and `useradd` would refuse the whole thing. + */ +export function osUserNameFor(params: { username: string | null; email: string }): string { + const base = toShellUsername(params.username ?? '', params.email); + return `${OS_USER_PREFIX}${base}`.slice(0, MAX_USERNAME); +} + +export type RunAsOptions = { + /** Passed through to the wrapped command. `setpriv --reset-env` means nothing else survives. */ + env?: Record; + cwd?: string; +}; + +/** + * The argv that runs `command` as `osUser`. Pure, so the shape is testable without spawning anything. + * + * sudo -n REQUIRED, and not merely for the uid. Measured 2026-08-11: `--init-groups` fails + * with "initgroups failed: Operation not permitted" for an unprivileged caller even + * when reuid'ing to its OWN account — setgroups(2) is root-only, full stop. So this + * cannot be done without privilege, and `-n` makes a missing sudoers entry an + * immediate error instead of a process blocking on a password prompt nobody will see. + * --reuid/--regid the REAL ids, not merely effective — there is nothing to switch back to. + * --init-groups apply the account's supplementary groups. Without it the process keeps the OWNER'S + * groups, which quietly retains access we just took away. + * --reset-env drop the inherited environment, then set HOME/SHELL/USER/LOGNAME/PATH from the + * target's passwd entry. Both halves matter: the parent's env carries the owner's HOME + * and — in a PM2 process started in the platform directory — everything Bun auto-loaded + * from `.env`. Verified: `POSTGRES_URL` is unset on the far side, and HOME arrives as + * the member's own. + * + * `sudo -u ` alone would also work and would be shorter. It is not used because its environment + * handling is sudoers policy (`env_reset`, `env_keep`, `always_set_home`) rather than something this file + * states — and "which variables cross into a member's shell" is exactly the question that must not depend + * on a config file somebody may have edited. + */ +export function runAsArgv(osUser: string, command: string[]): string[] { + if (!osUser) throw new Error('runAsArgv: no OS user'); + if (!command.length) throw new Error('runAsArgv: empty command'); + return [ + 'sudo', + '-n', + 'setpriv', + `--reuid=${osUser}`, + `--regid=${osUser}`, + '--init-groups', + '--reset-env', + '--', + ...command, + ]; +} + +/** + * Run a command as another Linux account. + * + * Deliberately does NOT accept a `uid` option. The only supported way to change user in this codebase is + * this function, precisely because the option that looks like it would work does nothing. + */ +export function runAs(osUser: string, command: string[], options: RunAsOptions = {}) { + return Bun.spawn(runAsArgv(osUser, command), { + cwd: options.cwd, + // Reaches sudo and setpriv, NOT the command — `--reset-env` clears it on the way through. Anything + // the command needs beyond the passwd-derived HOME/SHELL/USER/LOGNAME/PATH has to be stated inside + // `command` itself (`env FOO=bar cmd …`). That asymmetry is deliberate: it means a variable can only + // cross into a member's process because someone wrote it there. + env: options.env, + stdout: 'pipe', + stderr: 'pipe', + }); +} + +async function run(command: string[]): Promise<{ ok: boolean; out: string }> { + const proc = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' }); + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const code = await proc.exited; + return { ok: code === 0, out: `${out}${err}`.trim() }; +} + +/** uid/gid from the passwd database, or null when the account does not exist. */ +export async function lookupOsUser(osUser: string): Promise<{ uid: number; gid: number } | null> { + const uid = await run(['id', '-u', osUser]); + if (!uid.ok) return null; + const gid = await run(['id', '-g', osUser]); + if (!gid.ok) return null; + return { uid: Number(uid.out), gid: Number(gid.out) }; +} + +/** The member's home: `DATA_PATH//home`, where `provisionUserDirs` already put it. */ +export const osUserHome = (email: string): string => join(DATA_PATH, email, 'home'); + +export type EnsureOsUserResult = + | { ok: true; osUser: string; uid: number; gid: number; created: boolean } + | { ok: false; error: string }; + +/** + * Create the Linux account if it does not exist, then place the ownership and mode bits. + * + * Idempotent in both halves: an existing account is adopted rather than recreated, and the modes are + * re-applied every time, so a directory the platform added later is confined without needing a + * migration. + * + * Never throws. Account creation is a side effect of creating a platform account, and a `useradd` that + * failed must not leave a half-made user behind — the caller records the error and the platform account + * simply has no OS account yet. + */ +export async function ensureOsUser(params: { email: string; username: string | null }): Promise { + const osUser = osUserNameFor(params); + const home = osUserHome(params.email); + + let created = false; + let ids = await lookupOsUser(osUser); + + if (!ids) { + // `-M` because provisionUserDirs already made the directory, and letting useradd create it would + // copy /etc/skel in as root-owned. `-s` explicitly: /etc/default/useradd here says /bin/sh, and a + // member opening a terminal should get the same shell everyone else gets. + const create = await run([ + 'sudo', + '-n', + 'useradd', + '--home-dir', + home, + '-M', + '--shell', + process.env.SHELL ?? '/bin/bash', + osUser, + ]); + if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` }; + created = true; + ids = await lookupOsUser(osUser); + if (!ids) return { ok: false, error: `useradd reported success but ${osUser} is not in passwd` }; + } + + const confined = await confineUserTree({ email: params.email, uid: ids.uid, gid: ids.gid }); + if (!confined.ok) return { ok: false, error: confined.error }; + + return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created }; +} + +/** + * Place the mode bits described in docs/per-user-linux-accounts.md § "The layout". + * + * DATA_PATH 711 service user traverse only — a member cannot enumerate the members + * DATA_PATH/ 711 service user traverse only — a member cannot list their OWN siblings + * …/home 700 the member their home + * every sibling 700 service user platform-written, unreachable even by name + * + * 711 on the account directory is the load-bearing one. The member needs `x` to reach `home`, and must + * not have `r`, or `ls` would show them the platform's private tree beside it. Because every sibling is + * 700 and owned by the service user, guessing a name gains nothing either. + * + * `chown` on the home is done with sudo: the service user owns the directory but cannot give it away — + * `chown` to another user is a root-only operation on Linux regardless of who owns the file. + */ +export async function confineUserTree(params: { + email: string; + uid: number; + gid: number; +}): Promise<{ ok: true } | { ok: false; error: string }> { + const accountDir = join(DATA_PATH, params.email); + const home = join(accountDir, 'home'); + + try { + if (!existsSync(home)) await mkdir(home, { recursive: true }); + + // Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the + // directory and learn every other member's email address. + await chmod(DATA_PATH, 0o711); + await chmod(accountDir, 0o711); + + // Every sibling of `home` is the platform's. 700 means traversal alone does not open them. + const entries = await readdir(accountDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === 'home') continue; + if (!entry.isDirectory()) continue; + await chmod(join(accountDir, entry.name), 0o700); + } + // And any of the standard set that does not exist yet, so a directory created later starts confined + // rather than at the process umask. + for (const dir of USER_DIRS) { + if (dir === 'home') continue; + const path = join(accountDir, dir); + if (!existsSync(path)) await mkdir(path, { recursive: true, mode: 0o700 }); + } + + // The home goes through sudo for BOTH operations, and that is the only form that is idempotent. + // `chmod` requires ownership, so: + // - chmod then chown, unprivileged: works once, then fails EPERM forever after, because the home now + // belongs to the member. Re-running an install would report failure on a correct tree. + // - chown then chmod, unprivileged: fails immediately, for the same reason. + // Both were observed. Root does not care about either ordering, so both go through sudo and the + // function can be run any number of times. + const give = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, home]); + if (!give.ok) return { ok: false, error: `chown of ${home} failed: ${give.out}` }; + const close = await run(['sudo', '-n', 'chmod', '700', home]); + if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` }; + + return { ok: true }; + } catch (ex) { + return { ok: false, error: ex instanceof Error ? ex.message : String(ex) }; + } +} + +/** + * Refuse to enable OS users while a secret in the project tree is readable by them. + * + * `platform/.env` was 664 on this machine when this was written — world-readable, holding the JWT signing + * secret and `POSTGRES_URL`. A member with a shell could read it and mint an owner token, which would + * leave the capability model intact and entirely bypassed. + * + * Checked at boot rather than documented, because a prerequisite that is only written down is one that + * gets skipped. Returns the offending paths; the caller decides whether that is fatal. + */ +export async function findReadableSecrets(projectDir: string): Promise { + const candidates = ['.env', '.env.local', '.env.production']; + const bad: string[] = []; + for (const name of candidates) { + const path = join(projectDir, name); + if (!existsSync(path)) continue; + try { + const info = await stat(path); + // Anything readable by group or other. 0o044 covers both read bits. + if (info.mode & 0o044) bad.push(path); + } catch { + // Unreadable to us is not a leak to them; nothing to report. + } + } + return bad; +} + +/** + * Refuse to boot with OS users enabled while a secret in the project tree is readable by them. + * + * Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that + * silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a + * member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and + * bypass every capability check in the codebase. + * + * A no-op when the feature is off, so an existing install is unaffected until the owner opts in. + */ +export async function assertSecretsClosed(projectDir: string): Promise { + if (!OS_USERS_ENABLED) return; + const readable = await findReadableSecrets(projectDir); + if (!readable.length) return; + + throw new Error( + [ + 'OFFICER_OS_USERS is enabled, but these files are readable by other accounts on this machine:', + '', + ...readable.map((p) => ` • ${p}`), + '', + 'A member with a shell can read them. JWT_SECRET alone is enough to mint an owner token, which', + 'bypasses every capability check. Fix with:', + '', + ...readable.map((p) => ` chmod 600 ${p}`), + '', + 'Then restart. See docs/per-user-linux-accounts.md → "Hard prerequisite".', + ].join('\n'), + ); +}