device_claim checks that a device exists and is free. That is all. Any process may claim any unclaimed device, and a claim is what gates mmio_map and irq_bind — a licence to map physical memory and take interrupts. The device manager's matching is real but advisory: it spawns a driver with the device id in argv[1] and nothing binds that decision to the kernel's grant. maximum_children_per_parent is the visible cost. It exists because a driver that claimed one device could loop device_register under it, and it is a poor defence — an attacker burns 16 slots, claims another device, burns 16 more — while reliably refusing a legitimate PCI bus with more than 16 functions. Closing the hole is what retires the constant. The principles decide the split: matching is policy and stays in the device manager; enforcing that a driver holds only what it was given is security and stays in the kernel, and is the whole of what the kernel needs. The mechanism is decided by an awkward fact. Five of six claimants are device-manager children spawned with their device id. display is not — init spawns it, and it finds its framebuffer by enumerating for a display-class node and claiming whatever it finds. So "record the device named at spawn" closes the hole for five and breaks the sixth, and the sixth is not an oddity to special-case: it shows authority must be delegable rather than welded to the moment of spawn. So: a device grant is a capability, minted by the kernel to init for the devices firmware discovery found, delegated by init to the device manager and to display, and passed by the manager to each driver it spawns. The cap-passing path already exists and already carries shared memory and DMA regions. init is already the grantor for /protocol, and protocol.csv already records init granting the device manager its binding. Costs named rather than buried: five drivers must hello before claiming (pci-bus claims first today), and init grows a device role on top of the protocol registry. A device-grant manifest mirroring protocol.csv would be the natural symmetry and is deliberately not proposed yet.
139 lines
7.2 KiB
Markdown
139 lines
7.2 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: it reads a CSV, matches
|
|
identity triples, and picks a binary. That is the device manager's, and it stays there.
|
|
|
|
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.
|
|
|
|
## 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.
|
|
|
|
1. **Root.** At boot the kernel mints grants for the devices firmware discovery found
|
|
and hands them to `init` (PID 1, which the kernel spawns and therefore need not
|
|
authenticate). This is the only place device authority enters the system, and it
|
|
comes from ACPI rather than from anyone's say-so.
|
|
2. **Delegation.** `init` passes the device manager the grants it will need — in
|
|
practice all of them — and passes `display` the framebuffer grant, because `init` is
|
|
what starts `display`. This is the same shape as the `/protocol` registry, where
|
|
`init` is already the grantor and `protocol.csv` already records
|
|
`/system/services/device-manager, /system/services/init, bind, device-manager`.
|
|
3. **Assignment.** 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.
|
|
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.
|
|
|
|
**`init` grows a device role.** It already registers `/protocol` and reads
|
|
`protocol.csv`; it would also hold root device grants and hand them on. That is more
|
|
responsibility in PID 1, which is a cost worth naming — though the alternative is the
|
|
kernel deciding who may hold what, which principle 5 excludes.
|
|
|
|
**A configuration question follows.** `/protocol` grants are data (`protocol.csv`),
|
|
because who may speak to whom is policy. Device grants could be too — a manifest saying
|
|
which binary may be given which device class. That would be the natural symmetry, and it
|
|
is *not* proposed here: `init` handing the manager everything and the manager matching
|
|
by `devices.csv` is the smaller step, and the manifest can follow if it earns itself.
|
|
|
|
## 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.
|