init: a protocol you were not granted does not exist
The registry consults the open rows it has been parsing since P2, so reaching a contract now takes a grant as well as a binding. A caller without one is answered exactly as it would be for a name nobody ever bound: same status, same empty reply, same absent capability, byte for byte, and no log line on either path — klog_read is ungated, so a line on one and not the other would be the oracle the design set out to remove. Refusal and absence being one answer is what lets a supervisor later narrow, fake or park a child's namespace without the child learning what it was denied. The manifest gains a third permission for a shape the plan did not foresee: attestation is one hop, but the driver tree is three deep — the PS/2 keyboard and mouse are spawned by ps2-bus, which the device manager spawned — so no row could name them and PS/2 input would simply stop. A supervise grant lets a delegate vouch for what its children *reach*, never for what they claim; the bind path is untouched, and the laundering deputy is still refused. The review found the receive side of a rule this track had already written down. Every process holds a sendable handle to the registrar — resolve installs one for anyone who asks — and ipc_reply_wait never asked who owned the endpoint, so a stranger could dequeue there: take the provider endpoints riding bind requests, and answer other clients' opens in the registrar's name. Receiving is the owner's privilege, like binding a signal or a timer; sending remains anyone's. Suite 109/109.
This commit is contained in:
+106
-13
@@ -35,6 +35,12 @@
|
||||
//! stranger's bytes; the only identity on it is the task id the kernel stamps.
|
||||
//! Content never authorizes (`onPowerEvent`), and neither does a name — the
|
||||
//! registrar attests a caller's supervision by task id (`supervisorSatisfies`).
|
||||
//! - **Absence is the enforcement.** P3: `open` consults the manifest with the
|
||||
//! same attested identity a `bind` does, and a caller with no grant is told
|
||||
//! exactly what a caller asking for a name nobody bound is told — `-ENOENT`,
|
||||
//! and no capability (`onOpen`). Restriction stage one of
|
||||
//! docs/os-development/protocol-namespace.md: what a process cannot open does
|
||||
//! not exist for it, so there is no "permission denied" to distinguish.
|
||||
//! - **A capability that arrives is closed unless it is claimed** (`Arrival`),
|
||||
//! because PID 1's thirty-two handle slots are a resource an unauthenticated
|
||||
//! caller would otherwise be able to spend.
|
||||
@@ -153,7 +159,7 @@ const maximum_restarts = 3;
|
||||
/// init holds.
|
||||
const maximum_name = 64;
|
||||
const maximum_bindings = 16;
|
||||
const maximum_grants = 48;
|
||||
const maximum_grants = 64;
|
||||
|
||||
/// One bound contract: the name, the provider's endpoint (a capability init
|
||||
/// holds and hands to whoever opens the name), and the provenance a diagnostic
|
||||
@@ -177,10 +183,26 @@ const Binding = struct {
|
||||
|
||||
var bindings: [maximum_bindings]Binding = .{Binding{}} ** maximum_bindings;
|
||||
|
||||
/// What a grant row permits: claiming a name, or reaching one. `open` rows are
|
||||
/// parsed and held but not yet enforced — every open resolves in P2, and P3 is
|
||||
/// the milestone that turns these into refusals (docs/security-track-plan.md).
|
||||
const Permission = enum { bind, open };
|
||||
/// What a grant row permits.
|
||||
///
|
||||
/// - `bind` — claim the name, i.e. provide the contract.
|
||||
/// - `open` — reach the name, i.e. speak the contract to whoever provides it.
|
||||
/// - `supervise` — stand in a third task's supervision chain: a task running this
|
||||
/// binary, under this supervisor, may be the supervising task named by an
|
||||
/// `open` row for this contract. It grants the *delegate* nothing itself.
|
||||
///
|
||||
/// `supervise` exists because attestation is deliberately one hop deep
|
||||
/// (`supervisorSatisfies`): init vouches only for tasks it or the kernel started.
|
||||
/// The driver tree is deeper than that — the device manager starts the PS/2 bus,
|
||||
/// and the bus starts the keyboard and mouse drivers — so without a way to say
|
||||
/// "this task is an authorized supervisor", a legitimate grandchild would be
|
||||
/// indistinguishable from a laundering deputy. Naming the delegate in the
|
||||
/// manifest is what tells them apart, and it is the same shape as every other
|
||||
/// row: a binary, the supervisor it must have, and the contract it concerns.
|
||||
/// Deliberately `open`-only — a delegate may vouch for what its children may
|
||||
/// *reach*, never for what they may *claim* — so the bind path's attestation is
|
||||
/// exactly what P2 shipped and every refusal it makes still holds.
|
||||
const Permission = enum { bind, open, supervise };
|
||||
|
||||
/// One row of `/system/configuration/protocol.csv`. Every field may end in `*`,
|
||||
/// which matches any tail — the subtree scoping the design doc describes, and
|
||||
@@ -193,8 +215,9 @@ const Grant = struct {
|
||||
};
|
||||
|
||||
/// Roomier than init.csv's: this manifest carries a row per provider per spawn
|
||||
/// path, its own format documentation, and grows again with the open grants.
|
||||
var protocol_csv: [8192]u8 = undefined;
|
||||
/// path, a row per client per contract it reaches, and its own format
|
||||
/// documentation — which is most of the bytes, and is the point of the file.
|
||||
var protocol_csv: [16384]u8 = undefined;
|
||||
var grants: [maximum_grants]Grant = .{Grant{}} ** maximum_grants;
|
||||
var grant_count: usize = 0;
|
||||
|
||||
@@ -225,6 +248,8 @@ fn loadGrants() void {
|
||||
.bind
|
||||
else if (std.mem.eql(u8, permission, "open"))
|
||||
.open
|
||||
else if (std.mem.eql(u8, permission, "supervise"))
|
||||
.supervise
|
||||
else
|
||||
continue; // an unreadable row grants nothing rather than something wrong
|
||||
grants[grant_count] = .{ .binary = binary, .supervisor = supervisor, .permission = kind, .name = name };
|
||||
@@ -393,6 +418,44 @@ fn granted(identity: Identity, permission: Permission, name: []const u8) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether `identity` may reach `name` — `granted(.open, …)`, plus the one hop
|
||||
/// `open` takes that `bind` does not (`Permission.supervise`).
|
||||
///
|
||||
/// The hop is needed because the driver tree is three deep and attestation is
|
||||
/// one: the PS/2 keyboard driver's supervising task is the PS/2 bus driver,
|
||||
/// which the device manager started, which init started. Init cannot vouch for
|
||||
/// the bus by acquaintance — it never met it — so the manifest says so instead,
|
||||
/// and says it per contract: `ps2-bus` may be the supervisor named in an `open`
|
||||
/// grant for `ps2-bus` and for `input`, and for nothing else.
|
||||
fn mayOpen(identity: Identity, name: []const u8) bool {
|
||||
if (granted(identity, .open, name)) return true;
|
||||
return delegatedOpen(identity, name);
|
||||
}
|
||||
|
||||
/// The delegated `open`: the row's supervisor column names the caller's actual
|
||||
/// supervising task by binary, that task is one init cannot vouch for directly,
|
||||
/// and a `supervise` row authorizes it for exactly this contract.
|
||||
///
|
||||
/// The delegate itself is attested the ordinary way (`granted` → strict
|
||||
/// `supervisorSatisfies`), so the chain is still anchored one hop above it in
|
||||
/// init or the kernel and the recursion stops there. Two hops of manifest, never
|
||||
/// an unbounded walk — a laundering deputy is refused at the first hop nobody
|
||||
/// wrote a row for.
|
||||
fn delegatedOpen(identity: Identity, name: []const u8) bool {
|
||||
if (identity.supervisor_task == 0) return false; // a kernel-spawned caller needs no delegate
|
||||
if (identity.supervisor_vouched) return false; // already answered by `granted` above
|
||||
const delegate = identify(identity.supervisor_task) orelse return false;
|
||||
if (!granted(delegate, .supervise, name)) return false;
|
||||
for (grants[0..grant_count]) |grant| {
|
||||
if (grant.permission != .open) continue;
|
||||
if (!matches(grant.binary, identity.binary)) continue;
|
||||
if (!matches(grant.supervisor, identity.supervisor_binary)) continue;
|
||||
if (!matches(grant.name, name)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn findBinding(name: []const u8) ?*Binding {
|
||||
for (&bindings) |*binding| {
|
||||
if (binding.used and std.mem.eql(u8, binding.nameSlice(), name)) return binding;
|
||||
@@ -501,7 +564,7 @@ fn serveRegistry(request_bytes: []const u8, reply: []u8, sender: u32, arrived: *
|
||||
// Only `bind` claims a capability; one attached to anything else is closed by
|
||||
// the turn's `defer` in the loop, along with the ones sent to a request that
|
||||
// was too short to name a verb at all.
|
||||
if (operation == @intFromEnum(vfs_protocol.Operation.open)) return onOpen(reply, payload);
|
||||
if (operation == @intFromEnum(vfs_protocol.Operation.open)) return onOpen(reply, sender, payload);
|
||||
if (operation == @intFromEnum(vfs_protocol.Operation.readdir)) return onReaddir(reply, cursor);
|
||||
// Everything else a filesystem answers is meaningless here: `/protocol` holds
|
||||
// contracts, not bytes.
|
||||
@@ -576,12 +639,42 @@ fn onBind(sender: u32, raw_name: []const u8, arrived: *Arrival) i32 {
|
||||
}
|
||||
|
||||
/// `open(name)` -> the provider's endpoint, delivered as the reply's capability.
|
||||
/// A name nothing has bound is `-ENOENT`; in P3 an ungranted one becomes the same
|
||||
/// answer, because absence and refusal are deliberately indistinguishable.
|
||||
fn onOpen(reply: []u8, raw_name: []const u8) usize {
|
||||
///
|
||||
/// **A refusal and an absence are the same answer, and that is the whole point.**
|
||||
/// The namespace is the restriction (docs/os-development/protocol-namespace.md):
|
||||
/// what a process may open is what exists for it, so "you may not have this" and
|
||||
/// "there is no such thing" collapse into one reply — `-ENOENT`, no payload, no
|
||||
/// capability. A caller therefore has no oracle: it cannot use `open` to learn
|
||||
/// that a contract it lacks is bound, and — the reason this matters beyond
|
||||
/// tidiness — stage two's supervisor can refuse, stall for a human, or substitute
|
||||
/// a fake without the child being able to tell which happened.
|
||||
///
|
||||
/// Indistinguishable is a claim about *work done*, not only about the bytes, so
|
||||
/// both questions are asked on every open whatever the first one answers: the
|
||||
/// process table is refreshed, the caller identified, the grants scanned and the
|
||||
/// bindings scanned, and only then is the single verdict formed. Nothing here
|
||||
/// logs, either — `klog_read` is ungated (system/kernel/process.zig), so a line
|
||||
/// written on one branch is a line the refused caller can read, and a serial line
|
||||
/// costs milliseconds it could time. The operator's diagnosis is the pair the
|
||||
/// namespace already publishes on purpose: `readdir` over `/protocol` says what is
|
||||
/// bound, `/system/configuration/protocol.csv` says who may reach it, and the
|
||||
/// client's own retry loop says which one it wanted.
|
||||
///
|
||||
/// (Not constant-time in the cryptographic sense, and not claimed to be: the two
|
||||
/// scans stop at the row they match, and the optimiser is free to sink a pure
|
||||
/// table walk past a branch that discards it. What is removed is the difference a
|
||||
/// caller could actually measure or read — a syscall on one branch and not the
|
||||
/// other, a line in a world-readable log ring, or a serial write costing
|
||||
/// milliseconds.)
|
||||
fn onOpen(reply: []u8, sender: u32, raw_name: []const u8) usize {
|
||||
const name = contractName(raw_name) orelse return answer(reply, -envelope.ENOENT, 0, 0);
|
||||
const binding = findBinding(name) orelse return answer(reply, -envelope.ENOENT, 0, 0);
|
||||
pending_capability = binding.endpoint;
|
||||
refreshProcessTable();
|
||||
const identity = identify(sender);
|
||||
const permitted = if (identity) |who| mayOpen(who, name) else false;
|
||||
const binding = findBinding(name);
|
||||
if (!permitted) return answer(reply, -envelope.ENOENT, 0, 0);
|
||||
const found = binding orelse return answer(reply, -envelope.ENOENT, 0, 0);
|
||||
pending_capability = found.endpoint;
|
||||
return answer(reply, 0, 0, 0);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user