← Blog

Multi-User Identity: Capability-Backed Sessions in sigilOS

June 22, 2026 · sigil-os · Sigil-Docs
security identity capabilities multi-user 0.6.0

sigilOS closes its identity seam with the landing of identity.sg (sigil-os#13). A 16-slot user table, pid-gated session tokens, a sudo-equivalent elevation gate backed by a broker-held token that EL0 cannot forge, and full POSIX rwx access checks are all now in place. Multi-user sigilOS is real.

Authority/permissions panel in Lumen
Authority panel in Lumen — capability grants visible per session

Memory layout

Two tables sit in a fixed region of the OS address space, above the FS tag state at 0xA20000 and below the IPC ring at 0xA60000:

0xA50000
User table — 16 slots × 64B. Fields: uid, gid, home_off, flags, elev_cap.
0xA51000
Session table — 16 slots × 64B. Fields: sess_uid, sess_gid, pid, state, cap_token.

Root (uid=0) and guest (uid=1) are pre-installed by id_user_init() at boot. The session table starts empty; slots are allocated as processes start.


Sessions and the pid gate

id_sess_alloc(uid, pid) finds a free slot in the session table and writes the uid, gid (from the user record), pid, and a freshly-allocated capability token into it. From that point on, every session operation requires the caller to present the same pid via id_sess_verify(slot, pid). A mismatch returns -1 immediately — no state is read, no capability is checked.

This pid-gate is the same structural pattern as capability gates everywhere else in sigilOS: the check is the first instruction in the dispatch path, not an afterthought added at the end. A process that doesn't hold the right pid cannot read another session's uid, gid, or cap_token — even if it somehow learns the slot number.

The capability token stored in each session slot is the handle the broker passes to the FS tag-query layer (syscalls 91–102) to scope file access. When the kernel injects the session slot on tag-query calls, it reads the cap_token here and presents it to the FS — EL0 never handles the raw token.


Elevation: a capability-backed sudo

id_elevate(slot, pid, elev_token) is the privilege escalation gate. To elevate a session to uid=0 (root), the caller must present:

The elevation token is held by the OS broker, not EL0. An EL0 process cannot read the broker's memory — the capability boundary prevents it. So the only way for a process to elevate is to ask the broker, which can apply its own policy (PIN prompt, biometric, time-of-day gating) before presenting the token on the caller's behalf.

This is materially different from a traditional setuid binary. With setuid, any process that can execute the binary gets root. With id_elevate, elevation requires the broker to actively vouch for the caller — the token is the proof. If the broker never hands it over, elevation never happens, regardless of what EL0 tries.

id_elevate(slot, pid, 0xDEADBEEF)  → -1   # wrong token
id_elevate(slot, 9999, ID_ELEV_TOKEN) → -1  # wrong pid
id_elevate(slot, pid, ID_ELEV_TOKEN)  → 0   # elevation granted, sess_uid=0

POSIX rwx access checks

id_access(mode, fuid, fgid, uid, gid) implements the standard UNIX permission check. The mode is a 9-bit octal value (owner/group/other × read/write/exec). The function decodes it without bitwise shifts — all arithmetic — to stay within the Sigil integer model:

owner = (mode / 64) % 8
group = (mode / 8)  % 8
other =  mode       % 8

The decision tree:

  1. uid == 0 → return 1 (root bypasses all permission checks)
  2. uid == fuid → check owner bits
  3. gid == fgid → check group bits
  4. else → check other bits

The FS mode/owner/group metadata that feeds this check was already landed in sigil-fs#2 (every MFT record carries uid, gid, mode). The identity seam completes the circuit: the FS has the metadata, the OS now has the check, and the kernel injects the session's uid/gid on every syscall so EL0 cannot supply a forged identity.


Test coverage

AssertionWhat it checksResult
initroot uid=0, guest uid=1 pre-installedPASS
alloc/verifyid_sess_alloc → slot; id_sess_verify correct pidPASS
uid/gidsession carries correct uid/gid from user recordPASS
dual-sessiontwo sessions don't share slots; each verifies independentlyPASS
wrong tokenid_elevate with bad token → -1, uid unchangedPASS
elevationid_elevate with ID_ELEV_TOKEN → sess_uid=0PASS
access ownerowner rwx bits checked correctlyPASS
access othernon-owner/non-group falls through to other bitsPASS
root bypassuid=0 → access=1 regardless of modePASS
close lifecycleid_sess_close frees slot; verify after close → -1PASS
invalid uidid_sess_alloc with uid≥16 → -1PASS

17 assertions total. QEMU raspi3b PASS.