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

2164 lines
139 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Multi-User / Profiles"
description: "End-to-end design: map a connecting client to a real host OS user account (own auto-logged-in, isolated desktop), configured in the web console; device-assignment + optional per-profile passcode; tvOS bound to the selected Apple TV user."
---
> **Status: design, schema-of-record.** This document is the single source of truth for the
> punktfunk "Magic multi-user / profiles" feature. It **supersedes and absorbs**
> [`design/gamescope-multiuser.md`](gamescope-multiuser.md): the deferred per-uid gamescope isolation
> plumbing is now mandatory here, hardened so its EIS/PipeWire sockets live in the target user's
> `XDG_RUNTIME_DIR` rather than the old shared-`/tmp` sketch. Where any earlier per-section draft
> disagrees with this document, **this document wins** — it has already folded in every blocker and
> load-bearing major from the adversarial review (see the Appendix).
## Table of contents
1. [Overview & goals](#1-overview--goals)
2. [Concepts & terminology](#2-concepts--terminology)
3. [Architecture at a glance](#3-architecture-at-a-glance)
4. [Data model & persistence (`profiles.rs`)](#4-data-model--persistence-profilesrs)
5. [Wire protocol & C ABI](#5-wire-protocol--c-abi)
6. [Security model & threat analysis](#6-security-model--threat-analysis)
7. [Linux host](#7-linux-host)
8. [Windows host](#8-windows-host)
9. [Web console & management API](#9-web-console--management-api)
10. [Apple clients (macOS / iOS / iPadOS)](#10-apple-clients-macos--ios--ipados)
11. [tvOS profile binding](#11-tvos-profile-binding)
12. [Lifecycle & edge cases](#12-lifecycle--edge-cases)
13. [Rollout, phasing, back-compat & testing](#13-rollout-phasing-back-compat--testing)
14. [Open questions](#14-open-questions)
15. [Appendix — reconciliations applied from adversarial review](#15-appendix--reconciliations-applied-from-adversarial-review)
---
## 1. Overview & goals
### 1.1 Roadmap intent
"**Magic multi-user support.**" Today a punktfunk host streams one thing: the host operator's own
graphical session, with one shared input/audio/mic plane. The roadmap promise is that a connecting
client lands in **its human's own desktop, auto-logged-in, isolated**, configured entirely from the
web console — with zero on-box fiddling per connect. A family TV connects and the kid gets the kid's
games; a personal laptop connects and lands straight in the owner's account; a shared Apple TV maps
whoever is in front of it to their profile.
### 1.2 The three locked product decisions
1. **A profile = a real host OS user account.** Not a sandbox, not a virtual persona — a profile
resolves to a real Linux `uid` / Windows `SID`, and the session runs *as that user* (their `HOME`,
their Steam library, their files). This is what makes it "their desktop," and it is what forces the
privilege model in §3/§6/§7.
2. **Device-assignment + an optional per-profile passcode.** A paired device cert can be **assigned**
to a profile → frictionless connect (the device *is* the authorization). A profile can also carry
an **optional passcode** (a second factor) so a *shared* device (e.g. a family Apple TV) can pick a
profile that isn't its assignment.
3. **tvOS is bound to the selected Apple TV user.** One Apple TV = one app identity = one host
pairing; `TVUserManager.currentUserIdentifier` selects which profile the client auto-picks. That
identifier is a **client-side hint only** — never a wire trust factor (§6.6, §11).
### 1.3 What ships per platform (the honesty note)
This feature creates OS logon sessions and impersonates OS users; the scope it *actually* delivers in
v1 differs per platform, and we state it plainly rather than imply parity:
- **Linux** delivers genuine **cold auto-login** on first connect — but into a **gamescope game-mode**
session (a fresh nested gamescope running the resolved title / Steam Big-Picture as the target uid),
**not** the user's full KDE/GNOME desktop. The isolating gamescope worker is what makes per-user
separation safe (SEC-3). A full-desktop-per-user path is a later scope.
- **Windows v1** is **fast-user-switch** only: the target user must have been signed in once
(operator pre-login / FUS); the host then switches the single interactive console to them. True
**cold console auto-login needs a custom Credential Provider**, which is **deferred** to a later
phase. Windows is also **sequential** (one active profile at a time — the single-console constraint),
whereas Linux is concurrent.
- **GameStream / Moonlight is excluded** from profiles entirely (cert-only identity, single global
catalog, one global `LaunchSession`); GameStream sessions always run as the operator. This is a
documented protocol fact, not a bug (§6.7).
---
## 2. Concepts & terminology
One name per concept — used consistently throughout this document and the code.
| Term | Meaning |
|---|---|
| **Profile** | A host-side record (`crates/punktfunk-host/src/profiles.rs`) binding a `display_name`, an `OsAccount`, device assignments, an optional passcode, library curation, and session policy. Identified by a stable `ProfileId`. |
| **`ProfileId`** | 12 lowercase hex characters, minted `hex(sha256("{display_name}:{nanos}")[..6])` (like `library::new_id`), plus the reserved id `"operator"`. `PROFILE_ID_MAX = 64` everywhere (matches the `char id[65]` ABI struct). |
| **`OsAccount`** | The real OS user a profile maps to: the enum `{ Operator, Linux{username,uid?}, Windows{account_name,sid?,credential} }`. |
| **Operator profile** | `OsAccount::Operator` — the host operator's already-running shared graphical session = **today's behavior** (shared desktop, shared input/audio, multi-occupant). The implicit default. |
| **`default_profile_id`** | The operator-designated profile for *paired-but-unassigned* devices. `None`/dangling ⇒ the synthesized implicit operator profile. |
| **Device assignment** | A device cert **fingerprint** (lowercase hex SHA-256) listed in `Profile.assigned_fingerprints`. Assignment is *the* frictionless authorization; a fingerprint is in **at most one** profile. |
| **Passcode** | An optional per-profile secret (Argon2id PHC, host-side verify). The **second factor** that gates *shared-device* access to a profile. Never the OS password. |
| **Isolation** | A non-Operator profile runs on its own per-uid worker with its own injector/audio/mic/compositor — its bytes and input never touch another user's plane (SEC-1/SEC-3). |
| **Occupancy** | Single-session-per-resolved-OS-user. Keyed by the **resolved uid/SID**, not the profile id. Operator profiles are exempt (multi-occupant). |
---
## 3. Architecture at a glance
### 3.1 The connect → resolve → session flow
```
client zone 1: punktfunk-host (network) zone 0/2 (privileged + per-user)
│ QUIC mTLS + pinned host
├─ Hello{…, profile_id?} ───► paired-fingerprint gate (punktfunk1.rs:535-564)
├─ ProfileUnlock{passcode}? ─► resolve_session_profile(fp, id?, passcode?) ── D4 authority table
│ │ Operator → today's shared-desktop pipeline (zone 1, no privilege)
│ │ non-Operator → SEC-3 fail-closed gate (D5):
│ │ • broker present? else SessionUnavailable{NoBroker}
│ │ • occupancy by uid/SID free? else Occupied
│ │ • OpenSession(profile_id) ──────────────► root broker (PAM+setuid)
│ │ spawns per-uid worker
│ ◄─ ProfileReject{reason} │ (any deny → typed reject + close; one passcode try per connection)
│ ◄─ Welcome{resolved_profile}│ echoes the RESOLVED profile id
└─ stream ◄──── encoded AUs ───┘ ◄──── session_fd (encoded AU/audio/input) ──┘
```
### 3.2 The privilege invariant (the headline)
> **Invariant (D7).** The network-facing process — the one parsing attacker-controlled QUIC and
> linking FFmpeg/CUDA/Vulkan/codec FFI (adversary-exposed) — **MUST NOT hold the privilege to become
> other users.**
A single codec/QUIC RCE in that process must not equal "log into any mapped OS account." The feature
is built so that the privilege to impersonate lives in a **tiny separate component with no untrusted
parser and no network listener.**
### 3.3 How the zones realize it (Linux)
Three trust zones (Linux):
- **Zone 1 — `punktfunk-host serve` (network, non-root).** Terminates QUIC, runs Leopard FEC +
AES-GCM + UDP `sendmmsg`, serves the mgmt API, **reads** `profiles.json`, verifies the passcode. It
holds *no* power to become another user. It is the adversary-reachable surface.
- **Zone 0 — `punktfunk-session-broker` (root, NEW).** The only root code. No network listener, no
untrusted parser — its sole input is a length-prefixed `serde_json` request over a
`SO_PEERCRED`-restricted `SOCK_SEQPACKET` unix socket. It **trusts only a `profile_id`** (re-reads
`profiles.json` itself — SEC-2), does the PAM session + `setuid`, spawns the per-uid worker, and
treats the zone-1 connection as the **session lifeline**. Caps trimmed to
`CAP_SETUID/SETGID/SETPCAP/KILL` only (D7).
- **Zone 2 — `punktfunk-host session-worker` (target uid, NEW).** Runs **as the profile uid** inside
its PAM/logind session. The *only* process that ever touches that user's **decoded** desktop bytes,
raw input, and mic — because those live in the user's `XDG_RUNTIME_DIR` (mode 0700), inaccessible to
zone 1 by construction. It hands **encoded** AUs + Opus back to zone 1 over an inherited
socketpair and receives opaque input/mic to inject locally.
### 3.4 Windows: mirror the split (target), with a documented v1 gap
Windows has no second concurrent interactive desktop on a client SKU, and the host already runs as
**LocalSystem**. The **target architecture mirrors the Linux split**: a non-SYSTEM streamer front-door
with `LogonUser`/`LoadUserProfile`/`CreateProcessAsUserW` + credential unsealing behind a tiny SYSTEM
broker over ALPC/named-pipe with a verified client SID.
**Windows v1 may ship the front-door as SYSTEM** — but MUST then **document explicitly** that it does
**not** satisfy the §3.2 invariant: a host RCE = full box compromise *plus* recovery of every stored
credential. Operators therefore run Windows multi-user **trusted-LAN only**, GameStream-style. This
gap is called out wherever Windows appears (§6.2, §8, §13).
---
## 4. Data model & persistence (`profiles.rs`)
**New module:** `crates/punktfunk-host/src/profiles.rs`. **New host dependency:** `argon2 = "0.5"`
(RustCrypto; pure-Rust, builds on MSVC) added to **`punktfunk-host` only** — never to
`punktfunk-core` (D1: the passcode is verified host-side; no client target needs `argon2`).
`serde`/`serde_json`/`sha2`/`hex`/`utoipa` are already present.
### 4.1 Persistence
Profiles live in a **new** `~/.config/punktfunk/profiles.json` (`%ProgramData%\punktfunk\profiles.json`
on Windows) — *not* an extension of `native_pairing`. It is owner-only, atomic-write, built from the
exact helpers `native_pairing.rs` already uses (`gamestream::config_dir` / `create_private_dir` (0700)
/ `write_secret_file` (0600 unix / SYSTEM-DACL Windows), `gamestream/mod.rs:245-313`), with the temp +
atomic-rename discipline of `native_pairing.rs:116-127`.
```rust
// profiles.rs
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::collections::HashMap;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::library::{CustomEntry, GameEntry};
/// Bump on any breaking field-semantics change; `serde(default)` covers additive growth.
pub const PROFILES_SCHEMA_VERSION: u32 = 1;
/// Reserved id of the synthesized implicit profile = today's shared operator session.
pub const OPERATOR_PROFILE_ID: &str = "operator";
/// Max bytes of a wire/ABI profile id (matches `char id[65]` = 64 + NUL).
pub const PROFILE_ID_MAX: usize = 64;
#[derive(Default, Serialize, Deserialize)]
pub struct ProfilesFile {
/// `0`/absent on a pre-feature/hand-written file is treated as `1`.
#[serde(default)]
pub version: u32,
#[serde(default)]
pub profiles: Vec<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_default`s), a
malformed `profiles.json` keeps the **raw bytes** in memory, logs loudly, and **refuses mutations**
until readable — a later `save()` must never overwrite a file we failed to parse (that would strand
every assigned device). **A failed `save()` is a failed mutation (D11):** do not keep the optimistic
in-memory change; return an error to the operator. `profiles.json` is the single on-disk source of
truth that both zone 1 and the root broker read.
### 4.2 The `Profile` entity (canonical serde — D3)
```rust
/// Host-assigned, stable id (the `{id}` in CRUD paths). 12 hex chars, minted like
/// `library::new_id` (`library.rs:1239-1246`): `hex(sha256("{display_name}:{nanos}")[..6])`.
pub type ProfileId = String;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct Profile {
pub id: ProfileId,
pub display_name: String,
/// UI accent ("#RRGGBB" or a brand token). Optional.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<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 stored**`CredentialRef` is **Windows-only**.
Windows must materialize a *primary token* for a non-logged-in user, which requires the password (or
an existing session) — hence `CredentialRef`.
### 4.3 Passcode: Argon2id parameters + verify (D1/D10)
The passcode is a **secondary factor over an already mutually-authenticated, encrypted QUIC channel**
(the device cert is the primary auth; the host fingerprint is pinned). There is no MITM and no
offline-dictionary exposure on the wire, so — unlike the SPAKE2 pairing PIN — the passcode is sent as
plaintext **inside the established control stream** in a dedicated, never-logged `ProfileUnlock`
message (§5.3), verified host-side, and zeroized immediately. Argon2id protects the *at-rest* file if
`profiles.json` leaks.
- **Algorithm:** Argon2id, version 0x13. **Params (unified across the whole design):** `m = 19456`
KiB, `t = 2`, `p = 1` (OWASP argon2id floor; ~tens of ms — cheap, online-only).
- **Salt:** 16-byte random per hash via `SaltString::generate(&mut OsRng)`, embedded in the PHC string.
- **Decoy verify (no oracle):** when the target profile is missing or has no passcode, run a **dummy
verify against a fixed decoy PHC** so every `ProfileUnlock` costs ~one Argon2 evaluation regardless
of outcome (no profile-existence / passcode-set timing oracle).
- **Online rate limit + lockout** — the real defense against weak codes — is the in-memory
`PasscodeGate`, keyed by **`(profile_id, client_fp)`** (§4.4, D1).
```rust
fn hash_passcode(plain: &str) -> Result<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)
```rust
const PASSCODE_FAIL_THRESHOLD: u32 = 5; // failures before hard lockout
const PASSCODE_LOCKOUT_BASE: Duration = Duration::from_secs(2); // backoff 2s · 2^failures
const PASSCODE_LOCKOUT_CAP: Duration = Duration::from_secs(60);
const PASSCODE_HARD_LOCKOUT: Duration = Duration::from_secs(900); // 15 min after threshold
/// Keyed by (profile_id, client_fp) — NOT per-profile (that is a cross-device DoS; any one device
/// could hard-lock a profile for everyone). In-memory; mirrors the `Pending` restart-clearing design
/// (native_pairing.rs:54-83). One attempt per connection (D1) — no in-handshake backoff sleep.
struct PasscodeGate { entries: Mutex<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).
```rust
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResolveVia { Assigned, Default, Selected }
#[derive(Clone, Debug)]
pub struct ResolvedProfile {
pub id: ProfileId,
pub display_name: String,
pub os_account: OsAccount,
pub session_defaults: SessionDefaults,
pub via: ResolveVia,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProfileError {
NotFound, // unknown profile id → ABI NotFound(-12)
PasscodeRequired, // passcode needed, none/blank supplied → ABI AuthRequired(-11)
PasscodeIncorrect, // passcode wrong → ABI AuthRequired(-11)
NotPermitted, // passcode-less profile, device not assigned, no shared-view → ABI NotFound(-12)
RateLimited { retry_in_secs: u64 }, // PasscodeGate lockout → ABI AuthRequired(-11)
}
```
`resolve_session_profile(client_fp, requested_id: Option<&str>, passcode: Option<&str>)`
`Result<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`.
```rust
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
```rust
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`).
```rust
// crates/punktfunk-core/src/quic.rs — Hello (struct :42-81, encode :619-656, decode :658-718)
/// Opaque host ProfileId the client wants this session in (NOT an OS username). Appended after
/// `video_caps` as `len u8 || UTF-8` (≤ PROFILE_ID_MAX). Presence forces the name/launch length
/// bytes + the `video_caps` byte to be emitted as placeholders first so it lands at a deterministic
/// offset (same rule launch/video_caps already obey). None = assigned/default (back-compat).
pub profile_id: Option<String>,
```
`encode` tail (replacing the `need_placeholders`/video_caps tail at `quic.rs:636-654`):
```rust
let need_caps = self.video_caps != 0 || self.profile_id.is_some();
match (&self.name, &self.launch) {
(None, None) if !need_caps => {}
(name, _) => { let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX);
b.push(n.len() as u8); b.extend_from_slice(n.as_bytes()); }
}
if self.launch.is_some() || need_caps {
let l = truncate_to(self.launch.as_deref().unwrap_or(""), HELLO_LAUNCH_MAX);
b.push(l.len() as u8); b.extend_from_slice(l.as_bytes());
}
if need_caps { b.push(self.video_caps); }
if let Some(p) = &self.profile_id {
let p = truncate_to(p, PROFILE_ID_MAX); b.push(p.len() as u8); b.extend_from_slice(p.as_bytes());
}
```
`decode` field (after the `video_caps` block at `quic.rs:711-716`):
```rust
profile_id: {
let name_len = b.get(26).copied().unwrap_or(0) as usize;
let launch_off = 27 + name_len;
let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize;
let prof_off = launch_off + 1 + launch_len + 1; // caps byte + 1
b.get(prof_off).and_then(|&len| {
let len = len as usize;
if len == 0 || len > PROFILE_ID_MAX { return None; }
b.get(prof_off+1..prof_off+1+len).and_then(|s| std::str::from_utf8(s).ok()).map(String::from)
})
},
```
A malformed tail never fails the handshake (the decoder returns `None`). `PROFILE_ID_MAX = 64`.
### 5.2 Control messages (CTL_MAGIC + type-byte framing, like `Reconfigure` `quic.rs:215`)
Type bytes (free range): `MSG_LIST_PROFILES = 0x40`, `MSG_PROFILE_LIST = 0x41`,
`MSG_PROFILE_UNLOCK = 0x42`, `MSG_PROFILE_REJECT = 0x43`. `pub const MAX_PROFILES: usize = 64;`
Each gets `encode()/decode()` mirroring the existing `get_bytes`/`put_bytes` helpers
(`quic.rs:403-419`); decoders validate `CTL_MAGIC` + type byte and exact/`<=` length.
```rust
/// client→host; an alternate first message (pre-connect enumerate). Wire: CTL_MAGIC‖0x40
pub struct ListProfiles;
/// One display-safe profile a paired device may pick. accent/avatar optional (UI only).
pub struct ProfileEntry {
pub id: String, pub display_name: String,
pub accent: Option<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`:
```rust
if ListProfiles::decode(&first).is_ok() {
// SCOPED roster (D9): only profiles assigned to THIS cert + passcode-protected picker-visible
// ones. Unpaired (when require_pairing) → empty (no roster leak).
let profiles = if peer_is_paired { profiles.enumerate_entries(&fingerprint_hex) } else { vec![] };
io::write_msg(&mut send, &ProfileList { profiles }.encode()).await?;
first = read_msg(&mut recv).await?; // now expect a Hello on the same bi-stream
}
```
**Inside the handshake**, after the `require_pairing` gate (`punktfunk1.rs:564`) and before
`validate_dimensions`:
```rust
// (1) If the connector sent a passcode, it is the NEXT message after Hello (never in Hello).
let passcode: Option<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`):
```rust
/// The profile id the host PLACED this session in (echoes the RESOLVED id — not the display name;
/// the client needs the id to reland + seed the next connect). None = single-user / old host.
/// Appended after `color` as `len u8 || UTF-8` at offset 64.
pub resolved_profile: Option<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`.
```c
PunktfunkConnection *punktfunk_connect_ex6(
const char *host, uint16_t port,
uint32_t width, uint32_t height, uint32_t refresh_hz,
uint32_t compositor, uint32_t gamepad, uint32_t bitrate_kbps, uint8_t video_caps,
const char *launch_id,
const char *profile_id, /* NULL => default profile (today's behavior) */
const char *passcode, /* NULL => none; connector sends ProfileUnlock iff non-NULL */
const uint8_t *pin_sha256,
uint8_t *observed_sha256_out,
const char *client_cert_pem, const char *client_key_pem,
PunktfunkStatus *status_out, /* nullable; receives the typed failure */
uint32_t timeout_ms);
```
- `connect_ex5` delegates with `profile_id = NULL, passcode = NULL, status_out = NULL` (its body moves
into `ex6`). `ex6` parses `profile_id`/`passcode` via `opt_cstr`, threads them into
`NativeClient::connect`, and on `Err(e)` writes `*status_out = e.status()` before returning null.
- Resolved-profile accessor: `const char *punktfunk_connection_profile(const PunktfunkConnection *conn)`
→ the resolved profile id the host echoed (`""`/NUL-terminated if None).
- Enumerate (standalone, like `punktfunk_pair`):
```c
typedef struct PunktfunkProfile {
char id[65]; char display_name[65];
uint8_t passcode_required; uint8_t assigned; /* assigned flag per D9 */
} PunktfunkProfile;
PunktfunkStatus punktfunk_list_profiles(
const char *host, uint16_t port,
const uint8_t *pin_sha256, uint8_t *observed_sha256_out,
const char *client_cert_pem, const char *client_key_pem,
PunktfunkProfile *out, size_t cap, size_t *count_out, uint32_t timeout_ms);
// fills min(cap, available); *count_out = total available. The host NUL-terminates each char[65].
```
(The wire `ProfileEntry` carries `accent`/`avatar`; the fixed C struct drops them — native pickers use
a monogram fallback, or fetch richer detail via the REST enumerate if needed.) Regenerate
`include/punktfunk_core.h` (cbindgen; CI checks drift).
### 5.6 `NativeClient` (`client.rs`)
`connect` (`client.rs:238`) gains two params after `launch`:
```rust
pub fn connect(host:&str, port:u16, mode:Mode, compositor:CompositorPref, gamepad:GamepadPref,
bitrate_kbps:u32, video_caps:u8, launch:Option<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_id``requested=None` → assignment-or-default (D4). Old client cannot answer a passcode → a passcode-gated profile is unreachable; such a device must be **assigned** or stays on the default. `resolved_profile` ignored by the old decoder. **Works.** |
| **New client** | Trailing `profile_id` ignored by old `Hello::decode`; old host runs its single session. `Welcome` omits `resolved_profile` → client sees `None`, hides the picker. `prof` absent in TXT → no profile UI. A stray `ListProfiles` to an old host fails first-message decode → connector falls back to a plain `connect(profile=None)`. **Graceful.** | Full feature: `connect_ex6` carries `profile_id`; connector sends `ProfileUnlock` iff passcode non-NULL; Welcome echoes the resolved id. |
A `Hello` with `profile_id=None` and no new fields is **byte-identical** to today's Hello.
---
## 6. Security model & threat analysis
### 6.0 Principals, assets, adversaries
**Principals:** *Operator* (owns the box; holds the mgmt bearer token,
`~/.config/punktfunk/mgmt-token` 0600, constant-time compared `mgmt.rs:532`; sole editor of
`profiles.json`; root-equivalent). *Paired device* (a client cert pinned in `punktfunk1-paired.json`,
mTLS-authenticated every connect). *Profile user* (a real OS account a profile maps to). *Shared
device* (one cert used by several humans — a family Apple TV).
**Assets (ranked):** OS-session-creation capability (the broker) > each profile user's desktop bytes +
input stream > the Windows auto-login credential blob > `profiles.json` (OS-user map, passcode hashes,
assignments) > mgmt token > per-session AES-GCM keys (already per-session random in `Welcome.key/salt`,
`quic.rs:172-173` — no new work).
**Adversaries:** (A) hostile unpaired LAN device; (B) paired-but-malicious device / stolen client key;
(C) unprivileged local OS user on the box; (D) a passenger on a shared device wanting another human's
profile; (E) the network-facing host post-RCE (codec/QUIC bug).
### 6.1 Privilege split (D7) and its invariants
The §3.2 invariant is realized by the zone split (§3.3). The do-not-regress invariants:
- **SEC-1 — no cross-uid byte access.** A profile user's *decoded* frames, raw input, and mic audio
are handled **only** by a process running as that uid (the zone-2 worker). Zone 1 sees only encoded
media + forwards opaque input. This supersedes the shared-`/tmp` EIS socket of the old
`gamescope-multiuser.md` — the sockets now live in the user's `XDG_RUNTIME_DIR`.
- **SEC-2 — broker trusts only the profile id.** The broker resolves `profile_id → {uid, username}`
from its **own** root-owned read of `profiles.json`, refuses an absent id, and **never** accepts a
raw uid/username/SID over IPC. A compromised zone 1 can only open sessions for OS users the operator
already mapped — not `root`, not an arbitrary account.
- **SEC-3 — isolation-required backends.** A profile whose OS user ≠ operator MUST run on an isolating
gamescope per-uid worker; shared-desktop backends (kwin/mutter/wlroots) serve the operator profile
**only**. Enforced fail-closed at resolve time (§6.3).
### 6.2 Host privilege model per platform
**Linux — root session-broker + per-uid worker (D7).** `punktfunk-session-broker` is a `root` systemd
**system** service; `punktfunk-host` stays non-root. Broker hardening: caps
`CAP_SETUID/SETGID/SETPCAP/KILL` **only**`CAP_SYS_ADMIN` and `CAP_DAC_OVERRIDE` are **dropped**
(the broker creates no namespaces and root-owner bits already grant the reads it needs; keeping them
would make the "tiny TCB" claim notional). `RestrictAddressFamilies=AF_UNIX` (never `AF_INET*`),
`RestrictNamespaces=true`, `SystemCallFilter=@system-service @setuid`, `MemoryDenyWriteExecute=yes`
(**re-verified** against the PAM module loader in the Phase-0 spike — if a module trips it, document
the `MDWX=no` exception). Full unit + handler in §7.1.
**Windows — mirror the split (target), SYSTEM front-door (v1 gap).** Target: non-SYSTEM streamer +
tiny SYSTEM broker with a verified client SID (the Windows analogue of SEC-2). **v1 may keep the
front-door as SYSTEM** but then **does NOT satisfy** the invariant — a host RCE = full box compromise
+ recovery of every stored credential. This is documented as a residual; operators run Windows
multi-user trusted-LAN only (§8.1, §13 Phase 3 gate).
### 6.3 Fail-closed isolation gate (SEC-3 / D5)
> The SEC-3 check lives **with resolution**, never "resolve-then-hope." At resolve time, if
> `os_account != Operator` **and** the isolating worker/broker is not present/available, the host
> **rejects** with `SessionUnavailable{NoBroker}` / `NeedsIsolation` **before the session pipeline is
> built**. "Non-Operator profile served on a shared host-lifetime injector/audio/mic singleton" is an
> **impossible state** — it would inject device D's keystrokes into another user's live desktop (the
> catastrophic leak).
Operator profiles keep today's shared-desktop multi-view (KWin/Mutter/wlroots) and are multi-occupant.
The brokered worker **always forces `Compositor::Gamescope`** regardless of the operator's own
compositor, so a Mutter/KWin operator desktop coexists with isolated gamescope users on the same box
(§7.4).
### 6.4 The passcode as a second factor (D1/D10)
The passcode is an **optional per-profile** secret gating *shared-device* access (decision #2). It is
**not** the OS password and never touches PAM/LogonUser auth directly (except as the Windows
credential-wrap key, §8.6).
- **Storage/verify:** Argon2id PHC only, m=19456/t=2/p=1, decoy-verify on miss, host-side
(§4.3). Never logged, never echoed in errors.
- **Plaintext-over-channel is sound here:** the channel is QUIC TLS 1.3 (confidential) **and**
cert-mutually-authenticated (the device is already paired) — unlike the *pairing* PIN, where SPAKE2 is
needed because no trust exists yet. So a plain host-side verify is correct and a PAKE only adds a
round trip. The passcode rides the never-logged `ProfileUnlock`, **never** Hello.
- **One attempt per connection; cross-connection `PasscodeGate`** keyed `(profile_id, client_fp)` so
one device cannot DoS-lock a profile for others; Windows persists the counters across the
console-switch relaunch.
- **Stolen paired key (adversary B):** an attacker with a device's key+cert lands in the profiles that
device is **assigned** to (frictionless skips the passcode). For high-value profiles the operator
sets `require_passcode_even_when_assigned = true`, turning the cert into a *first* factor and the
passcode into a *true second* factor (a stolen key alone is then insufficient). Recovery from a
stolen key is unpairing the fingerprint (`DELETE /api/v1/native/clients/{fp}`).
### 6.5 Occupancy (D6)
Single-occupancy is keyed by the **resolved Linux uid / Windows SID**, not the profile id — so two
profiles mapping to the same OS user can't both occupy it (Steam single-instance / `/run/user/<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):**
```rust
// crates/punktfunk-broker/src/proto.rs (shared by broker + crate::broker_client)
pub const PROTO_VERSION: u32 = 1;
#[derive(Serialize, Deserialize)]
pub enum BrokerRequest {
/// Host passes ONLY a profile_id (SEC-2: broker re-reads profiles.json itself). client_fp is for
/// the broker's independent audit. The authoritative (re-clamped) mode rides SessionInit over
/// session_fd, so the LAUNCH command never enters the root TCB.
OpenSession { proto: u32, profile_id: String, client_fp: String, mode: Mode },
CloseSession { session_id: u64 }, // idempotent; lifeline EOF is the crash-path reaper
}
#[derive(Serialize, Deserialize)]
pub enum BrokerReply {
Opened { session_id: u64, uid: u32, worker_pid: u32 }, // session_fd via SCM_RIGHTS, out-of-band
Error { kind: BrokerError, msg: String },
}
#[derive(Serialize, Deserialize, Clone, Copy)]
pub enum BrokerError {
NoSuchProfile, // absent from broker's own profiles.json read (SEC-2)
NotIsolatable, // resolves to Operator — zone-1 bug, must not brokerage it
MissingGroups, // target uid lacks render/video/input (D12) — msg names which
LingerFailed, PamFailure, SpawnFailure, Busy,
}
```
`Mode` is a leaf re-export of `punktfunk_core::Mode` so the crate doesn't pull all of core. Framing:
4-byte LE length + `serde_json` (auditable, no custom parser to fuzz); `SOCK_SEQPACKET` for
kernel-enforced message boundaries.
**`OpenSession` handler order** — the non-obvious part is making a **cold** OS user (never
interactively logged in, headless box, no seat) get a real `XDG_RUNTIME_DIR`, user D-Bus, and tracked
logind session:
1. **`SO_PEERCRED` gate** — `ucred.uid == host_uid` or `Error`.
2. **Resolve `profile_id → (username, uid)` from the broker's OWN read of `profiles.json`** (root-owned;
`stat`+reload per request so mgmt edits are picked up; a parse failure denies, never serves stale).
Reject `OsAccount::Operator` with `NotIsolatable`; absent id with `NoSuchProfile`.
3. **Group preflight (D12)**`getpwnam_r` + `getgrouplist`; require `render`, `video`, `input` (GPU
`renderD128`; `uinput`/`uhid`; PipeWire). On a miss → `MissingGroups{msg:"user 'kids' not in: render, input"}`
→ surfaced to the client as `SessionUnavailable{LoginFailed}` with that reason, not a black screen.
4. **`loginctl enable-linger`** (`org.freedesktop.login1.Manager.SetUserLinger(uid,true,false)`,
idempotent) — provisions `/run/user/<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:
```text
pam_start("punktfunk", username, &conv_null, &pamh)
pam_set_item(PAM_TTY, "pts/punktfunk") pam_set_item(PAM_RUSER, "root")
pam_putenv("XDG_SESSION_CLASS=user") pam_putenv("XDG_SESSION_TYPE=wayland")
pam_putenv("XDG_SESSION_DESKTOP=gamescope")
pam_authenticate(0) // pam_permit — passwordless (broker is root)
pam_acct_mgmt(0) pam_setcred(PAM_ESTABLISH_CRED) pam_open_session(0)
```
6. **Verify the session class** (`sd_uid_get_state` / inspect the new `XDG_SESSION_ID`) is `user`; a
`background` class → close + `PamFailure` (belt-and-suspenders with linger, which already guarantees
the runtime dir).
7. **`fork()`**; child: `pam_getenvlist` → set the session env → `setgid(pw_gid)` →
`initgroups(username, pw_gid)` (picks up render/video/input) → **`setuid(uid)`** →
**re-assert `getuid()==uid && setuid(0)==EPERM`** (drop is irreversible) →
`prctl(PR_SET_PDEATHSIG, SIGKILL)` → **drop the capability bounding set** (worker holds zero caps) →
dup the worker `session_fd` end to fd 3 → `execve punktfunk-host session-worker --session-fd 3 --mode WxHxHz --uid N`.
8. Parent records `session_id → SessionRec{pamh, worker_pid, uid, peer_conn}`, writes the **zone-0
audit** line, replies `Opened` with the **zone-1 end** of the socketpair via `SCM_RIGHTS`.
9. **Teardown** (on `CloseSession`, lifeline EOF, or worker `SIGCHLD`): `pam_close_session` +
`pam_setcred(PAM_DELETE_CRED)` + `pam_end`; `kill(worker_pid)` if alive; `waitpid`; audit `close`.
Linger is left enabled (a provisioning property of the user) unless the operator unprovisions.
**Hardening unit** (`scripts/punktfunk-session-broker.service`): `Type=notify`, `User=root`,
`CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_KILL`, `AmbientCapabilities=`,
`NoNewPrivileges=no` (the broker's whole job is to change uid), `RestrictAddressFamilies=AF_UNIX`,
`RestrictNamespaces=true`, `SystemCallFilter=@system-service @setuid`, `ProtectKernelModules=yes`,
`MemoryDenyWriteExecute=yes` (re-verified in the Phase-0 spike). **PAM service** `scripts/pam/punktfunk`:
```text
auth required pam_permit.so # passwordless — broker is already root
account required pam_unix.so # still honors account lock/expiry
session required pam_systemd.so # registers the logind session + XDG_RUNTIME_DIR
session required pam_unix.so
```
### 7.2 Zone 2 — `punktfunk-host session-worker` (per-uid)
The existing per-session machinery, but **its own process, running as the uid**, with **per-session**
injector/audio/mic instead of the host-lifetime singletons. Re-entered as `punktfunk-host
session-worker` (no second binary). Entry sketch:
```rust
// crates/punktfunk-host/src/session_worker.rs
pub fn run(opts: WorkerOpts) -> Result<()> {
let mut chan = session_relay::WorkerChannel::from_raw_fd(opts.session_fd); // fd 3 → zone 1
let init = chan.recv_init()?; // SessionInit{ mode, launch_cmd, bitrate_kbps, bit_depth }
// (1) Force the isolating backend — a FRESH nested gamescope as this uid (own EIS/GPU/PipeWire/
// null-sink). SEC-3 by construction; the client's + operator's compositor prefs are irrelevant.
let mut vd = vdisplay::open(Compositor::Gamescope)?;
vd.set_launch_command(init.launch_cmd); // per-instance, never the process-global env (vdisplay.rs:56)
// (2) Same retry helper zone-1 uses — capture+encode run HERE.
let plan = session_plan::SessionPlan::resolve(init.bit_depth);
let (mut capturer, mut enc, ..) =
build_pipeline_with_retry(&mut vd, init.mode, init.bitrate_kbps, init.bit_depth, plan)?;
// (3) Per-session, uid-owned side services (NOT the host-lifetime singletons):
let injector = SessionInjector::start(vd.current_ei_socket()); // EIS bound to THIS socket
let audio = SessionAudio::start(vd.audio_sink_node())?; // null-sink monitor
let mic = SessionMic::start(); // "punktfunk-mic-<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:
```rust
pub trait AuSink { fn push_au(&mut self, au: &[u8], pts_ns: u64, keyframe: bool) -> Result<()>; }
// zone-1 Operator path: impl AuSink for &mut Session (today's behavior; nothing moves)
// zone-2 worker path: impl AuSink for WorkerChannel (writes a tagged frame to fd 3)
```
Zero-copy paths (`frame.is_cuda()`) are untouched — the worker is just a different consumer of the
same encoded bytes (5K@240: the AU is already one `sendmmsg`-batched object; the SEQPACKET hop is one
`write`, same as the Windows relay). Gamepads stay correct: the worker owns its uinput/uhid pads (from
the resolved `welcome.gamepad`), so the devices live in the uid's namespace and rumble/HID flows back
over fd 3 → zone 1 → client datagram.
### 7.3 The four gamescope-multiuser isolation pieces (now mandatory)
These are `gamescope-multiuser.md`'s parked plumbing, now mandatory and hardened to live inside the
worker (sockets in the uid's `XDG_RUNTIME_DIR`, not shared `/tmp` — the SEC-1 improvement):
1. **Per-instance EIS socket → new `VirtualOutput` field.** `vdisplay.rs:29-47` gains
`#[cfg(target_os="linux")] pub ei_socket_file: Option<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`):
```rust
let resolved: ResolvedProfile = match profiles.resolve_session_profile(&fp_hex, req_id, passcode) {
Ok(r) => r,
Err(e) => { write_profile_reject(&mut send, reason_for(e)).await?; return Ok(()); }
};
let route: SessionRoute = match resolved.os_account {
// (A) Operator → today's shared-desktop path, UNCHANGED. Multi-occupant (D6).
OsAccount::Operator => SessionRoute::Operator { compositor: resolve_compositor(hello.compositor)? },
// (B) A real Linux user → MUST run on the isolating gamescope worker. Fail CLOSED here.
OsAccount::Linux { uid, .. } => {
let uid = uid.expect("resolve stamps uid at create-time");
if !broker_client::is_available() { // Flatpak / unprovisioned host (D12)
write_session_unavailable(&mut send, NoBroker).await?; return Ok(()); } // NEVER fall through
if !vdisplay::gamescope_present() {
write_profile_reject(&mut send, NeedsIsolation).await?; return Ok(()); }
let occ = match occupancy.try_acquire(uid, &fp_hex) { // D6: by RESOLVED uid; Operator exempt
Some(g) => g,
None => { write_profile_reject(&mut send, Occupied).await?; return Ok(()); }
};
let session = match broker_client::open(&resolved.id, &fp_hex, hello.mode) {
Ok(s) => s,
Err(BrokerError::MissingGroups | BrokerError::LingerFailed
| BrokerError::PamFailure | BrokerError::SpawnFailure)
=> { write_session_unavailable(&mut send, LoginFailed).await?; return Ok(()); }
Err(_) => { write_session_unavailable(&mut send, NoBroker).await?; return Ok(()); }
};
SessionRoute::Brokered { uid, occ, session }
}
};
```
**The KWin/Mutter/wlroots-operator question, answered:** a non-Operator profile is **never** served on
those shared backends (they create a virtual output *inside the operator's live session* —
`remote_fd:None`, no isolation = the SEC-3 violation). The worker always forces gamescope, so a Mutter
or KWin operator desktop coexists fine with isolated gamescope users on the same box. The only
rejections are when isolation can't be stood up (`NoBroker` / `NeedsIsolation` / `LoginFailed`) — never
a silent fallthrough, never a black screen. Synthetic source and `--gamestream` are unaffected
(GameStream pinned to Operator, D13; the synthetic test source has no compositor).
### 7.5 Threading the route through `SessionContext` / pipeline / accept loop
The accept loop (`punktfunk1.rs:167-323`) keeps the host-lifetime singletons (`audio_cap` `:212`,
`injector` `:217`, `mic_service` `:221`) — they now serve **only Operator** sessions. Add two
host-lifetime handles cloned into each spawned session: `occupancy: Arc<Occupancy>` and
`broker: Arc<BrokerClient>` (lazily-connected; error ⇒ `is_available()==false`). `SessionContext`
(`:2352-2391`) replaces the bare `compositor` with a route + a logging id:
```rust
enum SessionRoute {
Operator { compositor: crate::vdisplay::Compositor }, // capture+encode in zone 1 (today)
Brokered { uid: u32, occ: OccupancyGuard, session: BrokerSession }, // capture+encode in the worker
}
// SessionContext gains: route: SessionRoute, resolved_profile_id: String // for GET /api/v1/sessions (D11)
struct Occupancy { by_uid: Mutex<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 `close` → `OccupancyGuard::drop` frees the uid slot. The worker, on `session_fd` EOF or `SIGTERM`,
drops its gamescope keepalive, `SessionInjector` (destroys the per-session pads + EIS), and
null-sink/mic.
**Orphan reaping (the lifeline).** The broker treats the **zone-1 connection** as each session's
lifeline: if the host dies (crash, console-change restart, OOM), every `SessionRec` on that connection
is reaped (`pam_close_session` + kill + waitpid). Redundant nets: `PR_SET_PDEATHSIG=SIGKILL`; the worker
self-exits on `session_fd` EOF; on broker startup it sweeps `/run/punktfunk/sessions/*.pid` and kills
survivors from a previous broker generation before accepting.
**Reconfigure re-clamp (D11).** The mid-stream `Reconfigure` (`punktfunk1.rs:722`) is **re-intersected
with the resolved profile's `SessionDefaults`** (not just the initial Hello) via
`clamp_mode(requested, resolved.session_defaults)`, then for a brokered session **forwarded to the
worker** over `session_fd` (a `Reconfigure{mode}` control frame). The worker rebuilds gamescope+capture+
encoder at the new clamped mode via `build_pipeline_with_retry` (gamescope can't change output mode
live → relaunch the nested compositor; the data plane in zone 1 runs on). The Operator path keeps its
in-process rebuild.
**Observability (D11).** `GET /api/v1/sessions` reads the occupancy registry +
`SessionContext.resolved_profile_id`/`uid`/`route`; `DELETE /api/v1/sessions/{id}` sets `stop` + drops
the `BrokerSession` + frees occupancy (the web console **Reclaim** action).
### 7.7 Honest cold-login scope + packaging (D12)
**What Linux delivers:** a brokered profile gets a **cold auto-login into a gamescope game-mode
session** (a fresh nested gamescope running the resolved title, or Steam Big-Picture via the managed
`gamescope-session-plus`/SteamOS paths), **not** the user's full KDE/GNOME desktop — that is the
SEC-3-required isolating backend. A **full-desktop-per-user** path (logging the uid into their own
KWin/Mutter and capturing *that* in isolation) is a **later scope**. Product framing: "stream into a
user's games, isolated" now; "stream into a user's full desktop" later.
**Packaging:**
| Package | Ships broker + PAM + units? | Capability |
|---|---|---|
| **deb / rpm / COPR / Arch / sysext / Bazzite bootc** | yes — `/usr/lib/punktfunk/punktfunk-session-broker`, `/etc/pam.d/punktfunk`, both systemd units, tmpfiles.d; `postinst`/`%post` enables `punktfunk-session-broker.socket` | Operator **and** Linux-user |
| **Flatpak / sandboxed** | **no** (can't install a root service) | **Operator-only**, advertised via a capability flag; a Linux-user profile returns `SessionUnavailable{NoBroker}` — never a silent operator fallthrough |
The broker is a **separate, optional package** declared `Recommends:`/`Suggests:` of `punktfunk-host`
— **not** a hard dependency. A minimal install has **no root component on disk at all** (Operator-only
by construction). The host advertises an `isolating_profiles: bool` capability (broker reachable) over
the mgmt API / mDNS TXT so the console + clients can grey out real-user profiles on an Operator-only
install.
### 7.8 Phases (mapped to the lab boxes; headless spike first)
- **Phase 0 — PAM/logind feasibility spike (headless, the de-risk).** QEMU VM. A standalone
`broker-spike` (no QUIC, no host): throwaway user `pftest`, `enable-linger`, full
`pam_start … pam_open_session` + `setuid`, then from the dropped child spawn
`gamescope --backend headless -W 1280 -H 720 -r 60 -- vkcube`, connect to *that* user's PipeWire and
capture. Assert: runtime dir provisioned cold, session `Class==user`, render-node access as the uid,
**and `MemoryDenyWriteExecute=yes` survives the box's PAM stack.** **Gate the rest of the work on
this passing.**
- **Phase 1 — broker + worker + the four isolation pieces.** QEMU VM (gamescope 3.16.22). Validate
worker↔zone-1 AU relay + per-session EIS/null-sink/mic in isolation.
- **Phase 2 — SEC-3 gate + route threading + occupancy + `GET/DELETE /api/v1/sessions`.** Verify the
Operator path byte-for-byte unchanged; broker-stopped Linux profile → `NoBroker`; missing-group user
→ `LoginFailed`; second device on the same uid → `Occupied`.
- **Phase 3 — concurrent multi-user + cross-GPU.** QEMU VM (two users, leak test — drive pad on device
A, confirm nothing reaches user B); AMD Sway box (192.168.1.25, VAAPI worker as a non-operator uid);
GNOME/Mutter box (192.168.1.248, operator desktop is Mutter while a Linux-user profile runs on its
forced gamescope worker).
- **Phase 4 — cold-login honesty, packaging, docs.** deb/rpm install on the GNOME + Sway boxes; Steam
Deck (deck@192.168.1.253) `gamescope-session-plus` coexistence.
---
## 8. Windows host
Maps a resolved `OsAccount::Windows { account_name, sid, credential }` to a real, capturable Windows
session, conforming to the D7 broker-split **target** with the v1 SYSTEM honesty caveat, and to the D11
lifecycle fixes.
### 8.1 The hard constraint + privilege posture
A Windows **client SKU** has exactly ONE interactive console session; all GPU capture (DDA/WGC/IDD-push)
follows it (`WTSGetActiveConsoleSessionId()` hardcoded in `service.rs:325`, `wgc_relay.rs:218`,
`interactive.rs:53`). True concurrent multi-user desktops are RDS/Server-only. Therefore **on Windows,
profiles are SEQUENTIAL: one active profile at a time; selecting a different profile is a console
user-switch.** Concurrency stays a Linux/gamescope capability. Occupancy is **global** (single console),
persisted across the console-switch relaunch (D6).
**Privilege (D7).** Target = the mirror split: a **non-SYSTEM** streamer front-door with
`LogonUser`/`LoadUserProfile`/`CreateProcessAsUserW` + credential unsealing behind a **tiny SYSTEM
broker** over ALPC/named-pipe with a verified client SID. **v1 may ship the front-door as SYSTEM**, but
the Phase-3 gate decision MUST record that Windows v1 does **not** satisfy the §3.2 invariant (a host
RCE = full box compromise + recovery of every stored credential → trusted-LAN-only posture).
### 8.2 W0 — generalize the primitives from "active console" to a target session id
New module `crates/punktfunk-host/src/windows/profile_session.rs`. The single mechanical unblock: stop
hardcoding `WTSGetActiveConsoleSessionId()`, thread an explicit `target_session: u32`.
```rust
pub struct WindowsProfileTarget {
pub profile_id: String,
pub account_name: String, // "DOMAIN\\user" or ".\\user"
pub sid: Option<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
```rust
#[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:
```rust
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).
```rust
// inner plaintext blob: { account_name: String, password: String } (serde, zeroized after use)
pub fn store(profile_id: &str, account: &str, password: &str, passcode: Option<&str>) -> Result<()>;
pub fn load(profile_id: &str, passcode: Option<&str>) -> Result<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::clear`s the blob (§12.2).
### 8.8 SessionPlan / SE_TCB / GameStream
`SessionPlan::resolve` (`session_plan.rs:113`) is unchanged (capture/topology/encoder are
profile-independent) — the profile only redirects *which session* the topology's helper/launch targets,
carried in `SessionContext.profile_target`. The host already holds `SE_TCB_NAME` (SYSTEM), satisfying
`WTSQueryUserToken` + `CreateProcessAsUserW` + `LogonUser`; a non-SYSTEM dev `serve` cannot do profiles
→ falls back to operator/console. GameStream is pinned to operator: its stream loop and `launch_title`
calls pass `target = active console`, never a `profile_target`, with an audit line per session (D13).
### 8.9 Per-user library
`library::all_games()` currently enumerates the SYSTEM host process's HOME — wrong for a profile. When
`profile_target` is set, enumerate **as the target user** (read that user's hive/paths after
`LoadUserProfileW`, or rely on the existing `windows_launch_for` recipes which already run via the user
token once `spawn_in_session(wts)` targets the right session). Visibility curation layers on top via
`profiles.scoped_library(fp, all_games_for_user(sid))`.
### 8.10 Phase ordering & validation matrix
| Phase | Work | Validatable on RTX box (single user)? |
|---|---|---|
| W0 | thread `target_session` through the 3 primitives | YES — target=console = no-op regression |
| W1 | resolve profile→session, occupancy by SID, `profile_target` | Partial — needs a 2nd pre-logged-in account (FUS) |
| W2a | reconnect to existing/disconnected session + supervisor follow + takeover guard | needs 2nd account, signed in once |
| W2b | credential store + `LogonUser`+`LoadUserProfile` | store unit-testable on box; cold logon needs 2nd account |
| W3 | custom Credential Provider (cold-user console auto-login) | needs 2nd account + a display; heaviest; **deferred** |
| W4 | RDS multi-session | Server SKU / RDS host (out of lab) |
Back-compat: absent `profiles.json` or operator/default → `profile_target = None` → `target = active
console` → byte-identical to today at every primitive.
---
## 9. Web console & management API
Operator-facing config surface. Adds a `profiles` tag to the Axum mgmt API
(`crates/punktfunk-host/src/mgmt.rs`), an OS-account enumeration endpoint, the Live-sessions
observability endpoints, and a new web console **Profiles** page following the
`index.tsx`/`view.tsx`/stories/fixtures/i18n conventions. Built against the §4 data model exactly — it
does **not** redefine those types.
### 9.1 `MgmtState` wiring
```rust
struct MgmtState {
app: Arc<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 | `ProfileInput` → `ProfileAdmin` (201) |
| `GET /api/v1/profiles/{id}` | `getProfile` | bearer | → `ProfileAdmin` |
| `PUT /api/v1/profiles/{id}` | `updateProfile` | bearer | `ProfileInput` → `ProfileAdmin` |
| `DELETE /api/v1/profiles/{id}` | `deleteProfile` | bearer | → 204 |
| `POST /api/v1/profiles/{id}/passcode` | `setProfilePasscode` | bearer | `SetPasscode` → 204 |
| `POST /api/v1/profiles/{id}/devices` | `assignProfileDevice` | bearer | `AssignDevice` → 204 |
| `DELETE /api/v1/profiles/devices/{fp}` | `unassignProfileDevice` | bearer | → 204 |
| `PUT /api/v1/profiles/default` | `setDefaultProfile` | bearer | `SetDefault` → 204 |
| `POST /api/v1/profiles/{id}/windows-credential` | `setWindowsCredential` | bearer (Windows-only) | `{account_name,password,passcode?}` → 204; `DELETE` clears |
| `GET /api/v1/os-accounts` | `listOsAccounts` | bearer | → `Vec<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
```rust
/// Admin view — the full profile MINUS the secret. Never exposes the PHC.
#[derive(Serialize, ToSchema)]
struct ProfileAdmin {
id: String, display_name: String, accent: Option<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()`.
```rust
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.
```rust
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`).
```tsx
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.json` → `list_profiles` returns `[]` → the page shows the
implicit-operator pseudo-tile + an empty state; `os-accounts`/`sessions` are additive; the `get_library`
scoping branch is a no-op for bearer callers.
---
## 10. Apple clients (macOS / iOS / iPadOS)
Connect-time profile selection for non-tvOS Apple clients (tvOS adds the get-current-user layer in §11
but reuses these views). Built against the **reconciled** model: the profile id rides the Hello, the
passcode rides a never-logged `ProfileUnlock` the connector sends right after — surfaced through
`connect_ex6`; the host rejects a needed/bad passcode **at handshake before building the pipeline**
(one attempt per connection), and the Welcome echoes the resolved id. Enumeration uses the control-plane
`punktfunk_list_profiles` (not the mgmt port — works even when mgmt is loopback-only).
### 10.1 Where it slots vs. trust / pairing
Trust is established **first and unchanged**; the picker is only reachable for an already-pinned host.
| Situation | Behavior |
|---|---|
| Unpinned, `pair=optional` (TOFU), first contact | Connect `profileID = nil`; host resolves the brand-new unassigned fingerprint to its default. No picker (a never-seen device has nothing to pick). |
| Unpinned, `pair=required` | `PairSheet` first; after `handlePaired` pins, the follow-up connect uses `profileID = nil`. |
| **Pinned, default tap** | Connect `profileID = host.lastProfileID` (nil first time) + remembered passcode if any. After `streaming`, cache `connection.resolvedProfile`. |
| **Pinned, "Choose Profile…"** | Present `ProfilePicker` → enumerate → pick → (passcode if challenged) → connect with the explicit selection. |
| **Connect rejected** (`profilePasscodeRequired`/`Wrong`/`profileNotFound`) | `SessionModel` publishes a typed `profilePrompt`; `ContentView` opens `ProfilePicker` pre-seeded to re-prompt. |
First-contact carries the profile id in the *same* Hello that establishes the session — there is no
second Hello after the user confirms trust, so a never-seen (unassigned) device always lands in the
host default; explicit picking is meaningful only once pinned.
### 10.2 `PunktfunkKit/PunktfunkConnection.swift`
New error cases (append to `PunktfunkClientError`): `.profilePasscodeRequired`, `.profilePasscodeWrong`,
`.profileNotFound`. **Reconciled mapping** — wrong **and** missing passcode both arrive as ABI
`AuthRequired = -11`; the client distinguishes by whether it supplied a passcode:
```swift
guard let h = handle else {
switch status {
case statusAuthRequired where passcode != nil: throw PunktfunkClientError.profilePasscodeWrong
case statusAuthRequired: throw PunktfunkClientError.profilePasscodeRequired
case statusNotFound: throw PunktfunkClientError.profileNotFound
default: throw PunktfunkClientError.connectFailed
}
}
```
`init` gains `profileID: String? = nil` and `passcode: String? = nil` (after `launchID`; defaulted so
existing call sites compile unchanged), replaces `punktfunk_connect_ex5` with `punktfunk_connect_ex6`
(threading `profileID`/`passcode` via `withOptionalCString` + capturing the `status_out` pointer), and
reads `resolvedProfile` via `punktfunk_connection_profile(h, &profBuf, …)`:
```swift
public private(set) var resolvedProfile: String? // Welcome echo; nil = single-user / old host
```
Enumerate (free function, mirrors `pair()`), now carrying the **`assigned`** flag (D9 — so the picker
renders frictionless profiles without a blind try):
```swift
public struct HostProfile: Identifiable, Hashable, Sendable {
public let id: String
public let displayName: String
public let passcodeRequired: Bool
public let assigned: Bool // NEW (D9): requesting cert is assigned → connect on one tap
}
public func listProfiles(host: String, port: UInt16 = 9777, identity: ClientIdentity,
pinSHA256: Data? = nil, timeoutMs: UInt32 = 10_000) throws -> (profiles: [HostProfile], hostFingerprint: Data)
// over punktfunk_list_profiles → PunktfunkProfile{ char id[65]; char display_name[65];
// uint8_t passcode_required; uint8_t assigned }
```
### 10.3 `SessionModel.swift`
`connect(to:…)` gains `profileID`/`passcode` (defaulted), passes them into the `PunktfunkConnection(...)`
init inside the existing `Task.detached`, and in the `.failure` arm publishes a structured prompt:
```swift
@Published var profilePrompt: ProfilePrompt?
struct ProfilePrompt: Identifiable, Equatable {
let id = UUID(); let host: StoredHost; let attemptedProfileID: String?
enum Reason: Equatable { case choose, passcodeRequired, wrongPasscode, notFound }
let reason: Reason
}
// .failure(.profilePasscodeRequired) → .passcodeRequired ; .profilePasscodeWrong → .wrongPasscode ;
// .profileNotFound → .notFound ; existing generic errorMessage handling otherwise unchanged.
```
### 10.4 Per-host cache + passcode at rest
`StoredHost` gains `var lastProfileID: String?` (Codable back-compat like `mgmtPort`; seeds the next
connect's `profileID`) + `HostStore.setProfile(_:profileID:)`. New
`PunktfunkClient/ProfilePasscodeStore.swift` — a Keychain store mirroring `ClientIdentityStore`'s
data-protection-keychain + legacy-fallback, keyed by `(host cert fingerprint hex, profileID)`:
```swift
final class ProfilePasscodeStore: @unchecked Sendable {
static let shared = ProfilePasscodeStore()
func load(hostFingerprint: Data, profileID: String) -> String? // nil = not remembered
func save(_ passcode: String, hostFingerprint: Data, profileID: String)
func delete(hostFingerprint: Data, profileID: String)
}
```
Default behavior is **not** to remember (shared-device safety); the picker offers an explicit "Remember
on this device" toggle. **Clear on wrong (D12):** a wrong-passcode result clears any cached entry. The
toggle is **forbidden on shared/tvOS builds** (§11, D12). Deleting a host (`HostStore.remove`) /
`forgetIdentity` sweeps the host's stored passcodes.
### 10.5 `ContentView.swift` orchestration + `ProfilePicker.swift`
`connect(_:)` threads `profileOverride: ProfileSelection?`; `defaultSelection(for:)` returns
`(host.lastProfileID, remembered passcode)`; the `streaming` `.onChange` caches
`store.setProfile(host.id, profileID: model.connection?.resolvedProfile)`. A single sheet bound to
`model.profilePrompt` drives `ProfilePicker` (tvOS uses `.navigationDestination(item:)`). A host-card
context-menu callback `chooseProfile(_:)` opens the picker with `reason: .choose`, gated on
`prof=1`/`lastProfileID` + pinned.
`ProfilePicker.swift` (new): a two-step flow — enumerate (off-main via `listProfiles`) → list → on a
passcode-required pick (or `.passcodeRequired`/`.wrongPasscode` re-entry) collect the passcode; returns
`Result?{ profileID, passcode?, remember }` (nil = cancelled). Each row shows `displayName` + a lock
glyph when `passcodeRequired`; an **assigned** or passcode-less profile connects on one tap (try
`passcode = nil` first, re-prompt only on `profilePasscodeRequired`). The passcode step is a `SecureField`
(numberPad on iOS), a default-off "Remember" toggle, an inline "Wrong passcode — try again" on re-entry;
tvOS swaps to `TVFieldRow`/`TVTextEntry` like `PairSheet`.
### 10.6 Settings, HUD, back-compat
Per-profile client settings are **not** needed (the host-side `SessionDefaults` clamps the client's
global request at resolve time + Reconfigure). The HUD adds an optional "Profile: …" line from
`connection.resolvedProfile`. Touchpoints: `PunktfunkConnection.swift`, `SessionModel.swift`,
`HostStore.swift`, new `ProfilePasscodeStore.swift` + `ProfilePicker.swift`, `ContentView.swift`,
`HomeView.swift`, `HostCards.swift`, `HostDiscovery.swift` (expose `DiscoveredHost.supportsProfiles`
from TXT `prof=1`), `StreamHUDView.swift`. Every new param defaults `nil`/`.auto`, so an un-updated
client behaves exactly as today; only `PunktfunkConnection.init` migrates to `ex6`.
---
## 11. tvOS profile binding
Binds the active Apple TV user to a profile. **Extends** §10 (it does not re-spec the connect plumbing);
it adds the tvOS-only "read who's in front of the TV → auto-select their mapped profile" layer. **One
shared Apple TV app identity + one host pairing:** the single device cert is presented by *every* tvOS
user, so the host sees **one fingerprint** for the whole Apple TV; the **profile** (Hello `profile_id`)
plus its passcode separates the humans. `currentUserIdentifier` is a **client-side hint only** (D8).
### 11.1 Mechanism + Apple symbols
- **Framework:** `import TVServices` (tvOS only). **Class:** instantiate `TVUserManager()` and read
`var currentUserIdentifier: String?` — an opaque, per-app-stable string (not an Apple ID/name/email),
non-nil only once per-user profiles are enabled **and** the app holds the user-management entitlement;
`nil` on a single-user device or an un-entitled build.
- **User-changed detection:** the **primary** mechanism is re-reading `currentUserIdentifier` on every
foreground (`scenePhase == .active`) + at launch (covers the case where the app is not relaunched on a
switch). A dedicated change notification is a **secondary/defensive** observer if it exists.
- **Entitlement:** `com.apple.developer.user-management = ["get-current-user"]` (read the active user
only). We deliberately do **not** request `runs-as-current-user*` — those swap the app's data container
per tvOS user → a separate Keychain identity → a separate host pairing, contradicting "one shared
identity." `get-current-user` keeps a single shared container. Goes in a **new**
`Config/Punktfunk-tvOS.entitlements` (split from the shared iOS file, mirroring the macOS split). The
entitlement is **Apple-gated and not guaranteed** for a streaming app → the whole feature must degrade
gracefully (§11.4).
> **Open symbols (§14):** the exact `TVUserManager` construction form (`()` vs `.shared`) and whether a
> public `didChangeCurrentUserNotification` exists are **unverified** against the tvOS SDK; the design
> caches one instance and treats the notification as optional so the `scenePhase` re-read alone is
> sufficient.
### 11.2 The mapping store — per-(tvOS user, host) → profile id
```swift
// DefaultsKeys.swift
public static let tvUserProfileMap = "punktfunk.tvUserProfileMap" // [compositeKey: profileID]
// compositeKey = "<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 <device>" 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
<profile> 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 first** → `punktfunk-broker` crate + root service/socket (caps trimmed) + `/etc/pam.d/punktfunk` + per-uid worker (SEC-1) + SEC-3 routes through the broker + occupancy by uid + orphan reaping + `GET/DELETE /api/v1/sessions` + render/video/input preflight + zone-0 audit. | **PRIVILEGED** | **explicit user go/no-go before merging privileged code** |
| **3 — Windows** | W0→W2b (target-session generalization, SID occupancy, FUS reconnect + takeover guard, DPAPI cred store + TPM-bind + entropy floor, hive/token unload, set_passcode re-wrap, lockout/occupancy persistence, windows-credential route + dead-state badge). **Credential Provider (cold console) deferred.** Front-door SYSTEM-vs-non-SYSTEM is the gate decision (D7). | **PRIVILEGED** | **explicit user go/no-go**; record the SYSTEM-v1 invariant-violation + trusted-LAN posture |
| **4 — tvOS** | get-current-user binding → client-side auto-selection only (D8); reconnect on switch; Remember forbidden on tvOS. | none | none (entitlement may be denied → degraded mode is default-safe) |
Phases 2 and 3 are independent (different platforms/boxes) and may proceed in parallel; each needs its
own gate. Phase 4 only needs the Phase-1 wire/ABI.
### 13.4 Test strategy (cross-cutting)
- **Unit (`profiles.rs`):** the **D4 authority table, one case per row** (incl. the
`None`+unassigned+passcode-default = require regression, and `require_passcode_even_when_assigned`
upgrading each grant); Argon2id PHC round-trip + decoy-verify constant cost + `(profile_id,client_fp)`
keying (one device's lockout does NOT lock another or the profile globally) + backoff/lockout schedule;
load/save atomicity + **failed-save-is-failed-mutation** + parse-failure-refuses-mutation + load-time
dedupe + **same-uid/SID reject (D6)** + compositor + entropy-floor validation.
- **Core round-trip:** Hello/Welcome new trailing fields decode to `None` from old bytes; placeholder
offsets deterministic; `ListProfiles`/`ProfileList`/`ProfileUnlock`/`ProfileReject` encode/decode;
`Welcome.resolved_profile` carries the **id**; `connect_ex6` ABI harness (C round-trip, `ex5`→`ex6`
`NULL,NULL,NULL` delegation, status codes).
- **Loopback (QEMU VM):** probe `--profile <id>` happy (assigned/no-passcode, correct passcode) and
`--passcode 0000` → `ProfileReject{WrongPasscode}`; wrong ×N → `LockedOut`, confirm a **second
`client_fp` is NOT locked**; a `Linux` profile → **MUST reject `SessionUnavailable{NoBroker}`** (the
safety rule). Phase 2 adds the real-uid worker over loopback.
- **Privilege/PAM headless spike — on the QEMU VM, FIRST in Phase 2** (gate-before-build): prove
root→PAM→setuid→gamescope-as-uid headless (`enable-linger`, PAM items, session class,
`pam_close_session` across a worker crash, render/video/input membership, MDWX compatibility).
- **Real OS-user e2e:** QEMU VM (2nd user, isolation + reaping + leak test); GNOME box (2nd user +
operator-multi-occupant coexistence); Steam Deck (gamescope game-mode cold login); AMD Sway (VAAPI
worker); RTX Windows box with a 2nd account `pf-test` (FUS reconnect, console switch, SID occupancy,
restart-persistence, local-user-not-evicted).
- **Security regression list (must stay green):** default-profile passcode no-bypass (D4); occupancy by
uid/SID, Operator exempt (D6); orphan reaping (D11); SEC-3 impossible-state (D5); passcode never
logged + never in Hello (D1, grep-assert); rate-limit keying (D1); Windows restart-persistence (D1/D6);
SEC-2 (broker refuses an absent id / a wire uid); GameStream pinned to operator (D13); tvOS match still
requires passcode (D8).
- **Web/CI:** `pnpm typecheck` + Storybook (Profiles page, Live-sessions card); **OpenAPI drift gate**;
`cargo clippy --workspace --all-targets -- -D warnings` + `cargo fmt --all --check` every phase;
`swift build`/`swift test` on `home-mac-mini-1` (Phases 1, 4).
### 13.5 Packaging / migration
- **Linux broker package** (`deb.yml`/`rpm.yml` + COPR/Arch/sysext/bootc): separate
`punktfunk-session-broker` package (broker binary + `.service`/`.socket` + `/etc/pam.d/punktfunk` +
tmpfiles), `Recommends:`/`Suggests:` of `punktfunk-host` (not a hard dep).
- **Flatpak / sandboxed:** `Operator`-profile-only; ships a capability flag advertising
`profiles=operator-only`; real-user profile → `SessionUnavailable{NoBroker}`, never silent fallthrough.
- **Windows:** `windows-host.yml` Inno installer bundles the (future) SYSTEM broker + cred store +
(deferred) Credential Provider; the front-door service principal (SYSTEM vs non-SYSTEM) is set by the
installer per the Phase-3 gate.
- **Migration:** absent `profiles.json` = today's behavior; `version:0`/missing → `1`; additive fields
`serde(default)`; a breaking change bumps `PROFILES_SCHEMA_VERSION` with a one-shot `load()` migration.
### 13.6 Docs
- This document (`design/multi-user-profiles.md`) is the SoT, including the headless-spike write-up and
the per-platform honest-scope statement.
- **Supersede `design/gamescope-multiuser.md`** (mark it superseded here — the per-uid worker moves the
EIS/PipeWire sockets out of shared `/tmp` into the user's runtime dir; the old variant must not ship).
- `docs-site/content/docs`: move "Magic multi-user support" to **In-progress**, fold in "gamescope
multi-user isolation," update `status.md` per phase. Add a CLAUDE.md *What's left* §3 bullet
summarizing the phased state + the D7/D8/D12 honest-scope caveats.
---
## 14. Open questions
Carried forward; none block the foundation (Phase 0/1), several gate later phases.
1. **tvOS Apple symbols / entitlement (HIGH uncertainty, §11.1):** confirm `TVUserManager()` vs
`.shared`; whether a public `didChangeCurrentUserNotification` exists (if not, the `scenePhase`
re-read is the sole, sufficient detector); whether `com.apple.developer.user-management` /
`get-current-user` is grantable to a streaming app (Apple-gated, positioned at games); and that
`get-current-user` **without** `runs-as-current-user` keeps a single shared data container (else the
single-pairing decision breaks). The whole feature degrades to the manual picker if denied.
2. **Linux AU-relay throughput at 5K@240** across the `session_fd` SEQPACKET hop is asserted-equal to the
Windows `wgc_relay` precedent but **not yet Linux-measured** — Phase 1 benchmarks it (`tools/latency-probe`)
before committing the relay as the only isolated path. Fallback if it regresses: a per-uid QUIC
terminator in the worker (duplicates the endpoint, removes the hop).
3. **Windows Credential Provider scope (W3):** ship a custom `ICredentialProvider` for true cold-console
auto-login, or stay on "operator pre-logs-in the user once" indefinitely? Affects whether Windows ever
delivers the headline cold-login.
4. **Full-desktop Linux session path:** add a path that logs the uid into their **own** KWin/GNOME and
captures *that* in isolation (vs. the v1 gamescope game-mode session)? Needed if "their own desktop"
becomes a hard requirement.
5. **Assigned device picking a different profile:** the D4 table allows an assigned device to select a
*different* profile (subject to that profile's passcode) — confirm this "guest mode on a personal
device" is desired vs. pinning assigned devices to their assignment.
6. **Broker ↔ `profiles.json` reload (TOCTOU):** the broker `stat`+reloads per `OpenSession` (fresh,
simple). Confirm sufficient vs. an `inotify` watch; the only race is an operator editing an assignment
in the millisecond a session opens, which the next connect corrects.
7. **LibraryScope granularity:** id-level only (today) vs. whole-store rules (`Deny{stores:["xbox"]}`) for
a kids profile; and the v1 scope editor (full library-id multi-select vs. a `store:id` textarea).
8. **Avatar/accent storage:** inline `data:` URL vs. a host-served asset endpoint (size cap?).
---
## 15. Appendix — reconciliations applied from adversarial review
Every blocker and load-bearing major from the three adversarial reviewers is resolved here; the body
reflects the decision silently. This table records what changed and the governing reconciled decision.
| Finding (severity) | Decision taken | Ref |
|---|---|---|
| Passcode transport — three contradictory designs (plaintext-in-Hello / HMAC challenge-response / mid-handshake) **(blocker ×3)** | Hello carries `profile_id` **only**; passcode rides a dedicated **never-logged `ProfileUnlock`** verified host-side (Argon2id). HMAC challenge-response dropped (`argon2` stays out of core). **One attempt per connection; cross-connection `PasscodeGate` keyed `(profile_id, client_fp)`** (no in-handshake backoff vs `HANDSHAKE_TIMEOUT`). | D1 |
| C ABI `connect_ex6` signature — `status_out` present/absent **(blocker)** | Adopt the signature **with `status_out`** + status codes `AuthRequired=-11`/`NotFound=-12`/`Occupied=-13`/`SessionUnavailable=-14`/`NeedsIsolation=-15`; `ex5` delegates `NULL,NULL,NULL`. | D2 |
| `Profile`/`OsAccount` schema divergence (enum vs flat `OsUserRef`; field names) **(blocker)** | §4 `Profile` is the single schema of record; `OsAccount` is the enum; `CredentialRef` gains `DpapiBlob`; fields `assigned_fingerprints`/`default_profile_id`; add `require_passcode_even_when_assigned`/`tvos_user_ids`/`allow_shared_view`. | D3 |
| Resolution of selected-passcode-less-unassigned (admit vs deny) **(blocker)** + default-profile passcode bypass **(major)** | The **safer stance**: passcode-less-unassigned is **denied** (`NotPermitted`) unless `allow_shared_view`; the default-profile passcode is **enforced** (no bypass). One D4 authority table. | D4 |
| Isolation fail-closed gate absent from buildable sections **(blocker)** | SEC-3 ships in the **same deliverable as resolution** (Phase 1); non-Operator + no isolating worker → reject **before** pipeline build. Impossible-state by construction. | D5 |
| Windows privilege model = host-as-root **(blocker)** | Target = mirror the Linux split (non-SYSTEM front-door + tiny SYSTEM broker); v1-SYSTEM **documented** as not satisfying the invariant → trusted-LAN-only posture. | D7 |
| Observability — no live session view/kill **(blocker)** + reclaim occupied **(major)** | Add `GET /api/v1/sessions` + `DELETE /api/v1/sessions/{id}`; web **Live sessions** card + **Reclaim**. | D11 |
| Orphaned OS sessions on zone-1 death **(blocker)** | Broker treats the zone-1 socket as the **session lifeline** → reaps on peer death; `PR_SET_PDEATHSIG`, worker EOF self-exit, startup PID sweep. | D11 |
| Error surfacing for OS-login failures **(blocker)** | New `SessionUnavailable{NoBroker\|LoginFailed\|NeedsConsoleSwitch\|Busy}` + ABI `-14`, mapped in every client; stop overloading `NeedsIsolation`. | D12 |
| `PROFILE_ID_MAX` 32 vs 64 **(major)** | **64** everywhere (matches `char id[65]`). | D3 |
| `ProfileId` format (`p_`+6hex vs 12-hex) **(major)** | **12 lowercase hex** + reserved `"operator"`. | D3 |
| Welcome echo: name vs id **(major)** | Echo the **resolved id** (client needs it to reland; HUD looks up the name). | D2 |
| Argon2 params + crate placement **(major)** | **m=19456, t=2, p=1**, host-side only (`punktfunk-host`, not core). | D1/D10 |
| Picker DTO shape divergence + missing `assigned` **(major)** | One display-safe DTO; `ProfileEntry`/`ProfilePublic`/`PunktfunkProfile` carry **`assigned`** (frictionless without a blind try). | D9 |
| Enumerate transport REST vs control-stream **(major)** | Control-stream `ListProfiles`→`ProfileList` **PRIMARY** for native clients; REST `GET /api/v1/profiles/enumerate` (cert, **scoped**) secondary; `GET /api/v1/profiles` bearer-only. | D9 |
| Assign/unassign route shapes **(major)** | `POST /api/v1/profiles/{id}/devices`, `DELETE /api/v1/profiles/devices/{fp}`. | D9 |
| CredentialRef variants + cred route **(major)** | Add `CredentialRef::DpapiBlob`; add `POST/DELETE /api/v1/profiles/{id}/windows-credential` to the inventory + OpenAPI; web badge for `None` Windows profiles. | D3/D12 |
| tvOS attestation client-asserted/unverifiable **(major)** | Identifier = **UI hint only**; `tvos_user_ids` match **still requires the passcode**; `tvos_attested` dropped. | D8 |
| Passcode rate-limit DoS keying + Windows console-switch wipe **(major)** | Key `(profile_id, client_fp)`; **persist counters in the SYSTEM broker / SYSTEM-DACL file** on Windows. | D1 |
| Offline crack of low-entropy passcode + DPAPI from disk image **(major)** | **≥6 alphanumeric** entropy floor for credential-wrapping passcodes; **TPM/host-identity-bound** wrap KDF; documented disk-image recoverability of passcode-less blobs. | D10 |
| Occupancy keyed by profile_id, not uid/SID **(major)** | Key by **resolved uid/SID**; Operator **exempt**; reject same-uid/SID double-map at load. | D6 |
| Operator profile must stay multi-occupant **(major)** | `OsAccount::Operator` is multi-occupant (preserves `--max-concurrent` multi-view); single-occupancy applies only to real-OS-user profiles (+ Windows global). | D6 |
| PAM/logind headless feasibility **(major)** | Pre-provision with `enable-linger`; set PAM items (`PAM_TTY`/`XDG_SESSION_CLASS=user`/`TYPE`); verify session class; **headless spike gates the broker build**. | D12 / §7.8 |
| Passcode change doesn't re-wrap Windows credential **(major)** | `set_passcode` **re-wraps** or surfaces "credential needs re-entry"; `update(os_account)` clears the blob + resolved SID. | D10 |
| Profile deletion side effects / cross-store cleanup **(major)** | `delete()`: terminate-or-refuse sessions, `profile_cred::clear`, drop `PasscodeGate`, release occupancy; mid-session unassign continues, re-resolves next connect. | D11 |
| Cold-login honesty (Windows v1 / Linux game-mode) **(major)** | Stated per-platform: Linux cold auto-login into a **gamescope game-mode** session; Windows v1 = fast-user-switch (CP deferred). | D12 |
| Packaging broker/PAM + graceful degradation **(major)** | deb/rpm/COPR/Arch/bootc ship the broker (optional pkg); Flatpak **Operator-only** + capability flag; broker-absent real-user profile → `SessionUnavailable{NoBroker}`. | D12 |
| Windows console takeover of a local user **(major)** | Detect local vs remote console; **refuse/opt-in** before evicting a physically-present user. | D12 |
| `require_passcode_even_when_assigned` / `tvos_user_ids` / `allow_shared_view` absent from schema **(minor)** | All three folded into `Profile`/`ProfileInput`/DTOs; `resolve_for_device` honors the override. | D3 |
| SEC-3 vs free `SessionDefaults.compositor` **(minor)** | Validate at create/update — non-Operator may not set a shared-desktop compositor. | D5 |
| Zone-1 RCE input-injection residual **(minor)** | Documented residual R1; worker owns + rate-limits the input grant. | §6.8 |
| Broker capability surface contradicts "tiny TCB" **(minor)** | Caps trimmed to `CAP_SETUID/SETGID/SETPCAP/KILL`; `CAP_SYS_ADMIN`/`CAP_DAC_OVERRIDE` dropped; MDWX re-verified. | D7 |
| Enumerate leaks full roster **(minor)** | Scope to the requester's assigned + passcode-protected picker-visible profiles. | D9 |
| LibraryScope marketed as enforcement **(minor)** | Documented as presentation curation, not a sandbox; launch-by-id scoped. | D12 |
| PAM passwordless file is a standing hazard **(minor)** | Documented broker-only + security-sensitive; `pam_succeed_if` to mapped accounts; keep `account pam_unix`. | §6.8 / §7.1 |
| Windows hive/token not unloaded **(minor)** | `UnloadUserProfileW` + `CloseHandle(token)` on teardown. | D11 |
| No UI to provision Windows credential **(minor)** | windows-credential route + "credential not set" badge. | D12 |
| Reconfigure doesn't re-clamp SessionDefaults **(minor)** | Reconfigure re-intersects with the resolved profile's `SessionDefaults`. | D11 |
| Broker vs zone-1 `profiles.json` consistency **(minor)** | Single on-disk source of truth; failed save = failed mutation; broker re-stat/reloads per `OpenSession`. | D11 |
| Stale cached passcode + remember on shared/tvOS **(minor)** | Clear cache on wrong result; **forbid "Remember"** on shared/tvOS builds. | D12 |