Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 KiB
title, description
| title | description |
|---|---|
| Multi-User / Profiles | End-to-end design: map a connecting client to a real host OS user account (own auto-logged-in, isolated desktop), configured in the web console; device-assignment + optional per-profile passcode; tvOS bound to the selected Apple TV user. |
Status: design, schema-of-record. This document is the single source of truth for the punktfunk "Magic multi-user / profiles" feature. It supersedes and absorbs
design/gamescope-multiuser.md: the deferred per-uid gamescope isolation plumbing is now mandatory here, hardened so its EIS/PipeWire sockets live in the target user'sXDG_RUNTIME_DIRrather than the old shared-/tmpsketch. 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
- Overview & goals
- Concepts & terminology
- Architecture at a glance
- Data model & persistence (
profiles.rs) - Wire protocol & C ABI
- Security model & threat analysis
- Linux host
- Windows host
- Web console & management API
- Apple clients (macOS / iOS / iPadOS)
- tvOS profile binding
- Lifecycle & edge cases
- Rollout, phasing, back-compat & testing
- Open questions
- 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
- A profile = a real host OS user account. Not a sandbox, not a virtual persona — a profile
resolves to a real Linux
uid/ WindowsSID, and the session runs as that user (theirHOME, their Steam library, their files). This is what makes it "their desktop," and it is what forces the privilege model in §3/§6/§7. - 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.
- tvOS is bound to the selected Apple TV user. One Apple TV = one app identity = one host
pairing;
TVUserManager.currentUserIdentifierselects 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 + UDPsendmmsg, serves the mgmt API, readsprofiles.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-prefixedserde_jsonrequest over aSO_PEERCRED-restrictedSOCK_SEQPACKETunix socket. It trusts only aprofile_id(re-readsprofiles.jsonitself — SEC-2), does the PAM session +setuid, spawns the per-uid worker, and treats the zone-1 connection as the session lifeline. Caps trimmed toCAP_SETUID/SETGID/SETPCAP/KILLonly (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'sXDG_RUNTIME_DIR(mode 0700), inaccessible to zone 1 by construction. It hands encoded AUs + Opus back to zone 1 over an inherited socketpair and receives opaque input/mic to inject locally.
3.4 Windows: mirror the split (target), with a documented v1 gap
Windows has no second concurrent interactive desktop on a client SKU, and the host already runs as
LocalSystem. The target architecture mirrors the Linux split: a non-SYSTEM streamer front-door
with LogonUser/LoadUserProfile/CreateProcessAsUserW + credential unsealing behind a tiny SYSTEM
broker over ALPC/named-pipe with a verified client SID.
Windows v1 may ship the front-door as SYSTEM — but MUST then document explicitly that it does not satisfy the §3.2 invariant: a host RCE = full box compromise plus recovery of every stored credential. Operators therefore run Windows multi-user trusted-LAN only, GameStream-style. This gap is called out wherever Windows appears (§6.2, §8, §13).
4. Data model & persistence (profiles.rs)
New module: crates/punktfunk-host/src/profiles.rs. New host dependency: argon2 = "0.5"
(RustCrypto; pure-Rust, builds on MSVC) added to punktfunk-host only — never to
punktfunk-core (D1: the passcode is verified host-side; no client target needs argon2).
serde/serde_json/sha2/hex/utoipa are already present.
4.1 Persistence
Profiles live in a new ~/.config/punktfunk/profiles.json (%ProgramData%\punktfunk\profiles.json
on Windows) — not an extension of native_pairing. It is owner-only, atomic-write, built from the
exact helpers native_pairing.rs already uses (gamestream::config_dir / create_private_dir (0700)
/ write_secret_file (0600 unix / SYSTEM-DACL Windows), gamestream/mod.rs:245-313), with the temp +
atomic-rename discipline of native_pairing.rs:116-127.
// profiles.rs
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::collections::HashMap;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::library::{CustomEntry, GameEntry};
/// Bump on any breaking field-semantics change; `serde(default)` covers additive growth.
pub const PROFILES_SCHEMA_VERSION: u32 = 1;
/// Reserved id of the synthesized implicit profile = today's shared operator session.
pub const OPERATOR_PROFILE_ID: &str = "operator";
/// Max bytes of a wire/ABI profile id (matches `char id[65]` = 64 + NUL).
pub const PROFILE_ID_MAX: usize = 64;
#[derive(Default, Serialize, Deserialize)]
pub struct ProfilesFile {
/// `0`/absent on a pre-feature/hand-written file is treated as `1`.
#[serde(default)]
pub version: u32,
#[serde(default)]
pub profiles: Vec<Profile>,
/// Operator-designated default for paired-but-unassigned devices. `None` (or a dangling id)
/// => the implicit operator profile. Never set to `OPERATOR_PROFILE_ID` (that's implicit).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_profile_id: Option<String>,
}
fn default_path() -> Result<PathBuf> {
Ok(crate::gamestream::config_dir().join("profiles.json"))
}
A parse failure is never silently dropped. Unlike the cert store (which unwrap_or_defaults), a
malformed profiles.json keeps the raw bytes in memory, logs loudly, and refuses mutations
until readable — a later save() must never overwrite a file we failed to parse (that would strand
every assigned device). A failed save() is a failed mutation (D11): do not keep the optimistic
in-memory change; return an error to the operator. profiles.json is the single on-disk source of
truth that both zone 1 and the root broker read.
4.2 The Profile entity (canonical serde — D3)
/// Host-assigned, stable id (the `{id}` in CRUD paths). 12 hex chars, minted like
/// `library::new_id` (`library.rs:1239-1246`): `hex(sha256("{display_name}:{nanos}")[..6])`.
pub type ProfileId = String;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct Profile {
pub id: ProfileId,
pub display_name: String,
/// UI accent ("#RRGGBB" or a brand token). Optional.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
/// Avatar URL or `data:` URL (same "field is a URL the client fetches" convention as
/// `library::Artwork`). Optional.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>,
/// The real OS user this profile lands in. Both platform variants compile cross-platform
/// (like the library providers) so a file authored on one OS round-trips on the other.
pub os_account: OsAccount,
/// Device cert fingerprints (lowercase hex SHA-256) assigned to this profile. A fingerprint
/// is in AT MOST ONE profile (uniqueness enforced on assign + at load).
#[serde(default)]
pub assigned_fingerprints: Vec<String>,
/// Optional secondary unlock. Argon2id PHC. Never serialized to any client-facing DTO.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub passcode: Option<PasscodeHash>,
/// Turns the device cert into a TRUE first factor: even an assigned device must supply the
/// passcode. Default false. (Mitigates a stolen device key for high-value profiles — §6.4.)
#[serde(default)]
pub require_passcode_even_when_assigned: bool,
/// Allow a NON-assigned device to enter this passcode-LESS profile (shared-view opt-in).
/// Default false → a passcode-less profile is reachable only by its assigned devices/default.
#[serde(default)]
pub allow_shared_view: bool,
/// Advisory operator metadata: Apple TV `currentUserIdentifier`s normally seen on this profile.
/// NEVER a trust input (D8) — purely a console hint / client auto-select aid.
#[serde(default)]
pub tvos_user_ids: Vec<String>,
/// How the global library is curated for this profile.
#[serde(default)]
pub library_scope: LibraryScope,
/// Profile-private custom titles (same shape as `library::CustomEntry`, `library.rs:1172-1181`).
#[serde(default)]
pub custom_entries: Vec<CustomEntry>,
/// Per-profile session policy (intersected with the client's request at connect AND Reconfigure).
#[serde(default)]
pub session_defaults: SessionDefaults,
pub created_unix: u64,
pub updated_unix: u64,
}
/// The mapping to a real OS user account. Serde-tagged so it's self-describing on disk.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OsAccount {
/// The host operator's already-running shared graphical session — today's behavior. Implicit
/// default. Multi-occupant; served on the shared-desktop backends (kwin/mutter/wlroots).
Operator,
/// A real Linux user. The broker drops to this uid and runs a per-uid gamescope worker. No
/// stored password (PAM is passwordless; the broker is root). `uid` resolved at create time.
Linux {
username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
uid: Option<u32>,
},
/// A real Windows user. Auto-login into the single interactive console needs a primary token;
/// `credential` says how to get one. `sid` resolved at create time (stable across renames).
Windows {
/// "DOMAIN\\user" or ".\\user" (local).
account_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
sid: Option<String>,
credential: CredentialRef,
},
}
/// Where the Windows auto-login secret lives. We NEVER store the plaintext password in
/// profiles.json — only a reference into an OS-protected vault that SYSTEM can read.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum CredentialRef {
/// No stored secret: the user is already logged in / FUS-reachable. The host only switches the
/// console. Graceful default; nothing secret in our store.
None,
/// A Windows Credential Manager generic-credential target name (CredReadW). Store only the NAME.
CredentialManager { target: String },
/// An LSA private-data key ("L$punktfunk_<id>", LsaRetrievePrivateData). Store only the key name.
LsaSecret { key: String },
/// A DPAPI-machine-sealed blob on disk (`cred/<profile_id>.bin`), optionally passcode-wrapped.
/// The default the Windows host writes (§8.6). Store only the relative path.
DpapiBlob { path: String },
}
/// Argon2id password-hash record. Self-describing PHC string so a params/algo bump needs no schema
/// change. e.g. "$argon2id$v=19$m=19456,t=2,p=1$<salt-b64>$<hash-b64>".
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PasscodeHash { pub phc: String }
/// How the global library is filtered for this profile. Presentation curation, NOT a sandbox (D12).
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum LibraryScope {
#[default]
All,
/// Only these store-qualified ids are visible (e.g. "steam:570", "custom:abc123").
Allow { ids: Vec<String> },
/// Full library minus these ids.
Deny { ids: Vec<String> },
}
/// Per-profile session policy. Each field mirrors an existing Hello field / HostConfig knob so the
/// resolver computes `client_request ∩ profile_policy`. `None` = no profile constraint.
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct SessionDefaults {
#[serde(default, skip_serializing_if = "Option::is_none")] pub max_width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")] pub max_height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")] pub max_refresh_hz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")] pub max_bitrate_kbps: Option<u32>,
/// Mirrors `Hello::compositor` / `HostConfig.compositor` (`config.rs:71`). "kwin"|"mutter"|...
/// VALIDATED at create/update: a non-Operator `os_account` may not set a shared-desktop
/// compositor — the worker always forces gamescope (SEC-3, §6.3), so the web UI + resolver agree
/// up front instead of discovering it as a connect-time denial.
#[serde(default, skip_serializing_if = "Option::is_none")] pub compositor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] pub gamepad: Option<String>,
}
Credential asymmetry (contract). Linux drops privilege to the target uid (broker setuid); the
broker is already root, so no password is needed or 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 = 19456KiB,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
ProfileUnlockcosts ~one Argon2 evaluation regardless of outcome (no profile-existence / passcode-set timing oracle). - Online rate limit + lockout — the real defense against weak codes — is the in-memory
PasscodeGate, keyed by(profile_id, client_fp)(§4.4, D1).
fn hash_passcode(plain: &str) -> Result<PasscodeHash> {
use argon2::{Argon2, Algorithm, Version, Params, PasswordHasher};
use argon2::password_hash::{SaltString, rand_core::OsRng};
let salt = SaltString::generate(&mut OsRng);
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::new(19456, 2, 1, None)?);
Ok(PasscodeHash { phc: argon.hash_password(plain.as_bytes(), &salt)?.to_string() })
}
fn verify_passcode(hash: &PasscodeHash, candidate: &str) -> bool {
use argon2::{Argon2, PasswordVerifier};
use argon2::password_hash::PasswordHash;
match PasswordHash::new(&hash.phc) {
Ok(parsed) => Argon2::default().verify_password(candidate.as_bytes(), &parsed).is_ok(),
Err(_) => false, // a corrupt stored hash never authenticates
}
}
Entropy floor (D10). A passcode that wraps a Windows credential (§8.6) must be ≥6 alphanumeric — a 4-digit PIN is offline-crackable against the AES-GCM blob in minutes. 4-digit codes are allowed only for non-credential (Linux/Operator) profiles. The mgmt route enforces this (§9.5).
4.4 The PasscodeGate (rate limit / lockout)
const PASSCODE_FAIL_THRESHOLD: u32 = 5; // failures before hard lockout
const PASSCODE_LOCKOUT_BASE: Duration = Duration::from_secs(2); // backoff 2s · 2^failures
const PASSCODE_LOCKOUT_CAP: Duration = Duration::from_secs(60);
const PASSCODE_HARD_LOCKOUT: Duration = Duration::from_secs(900); // 15 min after threshold
/// Keyed by (profile_id, client_fp) — NOT per-profile (that is a cross-device DoS; any one device
/// could hard-lock a profile for everyone). In-memory; mirrors the `Pending` restart-clearing design
/// (native_pairing.rs:54-83). One attempt per connection (D1) — no in-handshake backoff sleep.
struct PasscodeGate { entries: Mutex<HashMap<(ProfileId, String), GateEntry>> }
struct GateEntry { failures: u32, next_allowed: Instant }
Windows persistence (D1). The Windows host relaunches on every console-session change
(service.rs:375-392), which would otherwise reset the budget. On Windows the PasscodeGate counters
(and the occupancy registry) are persisted in the SYSTEM broker / a SYSTEM-DACL on-disk file so a
console-switch restart does not reset the attempt budget.
4.5 Profile resolution authority (D4 — the one table)
Two indexes from assigned_fingerprints: the persisted forward list, and an in-memory reverse
HashMap<fp_hex_lowercase, ProfileId> rebuilt on load + every mutation (a fingerprint maps to at most
one profile; assign_device removes it from any prior profile first; load de-dups last-writer-wins).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResolveVia { Assigned, Default, Selected }
#[derive(Clone, Debug)]
pub struct ResolvedProfile {
pub id: ProfileId,
pub display_name: String,
pub os_account: OsAccount,
pub session_defaults: SessionDefaults,
pub via: ResolveVia,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProfileError {
NotFound, // unknown profile id → ABI NotFound(-12)
PasscodeRequired, // passcode needed, none/blank supplied → ABI AuthRequired(-11)
PasscodeIncorrect, // passcode wrong → ABI AuthRequired(-11)
NotPermitted, // passcode-less profile, device not assigned, no shared-view → ABI NotFound(-12)
RateLimited { retry_in_secs: u64 }, // PasscodeGate lockout → ABI AuthRequired(-11)
}
resolve_session_profile(client_fp, requested_id: Option<&str>, passcode: Option<&str>) →
Result<ResolvedProfile, ProfileError>. (The signature takes requested_id and passcode
independently — the wire delivers the id in Hello and the passcode in ProfileUnlock, and the
default-with-passcode row below needs a passcode even when requested_id == None.)
| # | Case | Result |
|---|---|---|
| 1 | requested=None, device assigned to profile P |
Grant P (frictionless) — unless P.require_passcode_even_when_assigned → require passcode |
| 2 | requested=None, unassigned, default has no passcode |
Grant default (= operator, today's behavior) |
| 3 | requested=None, unassigned, default has a passcode |
Require passcode (NO bypass) |
| 4 | requested=P, device assigned to P |
Grant (or require passcode iff require_passcode_even_when_assigned) |
| 5 | requested=P, P has passcode, correct |
Grant |
| 6 | requested=P, P has passcode, wrong/absent |
PasscodeRequired/PasscodeIncorrect |
| 7 | requested=P, P has no passcode, device not assigned |
Deny NotPermitted — unless P.allow_shared_view = true |
| 8 | requested=P unknown |
NotFound |
The default-profile passcode is enforced (row 3 closes the bypass). The plaintext passcode is
wrapped in a Zeroizing<String> at the call boundary and zeroized after verify_passcode returns.
The synthesized implicit operator profile (id: "operator", display_name: "Host",
os_account: Operator, library_scope: All, no passcode) is never stored; to give the operator
account a passcode/scope, create a real os_account: Operator profile and set it as
default_profile_id.
Order of evaluation.
resolve_session_profilereturns 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 layerNeedsIsolation,Occupied, andSessionUnavailable{…}on top. This keeps the data model pure and the security gate fail-closed (D5).
4.6 Library scoping
library::all_games() (library.rs:1472-1491) returns the merged global Vec<GameEntry>. Profiles
filter that result; they do not fork library.rs.
impl Profiles {
/// Apply allow/deny to `global`, then append the profile's private custom entries
/// (via `From<CustomEntry> for GameEntry`, library.rs:1193-1203). Sorted by title like all_games().
pub fn scoped_library(&self, fp_hex: &str, global: Vec<GameEntry>) -> Vec<GameEntry>;
}
Wiring: GET /api/v1/library returns scoped_library(fp, all_games()) for a streaming-cert
caller (the PeerCertFingerprint arm of require_auth, mgmt.rs:483-489) and unscoped all_games()
for the bearer operator. Launch resolution uses the same scope (library::launch_command /
launch_title resolve the Hello.launch id against the resolved profile's scoped set), so a
crafted Hello.launch cannot bypass a Deny. LibraryScope is presentation curation, not a sandbox
(D12): the installed titles come from the target OS user's own Steam/HOME (the session runs as that
user); real restriction needs OS-level controls in the account.
4.7 Module public API
pub struct Profiles { /* Mutex<ProfilesState{path, file, reverse_index, raw_unparsed}>, PasscodeGate */ }
impl Profiles {
pub fn load() -> Result<Profiles>;
pub fn load_with(path: Option<PathBuf>) -> Result<Profiles>; // tests / Windows override
// --- admin (bearer; web console) ---
pub fn list(&self) -> Vec<Profile>;
pub fn get(&self, id: &str) -> Option<Profile>;
pub fn create(&self, input: ProfileInput) -> Result<Profile>;
pub fn update(&self, id: &str, input: ProfileInput) -> Result<Option<Profile>>; // preserves id/passcode/created;
// changing os_account clears credential + resolved uid/SID (D10)
pub fn delete(&self, id: &str) -> Result<bool>; // side effects per §12.2
pub fn assign_device(&self, id: &str, fp_hex: &str) -> Result<bool>; // unique: removes fp from any other profile
pub fn unassign_device(&self, fp_hex: &str) -> Result<bool>;
pub fn set_passcode(&self, id: &str, passcode: Option<&str>) -> Result<bool>; // None clears; argon2id;
// re-wraps a passcode-wrapped Windows credential (D10)
pub fn set_default(&self, id: Option<&str>) -> Result<()>;
pub fn default_profile_id(&self) -> Option<String>;
// --- runtime (session gate + cert-authed clients) ---
pub fn resolve_for_device(&self, fp_hex: &str) -> ProfileResolution; // {Assigned|Default}
pub fn resolve_session_profile(&self, fp_hex: &str, requested_id: Option<&str>, passcode: Option<&str>)
-> Result<ResolvedProfile, ProfileError>;
pub fn enumerate_public(&self, fp_hex: &str) -> Vec<ProfilePublic>; // SCOPED roster (D9)
pub fn scoped_library(&self, fp_hex: &str, global: Vec<GameEntry>) -> Vec<GameEntry>;
}
/// Create/replace body. No `id` (host-owned), no passcode (set via `set_passcode`).
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct ProfileInput {
pub display_name: String,
#[serde(default)] pub accent: Option<String>,
#[serde(default)] pub avatar: Option<String>,
pub os_account: OsAccount,
#[serde(default)] pub assigned_fingerprints: Vec<String>,
#[serde(default)] pub require_passcode_even_when_assigned: bool,
#[serde(default)] pub allow_shared_view: bool,
#[serde(default)] pub tvos_user_ids: Vec<String>,
#[serde(default)] pub library_scope: LibraryScope,
#[serde(default)] pub custom_entries: Vec<CustomEntry>,
#[serde(default)] pub session_defaults: SessionDefaults,
}
Profiles is shared exactly like NativePairing: an Arc<Profiles> added to MgmtState
(mgmt.rs:69,83,117) and threaded into the punktfunk1 accept loop (punktfunk1.rs:493) — the
same Arc, one source of truth. It is Send + Sync (only Mutex-guarded state), so the
tokio::spawn-per-session model is unaffected.
4.8 Validation at create / update / load (D5/D6/D10)
- SEC-3 compositor (D5): a non-
Operatoros_accountmay not set a shared-desktop compositor (kwin/mutter/wlroots) insession_defaults.compositor— reject at create/update. - Occupancy uniqueness (D6): reject a
profiles.json(and a create/update) that maps two profiles to the same uid/SID at load/validate time. - Entropy floor (D10): a passcode wrapping a Windows credential must be ≥6 alphanumeric.
- Fingerprint uniqueness:
load()de-dups the reverse index (last-writer-wins) so a hand-edited file can't make resolution ambiguous.
4.9 Relationship to the existing PairedClient store
Kept separate, joined by fingerprint. A device must be paired (punktfunk1-paired.json,
native_pairing.rs:22-27) and resolvable to a profile. Pairing answers "may this device connect";
the profile answers "as which OS user, with which library/limits." Order at the session gate
(punktfunk1.rs:535-564): (1) np.is_paired(fp) (unchanged; unpaired → pending/bail); (2) NEW:
resolve_session_profile. Cross-store cleanup: unpair_native_client (DELETE /native/clients/{fp})
and np.remove(fp) also call profiles.unassign_device(fp). A profile may pre-reference an unpaired
fingerprint (operator pre-assigns) — allowed; the pairing gate still governs connect.
5. Wire protocol & C ABI
No ABI bump. All additions use the existing trailing-byte / length-prefixed back-compat convention
and the connect_exN chaining pattern. ABI_VERSION stays 2 (lib.rs:52); the host gate
hello.abi_version == ABI_VERSION (punktfunk1.rs:530) is unaffected. GameStream excluded —
profiles are native punktfunk/1 only (§6.7).
5.1 Hello — append profile_id after video_caps
The Hello carries the profile id ONLY (D1). The passcode is never in Hello — Hello feeds
logging / pending-knock / approval records (punktfunk1.rs:548-557).
// crates/punktfunk-core/src/quic.rs — Hello (struct :42-81, encode :619-656, decode :658-718)
/// Opaque host ProfileId the client wants this session in (NOT an OS username). Appended after
/// `video_caps` as `len u8 || UTF-8` (≤ PROFILE_ID_MAX). Presence forces the name/launch length
/// bytes + the `video_caps` byte to be emitted as placeholders first so it lands at a deterministic
/// offset (same rule launch/video_caps already obey). None = assigned/default (back-compat).
pub profile_id: Option<String>,
encode tail (replacing the need_placeholders/video_caps tail at quic.rs:636-654):
let need_caps = self.video_caps != 0 || self.profile_id.is_some();
match (&self.name, &self.launch) {
(None, None) if !need_caps => {}
(name, _) => { let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX);
b.push(n.len() as u8); b.extend_from_slice(n.as_bytes()); }
}
if self.launch.is_some() || need_caps {
let l = truncate_to(self.launch.as_deref().unwrap_or(""), HELLO_LAUNCH_MAX);
b.push(l.len() as u8); b.extend_from_slice(l.as_bytes());
}
if need_caps { b.push(self.video_caps); }
if let Some(p) = &self.profile_id {
let p = truncate_to(p, PROFILE_ID_MAX); b.push(p.len() as u8); b.extend_from_slice(p.as_bytes());
}
decode field (after the video_caps block at quic.rs:711-716):
profile_id: {
let name_len = b.get(26).copied().unwrap_or(0) as usize;
let launch_off = 27 + name_len;
let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize;
let prof_off = launch_off + 1 + launch_len + 1; // caps byte + 1
b.get(prof_off).and_then(|&len| {
let len = len as usize;
if len == 0 || len > PROFILE_ID_MAX { return None; }
b.get(prof_off+1..prof_off+1+len).and_then(|s| std::str::from_utf8(s).ok()).map(String::from)
})
},
A malformed tail never fails the handshake (the decoder returns None). PROFILE_ID_MAX = 64.
5.2 Control messages (CTL_MAGIC + type-byte framing, like Reconfigure quic.rs:215)
Type bytes (free range): MSG_LIST_PROFILES = 0x40, MSG_PROFILE_LIST = 0x41,
MSG_PROFILE_UNLOCK = 0x42, MSG_PROFILE_REJECT = 0x43. pub const MAX_PROFILES: usize = 64;
Each gets encode()/decode() mirroring the existing get_bytes/put_bytes helpers
(quic.rs:403-419); decoders validate CTL_MAGIC + type byte and exact/<= length.
/// client→host; an alternate first message (pre-connect enumerate). Wire: CTL_MAGIC‖0x40
pub struct ListProfiles;
/// One display-safe profile a paired device may pick. accent/avatar optional (UI only).
pub struct ProfileEntry {
pub id: String, pub display_name: String,
pub accent: Option<String>, pub avatar: Option<String>,
pub passcode_required: bool,
/// Computed for the REQUESTING cert: true → frictionless, render without a failed attempt (D9).
pub assigned: bool,
}
/// host→client. Wire 0x41: CTL_MAGIC‖0x41‖u8 count‖[ u8 id_len‖id ‖ u8 name_len‖name
/// ‖ u8 accent_len‖accent ‖ u8 avatar_len‖avatar ‖ u8 flags(bit0=passcode_required,bit1=assigned) ]*
pub struct ProfileList { pub profiles: Vec<ProfileEntry> }
/// client→host. THE never-logged message. Sent immediately after Hello, before the host builds the
/// pipeline, iff the connector has a passcode. The handler MUST NOT place `passcode` in any tracing
/// field. Wire 0x42: CTL_MAGIC‖0x42‖u8 len‖passcode UTF-8 (≤ HELLO_PASSCODE_MAX = 64).
pub struct ProfileUnlock { pub passcode: String }
/// host→client. Terminal typed denial; the host then closes (one passcode try per connection, D1).
pub struct ProfileReject { pub reason: ProfileRejectReason }
pub enum ProfileRejectReason {
PasscodeRequired, WrongPasscode, LockedOut, // → ABI AuthRequired(-11)
NotFound, NotPermitted, NeedsSelection, // → ABI NotFound(-12)
Occupied, // → ABI Occupied(-13)
SessionUnavailable(SessionUnavailableKind), // → ABI SessionUnavailable(-14)
NeedsIsolation, // → ABI NeedsIsolation(-15)
}
pub enum SessionUnavailableKind { NoBroker, LoginFailed, NeedsConsoleSwitch, Busy }
Reconciled out: the original wire draft's Argon2id-verifier HMAC challenge-response (
ProfileChallenge/ProfileProof) is dropped (D1). It would forceargon2intopunktfunk-corefor every client target (Swift/Kotlin/Rust) for a marginal gain over an already-authenticated channel. Only the host depends onargon2. There is also notvos_attesteddiscriminator on any message (D8 — the tvOS identifier is never a wire trust signal).
5.3 Host serve_session ordering (punktfunk1.rs)
First-message dispatch (punktfunk1.rs:507): after the PairRequest arm and before
Hello::decode, branch on ListProfiles:
if ListProfiles::decode(&first).is_ok() {
// SCOPED roster (D9): only profiles assigned to THIS cert + passcode-protected picker-visible
// ones. Unpaired (when require_pairing) → empty (no roster leak).
let profiles = if peer_is_paired { profiles.enumerate_entries(&fingerprint_hex) } else { vec![] };
io::write_msg(&mut send, &ProfileList { profiles }.encode()).await?;
first = read_msg(&mut recv).await?; // now expect a Hello on the same bi-stream
}
Inside the handshake, after the require_pairing gate (punktfunk1.rs:564) and before
validate_dimensions:
// (1) If the connector sent a passcode, it is the NEXT message after Hello (never in Hello).
let passcode: Option<Zeroizing<String>> = match peek_msg(&recv) {
Some(m) if ProfileUnlock::is(&m) => Some(Zeroizing::new(ProfileUnlock::decode(&consume(&mut recv).await?)?.passcode)),
_ => None,
};
// (2) Resolve per the D4 table. PasscodeGate is keyed (profile_id, client_fp); ONE try this connection.
let resolved: ResolvedProfile = match profiles.resolve_session_profile(
&fingerprint_hex, hello.profile_id.as_deref(), passcode.as_deref().map(|z| z.as_str())) {
Ok(r) => r,
Err(e) => { write_profile_reject(&mut send, reason_for(e)).await?; return Ok(()); } // typed close; device stays paired
};
// (3) SEC-3 fail-closed gate + occupancy + (Linux) broker open / (Windows) console target — §7.4 / §8.4.
// Any deny → ProfileReject{NeedsIsolation|Occupied|SessionUnavailable{..}} and bail, BEFORE the pipeline.
// (4) Build the pipeline; set Welcome { …, resolved_profile: Some(resolved.id) }; proceed; audit (§6.… / §7.…).
There is no in-handshake backoff sleep — a 60 s backoff or 15 min lockout cannot run inside the
10 s HANDSHAKE_TIMEOUT (punktfunk1.rs:327/702). Rate limiting is cross-connection in the
PasscodeGate; a wrong passcode rejects fast and the client reconnects for the next try.
5.4 Welcome — resolved-profile echo
Welcome (quic.rs:164) drops Copy (derive Clone, Debug, PartialEq, Eq — small, off the
per-frame path). Append after color (quic.rs:721-751):
/// The profile id the host PLACED this session in (echoes the RESOLVED id — not the display name;
/// the client needs the id to reland + seed the next connect). None = single-user / old host.
/// Appended after `color` as `len u8 || UTF-8` at offset 64.
pub resolved_profile: Option<String>,
5.5 C ABI (abi.rs) — connect_ex6 (D2 canonical)
Append-only status variants (error.rs:34, with matching PunktfunkError::* + status() arms at
error.rs:51): AuthRequired = -11 (passcode required or wrong), NotFound = -12 (unknown
profile id), Occupied = -13, SessionUnavailable = -14, NeedsIsolation = -15.
PunktfunkConnection *punktfunk_connect_ex6(
const char *host, uint16_t port,
uint32_t width, uint32_t height, uint32_t refresh_hz,
uint32_t compositor, uint32_t gamepad, uint32_t bitrate_kbps, uint8_t video_caps,
const char *launch_id,
const char *profile_id, /* NULL => default profile (today's behavior) */
const char *passcode, /* NULL => none; connector sends ProfileUnlock iff non-NULL */
const uint8_t *pin_sha256,
uint8_t *observed_sha256_out,
const char *client_cert_pem, const char *client_key_pem,
PunktfunkStatus *status_out, /* nullable; receives the typed failure */
uint32_t timeout_ms);
connect_ex5delegates withprofile_id = NULL, passcode = NULL, status_out = NULL(its body moves intoex6).ex6parsesprofile_id/passcodeviaopt_cstr, threads them intoNativeClient::connect, and onErr(e)writes*status_out = e.status()before returning null.- Resolved-profile accessor:
const char *punktfunk_connection_profile(const PunktfunkConnection *conn)→ the resolved profile id the host echoed (""/NUL-terminated if None). - Enumerate (standalone, like
punktfunk_pair):
typedef struct PunktfunkProfile {
char id[65]; char display_name[65];
uint8_t passcode_required; uint8_t assigned; /* assigned flag per D9 */
} PunktfunkProfile;
PunktfunkStatus punktfunk_list_profiles(
const char *host, uint16_t port,
const uint8_t *pin_sha256, uint8_t *observed_sha256_out,
const char *client_cert_pem, const char *client_key_pem,
PunktfunkProfile *out, size_t cap, size_t *count_out, uint32_t timeout_ms);
// fills min(cap, available); *count_out = total available. The host NUL-terminates each char[65].
(The wire ProfileEntry carries accent/avatar; the fixed C struct drops them — native pickers use
a monogram fallback, or fetch richer detail via the REST enumerate if needed.) Regenerate
include/punktfunk_core.h (cbindgen; CI checks drift).
5.6 NativeClient (client.rs)
connect (client.rs:238) gains two params after launch:
pub fn connect(host:&str, port:u16, mode:Mode, compositor:CompositorPref, gamepad:GamepadPref,
bitrate_kbps:u32, video_caps:u8, launch:Option<String>,
profile:Option<String>, passcode:Option<String>, // NEW
pin:Option<[u8;32]>, identity:Option<(String,String)>, timeout:Duration) -> Result<NativeClient>
worker_main sets Hello.profile_id = profile; iff passcode is non-NULL, writes one
ProfileUnlock { passcode } immediately after the Hello, then reads the next message: a Welcome
(success) or a ProfileReject → typed error (reason → PunktfunkError). Negotiated
(client.rs:45) gains a 9th element resolved_profile: Option<String> from welcome.resolved_profile;
add pub fn resolved_profile(&self) -> Option<&str>. New
NativeClient::list_profiles(host, port, identity, pin, timeout) -> Result<(Vec<ProfileEntry>, [u8;32])>
mirrors pair(): open a bi-stream, write ListProfiles, read ProfileList, return entries + observed
fingerprint.
Caller updates: every NativeClient::connect site adds None, None until its UI lands
(clients/linux/src/{session,app}.rs, clients/windows/src/session.rs,
clients/android/native/src/session.rs, host self-tests). Every Hello { … } literal adds
profile_id: None (the quic.rs test constructors; clients/probe/src/main.rs gets --profile /
--passcode flags). Every Welcome { … } literal adds resolved_profile: None.
5.7 mDNS + REST mirror
- mDNS TXT gains
prof=1(discovery.rs:56) so clients show the profile UI only against a supporting host (and skip a wastedListProfilesto 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-streamListProfilesis PRIMARY for native clients (no mgmt-port knowledge needed mid-connect).GET /api/v1/profilesstays 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-
/tmpEIS socket of the oldgamescope-multiuser.md— the sockets now live in the user'sXDG_RUNTIME_DIR. - SEC-2 — broker trusts only the profile id. The broker resolves
profile_id → {uid, username}from its own root-owned read ofprofiles.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 — notroot, 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 != Operatorand the isolating worker/broker is not present/available, the host rejects withSessionUnavailable{NoBroker}/NeedsIsolationbefore 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
PasscodeGatekeyed(profile_id, client_fp)so one device cannot DoS-lock a profile for others; Windows persists the counters across the console-switch relaunch. - Stolen paired key (adversary B): an attacker with a device's key+cert lands in the profiles that
device is assigned to (frictionless skips the passcode). For high-value profiles the operator
sets
require_passcode_even_when_assigned = true, turning the cert into a first factor and the passcode into a true second factor (a stolen key alone is then insufficient). Recovery from a stolen key is unpairing the fingerprint (DELETE /api/v1/native/clients/{fp}).
6.5 Occupancy (D6)
Single-occupancy is keyed by the resolved Linux uid / Windows SID, not the profile id — so two
profiles mapping to the same OS user can't both occupy it (Steam single-instance / /run/user/<uid> /
socket-name collisions). Operator profiles are exempt (multi-occupant, preserves
--max-concurrent multi-view). A profiles.json mapping two profiles to the same uid/SID is rejected
at load/validate. A reconnect by the same device to the same profile preempts (terminate the prior
session, like the IDD-push reconnect-preempt punktfunk1.rs:2449-2456); a different profile while
one is active → Occupied. On Windows occupancy is global (single console), persisted in the
SYSTEM broker so a console-switch relaunch can't double-grant.
6.6 tvOS identifier is a UI hint, not a trust factor (D8)
TVUserManager.currentUserIdentifier is client-asserted and Apple provides no signed token → the
host cannot verify it, so it never rides the wire. It is used only for client-side
auto-selection of the mapped profile. A tvos_user_ids match still requires the profile's passcode
for any passcode-protected profile (no skip). There is no tvos_attested wire signal. An
un-entitled build degrades to the manual picker + passcode — the only sound mode anyway (§11).
6.7 GameStream / Moonlight exclusion (D13)
Profiles are punktfunk/1-only, by protocol fact: GameStream pairing stores only the client cert
DER (no name/profile, gamestream/pairing.rs), the catalog is the single global apps.json, and
nvhttp.rs holds one Mutex<Option<LaunchSession>> (one global session). There is no Hello-equivalent
to carry a profile id/passcode and no per-user identity to map. A host with profiles.json and
serve --gamestream MUST pin every GameStream session to the operator profile and emit an audit
line recording the pinning. Stated in the docs and the profiles OpenAPI tag description.
6.8 Threat model with explicitly-stated residuals
| # | Residual | Mitigation / posture |
|---|---|---|
| R1 | Host-RCE input-injection into live sessions (SEC-1 residual). SEC-1 protects byte confidentiality and session creation, not input integrity: zone 1 forwards opaque input, so an RCE'd zone 1 can synthesize keyboard/mouse into a currently-active profile desktop (code-exec as that user) — it just can't read the pixels. | The worker (not zone 1) owns + rate-limits the EIS/uinput grant; per-session authorization bounds blast radius. Documented: a host RCE yields input-level code-exec into live sessions. |
| R2 | Windows v1 SYSTEM front-door (D7 gap). A codec/QUIC RCE = full box compromise + recovery of every stored credential — the very "host-as-root" posture rejected for Linux. | Documented; mirror-split is the target architecture; v1 = trusted-LAN-only, GameStream-style posture (operator's explicit go/no-go, §13 Phase 3). |
| R3 | tvOS client-asserted identifier (D8). A modified/stolen shared-TV key can claim any currentUserIdentifier. |
The identifier is a UI hint only; the only real factors on a shared TV are the device cert (device-level) + the passcode (human-level). Passcode is required regardless of any tvOS "match." |
| R4 | LibraryScope is curation, not a sandbox (D12). Visibility filtering only; once in their own session the user can launch anything via the store UI; installed titles are the target user's own. |
Launch-by-id resolution is scoped (a crafted Hello.launch can't bypass a Deny). Real restriction needs OS-level parental controls. Never marketed as enforcement. |
| R5 | Passcode-less Windows credential recoverable from a stolen disk image (D10). CRYPTPROTECT_LOCALMACHINE blobs decrypt from the SYSTEM/SECURITY hives on the same disk, not only by a live SYSTEM process. A 4-digit wrap is also offline-crackable against the GCM blob in minutes. |
Entropy floor ≥6 alphanumeric for credential-wrapping passcodes; bind the wrap KDF to a TPM-sealed / host-identity-derived secret; reserve passcode-less credentials for dedicated low-privilege local accounts (never a domain/admin account). |
| R6 | Zone-1 RCE can open sessions for any mapped user (not root/unmapped). | SEC-2 + SO_PEERCRED + broker syscall filter + per-open audit (zone-0 stream a zone-1 RCE can't erase). Map profiles to dedicated low-priv accounts. |
| R7 | Passwordless /etc/pam.d/punktfunk is a standing box-wide service. Any root-context caller of pam_start("punktfunk", user) gets a passwordless session. |
Documented broker-only + security-sensitive; harden with pam_succeed_if to the mapped accounts; keep account required pam_unix.so so disabled/expired accounts are refused. |
Audit (punktfunk::audit, structured, never secrets): profile resolution (every connect:
client_fp, profile_id, outcome); passcode attempts (result, failures_so_far — never the
passcode); OS-session create/teardown (the broker, zone 0 — the one record a zone-1 RCE can't
tamper: profile_id, uid, xdg_session_id, worker_pid, open/close+reason); profile administration
(mgmt mutations, operator principal). Windows writes the corresponding Event Log entries.
7. Linux host
The Linux realization of the privilege split (D7), the fail-closed isolation gate (D5), uid-keyed
occupancy (D6), lifecycle/reaping (D11), and the honest cold-login + packaging story (D12). On Linux
the whole feature is the difference between OsAccount::Operator (today's shared desktop,
unchanged) and OsAccount::Linux{username, uid} (a real OS user, auto-logged-in,
input/audio-isolated).
7.0 The three processes and the one new boundary
zone 0 (root) zone 1 (network, non-root) zone 2 (target uid)
punktfunk-session-broker punktfunk-host (serve) punktfunk-host session-worker
tiny TCB, no codec/QUIC QUIC/FEC/AES-GCM, mgmt API, gamescope(uid) + capture
PAM open · setuid · spawn profiles.json (READ), + NVENC/VAAPI encode
▲ │ fork+exec passcode VERIFY, occupancy ▲ + per-session injector(EIS)
│ uds │ (SCM_RIGHTS: │ session_fd │ + null-sink audio + mic
OpenSession session_fd) ▼ framed AU/audio/input runs INSIDE the uid's
/run/punktfunk/broker.sock ◄──────────┘ ◄───────────────────────► logind session (linger)
(0600, SO_PEERCRED==host uid) (lifeline = broker conn) owns U's XDG_RUNTIME_DIR
Split point. Zone 1 stays the sole QUIC + data-plane terminator (Session = Leopard FEC +
AES-GCM + UDP sendmmsg). The zone-2 worker does capture + encode + inject + audio as the uid
and exchanges opaque encoded AUs / Opus / input with zone 1 over one inherited SOCK_SEQPACKET
socketpair (session_fd) — the exact shape of the shipping Windows two-process secure-desktop relay
(virtual_stream_relay punktfunk1.rs:2408; capture::wgc_relay), reusing that AU-framing
discipline so SEC-1 holds without a per-uid QUIC endpoint.
New code surface:
| Artifact | Kind | TCB notes |
|---|---|---|
crates/punktfunk-broker/ |
new crate (proto.rs + bin/punktfunk-session-broker.rs) |
deps only: serde/serde_json, nix/libc, pam-sys, sd-notify. No quinn/ffmpeg/cuda/punktfunk-host. ~few-hundred LoC. |
punktfunk-host session-worker |
new subcommand (sibling to service/driver/web) |
is zone 2; reuses vdisplay/capture/encode/inject/audio verbatim. |
crate::broker_client |
new host module | zone-1 side: connect the broker, OpenSession/CloseSession, receive session_fd via SCM_RIGHTS. |
crate::session_relay |
new host module | zone-1 ↔ worker session_fd framing + virtual_stream_brokered. |
scripts/punktfunk-session-broker.{socket,service} · scripts/pam/punktfunk · scripts/tmpfiles/punktfunk.conf |
systemd/PAM/tmpfiles | shipped by deb/rpm/copr/arch/bootc. |
7.1 Zone 0 — punktfunk-session-broker
Socket + peer auth. Created by systemd socket activation (ListenStream=/run/punktfunk/broker.sock,
SocketUser=punktfunk, SocketMode=0600) so ownership/mode are declarative. The broker re-checks
SO_PEERCRED on every accept() and rejects any peer whose ucred.uid != configured host uid
(adversary C must not call it even if the DACL were widened).
Wire protocol (passcode never crosses here):
// crates/punktfunk-broker/src/proto.rs (shared by broker + crate::broker_client)
pub const PROTO_VERSION: u32 = 1;
#[derive(Serialize, Deserialize)]
pub enum BrokerRequest {
/// Host passes ONLY a profile_id (SEC-2: broker re-reads profiles.json itself). client_fp is for
/// the broker's independent audit. The authoritative (re-clamped) mode rides SessionInit over
/// session_fd, so the LAUNCH command never enters the root TCB.
OpenSession { proto: u32, profile_id: String, client_fp: String, mode: Mode },
CloseSession { session_id: u64 }, // idempotent; lifeline EOF is the crash-path reaper
}
#[derive(Serialize, Deserialize)]
pub enum BrokerReply {
Opened { session_id: u64, uid: u32, worker_pid: u32 }, // session_fd via SCM_RIGHTS, out-of-band
Error { kind: BrokerError, msg: String },
}
#[derive(Serialize, Deserialize, Clone, Copy)]
pub enum BrokerError {
NoSuchProfile, // absent from broker's own profiles.json read (SEC-2)
NotIsolatable, // resolves to Operator — zone-1 bug, must not brokerage it
MissingGroups, // target uid lacks render/video/input (D12) — msg names which
LingerFailed, PamFailure, SpawnFailure, Busy,
}
Mode is a leaf re-export of punktfunk_core::Mode so the crate doesn't pull all of core. Framing:
4-byte LE length + serde_json (auditable, no custom parser to fuzz); SOCK_SEQPACKET for
kernel-enforced message boundaries.
OpenSession handler order — the non-obvious part is making a cold OS user (never
interactively logged in, headless box, no seat) get a real XDG_RUNTIME_DIR, user D-Bus, and tracked
logind session:
SO_PEERCREDgate —ucred.uid == host_uidorError.- Resolve
profile_id → (username, uid)from the broker's OWN read ofprofiles.json(root-owned;stat+reload per request so mgmt edits are picked up; a parse failure denies, never serves stale). RejectOsAccount::OperatorwithNotIsolatable; absent id withNoSuchProfile. - Group preflight (D12) —
getpwnam_r+getgrouplist; requirerender,video,input(GPUrenderD128;uinput/uhid; PipeWire). On a miss →MissingGroups{msg:"user 'kids' not in: render, input"}→ surfaced to the client asSessionUnavailable{LoginFailed}with that reason, not a black screen. loginctl enable-linger(org.freedesktop.login1.Manager.SetUserLinger(uid,true,false), idempotent) — provisions/run/user/<uid>(0700) and startsuser@<uid>.service(PipeWire/ WirePlumber) with no seat/graphical login. On failure →LingerFailed.- PAM session with the items
pam_systemdneeds to classify auser(notbackground) session:pam_start("punktfunk", username, &conv_null, &pamh) pam_set_item(PAM_TTY, "pts/punktfunk") pam_set_item(PAM_RUSER, "root") pam_putenv("XDG_SESSION_CLASS=user") pam_putenv("XDG_SESSION_TYPE=wayland") pam_putenv("XDG_SESSION_DESKTOP=gamescope") pam_authenticate(0) // pam_permit — passwordless (broker is root) pam_acct_mgmt(0) pam_setcred(PAM_ESTABLISH_CRED) pam_open_session(0) - Verify the session class (
sd_uid_get_state/ inspect the newXDG_SESSION_ID) isuser; abackgroundclass → close +PamFailure(belt-and-suspenders with linger, which already guarantees the runtime dir). fork(); child:pam_getenvlist→ set the session env →setgid(pw_gid)→initgroups(username, pw_gid)(picks up render/video/input) →setuid(uid)→ re-assertgetuid()==uid && setuid(0)==EPERM(drop is irreversible) →prctl(PR_SET_PDEATHSIG, SIGKILL)→ drop the capability bounding set (worker holds zero caps) → dup the workersession_fdend to fd 3 →execve punktfunk-host session-worker --session-fd 3 --mode WxHxHz --uid N.- Parent records
session_id → SessionRec{pamh, worker_pid, uid, peer_conn}, writes the zone-0 audit line, repliesOpenedwith the zone-1 end of the socketpair viaSCM_RIGHTS. - Teardown (on
CloseSession, lifeline EOF, or workerSIGCHLD):pam_close_session+pam_setcred(PAM_DELETE_CRED)+pam_end;kill(worker_pid)if alive;waitpid; auditclose. Linger is left enabled (a provisioning property of the user) unless the operator unprovisions.
Hardening unit (scripts/punktfunk-session-broker.service): Type=notify, User=root,
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_KILL, AmbientCapabilities=,
NoNewPrivileges=no (the broker's whole job is to change uid), RestrictAddressFamilies=AF_UNIX,
RestrictNamespaces=true, SystemCallFilter=@system-service @setuid, ProtectKernelModules=yes,
MemoryDenyWriteExecute=yes (re-verified in the Phase-0 spike). PAM service scripts/pam/punktfunk:
auth required pam_permit.so # passwordless — broker is already root
account required pam_unix.so # still honors account lock/expiry
session required pam_systemd.so # registers the logind session + XDG_RUNTIME_DIR
session required pam_unix.so
7.2 Zone 2 — punktfunk-host session-worker (per-uid)
The existing per-session machinery, but its own process, running as the uid, with per-session
injector/audio/mic instead of the host-lifetime singletons. Re-entered as punktfunk-host session-worker (no second binary). Entry sketch:
// crates/punktfunk-host/src/session_worker.rs
pub fn run(opts: WorkerOpts) -> Result<()> {
let mut chan = session_relay::WorkerChannel::from_raw_fd(opts.session_fd); // fd 3 → zone 1
let init = chan.recv_init()?; // SessionInit{ mode, launch_cmd, bitrate_kbps, bit_depth }
// (1) Force the isolating backend — a FRESH nested gamescope as this uid (own EIS/GPU/PipeWire/
// null-sink). SEC-3 by construction; the client's + operator's compositor prefs are irrelevant.
let mut vd = vdisplay::open(Compositor::Gamescope)?;
vd.set_launch_command(init.launch_cmd); // per-instance, never the process-global env (vdisplay.rs:56)
// (2) Same retry helper zone-1 uses — capture+encode run HERE.
let plan = session_plan::SessionPlan::resolve(init.bit_depth);
let (mut capturer, mut enc, ..) =
build_pipeline_with_retry(&mut vd, init.mode, init.bitrate_kbps, init.bit_depth, plan)?;
// (3) Per-session, uid-owned side services (NOT the host-lifetime singletons):
let injector = SessionInjector::start(vd.current_ei_socket()); // EIS bound to THIS socket
let audio = SessionAudio::start(vd.audio_sink_node())?; // null-sink monitor
let mic = SessionMic::start(); // "punktfunk-mic-<pid>"
// (4) encode → frame the AU → write to fd 3. Input/mic come back the other way.
run_worker_loop(&mut capturer, &mut enc, &mut chan, &injector, &audio, &mic)
}
build_pipeline_with_retry/build_pipeline (punktfunk1.rs:3430/3499) are reused as-is. The only
refactor is the AU sink seam — today virtual_stream pushes each encoded AU into the in-process
Session; extract a trait:
pub trait AuSink { fn push_au(&mut self, au: &[u8], pts_ns: u64, keyframe: bool) -> Result<()>; }
// zone-1 Operator path: impl AuSink for &mut Session (today's behavior; nothing moves)
// zone-2 worker path: impl AuSink for WorkerChannel (writes a tagged frame to fd 3)
Zero-copy paths (frame.is_cuda()) are untouched — the worker is just a different consumer of the
same encoded bytes (5K@240: the AU is already one sendmmsg-batched object; the SEQPACKET hop is one
write, same as the Windows relay). Gamepads stay correct: the worker owns its uinput/uhid pads (from
the resolved welcome.gamepad), so the devices live in the uid's namespace and rumble/HID flows back
over fd 3 → zone 1 → client datagram.
7.3 The four gamescope-multiuser isolation pieces (now mandatory)
These are gamescope-multiuser.md's parked plumbing, now mandatory and hardened to live inside the
worker (sockets in the uid's XDG_RUNTIME_DIR, not shared /tmp — the SEC-1 improvement):
- Per-instance EIS socket → new
VirtualOutputfield.vdisplay.rs:29-47gains#[cfg(target_os="linux")] pub ei_socket_file: Option<PathBuf>.vdisplay/linux/gamescope.rsreplaces the globalEI_SOCKET_FILE="/tmp/punktfunk-gamescope-ei"(:778) with a per-instance pathformat!("{}/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. - Per-session injector bound to that socket.
inject.rs:24enum BackendgainsGamescopeEiAt(PathBuf)→libei::LibeiInjector::open_with(EiSource::SocketPathFile(path)). NewSessionInjectorinsession_worker.rs: own thread (the libeiInputInjectoris!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-lifetimeInjectorService(:167) stays for Operator/portal backends. - 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. Newopen_audio_capture_for(channels, target_node)setsPW_KEY_TARGET_OBJECT/node.targetinstead ofPW_ID_ANY. The host-lifetimeAudioCapSlot(punktfunk1.rs:212) keeps the default-monitor path for Operator. - Per-session virtual mic. The worker opens
punktfunk-mic-<pid>viaopen_virtual_micinto its gamescope; the globalMicService(punktfunk1.rs:1121) stays for Operator.
Net: zero shared injector/audio/mic state crosses a uid boundary — the catastrophic leak is structurally impossible.
7.4 The fail-closed SEC-3 gate (D5)
In serve_session, immediately after resolution, before the data-plane spawn (:958):
let resolved: ResolvedProfile = match profiles.resolve_session_profile(&fp_hex, req_id, passcode) {
Ok(r) => r,
Err(e) => { write_profile_reject(&mut send, reason_for(e)).await?; return Ok(()); }
};
let route: SessionRoute = match resolved.os_account {
// (A) Operator → today's shared-desktop path, UNCHANGED. Multi-occupant (D6).
OsAccount::Operator => SessionRoute::Operator { compositor: resolve_compositor(hello.compositor)? },
// (B) A real Linux user → MUST run on the isolating gamescope worker. Fail CLOSED here.
OsAccount::Linux { uid, .. } => {
let uid = uid.expect("resolve stamps uid at create-time");
if !broker_client::is_available() { // Flatpak / unprovisioned host (D12)
write_session_unavailable(&mut send, NoBroker).await?; return Ok(()); } // NEVER fall through
if !vdisplay::gamescope_present() {
write_profile_reject(&mut send, NeedsIsolation).await?; return Ok(()); }
let occ = match occupancy.try_acquire(uid, &fp_hex) { // D6: by RESOLVED uid; Operator exempt
Some(g) => g,
None => { write_profile_reject(&mut send, Occupied).await?; return Ok(()); }
};
let session = match broker_client::open(&resolved.id, &fp_hex, hello.mode) {
Ok(s) => s,
Err(BrokerError::MissingGroups | BrokerError::LingerFailed
| BrokerError::PamFailure | BrokerError::SpawnFailure)
=> { write_session_unavailable(&mut send, LoginFailed).await?; return Ok(()); }
Err(_) => { write_session_unavailable(&mut send, NoBroker).await?; return Ok(()); }
};
SessionRoute::Brokered { uid, occ, session }
}
};
The KWin/Mutter/wlroots-operator question, answered: a non-Operator profile is never served on
those shared backends (they create a virtual output inside the operator's live 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:
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 userpftest,enable-linger, fullpam_start … pam_open_session+setuid, then from the dropped child spawngamescope --backend headless -W 1280 -H 720 -r 60 -- vkcube, connect to that user's PipeWire and capture. Assert: runtime dir provisioned cold, sessionClass==user, render-node access as the uid, andMemoryDenyWriteExecute=yessurvives 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-pluscoexistence.
8. Windows host
Maps a resolved OsAccount::Windows { account_name, sid, credential } to a real, capturable Windows
session, conforming to the D7 broker-split target with the v1 SYSTEM honesty caveat, and to the D11
lifecycle fixes.
8.1 The hard constraint + privilege posture
A Windows client SKU has exactly ONE interactive console session; all GPU capture (DDA/WGC/IDD-push)
follows it (WTSGetActiveConsoleSessionId() hardcoded in service.rs:325, wgc_relay.rs:218,
interactive.rs:53). True concurrent multi-user desktops are RDS/Server-only. Therefore on Windows,
profiles are SEQUENTIAL: one active profile at a time; selecting a different profile is a console
user-switch. Concurrency stays a Linux/gamescope capability. Occupancy is global (single console),
persisted across the console-switch relaunch (D6).
Privilege (D7). Target = the mirror split: a non-SYSTEM streamer front-door with
LogonUser/LoadUserProfile/CreateProcessAsUserW + credential unsealing behind a tiny SYSTEM
broker over ALPC/named-pipe with a verified client SID. v1 may ship the front-door as SYSTEM, but
the Phase-3 gate decision MUST record that Windows v1 does not satisfy the §3.2 invariant (a host
RCE = full box compromise + recovery of every stored credential → trusted-LAN-only posture).
8.2 W0 — generalize the primitives from "active console" to a target session id
New module crates/punktfunk-host/src/windows/profile_session.rs. The single mechanical unblock: stop
hardcoding WTSGetActiveConsoleSessionId(), thread an explicit target_session: u32.
pub struct WindowsProfileTarget {
pub profile_id: String,
pub account_name: String, // "DOMAIN\\user" or ".\\user"
pub sid: Option<String>,
pub wts_session: u32, // resolved WTS session id to capture + launch into
}
/// WTSEnumerateSessionsExW match account_name/sid -> an Active session id. None => needs logon (§8.5).
pub fn find_user_session(account: &str, sid: Option<&str>) -> Option<u32>;
/// Generalization of interactive::spawn_in_active_session:
/// WTSQueryUserToken(session) -> DuplicateTokenEx(TokenPrimary) -> CreateProcessAsUserW(winsta0/default).
pub fn spawn_in_session(session: u32, cmdline: &str, workdir: Option<&Path>) -> Result<u32>;
Refactors (behavior-preserving when target == active console): interactive.rs:45
spawn_in_active_session becomes a thin wrapper over spawn_in_session(WTSGetActiveConsoleSessionId(),…);
wgc_relay.rs:80 HelperRelay::spawn gains target_session: u32; library::launch_title(id) gains
session: u32. W0 is validatable on the RTX box (192.168.1.173) single-user (target = active
console → byte-identical).
8.3 W1 — resolve profile → session, occupancy, thread the token
#[cfg(windows)]
let win_target: Option<WindowsProfileTarget> = match resolved.os_account {
OsAccount::Operator => None, // today's path: active console
OsAccount::Windows { account_name, sid, .. } => {
let _guard = occupancy().try_acquire_sid(&sid_of(&resolved), &fingerprint_hex)
.ok_or(ProfileReject::Occupied)?; // D6: keyed by resolved SID (global)
let wts = find_user_session(&account_name, sid.as_deref())
.or_else(|| logon_user_session(&resolved.id, &account_name, passcode.as_deref()).ok())
.ok_or(ProfileReject::SessionUnavailable(LoginFailed))?; // no session, no creds
Some(WindowsProfileTarget { profile_id: resolved.id, account_name, sid, wts_session: wts })
}
OsAccount::Linux { .. } => bail!("linux profile on windows host"),
};
Occupancy is a host-lifetime registry keyed by resolved SID (persisted in the SYSTEM broker so a
console-switch relaunch can't double-grant). A reconnect by the same device to the same profile preempts
(like the IDD preempt punktfunk1.rs:2449); a different profile while one is active → Occupied.
SessionContext (:2389-2390) gains #[cfg(windows)] profile_target: Option<WindowsProfileTarget>;
virtual_stream/_relay read ctx.profile_target.map(|t| t.wts_session).unwrap_or_else(active_console)
and feed it to HelperRelay::spawn + launch_title. W1 validatable with a SECOND pre-logged-in local
account (pf-test, FUS).
8.4 W2 — putting the target user on the (capturable) console
W2a — user already has a live session (reconnect). find_user_session returns Active/Disconnected.
The existing supervisor (service.rs:299-426) already relaunches the host on
WTSGetActiveConsoleSessionId() change — once the console moves, capture follows. Trigger:
reconnect-to-disconnected where available. Console-takeover guard (D12): before switching, detect
whether the current console session is a local/physically-present interactive user vs a
punktfunk-driven remote one, and refuse (SessionUnavailable{NeedsConsoleSwitch}) or require
explicit operator opt-in before evicting a local human (no silent hostile takeover with data-loss risk).
W2b — cold user (needs logon). Mint the token from the stored credential:
fn logon_user_session(profile_id: &str, account: &str, passcode: Option<&str>) -> Result<u32> {
let pw = profile_cred::load(profile_id, passcode)?; // Zeroizing<String>, §8.6
// LogonUserW(user, domain, &pw, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT) -> token
// LoadUserProfileW(token, &mut PROFILEINFOW) // HKCU + %USERPROFILE% resolve
// ... token for the credential provider / RDS logon
}
A LogonUser token alone does NOT put the user on the console — establishing the interactive console
logon for a never-logged-in user requires a custom Credential Provider (ICredentialProvider, a COM
in-proc DLL the SYSTEM service signals): Phase W3, deferred. Documented v1 fallback (D12): a
cold profile requires the operator to sign that user in once (or FUS); thereafter W2a reconnect works.
Token asymmetry: to launch processes use WTSQueryUserToken(session) (the real interactive token),
NOT the LogonUser token (only for establishing/reconnecting the logon).
8.5 W4 (advanced/optional) — RDS multi-session
On a Server SKU with RDS, each logon is its own session+desktop. The W0 generalization (spawn_in_session
/ HelperRelay targeting a named WTS session) is exactly what's needed: WTSEnumerateSessionsExW to
discover, the credential provider / WinStationConnectW to establish, capture by session id → concurrent
occupancy allowed. Gated behind PUNKTFUNK_RDS_MULTISESSION + an SKU probe; document the RDS CAL
reality. NOT v1; the primitives are designed so it is additive.
8.6 The auto-login credential — windows/profile_cred.rs
profiles.json never holds the password (§4.2). New blob per profile at config_dir()/cred/<profile_id>.bin
(%ProgramData%/punktfunk), written with write_secret_file (DACL → SYSTEM/Administrators only).
// inner plaintext blob: { account_name: String, password: String } (serde, zeroized after use)
pub fn store(profile_id: &str, account: &str, password: &str, passcode: Option<&str>) -> Result<()>;
pub fn load(profile_id: &str, passcode: Option<&str>) -> Result<Zeroizing<String>>; // returns password
pub fn clear(profile_id: &str) -> Result<()>;
Sealing layers: (1) CryptProtectData(CRYPTPROTECT_LOCALMACHINE) — machine-DPAPI, SYSTEM-readable, not
portable off the box; (2) if passcode-protected, additionally wrap with Argon2id(passcode)→AES-256-GCM
so the blob is useless until the connect-time passcode arrives — a real second factor for the credential.
D10 hardening: the credential-wrapping passcode is ≥6 alphanumeric (a 4-digit wrap is
offline-crackable against the GCM blob in minutes), and the wrap KDF is bound to a TPM-sealed /
host-identity-derived secret so the blob isn't crackable from a stolen disk image alone.
CredentialRef::DpapiBlob{path} points at cred/<id>.bin; CredentialRef::None = "user already logged
in" (W2a only). The operator writes it via a bearer-only, Windows-only mgmt route:
POST /api/v1/profiles/{id}/windows-credential body { account_name, password, passcode?: string|null }
→ 204; DELETE clears. Plaintext travels the existing TLS mgmt channel; the host seals immediately and
never echoes/logs (§9).
8.7 Lifecycle fixes (D10/D11)
- Hive/token unload (D11): session teardown calls
UnloadUserProfileW+CloseHandle(token)symmetric toLoadUserProfileW— no leaked hives/tokens across repeated switches. set_passcodere-wrap (D10): on a passcode-wrapped Windows profile,set_passcodemust 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)alsoprofile_cred::clears the blob (§12.2).
8.8 SessionPlan / SE_TCB / GameStream
SessionPlan::resolve (session_plan.rs:113) is unchanged (capture/topology/encoder are
profile-independent) — the profile only redirects which session the topology's helper/launch targets,
carried in SessionContext.profile_target. The host already holds SE_TCB_NAME (SYSTEM), satisfying
WTSQueryUserToken + CreateProcessAsUserW + LogonUser; a non-SYSTEM dev serve cannot do profiles
→ falls back to operator/console. GameStream is pinned to operator: its stream loop and launch_title
calls pass target = active console, never a profile_target, with an audit line per session (D13).
8.9 Per-user library
library::all_games() currently enumerates the SYSTEM host process's HOME — wrong for a profile. When
profile_target is set, enumerate as the target user (read that user's hive/paths after
LoadUserProfileW, or rely on the existing windows_launch_for recipes which already run via the user
token once spawn_in_session(wts) targets the right session). Visibility curation layers on top via
profiles.scoped_library(fp, all_games_for_user(sid)).
8.10 Phase ordering & validation matrix
| Phase | Work | Validatable on RTX box (single user)? |
|---|---|---|
| W0 | thread target_session through the 3 primitives |
YES — target=console = no-op regression |
| W1 | resolve profile→session, occupancy by SID, profile_target |
Partial — needs a 2nd pre-logged-in account (FUS) |
| W2a | reconnect to existing/disconnected session + supervisor follow + takeover guard | needs 2nd account, signed in once |
| W2b | credential store + LogonUser+LoadUserProfile |
store unit-testable on box; cold logon needs 2nd account |
| W3 | custom Credential Provider (cold-user console auto-login) | needs 2nd account + a display; heaviest; deferred |
| W4 | RDS multi-session | Server SKU / RDS host (out of lab) |
Back-compat: absent profiles.json or operator/default → profile_target = 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
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
/// Admin view — the full profile MINUS the secret. Never exposes the PHC.
#[derive(Serialize, ToSchema)]
struct ProfileAdmin {
id: String, display_name: String, accent: Option<String>, avatar: Option<String>,
os_account: OsAccount, assigned_fingerprints: Vec<String>,
has_passcode: bool, // derived from passcode.is_some()
require_passcode_even_when_assigned: bool, allow_shared_view: bool, tvos_user_ids: Vec<String>,
library_scope: LibraryScope, custom_entries: Vec<CustomEntry>, session_defaults: SessionDefaults,
is_default: bool, created_unix: u64, updated_unix: u64,
}
/// Public picker view (cert-authed; no secret, no OS account, no fingerprint list). Field names align
/// to the wire ProfileEntry (snake_case).
#[derive(Serialize, ToSchema)]
struct ProfilePublic {
id: String, display_name: String, accent: Option<String>, avatar: Option<String>,
requires_passcode: bool,
assigned: bool, // requesting cert is assigned → frictionless
}
/// One real OS user the operator may bind. MINIMAL + non-secret: never a password/home/group list.
#[derive(Serialize, ToSchema)]
struct OsAccountCandidate {
username: String, uid: Option<u32>, sid: Option<String>, full_name: Option<String>,
platform: String, // "linux" | "windows"
is_operator: bool, in_use: bool,
}
/// A live profile session (D11).
#[derive(Serialize, ToSchema)]
struct SessionInfo {
session_id: String, profile_id: String, display_name: String, os_account: OsAccount,
client_fp: String, device_name: Option<String>, backend: String, started_unix: u64,
}
#[derive(Deserialize, ToSchema)] struct SetPasscode { passcode: Option<String> } // null clears
#[derive(Deserialize, ToSchema)] struct AssignDevice { fingerprint: String }
#[derive(Deserialize, ToSchema)] struct SetDefault { profile_id: Option<String> }
ProfileInput (§4.7) carries os_account: OsAccount directly; the web client builds it from the
picked OsAccountCandidate: {kind:"linux",username,uid} / {kind:"windows",account_name,sid,credential:{source:"none"}}
/ {kind:"operator"}.
9.4 OS-account enumeration source (os_accounts.rs)
New cfg-split module crates/punktfunk-host/src/os_accounts.rs. Linux: iterate getpwent,
filtering to human accounts (uid == host uid → is_operator:true; uid in [UID_MIN, 65534) from
/etc/login.defs; valid login shell); full_name = first GECOS field. Windows:
NetUserEnum(NULL, level=20, FILTER_NORMAL_ACCOUNT) + LookupAccountNameW → SID string;
username = ".\\<name>"; domain enumeration out of scope (operator types DOMAIN\\user manually).
is_operator candidate is always emitted first. in_use is computed by joining against
profiles.list().
async fn list_os_accounts(State(st): State<Arc<MgmtState>>) -> Json<Vec<OsAccountCandidate>> {
let host_uid = crate::os_accounts::host_uid();
let mut cands = crate::os_accounts::list_candidates(host_uid);
let bound: HashSet<String> = st.profiles.list().iter()
.filter_map(|p| crate::os_accounts::account_key(&p.os_account)).collect();
for c in &mut cands { c.in_use = bound.contains(crate::os_accounts::candidate_key(c).as_str()); }
Json(cands)
}
9.5 Handler bodies (representative)
Thin wrappers over the §4.7 Profiles API, identical in shape to the native-pairing handlers. Each
redacts to ProfileAdmin (never serializing PasscodeHash). The audit line ("Profile administration",
§6.8) is a tracing::info!(target: "punktfunk::audit", …) in each mutation, never the passcode.
async fn set_profile_passcode(State(st): State<Arc<MgmtState>>, Path(id): Path<String>,
ApiJson(req): ApiJson<SetPasscode>) -> Response {
let pass = req.passcode.as_deref().map(str::trim).filter(|s| !s.is_empty());
if let Some(p) = pass {
if p.len() < 4 || p.len() > 64 { return api_error(StatusCode::BAD_REQUEST, "passcode 4–64 chars"); }
// D10 entropy floor: a passcode wrapping a Windows credential must be >=6 alphanumeric.
if st.profiles.wraps_windows_credential(&id) && !is_alnum_min6(p) {
return api_error(StatusCode::BAD_REQUEST, "credential profiles need a >=6 alphanumeric passcode");
}
}
match st.profiles.set_passcode(&id, pass) { // re-wraps the credential blob if needed (D10)
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => api_error(StatusCode::NOT_FOUND, "no profile with that id"),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
async fn enumerate_profiles(State(st): State<Arc<MgmtState>>, req: Request) -> Json<Vec<ProfilePublic>> {
let fp = req.extensions().get::<PeerCertFingerprint>().and_then(|p| p.0.clone()).unwrap_or_default();
Json(st.profiles.enumerate_public(&fp)) // SCOPED roster (D9)
}
Drift gate: after editing mgmt.rs, regenerate cargo run -p punktfunk-host -- openapi > api/openapi.json (CI fails on drift), then cd web && pnpm codegen regenerates web/src/api/gen/ profiles/* + the model types.
9.6 Web Profiles page
Files mirror Pairing/Library: web/src/routes/profiles.tsx (4-line route), sections/Profiles/index.tsx
(container — orval hooks + mutations), sections/Profiles/view.tsx (presentational, Loadable<T> +
callbacks), stories/Profiles.stories.tsx + fixtures in stories/lib/fixtures.ts, a NAV entry
({to:"/profiles", icon: UserCog, label: () => m.nav_profiles()} in app-shell.tsx:21-28), i18n keys in
messages/{en,de}.json. pnpm codegen emits hooks (useListProfiles, useCreateProfile,
useUpdateProfile, useDeleteProfile, useSetProfilePasscode, useAssignProfileDevice,
useUnassignProfileDevice, useSetDefaultProfile, useListOsAccounts, useEnumerateProfiles,
useListSessions, useTerminateSession).
export interface ProfilesViewProps {
profiles: Loadable<ProfileAdmin[]>;
osAccounts: Loadable<OsAccountCandidate[]>;
clients: Loadable<NativeClient[]>; // paired devices, for the assign dropdown
sessions: Loadable<SessionInfo[]>; // Live sessions card (D11)
onCreate: (data: ProfileInput) => Promise<unknown>;
onUpdate: (id: string, data: ProfileInput) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
onSetPasscode: (id: string, passcode: string | null) => Promise<unknown>;
onAssign: (id: string, fingerprint: string) => Promise<unknown>;
onUnassign: (fingerprint: string) => Promise<unknown>;
onSetDefault: (id: string | null) => Promise<unknown>;
onReclaim: (sessionId: string) => Promise<unknown>; // DELETE /api/v1/sessions/{id}
isSaving: boolean; isDeleting: boolean;
}
Layout (one <Section>): (1) header h1 + "Add profile"; (2) profile-tile grid (avatar tinted by
accent, monogram fallback; OS-account chip; has_passcode lock badge; is_default star;
assigned-device count; Edit/Delete on hover; "Make default" row action); (3) create/edit form (gated by
editing: string|null) with display_name, accent, avatar, an OS-account <select> populated
from osAccounts.data (first option "Operator (this host — shared desktop)" → {kind:"operator"}; a
free-text "Other (DOMAIN\user)…" row for Windows domain accounts), a separate passcode sub-form
(routes through set_passcode; a brand-new profile creates first then reveals the sub-form),
LibraryScope radio (All|Allow|Deny + a store:id textarea in v1), SessionDefaults numeric
inputs; (4) Device assignment subsection (the profile's assigned_fingerprints joined against
clients.data to render device names, an unassign button, and a dropdown of unassigned paired devices);
(5) Live sessions card (D11) listing sessions.data with a Reclaim action calling
onReclaim(session_id); (6) a "credential not set" badge on a CredentialRef::None Windows profile
("sign this user in manually or add a credential", Critic 2 minor).
Pairing integration: PendingDevicesCard (Pairing/view.tsx:111-179) gains an inline profile
<select> per pending row; on Approve, the container chains approve.mutateAsync(...) → if a
profileId was chosen, assign.mutateAsync({id: profileId, data: {fingerprint: approvedClient.fingerprint}})
(the approve response is a NativeClient carrying the fingerprint). New keys
pairing_pending_assign_label/_none.
GameStream exclusion (UI copy): a muted note profiles_gamestream_note on the page header:
"Profiles apply to native punktfunk connections only. Moonlight/GameStream sessions always use the
operator account and the global library." Also in the profiles tag description.
Back-compat: absent profiles.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:
guard let h = handle else {
switch status {
case statusAuthRequired where passcode != nil: throw PunktfunkClientError.profilePasscodeWrong
case statusAuthRequired: throw PunktfunkClientError.profilePasscodeRequired
case statusNotFound: throw PunktfunkClientError.profileNotFound
default: throw PunktfunkClientError.connectFailed
}
}
init gains profileID: String? = nil and passcode: String? = nil (after launchID; defaulted so
existing call sites compile unchanged), replaces punktfunk_connect_ex5 with punktfunk_connect_ex6
(threading profileID/passcode via withOptionalCString + capturing the status_out pointer), and
reads resolvedProfile via punktfunk_connection_profile(h, &profBuf, …):
public private(set) var resolvedProfile: String? // Welcome echo; nil = single-user / old host
Enumerate (free function, mirrors pair()), now carrying the assigned flag (D9 — so the picker
renders frictionless profiles without a blind try):
public struct HostProfile: Identifiable, Hashable, Sendable {
public let id: String
public let displayName: String
public let passcodeRequired: Bool
public let assigned: Bool // NEW (D9): requesting cert is assigned → connect on one tap
}
public func listProfiles(host: String, port: UInt16 = 9777, identity: ClientIdentity,
pinSHA256: Data? = nil, timeoutMs: UInt32 = 10_000) throws -> (profiles: [HostProfile], hostFingerprint: Data)
// over punktfunk_list_profiles → PunktfunkProfile{ char id[65]; char display_name[65];
// uint8_t passcode_required; uint8_t assigned }
10.3 SessionModel.swift
connect(to:…) gains profileID/passcode (defaulted), passes them into the PunktfunkConnection(...)
init inside the existing Task.detached, and in the .failure arm publishes a structured prompt:
@Published var profilePrompt: ProfilePrompt?
struct ProfilePrompt: Identifiable, Equatable {
let id = UUID(); let host: StoredHost; let attemptedProfileID: String?
enum Reason: Equatable { case choose, passcodeRequired, wrongPasscode, notFound }
let reason: Reason
}
// .failure(.profilePasscodeRequired) → .passcodeRequired ; .profilePasscodeWrong → .wrongPasscode ;
// .profileNotFound → .notFound ; existing generic errorMessage handling otherwise unchanged.
10.4 Per-host cache + passcode at rest
StoredHost gains var lastProfileID: String? (Codable back-compat like mgmtPort; seeds the next
connect's profileID) + HostStore.setProfile(_:profileID:). New
PunktfunkClient/ProfilePasscodeStore.swift — a Keychain store mirroring ClientIdentityStore's
data-protection-keychain + legacy-fallback, keyed by (host cert fingerprint hex, profileID):
final class ProfilePasscodeStore: @unchecked Sendable {
static let shared = ProfilePasscodeStore()
func load(hostFingerprint: Data, profileID: String) -> String? // nil = not remembered
func save(_ passcode: String, hostFingerprint: Data, profileID: String)
func delete(hostFingerprint: Data, profileID: String)
}
Default behavior is not to remember (shared-device safety); the picker offers an explicit "Remember
on this device" toggle. Clear on wrong (D12): a wrong-passcode result clears any cached entry. The
toggle is forbidden on shared/tvOS builds (§11, D12). Deleting a host (HostStore.remove) /
forgetIdentity sweeps the host's stored passcodes.
10.5 ContentView.swift orchestration + ProfilePicker.swift
connect(_:) threads profileOverride: ProfileSelection?; defaultSelection(for:) returns
(host.lastProfileID, remembered passcode); the streaming .onChange caches
store.setProfile(host.id, profileID: model.connection?.resolvedProfile). A single sheet bound to
model.profilePrompt drives ProfilePicker (tvOS uses .navigationDestination(item:)). A host-card
context-menu callback chooseProfile(_:) opens the picker with reason: .choose, gated on
prof=1/lastProfileID + pinned.
ProfilePicker.swift (new): a two-step flow — enumerate (off-main via listProfiles) → list → on a
passcode-required pick (or .passcodeRequired/.wrongPasscode re-entry) collect the passcode; returns
Result?{ profileID, passcode?, remember } (nil = cancelled). Each row shows displayName + a lock
glyph when passcodeRequired; an assigned or passcode-less profile connects on one tap (try
passcode = nil first, re-prompt only on profilePasscodeRequired). The passcode step is a SecureField
(numberPad on iOS), a default-off "Remember" toggle, an inline "Wrong passcode — try again" on re-entry;
tvOS swaps to TVFieldRow/TVTextEntry like PairSheet.
10.6 Settings, HUD, back-compat
Per-profile client settings are not needed (the host-side SessionDefaults clamps the client's
global request at resolve time + Reconfigure). The HUD adds an optional "Profile: …" line from
connection.resolvedProfile. Touchpoints: PunktfunkConnection.swift, SessionModel.swift,
HostStore.swift, new ProfilePasscodeStore.swift + ProfilePicker.swift, ContentView.swift,
HomeView.swift, HostCards.swift, HostDiscovery.swift (expose DiscoveredHost.supportsProfiles
from TXT prof=1), StreamHUDView.swift. Every new param defaults nil/.auto, so an un-updated
client behaves exactly as today; only PunktfunkConnection.init migrates to ex6.
11. tvOS profile binding
Binds the active Apple TV user to a profile. Extends §10 (it does not re-spec the connect plumbing);
it adds the tvOS-only "read who's in front of the TV → auto-select their mapped profile" layer. One
shared Apple TV app identity + one host pairing: the single device cert is presented by every tvOS
user, so the host sees one fingerprint for the whole Apple TV; the profile (Hello profile_id)
plus its passcode separates the humans. currentUserIdentifier is a client-side hint only (D8).
11.1 Mechanism + Apple symbols
- Framework:
import TVServices(tvOS only). Class: instantiateTVUserManager()and readvar 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;nilon a single-user device or an un-entitled build. - User-changed detection: the primary mechanism is re-reading
currentUserIdentifieron 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 requestruns-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-userkeeps a single shared container. Goes in a newConfig/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
TVUserManagerconstruction form (()vs.shared) and whether a publicdidChangeCurrentUserNotificationexists are unverified against the tvOS SDK; the design caches one instance and treats the notification as optional so thescenePhasere-read alone is sufficient.
11.2 The mapping store — per-(tvOS user, host) → profile id
// DefaultsKeys.swift
public static let tvUserProfileMap = "punktfunk.tvUserProfileMap" // [compositeKey: profileID]
// compositeKey = "<tvUserID>\u{1f}<host UUID>" (0x1F unit separator — absent from either half)
TVUserProfileBinding (new, tvOS-only) is a thin typed wrapper over that [String:String]
UserDefaults dictionary: profileID(user:host:), setProfileID(_:user:host:), forgetHost(_:). No
passcode is ever persisted here (D12) — only the chosen profile id, which is non-secret. First-run for
a (user, host) pair → present §10's ProfilePicker(.choose); on resolve, persist the binding + connect.
Subsequent connects auto-select silently, re-prompting only for a passcode-protected profile's passcode.
A switch detected mid-session disconnects (the live stream belonged to the previous human).
11.3 The identifier is a HINT only (D8)
currentUserIdentifier never rides the wire; it selects the profileID passed to connect_ex6, and the
host resolves by its own D4 table keyed on the device cert fingerprint + the profile's passcode
policy. A tvos_user_ids match on the host still requires the profile's passcode for any protected
profile: the client auto-selects profileID = P, sends passcode = nil first, and drives the passcode
step on profilePasscodeRequired. There is no tvos_attested flag and no passcode bypass. Because
all tvOS users present the same cert, host-side device assignment cannot differentiate them — so a
passcode-less assigned profile is frictionless for every user on that Apple TV (the family "shared
TV" profile), and any enforced separation between humans requires a passcode.
11.4 Remote-friendly passcode + D12 + graceful fallback
Passcode entry reuses §10's ProfilePicker (tvOS keyboard via TVFieldRow/TVTextEntry/fullScreenCover).
tvOS rules: forbid "Remember on this device" (ProfilePicker's remember toggle + every
ProfilePasscodeStore call gated #if !os(tvOS); Result.remember forced false; the tvOS connect path
never reads/writes the Keychain) — the passcode is a true second factor entered once per session. Clear
on wrong is automatic (the failed connect_ex6 already tore down its connection; one attempt per
connection → the retry is a fresh connect with the freshly-typed code).
Fallback: entitlements are signing-time, not compile-time, so the code path is always present. When
the entitlement is absent (or "Users" off, or an iOS/macOS build), currentUserIdentifier returns nil
⇒ the tvOS connect path falls through to §10's device-global behavior (host.lastProfileID + the explicit
"Choose Profile…" item) — the same flow macOS/iOS use, and the only sound mode without a per-user
identity. When the entitlement is later approved, the same binary activates the per-user binding with
zero code change.
11.5 App-lifecycle wiring + scope
TVUserBinding (new, tvOS-only @MainActor ObservableObject owned by ContentView) reads
TVUserManager().currentUserIdentifier, republishes on refresh(), and returns the previous value so
callers detect a switch. ContentView (tvOS): @StateObject tvUser, @Environment(\.scenePhase); on
scenePhase == .active, tvUser.refresh() — if it changed and a session is live, model.disconnect().
defaultSelection/connect branch on tvOS (mapped user → bound profile, passcode: nil; first-run →
ProfilePicker(.choose)); the picker-resolution closure persists the binding on tvOS (and skips the
ProfilePasscodeStore save/delete). Keep HostStore/ClientIdentityStore/SettingsView/DefaultsKeys
GLOBAL (one shared identity ⇒ one pairing ⇒ one host list — forced by the decision and right for a
family TV); only the profile binding is per-user (non-secret). SessionModel stays a single global
@StateObject (one Apple TV streams one session).
11.6 Host-on-disk implication
punktfunk1-paired.json records one fingerprint for the whole Apple TV. The operator should: assign
the shared fingerprint to the family/default profile for a passcode-less landing; give any private
family-member profile a passcode (device assignment alone won't protect it — the kid's tvOS user
presents the identical cert); treat tvos_user_ids as advisory console metadata only (never a trust
input, never relaxes the passcode).
12. Lifecycle & edge cases
A consolidated, normative list of the lifecycle behaviors the implementation must honor (all from D11 unless noted).
12.1 Orphan-session reaping
- Linux: the broker treats the zone-1 control socket as each session's lifeline — on peer death
(host crash / console-change restart / OOM), every
SessionRecopened on that connection is reaped (pam_close_session+kill+waitpid). Redundant nets:PR_SET_PDEATHSIG=SIGKILL, worker self-exit onsession_fdEOF, 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 throughDELETE /api/v1/sessions/{id}), cleardefault_profile_idif it pointed here,profile_cred::clear(Windows blob), drop thePasscodeGateentries, release occupancy.unassign_device/reassign mid-session: the existing session continues; the change re-resolves on the next connect (documented policy — no mid-session eviction).
12.3 Passcode-change re-wrap (D10)
set_passcode on a passcode-wrapped Windows profile must re-wrap (decrypt-old → encrypt-new) or
surface a "credential needs re-entry" state — otherwise the blob stays under the OLD passcode and cold
logon silently fails. update(os_account) clears the credential blob + the resolved uid/SID (the blob's
inner account would otherwise point at the wrong user).
12.4 Reconfigure SessionDefaults re-clamp
The mid-stream Reconfigure path re-intersects the requested mode/bitrate with the resolved profile's
SessionDefaults (not just the initial Hello), so a client cannot renegotiate past the profile's policy
(§7.6 for the brokered worker; the Operator path clamps in-process).
12.5 Occupancy reclaim
A second device to an occupied uid/SID → Occupied; the operator sees "occupied by " on the
Live-sessions card and can Reclaim (DELETE /api/v1/sessions/{id} → release occupancy + broker
CloseSession / UnloadUserProfile). A same-device-same-profile reconnect preempts the prior session.
12.6 profiles.json consistency (D11)
profiles.json is the single on-disk source of truth both zone 1 and the broker read. A failed
save() is a failed mutation — return an error, don't keep the optimistic in-memory change (else the
broker, which re-stat/reloads per OpenSession, would resolve against stale data — e.g. a just-deleted
profile). A parse failure keeps raw bytes and refuses mutations.
12.7 Provisioning preflight (Linux render/video/input)
The zone-2 worker runs capture+encode + gamepad injection as the target uid, so that user needs
render/video (NVENC/VAAPI on renderD128) and input (uinput/uhid) group membership. The broker
preflights getgrouplist and returns MissingGroups → the client sees SessionUnavailable{LoginFailed}
naming the missing groups, not a black screen.
12.8 Cold-user / SessionUnavailable surfacing + client UX
When a profile resolves but the OS session can't be created (Linux PAM/spawn/linger/missing-groups;
broker absent; Windows bad/absent credential, cold user needing the deferred Credential Provider, or a
local-user console held), the host sends a typed ProfileReject{SessionUnavailable{NoBroker|LoginFailed| NeedsConsoleSwitch|Busy}} (ABI -14). Every client maps it to a clear message — e.g. "Could not sign in
on the host — the account may need a one-time manual login / credential setup" — never a
generic connectFailed and never a silent fallthrough to the operator desktop. NeedsIsolation
(-15) is reserved for "the box can't isolate this profile at all" (no gamescope), no longer overloaded
for credential/cold-user failures.
13. Rollout, phasing, back-compat & testing
Normative ordering — where an upstream section implies a different build order, this wins.
13.1 Sequencing principles + the one inviolable safety rule
- Privilege last. Every increment that does not become another OS user ships and validates before any privileged code (Linux root broker / Windows SYSTEM logon). A bug in Phases 0–1 cannot escalate.
- 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.
- Every phase is independently shippable + on-by-default-safe. At every boundary, a host with no
profiles.jsonis 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 → rejectSessionUnavailable{NoBroker}/NeedsIsolationbefore any pipeline is built. The only code path consuming a non-OperatorResolvedProfileis the broker client, which does not exist until Phase 2; until then the non-Operatorarm 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. theNone+unassigned+passcode-default = require regression, andrequire_passcode_even_when_assignedupgrading 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
Nonefrom old bytes; placeholder offsets deterministic;ListProfiles/ProfileList/ProfileUnlock/ProfileRejectencode/decode;Welcome.resolved_profilecarries the id;connect_ex6ABI harness (C round-trip,ex5→ex6NULL,NULL,NULLdelegation, status codes). - Loopback (QEMU VM): probe
--profile <id>happy (assigned/no-passcode, correct passcode) and--passcode 0000→ProfileReject{WrongPasscode}; wrong ×N →LockedOut, confirm a secondclient_fpis NOT locked; aLinuxprofile → MUST rejectSessionUnavailable{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_sessionacross 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 --checkevery phase;swift build/swift testonhome-mac-mini-1(Phases 1, 4).
13.5 Packaging / migration
- Linux broker package (
deb.yml/rpm.yml+ COPR/Arch/sysext/bootc): separatepunktfunk-session-brokerpackage (broker binary +.service/.socket+/etc/pam.d/punktfunk+ tmpfiles),Recommends:/Suggests:ofpunktfunk-host(not a hard dep). - Flatpak / sandboxed:
Operator-profile-only; ships a capability flag advertisingprofiles=operator-only; real-user profile →SessionUnavailable{NoBroker}, never silent fallthrough. - Windows:
windows-host.ymlInno 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 fieldsserde(default); a breaking change bumpsPROFILES_SCHEMA_VERSIONwith a one-shotload()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/tmpinto 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," updatestatus.mdper 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.
- tvOS Apple symbols / entitlement (HIGH uncertainty, §11.1): confirm
TVUserManager()vs.shared; whether a publicdidChangeCurrentUserNotificationexists (if not, thescenePhasere-read is the sole, sufficient detector); whethercom.apple.developer.user-management/get-current-useris grantable to a streaming app (Apple-gated, positioned at games); and thatget-current-userwithoutruns-as-current-userkeeps a single shared data container (else the single-pairing decision breaks). The whole feature degrades to the manual picker if denied. - Linux AU-relay throughput at 5K@240 across the
session_fdSEQPACKET hop is asserted-equal to the Windowswgc_relayprecedent 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). - Windows Credential Provider scope (W3): ship a custom
ICredentialProviderfor 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. - 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.
- 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.
- Broker ↔
profiles.jsonreload (TOCTOU): the brokerstat+reloads perOpenSession(fresh, simple). Confirm sufficient vs. aninotifywatch; the only race is an operator editing an assignment in the millisecond a session opens, which the next connect corrects. - 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. astore:idtextarea). - 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 |