diff --git a/design/multi-user-profiles.md b/design/multi-user-profiles.md new file mode 100644 index 0000000..46b20bf --- /dev/null +++ b/design/multi-user-profiles.md @@ -0,0 +1,2163 @@ +--- +title: "Multi-User / Profiles" +description: "End-to-end design: map a connecting client to a real host OS user account (own auto-logged-in, isolated desktop), configured in the web console; device-assignment + optional per-profile passcode; tvOS bound to the selected Apple TV user." +--- + +> **Status: design, schema-of-record.** This document is the single source of truth for the +> punktfunk "Magic multi-user / profiles" feature. It **supersedes and absorbs** +> [`design/gamescope-multiuser.md`](gamescope-multiuser.md): the deferred per-uid gamescope isolation +> plumbing is now mandatory here, hardened so its EIS/PipeWire sockets live in the target user's +> `XDG_RUNTIME_DIR` rather than the old shared-`/tmp` sketch. Where any earlier per-section draft +> disagrees with this document, **this document wins** — it has already folded in every blocker and +> load-bearing major from the adversarial review (see the Appendix). + +## Table of contents + +1. [Overview & goals](#1-overview--goals) +2. [Concepts & terminology](#2-concepts--terminology) +3. [Architecture at a glance](#3-architecture-at-a-glance) +4. [Data model & persistence (`profiles.rs`)](#4-data-model--persistence-profilesrs) +5. [Wire protocol & C ABI](#5-wire-protocol--c-abi) +6. [Security model & threat analysis](#6-security-model--threat-analysis) +7. [Linux host](#7-linux-host) +8. [Windows host](#8-windows-host) +9. [Web console & management API](#9-web-console--management-api) +10. [Apple clients (macOS / iOS / iPadOS)](#10-apple-clients-macos--ios--ipados) +11. [tvOS profile binding](#11-tvos-profile-binding) +12. [Lifecycle & edge cases](#12-lifecycle--edge-cases) +13. [Rollout, phasing, back-compat & testing](#13-rollout-phasing-back-compat--testing) +14. [Open questions](#14-open-questions) +15. [Appendix — reconciliations applied from adversarial review](#15-appendix--reconciliations-applied-from-adversarial-review) + +--- + +## 1. Overview & goals + +### 1.1 Roadmap intent + +"**Magic multi-user support.**" Today a punktfunk host streams one thing: the host operator's own +graphical session, with one shared input/audio/mic plane. The roadmap promise is that a connecting +client lands in **its human's own desktop, auto-logged-in, isolated**, configured entirely from the +web console — with zero on-box fiddling per connect. A family TV connects and the kid gets the kid's +games; a personal laptop connects and lands straight in the owner's account; a shared Apple TV maps +whoever is in front of it to their profile. + +### 1.2 The three locked product decisions + +1. **A profile = a real host OS user account.** Not a sandbox, not a virtual persona — a profile + resolves to a real Linux `uid` / Windows `SID`, and the session runs *as that user* (their `HOME`, + their Steam library, their files). This is what makes it "their desktop," and it is what forces the + privilege model in §3/§6/§7. +2. **Device-assignment + an optional per-profile passcode.** A paired device cert can be **assigned** + to a profile → frictionless connect (the device *is* the authorization). A profile can also carry + an **optional passcode** (a second factor) so a *shared* device (e.g. a family Apple TV) can pick a + profile that isn't its assignment. +3. **tvOS is bound to the selected Apple TV user.** One Apple TV = one app identity = one host + pairing; `TVUserManager.currentUserIdentifier` selects which profile the client auto-picks. That + identifier is a **client-side hint only** — never a wire trust factor (§6.6, §11). + +### 1.3 What ships per platform (the honesty note) + +This feature creates OS logon sessions and impersonates OS users; the scope it *actually* delivers in +v1 differs per platform, and we state it plainly rather than imply parity: + +- **Linux** delivers genuine **cold auto-login** on first connect — but into a **gamescope game-mode** + session (a fresh nested gamescope running the resolved title / Steam Big-Picture as the target uid), + **not** the user's full KDE/GNOME desktop. The isolating gamescope worker is what makes per-user + separation safe (SEC-3). A full-desktop-per-user path is a later scope. +- **Windows v1** is **fast-user-switch** only: the target user must have been signed in once + (operator pre-login / FUS); the host then switches the single interactive console to them. True + **cold console auto-login needs a custom Credential Provider**, which is **deferred** to a later + phase. Windows is also **sequential** (one active profile at a time — the single-console constraint), + whereas Linux is concurrent. +- **GameStream / Moonlight is excluded** from profiles entirely (cert-only identity, single global + catalog, one global `LaunchSession`); GameStream sessions always run as the operator. This is a + documented protocol fact, not a bug (§6.7). + +--- + +## 2. Concepts & terminology + +One name per concept — used consistently throughout this document and the code. + +| Term | Meaning | +|---|---| +| **Profile** | A host-side record (`crates/punktfunk-host/src/profiles.rs`) binding a `display_name`, an `OsAccount`, device assignments, an optional passcode, library curation, and session policy. Identified by a stable `ProfileId`. | +| **`ProfileId`** | 12 lowercase hex characters, minted `hex(sha256("{display_name}:{nanos}")[..6])` (like `library::new_id`), plus the reserved id `"operator"`. `PROFILE_ID_MAX = 64` everywhere (matches the `char id[65]` ABI struct). | +| **`OsAccount`** | The real OS user a profile maps to: the enum `{ Operator, Linux{username,uid?}, Windows{account_name,sid?,credential} }`. | +| **Operator profile** | `OsAccount::Operator` — the host operator's already-running shared graphical session = **today's behavior** (shared desktop, shared input/audio, multi-occupant). The implicit default. | +| **`default_profile_id`** | The operator-designated profile for *paired-but-unassigned* devices. `None`/dangling ⇒ the synthesized implicit operator profile. | +| **Device assignment** | A device cert **fingerprint** (lowercase hex SHA-256) listed in `Profile.assigned_fingerprints`. Assignment is *the* frictionless authorization; a fingerprint is in **at most one** profile. | +| **Passcode** | An optional per-profile secret (Argon2id PHC, host-side verify). The **second factor** that gates *shared-device* access to a profile. Never the OS password. | +| **Isolation** | A non-Operator profile runs on its own per-uid worker with its own injector/audio/mic/compositor — its bytes and input never touch another user's plane (SEC-1/SEC-3). | +| **Occupancy** | Single-session-per-resolved-OS-user. Keyed by the **resolved uid/SID**, not the profile id. Operator profiles are exempt (multi-occupant). | + +--- + +## 3. Architecture at a glance + +### 3.1 The connect → resolve → session flow + +``` +client zone 1: punktfunk-host (network) zone 0/2 (privileged + per-user) + │ QUIC mTLS + pinned host + ├─ Hello{…, profile_id?} ───► paired-fingerprint gate (punktfunk1.rs:535-564) + ├─ ProfileUnlock{passcode}? ─► resolve_session_profile(fp, id?, passcode?) ── D4 authority table + │ │ Operator → today's shared-desktop pipeline (zone 1, no privilege) + │ │ non-Operator → SEC-3 fail-closed gate (D5): + │ │ • broker present? else SessionUnavailable{NoBroker} + │ │ • occupancy by uid/SID free? else Occupied + │ │ • OpenSession(profile_id) ──────────────► root broker (PAM+setuid) + │ │ spawns per-uid worker + │ ◄─ ProfileReject{reason} │ (any deny → typed reject + close; one passcode try per connection) + │ ◄─ Welcome{resolved_profile}│ echoes the RESOLVED profile id + └─ stream ◄──── encoded AUs ───┘ ◄──── session_fd (encoded AU/audio/input) ──┘ +``` + +### 3.2 The privilege invariant (the headline) + +> **Invariant (D7).** The network-facing process — the one parsing attacker-controlled QUIC and +> linking FFmpeg/CUDA/Vulkan/codec FFI (adversary-exposed) — **MUST NOT hold the privilege to become +> other users.** + +A single codec/QUIC RCE in that process must not equal "log into any mapped OS account." The feature +is built so that the privilege to impersonate lives in a **tiny separate component with no untrusted +parser and no network listener.** + +### 3.3 How the zones realize it (Linux) + +Three trust zones (Linux): + +- **Zone 1 — `punktfunk-host serve` (network, non-root).** Terminates QUIC, runs Leopard FEC + + AES-GCM + UDP `sendmmsg`, serves the mgmt API, **reads** `profiles.json`, verifies the passcode. It + holds *no* power to become another user. It is the adversary-reachable surface. +- **Zone 0 — `punktfunk-session-broker` (root, NEW).** The only root code. No network listener, no + untrusted parser — its sole input is a length-prefixed `serde_json` request over a + `SO_PEERCRED`-restricted `SOCK_SEQPACKET` unix socket. It **trusts only a `profile_id`** (re-reads + `profiles.json` itself — SEC-2), does the PAM session + `setuid`, spawns the per-uid worker, and + treats the zone-1 connection as the **session lifeline**. Caps trimmed to + `CAP_SETUID/SETGID/SETPCAP/KILL` only (D7). +- **Zone 2 — `punktfunk-host session-worker` (target uid, NEW).** Runs **as the profile uid** inside + its PAM/logind session. The *only* process that ever touches that user's **decoded** desktop bytes, + raw input, and mic — because those live in the user's `XDG_RUNTIME_DIR` (mode 0700), inaccessible to + zone 1 by construction. It hands **encoded** AUs + Opus back to zone 1 over an inherited + socketpair and receives opaque input/mic to inject locally. + +### 3.4 Windows: mirror the split (target), with a documented v1 gap + +Windows has no second concurrent interactive desktop on a client SKU, and the host already runs as +**LocalSystem**. The **target architecture mirrors the Linux split**: a non-SYSTEM streamer front-door +with `LogonUser`/`LoadUserProfile`/`CreateProcessAsUserW` + credential unsealing behind a tiny SYSTEM +broker over ALPC/named-pipe with a verified client SID. + +**Windows v1 may ship the front-door as SYSTEM** — but MUST then **document explicitly** that it does +**not** satisfy the §3.2 invariant: a host RCE = full box compromise *plus* recovery of every stored +credential. Operators therefore run Windows multi-user **trusted-LAN only**, GameStream-style. This +gap is called out wherever Windows appears (§6.2, §8, §13). + +--- + +## 4. Data model & persistence (`profiles.rs`) + +**New module:** `crates/punktfunk-host/src/profiles.rs`. **New host dependency:** `argon2 = "0.5"` +(RustCrypto; pure-Rust, builds on MSVC) added to **`punktfunk-host` only** — never to +`punktfunk-core` (D1: the passcode is verified host-side; no client target needs `argon2`). +`serde`/`serde_json`/`sha2`/`hex`/`utoipa` are already present. + +### 4.1 Persistence + +Profiles live in a **new** `~/.config/punktfunk/profiles.json` (`%ProgramData%\punktfunk\profiles.json` +on Windows) — *not* an extension of `native_pairing`. It is owner-only, atomic-write, built from the +exact helpers `native_pairing.rs` already uses (`gamestream::config_dir` / `create_private_dir` (0700) +/ `write_secret_file` (0600 unix / SYSTEM-DACL Windows), `gamestream/mod.rs:245-313`), with the temp + +atomic-rename discipline of `native_pairing.rs:116-127`. + +```rust +// profiles.rs +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::collections::HashMap; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use crate::library::{CustomEntry, GameEntry}; + +/// Bump on any breaking field-semantics change; `serde(default)` covers additive growth. +pub const PROFILES_SCHEMA_VERSION: u32 = 1; +/// Reserved id of the synthesized implicit profile = today's shared operator session. +pub const OPERATOR_PROFILE_ID: &str = "operator"; +/// Max bytes of a wire/ABI profile id (matches `char id[65]` = 64 + NUL). +pub const PROFILE_ID_MAX: usize = 64; + +#[derive(Default, Serialize, Deserialize)] +pub struct ProfilesFile { + /// `0`/absent on a pre-feature/hand-written file is treated as `1`. + #[serde(default)] + pub version: u32, + #[serde(default)] + pub profiles: Vec, + /// Operator-designated default for paired-but-unassigned devices. `None` (or a dangling id) + /// => the implicit operator profile. Never set to `OPERATOR_PROFILE_ID` (that's implicit). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_profile_id: Option, +} + +fn default_path() -> Result { + Ok(crate::gamestream::config_dir().join("profiles.json")) +} +``` + +**A parse failure is never silently dropped.** Unlike the cert store (which `unwrap_or_default`s), a +malformed `profiles.json` keeps the **raw bytes** in memory, logs loudly, and **refuses mutations** +until readable — a later `save()` must never overwrite a file we failed to parse (that would strand +every assigned device). **A failed `save()` is a failed mutation (D11):** do not keep the optimistic +in-memory change; return an error to the operator. `profiles.json` is the single on-disk source of +truth that both zone 1 and the root broker read. + +### 4.2 The `Profile` entity (canonical serde — D3) + +```rust +/// Host-assigned, stable id (the `{id}` in CRUD paths). 12 hex chars, minted like +/// `library::new_id` (`library.rs:1239-1246`): `hex(sha256("{display_name}:{nanos}")[..6])`. +pub type ProfileId = String; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct Profile { + pub id: ProfileId, + pub display_name: String, + /// UI accent ("#RRGGBB" or a brand token). Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accent: Option, + /// Avatar URL or `data:` URL (same "field is a URL the client fetches" convention as + /// `library::Artwork`). Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar: Option, + /// The real OS user this profile lands in. Both platform variants compile cross-platform + /// (like the library providers) so a file authored on one OS round-trips on the other. + pub os_account: OsAccount, + /// Device cert fingerprints (lowercase hex SHA-256) assigned to this profile. A fingerprint + /// is in AT MOST ONE profile (uniqueness enforced on assign + at load). + #[serde(default)] + pub assigned_fingerprints: Vec, + /// Optional secondary unlock. Argon2id PHC. Never serialized to any client-facing DTO. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passcode: Option, + /// Turns the device cert into a TRUE first factor: even an assigned device must supply the + /// passcode. Default false. (Mitigates a stolen device key for high-value profiles — §6.4.) + #[serde(default)] + pub require_passcode_even_when_assigned: bool, + /// Allow a NON-assigned device to enter this passcode-LESS profile (shared-view opt-in). + /// Default false → a passcode-less profile is reachable only by its assigned devices/default. + #[serde(default)] + pub allow_shared_view: bool, + /// Advisory operator metadata: Apple TV `currentUserIdentifier`s normally seen on this profile. + /// NEVER a trust input (D8) — purely a console hint / client auto-select aid. + #[serde(default)] + pub tvos_user_ids: Vec, + /// How the global library is curated for this profile. + #[serde(default)] + pub library_scope: LibraryScope, + /// Profile-private custom titles (same shape as `library::CustomEntry`, `library.rs:1172-1181`). + #[serde(default)] + pub custom_entries: Vec, + /// Per-profile session policy (intersected with the client's request at connect AND Reconfigure). + #[serde(default)] + pub session_defaults: SessionDefaults, + pub created_unix: u64, + pub updated_unix: u64, +} + +/// The mapping to a real OS user account. Serde-tagged so it's self-describing on disk. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum OsAccount { + /// The host operator's already-running shared graphical session — today's behavior. Implicit + /// default. Multi-occupant; served on the shared-desktop backends (kwin/mutter/wlroots). + Operator, + /// A real Linux user. The broker drops to this uid and runs a per-uid gamescope worker. No + /// stored password (PAM is passwordless; the broker is root). `uid` resolved at create time. + Linux { + username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + uid: Option, + }, + /// A real Windows user. Auto-login into the single interactive console needs a primary token; + /// `credential` says how to get one. `sid` resolved at create time (stable across renames). + Windows { + /// "DOMAIN\\user" or ".\\user" (local). + account_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + sid: Option, + credential: CredentialRef, + }, +} + +/// Where the Windows auto-login secret lives. We NEVER store the plaintext password in +/// profiles.json — only a reference into an OS-protected vault that SYSTEM can read. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[serde(tag = "source", rename_all = "snake_case")] +pub enum CredentialRef { + /// No stored secret: the user is already logged in / FUS-reachable. The host only switches the + /// console. Graceful default; nothing secret in our store. + None, + /// A Windows Credential Manager generic-credential target name (CredReadW). Store only the NAME. + CredentialManager { target: String }, + /// An LSA private-data key ("L$punktfunk_", LsaRetrievePrivateData). Store only the key name. + LsaSecret { key: String }, + /// A DPAPI-machine-sealed blob on disk (`cred/.bin`), optionally passcode-wrapped. + /// The default the Windows host writes (§8.6). Store only the relative path. + DpapiBlob { path: String }, +} + +/// Argon2id password-hash record. Self-describing PHC string so a params/algo bump needs no schema +/// change. e.g. "$argon2id$v=19$m=19456,t=2,p=1$$". +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PasscodeHash { pub phc: String } + +/// How the global library is filtered for this profile. Presentation curation, NOT a sandbox (D12). +#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum LibraryScope { + #[default] + All, + /// Only these store-qualified ids are visible (e.g. "steam:570", "custom:abc123"). + Allow { ids: Vec }, + /// Full library minus these ids. + Deny { ids: Vec }, +} + +/// Per-profile session policy. Each field mirrors an existing Hello field / HostConfig knob so the +/// resolver computes `client_request ∩ profile_policy`. `None` = no profile constraint. +#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)] +pub struct SessionDefaults { + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_refresh_hz: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_bitrate_kbps: Option, + /// Mirrors `Hello::compositor` / `HostConfig.compositor` (`config.rs:71`). "kwin"|"mutter"|... + /// VALIDATED at create/update: a non-Operator `os_account` may not set a shared-desktop + /// compositor — the worker always forces gamescope (SEC-3, §6.3), so the web UI + resolver agree + /// up front instead of discovering it as a connect-time denial. + #[serde(default, skip_serializing_if = "Option::is_none")] pub compositor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub gamepad: Option, +} +``` + +**Credential asymmetry (contract).** Linux drops privilege to the target uid (broker `setuid`); the +broker is already root, so **no password is needed or stored** — `CredentialRef` is **Windows-only**. +Windows must materialize a *primary token* for a non-logged-in user, which requires the password (or +an existing session) — hence `CredentialRef`. + +### 4.3 Passcode: Argon2id parameters + verify (D1/D10) + +The passcode is a **secondary factor over an already mutually-authenticated, encrypted QUIC channel** +(the device cert is the primary auth; the host fingerprint is pinned). There is no MITM and no +offline-dictionary exposure on the wire, so — unlike the SPAKE2 pairing PIN — the passcode is sent as +plaintext **inside the established control stream** in a dedicated, never-logged `ProfileUnlock` +message (§5.3), verified host-side, and zeroized immediately. Argon2id protects the *at-rest* file if +`profiles.json` leaks. + +- **Algorithm:** Argon2id, version 0x13. **Params (unified across the whole design):** `m = 19456` + KiB, `t = 2`, `p = 1` (OWASP argon2id floor; ~tens of ms — cheap, online-only). +- **Salt:** 16-byte random per hash via `SaltString::generate(&mut OsRng)`, embedded in the PHC string. +- **Decoy verify (no oracle):** when the target profile is missing or has no passcode, run a **dummy + verify against a fixed decoy PHC** so every `ProfileUnlock` costs ~one Argon2 evaluation regardless + of outcome (no profile-existence / passcode-set timing oracle). +- **Online rate limit + lockout** — the real defense against weak codes — is the in-memory + `PasscodeGate`, keyed by **`(profile_id, client_fp)`** (§4.4, D1). + +```rust +fn hash_passcode(plain: &str) -> Result { + use argon2::{Argon2, Algorithm, Version, Params, PasswordHasher}; + use argon2::password_hash::{SaltString, rand_core::OsRng}; + let salt = SaltString::generate(&mut OsRng); + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::new(19456, 2, 1, None)?); + Ok(PasscodeHash { phc: argon.hash_password(plain.as_bytes(), &salt)?.to_string() }) +} + +fn verify_passcode(hash: &PasscodeHash, candidate: &str) -> bool { + use argon2::{Argon2, PasswordVerifier}; + use argon2::password_hash::PasswordHash; + match PasswordHash::new(&hash.phc) { + Ok(parsed) => Argon2::default().verify_password(candidate.as_bytes(), &parsed).is_ok(), + Err(_) => false, // a corrupt stored hash never authenticates + } +} +``` + +**Entropy floor (D10).** A passcode that **wraps a Windows credential** (§8.6) must be **≥6 +alphanumeric** — a 4-digit PIN is offline-crackable against the AES-GCM blob in minutes. 4-digit codes +are allowed **only** for non-credential (Linux/Operator) profiles. The mgmt route enforces this +(§9.5). + +### 4.4 The `PasscodeGate` (rate limit / lockout) + +```rust +const PASSCODE_FAIL_THRESHOLD: u32 = 5; // failures before hard lockout +const PASSCODE_LOCKOUT_BASE: Duration = Duration::from_secs(2); // backoff 2s · 2^failures +const PASSCODE_LOCKOUT_CAP: Duration = Duration::from_secs(60); +const PASSCODE_HARD_LOCKOUT: Duration = Duration::from_secs(900); // 15 min after threshold + +/// Keyed by (profile_id, client_fp) — NOT per-profile (that is a cross-device DoS; any one device +/// could hard-lock a profile for everyone). In-memory; mirrors the `Pending` restart-clearing design +/// (native_pairing.rs:54-83). One attempt per connection (D1) — no in-handshake backoff sleep. +struct PasscodeGate { entries: Mutex> } +struct GateEntry { failures: u32, next_allowed: Instant } +``` + +**Windows persistence (D1).** The Windows host relaunches on every console-session change +(`service.rs:375-392`), which would otherwise reset the budget. On Windows the `PasscodeGate` counters +(and the occupancy registry) are persisted in the SYSTEM broker / a SYSTEM-DACL on-disk file so a +console-switch restart does not reset the attempt budget. + +### 4.5 Profile resolution authority (D4 — the one table) + +Two indexes from `assigned_fingerprints`: the persisted **forward** list, and an in-memory **reverse** +`HashMap` rebuilt on load + every mutation (a fingerprint maps to at most +one profile; `assign_device` removes it from any prior profile first; load de-dups last-writer-wins). + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResolveVia { Assigned, Default, Selected } + +#[derive(Clone, Debug)] +pub struct ResolvedProfile { + pub id: ProfileId, + pub display_name: String, + pub os_account: OsAccount, + pub session_defaults: SessionDefaults, + pub via: ResolveVia, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProfileError { + NotFound, // unknown profile id → ABI NotFound(-12) + PasscodeRequired, // passcode needed, none/blank supplied → ABI AuthRequired(-11) + PasscodeIncorrect, // passcode wrong → ABI AuthRequired(-11) + NotPermitted, // passcode-less profile, device not assigned, no shared-view → ABI NotFound(-12) + RateLimited { retry_in_secs: u64 }, // PasscodeGate lockout → ABI AuthRequired(-11) +} +``` + +`resolve_session_profile(client_fp, requested_id: Option<&str>, passcode: Option<&str>)` → +`Result`. (The signature takes `requested_id` and `passcode` +**independently** — the wire delivers the id in `Hello` and the passcode in `ProfileUnlock`, and the +*default-with-passcode* row below needs a passcode even when `requested_id == None`.) + +| # | Case | Result | +|---|---|---| +| 1 | `requested=None`, device assigned to profile P | **Grant P** (frictionless) — unless `P.require_passcode_even_when_assigned` → require passcode | +| 2 | `requested=None`, unassigned, default has **no** passcode | Grant default (= operator, today's behavior) | +| 3 | `requested=None`, unassigned, **default has a passcode** | **Require passcode** (NO bypass) | +| 4 | `requested=P`, device assigned to P | Grant (or require passcode iff `require_passcode_even_when_assigned`) | +| 5 | `requested=P`, P has passcode, correct | Grant | +| 6 | `requested=P`, P has passcode, wrong/absent | `PasscodeRequired`/`PasscodeIncorrect` | +| 7 | `requested=P`, P has **no** passcode, device **not** assigned | **Deny** `NotPermitted` — unless `P.allow_shared_view = true` | +| 8 | `requested=P` unknown | `NotFound` | + +The default-profile passcode is **enforced** (row 3 closes the bypass). The plaintext passcode is +wrapped in a `Zeroizing` at the call boundary and zeroized after `verify_passcode` returns. +The synthesized **implicit operator profile** (`id: "operator"`, `display_name: "Host"`, +`os_account: Operator`, `library_scope: All`, no passcode) is never stored; to give the operator +account a passcode/scope, create a real `os_account: Operator` profile and set it as +`default_profile_id`. + +> **Order of evaluation.** `resolve_session_profile` returns the **profile-level** outcome (rows +> above). The downstream **SEC-3 isolation gate**, **occupancy**, and **broker** checks (§6.3, §6.5, +> §7.4) run *after* a successful resolve, in the session gate — they layer `NeedsIsolation`, +> `Occupied`, and `SessionUnavailable{…}` on top. This keeps the data model pure and the security +> gate fail-closed (D5). + +### 4.6 Library scoping + +`library::all_games()` (`library.rs:1472-1491`) returns the merged global `Vec`. Profiles +filter that result; they do **not** fork `library.rs`. + +```rust +impl Profiles { + /// Apply allow/deny to `global`, then append the profile's private custom entries + /// (via `From for GameEntry`, library.rs:1193-1203). Sorted by title like all_games(). + pub fn scoped_library(&self, fp_hex: &str, global: Vec) -> Vec; +} +``` + +Wiring: `GET /api/v1/library` returns `scoped_library(fp, all_games())` for a **streaming-cert** +caller (the `PeerCertFingerprint` arm of `require_auth`, `mgmt.rs:483-489`) and unscoped `all_games()` +for the bearer operator. **Launch resolution uses the same scope** (`library::launch_command` / +`launch_title` resolve the `Hello.launch` id against the *resolved profile's* scoped set), so a +crafted `Hello.launch` cannot bypass a Deny. **`LibraryScope` is presentation curation, not a sandbox +(D12):** the installed titles come from the *target OS user's* own Steam/HOME (the session runs as that +user); real restriction needs OS-level controls in the account. + +### 4.7 Module public API + +```rust +pub struct Profiles { /* Mutex, PasscodeGate */ } + +impl Profiles { + pub fn load() -> Result; + pub fn load_with(path: Option) -> Result; // tests / Windows override + + // --- admin (bearer; web console) --- + pub fn list(&self) -> Vec; + pub fn get(&self, id: &str) -> Option; + pub fn create(&self, input: ProfileInput) -> Result; + pub fn update(&self, id: &str, input: ProfileInput) -> Result>; // preserves id/passcode/created; + // changing os_account clears credential + resolved uid/SID (D10) + pub fn delete(&self, id: &str) -> Result; // side effects per §12.2 + pub fn assign_device(&self, id: &str, fp_hex: &str) -> Result; // unique: removes fp from any other profile + pub fn unassign_device(&self, fp_hex: &str) -> Result; + pub fn set_passcode(&self, id: &str, passcode: Option<&str>) -> Result; // None clears; argon2id; + // re-wraps a passcode-wrapped Windows credential (D10) + pub fn set_default(&self, id: Option<&str>) -> Result<()>; + pub fn default_profile_id(&self) -> Option; + + // --- runtime (session gate + cert-authed clients) --- + pub fn resolve_for_device(&self, fp_hex: &str) -> ProfileResolution; // {Assigned|Default} + pub fn resolve_session_profile(&self, fp_hex: &str, requested_id: Option<&str>, passcode: Option<&str>) + -> Result; + pub fn enumerate_public(&self, fp_hex: &str) -> Vec; // SCOPED roster (D9) + pub fn scoped_library(&self, fp_hex: &str, global: Vec) -> Vec; +} + +/// Create/replace body. No `id` (host-owned), no passcode (set via `set_passcode`). +#[derive(Clone, Debug, Deserialize, ToSchema)] +pub struct ProfileInput { + pub display_name: String, + #[serde(default)] pub accent: Option, + #[serde(default)] pub avatar: Option, + pub os_account: OsAccount, + #[serde(default)] pub assigned_fingerprints: Vec, + #[serde(default)] pub require_passcode_even_when_assigned: bool, + #[serde(default)] pub allow_shared_view: bool, + #[serde(default)] pub tvos_user_ids: Vec, + #[serde(default)] pub library_scope: LibraryScope, + #[serde(default)] pub custom_entries: Vec, + #[serde(default)] pub session_defaults: SessionDefaults, +} +``` + +`Profiles` is shared exactly like `NativePairing`: an `Arc` added to `MgmtState` +(`mgmt.rs:69,83,117`) **and** threaded into the `punktfunk1` accept loop (`punktfunk1.rs:493`) — the +same `Arc`, one source of truth. It is `Send + Sync` (only `Mutex`-guarded state), so the +`tokio::spawn`-per-session model is unaffected. + +### 4.8 Validation at create / update / load (D5/D6/D10) + +- **SEC-3 compositor (D5):** a non-`Operator` `os_account` may not set a shared-desktop compositor + (`kwin`/`mutter`/`wlroots`) in `session_defaults.compositor` — reject at create/update. +- **Occupancy uniqueness (D6):** reject a `profiles.json` (and a create/update) that maps **two + profiles to the same uid/SID** at load/validate time. +- **Entropy floor (D10):** a passcode wrapping a Windows credential must be ≥6 alphanumeric. +- **Fingerprint uniqueness:** `load()` de-dups the reverse index (last-writer-wins) so a hand-edited + file can't make resolution ambiguous. + +### 4.9 Relationship to the existing `PairedClient` store + +Kept **separate**, joined by fingerprint. A device must be **paired** (`punktfunk1-paired.json`, +`native_pairing.rs:22-27`) **and** resolvable to a profile. Pairing answers "may this device connect"; +the profile answers "as which OS user, with which library/limits." Order at the session gate +(`punktfunk1.rs:535-564`): (1) `np.is_paired(fp)` (unchanged; unpaired → pending/bail); (2) **NEW:** +`resolve_session_profile`. Cross-store cleanup: `unpair_native_client` (`DELETE /native/clients/{fp}`) +and `np.remove(fp)` also call `profiles.unassign_device(fp)`. A profile may pre-reference an unpaired +fingerprint (operator pre-assigns) — allowed; the pairing gate still governs connect. + +--- + +## 5. Wire protocol & C ABI + +**No ABI bump.** All additions use the existing trailing-byte / length-prefixed back-compat convention +and the `connect_exN` chaining pattern. `ABI_VERSION` stays `2` (`lib.rs:52`); the host gate +`hello.abi_version == ABI_VERSION` (`punktfunk1.rs:530`) is unaffected. **GameStream excluded** — +profiles are native `punktfunk/1` only (§6.7). + +### 5.1 `Hello` — append `profile_id` after `video_caps` + +The Hello carries the **profile id ONLY** (D1). The passcode is **never** in Hello — Hello feeds +logging / pending-knock / approval records (`punktfunk1.rs:548-557`). + +```rust +// crates/punktfunk-core/src/quic.rs — Hello (struct :42-81, encode :619-656, decode :658-718) +/// Opaque host ProfileId the client wants this session in (NOT an OS username). Appended after +/// `video_caps` as `len u8 || UTF-8` (≤ PROFILE_ID_MAX). Presence forces the name/launch length +/// bytes + the `video_caps` byte to be emitted as placeholders first so it lands at a deterministic +/// offset (same rule launch/video_caps already obey). None = assigned/default (back-compat). +pub profile_id: Option, +``` + +`encode` tail (replacing the `need_placeholders`/video_caps tail at `quic.rs:636-654`): + +```rust +let need_caps = self.video_caps != 0 || self.profile_id.is_some(); +match (&self.name, &self.launch) { + (None, None) if !need_caps => {} + (name, _) => { let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX); + b.push(n.len() as u8); b.extend_from_slice(n.as_bytes()); } +} +if self.launch.is_some() || need_caps { + let l = truncate_to(self.launch.as_deref().unwrap_or(""), HELLO_LAUNCH_MAX); + b.push(l.len() as u8); b.extend_from_slice(l.as_bytes()); +} +if need_caps { b.push(self.video_caps); } +if let Some(p) = &self.profile_id { + let p = truncate_to(p, PROFILE_ID_MAX); b.push(p.len() as u8); b.extend_from_slice(p.as_bytes()); +} +``` + +`decode` field (after the `video_caps` block at `quic.rs:711-716`): + +```rust +profile_id: { + let name_len = b.get(26).copied().unwrap_or(0) as usize; + let launch_off = 27 + name_len; + let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize; + let prof_off = launch_off + 1 + launch_len + 1; // caps byte + 1 + b.get(prof_off).and_then(|&len| { + let len = len as usize; + if len == 0 || len > PROFILE_ID_MAX { return None; } + b.get(prof_off+1..prof_off+1+len).and_then(|s| std::str::from_utf8(s).ok()).map(String::from) + }) +}, +``` + +A malformed tail never fails the handshake (the decoder returns `None`). `PROFILE_ID_MAX = 64`. + +### 5.2 Control messages (CTL_MAGIC + type-byte framing, like `Reconfigure` `quic.rs:215`) + +Type bytes (free range): `MSG_LIST_PROFILES = 0x40`, `MSG_PROFILE_LIST = 0x41`, +`MSG_PROFILE_UNLOCK = 0x42`, `MSG_PROFILE_REJECT = 0x43`. `pub const MAX_PROFILES: usize = 64;` +Each gets `encode()/decode()` mirroring the existing `get_bytes`/`put_bytes` helpers +(`quic.rs:403-419`); decoders validate `CTL_MAGIC` + type byte and exact/`<=` length. + +```rust +/// client→host; an alternate first message (pre-connect enumerate). Wire: CTL_MAGIC‖0x40 +pub struct ListProfiles; + +/// One display-safe profile a paired device may pick. accent/avatar optional (UI only). +pub struct ProfileEntry { + pub id: String, pub display_name: String, + pub accent: Option, pub avatar: Option, + pub passcode_required: bool, + /// Computed for the REQUESTING cert: true → frictionless, render without a failed attempt (D9). + pub assigned: bool, +} +/// host→client. Wire 0x41: CTL_MAGIC‖0x41‖u8 count‖[ u8 id_len‖id ‖ u8 name_len‖name +/// ‖ u8 accent_len‖accent ‖ u8 avatar_len‖avatar ‖ u8 flags(bit0=passcode_required,bit1=assigned) ]* +pub struct ProfileList { pub profiles: Vec } + +/// client→host. THE never-logged message. Sent immediately after Hello, before the host builds the +/// pipeline, iff the connector has a passcode. The handler MUST NOT place `passcode` in any tracing +/// field. Wire 0x42: CTL_MAGIC‖0x42‖u8 len‖passcode UTF-8 (≤ HELLO_PASSCODE_MAX = 64). +pub struct ProfileUnlock { pub passcode: String } + +/// host→client. Terminal typed denial; the host then closes (one passcode try per connection, D1). +pub struct ProfileReject { pub reason: ProfileRejectReason } +pub enum ProfileRejectReason { + PasscodeRequired, WrongPasscode, LockedOut, // → ABI AuthRequired(-11) + NotFound, NotPermitted, NeedsSelection, // → ABI NotFound(-12) + Occupied, // → ABI Occupied(-13) + SessionUnavailable(SessionUnavailableKind), // → ABI SessionUnavailable(-14) + NeedsIsolation, // → ABI NeedsIsolation(-15) +} +pub enum SessionUnavailableKind { NoBroker, LoginFailed, NeedsConsoleSwitch, Busy } +``` + +> **Reconciled out:** the original wire draft's Argon2id-verifier **HMAC challenge-response** +> (`ProfileChallenge`/`ProfileProof`) is **dropped** (D1). It would force `argon2` into +> `punktfunk-core` for every client target (Swift/Kotlin/Rust) for a marginal gain over an +> already-authenticated channel. Only the host depends on `argon2`. There is also **no +> `tvos_attested`** discriminator on any message (D8 — the tvOS identifier is never a wire trust +> signal). + +### 5.3 Host `serve_session` ordering (`punktfunk1.rs`) + +**First-message dispatch** (`punktfunk1.rs:507`): after the `PairRequest` arm and before +`Hello::decode`, branch on `ListProfiles`: + +```rust +if ListProfiles::decode(&first).is_ok() { + // SCOPED roster (D9): only profiles assigned to THIS cert + passcode-protected picker-visible + // ones. Unpaired (when require_pairing) → empty (no roster leak). + let profiles = if peer_is_paired { profiles.enumerate_entries(&fingerprint_hex) } else { vec![] }; + io::write_msg(&mut send, &ProfileList { profiles }.encode()).await?; + first = read_msg(&mut recv).await?; // now expect a Hello on the same bi-stream +} +``` + +**Inside the handshake**, after the `require_pairing` gate (`punktfunk1.rs:564`) and before +`validate_dimensions`: + +```rust +// (1) If the connector sent a passcode, it is the NEXT message after Hello (never in Hello). +let passcode: Option> = match peek_msg(&recv) { + Some(m) if ProfileUnlock::is(&m) => Some(Zeroizing::new(ProfileUnlock::decode(&consume(&mut recv).await?)?.passcode)), + _ => None, +}; +// (2) Resolve per the D4 table. PasscodeGate is keyed (profile_id, client_fp); ONE try this connection. +let resolved: ResolvedProfile = match profiles.resolve_session_profile( + &fingerprint_hex, hello.profile_id.as_deref(), passcode.as_deref().map(|z| z.as_str())) { + Ok(r) => r, + Err(e) => { write_profile_reject(&mut send, reason_for(e)).await?; return Ok(()); } // typed close; device stays paired +}; +// (3) SEC-3 fail-closed gate + occupancy + (Linux) broker open / (Windows) console target — §7.4 / §8.4. +// Any deny → ProfileReject{NeedsIsolation|Occupied|SessionUnavailable{..}} and bail, BEFORE the pipeline. +// (4) Build the pipeline; set Welcome { …, resolved_profile: Some(resolved.id) }; proceed; audit (§6.… / §7.…). +``` + +There is **no in-handshake backoff sleep** — a 60 s backoff or 15 min lockout cannot run inside the +10 s `HANDSHAKE_TIMEOUT` (`punktfunk1.rs:327/702`). Rate limiting is **cross-connection** in the +`PasscodeGate`; a wrong passcode rejects fast and the client reconnects for the next try. + +### 5.4 `Welcome` — resolved-profile echo + +`Welcome` (`quic.rs:164`) drops `Copy` (derive `Clone, Debug, PartialEq, Eq` — small, off the +per-frame path). Append after `color` (`quic.rs:721-751`): + +```rust +/// The profile id the host PLACED this session in (echoes the RESOLVED id — not the display name; +/// the client needs the id to reland + seed the next connect). None = single-user / old host. +/// Appended after `color` as `len u8 || UTF-8` at offset 64. +pub resolved_profile: Option, +``` + +### 5.5 C ABI (`abi.rs`) — `connect_ex6` (D2 canonical) + +Append-only status variants (`error.rs:34`, with matching `PunktfunkError::*` + `status()` arms at +`error.rs:51`): `AuthRequired = -11` (passcode required **or** wrong), `NotFound = -12` (unknown +profile id), `Occupied = -13`, `SessionUnavailable = -14`, `NeedsIsolation = -15`. + +```c +PunktfunkConnection *punktfunk_connect_ex6( + const char *host, uint16_t port, + uint32_t width, uint32_t height, uint32_t refresh_hz, + uint32_t compositor, uint32_t gamepad, uint32_t bitrate_kbps, uint8_t video_caps, + const char *launch_id, + const char *profile_id, /* NULL => default profile (today's behavior) */ + const char *passcode, /* NULL => none; connector sends ProfileUnlock iff non-NULL */ + const uint8_t *pin_sha256, + uint8_t *observed_sha256_out, + const char *client_cert_pem, const char *client_key_pem, + PunktfunkStatus *status_out, /* nullable; receives the typed failure */ + uint32_t timeout_ms); +``` + +- `connect_ex5` delegates with `profile_id = NULL, passcode = NULL, status_out = NULL` (its body moves + into `ex6`). `ex6` parses `profile_id`/`passcode` via `opt_cstr`, threads them into + `NativeClient::connect`, and on `Err(e)` writes `*status_out = e.status()` before returning null. +- Resolved-profile accessor: `const char *punktfunk_connection_profile(const PunktfunkConnection *conn)` + → the resolved profile id the host echoed (`""`/NUL-terminated if None). +- Enumerate (standalone, like `punktfunk_pair`): + +```c +typedef struct PunktfunkProfile { + char id[65]; char display_name[65]; + uint8_t passcode_required; uint8_t assigned; /* assigned flag per D9 */ +} PunktfunkProfile; + +PunktfunkStatus punktfunk_list_profiles( + const char *host, uint16_t port, + const uint8_t *pin_sha256, uint8_t *observed_sha256_out, + const char *client_cert_pem, const char *client_key_pem, + PunktfunkProfile *out, size_t cap, size_t *count_out, uint32_t timeout_ms); +// fills min(cap, available); *count_out = total available. The host NUL-terminates each char[65]. +``` + +(The wire `ProfileEntry` carries `accent`/`avatar`; the fixed C struct drops them — native pickers use +a monogram fallback, or fetch richer detail via the REST enumerate if needed.) Regenerate +`include/punktfunk_core.h` (cbindgen; CI checks drift). + +### 5.6 `NativeClient` (`client.rs`) + +`connect` (`client.rs:238`) gains two params after `launch`: + +```rust +pub fn connect(host:&str, port:u16, mode:Mode, compositor:CompositorPref, gamepad:GamepadPref, + bitrate_kbps:u32, video_caps:u8, launch:Option, + profile:Option, passcode:Option, // NEW + pin:Option<[u8;32]>, identity:Option<(String,String)>, timeout:Duration) -> Result +``` + +`worker_main` sets `Hello.profile_id = profile`; **iff `passcode` is non-NULL**, writes one +`ProfileUnlock { passcode }` immediately after the Hello, then reads the next message: a `Welcome` +(success) or a `ProfileReject` → typed error (`reason → PunktfunkError`). `Negotiated` +(`client.rs:45`) gains a 9th element `resolved_profile: Option` from `welcome.resolved_profile`; +add `pub fn resolved_profile(&self) -> Option<&str>`. New +`NativeClient::list_profiles(host, port, identity, pin, timeout) -> Result<(Vec, [u8;32])>` +mirrors `pair()`: open a bi-stream, write `ListProfiles`, read `ProfileList`, return entries + observed +fingerprint. + +**Caller updates:** every `NativeClient::connect` site adds `None, None` until its UI lands +(`clients/linux/src/{session,app}.rs`, `clients/windows/src/session.rs`, +`clients/android/native/src/session.rs`, host self-tests). Every `Hello { … }` literal adds +`profile_id: None` (the `quic.rs` test constructors; `clients/probe/src/main.rs` gets `--profile` / +`--passcode` flags). Every `Welcome { … }` literal adds `resolved_profile: None`. + +### 5.7 mDNS + REST mirror + +- mDNS TXT gains `prof=1` (`discovery.rs:56`) so clients show the profile UI only against a supporting + host (and skip a wasted `ListProfiles` to an old host). +- REST `GET /api/v1/profiles/enumerate` (cert-allowlisted, **same scoping** as the control stream) is + the operator-console / pre-connect secondary path. The **control-stream `ListProfiles` is PRIMARY** + for native clients (no mgmt-port knowledge needed mid-connect). `GET /api/v1/profiles` stays + **bearer-only** (full admin list). + +### 5.8 Back-compat matrix + +| Client \ Host | **Old host** (no profiles) | **New host** (profiles built) | +|---|---|---| +| **Old client** (no `profile_id`) | Today's single session. Unchanged. | Hello lacks the trailing `profile_id` → `requested=None` → assignment-or-default (D4). Old client cannot answer a passcode → a passcode-gated profile is unreachable; such a device must be **assigned** or stays on the default. `resolved_profile` ignored by the old decoder. **Works.** | +| **New client** | Trailing `profile_id` ignored by old `Hello::decode`; old host runs its single session. `Welcome` omits `resolved_profile` → client sees `None`, hides the picker. `prof` absent in TXT → no profile UI. A stray `ListProfiles` to an old host fails first-message decode → connector falls back to a plain `connect(profile=None)`. **Graceful.** | Full feature: `connect_ex6` carries `profile_id`; connector sends `ProfileUnlock` iff passcode non-NULL; Welcome echoes the resolved id. | + +A `Hello` with `profile_id=None` and no new fields is **byte-identical** to today's Hello. + +--- + +## 6. Security model & threat analysis + +### 6.0 Principals, assets, adversaries + +**Principals:** *Operator* (owns the box; holds the mgmt bearer token, +`~/.config/punktfunk/mgmt-token` 0600, constant-time compared `mgmt.rs:532`; sole editor of +`profiles.json`; root-equivalent). *Paired device* (a client cert pinned in `punktfunk1-paired.json`, +mTLS-authenticated every connect). *Profile user* (a real OS account a profile maps to). *Shared +device* (one cert used by several humans — a family Apple TV). + +**Assets (ranked):** OS-session-creation capability (the broker) > each profile user's desktop bytes + +input stream > the Windows auto-login credential blob > `profiles.json` (OS-user map, passcode hashes, +assignments) > mgmt token > per-session AES-GCM keys (already per-session random in `Welcome.key/salt`, +`quic.rs:172-173` — no new work). + +**Adversaries:** (A) hostile unpaired LAN device; (B) paired-but-malicious device / stolen client key; +(C) unprivileged local OS user on the box; (D) a passenger on a shared device wanting another human's +profile; (E) the network-facing host post-RCE (codec/QUIC bug). + +### 6.1 Privilege split (D7) and its invariants + +The §3.2 invariant is realized by the zone split (§3.3). The do-not-regress invariants: + +- **SEC-1 — no cross-uid byte access.** A profile user's *decoded* frames, raw input, and mic audio + are handled **only** by a process running as that uid (the zone-2 worker). Zone 1 sees only encoded + media + forwards opaque input. This supersedes the shared-`/tmp` EIS socket of the old + `gamescope-multiuser.md` — the sockets now live in the user's `XDG_RUNTIME_DIR`. +- **SEC-2 — broker trusts only the profile id.** The broker resolves `profile_id → {uid, username}` + from its **own** root-owned read of `profiles.json`, refuses an absent id, and **never** accepts a + raw uid/username/SID over IPC. A compromised zone 1 can only open sessions for OS users the operator + already mapped — not `root`, not an arbitrary account. +- **SEC-3 — isolation-required backends.** A profile whose OS user ≠ operator MUST run on an isolating + gamescope per-uid worker; shared-desktop backends (kwin/mutter/wlroots) serve the operator profile + **only**. Enforced fail-closed at resolve time (§6.3). + +### 6.2 Host privilege model per platform + +**Linux — root session-broker + per-uid worker (D7).** `punktfunk-session-broker` is a `root` systemd +**system** service; `punktfunk-host` stays non-root. Broker hardening: caps +`CAP_SETUID/SETGID/SETPCAP/KILL` **only** — `CAP_SYS_ADMIN` and `CAP_DAC_OVERRIDE` are **dropped** +(the broker creates no namespaces and root-owner bits already grant the reads it needs; keeping them +would make the "tiny TCB" claim notional). `RestrictAddressFamilies=AF_UNIX` (never `AF_INET*`), +`RestrictNamespaces=true`, `SystemCallFilter=@system-service @setuid`, `MemoryDenyWriteExecute=yes` +(**re-verified** against the PAM module loader in the Phase-0 spike — if a module trips it, document +the `MDWX=no` exception). Full unit + handler in §7.1. + +**Windows — mirror the split (target), SYSTEM front-door (v1 gap).** Target: non-SYSTEM streamer + +tiny SYSTEM broker with a verified client SID (the Windows analogue of SEC-2). **v1 may keep the +front-door as SYSTEM** but then **does NOT satisfy** the invariant — a host RCE = full box compromise ++ recovery of every stored credential. This is documented as a residual; operators run Windows +multi-user trusted-LAN only (§8.1, §13 Phase 3 gate). + +### 6.3 Fail-closed isolation gate (SEC-3 / D5) + +> The SEC-3 check lives **with resolution**, never "resolve-then-hope." At resolve time, if +> `os_account != Operator` **and** the isolating worker/broker is not present/available, the host +> **rejects** with `SessionUnavailable{NoBroker}` / `NeedsIsolation` **before the session pipeline is +> built**. "Non-Operator profile served on a shared host-lifetime injector/audio/mic singleton" is an +> **impossible state** — it would inject device D's keystrokes into another user's live desktop (the +> catastrophic leak). + +Operator profiles keep today's shared-desktop multi-view (KWin/Mutter/wlroots) and are multi-occupant. +The brokered worker **always forces `Compositor::Gamescope`** regardless of the operator's own +compositor, so a Mutter/KWin operator desktop coexists with isolated gamescope users on the same box +(§7.4). + +### 6.4 The passcode as a second factor (D1/D10) + +The passcode is an **optional per-profile** secret gating *shared-device* access (decision #2). It is +**not** the OS password and never touches PAM/LogonUser auth directly (except as the Windows +credential-wrap key, §8.6). + +- **Storage/verify:** Argon2id PHC only, m=19456/t=2/p=1, decoy-verify on miss, host-side + (§4.3). Never logged, never echoed in errors. +- **Plaintext-over-channel is sound here:** the channel is QUIC TLS 1.3 (confidential) **and** + cert-mutually-authenticated (the device is already paired) — unlike the *pairing* PIN, where SPAKE2 is + needed because no trust exists yet. So a plain host-side verify is correct and a PAKE only adds a + round trip. The passcode rides the never-logged `ProfileUnlock`, **never** Hello. +- **One attempt per connection; cross-connection `PasscodeGate`** keyed `(profile_id, client_fp)` so + one device cannot DoS-lock a profile for others; Windows persists the counters across the + console-switch relaunch. +- **Stolen paired key (adversary B):** an attacker with a device's key+cert lands in the profiles that + device is **assigned** to (frictionless skips the passcode). For high-value profiles the operator + sets `require_passcode_even_when_assigned = true`, turning the cert into a *first* factor and the + passcode into a *true second* factor (a stolen key alone is then insufficient). Recovery from a + stolen key is unpairing the fingerprint (`DELETE /api/v1/native/clients/{fp}`). + +### 6.5 Occupancy (D6) + +Single-occupancy is keyed by the **resolved Linux uid / Windows SID**, not the profile id — so two +profiles mapping to the same OS user can't both occupy it (Steam single-instance / `/run/user/` / +socket-name collisions). **Operator profiles are exempt** (multi-occupant, preserves +`--max-concurrent` multi-view). A `profiles.json` mapping two profiles to the same uid/SID is rejected +at load/validate. A reconnect by the **same device to the same profile** preempts (terminate the prior +session, like the IDD-push reconnect-preempt `punktfunk1.rs:2449-2456`); a **different** profile while +one is active → `Occupied`. On Windows occupancy is **global** (single console), persisted in the +SYSTEM broker so a console-switch relaunch can't double-grant. + +### 6.6 tvOS identifier is a UI hint, not a trust factor (D8) + +`TVUserManager.currentUserIdentifier` is client-asserted and Apple provides **no signed token** → the +host **cannot** verify it, so it **never rides the wire**. It is used **only** for client-side +auto-selection of the mapped profile. A `tvos_user_ids` match **still requires the profile's passcode** +for any passcode-protected profile (no skip). There is **no `tvos_attested`** wire signal. An +un-entitled build degrades to the manual picker + passcode — the only sound mode anyway (§11). + +### 6.7 GameStream / Moonlight exclusion (D13) + +Profiles are **`punktfunk/1`-only**, by protocol fact: GameStream pairing stores only the client cert +DER (no name/profile, `gamestream/pairing.rs`), the catalog is the single global `apps.json`, and +`nvhttp.rs` holds one `Mutex>` (one global session). There is no Hello-equivalent +to carry a profile id/passcode and no per-user identity to map. A host with `profiles.json` **and** +`serve --gamestream` MUST **pin every GameStream session to the operator profile** and emit an audit +line recording the pinning. Stated in the docs and the `profiles` OpenAPI tag description. + +### 6.8 Threat model with explicitly-stated residuals + +| # | Residual | Mitigation / posture | +|---|---|---| +| R1 | **Host-RCE input-injection into live sessions (SEC-1 residual).** SEC-1 protects byte *confidentiality* and session *creation*, not input *integrity*: zone 1 forwards opaque input, so an RCE'd zone 1 can synthesize keyboard/mouse into a currently-active profile desktop (code-exec as that user) — it just can't read the pixels. | The **worker** (not zone 1) owns + rate-limits the EIS/uinput grant; per-session authorization bounds blast radius. Documented: a host RCE yields input-level code-exec into *live* sessions. | +| R2 | **Windows v1 SYSTEM front-door (D7 gap).** A codec/QUIC RCE = full box compromise + recovery of every stored credential — the very "host-as-root" posture rejected for Linux. | Documented; mirror-split is the target architecture; v1 = trusted-LAN-only, GameStream-style posture (operator's explicit go/no-go, §13 Phase 3). | +| R3 | **tvOS client-asserted identifier (D8).** A modified/stolen shared-TV key can claim any `currentUserIdentifier`. | The identifier is a UI hint only; the **only** real factors on a shared TV are the device cert (device-level) + the passcode (human-level). Passcode is required regardless of any tvOS "match." | +| R4 | **`LibraryScope` is curation, not a sandbox (D12).** Visibility filtering only; once in their own session the user can launch anything via the store UI; installed titles are the target user's own. | Launch-by-id resolution is scoped (a crafted `Hello.launch` can't bypass a Deny). Real restriction needs OS-level parental controls. Never marketed as enforcement. | +| R5 | **Passcode-less Windows credential recoverable from a stolen disk image (D10).** `CRYPTPROTECT_LOCALMACHINE` blobs decrypt from the SYSTEM/SECURITY hives on the same disk, not only by a live SYSTEM process. A 4-digit wrap is also offline-crackable against the GCM blob in minutes. | Entropy floor ≥6 alphanumeric for credential-wrapping passcodes; bind the wrap KDF to a TPM-sealed / host-identity-derived secret; reserve passcode-less credentials for **dedicated low-privilege local accounts** (never a domain/admin account). | +| R6 | **Zone-1 RCE can open sessions for any mapped user** (not root/unmapped). | SEC-2 + `SO_PEERCRED` + broker syscall filter + per-open audit (zone-0 stream a zone-1 RCE can't erase). Map profiles to dedicated low-priv accounts. | +| R7 | **Passwordless `/etc/pam.d/punktfunk` is a standing box-wide service.** Any root-context caller of `pam_start("punktfunk", user)` gets a passwordless session. | Documented broker-only + security-sensitive; harden with `pam_succeed_if` to the mapped accounts; keep `account required pam_unix.so` so disabled/expired accounts are refused. | + +**Audit (`punktfunk::audit`, structured, never secrets):** profile resolution (every connect: +`client_fp`, `profile_id`, outcome); passcode attempts (`result`, `failures_so_far` — never the +passcode); OS-session create/teardown (the **broker, zone 0** — the one record a zone-1 RCE can't +tamper: `profile_id`, `uid`, `xdg_session_id`, `worker_pid`, open/close+reason); profile administration +(mgmt mutations, operator principal). Windows writes the corresponding Event Log entries. + +--- + +## 7. Linux host + +The Linux realization of the privilege split (D7), the fail-closed isolation gate (D5), uid-keyed +occupancy (D6), lifecycle/reaping (D11), and the honest cold-login + packaging story (D12). On Linux +the whole feature is the difference between **`OsAccount::Operator`** (today's shared desktop, +unchanged) and **`OsAccount::Linux{username, uid}`** (a real OS user, auto-logged-in, +input/audio-isolated). + +### 7.0 The three processes and the one new boundary + +``` + zone 0 (root) zone 1 (network, non-root) zone 2 (target uid) + punktfunk-session-broker punktfunk-host (serve) punktfunk-host session-worker + tiny TCB, no codec/QUIC QUIC/FEC/AES-GCM, mgmt API, gamescope(uid) + capture + PAM open · setuid · spawn profiles.json (READ), + NVENC/VAAPI encode + ▲ │ fork+exec passcode VERIFY, occupancy ▲ + per-session injector(EIS) + │ uds │ (SCM_RIGHTS: │ session_fd │ + null-sink audio + mic + OpenSession session_fd) ▼ framed AU/audio/input runs INSIDE the uid's + /run/punktfunk/broker.sock ◄──────────┘ ◄───────────────────────► logind session (linger) + (0600, SO_PEERCRED==host uid) (lifeline = broker conn) owns U's XDG_RUNTIME_DIR +``` + +**Split point.** Zone 1 stays the **sole QUIC + data-plane terminator** (`Session` = Leopard FEC + +AES-GCM + UDP `sendmmsg`). The zone-2 worker does **capture + encode + inject + audio** *as the uid* +and exchanges **opaque encoded AUs / Opus / input** with zone 1 over one inherited `SOCK_SEQPACKET` +socketpair (`session_fd`) — the exact shape of the shipping Windows two-process secure-desktop relay +(`virtual_stream_relay` `punktfunk1.rs:2408`; `capture::wgc_relay`), reusing that AU-framing +discipline so SEC-1 holds without a per-uid QUIC endpoint. + +**New code surface:** + +| Artifact | Kind | TCB notes | +|---|---|---| +| `crates/punktfunk-broker/` | new crate (`proto.rs` + `bin/punktfunk-session-broker.rs`) | deps only: `serde`/`serde_json`, `nix`/`libc`, `pam-sys`, `sd-notify`. **No** `quinn`/`ffmpeg`/`cuda`/`punktfunk-host`. ~few-hundred LoC. | +| `punktfunk-host session-worker` | new subcommand (sibling to `service`/`driver`/`web`) | *is* zone 2; reuses `vdisplay`/`capture`/`encode`/`inject`/`audio` verbatim. | +| `crate::broker_client` | new host module | zone-1 side: connect the broker, `OpenSession`/`CloseSession`, receive `session_fd` via `SCM_RIGHTS`. | +| `crate::session_relay` | new host module | zone-1 ↔ worker `session_fd` framing + `virtual_stream_brokered`. | +| `scripts/punktfunk-session-broker.{socket,service}` · `scripts/pam/punktfunk` · `scripts/tmpfiles/punktfunk.conf` | systemd/PAM/tmpfiles | shipped by deb/rpm/copr/arch/bootc. | + +### 7.1 Zone 0 — `punktfunk-session-broker` + +**Socket + peer auth.** Created by **systemd socket activation** (`ListenStream=/run/punktfunk/broker.sock`, +`SocketUser=punktfunk`, `SocketMode=0600`) so ownership/mode are declarative. The broker re-checks +`SO_PEERCRED` on every `accept()` and rejects any peer whose `ucred.uid != configured host uid` +(adversary C must not call it even if the DACL were widened). + +**Wire protocol (passcode never crosses here):** + +```rust +// crates/punktfunk-broker/src/proto.rs (shared by broker + crate::broker_client) +pub const PROTO_VERSION: u32 = 1; + +#[derive(Serialize, Deserialize)] +pub enum BrokerRequest { + /// Host passes ONLY a profile_id (SEC-2: broker re-reads profiles.json itself). client_fp is for + /// the broker's independent audit. The authoritative (re-clamped) mode rides SessionInit over + /// session_fd, so the LAUNCH command never enters the root TCB. + OpenSession { proto: u32, profile_id: String, client_fp: String, mode: Mode }, + CloseSession { session_id: u64 }, // idempotent; lifeline EOF is the crash-path reaper +} +#[derive(Serialize, Deserialize)] +pub enum BrokerReply { + Opened { session_id: u64, uid: u32, worker_pid: u32 }, // session_fd via SCM_RIGHTS, out-of-band + Error { kind: BrokerError, msg: String }, +} +#[derive(Serialize, Deserialize, Clone, Copy)] +pub enum BrokerError { + NoSuchProfile, // absent from broker's own profiles.json read (SEC-2) + NotIsolatable, // resolves to Operator — zone-1 bug, must not brokerage it + MissingGroups, // target uid lacks render/video/input (D12) — msg names which + LingerFailed, PamFailure, SpawnFailure, Busy, +} +``` + +`Mode` is a leaf re-export of `punktfunk_core::Mode` so the crate doesn't pull all of core. Framing: +4-byte LE length + `serde_json` (auditable, no custom parser to fuzz); `SOCK_SEQPACKET` for +kernel-enforced message boundaries. + +**`OpenSession` handler order** — the non-obvious part is making a **cold** OS user (never +interactively logged in, headless box, no seat) get a real `XDG_RUNTIME_DIR`, user D-Bus, and tracked +logind session: + +1. **`SO_PEERCRED` gate** — `ucred.uid == host_uid` or `Error`. +2. **Resolve `profile_id → (username, uid)` from the broker's OWN read of `profiles.json`** (root-owned; + `stat`+reload per request so mgmt edits are picked up; a parse failure denies, never serves stale). + Reject `OsAccount::Operator` with `NotIsolatable`; absent id with `NoSuchProfile`. +3. **Group preflight (D12)** — `getpwnam_r` + `getgrouplist`; require `render`, `video`, `input` (GPU + `renderD128`; `uinput`/`uhid`; PipeWire). On a miss → `MissingGroups{msg:"user 'kids' not in: render, input"}` + → surfaced to the client as `SessionUnavailable{LoginFailed}` with that reason, not a black screen. +4. **`loginctl enable-linger`** (`org.freedesktop.login1.Manager.SetUserLinger(uid,true,false)`, + idempotent) — provisions `/run/user/` (0700) and starts `user@.service` (PipeWire/ + WirePlumber) with no seat/graphical login. On failure → `LingerFailed`. +5. **PAM session** with the items `pam_systemd` needs to classify a `user` (not `background`) session: + ```text + pam_start("punktfunk", username, &conv_null, &pamh) + pam_set_item(PAM_TTY, "pts/punktfunk") pam_set_item(PAM_RUSER, "root") + pam_putenv("XDG_SESSION_CLASS=user") pam_putenv("XDG_SESSION_TYPE=wayland") + pam_putenv("XDG_SESSION_DESKTOP=gamescope") + pam_authenticate(0) // pam_permit — passwordless (broker is root) + pam_acct_mgmt(0) pam_setcred(PAM_ESTABLISH_CRED) pam_open_session(0) + ``` +6. **Verify the session class** (`sd_uid_get_state` / inspect the new `XDG_SESSION_ID`) is `user`; a + `background` class → close + `PamFailure` (belt-and-suspenders with linger, which already guarantees + the runtime dir). +7. **`fork()`**; child: `pam_getenvlist` → set the session env → `setgid(pw_gid)` → + `initgroups(username, pw_gid)` (picks up render/video/input) → **`setuid(uid)`** → + **re-assert `getuid()==uid && setuid(0)==EPERM`** (drop is irreversible) → + `prctl(PR_SET_PDEATHSIG, SIGKILL)` → **drop the capability bounding set** (worker holds zero caps) → + dup the worker `session_fd` end to fd 3 → `execve punktfunk-host session-worker --session-fd 3 --mode WxHxHz --uid N`. +8. Parent records `session_id → SessionRec{pamh, worker_pid, uid, peer_conn}`, writes the **zone-0 + audit** line, replies `Opened` with the **zone-1 end** of the socketpair via `SCM_RIGHTS`. +9. **Teardown** (on `CloseSession`, lifeline EOF, or worker `SIGCHLD`): `pam_close_session` + + `pam_setcred(PAM_DELETE_CRED)` + `pam_end`; `kill(worker_pid)` if alive; `waitpid`; audit `close`. + Linger is left enabled (a provisioning property of the user) unless the operator unprovisions. + +**Hardening unit** (`scripts/punktfunk-session-broker.service`): `Type=notify`, `User=root`, +`CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_KILL`, `AmbientCapabilities=`, +`NoNewPrivileges=no` (the broker's whole job is to change uid), `RestrictAddressFamilies=AF_UNIX`, +`RestrictNamespaces=true`, `SystemCallFilter=@system-service @setuid`, `ProtectKernelModules=yes`, +`MemoryDenyWriteExecute=yes` (re-verified in the Phase-0 spike). **PAM service** `scripts/pam/punktfunk`: + +```text +auth required pam_permit.so # passwordless — broker is already root +account required pam_unix.so # still honors account lock/expiry +session required pam_systemd.so # registers the logind session + XDG_RUNTIME_DIR +session required pam_unix.so +``` + +### 7.2 Zone 2 — `punktfunk-host session-worker` (per-uid) + +The existing per-session machinery, but **its own process, running as the uid**, with **per-session** +injector/audio/mic instead of the host-lifetime singletons. Re-entered as `punktfunk-host +session-worker` (no second binary). Entry sketch: + +```rust +// crates/punktfunk-host/src/session_worker.rs +pub fn run(opts: WorkerOpts) -> Result<()> { + let mut chan = session_relay::WorkerChannel::from_raw_fd(opts.session_fd); // fd 3 → zone 1 + let init = chan.recv_init()?; // SessionInit{ mode, launch_cmd, bitrate_kbps, bit_depth } + + // (1) Force the isolating backend — a FRESH nested gamescope as this uid (own EIS/GPU/PipeWire/ + // null-sink). SEC-3 by construction; the client's + operator's compositor prefs are irrelevant. + let mut vd = vdisplay::open(Compositor::Gamescope)?; + vd.set_launch_command(init.launch_cmd); // per-instance, never the process-global env (vdisplay.rs:56) + + // (2) Same retry helper zone-1 uses — capture+encode run HERE. + let plan = session_plan::SessionPlan::resolve(init.bit_depth); + let (mut capturer, mut enc, ..) = + build_pipeline_with_retry(&mut vd, init.mode, init.bitrate_kbps, init.bit_depth, plan)?; + + // (3) Per-session, uid-owned side services (NOT the host-lifetime singletons): + let injector = SessionInjector::start(vd.current_ei_socket()); // EIS bound to THIS socket + let audio = SessionAudio::start(vd.audio_sink_node())?; // null-sink monitor + let mic = SessionMic::start(); // "punktfunk-mic-" + + // (4) encode → frame the AU → write to fd 3. Input/mic come back the other way. + run_worker_loop(&mut capturer, &mut enc, &mut chan, &injector, &audio, &mic) +} +``` + +`build_pipeline_with_retry`/`build_pipeline` (`punktfunk1.rs:3430/3499`) are reused **as-is**. The only +refactor is the **AU sink seam** — today `virtual_stream` pushes each encoded AU into the in-process +`Session`; extract a trait: + +```rust +pub trait AuSink { fn push_au(&mut self, au: &[u8], pts_ns: u64, keyframe: bool) -> Result<()>; } +// zone-1 Operator path: impl AuSink for &mut Session (today's behavior; nothing moves) +// zone-2 worker path: impl AuSink for WorkerChannel (writes a tagged frame to fd 3) +``` + +Zero-copy paths (`frame.is_cuda()`) are untouched — the worker is just a different consumer of the +same encoded bytes (5K@240: the AU is already one `sendmmsg`-batched object; the SEQPACKET hop is one +`write`, same as the Windows relay). Gamepads stay correct: the worker owns its uinput/uhid pads (from +the resolved `welcome.gamepad`), so the devices live in the uid's namespace and rumble/HID flows back +over fd 3 → zone 1 → client datagram. + +### 7.3 The four gamescope-multiuser isolation pieces (now mandatory) + +These are `gamescope-multiuser.md`'s parked plumbing, now mandatory and hardened to live inside the +worker (sockets in the uid's `XDG_RUNTIME_DIR`, not shared `/tmp` — the SEC-1 improvement): + +1. **Per-instance EIS socket → new `VirtualOutput` field.** `vdisplay.rs:29-47` gains + `#[cfg(target_os="linux")] pub ei_socket_file: Option`. `vdisplay/linux/gamescope.rs` + replaces the global `EI_SOCKET_FILE="/tmp/punktfunk-gamescope-ei"` (`:778`) with a per-instance path + `format!("{}/punktfunk-gamescope-{id}-ei", env("XDG_RUNTIME_DIR"))`; `current_ei_socket()` hands it + to the worker. The legacy global path remains **only** on the Operator attach/managed paths. +2. **Per-session injector bound to that socket.** `inject.rs:24` `enum Backend` gains + `GamescopeEiAt(PathBuf)` → `libei::LibeiInjector::open_with(EiSource::SocketPathFile(path))`. New + `SessionInjector` in `session_worker.rs`: own thread (the libei `InputInjector` is `!Send`), + **lazy-open on first event** (the socket file doesn't exist until the nested compositor is up). Owns + the per-session uinput/uhid pads; emits rumble/HID back to the worker channel. The host-lifetime + `InjectorService` (`:167`) stays for Operator/portal backends. +3. **Per-session audio null-sink + monitor.** The worker (as the uid) creates a null-sink per session + (`module-null-sink sink_name=punktfunk- media.class=Audio/Sink`), routes the nested apps to it, + and captures **that sink's monitor**. New `open_audio_capture_for(channels, target_node)` sets + `PW_KEY_TARGET_OBJECT`/`node.target` instead of `PW_ID_ANY`. The host-lifetime `AudioCapSlot` + (`punktfunk1.rs:212`) keeps the default-monitor path for Operator. +4. **Per-session virtual mic.** The worker opens `punktfunk-mic-` via `open_virtual_mic` into its + gamescope; the global `MicService` (`punktfunk1.rs:1121`) stays for Operator. + +Net: **zero** shared injector/audio/mic state crosses a uid boundary — the catastrophic leak is +structurally impossible. + +### 7.4 The fail-closed SEC-3 gate (D5) + +In `serve_session`, immediately after resolution, **before** the data-plane spawn (`:958`): + +```rust +let resolved: ResolvedProfile = match profiles.resolve_session_profile(&fp_hex, req_id, passcode) { + Ok(r) => r, + Err(e) => { write_profile_reject(&mut send, reason_for(e)).await?; return Ok(()); } +}; +let route: SessionRoute = match resolved.os_account { + // (A) Operator → today's shared-desktop path, UNCHANGED. Multi-occupant (D6). + OsAccount::Operator => SessionRoute::Operator { compositor: resolve_compositor(hello.compositor)? }, + // (B) A real Linux user → MUST run on the isolating gamescope worker. Fail CLOSED here. + OsAccount::Linux { uid, .. } => { + let uid = uid.expect("resolve stamps uid at create-time"); + if !broker_client::is_available() { // Flatpak / unprovisioned host (D12) + write_session_unavailable(&mut send, NoBroker).await?; return Ok(()); } // NEVER fall through + if !vdisplay::gamescope_present() { + write_profile_reject(&mut send, NeedsIsolation).await?; return Ok(()); } + let occ = match occupancy.try_acquire(uid, &fp_hex) { // D6: by RESOLVED uid; Operator exempt + Some(g) => g, + None => { write_profile_reject(&mut send, Occupied).await?; return Ok(()); } + }; + let session = match broker_client::open(&resolved.id, &fp_hex, hello.mode) { + Ok(s) => s, + Err(BrokerError::MissingGroups | BrokerError::LingerFailed + | BrokerError::PamFailure | BrokerError::SpawnFailure) + => { write_session_unavailable(&mut send, LoginFailed).await?; return Ok(()); } + Err(_) => { write_session_unavailable(&mut send, NoBroker).await?; return Ok(()); } + }; + SessionRoute::Brokered { uid, occ, session } + } +}; +``` + +**The KWin/Mutter/wlroots-operator question, answered:** a non-Operator profile is **never** served on +those shared backends (they create a virtual output *inside the operator's live session* — +`remote_fd:None`, no isolation = the SEC-3 violation). The worker always forces gamescope, so a Mutter +or KWin operator desktop coexists fine with isolated gamescope users on the same box. The only +rejections are when isolation can't be stood up (`NoBroker` / `NeedsIsolation` / `LoginFailed`) — never +a silent fallthrough, never a black screen. Synthetic source and `--gamestream` are unaffected +(GameStream pinned to Operator, D13; the synthetic test source has no compositor). + +### 7.5 Threading the route through `SessionContext` / pipeline / accept loop + +The accept loop (`punktfunk1.rs:167-323`) keeps the host-lifetime singletons (`audio_cap` `:212`, +`injector` `:217`, `mic_service` `:221`) — they now serve **only Operator** sessions. Add two +host-lifetime handles cloned into each spawned session: `occupancy: Arc` and +`broker: Arc` (lazily-connected; error ⇒ `is_available()==false`). `SessionContext` +(`:2352-2391`) replaces the bare `compositor` with a route + a logging id: + +```rust +enum SessionRoute { + Operator { compositor: crate::vdisplay::Compositor }, // capture+encode in zone 1 (today) + Brokered { uid: u32, occ: OccupancyGuard, session: BrokerSession }, // capture+encode in the worker +} +// SessionContext gains: route: SessionRoute, resolved_profile_id: String // for GET /api/v1/sessions (D11) + +struct Occupancy { by_uid: Mutex> } // host-lifetime, Arc +impl Occupancy { fn try_acquire(&self, uid: u32, fp: &str) -> Option; } // Operator never calls this +``` + +`virtual_stream` (`:2393`) branches at the top exactly like the Windows `TwoProcessRelay` branch +(`:2407-2410`): `if let SessionRoute::Brokered { .. } = ctx.route { return virtual_stream_brokered(ctx); }`. +`virtual_stream_brokered` runs the zone-1 half: send `SessionInit{ mode, launch_cmd (resolved against +the profile's **scoped** library, §4.6), bitrate_kbps, bit_depth }` over `session_fd`, then loop +reading framed AUs/Opus and pushing them into `ctx.session` (FEC/crypto/UDP) while input/mic/ +reconfigure/keyframe go the other way. The Operator path's `input_thread`/`audio_thread` are skipped +for brokered sessions — input/mic forward to the worker, audio arrives from its null-sink monitor, so +the host-lifetime injector/audio/mic are never touched by a non-Operator session. + +### 7.6 Lifecycle, reaping, Reconfigure (D11) + +**Teardown ordering** (mirrors the existing teardown at `:1028`): `stop.store(true)` → +`BrokerSession::drop` sends `CloseSession` → broker `pam_close_session` + `kill(worker)` + `waitpid` + +audit `close` → `OccupancyGuard::drop` frees the uid slot. The worker, on `session_fd` EOF or `SIGTERM`, +drops its gamescope keepalive, `SessionInjector` (destroys the per-session pads + EIS), and +null-sink/mic. + +**Orphan reaping (the lifeline).** The broker treats the **zone-1 connection** as each session's +lifeline: if the host dies (crash, console-change restart, OOM), every `SessionRec` on that connection +is reaped (`pam_close_session` + kill + waitpid). Redundant nets: `PR_SET_PDEATHSIG=SIGKILL`; the worker +self-exits on `session_fd` EOF; on broker startup it sweeps `/run/punktfunk/sessions/*.pid` and kills +survivors from a previous broker generation before accepting. + +**Reconfigure re-clamp (D11).** The mid-stream `Reconfigure` (`punktfunk1.rs:722`) is **re-intersected +with the resolved profile's `SessionDefaults`** (not just the initial Hello) via +`clamp_mode(requested, resolved.session_defaults)`, then for a brokered session **forwarded to the +worker** over `session_fd` (a `Reconfigure{mode}` control frame). The worker rebuilds gamescope+capture+ +encoder at the new clamped mode via `build_pipeline_with_retry` (gamescope can't change output mode +live → relaunch the nested compositor; the data plane in zone 1 runs on). The Operator path keeps its +in-process rebuild. + +**Observability (D11).** `GET /api/v1/sessions` reads the occupancy registry + +`SessionContext.resolved_profile_id`/`uid`/`route`; `DELETE /api/v1/sessions/{id}` sets `stop` + drops +the `BrokerSession` + frees occupancy (the web console **Reclaim** action). + +### 7.7 Honest cold-login scope + packaging (D12) + +**What Linux delivers:** a brokered profile gets a **cold auto-login into a gamescope game-mode +session** (a fresh nested gamescope running the resolved title, or Steam Big-Picture via the managed +`gamescope-session-plus`/SteamOS paths), **not** the user's full KDE/GNOME desktop — that is the +SEC-3-required isolating backend. A **full-desktop-per-user** path (logging the uid into their own +KWin/Mutter and capturing *that* in isolation) is a **later scope**. Product framing: "stream into a +user's games, isolated" now; "stream into a user's full desktop" later. + +**Packaging:** + +| Package | Ships broker + PAM + units? | Capability | +|---|---|---| +| **deb / rpm / COPR / Arch / sysext / Bazzite bootc** | yes — `/usr/lib/punktfunk/punktfunk-session-broker`, `/etc/pam.d/punktfunk`, both systemd units, tmpfiles.d; `postinst`/`%post` enables `punktfunk-session-broker.socket` | Operator **and** Linux-user | +| **Flatpak / sandboxed** | **no** (can't install a root service) | **Operator-only**, advertised via a capability flag; a Linux-user profile returns `SessionUnavailable{NoBroker}` — never a silent operator fallthrough | + +The broker is a **separate, optional package** declared `Recommends:`/`Suggests:` of `punktfunk-host` +— **not** a hard dependency. A minimal install has **no root component on disk at all** (Operator-only +by construction). The host advertises an `isolating_profiles: bool` capability (broker reachable) over +the mgmt API / mDNS TXT so the console + clients can grey out real-user profiles on an Operator-only +install. + +### 7.8 Phases (mapped to the lab boxes; headless spike first) + +- **Phase 0 — PAM/logind feasibility spike (headless, the de-risk).** QEMU VM. A standalone + `broker-spike` (no QUIC, no host): throwaway user `pftest`, `enable-linger`, full + `pam_start … pam_open_session` + `setuid`, then from the dropped child spawn + `gamescope --backend headless -W 1280 -H 720 -r 60 -- vkcube`, connect to *that* user's PipeWire and + capture. Assert: runtime dir provisioned cold, session `Class==user`, render-node access as the uid, + **and `MemoryDenyWriteExecute=yes` survives the box's PAM stack.** **Gate the rest of the work on + this passing.** +- **Phase 1 — broker + worker + the four isolation pieces.** QEMU VM (gamescope 3.16.22). Validate + worker↔zone-1 AU relay + per-session EIS/null-sink/mic in isolation. +- **Phase 2 — SEC-3 gate + route threading + occupancy + `GET/DELETE /api/v1/sessions`.** Verify the + Operator path byte-for-byte unchanged; broker-stopped Linux profile → `NoBroker`; missing-group user + → `LoginFailed`; second device on the same uid → `Occupied`. +- **Phase 3 — concurrent multi-user + cross-GPU.** QEMU VM (two users, leak test — drive pad on device + A, confirm nothing reaches user B); AMD Sway box (192.168.1.25, VAAPI worker as a non-operator uid); + GNOME/Mutter box (192.168.1.248, operator desktop is Mutter while a Linux-user profile runs on its + forced gamescope worker). +- **Phase 4 — cold-login honesty, packaging, docs.** deb/rpm install on the GNOME + Sway boxes; Steam + Deck (deck@192.168.1.253) `gamescope-session-plus` coexistence. + +--- + +## 8. Windows host + +Maps a resolved `OsAccount::Windows { account_name, sid, credential }` to a real, capturable Windows +session, conforming to the D7 broker-split **target** with the v1 SYSTEM honesty caveat, and to the D11 +lifecycle fixes. + +### 8.1 The hard constraint + privilege posture + +A Windows **client SKU** has exactly ONE interactive console session; all GPU capture (DDA/WGC/IDD-push) +follows it (`WTSGetActiveConsoleSessionId()` hardcoded in `service.rs:325`, `wgc_relay.rs:218`, +`interactive.rs:53`). True concurrent multi-user desktops are RDS/Server-only. Therefore **on Windows, +profiles are SEQUENTIAL: one active profile at a time; selecting a different profile is a console +user-switch.** Concurrency stays a Linux/gamescope capability. Occupancy is **global** (single console), +persisted across the console-switch relaunch (D6). + +**Privilege (D7).** Target = the mirror split: a **non-SYSTEM** streamer front-door with +`LogonUser`/`LoadUserProfile`/`CreateProcessAsUserW` + credential unsealing behind a **tiny SYSTEM +broker** over ALPC/named-pipe with a verified client SID. **v1 may ship the front-door as SYSTEM**, but +the Phase-3 gate decision MUST record that Windows v1 does **not** satisfy the §3.2 invariant (a host +RCE = full box compromise + recovery of every stored credential → trusted-LAN-only posture). + +### 8.2 W0 — generalize the primitives from "active console" to a target session id + +New module `crates/punktfunk-host/src/windows/profile_session.rs`. The single mechanical unblock: stop +hardcoding `WTSGetActiveConsoleSessionId()`, thread an explicit `target_session: u32`. + +```rust +pub struct WindowsProfileTarget { + pub profile_id: String, + pub account_name: String, // "DOMAIN\\user" or ".\\user" + pub sid: Option, + pub wts_session: u32, // resolved WTS session id to capture + launch into +} +/// WTSEnumerateSessionsExW match account_name/sid -> an Active session id. None => needs logon (§8.5). +pub fn find_user_session(account: &str, sid: Option<&str>) -> Option; +/// Generalization of interactive::spawn_in_active_session: +/// WTSQueryUserToken(session) -> DuplicateTokenEx(TokenPrimary) -> CreateProcessAsUserW(winsta0/default). +pub fn spawn_in_session(session: u32, cmdline: &str, workdir: Option<&Path>) -> Result; +``` + +Refactors (behavior-preserving when `target == active console`): `interactive.rs:45` +`spawn_in_active_session` becomes a thin wrapper over `spawn_in_session(WTSGetActiveConsoleSessionId(),…)`; +`wgc_relay.rs:80` `HelperRelay::spawn` gains `target_session: u32`; `library::launch_title(id)` gains +`session: u32`. **W0 is validatable on the RTX box (192.168.1.173) single-user** (target = active +console → byte-identical). + +### 8.3 W1 — resolve profile → session, occupancy, thread the token + +```rust +#[cfg(windows)] +let win_target: Option = match resolved.os_account { + OsAccount::Operator => None, // today's path: active console + OsAccount::Windows { account_name, sid, .. } => { + let _guard = occupancy().try_acquire_sid(&sid_of(&resolved), &fingerprint_hex) + .ok_or(ProfileReject::Occupied)?; // D6: keyed by resolved SID (global) + let wts = find_user_session(&account_name, sid.as_deref()) + .or_else(|| logon_user_session(&resolved.id, &account_name, passcode.as_deref()).ok()) + .ok_or(ProfileReject::SessionUnavailable(LoginFailed))?; // no session, no creds + Some(WindowsProfileTarget { profile_id: resolved.id, account_name, sid, wts_session: wts }) + } + OsAccount::Linux { .. } => bail!("linux profile on windows host"), +}; +``` + +Occupancy is a host-lifetime registry **keyed by resolved SID** (persisted in the SYSTEM broker so a +console-switch relaunch can't double-grant). A reconnect by the same device to the same profile preempts +(like the IDD preempt `punktfunk1.rs:2449`); a different profile while one is active → `Occupied`. +`SessionContext` (`:2389-2390`) gains `#[cfg(windows)] profile_target: Option`; +`virtual_stream`/`_relay` read `ctx.profile_target.map(|t| t.wts_session).unwrap_or_else(active_console)` +and feed it to `HelperRelay::spawn` + `launch_title`. **W1 validatable with a SECOND pre-logged-in local +account** (`pf-test`, FUS). + +### 8.4 W2 — putting the target user on the (capturable) console + +**W2a — user already has a live session (reconnect).** `find_user_session` returns Active/Disconnected. +The existing supervisor (`service.rs:299-426`) already relaunches the host on +`WTSGetActiveConsoleSessionId()` change — once the console moves, capture follows. Trigger: +reconnect-to-disconnected where available. **Console-takeover guard (D12):** before switching, detect +whether the current console session is a **local/physically-present** interactive user vs a +punktfunk-driven remote one, and **refuse** (`SessionUnavailable{NeedsConsoleSwitch}`) or require +explicit operator opt-in before evicting a local human (no silent hostile takeover with data-loss risk). + +**W2b — cold user (needs logon).** Mint the token from the stored credential: + +```rust +fn logon_user_session(profile_id: &str, account: &str, passcode: Option<&str>) -> Result { + let pw = profile_cred::load(profile_id, passcode)?; // Zeroizing, §8.6 + // LogonUserW(user, domain, &pw, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT) -> token + // LoadUserProfileW(token, &mut PROFILEINFOW) // HKCU + %USERPROFILE% resolve + // ... token for the credential provider / RDS logon +} +``` + +A `LogonUser` token alone does NOT put the user on the console — establishing the interactive console +logon for a never-logged-in user requires a **custom Credential Provider** (`ICredentialProvider`, a COM +in-proc DLL the SYSTEM service signals): **Phase W3, deferred.** **Documented v1 fallback (D12):** a +cold profile requires the operator to sign that user in once (or FUS); thereafter W2a reconnect works. +Token asymmetry: to *launch processes* use `WTSQueryUserToken(session)` (the real interactive token), +NOT the `LogonUser` token (only for establishing/reconnecting the logon). + +### 8.5 W4 (advanced/optional) — RDS multi-session + +On a Server SKU with RDS, each logon is its own session+desktop. The W0 generalization (`spawn_in_session` +/ `HelperRelay` targeting a named WTS session) is exactly what's needed: `WTSEnumerateSessionsExW` to +discover, the credential provider / `WinStationConnectW` to establish, capture by session id → concurrent +occupancy allowed. Gated behind `PUNKTFUNK_RDS_MULTISESSION` + an SKU probe; document the RDS CAL +reality. NOT v1; the primitives are designed so it is additive. + +### 8.6 The auto-login credential — `windows/profile_cred.rs` + +`profiles.json` never holds the password (§4.2). New blob per profile at `config_dir()/cred/.bin` +(`%ProgramData%/punktfunk`), written with `write_secret_file` (DACL → SYSTEM/Administrators only). + +```rust +// inner plaintext blob: { account_name: String, password: String } (serde, zeroized after use) +pub fn store(profile_id: &str, account: &str, password: &str, passcode: Option<&str>) -> Result<()>; +pub fn load(profile_id: &str, passcode: Option<&str>) -> Result>; // returns password +pub fn clear(profile_id: &str) -> Result<()>; +``` + +Sealing layers: (1) `CryptProtectData(CRYPTPROTECT_LOCALMACHINE)` — machine-DPAPI, SYSTEM-readable, not +portable off the box; (2) **if passcode-protected**, additionally wrap with Argon2id(passcode)→AES-256-GCM +so the blob is useless until the connect-time passcode arrives — a real second factor for the credential. +**D10 hardening:** the credential-wrapping passcode is **≥6 alphanumeric** (a 4-digit wrap is +offline-crackable against the GCM blob in minutes), and the wrap KDF is bound to a **TPM-sealed / +host-identity-derived** secret so the blob isn't crackable from a stolen disk image alone. + +`CredentialRef::DpapiBlob{path}` points at `cred/.bin`; `CredentialRef::None` = "user already logged +in" (W2a only). The operator writes it via a **bearer-only, Windows-only** mgmt route: +`POST /api/v1/profiles/{id}/windows-credential` body `{ account_name, password, passcode?: string|null }` +→ 204; `DELETE` clears. Plaintext travels the existing TLS mgmt channel; the host seals immediately and +never echoes/logs (§9). + +### 8.7 Lifecycle fixes (D10/D11) + +- **Hive/token unload (D11):** session teardown calls `UnloadUserProfileW` + `CloseHandle(token)` + symmetric to `LoadUserProfileW` — no leaked hives/tokens across repeated switches. +- **`set_passcode` re-wrap (D10):** on a passcode-wrapped Windows profile, `set_passcode` must **re-wrap** + (decrypt-old → encrypt-new) or surface a "credential needs re-entry" state — otherwise a passcode + change silently breaks cold logon (the blob is under the OLD passcode). `update(os_account)` clears the + credential blob + the resolved SID. +- **`delete(profile)`** also `profile_cred::clear`s the blob (§12.2). + +### 8.8 SessionPlan / SE_TCB / GameStream + +`SessionPlan::resolve` (`session_plan.rs:113`) is unchanged (capture/topology/encoder are +profile-independent) — the profile only redirects *which session* the topology's helper/launch targets, +carried in `SessionContext.profile_target`. The host already holds `SE_TCB_NAME` (SYSTEM), satisfying +`WTSQueryUserToken` + `CreateProcessAsUserW` + `LogonUser`; a non-SYSTEM dev `serve` cannot do profiles +→ falls back to operator/console. GameStream is pinned to operator: its stream loop and `launch_title` +calls pass `target = active console`, never a `profile_target`, with an audit line per session (D13). + +### 8.9 Per-user library + +`library::all_games()` currently enumerates the SYSTEM host process's HOME — wrong for a profile. When +`profile_target` is set, enumerate **as the target user** (read that user's hive/paths after +`LoadUserProfileW`, or rely on the existing `windows_launch_for` recipes which already run via the user +token once `spawn_in_session(wts)` targets the right session). Visibility curation layers on top via +`profiles.scoped_library(fp, all_games_for_user(sid))`. + +### 8.10 Phase ordering & validation matrix + +| Phase | Work | Validatable on RTX box (single user)? | +|---|---|---| +| W0 | thread `target_session` through the 3 primitives | YES — target=console = no-op regression | +| W1 | resolve profile→session, occupancy by SID, `profile_target` | Partial — needs a 2nd pre-logged-in account (FUS) | +| W2a | reconnect to existing/disconnected session + supervisor follow + takeover guard | needs 2nd account, signed in once | +| W2b | credential store + `LogonUser`+`LoadUserProfile` | store unit-testable on box; cold logon needs 2nd account | +| W3 | custom Credential Provider (cold-user console auto-login) | needs 2nd account + a display; heaviest; **deferred** | +| W4 | RDS multi-session | Server SKU / RDS host (out of lab) | + +Back-compat: absent `profiles.json` or operator/default → `profile_target = None` → `target = active +console` → byte-identical to today at every primitive. + +--- + +## 9. Web console & management API + +Operator-facing config surface. Adds a `profiles` tag to the Axum mgmt API +(`crates/punktfunk-host/src/mgmt.rs`), an OS-account enumeration endpoint, the Live-sessions +observability endpoints, and a new web console **Profiles** page following the +`index.tsx`/`view.tsx`/stories/fixtures/i18n conventions. Built against the §4 data model exactly — it +does **not** redefine those types. + +### 9.1 `MgmtState` wiring + +```rust +struct MgmtState { + app: Arc, + native: Option>, + profiles: Arc, // NEW — always Some; absent file => empty store => today's behavior + stats: Arc, + token: Option, port: u16, +} +``` + +`run()`/`app()` take an added `profiles: Arc` threaded into the struct literal +(`mgmt.rs:120-126`) — the **same `Arc`** the `punktfunk1` accept loop holds (one source of truth). +Unlike `native` (which can be `None` for a GameStream-only host), `profiles` is always present. + +### 9.2 Endpoint inventory + auth + +All routes `nest` under `/api/v1` via `routes!()` in `api_router_parts()` (`mgmt.rs:143-176`). New tag +`profiles`. **All mutations + OS-account + sessions are bearer-only** (default deny via `require_auth`, +`mgmt.rs:473-507`). Exactly **one** route joins the streaming-cert allowlist `cert_may_access` +(`mgmt.rs:514-528`): `GET /api/v1/profiles/enumerate`. + +| Method & path | operation_id | Auth | Body → Returns | +|---|---|---|---| +| `GET /api/v1/profiles` | `listProfiles` | bearer | → `Vec` | +| `POST /api/v1/profiles` | `createProfile` | bearer | `ProfileInput` → `ProfileAdmin` (201) | +| `GET /api/v1/profiles/{id}` | `getProfile` | bearer | → `ProfileAdmin` | +| `PUT /api/v1/profiles/{id}` | `updateProfile` | bearer | `ProfileInput` → `ProfileAdmin` | +| `DELETE /api/v1/profiles/{id}` | `deleteProfile` | bearer | → 204 | +| `POST /api/v1/profiles/{id}/passcode` | `setProfilePasscode` | bearer | `SetPasscode` → 204 | +| `POST /api/v1/profiles/{id}/devices` | `assignProfileDevice` | bearer | `AssignDevice` → 204 | +| `DELETE /api/v1/profiles/devices/{fp}` | `unassignProfileDevice` | bearer | → 204 | +| `PUT /api/v1/profiles/default` | `setDefaultProfile` | bearer | `SetDefault` → 204 | +| `POST /api/v1/profiles/{id}/windows-credential` | `setWindowsCredential` | bearer (Windows-only) | `{account_name,password,passcode?}` → 204; `DELETE` clears | +| `GET /api/v1/os-accounts` | `listOsAccounts` | bearer | → `Vec` | +| `GET /api/v1/sessions` | `listSessions` | bearer | → `Vec` | +| `DELETE /api/v1/sessions/{id}` | `terminateSession` | bearer | → 204 | +| `GET /api/v1/profiles/enumerate` | `enumerateProfiles` | **cert** allowlist | → `Vec` (scoped, D9) | + +`cert_may_access` extends its `matches!` arm with `"/api/v1/profiles/enumerate"` (GET-only). The +path-match is EXACT-string, so `/profiles`, `/os-accounts`, and `/sessions` are **not** cert-reachable +(deny-by-default). `get_library` (`mgmt.rs:1152`) branches on auth: streaming-cert → +`st.profiles.scoped_library(fp, all_games())`; bearer → `all_games()` (reads the `PeerCertFingerprint` +extension, the same value `require_auth` reads at `mgmt.rs:483`). + +### 9.3 DTOs + +```rust +/// Admin view — the full profile MINUS the secret. Never exposes the PHC. +#[derive(Serialize, ToSchema)] +struct ProfileAdmin { + id: String, display_name: String, accent: Option, avatar: Option, + os_account: OsAccount, assigned_fingerprints: Vec, + has_passcode: bool, // derived from passcode.is_some() + require_passcode_even_when_assigned: bool, allow_shared_view: bool, tvos_user_ids: Vec, + library_scope: LibraryScope, custom_entries: Vec, session_defaults: SessionDefaults, + is_default: bool, created_unix: u64, updated_unix: u64, +} +/// Public picker view (cert-authed; no secret, no OS account, no fingerprint list). Field names align +/// to the wire ProfileEntry (snake_case). +#[derive(Serialize, ToSchema)] +struct ProfilePublic { + id: String, display_name: String, accent: Option, avatar: Option, + requires_passcode: bool, + assigned: bool, // requesting cert is assigned → frictionless +} +/// One real OS user the operator may bind. MINIMAL + non-secret: never a password/home/group list. +#[derive(Serialize, ToSchema)] +struct OsAccountCandidate { + username: String, uid: Option, sid: Option, full_name: Option, + platform: String, // "linux" | "windows" + is_operator: bool, in_use: bool, +} +/// A live profile session (D11). +#[derive(Serialize, ToSchema)] +struct SessionInfo { + session_id: String, profile_id: String, display_name: String, os_account: OsAccount, + client_fp: String, device_name: Option, backend: String, started_unix: u64, +} +#[derive(Deserialize, ToSchema)] struct SetPasscode { passcode: Option } // null clears +#[derive(Deserialize, ToSchema)] struct AssignDevice { fingerprint: String } +#[derive(Deserialize, ToSchema)] struct SetDefault { profile_id: Option } +``` + +`ProfileInput` (§4.7) carries `os_account: OsAccount` directly; the web client builds it from the +picked `OsAccountCandidate`: `{kind:"linux",username,uid}` / `{kind:"windows",account_name,sid,credential:{source:"none"}}` +/ `{kind:"operator"}`. + +### 9.4 OS-account enumeration source (`os_accounts.rs`) + +New cfg-split module `crates/punktfunk-host/src/os_accounts.rs`. **Linux:** iterate `getpwent`, +filtering to human accounts (uid == host uid → `is_operator:true`; uid in `[UID_MIN, 65534)` from +`/etc/login.defs`; valid login shell); `full_name` = first GECOS field. **Windows:** +`NetUserEnum(NULL, level=20, FILTER_NORMAL_ACCOUNT)` + `LookupAccountNameW` → SID string; +`username = ".\\"`; domain enumeration out of scope (operator types `DOMAIN\\user` manually). +`is_operator` candidate is always emitted first. `in_use` is computed by joining against +`profiles.list()`. + +```rust +async fn list_os_accounts(State(st): State>) -> Json> { + let host_uid = crate::os_accounts::host_uid(); + let mut cands = crate::os_accounts::list_candidates(host_uid); + let bound: HashSet = st.profiles.list().iter() + .filter_map(|p| crate::os_accounts::account_key(&p.os_account)).collect(); + for c in &mut cands { c.in_use = bound.contains(crate::os_accounts::candidate_key(c).as_str()); } + Json(cands) +} +``` + +### 9.5 Handler bodies (representative) + +Thin wrappers over the §4.7 `Profiles` API, identical in shape to the native-pairing handlers. Each +redacts to `ProfileAdmin` (never serializing `PasscodeHash`). The audit line ("Profile administration", +§6.8) is a `tracing::info!(target: "punktfunk::audit", …)` in each mutation, never the passcode. + +```rust +async fn set_profile_passcode(State(st): State>, Path(id): Path, + ApiJson(req): ApiJson) -> Response { + let pass = req.passcode.as_deref().map(str::trim).filter(|s| !s.is_empty()); + if let Some(p) = pass { + if p.len() < 4 || p.len() > 64 { return api_error(StatusCode::BAD_REQUEST, "passcode 4–64 chars"); } + // D10 entropy floor: a passcode wrapping a Windows credential must be >=6 alphanumeric. + if st.profiles.wraps_windows_credential(&id) && !is_alnum_min6(p) { + return api_error(StatusCode::BAD_REQUEST, "credential profiles need a >=6 alphanumeric passcode"); + } + } + match st.profiles.set_passcode(&id, pass) { // re-wraps the credential blob if needed (D10) + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => api_error(StatusCode::NOT_FOUND, "no profile with that id"), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +async fn enumerate_profiles(State(st): State>, req: Request) -> Json> { + let fp = req.extensions().get::().and_then(|p| p.0.clone()).unwrap_or_default(); + Json(st.profiles.enumerate_public(&fp)) // SCOPED roster (D9) +} +``` + +**Drift gate:** after editing `mgmt.rs`, regenerate `cargo run -p punktfunk-host -- openapi > +api/openapi.json` (CI fails on drift), then `cd web && pnpm codegen` regenerates `web/src/api/gen/ +profiles/*` + the model types. + +### 9.6 Web Profiles page + +Files mirror Pairing/Library: `web/src/routes/profiles.tsx` (4-line route), `sections/Profiles/index.tsx` +(container — orval hooks + mutations), `sections/Profiles/view.tsx` (presentational, `Loadable` + +callbacks), `stories/Profiles.stories.tsx` + fixtures in `stories/lib/fixtures.ts`, a NAV entry +(`{to:"/profiles", icon: UserCog, label: () => m.nav_profiles()}` in `app-shell.tsx:21-28`), i18n keys in +`messages/{en,de}.json`. `pnpm codegen` emits hooks (`useListProfiles`, `useCreateProfile`, +`useUpdateProfile`, `useDeleteProfile`, `useSetProfilePasscode`, `useAssignProfileDevice`, +`useUnassignProfileDevice`, `useSetDefaultProfile`, `useListOsAccounts`, `useEnumerateProfiles`, +`useListSessions`, `useTerminateSession`). + +```tsx +export interface ProfilesViewProps { + profiles: Loadable; + osAccounts: Loadable; + clients: Loadable; // paired devices, for the assign dropdown + sessions: Loadable; // Live sessions card (D11) + onCreate: (data: ProfileInput) => Promise; + onUpdate: (id: string, data: ProfileInput) => Promise; + onDelete: (id: string) => Promise; + onSetPasscode: (id: string, passcode: string | null) => Promise; + onAssign: (id: string, fingerprint: string) => Promise; + onUnassign: (fingerprint: string) => Promise; + onSetDefault: (id: string | null) => Promise; + onReclaim: (sessionId: string) => Promise; // DELETE /api/v1/sessions/{id} + isSaving: boolean; isDeleting: boolean; +} +``` + +**Layout** (one `
`): (1) header `h1` + "Add profile"; (2) profile-tile grid (avatar tinted by +`accent`, monogram fallback; OS-account chip; `has_passcode` lock badge; `is_default` star; +assigned-device count; Edit/Delete on hover; "Make default" row action); (3) create/edit form (gated by +`editing: string|null`) with `display_name`, `accent`, `avatar`, an **OS-account `` per pending row; on Approve, the container chains `approve.mutateAsync(...)` → if a +`profileId` was chosen, `assign.mutateAsync({id: profileId, data: {fingerprint: approvedClient.fingerprint}})` +(the approve response is a `NativeClient` carrying the fingerprint). New keys +`pairing_pending_assign_label`/`_none`. + +**GameStream exclusion (UI copy):** a muted note `profiles_gamestream_note` on the page header: +"Profiles apply to native punktfunk connections only. Moonlight/GameStream sessions always use the +operator account and the global library." Also in the `profiles` tag `description`. + +**Back-compat:** absent `profiles.json` → `list_profiles` returns `[]` → the page shows the +implicit-operator pseudo-tile + an empty state; `os-accounts`/`sessions` are additive; the `get_library` +scoping branch is a no-op for bearer callers. + +--- + +## 10. Apple clients (macOS / iOS / iPadOS) + +Connect-time profile selection for non-tvOS Apple clients (tvOS adds the get-current-user layer in §11 +but reuses these views). Built against the **reconciled** model: the profile id rides the Hello, the +passcode rides a never-logged `ProfileUnlock` the connector sends right after — surfaced through +`connect_ex6`; the host rejects a needed/bad passcode **at handshake before building the pipeline** +(one attempt per connection), and the Welcome echoes the resolved id. Enumeration uses the control-plane +`punktfunk_list_profiles` (not the mgmt port — works even when mgmt is loopback-only). + +### 10.1 Where it slots vs. trust / pairing + +Trust is established **first and unchanged**; the picker is only reachable for an already-pinned host. + +| Situation | Behavior | +|---|---| +| Unpinned, `pair=optional` (TOFU), first contact | Connect `profileID = nil`; host resolves the brand-new unassigned fingerprint to its default. No picker (a never-seen device has nothing to pick). | +| Unpinned, `pair=required` | `PairSheet` first; after `handlePaired` pins, the follow-up connect uses `profileID = nil`. | +| **Pinned, default tap** | Connect `profileID = host.lastProfileID` (nil first time) + remembered passcode if any. After `streaming`, cache `connection.resolvedProfile`. | +| **Pinned, "Choose Profile…"** | Present `ProfilePicker` → enumerate → pick → (passcode if challenged) → connect with the explicit selection. | +| **Connect rejected** (`profilePasscodeRequired`/`Wrong`/`profileNotFound`) | `SessionModel` publishes a typed `profilePrompt`; `ContentView` opens `ProfilePicker` pre-seeded to re-prompt. | + +First-contact carries the profile id in the *same* Hello that establishes the session — there is no +second Hello after the user confirms trust, so a never-seen (unassigned) device always lands in the +host default; explicit picking is meaningful only once pinned. + +### 10.2 `PunktfunkKit/PunktfunkConnection.swift` + +New error cases (append to `PunktfunkClientError`): `.profilePasscodeRequired`, `.profilePasscodeWrong`, +`.profileNotFound`. **Reconciled mapping** — wrong **and** missing passcode both arrive as ABI +`AuthRequired = -11`; the client distinguishes by whether it supplied a passcode: + +```swift +guard let h = handle else { + switch status { + case statusAuthRequired where passcode != nil: throw PunktfunkClientError.profilePasscodeWrong + case statusAuthRequired: throw PunktfunkClientError.profilePasscodeRequired + case statusNotFound: throw PunktfunkClientError.profileNotFound + default: throw PunktfunkClientError.connectFailed + } +} +``` + +`init` gains `profileID: String? = nil` and `passcode: String? = nil` (after `launchID`; defaulted so +existing call sites compile unchanged), replaces `punktfunk_connect_ex5` with `punktfunk_connect_ex6` +(threading `profileID`/`passcode` via `withOptionalCString` + capturing the `status_out` pointer), and +reads `resolvedProfile` via `punktfunk_connection_profile(h, &profBuf, …)`: + +```swift +public private(set) var resolvedProfile: String? // Welcome echo; nil = single-user / old host +``` + +Enumerate (free function, mirrors `pair()`), now carrying the **`assigned`** flag (D9 — so the picker +renders frictionless profiles without a blind try): + +```swift +public struct HostProfile: Identifiable, Hashable, Sendable { + public let id: String + public let displayName: String + public let passcodeRequired: Bool + public let assigned: Bool // NEW (D9): requesting cert is assigned → connect on one tap +} +public func listProfiles(host: String, port: UInt16 = 9777, identity: ClientIdentity, + pinSHA256: Data? = nil, timeoutMs: UInt32 = 10_000) throws -> (profiles: [HostProfile], hostFingerprint: Data) +// over punktfunk_list_profiles → PunktfunkProfile{ char id[65]; char display_name[65]; +// uint8_t passcode_required; uint8_t assigned } +``` + +### 10.3 `SessionModel.swift` + +`connect(to:…)` gains `profileID`/`passcode` (defaulted), passes them into the `PunktfunkConnection(...)` +init inside the existing `Task.detached`, and in the `.failure` arm publishes a structured prompt: + +```swift +@Published var profilePrompt: ProfilePrompt? +struct ProfilePrompt: Identifiable, Equatable { + let id = UUID(); let host: StoredHost; let attemptedProfileID: String? + enum Reason: Equatable { case choose, passcodeRequired, wrongPasscode, notFound } + let reason: Reason +} +// .failure(.profilePasscodeRequired) → .passcodeRequired ; .profilePasscodeWrong → .wrongPasscode ; +// .profileNotFound → .notFound ; existing generic errorMessage handling otherwise unchanged. +``` + +### 10.4 Per-host cache + passcode at rest + +`StoredHost` gains `var lastProfileID: String?` (Codable back-compat like `mgmtPort`; seeds the next +connect's `profileID`) + `HostStore.setProfile(_:profileID:)`. New +`PunktfunkClient/ProfilePasscodeStore.swift` — a Keychain store mirroring `ClientIdentityStore`'s +data-protection-keychain + legacy-fallback, keyed by `(host cert fingerprint hex, profileID)`: + +```swift +final class ProfilePasscodeStore: @unchecked Sendable { + static let shared = ProfilePasscodeStore() + func load(hostFingerprint: Data, profileID: String) -> String? // nil = not remembered + func save(_ passcode: String, hostFingerprint: Data, profileID: String) + func delete(hostFingerprint: Data, profileID: String) +} +``` + +Default behavior is **not** to remember (shared-device safety); the picker offers an explicit "Remember +on this device" toggle. **Clear on wrong (D12):** a wrong-passcode result clears any cached entry. The +toggle is **forbidden on shared/tvOS builds** (§11, D12). Deleting a host (`HostStore.remove`) / +`forgetIdentity` sweeps the host's stored passcodes. + +### 10.5 `ContentView.swift` orchestration + `ProfilePicker.swift` + +`connect(_:)` threads `profileOverride: ProfileSelection?`; `defaultSelection(for:)` returns +`(host.lastProfileID, remembered passcode)`; the `streaming` `.onChange` caches +`store.setProfile(host.id, profileID: model.connection?.resolvedProfile)`. A single sheet bound to +`model.profilePrompt` drives `ProfilePicker` (tvOS uses `.navigationDestination(item:)`). A host-card +context-menu callback `chooseProfile(_:)` opens the picker with `reason: .choose`, gated on +`prof=1`/`lastProfileID` + pinned. + +`ProfilePicker.swift` (new): a two-step flow — enumerate (off-main via `listProfiles`) → list → on a +passcode-required pick (or `.passcodeRequired`/`.wrongPasscode` re-entry) collect the passcode; returns +`Result?{ profileID, passcode?, remember }` (nil = cancelled). Each row shows `displayName` + a lock +glyph when `passcodeRequired`; an **assigned** or passcode-less profile connects on one tap (try +`passcode = nil` first, re-prompt only on `profilePasscodeRequired`). The passcode step is a `SecureField` +(numberPad on iOS), a default-off "Remember" toggle, an inline "Wrong passcode — try again" on re-entry; +tvOS swaps to `TVFieldRow`/`TVTextEntry` like `PairSheet`. + +### 10.6 Settings, HUD, back-compat + +Per-profile client settings are **not** needed (the host-side `SessionDefaults` clamps the client's +global request at resolve time + Reconfigure). The HUD adds an optional "Profile: …" line from +`connection.resolvedProfile`. Touchpoints: `PunktfunkConnection.swift`, `SessionModel.swift`, +`HostStore.swift`, new `ProfilePasscodeStore.swift` + `ProfilePicker.swift`, `ContentView.swift`, +`HomeView.swift`, `HostCards.swift`, `HostDiscovery.swift` (expose `DiscoveredHost.supportsProfiles` +from TXT `prof=1`), `StreamHUDView.swift`. Every new param defaults `nil`/`.auto`, so an un-updated +client behaves exactly as today; only `PunktfunkConnection.init` migrates to `ex6`. + +--- + +## 11. tvOS profile binding + +Binds the active Apple TV user to a profile. **Extends** §10 (it does not re-spec the connect plumbing); +it adds the tvOS-only "read who's in front of the TV → auto-select their mapped profile" layer. **One +shared Apple TV app identity + one host pairing:** the single device cert is presented by *every* tvOS +user, so the host sees **one fingerprint** for the whole Apple TV; the **profile** (Hello `profile_id`) +plus its passcode separates the humans. `currentUserIdentifier` is a **client-side hint only** (D8). + +### 11.1 Mechanism + Apple symbols + +- **Framework:** `import TVServices` (tvOS only). **Class:** instantiate `TVUserManager()` and read + `var currentUserIdentifier: String?` — an opaque, per-app-stable string (not an Apple ID/name/email), + non-nil only once per-user profiles are enabled **and** the app holds the user-management entitlement; + `nil` on a single-user device or an un-entitled build. +- **User-changed detection:** the **primary** mechanism is re-reading `currentUserIdentifier` on every + foreground (`scenePhase == .active`) + at launch (covers the case where the app is not relaunched on a + switch). A dedicated change notification is a **secondary/defensive** observer if it exists. +- **Entitlement:** `com.apple.developer.user-management = ["get-current-user"]` (read the active user + only). We deliberately do **not** request `runs-as-current-user*` — those swap the app's data container + per tvOS user → a separate Keychain identity → a separate host pairing, contradicting "one shared + identity." `get-current-user` keeps a single shared container. Goes in a **new** + `Config/Punktfunk-tvOS.entitlements` (split from the shared iOS file, mirroring the macOS split). The + entitlement is **Apple-gated and not guaranteed** for a streaming app → the whole feature must degrade + gracefully (§11.4). + +> **Open symbols (§14):** the exact `TVUserManager` construction form (`()` vs `.shared`) and whether a +> public `didChangeCurrentUserNotification` exists are **unverified** against the tvOS SDK; the design +> caches one instance and treats the notification as optional so the `scenePhase` re-read alone is +> sufficient. + +### 11.2 The mapping store — per-(tvOS user, host) → profile id + +```swift +// DefaultsKeys.swift +public static let tvUserProfileMap = "punktfunk.tvUserProfileMap" // [compositeKey: profileID] +// compositeKey = "\u{1f}" (0x1F unit separator — absent from either half) +``` + +`TVUserProfileBinding` (new, tvOS-only) is a thin typed wrapper over that `[String:String]` +`UserDefaults` dictionary: `profileID(user:host:)`, `setProfileID(_:user:host:)`, `forgetHost(_:)`. **No +passcode is ever persisted here (D12)** — only the chosen profile id, which is non-secret. First-run for +a `(user, host)` pair → present §10's `ProfilePicker(.choose)`; on resolve, persist the binding + connect. +Subsequent connects auto-select silently, re-prompting only for a passcode-protected profile's passcode. +A switch detected mid-session disconnects (the live stream belonged to the previous human). + +### 11.3 The identifier is a HINT only (D8) + +`currentUserIdentifier` never rides the wire; it selects the `profileID` passed to `connect_ex6`, and the +host resolves by its own D4 table keyed on the **device cert fingerprint** + the profile's passcode +policy. A `tvos_user_ids` match on the host **still requires the profile's passcode** for any protected +profile: the client auto-selects `profileID = P`, sends `passcode = nil` first, and drives the passcode +step on `profilePasscodeRequired`. There is **no `tvos_attested`** flag and no passcode bypass. Because +all tvOS users present the same cert, host-side device assignment cannot differentiate them — so a +passcode-**less** assigned profile is frictionless for *every* user on that Apple TV (the family "shared +TV" profile), and any enforced separation between humans **requires a passcode**. + +### 11.4 Remote-friendly passcode + D12 + graceful fallback + +Passcode entry reuses §10's `ProfilePicker` (tvOS keyboard via `TVFieldRow`/`TVTextEntry`/`fullScreenCover`). +tvOS rules: **forbid "Remember on this device"** (`ProfilePicker`'s `remember` toggle + every +`ProfilePasscodeStore` call gated `#if !os(tvOS)`; `Result.remember` forced false; the tvOS connect path +never reads/writes the Keychain) — the passcode is a true second factor entered once per session. **Clear +on wrong** is automatic (the failed `connect_ex6` already tore down its connection; one attempt per +connection → the retry is a fresh connect with the freshly-typed code). + +**Fallback:** entitlements are signing-time, not compile-time, so the code path is always present. When +the entitlement is absent (or "Users" off, or an iOS/macOS build), `currentUserIdentifier` returns `nil` +⇒ the tvOS connect path falls through to §10's device-global behavior (`host.lastProfileID` + the explicit +"Choose Profile…" item) — the same flow macOS/iOS use, and the **only sound mode** without a per-user +identity. When the entitlement is later approved, the same binary activates the per-user binding with +**zero code change**. + +### 11.5 App-lifecycle wiring + scope + +`TVUserBinding` (new, tvOS-only `@MainActor ObservableObject` owned by `ContentView`) reads +`TVUserManager().currentUserIdentifier`, republishes on `refresh()`, and returns the previous value so +callers detect a switch. `ContentView` (tvOS): `@StateObject tvUser`, `@Environment(\.scenePhase)`; on +`scenePhase == .active`, `tvUser.refresh()` — if it changed and a session is live, `model.disconnect()`. +`defaultSelection`/`connect` branch on tvOS (mapped user → bound profile, `passcode: nil`; first-run → +`ProfilePicker(.choose)`); the picker-resolution closure persists the binding on tvOS (and skips the +`ProfilePasscodeStore` save/delete). **Keep `HostStore`/`ClientIdentityStore`/`SettingsView`/`DefaultsKeys` +GLOBAL** (one shared identity ⇒ one pairing ⇒ one host list — forced by the decision and right for a +family TV); **only the profile binding is per-user** (non-secret). `SessionModel` stays a single global +`@StateObject` (one Apple TV streams one session). + +### 11.6 Host-on-disk implication + +`punktfunk1-paired.json` records **one fingerprint** for the whole Apple TV. The operator should: assign +the shared fingerprint to the family/default profile for a passcode-less landing; give any private +family-member profile a **passcode** (device assignment alone won't protect it — the kid's tvOS user +presents the identical cert); treat `tvos_user_ids` as advisory console metadata only (never a trust +input, never relaxes the passcode). + +--- + +## 12. Lifecycle & edge cases + +A consolidated, normative list of the lifecycle behaviors the implementation must honor (all from D11 +unless noted). + +### 12.1 Orphan-session reaping + +- **Linux:** the broker treats the zone-1 control socket as each session's **lifeline** — on peer death + (host crash / console-change restart / OOM), every `SessionRec` opened on that connection is reaped + (`pam_close_session` + `kill` + `waitpid`). Redundant nets: `PR_SET_PDEATHSIG=SIGKILL`, worker + self-exit on `session_fd` EOF, and a broker-startup sweep of `/run/punktfunk/sessions/*.pid` (§7.6). +- **Windows:** session teardown is symmetric to setup — `UnloadUserProfileW` + `CloseHandle(token)` + + release occupancy (§8.7). No leaked hives/tokens. + +### 12.2 `delete()` / `unassign()` side effects + +- **`delete(profile)`:** terminate-or-refuse active sessions running as that profile (operator choice; + surfaced through `DELETE /api/v1/sessions/{id}`), clear `default_profile_id` if it pointed here, + `profile_cred::clear` (Windows blob), drop the `PasscodeGate` entries, release occupancy. +- **`unassign_device`/reassign mid-session:** the **existing** session continues; the change re-resolves + on the **next** connect (documented policy — no mid-session eviction). + +### 12.3 Passcode-change re-wrap (D10) + +`set_passcode` on a passcode-wrapped **Windows** profile must **re-wrap** (decrypt-old → encrypt-new) or +surface a "credential needs re-entry" state — otherwise the blob stays under the OLD passcode and cold +logon silently fails. `update(os_account)` clears the credential blob + the resolved uid/SID (the blob's +inner account would otherwise point at the wrong user). + +### 12.4 Reconfigure SessionDefaults re-clamp + +The mid-stream `Reconfigure` path re-intersects the requested mode/bitrate with the resolved profile's +`SessionDefaults` (not just the initial Hello), so a client cannot renegotiate past the profile's policy +(§7.6 for the brokered worker; the Operator path clamps in-process). + +### 12.5 Occupancy reclaim + +A second device to an occupied uid/SID → `Occupied`; the operator sees "occupied by " on the +Live-sessions card and can **Reclaim** (`DELETE /api/v1/sessions/{id}` → release occupancy + broker +`CloseSession` / `UnloadUserProfile`). A same-device-same-profile reconnect preempts the prior session. + +### 12.6 `profiles.json` consistency (D11) + +`profiles.json` is the single on-disk source of truth both zone 1 and the broker read. A **failed +`save()` is a failed mutation** — return an error, don't keep the optimistic in-memory change (else the +broker, which re-stat/reloads per `OpenSession`, would resolve against stale data — e.g. a just-deleted +profile). A parse failure keeps raw bytes and refuses mutations. + +### 12.7 Provisioning preflight (Linux render/video/input) + +The zone-2 worker runs capture+encode + gamepad injection **as the target uid**, so that user needs +`render`/`video` (NVENC/VAAPI on `renderD128`) and `input` (uinput/uhid) group membership. The broker +preflights `getgrouplist` and returns `MissingGroups` → the client sees `SessionUnavailable{LoginFailed}` +naming the missing groups, **not** a black screen. + +### 12.8 Cold-user / SessionUnavailable surfacing + client UX + +When a profile resolves but the OS session can't be created (Linux PAM/spawn/linger/missing-groups; +broker absent; Windows bad/absent credential, cold user needing the deferred Credential Provider, or a +local-user console held), the host sends a typed `ProfileReject{SessionUnavailable{NoBroker|LoginFailed| +NeedsConsoleSwitch|Busy}}` (ABI `-14`). Every client maps it to a clear message — e.g. "Could not sign in + on the host — the account may need a one-time manual login / credential setup" — **never** a +generic `connectFailed` and **never** a silent fallthrough to the operator desktop. `NeedsIsolation` +(`-15`) is reserved for "the box can't isolate this profile at all" (no gamescope), no longer overloaded +for credential/cold-user failures. + +--- + +## 13. Rollout, phasing, back-compat & testing + +Normative ordering — where an upstream section implies a different build order, **this wins**. + +### 13.1 Sequencing principles + the one inviolable safety rule + +1. **Privilege last.** Every increment that does *not* become another OS user ships and validates before + any privileged code (Linux root broker / Windows SYSTEM logon). A bug in Phases 0–1 cannot escalate. +2. **Lowest-risk / most-reusable first.** data model → wire/ABI → privileged Linux → privileged Windows → + tvOS. The D2/D3/D4 foundation types are frozen in Phase 0. +3. **Every phase is independently shippable + on-by-default-safe.** At every boundary, a host with no + `profiles.json` is byte-identical to today. + +> **THE SAFETY RULE — SEC-3 fail-closed ships WITH resolution, not with the broker (D5).** Profile +> *resolution* (Phase 1) and the *isolating worker* (Phase 2) land in different increments. That gap is +> the catastrophic-leak window. Therefore the fail-closed gate is part of the **Phase-1** deliverable: +> at resolve time, `os_account != Operator` && isolating worker absent → reject +> `SessionUnavailable{NoBroker}`/`NeedsIsolation` **before any pipeline is built**. The only code path +> consuming a non-`Operator` `ResolvedProfile` is the broker client, which does not exist until Phase 2; +> until then the non-`Operator` arm dead-ends in the reject. This is what makes "ship Phase 0/1 before the +> broker" safe. + +### 13.2 Back-compat contract + +The wire uses append-only trailing-field decoding (Hello after `video_caps`, Welcome after `color`); +`ABI_VERSION` stays `2`; no flag-day (full matrix in §5.8). Default-profile semantics are the back-compat +anchor: absent `profiles.json` → every device resolves to the implicit operator profile → today's +behavior byte-for-byte; an unassigned device with no `profile_id` resolves to the operator default +**only when the default has no passcode** (D4 — closes the default-passcode bypass). + +**Feature gate (the "installing this turns my box into a root-broker attack surface" worry).** No +`profiles.json`, or only `Operator` profiles → zero privileged code ever executes (the broker is the +*only* caller of the non-`Operator` arm; socket-activated, never spawns otherwise). The broker is a +**separate, optional package** (`Recommends:`/`Suggests:`, not a hard dependency); a minimal/Flatpak +install has no root component on disk → Operator-only by construction, real-user profiles fail closed +with `SessionUnavailable{NoBroker}`. + +### 13.3 Phased delivery + +| Phase | Scope | Privilege | Gate | +|---|---|---|---| +| **0** | `profiles.rs` schema-of-record + Argon2 verifier + D4 resolver + `PasscodeGate` + validations (D5/D6/D10) + mgmt CRUD + `os_accounts.rs` + web Profiles page + GameStream-exclusion copy + `scoped_library`. | none | standard review | +| **1** | Wire (`Hello.profile_id`, `Welcome.resolved_profile`, `ListProfiles`/`ProfileList`/`ProfileUnlock`/`ProfileReject`) + `connect_ex6` + status codes + `NativeClient` + mDNS `prof=1` + **the SEC-3 fail-closed gate** + Reconfigure re-clamp + Apple/Linux client plumbing + REST enumerate. **All resolve to the Operator session; non-Operator FAILS CLOSED.** | none | none, but the safety rule is *proven* (non-Operator must be observed to reject, not stream) | +| **2 — Linux** | PAM/logind **headless spike first** → `punktfunk-broker` crate + root service/socket (caps trimmed) + `/etc/pam.d/punktfunk` + per-uid worker (SEC-1) + SEC-3 routes through the broker + occupancy by uid + orphan reaping + `GET/DELETE /api/v1/sessions` + render/video/input preflight + zone-0 audit. | **PRIVILEGED** | **explicit user go/no-go before merging privileged code** | +| **3 — Windows** | W0→W2b (target-session generalization, SID occupancy, FUS reconnect + takeover guard, DPAPI cred store + TPM-bind + entropy floor, hive/token unload, set_passcode re-wrap, lockout/occupancy persistence, windows-credential route + dead-state badge). **Credential Provider (cold console) deferred.** Front-door SYSTEM-vs-non-SYSTEM is the gate decision (D7). | **PRIVILEGED** | **explicit user go/no-go**; record the SYSTEM-v1 invariant-violation + trusted-LAN posture | +| **4 — tvOS** | get-current-user binding → client-side auto-selection only (D8); reconnect on switch; Remember forbidden on tvOS. | none | none (entitlement may be denied → degraded mode is default-safe) | + +Phases 2 and 3 are independent (different platforms/boxes) and may proceed in parallel; each needs its +own gate. Phase 4 only needs the Phase-1 wire/ABI. + +### 13.4 Test strategy (cross-cutting) + +- **Unit (`profiles.rs`):** the **D4 authority table, one case per row** (incl. the + `None`+unassigned+passcode-default = require regression, and `require_passcode_even_when_assigned` + upgrading each grant); Argon2id PHC round-trip + decoy-verify constant cost + `(profile_id,client_fp)` + keying (one device's lockout does NOT lock another or the profile globally) + backoff/lockout schedule; + load/save atomicity + **failed-save-is-failed-mutation** + parse-failure-refuses-mutation + load-time + dedupe + **same-uid/SID reject (D6)** + compositor + entropy-floor validation. +- **Core round-trip:** Hello/Welcome new trailing fields decode to `None` from old bytes; placeholder + offsets deterministic; `ListProfiles`/`ProfileList`/`ProfileUnlock`/`ProfileReject` encode/decode; + `Welcome.resolved_profile` carries the **id**; `connect_ex6` ABI harness (C round-trip, `ex5`→`ex6` + `NULL,NULL,NULL` delegation, status codes). +- **Loopback (QEMU VM):** probe `--profile ` happy (assigned/no-passcode, correct passcode) and + `--passcode 0000` → `ProfileReject{WrongPasscode}`; wrong ×N → `LockedOut`, confirm a **second + `client_fp` is NOT locked**; a `Linux` profile → **MUST reject `SessionUnavailable{NoBroker}`** (the + safety rule). Phase 2 adds the real-uid worker over loopback. +- **Privilege/PAM headless spike — on the QEMU VM, FIRST in Phase 2** (gate-before-build): prove + root→PAM→setuid→gamescope-as-uid headless (`enable-linger`, PAM items, session class, + `pam_close_session` across a worker crash, render/video/input membership, MDWX compatibility). +- **Real OS-user e2e:** QEMU VM (2nd user, isolation + reaping + leak test); GNOME box (2nd user + + operator-multi-occupant coexistence); Steam Deck (gamescope game-mode cold login); AMD Sway (VAAPI + worker); RTX Windows box with a 2nd account `pf-test` (FUS reconnect, console switch, SID occupancy, + restart-persistence, local-user-not-evicted). +- **Security regression list (must stay green):** default-profile passcode no-bypass (D4); occupancy by + uid/SID, Operator exempt (D6); orphan reaping (D11); SEC-3 impossible-state (D5); passcode never + logged + never in Hello (D1, grep-assert); rate-limit keying (D1); Windows restart-persistence (D1/D6); + SEC-2 (broker refuses an absent id / a wire uid); GameStream pinned to operator (D13); tvOS match still + requires passcode (D8). +- **Web/CI:** `pnpm typecheck` + Storybook (Profiles page, Live-sessions card); **OpenAPI drift gate**; + `cargo clippy --workspace --all-targets -- -D warnings` + `cargo fmt --all --check` every phase; + `swift build`/`swift test` on `home-mac-mini-1` (Phases 1, 4). + +### 13.5 Packaging / migration + +- **Linux broker package** (`deb.yml`/`rpm.yml` + COPR/Arch/sysext/bootc): separate + `punktfunk-session-broker` package (broker binary + `.service`/`.socket` + `/etc/pam.d/punktfunk` + + tmpfiles), `Recommends:`/`Suggests:` of `punktfunk-host` (not a hard dep). +- **Flatpak / sandboxed:** `Operator`-profile-only; ships a capability flag advertising + `profiles=operator-only`; real-user profile → `SessionUnavailable{NoBroker}`, never silent fallthrough. +- **Windows:** `windows-host.yml` Inno installer bundles the (future) SYSTEM broker + cred store + + (deferred) Credential Provider; the front-door service principal (SYSTEM vs non-SYSTEM) is set by the + installer per the Phase-3 gate. +- **Migration:** absent `profiles.json` = today's behavior; `version:0`/missing → `1`; additive fields + `serde(default)`; a breaking change bumps `PROFILES_SCHEMA_VERSION` with a one-shot `load()` migration. + +### 13.6 Docs + +- This document (`design/multi-user-profiles.md`) is the SoT, including the headless-spike write-up and + the per-platform honest-scope statement. +- **Supersede `design/gamescope-multiuser.md`** (mark it superseded here — the per-uid worker moves the + EIS/PipeWire sockets out of shared `/tmp` into the user's runtime dir; the old variant must not ship). +- `docs-site/content/docs`: move "Magic multi-user support" to **In-progress**, fold in "gamescope + multi-user isolation," update `status.md` per phase. Add a CLAUDE.md *What's left* §3 bullet + summarizing the phased state + the D7/D8/D12 honest-scope caveats. + +--- + +## 14. Open questions + +Carried forward; none block the foundation (Phase 0/1), several gate later phases. + +1. **tvOS Apple symbols / entitlement (HIGH uncertainty, §11.1):** confirm `TVUserManager()` vs + `.shared`; whether a public `didChangeCurrentUserNotification` exists (if not, the `scenePhase` + re-read is the sole, sufficient detector); whether `com.apple.developer.user-management` / + `get-current-user` is grantable to a streaming app (Apple-gated, positioned at games); and that + `get-current-user` **without** `runs-as-current-user` keeps a single shared data container (else the + single-pairing decision breaks). The whole feature degrades to the manual picker if denied. +2. **Linux AU-relay throughput at 5K@240** across the `session_fd` SEQPACKET hop is asserted-equal to the + Windows `wgc_relay` precedent but **not yet Linux-measured** — Phase 1 benchmarks it (`tools/latency-probe`) + before committing the relay as the only isolated path. Fallback if it regresses: a per-uid QUIC + terminator in the worker (duplicates the endpoint, removes the hop). +3. **Windows Credential Provider scope (W3):** ship a custom `ICredentialProvider` for true cold-console + auto-login, or stay on "operator pre-logs-in the user once" indefinitely? Affects whether Windows ever + delivers the headline cold-login. +4. **Full-desktop Linux session path:** add a path that logs the uid into their **own** KWin/GNOME and + captures *that* in isolation (vs. the v1 gamescope game-mode session)? Needed if "their own desktop" + becomes a hard requirement. +5. **Assigned device picking a different profile:** the D4 table allows an assigned device to select a + *different* profile (subject to that profile's passcode) — confirm this "guest mode on a personal + device" is desired vs. pinning assigned devices to their assignment. +6. **Broker ↔ `profiles.json` reload (TOCTOU):** the broker `stat`+reloads per `OpenSession` (fresh, + simple). Confirm sufficient vs. an `inotify` watch; the only race is an operator editing an assignment + in the millisecond a session opens, which the next connect corrects. +7. **LibraryScope granularity:** id-level only (today) vs. whole-store rules (`Deny{stores:["xbox"]}`) for + a kids profile; and the v1 scope editor (full library-id multi-select vs. a `store:id` textarea). +8. **Avatar/accent storage:** inline `data:` URL vs. a host-served asset endpoint (size cap?). + +--- + +## 15. Appendix — reconciliations applied from adversarial review + +Every blocker and load-bearing major from the three adversarial reviewers is resolved here; the body +reflects the decision silently. This table records what changed and the governing reconciled decision. + +| Finding (severity) | Decision taken | Ref | +|---|---|---| +| Passcode transport — three contradictory designs (plaintext-in-Hello / HMAC challenge-response / mid-handshake) **(blocker ×3)** | Hello carries `profile_id` **only**; passcode rides a dedicated **never-logged `ProfileUnlock`** verified host-side (Argon2id). HMAC challenge-response dropped (`argon2` stays out of core). **One attempt per connection; cross-connection `PasscodeGate` keyed `(profile_id, client_fp)`** (no in-handshake backoff vs `HANDSHAKE_TIMEOUT`). | D1 | +| C ABI `connect_ex6` signature — `status_out` present/absent **(blocker)** | Adopt the signature **with `status_out`** + status codes `AuthRequired=-11`/`NotFound=-12`/`Occupied=-13`/`SessionUnavailable=-14`/`NeedsIsolation=-15`; `ex5` delegates `NULL,NULL,NULL`. | D2 | +| `Profile`/`OsAccount` schema divergence (enum vs flat `OsUserRef`; field names) **(blocker)** | §4 `Profile` is the single schema of record; `OsAccount` is the enum; `CredentialRef` gains `DpapiBlob`; fields `assigned_fingerprints`/`default_profile_id`; add `require_passcode_even_when_assigned`/`tvos_user_ids`/`allow_shared_view`. | D3 | +| Resolution of selected-passcode-less-unassigned (admit vs deny) **(blocker)** + default-profile passcode bypass **(major)** | The **safer stance**: passcode-less-unassigned is **denied** (`NotPermitted`) unless `allow_shared_view`; the default-profile passcode is **enforced** (no bypass). One D4 authority table. | D4 | +| Isolation fail-closed gate absent from buildable sections **(blocker)** | SEC-3 ships in the **same deliverable as resolution** (Phase 1); non-Operator + no isolating worker → reject **before** pipeline build. Impossible-state by construction. | D5 | +| Windows privilege model = host-as-root **(blocker)** | Target = mirror the Linux split (non-SYSTEM front-door + tiny SYSTEM broker); v1-SYSTEM **documented** as not satisfying the invariant → trusted-LAN-only posture. | D7 | +| Observability — no live session view/kill **(blocker)** + reclaim occupied **(major)** | Add `GET /api/v1/sessions` + `DELETE /api/v1/sessions/{id}`; web **Live sessions** card + **Reclaim**. | D11 | +| Orphaned OS sessions on zone-1 death **(blocker)** | Broker treats the zone-1 socket as the **session lifeline** → reaps on peer death; `PR_SET_PDEATHSIG`, worker EOF self-exit, startup PID sweep. | D11 | +| Error surfacing for OS-login failures **(blocker)** | New `SessionUnavailable{NoBroker\|LoginFailed\|NeedsConsoleSwitch\|Busy}` + ABI `-14`, mapped in every client; stop overloading `NeedsIsolation`. | D12 | +| `PROFILE_ID_MAX` 32 vs 64 **(major)** | **64** everywhere (matches `char id[65]`). | D3 | +| `ProfileId` format (`p_`+6hex vs 12-hex) **(major)** | **12 lowercase hex** + reserved `"operator"`. | D3 | +| Welcome echo: name vs id **(major)** | Echo the **resolved id** (client needs it to reland; HUD looks up the name). | D2 | +| Argon2 params + crate placement **(major)** | **m=19456, t=2, p=1**, host-side only (`punktfunk-host`, not core). | D1/D10 | +| Picker DTO shape divergence + missing `assigned` **(major)** | One display-safe DTO; `ProfileEntry`/`ProfilePublic`/`PunktfunkProfile` carry **`assigned`** (frictionless without a blind try). | D9 | +| Enumerate transport REST vs control-stream **(major)** | Control-stream `ListProfiles`→`ProfileList` **PRIMARY** for native clients; REST `GET /api/v1/profiles/enumerate` (cert, **scoped**) secondary; `GET /api/v1/profiles` bearer-only. | D9 | +| Assign/unassign route shapes **(major)** | `POST /api/v1/profiles/{id}/devices`, `DELETE /api/v1/profiles/devices/{fp}`. | D9 | +| CredentialRef variants + cred route **(major)** | Add `CredentialRef::DpapiBlob`; add `POST/DELETE /api/v1/profiles/{id}/windows-credential` to the inventory + OpenAPI; web badge for `None` Windows profiles. | D3/D12 | +| tvOS attestation client-asserted/unverifiable **(major)** | Identifier = **UI hint only**; `tvos_user_ids` match **still requires the passcode**; `tvos_attested` dropped. | D8 | +| Passcode rate-limit DoS keying + Windows console-switch wipe **(major)** | Key `(profile_id, client_fp)`; **persist counters in the SYSTEM broker / SYSTEM-DACL file** on Windows. | D1 | +| Offline crack of low-entropy passcode + DPAPI from disk image **(major)** | **≥6 alphanumeric** entropy floor for credential-wrapping passcodes; **TPM/host-identity-bound** wrap KDF; documented disk-image recoverability of passcode-less blobs. | D10 | +| Occupancy keyed by profile_id, not uid/SID **(major)** | Key by **resolved uid/SID**; Operator **exempt**; reject same-uid/SID double-map at load. | D6 | +| Operator profile must stay multi-occupant **(major)** | `OsAccount::Operator` is multi-occupant (preserves `--max-concurrent` multi-view); single-occupancy applies only to real-OS-user profiles (+ Windows global). | D6 | +| PAM/logind headless feasibility **(major)** | Pre-provision with `enable-linger`; set PAM items (`PAM_TTY`/`XDG_SESSION_CLASS=user`/`TYPE`); verify session class; **headless spike gates the broker build**. | D12 / §7.8 | +| Passcode change doesn't re-wrap Windows credential **(major)** | `set_passcode` **re-wraps** or surfaces "credential needs re-entry"; `update(os_account)` clears the blob + resolved SID. | D10 | +| Profile deletion side effects / cross-store cleanup **(major)** | `delete()`: terminate-or-refuse sessions, `profile_cred::clear`, drop `PasscodeGate`, release occupancy; mid-session unassign continues, re-resolves next connect. | D11 | +| Cold-login honesty (Windows v1 / Linux game-mode) **(major)** | Stated per-platform: Linux cold auto-login into a **gamescope game-mode** session; Windows v1 = fast-user-switch (CP deferred). | D12 | +| Packaging broker/PAM + graceful degradation **(major)** | deb/rpm/COPR/Arch/bootc ship the broker (optional pkg); Flatpak **Operator-only** + capability flag; broker-absent real-user profile → `SessionUnavailable{NoBroker}`. | D12 | +| Windows console takeover of a local user **(major)** | Detect local vs remote console; **refuse/opt-in** before evicting a physically-present user. | D12 | +| `require_passcode_even_when_assigned` / `tvos_user_ids` / `allow_shared_view` absent from schema **(minor)** | All three folded into `Profile`/`ProfileInput`/DTOs; `resolve_for_device` honors the override. | D3 | +| SEC-3 vs free `SessionDefaults.compositor` **(minor)** | Validate at create/update — non-Operator may not set a shared-desktop compositor. | D5 | +| Zone-1 RCE input-injection residual **(minor)** | Documented residual R1; worker owns + rate-limits the input grant. | §6.8 | +| Broker capability surface contradicts "tiny TCB" **(minor)** | Caps trimmed to `CAP_SETUID/SETGID/SETPCAP/KILL`; `CAP_SYS_ADMIN`/`CAP_DAC_OVERRIDE` dropped; MDWX re-verified. | D7 | +| Enumerate leaks full roster **(minor)** | Scope to the requester's assigned + passcode-protected picker-visible profiles. | D9 | +| LibraryScope marketed as enforcement **(minor)** | Documented as presentation curation, not a sandbox; launch-by-id scoped. | D12 | +| PAM passwordless file is a standing hazard **(minor)** | Documented broker-only + security-sensitive; `pam_succeed_if` to mapped accounts; keep `account pam_unix`. | §6.8 / §7.1 | +| Windows hive/token not unloaded **(minor)** | `UnloadUserProfileW` + `CloseHandle(token)` on teardown. | D11 | +| No UI to provision Windows credential **(minor)** | windows-credential route + "credential not set" badge. | D12 | +| Reconfigure doesn't re-clamp SessionDefaults **(minor)** | Reconfigure re-intersects with the resolved profile's `SessionDefaults`. | D11 | +| Broker vs zone-1 `profiles.json` consistency **(minor)** | Single on-disk source of truth; failed save = failed mutation; broker re-stat/reloads per `OpenSession`. | D11 | +| Stale cached passcode + remember on shared/tvOS **(minor)** | Clear cache on wrong result; **forbid "Remember"** on shared/tvOS builds. | D12 |