diff --git a/docs/os-development/smep-smap.md b/docs/os-development/smep-smap.md index 7c7a8e0..3c35908 100644 --- a/docs/os-development/smep-smap.md +++ b/docs/os-development/smep-smap.md @@ -52,26 +52,38 @@ The design is closer than it looks, because the IPC layer was built right: - **CR4 today:** the BSP inherits firmware CR4 (no kernel write anywhere); APs set PAE/OSFXSR/OSXMMEXCPT in `trampoline.s:62-68`. Neither path sets SMEP/SMAP yet, and both must. -- **The stragglers.** A handful of syscalls still dereference user pointers - raw after a bounds check — every one is a SMAP #PF waiting to happen, and +- **The stragglers.** Nine syscalls still dereference user pointers raw + after a bounds check — every one is a SMAP #PF waiting to happen, and every one is *already* a latent kernel fault today (an unmapped-but-in- - range user page oopses the kernel instead of failing the call). The audit - list, from the 2026-07-31 survey of `system/kernel/process.zig`: + range user page oopses the kernel instead of failing the call). The + verified sweep of `system/kernel/process.zig` (2026-07-31; a + whole-kernel `@ptrFromInt` audit found no user-address dereference + outside this file): - | Syscall | Raw access | - |---|---| - | `system_spawn` | name + argument blob (`:972`, `:980`) | - | `fs_resolve` | path in, result out (`:1780`, `:1797`) | - | `fs_mount` / `fs_unmount` | prefix + rewrite strings (`:1864`) | - | `fs_node` | read buffer out (`:1820`) | - | `debug_write` | message bytes (`:1700`) | - | `klog_read` | log bytes out (`:1740`) | - | `process_enumerate` | descriptor array out (`:1132`) | - | `device_enumerate` | descriptor array out (`:388`) | + | Syscall | Raw access | Direction | + |---|---|---| + | `system_spawn` | name + argument blob (`:972`, `:980`) | read | + | `fs_resolve` | path in (`:1780`), result out (`:1797`) | read + write | + | `fs_mount` | prefix + rewrite strings (`:1864`, `:1865`) | read | + | `fs_unmount` | prefix string (`:1883`) | read | + | `fs_node` | read buffer out (`:1820`) | write | + | `debug_write` | message bytes (`:1700`; read twice — memcpy `:1710` and `log.append` `:1717`) | read | + | `klog_read` | log bytes out (`:1741`) | write | + | `klog_status` | status struct out (`:1758`) | write | + | `process_enumerate` | descriptor array out (`:1132`) | write | + | `device_enumerate` | descriptor array out (`:388`) | write | + For the write-direction rows the `@ptrFromInt` is in process.zig but the + stores happen in callees (`scheduler.enumerate` + `system/kernel/scheduler.zig:1209`, `devices_broker.enumerate` + `devices-broker.zig:136`, `log.readAt` `log.zig:209`, the vfs node calls + `vfs.zig:257/269/289`) — converting them means bounce buffers plus + `copyToUser` around those calls, not just editing the process.zig lines. (Some paths already do it right — the futex word and the device-register descriptor go through `copyFromUser` (`:1087`, `:924`). The write - direction has no helper yet.) + direction has no public helper yet, but the mechanism exists: + `copyAcross` with a kernel source is exactly how IPC replies reach user + buffers, so `copyToUser` is a mechanical mirror.) - **One known gap inside the copy layer itself:** the walk checks presence, not the leaf U/S and writable bits (`ipc-synchronous.zig:20-22` flags @@ -85,7 +97,7 @@ The design is closer than it looks, because the IPC layer was built right: **H1 — copy discipline (the real work).** A `user-memory` kernel module: `copyFromUser` / `copyToUser` (the missing write direction) via the physmap walk, with U/S and writable leaf checks closing the in-tree TODO. Convert -the eight stragglers. This fixes the latent unmapped-page kernel fault on +the nine stragglers. This fixes the latent unmapped-page kernel fault on its own — it is worth doing even if SMEP/SMAP never shipped. QEMU suite green; no behavior change visible to correct programs. diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md new file mode 100644 index 0000000..ef5555a --- /dev/null +++ b/docs/security-track-plan.md @@ -0,0 +1,394 @@ +# Security track execution plan: paths, protocol namespace, SMEP/SMAP + +The design is settled in +[communication.md](os-development/communication.md), +[protocol-namespace.md](os-development/protocol-namespace.md), +[file-system-hierarchy.md](file-system-development/file-system-hierarchy.md), +and [smep-smap.md](os-development/smep-smap.md). This file is the build order +— one phase at a time, each phase green before the next starts. Delete or +archive this file when the last milestone lands. + +**Context a fresh session should read first:** the four design docs above, +then this plan's *Settled decisions* section — those decisions came out of a +full-code grounding pass (2026-07-31) and must not be re-derived or reopened. + +**Definition of green, every phase:** `zig build` clean, `zig build test` +clean, `python3 test/qemu_test.py` passes (existing scenarios plus the +phase's new ones — record the suite count in the checkbox), and the relevant +design doc's status/known-gap lines updated in the same commit. Commit per +green phase, style `area: lower-case declarative summary`, **no co-author +trailers**. On a suite failure, read +`zig-out/qemu-test/-failed-serial.log` before changing anything. + +**Workflow:** work in a dedicated git worktree on feature branches cut from +`main` (one branch per milestone group as marked below); when a group's +phases are all green, merge to `main` and push. The loop marks a phase `[x]` +in the same commit that lands it. + +**Numbering note:** milestones use the design docs' own names (PM, H1–H3, +HS, P1–P4) — the M-number sequence is left alone (M19–M22 are reserved by +the logging/USB-lifecycle track). + +## Status + +- [ ] **Phase 0** — baseline: suite green on `main` (expect 106/106), plan committed +- [ ] **PM** — path-migration flag-day (`/etc`→`/system/configuration`, `/var/log`→`/system/logs`, `/mnt/usb`→`/volumes/usb`) +- [ ] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks +- [ ] **merge** group 1 → main, push +- [ ] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` +- [ ] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups) +- [ ] **P3** — open grants: `protocol.csv` enforcement, denial test +- [ ] **merge** group 2 → main, push +- [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input) +- [ ] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer) +- [ ] **P4c** — harness subscriber lift + badge-scoped per-client integers +- [ ] **merge** group 3 → main, push +- [ ] **H2** — SMEP on every core +- [ ] **HS** — SYSRET canonical-RIP guard +- [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests +- [ ] **merge** group 4 → main, push + +--- + +## Settled decisions (grounding pass, 2026-07-31 — do not reopen) + +These resolve every open wrinkle the code inventory surfaced. Where one +amends a design doc, the amendment lands in the same commit as the phase +that implements it. + +1. **Every packet — request, reply, and event — begins with the envelope + `Header`, exactly as the design says; the header is FOLDED, never + stacked.** It absorbs each protocol's existing operation/id fields + rather than sitting on top of them, so the two apparent 64-byte-limit + offenders fit: `ChildAdded` re-lays to 60 bytes (its packed operation + byte and `device_id` become `Header.operation`/`.target`); + `InterruptReport` puts `device_token` in `Header.target` and trims + inline data 48 → 40 bytes (largest real report today is 8). A + headerless-events variant was considered and REJECTED (2026-07-31): it + re-invents per-protocol mini-headers and breaks uniform tooling. No + design-doc amendment; `Define`'s event check stays ≤ 64 *including* + the header. +2. **Bind/open authorization is chain-attested identity: the + kernel-stamped binary name PLUS the supervision chain**, both read from + the kernel's process records (`ProcessDescriptor` carries `name` and + `supervisor`; init walks the chain with `process_enumerate` — no new + protocol). A grant row names the binary *and* the supervisor expected + in its chain, so a malicious process re-spawning a granted binary + (ungated `spawn`, hostile argv — the confused deputy) is refused: its + chain roots at the attacker, not at init or device-manager. Name alone + is NOT sufficient — that was considered and rejected (2026-07-31). + Pure delegation (device-manager forwarding driver binds as + capabilities — "option B") is deliberately deferred to P5, whose + spawner-wired namespaces subsume it. Amends protocol-namespace.md's + "Authorization" bullet in P2. +3. **Grants live in a new manifest, `/system/configuration/protocol.csv`** + (rows: `binary-path, supervisor, bind|open, protocol-name`, where + `supervisor` is the binary expected in the caller's supervision chain — + `init` for init's own children, `kernel` for harness-spawned fixtures), + not in extra init.csv columns — today every post-path init.csv field is + argv, and overloading that is ambiguous. init parses both files. +4. **Test fixtures bind under `/protocol/test/...`**, granted to any + binary whose path starts `/test/` — the subtree-scoping rule from the + design doc, dogfooded. `shared_memory_test` (the borrowed-ServiceId + hack) becomes `/protocol/test/shared-memory`; process-test's child gets + `/protocol/test/process`. +5. **Rebind after provider death:** a `bind` hitting an existing binding + succeeds only if the current owner process is dead (init checks + liveness); otherwise `-EBUSY`. Init also unbinds in `restartChild` + before respawning its own children. This preserves collision-refusal + while making restart work for providers init does not supervise. +6. **Cross-thread service access** (the display mouse-listener's + per-thread self-lookup, `display.zig:512`): threads resolve and open + `/protocol/` like any client — once, at thread startup. No + special mechanism. +7. **The envelope module is `library/protocol/envelope/envelope.zig`** + (module name `envelope`) — the one protocol-package module not ending + in `-protocol`, because it is not a protocol. Wired as a new + `addModule` row in `library/protocol/build.zig` with its host tests in + that package's test step. +8. **The QEMU harness gains `-cpu max`** (in `qemu_args`, + `test/qemu_test.py:66-83`) so TCG exposes SMEP/SMAP — without it the + enabled paths never execute in CI. Landed in H2 so the flag soaks + before H3 depends on it. +9. **Scenario fixtures that need the registry are init-driven.** Kernel + test cases that today spawn providers directly (shared-memory, + process-test) either spawn init first or move to init.csv-driven + scenario boots — resolved per-case in P2 with the suite as the + arbiter. +10. **The capsule-staleness caveat is documented, not fixed.** On-volume + edits to `/system/configuration/*.csv` do not reach the initrd copy + the loader boots (capsule shadows tree). Same drift exists today with + `/etc`; PM adds the note to file-system-hierarchy.md and moves on. + +--- + +## PM — path-migration flag-day + +One commit, everything moves together. The authoritative site inventory is +the grounding pass; the checklist order: + +1. Move repo `etc/` → `configuration/` sources; fix the three CSVs' + self-referencing headers (`etc/init.csv:1,12`, `etc/devices.csv:1`, + `etc/init-diagnose.csv:1`). +2. `build.zig:309-311`: bundled entries `etc/...` → + `system/configuration/...` (this alone re-shapes the image, manifest, + and capsule — `tools/make-fat-image.py` and the EFI loader need + nothing; the tree-walk fallback even starts picking the CSVs up, a + bonus fix). +3. `system/kernel/vfs.zig` `mountBackend` (`:332-340`): allow exactly + `/system/configuration` and `/system/logs` as backend prefixes beneath + the initrd `/system` mount; keep refusing everything else under + `/system` and `/test`. +4. `system/services/fat/fat.zig`: `mount_point` → `/volumes/usb` (`:25`); + replace the `/var` mount (`:155`) with two `mountRewritten` calls for + `/system/configuration` and `/system/logs`; update the mount log lines + (the harness matches them). +5. `system/services/init/init.zig:76` and + `system/services/device-manager/device-manager.zig:48`: open the new + CSV paths; update the message strings (`init.zig:77,92`, + `device-manager.zig:49,61-63,454`). +6. `system/services/logger/logger.zig:44`: `base = "/system/logs"` + (buffers derive from `base.len` comptime — nothing else changes). +7. `system/kernel/tests.zig:2808-2810`: exclude `/system/configuration/` + from the spawn-everything sweep (the CSVs are not programs). +8. Tests: `fat-test.zig` and `vfs-test.zig` `/mnt/usb` literals → + `/volumes/usb`; harness regexes `test/qemu_test.py:175,211,632,717`. +9. Comment sweep (init, device-manager, logger, fat, engine, vfs, abi, + file-system, csv, device, protocol/device-manager, drivers, acpi, + build.zig — full list in the grounding inventory); delete vestigial + repo `var/`. + +**Test:** no new case — the existing 106 are the test, since fat/logger/ +init/device-manager scenarios all assert the new paths through their +regexes. Suite stays 106. + +## H1 — user-memory copy discipline + +New kernel module `system/kernel/user-memory.zig`: + +- `copyFromUser` moves from ipc-synchronous.zig (which re-exports or + imports it); new `copyToUser(user_as, user_va, source) bool` — the + mechanical mirror (kernel-source `copyAcross` already does this for IPC + replies at `ipc-synchronous.zig:431,460`). +- The page walk gains leaf U/S and writable checks: `paging.translateIn` + (`architecture/x86_64/paging.zig:513-525`) tests only `present` today — + add a flags-accumulating variant (2 MiB leaves included); reads require + U/S, writes require U/S+W. Closes the TODO at + `ipc-synchronous.zig:20-22`. +- Convert the nine stragglers (table in smep-smap.md). Read direction is + local to `process.zig`; the write direction restructures callees with + kernel bounce buffers: `scheduler.enumerate` (`scheduler.zig:1209`), + `devices_broker.enumerate` (`devices-broker.zig:136`), `log.readAt` + (`log.zig:209`), and the `fs_node` flows through + `vfs.nodeRead/nodeStatus/nodeReaddir` (`vfs.zig:257/269/289`). + +**Test:** kernel unit coverage in `system/kernel/tests.zig` for +`copyToUser` bounds/permission refusals; one new QEMU case `user-memory` — +a fixture passes an unmapped-but-in-range buffer to `klog_read`, +`process_enumerate`, and `fs_resolve` and asserts `-EFAULT` returns with +the system still alive (today each would oops the kernel). Suite 107. + +## P1 — envelope, vfs additions, Channel + +- `library/protocol/envelope/envelope.zig`: `Header` {operation:u32, pad, + target:u64}, `Status`, reserved verbs (describe=0, enumerate=1, + subscribe=2, unsubscribe=3, protocol verbs from 16), `packet_maximum` + = 256 / `post_maximum` = 64 (the floor constants protocols compile + against — nothing exports them today), and comptime + `Define(.{name, version, operations, events})` generating request/reply + types, encode/decode, a provider dispatch table (automatic `describe`, + `-ENOSYS` for unknown verbs), and compile-time size checks: + request/reply ≤ 256, each `.events` entry ≤ 64 *including* its Header + (decision 1). Host unit tests in the protocol package's test step. +- `library/protocol/vfs/vfs-protocol.zig`: `NodeKind.protocol = 7`; the + open-reply-may-carry-capability convention documented in the module. + Rewrite the value-pinning unit test (`:108-117`) to pin the *new* + stable values. +- `library/kernel/file-system.zig` + a new `Channel` type in + `library/kernel` (or `library/client`): `open("/protocol/")` → + resolve, vfs open, receive the reply capability → a `Channel` wrapping + the handle with `call`/typed helpers. Nothing uses it yet — P2 converts + the world. +- Docs: vfs-protocol.md's NodeKind table gains value 7 (no + protocol-namespace.md amendment — decision 1 conforms to it as written). + +**Test:** host unit tests only (envelope round-trips, size-check compile +errors via `error` tests, Channel plumbing against a mock). Suite stays +107. + +## P2 — the registry; ServiceId flag-day + +The single biggest phase; one branch, may be several commits, green at the +end of each. + +- **init as registry backend** (`system/services/init/init.zig`): a second + endpoint (the supervision endpoint's reply-empty loop is unsuitable for + a vfs backend); serve vfs `open`/`readdir` over `/protocol` plus the + `bind` operation (name payload + capability). Mount `/protocol` before + spawning children. Parse `/system/configuration/protocol.csv` + (decision 3). Authorization by chain-attested identity (decision 2): + badge → kernel process records → binary name **and** supervision chain + (walk `supervisor` links) checked against the grant row's expected + supervisor. Unbind on child death in `restartChild`; dead-owner rebind + rule (decision 5). + Provenance: readdir/diagnostics show name → pid → binary path. +- **Kernel:** reserve `/protocol` — `mountBackend` refuses mounts at or + under it once bound, `installMount`'s remount-replace path refuses it, + and `fs_unmount` refuses it (`vfs.zig:164-181,332-351`, + `process.zig:1879-1889`). First mount wins (init is PID 1). +- **Harness:** `library/kernel/service.zig` `Callbacks.service: + ?abi.ServiceId` becomes a protocol name; the register call (`:49-51`) + becomes bind-with-retry via the registry. +- **Flag-day conversion** — all 11 registration sites and 17 lookup sites + from the grounding inventory: providers (input:123, ps2-bus:223, + device-manager:569, acpi:193, usb-xhci-bus:676, usb-storage:205, + fat:307, display:699, virtio-gpu:550, shared-memory-server:43, + process-test:130 → `/protocol/test/...` per decision 4); clients + (input-client:53, display-client:28, driver.zig:173, usb.zig:139, + block.zig:72+87, ps2-bus keyboard:35 + mouse:34, virtio-gpu:478, + display:314+512 (decision 6), acpi:212, init:218+245 — init + short-circuits its own registry, shared-memory-client:22, + process-test:85, device-list:22, crash-test:32). Retry loops keep their + cadence, wrapping resolve+open instead of lookup. +- **Delete:** `abi.zig:36-37` (syscall ids — leave holes), + `abi.zig:287-303` (enum), `process.zig:223-224,314-343`, + `ipc-synchronous.zig:41-43,646-664` and the registry sweep in + `:121-140`; the wrappers `library/kernel/ipc.zig:33-35,47-50`; comment + sweep (irq.zig:50, tests.zig:3744, vdso.md's syscall table, the docs + list in the inventory). +- Kernel-spawned test scenarios made init-driven where they need the + registry (decision 9). + +**Test:** new QEMU case `protocol-registry`: a fixture asserts (a) bind of +an ungranted name → `-EPERM`, (b) bind collision with a live owner → +`-EBUSY`, (c) provider kill → re-resolve reaches the restarted instance. +Every existing scenario doubles as conversion proof. Suite 108. + +## P3 — open grants (restriction stage one) + +- `protocol.csv` `open` rows enforced in the registry's `open` handler, + same name-based identity as bind. Default rows grant what today's + clients need (from the P2 conversion table); a deliberate hole for the + test fixture. +- Docs: protocol-namespace.md stage-one section gets its "landed" line. + +**Test:** new QEMU case `protocol-denied`: a fixture granted +`/protocol/test/shared-memory` but not `/protocol/display` asserts open of +the first succeeds and the second fails identically to not-found. Suite +109. + +## P4a — clean protocols onto Define + +vfs, block, display, scanout, input — the modules whose shapes map +directly (grounding inventory §1,3,4,6,8): + +- vfs: `node` → `target`; `Reply.node` (open's result) moves to reply + payload — `library/kernel/file-system.zig` decoders change; readdir + stays a protocol verb. +- block: pure renumber; `attach`'s DMA cap rides the call as today. +- display: the overloaded 40-byte `Request` becomes per-operation structs + (attach_scanout's field abuse dies); `layer` → `target`; blit payload + grows to 224 bytes. +- scanout: renumber; drop its bogus `message_maximum=64` (sync floor is + 256); fix virtio-gpu's hard-coded `service.run(256, …)` to the + generated constant. +- input: subscribe merges into reserved subscribe; publish renumbers; + the event re-lays onto the Header folded (operation = event kind, + target = 0; 16 + 28-byte payload = 44 ≤ 64); **input moves onto the + service harness** (it is the last hand-rolled loop, no ping/terminate + compliance today). + +**Test:** new QEMU case `protocol-conformance`: a fixture opens every +registered protocol and asserts `describe` answers (name, version) and an +unknown verb returns `-ENOSYS`. Existing input/display/fat scenarios prove +the rebase. Suite 110. + +## P4b — misfit protocols onto Define + +device-manager, power, usb-transfer (inventory §2,5,7 — the u8-operation +re-layouts and raw-offset readers): + +- device-manager: u8 operations → Header; its enumerate=4/subscribe=5 + merge into the reserved verbs; `ChildAdded` splits its dual role — + request struct and event, both Header-first (folded to 60 B ≤ 64); + `ChildRemoved`'s (parent, bus_address) addressing stays payload. +- power: u8 operations → Header; subscribe merges; **init's raw + byte-offset event parsing (`init.zig:171-173`) and acpi's + `message[0]` dispatch (`acpi.zig:435-467`) are rewritten against the + generated types** — the two silent-breakage sites, called out so the + loop treats them as first-class conversions, not collateral. +- usb-transfer: `device_token` → `target` (already layout-identical); + `InterruptReport` re-lays onto the Header (`device_token` → `target`, + inline data trimmed 48 → 40 — largest real report is 8); control/bulk + budgets re-verified by `Define` (Status absorbs `actual_length`). + +**Test:** existing scenarios are the proof (device hot-add, power button, +USB storage/HID all exercise these wires); the conformance case now covers +three more providers. Suite 110. + +## P4c — harness subscriber lift + badge scoping + +- `library/kernel/service.zig` grows the subscriber table, exit- + notification sweep, and fan-out loop declared via `Define(.events)`; + input (:33-116), acpi (:67-68,393-406), and device-manager (:155-166) + delete their hand-rolled variants. One sweep idiom: exit notifications + (fat's pattern), replacing input's process-list polling and acpi's + none-at-all. +- Badge-scoped per-client integers (the guessable-id holes): fat node ids + gain owner checks on every operation (`fat.zig:72-76`), xhci device + tokens validate sender and sweep on exit (`usb-xhci-bus.zig:66-88,479`), + display layers gain an owner field. + +**Test:** extend the fat scenario: a second fixture guesses the first's +node id and asserts refusal; kernel-side unit test for the harness sweep. +Suite 111. + +## H2 — SMEP + +- Generalize the cpuid helper (`apic.zig:351-365`, private, subleaf-0) to + a shared probe; gate on `cpuid(0).eax >= 7`. +- Set CR4 bit 20 in `per-cpu.zig:initSystemCall` (or a sibling called + from both `cpu.zig:148` and `smp.zig:181` — the one path both BSP and + every AP already execute). Log enabled/absent (fail-open, IOMMU style). +- Harness: add `-cpu max` to `qemu_args` (decision 8). + +**Test:** new QEMU case `fault-smep` mirroring the `fault-*` injector +pattern (`tests.zig:3906-3938`): ring-0 call through a pointer into a +user-mapped page; expect `page fault (vector 14)` + `error code : 0x11` + +kernel-half IP, machine reports the exception (deliberate-exception cases +put the text in `expect`, per `qemu_test.py:189`). Suite 112. + +## HS — SYSRET canonical-RIP guard + +- `isr.s` syscall exit (`:256`): validate RCX canonicality before + `sysretq`; non-canonical → `iretq` fallback (or kill), per the hazard + note at `isr.s:192-194`. + +**Test:** kernel unit case driving a thread whose return RIP is forged +non-canonical via the syscall path if constructible cheaply; otherwise the +review-level proof plus the existing fault cases regression. Suite 112. + +## H3 — SMAP + +- `clac` patch site at `isr_common` (`isr.s:367`, before the CPL test — + ring-0 nesting inherits AC too): assemble a 3-byte NOP, patch to `clac` + at boot through the physmap (the `process.zig:1990-1995` / + `smp.zig:79-111` precedent), BSP-only before AP bring-up. +- Set CR4 bit 21 in the same per-CPU init as SMEP. +- Coding standards: kernel code touches user memory only through + `user-memory`; no `stac` anywhere, ever. + +**Test:** new QEMU case `fault-smap`: ring-0 deliberate read of a mapped +user page; expect vector 14 + `error code : 0x1` + kernel IP. And the +whole suite becomes the tripwire — any missed straggler now fails loudly. +Suite 113. + +--- + +**Explicitly out of scope** (own tracks, after this plan): P5 restriction +stage two (spawn's initial capability, namespace views, parked replies, +dedicated killable channels — needs a design session on the spawn +contract), file-path namespacing, trusted UI (display track), pipes/FIFOs +(Python track), `/applications` and its storage, `fs_mount`/`spawn`/ +`klog_read` gating beyond the `/protocol` reserved prefix, KPTI, IPC +priority inheritance.