Files
punktfunk/design/multi-user-profiles.md
T
enricobuehler ed54f22997
apple / swift (push) Successful in 1m10s
audit / cargo-audit (push) Successful in 1m16s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m8s
release / apple (push) Successful in 4m28s
ci / bench (push) Successful in 4m51s
apple / screenshots (push) Successful in 5m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m4s
android-screenshots / screenshots (push) Successful in 2m22s
windows-host / package (push) Successful in 7m31s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
android / android (push) Successful in 3m41s
deb / build-publish (push) Successful in 3m28s
decky / build-publish (push) Successful in 16s
linux-client-screenshots / screenshots (push) Successful in 2m20s
flatpak / build-publish (push) Successful in 4m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m43s
docker / deploy-docs (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
web-screenshots / screenshots (push) Successful in 2m29s
ci / rust (push) Failing after 4m8s
docs(design): add multi-user / profiles design (schema-of-record)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:52:43 +00:00

139 KiB
Raw Permalink Blame History

title, description
title description
Multi-User / Profiles 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: 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
  2. Concepts & terminology
  3. Architecture at a glance
  4. Data model & persistence (profiles.rs)
  5. Wire protocol & C ABI
  6. Security model & threat analysis
  7. Linux host
  8. Windows host
  9. Web console & management API
  10. Apple clients (macOS / iOS / iPadOS)
  11. tvOS profile binding
  12. Lifecycle & edge cases
  13. Rollout, phasing, back-compat & testing
  14. Open questions
  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.

// 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<Profile>,
    /// 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<String>,
}

fn default_path() -> Result<PathBuf> {
    Ok(crate::gamestream::config_dir().join("profiles.json"))
}

A parse failure is never silently dropped. Unlike the cert store (which unwrap_or_defaults), 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)

/// 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<String>,
    /// 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<String>,
    /// 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<String>,
    /// Optional secondary unlock. Argon2id PHC. Never serialized to any client-facing DTO.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub passcode: Option<PasscodeHash>,
    /// 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<String>,
    /// 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<CustomEntry>,
    /// 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<u32>,
    },
    /// 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<String>,
        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_<id>", LsaRetrievePrivateData). Store only the key name.
    LsaSecret { key: String },
    /// A DPAPI-machine-sealed blob on disk (`cred/<profile_id>.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$<salt-b64>$<hash-b64>".
#[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<String> },
    /// Full library minus these ids.
    Deny { ids: Vec<String> },
}

/// 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<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub max_height: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub max_refresh_hz: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub max_bitrate_kbps: Option<u32>,
    /// 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<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub gamepad: Option<String>,
}

Credential asymmetry (contract). Linux drops privilege to the target uid (broker setuid); the broker is already root, so no password is needed or storedCredentialRef 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).
fn hash_passcode(plain: &str) -> Result<PasscodeHash> {
    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)

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<HashMap<(ProfileId, String), GateEntry>> }
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<fp_hex_lowercase, ProfileId> 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).

#[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<ResolvedProfile, ProfileError>. (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<String> 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<GameEntry>. Profiles filter that result; they do not fork library.rs.

impl Profiles {
    /// Apply allow/deny to `global`, then append the profile's private custom entries
    /// (via `From<CustomEntry> for GameEntry`, library.rs:1193-1203). Sorted by title like all_games().
    pub fn scoped_library(&self, fp_hex: &str, global: Vec<GameEntry>) -> Vec<GameEntry>;
}

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

pub struct Profiles { /* Mutex<ProfilesState{path, file, reverse_index, raw_unparsed}>, PasscodeGate */ }

impl Profiles {
    pub fn load() -> Result<Profiles>;
    pub fn load_with(path: Option<PathBuf>) -> Result<Profiles>;          // tests / Windows override

    // --- admin (bearer; web console) ---
    pub fn list(&self) -> Vec<Profile>;
    pub fn get(&self, id: &str) -> Option<Profile>;
    pub fn create(&self, input: ProfileInput) -> Result<Profile>;
    pub fn update(&self, id: &str, input: ProfileInput) -> Result<Option<Profile>>; // preserves id/passcode/created;
                                                                                    // changing os_account clears credential + resolved uid/SID (D10)
    pub fn delete(&self, id: &str) -> Result<bool>;                       // side effects per §12.2
    pub fn assign_device(&self, id: &str, fp_hex: &str) -> Result<bool>; // unique: removes fp from any other profile
    pub fn unassign_device(&self, fp_hex: &str) -> Result<bool>;
    pub fn set_passcode(&self, id: &str, passcode: Option<&str>) -> Result<bool>; // 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<String>;

