Audience: Kernel hackers, shell-command authors, Win32 thunk authors
Execution context: Kernel — broker runs in the calling task's context, prompts under the compositor lock
Maturity: v0 — broker + CLI prompt + grace cache shipping; GUI overlay path reserved; Argon2id alongside PBKDF2; persistence pending
Before this slice: every privileged action is either "your AuthRole is
Admin (allow)" or "it isn't (DENY)." There is no path between those — a
non-admin who needs to install a package must log out, log in as admin,
run the command, log out, log back in. That friction is the same
friction that makes desktop Linux feel hostile, and it produces the
exact UX pressure that gets users to disable security entirely.
This page documents the elevation broker (the in-session "prompt me for
my password to do this one thing") and the RBAC roles model (named
bundles of kCap* bits — what each role gets by default, what each
role can elevate to).
Pre-existing — DO NOT confuse:
AuthRole(kernel/security/auth.h) is a fixed 3-level enum:Guest/User/Admin. Decided at login. The account row carries it. Determines who can log in and what their baseline shell command set is.- Process capability authority (
kernel/proc/process.{h,cpp}) combines durableCapSetbits, temporary broker leases, and a monotonic ceiling. The cap gate reads an effective snapshot on every privileged syscall.
This slice adds:
Role(kernel/security/rbac.h) — a named bundle ofkCap*bits with an optional per-cap grace duration override. A row in the role table, not a field on the account. AnAuthRole::Usercan be a member of multipleRoles; the broker decides which one to elevate to based on the cap requested.
| Concept | Owned by | Set when? | Used for? |
|---|---|---|---|
AuthRole |
account row | account create | who can log in, baseline shell auth |
| capability authority | Process struct |
spawn + broker/voluntary drop | every privileged syscall |
Role |
global role table | role registration | broker: "which caps does X grant?" |
- Identity model: multi-user-with-named-roles data model ships now; UX defaults to single-user (one account, no login prompt past first boot). Multi-user is a config flip. Avoids the cost of retrofitting uid/role fields later.
- Grace leases: per-process, 5 min default, per-cap override
in role policy. The Process deadline is authoritative; the fixed
grace table stores prompt-suppression metadata only. A zero duration
cannot back ambient authority and fails closed with
NoLease;kCapFsWrite = 30 minis the canonical long-grace example. - CLI trusted path: reuse
LoginFeedKey. AddLoginMode::ElevatealongsideTty/Gui— kernel-trusted keystroke routing already exists, no new input path needed. - GUI trusted path: small modal drawn under the compositor lock.
Same discipline as
LoginStart(compositor lock + framebuffer primitives). No Secure Attention Key yet — reserved as future work when a real attack model demands it (see Future work below). - Win32 UAC mapping:
NtAdjustPrivilegesTokentranslates mapped privileges into kernel operations. Disable clears live authority,SE_PRIVILEGE_REMOVEDpermanently lowers the Process ceiling, and enable-on-miss requests a positive-duration broker lease. ACL and integrity-level probes remain cosmetic facades. - Password hashing: Argon2id (memory-hard, 64 MiB / t=3 / p=1) alongside PBKDF2. New password sets use Argon2id; PBKDF2 records migrate lazily on next successful verify.
- Per-binary always-allow ("never ask for
git pushagain"): NO. Only per-cap grace duration is overridable. Forever-allow is what a role grants by default, not a one-off knob.
shell `elevate FsWrite` or mapped Win32 token enable
→ BrokerRequestElevation(proc, kCapFsWrite)
├─ ceiling missing bit → CeilingDenied
├─ effective Process snapshot already has bit → Granted
├─ live cache row → re-publish its original Process lease
├─ role policy denies → Denied
├─ zero grace / no monotonic clock → NoLease
├─ trusted password prompt fails → Cancelled / BadPassword
└─ prompt succeeds
├─ install generation-tagged Process lease with absolute deadline
├─ publish prompt-suppression cache metadata
└─ Granted
→ next privileged syscall consumes ProcessCapsSnapshot
An ordinary syscall denial does not open an ambient prompt. The caller must use an explicit trusted elevation surface first.
| Surface | State |
|---|---|
| Broker prompt loop (TTY) | REAL — reads from Ps2KeyboardReadEvent directly |
| Broker prompt loop (GUI) | REAL — kernel-drawn modal under the compositor lock |
| Grace leases | REAL — Process deadline authority + metadata-only fixed cache |
| Role table + memberships | REAL — in-memory; admin→root, guest→sandbox seeded |
elevate <cap> |
REAL — single-cap prompt + grant |
elevate role <name> |
REAL — one prompt, grants every cap in the role bundle |
elevate off |
REAL — drops every active grant on the shell pseudo-process |
roles / roles me |
REAL — list all roles / list the active user's memberships |
roleadd / roledel |
REAL — admin-gated membership management |
elevations |
REAL — dump live grace-cache rows |
RequireAdmin integration |
REAL — composes on top of RequireCap(kCapFsWrite) |
RequireCap(cap, cmd) per-cap gating |
REAL — fine-grained gate at shell sites |
Win32 NtAdjustPrivilegesToken routing |
REAL — enable-but-not-held routes to broker via deferred prompt |
| Blake2b primitive | REAL — RFC 7693, KAT-verified at boot (foundation for Argon2id) |
| Argon2id KDF | REAL — RFC 9106 §5.3 KAT-verified at boot; V2 default for new pw |
| Lazy V1→V2 (PBKDF2→Argon2id) migration | REAL — fires on AuthVerify success; boot self-test pins behaviour |
| ChaCha20-Poly1305 AEAD | REAL — RFC 8439 §2.8.2 KAT-verified at boot |
DuetSecretsFile envelope |
REAL — security/persistence.{h,cpp}, round-trip + tamper KATs |
| Auth snapshot (export / import) | REAL — security/auth.{h,cpp}::Auth{Export,Import}Snapshot |
| RBAC snapshot (export / import) | REAL — security/rbac.{h,cpp}::Rbac{Export,Import}Snapshot |
On-disk persistence (/system/secrets/) |
DEFERRED — needs writable system FS slice |
RequireCap(cap, cmd_name) is the fine-grained admin gate. It passes
when:
- The active session is
AuthRole::Admin(admin holds every cap implicitly), OR - The shell pseudo-process holds the specific
capin its effective Process snapshot — i.e. the user ranelevate <cap>and its positive-duration lease has not expired.
A non-admin netop who runs elevate NetAdmin can now run
firewall and fwpolicy (gated on kCapNetAdmin) without being
root. The same netop running elevate FsWrite does NOT unlock
firewall — the cap they elevated for is different from the cap the
command requires. Each command picks the most appropriate cap; the
default RequireAdmin composes on top of RequireCap(kCapFsWrite)
for sites that don't yet have a more specific mapping.
On denial, RequireCap consults the role table for the active
user and, if their roles WOULD grant the missing cap, prints a
hint telling them which elevate <cap> to run. Without this hint
non-discoverable UX trapped users in "denied" loops.
Migrated sites:
firewall/fwpolicy→kCapNetAdminguard mode advisory|enforce|off→kCapDebug- Everything else still goes through
RequireAdmin→kCapFsWrite. Sites can migrate one at a time as their natural cap becomes clear.
Ps2KeyboardReadEvent is single-consumer by contract — concurrent
readers race for bytes. A shell-driven elevate works because the
shell IS the kbd-reader thread, so the inline prompt loop in
BrokerRequestElevation is safe. A Win32 PE syscall runs in a
different task and would race the shell.
The broker resolves this by picking the path at call time:
BrokerSetKbdReaderTid(tid)records the kbd-reader's TaskId at bring-up (kernel/core/main.cppafterSchedCreate).RunPromptchecksCurrentTaskId() == g_kbd_reader_tid. Match → inline TTY/GUI prompt (same as v0). Mismatch → deferred path.- The deferred path posts a request to a single-slot global
DeferredSlot, injects a synthetickKeyNoneevent to wake the kbd reader, and blocks on aWaitQueue. - The kbd-reader loop calls
BrokerKbdReaderPumpDeferred()at the top of every iteration. On a pending slot it runs the prompt UI (safe — the kbd reader IS the legalPs2KeyboardReadEventconsumer), stores the password in the slot, setscompleted, and wakes the waiter.
State is guarded by arch::Cli/Sti only (no spinlock), mirroring
the existing Process::StdinRing discipline. Single-flight for v0:
a second concurrent deferred request returns false immediately
and the caller falls through to the legacy denial branch
(NOT_ALL_ASSIGNED for Win32 callers).
| File | Owns |
|---|---|
kernel/security/rbac.h |
Role, RolePolicy, RoleId, registry API |
kernel/security/rbac.cpp |
Built-in role definitions, lookup |
kernel/security/broker.h |
BrokerRequest, BrokerOutcome, prompt hooks |
kernel/security/broker.cpp |
Cache + role check + prompt orchestration |
kernel/security/grace.h |
Metadata lookup / lease publish / metadata expiry |
kernel/security/grace.cpp |
Fixed-size prompt-suppression metadata table |
kernel/proc/process.{h,cpp} |
Locked durable caps, leases, deadlines, ceiling |
kernel/security/login.{h,cpp} |
extended with LoginMode::Elevate |
kernel/syscall/cap_gate.cpp |
Effective-snapshot syscall enforcement |
kernel/security/password_hash.* |
extended with Argon2id variant |
kernel/shell/shell_security.cpp |
extended with elevate / roles commands |
| Role | Cap bundle | Grace override |
|---|---|---|
root |
every kCap* bit |
kCapNetAdmin = 30 sec |
developer |
FsRead, FsWrite, SpawnThread, Debug, SerialConsole, Input | kCapFsWrite = 30 min |
netop |
Net, NetAdmin, FsRead | kCapNetAdmin = 30 sec |
auditor |
FsRead, SerialConsole, Input | default 5 min |
sandbox |
none — explicit deny role for untrusted PEs | n/a |
The role table is in-memory v0; persistence is a follow-up tied to a
writable system filesystem (see Persistence below). Adding a role
at runtime: RoleRegister(name, cap_mask, grace_overrides) —
admin-only via shell.
kCapNetAdmin uses a deliberately short positive lease for the
built-in root and netop roles. A zero-duration override is reserved
for policies that intentionally deny ambient elevation; it cannot
authorize even a momentary capability because the broker fails closed
rather than publishing an immediately expired lease.
The CLI prompt is safe by construction because the keyboard reader
demultiplexes keystrokes at the input ring level
(kernel/core/main.cpp kbd-reader loop). When LoginIsActive()
returns true (because the broker called LoginStartElevate()), every
keystroke routes through LoginFeedKey directly. No user-mode process
sees the password — the path is keyboard driver → input ring → reader
thread → login gate (in-kernel). The same path the boot login uses.
A malicious PE could draw a fake prompt to the framebuffer, but nothing it does can read the keystrokes when the gate is active. Worst case: the PE prints "Enter your password" and waits forever for input that never arrives, because the kernel ate the bytes.
The GUI elevation modal is drawn by the broker under the compositor
lock, same discipline as the boot login screen. Other windows cannot
paint over it (the broker raises a kElevationOverlay z-layer above
every app window). Keystrokes still demultiplex through LoginFeedKey.
A future hardening: bind a Secure Attention Key (Ctrl+Alt+Del at the PS/2 driver level) that always shows the kernel-drawn broker prompt, so a paranoid user can force a known-good prompt rather than trusting that the current one is the broker's. The keycode and the broker-prompt syscall shape are reserved now so the future SAK implementation has the seams to plug into.
userland/libs/ntdll/:
NtAdjustPrivilegesToken— maps supported LUIDs tokCap*. Disable clears live durable/leased bits,SE_PRIVILEGE_REMOVEDlowers the monotonic ceiling, and enable-on-miss calls the broker. PreviousState reports the pre-request effective snapshot.RtlAdjustPrivilege— same.OpenProcessToken/LookupPrivilegeValue— cosmetic facade, no broker call. They return believable handles; the actual gate fires at the next privileged NT syscall.
A mapped token-enable request triggers a real broker prompt drawn by the broker (not the PE's UI), with the calling account's password. If granted, the calling Process receives a temporary lease. Cache eviction does not revoke it; the Process deadline does. Child processes inherit durable caps and the ceiling, never lease bits.
Out of scope for this slice — gated on a writable system filesystem.
Today the role table and account table both seed from
AuthInit() / RbacInit() at boot. A follow-up will add
/system/secrets/ (encrypted at rest, TPM-sealed once the TPM driver
lands) and an installer-driven first-boot flow that replaces the
hardcoded seeding.
Tracked as a GAP marker in kernel/security/rbac.cpp and a row in
wiki/reference/Roadmap.md.
password_hash.h grows a tagged-union record:
struct PasswordHashRecord {
enum Kind : u8 { Pbkdf2 = 0, Argon2id = 1 } kind;
union {
Pbkdf2Record pbkdf2;
Argon2idRecord argon2id;
};
};
AuthAddUser,AuthChangePasswordalways write Argon2id.AuthVerifyreads the kind tag and runs the matching KDF. On a successful PBKDF2 verify, it re-hashes the supplied plaintext with Argon2id and overwrites the record in place (lazy migration).- Wall-clock uniformity is preserved: both code paths run a full derivation; the verify wall-clock no longer reveals which KDF a given account holds because both timings are within the password-derivation envelope the existing decoy path already smooths.
- Role + membership tables are in-memory only —
RbacInitre-seeds the built-in roles and memberships on every boot, so any runtimeroleadd/roledel/RoleRegisterchange is lost on reboot (rbac.cpp:302). Carries aFIX_NOTE_GAPmarker so the fix journal flags it at runtime. Persistence is blocked on a writable system FS + the/system/secrets/layout (see Persistence above) and tracked inwiki/reference/Roadmap.md. - On-disk persistence (
/system/secrets/) is deferred — the encrypted-at-rest, TPM-sealed envelope and the installer-driven first-boot flow that replaces hardcoded seeding both wait on the same writable-system-FS slice.