init: /protocol replaces the ServiceId registry

A protocol is reached by name now, not by a compile-time integer. Init is
PID 1 and already knows which binary it started, so init serves /protocol
as a vfs backend: bind claims a contract with the provider's endpoint
attached, open answers with that endpoint as the reply's capability, and
readdir lists what is bound with the task and binary behind it. The kernel
reserves the prefix — nothing may mount over it, under it, or unmount it —
and ServiceId, ipc_register and ipc_lookup are gone, their syscall numbers
left vacant.

A bind is authorized by who the caller *is*: the kernel-stamped binary
together with the supervising task's identity, matched against
/system/configuration/protocol.csv. Identity, not spelling — spawn is
ungated, so an attacker can run any bundled binary, and a name-only rule
would have let it launder grants through an init of its own making. A name
a live process holds is refused to everyone else; a dead one's is released.

Three review rounds against a hostile ring-3 process found what 108 green
tests could not, because the suite contains no attacker. Publishing init's
supervision endpoint as the registry put PID 1's mailbox in every process's
hands, where two forged bytes reached the shutdown path: privileged traffic
is now believed only from the task that holds the contract it speaks for.
A capability arriving on a request outlived every path that ignored it,
one handle per call until the table was full — in init, and in the harness
ten services share — so the arriving capability is owned by the turn and
released unless a handler says otherwise. And the kernel let anyone holding
an endpoint handle aim signals, timers, exit notices and interrupts at it:
binding now requires having created it.