    // --- 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<ResolvedProfile, ProfileError>;
    pub fn enumerate_public(&self, fp_hex: &str) -> Vec<ProfilePublic>;   // SCOPED roster (D9)
    pub fn scoped_library(&self, fp_hex: &str, global: Vec<GameEntry>) -> Vec<GameEntry>;
}

/// 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<String>,
    #[serde(default)] pub avatar: Option<String>,
    pub os_account: OsAccount,
    #[serde(default)] pub assigned_fingerprints: Vec<String>,
    #[serde(default)] pub require_passcode_even_when_assigned: bool,
    #[serde(default)] pub allow_shared_view: bool,
    #[serde(default)] pub tvos_user_ids: Vec<String>,
    #[serde(default)] pub library_scope: LibraryScope,
    #[serde(default)] pub custom_entries: Vec<CustomEntry>,
    #[serde(default)] pub session_defaults: SessionDefaults,
}

Profiles is shared exactly like NativePairing: an Arc<Profiles> 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).

// 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<String>,

encode tail (replacing the need_placeholders/video_caps tail at quic.rs:636-654):

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):

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.

/// 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<String>, pub avatar: Option<String>,
    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<ProfileEntry> }

/// 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:

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:

// (1) If the connector sent a passcode, it is the NEXT message after Hello (never in Hello).
let passcode: Option<Zeroizing<String>> = 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):

/// 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<String>,

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.

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):
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:

pub fn connect(host:&str, port:u16, mode:Mode, compositor:CompositorPref, gamepad:GamepadPref,
    bitrate_kbps:u32, video_caps:u8, launch:Option<String>,
    profile:Option<String>, passcode:Option<String>,   // NEW
    pin:Option<[u8;32]>, identity:Option<(String,String)>, timeout:Duration) -> Result<NativeClient>

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<String> from welcome.resolved_profile; add pub fn resolved_profile(&self) -> Option<&str>. New NativeClient::list_profiles(host, port, identity, pin, timeout) -> Result<(Vec<ProfileEntry>, [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_idrequested=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 onlyCAP_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/<uid> / 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<Option<LaunchSession>> (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):

// 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 gateucred.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/<uid> (0700) and starts user@<uid>.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:
    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:

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:

// 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-<pid>"

    // (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:

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<PathBuf>. 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-<pid> 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-<pid> 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):

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 sessionremote_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<Occupancy> and broker: Arc<BrokerClient> (lazily-connected; error ⇒ is_available()==false). SessionContext (:2352-2391) replaces the bare compositor with a route + a logging id:

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<HashMap<u32, ClientFp>> }    // host-lifetime, Arc
impl Occupancy { fn try_acquire(&self, uid: u32, fp: &str) -> Option<OccupancyGuard>; } // 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 closeOccupancyGuard::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-hostnot 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.

