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:
2026-07-26 04:44:58 -04:00
co-authored by Claude Opus 5
parent 91c4dedcb0
commit c78eb2dd39
3 changed files with 351 additions and 58 deletions
+220
View File
@@ -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