Suite 108/108. The new protocol-registry case asserts eleven properties,
each one an attack that must fail.
This commit is contained in:
Daniel Samson
2026-08-01 02:39:07 +01:00
parent 1ff0991452
commit 1379b699f3
66 changed files with 2191 additions and 392 deletions
+2 -2
View File
@@ -87,13 +87,13 @@ rest of the system hasn't had to face:
│ (ResourceKind.memory = [base, height*pitch], write-combining hint,
│ plus DisplayInfo{width, height, pitch, format, refresh_hz})
▼
display service (system/services/display/, ServiceId.display) ← the compositor
display service (system/services/display/, /protocol/display) ← the compositor
│ device.claim(display node) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB)
│ mmap(cacheable) a BACK buffer of the same geometry
│ owns: an ordered LAYER STACK + a per-frame DAMAGE tracker (rect list or tile grid)
│ loop: composite dirty layers → back buffer → present dirty rects → front
│ backend is an INTERNAL interface: {gop-fb} at boot; {virtio-gpu} on hot-attach (v2)
▼ reached by name (ipc_lookup); clients drive it over the display protocol
▼ reached by name (open /protocol/display); clients drive it over the display protocol
┌────────────────────────────────────┬──────────────────────────────────────┐
drawing clients (v1) surface clients (deferred)
display commands: display surfaces:
+25 -3
View File
@@ -114,9 +114,10 @@ unforgeable source addressing, a property a network's source field lacks.
The bootstrap problem — how a channel is first established — is the subject of
[protocol-namespace.md](../os-development/protocol-namespace.md): a protocol is
resolved by name and the channel arrives as a capability. (The mechanism this
replaces, `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers,
is retired by that design.)
resolved by name and the channel arrives as a capability. (The mechanism it
replaced — `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers —
is gone: both syscalls and the enum were deleted when the registry landed, and
their syscall numbers are left vacant.)
### Interrupts are signals
@@ -169,6 +170,27 @@ signal mechanism:
- **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a
timer-bit signal — the timed wait: a service arms a deadline and keeps
serving, instead of blocking in sleep.
- **Kernel notifications go only to your own endpoint.** `signal_bind`,
`timer_bind`, `process_subscribe`, `irq_bind`, `msi_bind`, and spawn's exit
endpoint all *nominate where the kernel will speak*, and all of them refuse an
endpoint the caller did not create (`-EPERM`; the check is `ipc.ownedBy`,
normalized to the process, so any thread may nominate an endpoint a sibling
created). Holding a handle is not enough, because holding a handle is cheap:
`fs_resolve` installs a mounted backend's capability in *any* caller's table,
so every process holds a handle to PID 1's mailbox. Without the rule, "bind
init's endpoint, then signal yourself" is a genuine, kernel-stamped `terminate`
badge in PID 1's queue — a shutdown a receiver has no way to disbelieve — and
timers, which carry no identity at all, multiply any loop that re-arms on its
own landing.
- **A capability that arrives belongs to the turn.** The kernel installs a sent
capability in the receiver's table whatever the message's length or kind, so a
receive loop must dispose of one on *every* path — the ping, the notification,
the malformed request. The service harness (`service.run`) and PID 1 both hold
it in an `ipc.Arrival`, released by a `defer`, and a handler that means to keep
it says `take()`: forgetting closes, keeping is explicit. The reverse
arrangement leaks a handle-table slot per request, and thirty-two unauthorized
zero-length pings then end a service's ability to accept any capability —
no subscribe, no shared-memory handover — for the rest of the boot.
- **The universal ping**: a **zero-length request is the liveness probe**,
answered with a zero-length reply by the service harness itself
(`service.run`). No protocol's requests start at length zero, so the
+2 -2
View File
@@ -231,8 +231,8 @@ Two consequences of neutrality bind on later work:
- **Cross-firmware surfaces are named by domain, not firmware.** System power is
a [`power`](power.md) protocol, not an "ACPI events" protocol: on x86 the acpi
service registers it, on ARM a PSCI/mailbox service registers the same
`ServiceId.power`, and subscribers never learn the difference.
service binds it, on ARM a PSCI/mailbox service binds the same
`/protocol/power`, and subscribers never learn the difference.
- **Identity must widen before the fdt service exists.** `DeviceDescriptor`'s
8-byte `hid` holds an EISA id but cannot hold an FDT `compatible` string
(`"brcm,bcm2835-aux-uart"`); the identity field grows before the ARM path can
+3 -3
View File
@@ -15,9 +15,9 @@ Where the events come from is firmware-specific — on x86 they ride the ACPI SC
([acpi.md](acpi.md)); on a Raspberry Pi they would come from PSCI or a mailbox.
What subscribers want is not: *the lid closed* means the same thing regardless of
who noticed. So the surface is **domain-named**. There is a `power-protocol`
module and a well-known `ServiceId.power = 5`; on x86 the **acpi service**
registers it, and on ARM a PSCI/mailbox service will register the *same* id.
Subscribers call `ipc.lookup(.power)` and never learn which firmware they
module and a contract named `/protocol/power`; on x86 the **acpi service**
binds it, and on ARM a PSCI/mailbox service will bind the *same* name.
Subscribers open `/protocol/power` and never learn which firmware they
are on — the neutrality the whole [discovery](discovery.md) migration exists to
preserve, carried one layer up into a running-system surface.
+1 -1
View File
@@ -27,7 +27,7 @@ packages whose build.zig calls `build_support.userBinary` (with `.threaded =
true` where a binary spawns threads) and get packed into the initial-ramdisk;
new syscalls extend [abi.zig](../../system/abi.zig) `SystemCall` + a
`library/kernel` wrapper; test services live beside the code they exercise and
register a `ServiceId` if they must be looked up.
bind a `/protocol/test/...` name if they must be reachable.
## How to verify along the way
+8 -6
View File
@@ -270,13 +270,15 @@ stays single-threaded and lean.
- *Handles do not cross threads.* The handle table lives on the `Task`
([scheduler.zig](../../system/kernel/scheduler.zig)), so a handle number is meaningful
only to the thread that created it — thread A's endpoint handle `3` is not thread B's.
A thread that needs to reach an endpoint another thread owns looks it up
(`ipc.lookup(service)`) to install its **own** handle to the same underlying endpoint.
This is how the display's mouse-listener thread reaches the compositor loop's endpoint
to poke it awake (docs/display.md).
A thread that needs to reach an endpoint another thread owns opens the name
(`channel.openEndpoint("display")`) to install its **own** handle to the same
underlying endpoint — an ordinary client open, with no special mechanism for the
fact that the provider happens to be this process. This is how the display's
mouse-listener thread reaches the compositor loop's endpoint to poke it awake
(docs/display.md).
- *IPC syscalls that touch shared kernel state now serialize under the big kernel lock.*
`create_ipc_endpoint`/`ipc_register`/`ipc_lookup` allocate from the kernel heap and
mutate the global service registry, endpoint refcounts, and handle tables. Those paths
`create_ipc_endpoint` allocates from the kernel heap and
mutates endpoint refcounts and handle tables. Those paths
were unlocked because a single-threaded process could not race itself; a multi-threaded
one can, from two cores at once. They now take `sync.enter()` like `call`/`reply_wait`/
`send` already did — the kernel heap has no lock of its own (heap.zig: "every kernel
+3 -3
View File
@@ -137,15 +137,15 @@ Grouped as `abi.zig` groups them:
| process | `danos_exit`, `danos_yield`, `danos_sleep`, `danos_spawn`, `danos_process_enumerate`, `danos_process_kill`, `danos_process_exit_reason`, `danos_process_subscribe`, `danos_process_signal`, `danos_signal_bind` |
| threads | `danos_thread_spawn`, `danos_thread_exit`, `danos_current_core`, `danos_futex_wait`, `danos_futex_wake`, `danos_thread_self`, `danos_thread_join`, `danos_set_thread_pointer` |
| memory | `danos_mmap`, `danos_munmap`, `danos_dma_alloc`, `danos_dma_free`, `danos_shared_memory_create`, `danos_shared_memory_map`, `danos_shared_memory_physical` |
| ipc | `danos_endpoint_create`, `danos_ipc_register`, `danos_ipc_lookup`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` |
| ipc | `danos_endpoint_create`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` (naming is not a syscall: a provider binds its contract at the registry and a client resolves `/protocol/<name>` — see [protocol-namespace.md](protocol-namespace.md)) |
| devices | `danos_device_enumerate`, `danos_device_claim`, `danos_device_register`, `danos_mmio_map`, `danos_irq_bind`, `danos_irq_ack`, `danos_msi_bind`, `danos_io_read`, `danos_io_write` |
| time | `danos_clock`, `danos_wall_clock`, `danos_timer_bind` |
| diagnostics | `danos_debug_write` (leveled, kernel-stamped records), `danos_klog_read`, `danos_klog_status` |
| filesystem naming | `danos_fs_resolve`, `danos_fs_node`, `danos_fs_mount`, `danos_fs_unmount` (naming only — file DATA still crosses the vfs-protocol IPC, see below) |
The constants that ride alongside the calls — mmap protection bits, DMA
flags, notification badge bits, `ExitReason`, `Signal`, well-known service
ids, `page_size`, the IPC message maximum — move to the public header too:
flags, notification badge bits, `ExitReason`, `Signal`, `page_size`, the IPC
message maximum — move to the public header too:
they are wire values a Rust program needs verbatim. What stays private in
`abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall`
numbers and the trap convention.
+21 -1
View File
@@ -36,7 +36,7 @@ the logging/USB-lifecycle track).
- [x] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an `mmio_map`'d buffer cannot fault ring 0 — this also closes the same hazard on the IPC path; `fs_resolve`'s out-capacity bound made overflow-safe; suite 107/107)
- [x] **merge** group 1 → main, push (f3bc23c, 2026-07-31)
- [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107)
- [ ] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups)
- [x] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups; `protocol.csv` grants, chain-attested identity, dead-owner rebind; the kernel's endpoint-death sweep generalized off the retired registry; suite 108/108). Three adversarial review rounds closed six defects a green suite had missed: a forged power event could shut the machine down; the ping path leaked a capability per call, first in init and then in the shared harness; supervisor attestation by name was defeated by a laundering deputy; and the kernel let any handle-holder bind signals, timers, exits and IRQs to an endpoint it did not own.
- [ ] **P3** — open grants: `protocol.csv` enforcement, denial test
- [ ] **merge** group 2 → main, push
- [ ] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input)
@@ -87,6 +87,22 @@ that implements it.
`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.
*(P2 spelling: the supervisor column carries the binary exactly as the
kernel stamped it, so init's own children say `/system/services/init` and
the drivers say `/system/services/device-manager`; `kernel` stays a bare
word because a kernel task has no binary. A trailing `*` on any field
matches a subtree, which is how decision 4's `/test/` rule is expressed.)*
*(Clarification, 2026-08-01: the supervisor column names **the authorized
supervising task, matched by identity** — the binary is how the row spells
it, but init checks the task id. `kernel` is satisfied only by supervisor
id 0 (which only the kernel confers — user `system_spawn` always stamps the
caller); init's own path only by this init's task id; any other path only by
a task init spawned itself or one the kernel spawned. Matching the supervisor
by *name* alone is defeated by a laundering deputy — an attacker runs its own
instance of `/system/services/init`, has that spawn `/system/services/input`,
and both stamped names satisfy the row while the chain is entirely the
attacker's. Walking to the root of the chain does not fix it either, since
the laundered chain still roots at the real PID 1.)*
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
@@ -115,6 +131,10 @@ that implements it.
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.
*(P2 resolution: init gained a `registry` argv role — it mounts
`/protocol`, reads the grants, and starts no services — and each affected
case calls `spawnRegistry(rd)` before its own providers. Every case keeps
its own spawn set, so no scenario had to be re-shaped.)*
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