The design had init minting device grants and handing them on, because init is what starts display and display claims the framebuffer. That was the wrong shape. init has nothing to do with devices: it is the first process and it starts the rest of the system. The device manager owns hardware. So the kernel mints the root grants to the device manager, and display asks the manager for its framebuffer like any other driver. The exception that drove the earlier draft disappears instead of being accommodated — one authority for hardware, not two. The kernel recognises the manager by the chain attestation the security track already settled on: the binary path it stamped itself, AND a supervisor of PID 1. Path alone is forgeable, because system_spawn is deliberately ungated and any process may spawn any bundled binary — but a rogue copy's supervisor is the rogue, and PID 1 is the kernel's own first process. Costs restated for the corrected model. display gains a boot-order dependency on the manager, mitigated by its existing ability to attach a backend late. The kernel gains one piece of knowledge about one binary, which is the minimum: authority has to enter somewhere, and the alternatives are a file parser in the kernel or first-to-ask, which is the hole again.
180 lines
9.6 KiB
Markdown
180 lines
9.6 KiB
Markdown
# Device authority: you hold what you were given
|
|
|
|
*Design for phase 2 of [the bounds track](../bounds-track-plan.md), 2026-08-08.
|
|
Supersedes an earlier draft that argued for capabilities on aesthetic grounds; this one
|
|
starts from the hole and from the project's principles.*
|
|
|
|
## The hole
|
|
|
|
`device_claim(id)` checks two things ([devices-broker.zig](../../system/kernel/devices-broker.zig)):
|
|
|
|
```zig
|
|
pub fn claim(id: u64, owner: u32) ClaimError!void {
|
|
if (id >= count) return error.NoSuchDevice;
|
|
if (claimed[@intCast(id)] != null) return error.AlreadyClaimed;
|
|
claimed[@intCast(id)] = owner;
|
|
}
|
|
```
|
|
|
|
Does it exist, and is it free. **Any process may claim any unclaimed device.**
|
|
|
|
The matching is real but it is entirely advisory: `device-manager` reads `devices.csv`,
|
|
matches a device to a driver, and spawns that driver with the device id as `argv[1]`
|
|
(`spawnDriver`). The driver parses the string and claims it. Nothing anywhere binds the
|
|
manager's decision to the kernel's grant — a process can pass any integer and win the
|
|
race.
|
|
|
|
A claim is not a small thing. It is what gates `mmio_map` and `irq_bind`, so it is a
|
|
licence to map physical memory and receive interrupts.
|
|
|
|
## What this costs, beyond the obvious
|
|
|
|
`maximum_children_per_parent = 16` exists because a driver that claimed one device could
|
|
loop `device_register` under it and exhaust the shared table. That threat only exists
|
|
*because* claiming is unauthenticated — and the cap is a poor defence against it, since
|
|
an attacker can burn 16 slots, claim another device, and burn 16 more. What it reliably
|
|
does instead is refuse a legitimate PCI bus with more than 16 functions, which is how an
|
|
AMD Ryzen came to boot with no USB and no storage.
|
|
|
|
So the cap is not merely mis-sized. It is standing in for an authorisation that is not
|
|
performed, and it punishes correct behaviour while barely inconveniencing incorrect
|
|
behaviour. **Closing the hole is what retires the constant**, not a bigger number.
|
|
|
|
## What the principles decide
|
|
|
|
- *Move as much responsibility as possible to user space* (3), and *what remains in the
|
|
kernel is there for security or a hardware limitation* (5).
|
|
|
|
Deciding **which** driver gets **which** device is policy — matching identity triples
|
|
and choosing a binary. That decision belongs to the device manager and stays there.
|
|
`devices.csv` is not the policy; it is **configuration**, the declarative data the
|
|
policy reads. Three distinct things, and worth keeping apart in this document:
|
|
|
|
| | Lives in | Example |
|
|
|---|---|---|
|
|
| **Mechanism** | the kernel | the check that a grant is held before a mapping is made |
|
|
| **Policy** | user space | the device manager matching a device to a driver |
|
|
| **Configuration** | files | `devices.csv`, `protocol.csv`, `init.csv` |
|
|
|
|
Enforcing that a driver **holds only what it was given** is security — it is the gate in
|
|
front of mapping physical memory. That stays in the kernel, and it is the whole of what
|
|
the kernel needs to do.
|
|
|
|
The kernel therefore does not need to know about matching, `devices.csv`, driver names,
|
|
or why a device was assigned. It needs to know that an authority it can verify granted
|
|
this device to this task.
|
|
|
|
## The awkward fact that decides the mechanism
|
|
|
|
Five of the six claimants are device-manager children, spawned with their device id in
|
|
`argv[1]`: `pci-bus`, `usb-xhci-bus`, `ps2-bus`, `virtio-gpu`, `acpi`.
|
|
|
|
**`display` is not.** It is spawned by `init` from `init.csv`, and it finds its device by
|
|
enumerating the table for a display-class node and claiming whatever it finds
|
|
([backend.zig](../../system/services/display/backend.zig)). There is no assignment to
|
|
enforce, because nobody assigned it anything.
|
|
|
|
That rules out the cheapest design. "The kernel records the device named at spawn, and
|
|
`device_claim` checks it" closes the hole for five claimants and breaks the sixth. And
|
|
the sixth is not an oddity to special-case — it is the one that shows the model is
|
|
wrong: authority should be *delegable*, not welded to the moment of spawn.
|
|
|
|
It is also what tempted an earlier draft of this document into giving `init` the root
|
|
grants, since `init` is what starts `display`. That was wrong for a plainer reason:
|
|
`init` has nothing to do with devices. It is the first process and it starts the rest of
|
|
the system; the device manager is what owns hardware. Under delegation `display` simply
|
|
asks the manager, like every other driver, and the exception disappears rather than
|
|
being accommodated.
|
|
|
|
## The design
|
|
|
|
**A device grant is a capability, delegated from a holder.** The mechanism already
|
|
exists: `callCap` passes a handle over an IPC call and the kernel installs it in the
|
|
receiver's table ([library/kernel/ipc.zig](../../library/kernel/ipc.zig)), which is how
|
|
shared memory and DMA regions already move between processes.
|
|
|
|
**The device manager is the root of device authority.** `init` has no part in this: it
|
|
is the first process, and its job is to start the rest of the system. It starts the
|
|
device manager the same way it starts everything else, and knows nothing about devices.
|
|
|
|
1. **Root.** At boot the kernel mints grants for the devices firmware discovery found
|
|
and hands them to the device manager. This is the only place device authority enters
|
|
the system, and it comes from ACPI rather than from anyone's say-so.
|
|
|
|
The kernel recognises the manager by **chain attestation**, the identity the security
|
|
track already settled on: the binary path the kernel itself stamped
|
|
(`/system/services/device-manager`) *and* a supervisor of PID 1. The binary path
|
|
alone would not do — `system_spawn` is deliberately ungated, so any process may spawn
|
|
any bundled binary, and a rogue could run a second copy under the same name. It could
|
|
not forge the other half: its copy's supervisor is the rogue, and PID 1 is the
|
|
kernel's own first process.
|
|
|
|
2. **Delegation.** The manager passes a driver its device when it spawns it, over the
|
|
channel that already exists — the driver `hello`s the manager and the reply carries
|
|
the grant. The manager attests its own children by task id, one hop deep, exactly as
|
|
`init` attests the services it spawned for `/protocol` (`supervisorSatisfies`); it
|
|
knows which task is which driver because it spawned them.
|
|
|
|
3. **`display` asks the manager too.** It is started by `init` as a service, but the
|
|
framebuffer is a device, so it receives that grant from the device manager like any
|
|
other driver. One authority for hardware, not two — which is what makes `display`
|
|
stop being the exception that broke the simpler design.
|
|
|
|
4. **Use.** `mmio_map`, `irq_bind`, `msi_bind`, `io_read`/`io_write` and `dma_bind`
|
|
check possession of the grant instead of consulting an ownership table.
|
|
|
|
Exclusivity stops being a broker refusing a second claimant and becomes the ordinary
|
|
property of a capability: only one process was given it.
|
|
|
|
**`maximum_children_per_parent` is deleted here.** After this a bus driver's children are
|
|
devices it enumerated on a bus it was actually given, and the threat the cap was written
|
|
for no longer exists.
|
|
|
|
## What this costs
|
|
|
|
**Bring-up order changes.** `pci-bus` today claims first and says hello afterwards — its
|
|
own comment says "Claim the bridge, map the ECAM, hello the manager, then scan." Under
|
|
delegation the hello must come first, because that is where the grant arrives. Five
|
|
drivers need that reordering, and it is the bulk of the work.
|
|
|
|
**`display` gains a dependency on the device manager.** It currently finds its
|
|
framebuffer by itself and needs nothing from anyone; afterwards it must ask. That is a
|
|
new ordering constraint at boot — `display` cannot bring up a screen until the manager
|
|
is up — and it is the honest price of there being one authority for hardware. Mitigating:
|
|
`display` already tolerates arriving before its backend (the virtio-gpu driver hands it
|
|
a scanout later, over `attach_scanout`), so the machinery for "wait, then attach" is
|
|
there.
|
|
|
|
**The kernel gains one piece of knowledge about a specific binary.** Chain attestation
|
|
means the kernel recognises `/system/services/device-manager` under PID 1 as the root
|
|
holder. That is a real concession — the kernel would rather know nothing about who is
|
|
who — and it is the minimum: device authority has to enter the system somewhere, and
|
|
every alternative is worse. Configuration the kernel reads would put a file parser in
|
|
the kernel; first-to-ask would be the hole again, at boot.
|
|
|
|
**A configuration question stays open.** Which binary may be given which device is
|
|
expressed today as `devices.csv`, read by the manager — configuration, with the manager
|
|
as the policy that acts on it. That is already the right shape and needs no new file.
|
|
A separate grant manifest earns itself only when something must differ from "the manager
|
|
gets the hardware": holding a device back so a test or a bare-metal driver can take it,
|
|
for instance. Not now.
|
|
|
|
## What it does not solve
|
|
|
|
- **Hot-unplug and re-enumeration drift.** Still the inventory problem (phase 3, and
|
|
open question 4 in the track plan). A grant dying with its holder is not the same as a
|
|
device going away.
|
|
- **The device table's size.** `maximum_devices` is untouched by this; it goes when the
|
|
inventory moves in phase 3.
|
|
- **Two processes racing for the same root grant.** Cannot arise, because roots are
|
|
minted to `init` alone.
|
|
|
|
## How this is verified
|
|
|
|
The invariant is **I3** from the track plan: a process holds what it was handed and
|
|
cannot name its way into holding more. The test is adversarial and the suite has never
|
|
had one of these for devices: a process that was granted nothing calls `device_claim`
|
|
on a device another driver owns, and on one nobody owns, and is refused both times with
|
|
its own errno. The audit's lesson was that "the suite contains no attacker"; this is the
|
|
attacker for devices.
|