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:
+127
-57
@@ -130,6 +130,22 @@ impl OwnerCtx {
|
||||
fn client_pid(&self, node: &NodeSnapshot) -> Option<u32> {
|
||||
self.client_pids.get(&node.props.client_id?).copied()
|
||||
}
|
||||
|
||||
/// Does this node have **protected provenance** — an unambiguous Client
|
||||
/// yielding `Some(pipewire.sec.pid)`?
|
||||
///
|
||||
/// ⚠️ Read **before** the pipewire-pulse suppression in [`keys_of`], and
|
||||
/// that ordering is the whole rule (F11-1, below). A Pulse-emulated app's
|
||||
/// Client resolves to the daemon's PID; the value is then omitted from the
|
||||
/// bridge keys as too coarse to *group* on, but it is still a protected
|
||||
/// `pipewire.*` answer to "who is this", so the app keeps its provenance.
|
||||
///
|
||||
/// ❌ Not "a unique Client object exists". A unique Client with
|
||||
/// `sec_pid = None` satisfies that and carries no protected identity at
|
||||
/// all, which is exactly the hole [`owner_is_bounded`] closes.
|
||||
fn client_is_resolved(&self, node: &NodeSnapshot) -> bool {
|
||||
self.client_pid(node).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
|
||||
@@ -257,10 +273,59 @@ fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
|
||||
/// the engine must fail closed rather than declare it clean
|
||||
/// (v3.4 §6.1.1, final paragraph).
|
||||
///
|
||||
/// # 🔴 OPEN, phase-6 blocking — the key union can *reduce* taint here
|
||||
/// # F11-1 — CLOSED. A self-claimed PID is not provenance
|
||||
///
|
||||
/// **Round 11 review, finding 1. Verified correct; deliberately not fixed in
|
||||
/// that round.** Round 10 made key 4 a union of the node's
|
||||
/// **The rule, implemented below:** a strong key (`node.link-group`,
|
||||
/// `pulse.module.id`) bounds an owner on its own; **key 4 bounds an owner only
|
||||
/// when the node's Client resolves** — see [`OwnerCtx::client_is_resolved`].
|
||||
/// A node whose Client cannot be resolved at all is *unbounded*, whatever
|
||||
/// `application.process.id` it puts on itself.
|
||||
///
|
||||
/// The five Client cases, which is the matrix this needed (two of them are
|
||||
/// indistinguishable under the wrong reading of "resolves"):
|
||||
///
|
||||
/// | Client | node's own pid | bounded? | why |
|
||||
/// | --- | --- | --- | --- |
|
||||
/// | **absent** | claimed | **no** | nothing corroborates the claim |
|
||||
/// | **ambiguous** (two Clients, one id) | claimed | **no** | "we do not know who owns this" must not be papered over |
|
||||
/// | **unique but pid-less** | claimed | **no** | a Client object is not an identity; `sec_pid` is |
|
||||
/// | **resolved-native** (`sec_pid` = the app's) | absent | **yes** | protected pid, and it *is* key 4 |
|
||||
/// | **resolved-to-pipewire-pulse** | claimed | **yes** | protected provenance; the daemon pid is suppressed as a *grouping* key only |
|
||||
///
|
||||
/// The last row is what keeps this from being the blunt fix. Applying
|
||||
/// "self-claims are not sound" without the provenance test unbounds every
|
||||
/// Pulse-emulated app — their Client's `sec_pid` is the daemon's and
|
||||
/// suppressed, so the node's own claim is their only per-app identity — which
|
||||
/// re-triggers the §6.1.1 mass over-exclusion the whole design exists to avoid
|
||||
/// and empties the eligible half of the §5.1 matrix.
|
||||
///
|
||||
/// **Cost, measured on the live graph** (2026-07-26): **zero**. The
|
||||
/// before- and after-binaries audited the *same* graph simultaneously — both
|
||||
/// are read-only observers, which is the only way to A/B a partition without
|
||||
/// churn between runs — with a tagged producer feeding the default sink,
|
||||
/// `parec` on its monitor as a real tainted reader (so the sweep was armed,
|
||||
/// not merely present in the code), and Firefox, `aplay` and `pacat` as
|
||||
/// bystanders. **181 records each, the same 14 distinct decision states, none
|
||||
/// exclusive to either side, no `unresolved-owner` on either.** The eligible
|
||||
/// half stayed non-empty throughout: native (`aplay`), Pulse-emulated
|
||||
/// (`pacat`) and Firefox all eligible. O5 is unmoved: identical p50 (15 µs)
|
||||
/// and busy fraction (0.0012), and the after-binary's worst per-record
|
||||
/// recompute was *lower* (217 µs vs 243 µs — noise, same debug build, same
|
||||
/// concurrent load).
|
||||
///
|
||||
/// Why it costs nothing here: every real app on this box is either native
|
||||
/// (Client `sec_pid` = its own pid) or Pulse-emulated (Client `sec_pid` = the
|
||||
/// daemon's), and **both resolve**. Sweeping all 18 live nodes for the
|
||||
/// predicate's inputs directly, the only unresolved-Client nodes were
|
||||
/// `Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose;
|
||||
/// session-manager device nodes are unresolved too (their Client is pid-less)
|
||||
/// but exception 2 already strips key 4 from them. That is the answer the
|
||||
/// deferral was waiting for: the rule bites exactly the anomalous shapes, and
|
||||
/// this host has none.
|
||||
///
|
||||
/// ## The leak it closes (round 11 review, finding 1)
|
||||
///
|
||||
/// Round 10 made key 4 a union of the node's
|
||||
/// `application.process.id` and its Client's `pipewire.sec.pid`, and the claim
|
||||
/// that this was "strictly additive" was too strong: the same key list also
|
||||
/// feeds *this* predicate, so adding a value can move a node from unbounded to
|
||||
@@ -277,55 +342,50 @@ fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
|
||||
/// 3. Neither the bridge nor the sweep fires, and the output stays eligible
|
||||
/// while re-emitting the call.
|
||||
///
|
||||
/// It cannot leak today: `evaluate()` is reached only by the dry-run audit,
|
||||
/// which creates no links. It becomes live when phase 6 consumes eligibility.
|
||||
/// Step 2 is now unbounded ⇒ the sweep fires ⇒ the leg is excluded. Note it
|
||||
/// could not leak *yet* when it was filed — `evaluate()` is reached only by the
|
||||
/// dry-run audit, which creates no links — and that is why the fix waited for
|
||||
/// the §5.1 measurement instead of guessing at its cost.
|
||||
///
|
||||
/// **Why it is not fixed yet.** The principled repair is provenance: a
|
||||
/// self-claimed `application.process.id` is not a *sound* bound, only the
|
||||
/// protected keys are. But applying that bluntly makes every Pulse-emulated
|
||||
/// app unbounded — their Client's `sec_pid` is the daemon's and suppressed, so
|
||||
/// the node's own claim is their only per-app identity — which re-triggers the
|
||||
/// §6.1.1 mass over-exclusion the whole design is built to avoid, and would
|
||||
/// make the eligible half of the §5.1 matrix empty.
|
||||
/// ## What this is deliberately NOT
|
||||
///
|
||||
/// The targeted rule that closes the path above without that cost: **a node
|
||||
/// whose Client cannot be resolved at all must not be bounded by its own
|
||||
/// self-claimed PID.** An ambiguous Client already means "we do not know who
|
||||
/// owns this", and a self-claim must not paper over it; a Pulse app's Client
|
||||
/// *is* resolved (to the daemon's PID, then suppressed), so it keeps its
|
||||
/// bound. Implementing it needs `OwnerCtx` to distinguish "resolved" from
|
||||
/// "absent", and `OwnerKeyIndex` to carry boundedness separately from the key
|
||||
/// set, since bridging must keep using the full union.
|
||||
/// It is not a claim that `application.process.id` is now unused: it still
|
||||
/// bridges (a self-claim is fine as *evidence that two legs are related* —
|
||||
/// the fail-closed direction), and a resolved-Client node is still bounded by
|
||||
/// whichever key-4 value survives suppression. Only *boundedness* — the
|
||||
/// permission to say "I can enumerate this owner's other legs, so a
|
||||
/// differently-keyed output is provably someone else" — now demands a
|
||||
/// `pipewire.*` answer to "who is this".
|
||||
///
|
||||
/// ⚠️ Do this **with the §5.1 matrix data in hand**, not before: the whole
|
||||
/// question is how much over-exclusion the rule actually causes on a real
|
||||
/// graph, and that is measurable rather than arguable.
|
||||
///
|
||||
/// ## Round 12 — the deferral holds, and "resolved" has a trap in it
|
||||
///
|
||||
/// Codex re-examined this and agreed the deferral is defensible while
|
||||
/// `evaluate()` is audit-only, and that the rule above closes the recorded path
|
||||
/// without unbounding normal Pulse-emulated apps — **but only under one
|
||||
/// reading of "resolves"**, and the wrong reading reintroduces the hole:
|
||||
///
|
||||
/// - ✅ "Resolved" must mean **an unambiguous Client that yields
|
||||
/// `Some(pipewire.sec.pid)`**, taken *before* the pipewire-pulse suppression
|
||||
/// step. A Pulse app then still has the daemon's protected PID as
|
||||
/// provenance, even though that value is omitted from the bridge keys, so it
|
||||
/// stays bounded and the eligible half survives.
|
||||
/// - ❌ **Do not** implement it as "a unique Client object exists". A unique
|
||||
/// Client with `sec_pid = None` would satisfy that test while providing no
|
||||
/// protected identity at all, leaving exactly the self-claimed-PID hole this
|
||||
/// rule is meant to close.
|
||||
///
|
||||
/// So the matrix needs five Client cases, not two: **absent**, **ambiguous**,
|
||||
/// **unique but pid-less**, **resolved-native**, and
|
||||
/// **resolved-to-pipewire-pulse**. The third is the one that distinguishes the
|
||||
/// two readings, and it is the row a two-case matrix would silently skip.
|
||||
/// ⚠️ Bridging must keep using the **full** union, so boundedness is carried
|
||||
/// separately from the key set in [`OwnerKeyIndex`] rather than being
|
||||
/// re-derived from it.
|
||||
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
|
||||
keys_of(node, ctx)
|
||||
.iter()
|
||||
.any(|(key, _)| *key != OwnerKey::ClientId)
|
||||
bounded_by(&keys_of(node, ctx), node, ctx)
|
||||
}
|
||||
|
||||
/// [`owner_is_bounded`]'s rule, over an already-computed key list.
|
||||
///
|
||||
/// The single implementation: [`OwnerKeyIndex::build`] has the keys in hand and
|
||||
/// must not recompute them, and two copies of a predicate this load-bearing is
|
||||
/// how the two spellings drift apart.
|
||||
fn bounded_by(keys: &[(OwnerKey, KeyValue)], node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
|
||||
let mut has_process_key = false;
|
||||
for (key, _) in keys {
|
||||
match key {
|
||||
// Strong keys are per-instance and name the sibling set directly.
|
||||
OwnerKey::LinkGroup | OwnerKey::PulseModuleId => return true,
|
||||
OwnerKey::ProcessId => has_process_key = true,
|
||||
// Never: one process can present two `client.id`s (the measured
|
||||
// GStreamer refutation, above).
|
||||
OwnerKey::ClientId => {}
|
||||
}
|
||||
}
|
||||
// F11-1. The key may be the node's own claim, the Client's protected pid,
|
||||
// or both — `keys_of` does not record which, and it does not need to: a
|
||||
// resolved Client is provenance for the node *whatever* value key 4 ends
|
||||
// up carrying, and without one there is no protected identity to stand on.
|
||||
has_process_key && ctx.client_is_resolved(node)
|
||||
}
|
||||
|
||||
/// Owner keys computed once per snapshot.
|
||||
@@ -336,16 +396,27 @@ pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OwnerKeyIndex {
|
||||
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
|
||||
/// Nodes whose owner is positively bounded — see [`owner_is_bounded`].
|
||||
///
|
||||
/// ⚠️ **Stored, not derived from `keys`.** Since F11-1 the predicate needs
|
||||
/// the node's Client as well as its key list, and the two answers are
|
||||
/// deliberately different: the full union still bridges, while a
|
||||
/// self-claimed pid no longer bounds.
|
||||
bounded: BTreeSet<Serial>,
|
||||
}
|
||||
|
||||
impl OwnerKeyIndex {
|
||||
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
|
||||
Self {
|
||||
keys: snapshot
|
||||
.nodes()
|
||||
.map(|node| (node.serial, keys_of(node, ctx)))
|
||||
.collect(),
|
||||
let mut keys = BTreeMap::new();
|
||||
let mut bounded = BTreeSet::new();
|
||||
for node in snapshot.nodes() {
|
||||
let node_keys = keys_of(node, ctx);
|
||||
if bounded_by(&node_keys, node, ctx) {
|
||||
bounded.insert(node.serial);
|
||||
}
|
||||
keys.insert(node.serial, node_keys);
|
||||
}
|
||||
Self { keys, bounded }
|
||||
}
|
||||
|
||||
/// The strongest key these two nodes share directly, if any.
|
||||
@@ -409,11 +480,10 @@ impl OwnerKeyIndex {
|
||||
})
|
||||
}
|
||||
|
||||
/// See [`owner_is_bounded`].
|
||||
/// See [`owner_is_bounded`]. A node outside this snapshot is unbounded,
|
||||
/// which is the fail-closed answer.
|
||||
pub fn is_bounded(&self, serial: Serial) -> bool {
|
||||
self.keys
|
||||
.get(&serial)
|
||||
.is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId))
|
||||
self.bounded.contains(&serial)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user