host/taint: correct the buffered-echo scoping (in-threat-model); pin ambiguous-client (Codex round 6)

Codex refuted my round-5 disposition and was right: the buffered-echo gap
is NOT limited to keyless/unbounded readers. A normal PID-bearing app —
recorder, DAW, GStreamer — can read the call, buffer it in application
memory, fully tear down its PipeWire Node *and* Client, then (still the
same live process) open a fresh Client + output and replay. `seed_sticky`
drops the PID fingerprint once every old serial is gone, so the replayed
leg is Eligible. That is in-threat-model, so my "outside the threat model"
claim was false.

- Rewrote the module-doc gap note honestly: in-threat-model, reachable by
  non-adversarial software, sitting on the design's §6.1.3 "full teardown
  ⇒ starts clean" boundary. Framed the two options — (A) accept as a
  documented v1 limitation, (B) process-generation lifetime (PID + /proc
  start-time, phase 3 supplies liveness, §6.1.3 revised). This is a
  designer's decision (it revises the security surface); NOT resolved in
  code. `a_fingerprint_does_not_outlive_its_owner` currently encodes
  Option A and flips under B.
- P2 (fixed): pinned the ambiguous-client-id branch. A mutation
  remembering only the first of two clients claiming one global id
  survived the suite; added a test scoped to the ambiguous owner (the
  global count was masked by the peerspeak owner's client). Verified the
  `.next()` mutation now fails it.

57 tests. Phase 2 is NOT converged — the buffered-echo design decision is
owed to the user before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 04:29:40 -04:00
co-authored by Claude Opus 4.8
parent 65fde92628
commit 279903e56e
2 changed files with 97 additions and 30 deletions
+61
View File
@@ -1745,3 +1745,64 @@ fn a_local_root_receiver_bridges_without_an_inbound_link() {
);
assert_tainted(&decisions, sink, "pixelpass-owned");
}
#[test]
fn an_ambiguous_client_id_remembers_every_claimant_for_stickiness() {
// Round 6 finding 2: nothing pinned the ambiguous-Client branch, so a
// mutation remembering only the first claimant survived. Two live
// clients claim one global id; the tainted owner's node references it.
// If we remember only one and it is the one that later disappears, the
// still-live claimant that reopens an output escapes.
use super::snapshot::{ClientSnapshot, GlobalId};
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);
// Two clients share one global id (the observer saw an id collision).
let shared_id = graph.dangling_id();
let client_a = graph.client_with_id(shared_id, Some(PULSE_PID));
let _client_b = graph.client_with_id(shared_id, Some(PULSE_PID));
assert_eq!(client_a, shared_id);
// The tainted reader references that (ambiguous) client id, no PID/keys.
let reader = graph.node(
"reader",
MediaRole::StreamInput,
NodeProps {
client_id: Some(shared_id),
..NodeProps::default()
},
);
let out = graph.node(
"out",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
..NodeProps::default()
},
);
graph.link(hw, reader);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_eq!(
first.candidates[&out.serial].reason().map(Reason::code),
Some("tainted-owner-bridge")
);
// Both claimants must be remembered, or a mutation keeping only one
// could drop the surviving owner. At least both client serials appear.
let client_members: usize = sticky
.owners
.iter()
.flat_map(|o| o.members.iter())
.filter(|m| matches!(m, super::ObjectRef::Client(_)))
.count();
assert!(
client_members >= 2,
"both ambiguous-id clients should be remembered: {sticky:#?}"
);
let _ = (ClientSnapshot { serial: Serial(0), id: GlobalId(0), sec_pid: None }, firefox);
}