host/taint: a self-claimed pid is not provenance (F11-1)
Boundedness now requires a strong key, or a key-4 value backed by a **resolved Client** — an unambiguous Client yielding Some(pipewire.sec.pid), read before pipewire-pulse suppression. A node whose Client cannot be resolved at all is unbounded whatever `application.process.id` it puts on itself, so it can no longer spare itself from `propagate_unresolved_owner`'s sweep with a value it made up. Closes the round-11 review's finding 1: the key-4 union could *reduce* taint, because the same key list feeds boundedness and the sweep is armed by an UNbounded tainted reader. The recorded three-step path (reader bounded by its Client's real pid, output leg on an ambiguous Client claiming a bogus pid, no shared key so no bridge either) is now a test. Bridging is untouched: it still uses the full union, so boundedness is stored on OwnerKeyIndex rather than re-derived from the key set, and `bounded_by` is the single implementation of the predicate. Five-case Client matrix as tests (absent · ambiguous · unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse). The pid-less row is the one that distinguishes the correct reading of "resolved" from "a unique Client exists", which would have left the hole open. Mutation-verified: dropping the provenance test fails four of the six rows and passes the two that must not regress. Measured cost on the live graph: zero. Before- and after-binaries audited the same graph simultaneously (tagged producer + parec on the monitor as a real tainted reader, so the sweep was armed) — 181 records each, the same 14 distinct decision states, none exclusive to either side, no unresolved-owner on either, eligible half non-empty throughout. O5 unmoved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1088,6 +1088,226 @@ fn the_nodes_own_process_id_wins_over_its_clients() {
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// F11-1 — a self-claimed pid is not provenance: the five Client cases
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The scaffold every F11-1 row needs: peerspeak's call reaching the hardware
|
||||
/// sink, a **bounded** tainted reader, and an ordinary bystander.
|
||||
///
|
||||
/// ⚠️ The reader must be *bounded* (`sunshine` carries a real pid). An
|
||||
/// unbounded tainted reader trips `propagate_unresolved_owner`'s other tier,
|
||||
/// which sweeps **every** output candidate on the box regardless of its own
|
||||
/// keys — the three "unbounded" rows below would then pass without testing
|
||||
/// anything. The bystander is the other half of that guard: it is bounded via
|
||||
/// the ordinary Pulse shape, so an implementation that unbounded everything
|
||||
/// fails every row instead of passing three of them.
|
||||
///
|
||||
/// Returns the graph, the hardware sink to hang nodes off, and the two nodes
|
||||
/// every row must name in its partition.
|
||||
fn armed_with_a_bounded_reader() -> (Graph, NodeRef, NodeRef, NodeRef) {
|
||||
let mut graph = Graph::new();
|
||||
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let call = graph.peerspeak_node("peerspeak", 7);
|
||||
graph.link(call, hw);
|
||||
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||
graph.link(hw, sunshine);
|
||||
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
|
||||
graph.link(bystander, hw);
|
||||
(graph, hw, call, bystander)
|
||||
}
|
||||
|
||||
/// Case 1 of 5 — **Client absent.** A node that names no Client at all has
|
||||
/// nothing but its own word for who owns it, so it cannot be bounded.
|
||||
#[test]
|
||||
fn an_absent_client_leaves_a_self_claimed_pid_unbounded() {
|
||||
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||
let orphan = graph.node(
|
||||
"no-client",
|
||||
MediaRole::StreamOutput,
|
||||
NodeProps {
|
||||
process_id: Some(70_001),
|
||||
..NodeProps::default()
|
||||
},
|
||||
);
|
||||
graph.link(orphan, hw);
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[("mpv", bystander)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("no-client", orphan, "unresolved-owner"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Case 2 of 5 — **Client ambiguous.** Two live Clients claim the id, so the
|
||||
/// observer missed a removal and we do not know who owns this node. A
|
||||
/// self-claimed pid must not paper over that: this is step 2 of the recorded
|
||||
/// leak path, and before F11-1 the claim bounded the node and spared it.
|
||||
#[test]
|
||||
fn an_ambiguous_client_leaves_a_self_claimed_pid_unbounded() {
|
||||
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||
let shared_id = graph.client(Some(70_010));
|
||||
graph.client_with_id(shared_id, Some(70_011));
|
||||
let app = graph.node(
|
||||
"ambiguous-client",
|
||||
MediaRole::StreamOutput,
|
||||
NodeProps {
|
||||
client_id: Some(shared_id),
|
||||
process_id: Some(70_012),
|
||||
..NodeProps::default()
|
||||
},
|
||||
);
|
||||
graph.link(app, hw);
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[("mpv", bystander)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("ambiguous-client", app, "unresolved-owner"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Case 3 of 5 — **Client unique but pid-less**, and *the row that decides
|
||||
/// which rule is implemented*.
|
||||
///
|
||||
/// A unique Client object exists, so "resolved = a unique Client exists" would
|
||||
/// call this node bounded — leaving the self-claimed-pid hole wide open under a
|
||||
/// rule that looks like it closed it. `sec_pid` is what carries protected
|
||||
/// identity, so `None` means unresolved, and pid-less Clients are ordinary
|
||||
/// (the session manager's is one).
|
||||
///
|
||||
/// A two-case absent/resolved matrix skips this silently. That is why it is
|
||||
/// written out.
|
||||
#[test]
|
||||
fn a_unique_but_pidless_client_leaves_a_self_claimed_pid_unbounded() {
|
||||
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||
let pidless = graph.client(None);
|
||||
let app = graph.node(
|
||||
"pidless-client",
|
||||
MediaRole::StreamOutput,
|
||||
NodeProps {
|
||||
client_id: Some(pidless),
|
||||
process_id: Some(70_020),
|
||||
..NodeProps::default()
|
||||
},
|
||||
);
|
||||
graph.link(app, hw);
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[("mpv", bystander)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("pidless-client", app, "unresolved-owner"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Case 4 of 5 — **Client resolved, native.** `pipewire.sec.pid` is the app's
|
||||
/// own, so provenance and key 4 are the same value and the node is bounded
|
||||
/// without claiming anything itself.
|
||||
#[test]
|
||||
fn a_resolved_native_client_bounds_its_node() {
|
||||
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||
let mpv2 = graph.native_client_node("mpv-native", MediaRole::StreamOutput, 70_030);
|
||||
graph.link(mpv2, hw);
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[("mpv", bystander), ("mpv-native", mpv2)],
|
||||
&[("call", call, "peerspeak-owned")],
|
||||
);
|
||||
}
|
||||
|
||||
/// Case 5 of 5 — **Client resolved to pipewire-pulse.** The row that stops
|
||||
/// this rule from being the blunt fix.
|
||||
///
|
||||
/// Every Pulse-emulated app looks like this: the Client's `sec_pid` is the
|
||||
/// daemon's — suppressed as a *grouping* key, because it would fuse fifteen
|
||||
/// unrelated apps — while the node's own `application.process.id` is the app's.
|
||||
/// Provenance is read **before** that suppression, so the app keeps its bound
|
||||
/// and stays eligible. Reading it after would unbound every Pulse app on the
|
||||
/// box and empty the eligible half of the §5.1 matrix, which is the §6.1.1
|
||||
/// catastrophe arriving through the boundedness door.
|
||||
#[test]
|
||||
fn a_client_resolving_to_pipewire_pulse_still_bounds_its_node() {
|
||||
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 70_040);
|
||||
graph.link(firefox, hw);
|
||||
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[("mpv", bystander), ("firefox", firefox)],
|
||||
&[("call", call, "peerspeak-owned")],
|
||||
);
|
||||
}
|
||||
|
||||
/// **The recorded leak path, end to end** (round 11 review, finding 1).
|
||||
///
|
||||
/// One process, two Clients. Its reading leg claims the daemon's pid — which
|
||||
/// exception 1 suppresses — while its Client holds a real protected pid `A`, so
|
||||
/// the union bounds the reader by `A` and the *unbounded-reader* tier never
|
||||
/// arms. Its re-emitting leg sits on a second Client whose id is **ambiguous**
|
||||
/// (one of the two claimants even holds `A`, so this is not "the guess would
|
||||
/// have been wrong" — it is "a guess is not evidence"), and claims a pid of its
|
||||
/// own. The two legs share no key, so the bridge does not fire either.
|
||||
///
|
||||
/// Before F11-1 the self-claim bounded the output leg, both tiers stayed quiet,
|
||||
/// and it re-emitted the call while eligible. Now the leg is unbounded, the
|
||||
/// bounded-reader tier sweeps it, and `mpv` shows the sweep is still targeted.
|
||||
#[test]
|
||||
fn a_self_claimed_pid_cannot_spare_an_output_leg_the_bridge_cannot_reach() {
|
||||
let mut graph = Graph::new();
|
||||
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||
let call = graph.peerspeak_node("peerspeak", 7);
|
||||
graph.link(call, hw);
|
||||
|
||||
let reader_client = graph.client(Some(80_000));
|
||||
let reader = graph.node(
|
||||
"forwarder-in",
|
||||
MediaRole::StreamInput,
|
||||
NodeProps {
|
||||
client_id: Some(reader_client),
|
||||
process_id: Some(PULSE_PID),
|
||||
..NodeProps::default()
|
||||
},
|
||||
);
|
||||
graph.link(hw, reader);
|
||||
|
||||
let ambiguous = graph.client(Some(80_000));
|
||||
graph.client_with_id(ambiguous, Some(80_001));
|
||||
let output = graph.node(
|
||||
"forwarder-out",
|
||||
MediaRole::StreamOutput,
|
||||
NodeProps {
|
||||
client_id: Some(ambiguous),
|
||||
process_id: Some(80_002),
|
||||
..NodeProps::default()
|
||||
},
|
||||
);
|
||||
graph.link(output, hw);
|
||||
|
||||
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
|
||||
graph.link(bystander, hw);
|
||||
|
||||
// `mpv` staying eligible is what proves the reader is bounded: an
|
||||
// unbounded tainted reader sweeps **every** output candidate, `mpv`
|
||||
// included, and this row would then be testing the wrong tier.
|
||||
assert_partition(
|
||||
&run(&graph, &ctx()),
|
||||
&[("mpv", bystander)],
|
||||
&[
|
||||
("call", call, "peerspeak-owned"),
|
||||
("forwarder-out", output, "unresolved-owner"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() {
|
||||
// v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify
|
||||
|
||||
Reference in New Issue
Block a user