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