pub struct WindowsProfileTarget {
    pub profile_id: String,
    pub account_name: String,     // "DOMAIN\\user" or ".\\user"
    pub sid: Option<String>,
    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<u32>;
/// 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<u32>;

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

#[cfg(windows)]
let win_target: Option<WindowsProfileTarget> = 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<WindowsProfileTarget>; 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:

fn logon_user_session(profile_id: &str, account: &str, passcode: Option<&str>) -> Result<u32> {
    let pw = profile_cred::load(profile_id, passcode)?;     // Zeroizing<String>, §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/<profile_id>.bin (%ProgramData%/punktfunk), written with write_secret_file (DACL → SYSTEM/Administrators only).

// 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<Zeroizing<String>>;   // 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/<id>.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::clears 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 = Nonetarget = 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

struct MgmtState {
    app: Arc<AppState>,
    native: Option<Arc<crate::native_pairing::NativePairing>>,
    profiles: Arc<crate::profiles::Profiles>,  // NEW — always Some; absent file => empty store => today's behavior
    stats: Arc<crate::stats_recorder::StatsRecorder>,
    token: Option<String>, port: u16,
}

run()/app() take an added profiles: Arc<Profiles> 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<ProfileAdmin>
POST /api/v1/profiles createProfile bearer ProfileInputProfileAdmin (201)
GET /api/v1/profiles/{id} getProfile bearer ProfileAdmin
PUT /api/v1/profiles/{id} updateProfile bearer ProfileInputProfileAdmin
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<OsAccountCandidate>
GET /api/v1/sessions listSessions bearer Vec<SessionInfo>
DELETE /api/v1/sessions/{id} terminateSession bearer → 204
GET /api/v1/profiles/enumerate enumerateProfiles cert allowlist Vec<ProfilePublic> (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

/// Admin view — the full profile MINUS the secret. Never exposes the PHC.
#[derive(Serialize, ToSchema)]
struct ProfileAdmin {
    id: String, display_name: String, accent: Option<String>, avatar: Option<String>,
    os_account: OsAccount, assigned_fingerprints: Vec<String>,
    has_passcode: bool,                         // derived from passcode.is_some()
    require_passcode_even_when_assigned: bool, allow_shared_view: bool, tvos_user_ids: Vec<String>,
    library_scope: LibraryScope, custom_entries: Vec<CustomEntry>, 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<String>, avatar: Option<String>,
    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<u32>, sid: Option<String>, full_name: Option<String>,
    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<String>, backend: String, started_unix: u64,
}
#[derive(Deserialize, ToSchema)] struct SetPasscode { passcode: Option<String> }   // null clears
#[derive(Deserialize, ToSchema)] struct AssignDevice { fingerprint: String }
#[derive(Deserialize, ToSchema)] struct SetDefault { profile_id: Option<String> }

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 = ".\\<name>"; 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().

async fn list_os_accounts(State(st): State<Arc<MgmtState>>) -> Json<Vec<OsAccountCandidate>> {
    let host_uid = crate::os_accounts::host_uid();
    let mut cands = crate::os_accounts::list_candidates(host_uid);
    let bound: HashSet<String> = 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.

async fn set_profile_passcode(State(st): State<Arc<MgmtState>>, Path(id): Path<String>,
    ApiJson(req): ApiJson<SetPasscode>) -> 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 464 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<Arc<MgmtState>>, req: Request) -> Json<Vec<ProfilePublic>> {
    let fp = req.extensions().get::<PeerCertFingerprint>().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<T> + 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).

export interface ProfilesViewProps {
  profiles: Loadable<ProfileAdmin[]>;
  osAccounts: Loadable<OsAccountCandidate[]>;
  clients: Loadable<NativeClient[]>;            // paired devices, for the assign dropdown
  sessions: Loadable<SessionInfo[]>;            // Live sessions card (D11)
  onCreate: (data: ProfileInput) => Promise<unknown>;
  onUpdate: (id: string, data: ProfileInput) => Promise<unknown>;
  onDelete: (id: string) => Promise<unknown>;
  onSetPasscode: (id: string, passcode: string | null) => Promise<unknown>;
  onAssign: (id: string, fingerprint: string) => Promise<unknown>;
  onUnassign: (fingerprint: string) => Promise<unknown>;
  onSetDefault: (id: string | null) => Promise<unknown>;
  onReclaim: (sessionId: string) => Promise<unknown>;     // DELETE /api/v1/sessions/{id}
  isSaving: boolean; isDeleting: boolean;
}

Layout (one <Section>): (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 <select> populated from osAccounts.data (first option "Operator (this host — shared desktop)" → {kind:"operator"}; a free-text "Other (DOMAIN\user)…" row for Windows domain accounts), a separate passcode sub-form (routes through set_passcode; a brand-new profile creates first then reveals the sub-form), LibraryScope radio (All|Allow|Deny + a store:id textarea in v1), SessionDefaults numeric inputs; (4) Device assignment subsection (the profile's assigned_fingerprints joined against clients.data to render device names, an unassign button, and a dropdown of unassigned paired devices); (5) Live sessions card (D11) listing sessions.data with a Reclaim action calling onReclaim(session_id); (6) a "credential not set" badge on a CredentialRef::None Windows profile ("sign this user in manually or add a credential", Critic 2 minor).

Pairing integration: PendingDevicesCard (Pairing/view.tsx:111-179) gains an inline profile <select> 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.jsonlist_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:

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, …):

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):

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:

@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):

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

// DefaultsKeys.swift
public static let tvUserProfileMap = "punktfunk.tvUserProfileMap"   // [compositeKey: profileID]
// compositeKey = "<tvUserID>\u{1f}<host UUID>"  (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 01 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 firstpunktfunk-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, ex5ex6 NULL,NULL,NULL delegation, status codes).
  • Loopback (QEMU VM): probe --profile <id> happy (assigned/no-passcode, correct passcode) and --passcode 0000ProfileReject{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 ListProfilesProfileList 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