Per-client access control: grants + temporary guest access, host-enforced end to end (WP1–WP13) #245

Merged
enricobuehler merged 11 commits from worktree-per-client-access-a0 into main 2026-08-15 09:51:20 +00:00
Owner

Implements the full per-client access plan (punktfunk-planning/design/per-client-access.md + its implementation plan, PR #8 there; §7 status table updated with these shas). A paired device now carries what it may do (six grant bits: gamepad / pointer / keyboard / clipboard / mic / launch, surfaced as three presets — Full control · Controller only · View only) and for how long (wall-clock expiry with live mid-session cut). Headline flow: a guest knocks, the owner clicks "Approve as guest" — controller-only for 4 h, pad #2 lights up, and the grant cleans itself up after the evening.

Ten commits, one per work package, plus a merge of main's 2026-08-15 security sweep:

  • WP1 core 97fe3a0f — grant vocabulary, exhaustive classify(InputKind) (a new InputKind breaks the build here — that's the default-deny), AccessUpdate (0x58), typed closes 0x69/0x6A, Welcome trailing grants/expires_in_secs. WIRE_VERSION stays 2 (trailing-append precedent).
  • WP2 store 25487a8b — grants + expiry on PairedClient (serde-defaulted; old stores decode as full/permanent), per-fingerprint watch registry, and the security fix: add() is now name-only on re-pair, so a guest re-pair can no longer escalate to full control (tripwire test included).
  • WP3–5 host enforcement 19411d8d — admission via effective() (expired ⇒ knock path, re-approval = re-grant), deadline task with T−5m/T−1m warnings and typed expiry close, one-relaxed-load classify-and-drop before offer(), deny-at-setup (no uinput pad / pad-audio / mic attach / clip coordinator without the bit), launch refused pre-Welcome, CLIP_REASON_NOT_PERMITTED, access.granted/changed/expired events. Quiet drops: counters + first-drop-per-class only.
  • WP6 mgmt 6753641c — grants in NativeClient/PendingDevice (all optional — version-skewed consoles degrade), approve/arm with access, PATCH /native/clients/{fingerprint}, OpenAPI regen.
  • WP7+8 console ab8fa46b — one approve dialog (Full · Forever default + distinct "Approve as guest" secondary, two clicks total), arm-form access controls, Access column with a single shared countdown interval, edit dialog with partial PATCH; renders "—" against older hosts.
  • WP9 desktop clients b2e6debb — live grants on the connector (access_grants/access_deadline_unix/next_access_update/end_reject), capture funneled through the host's own classify(), chip + expiry toasts, typed end reasons.
  • WP10 Apple ee6dff11 — new C ABI surface (punktfunk_connection_grants / _access_expires_in / _end_reject), class-gated send funnels, chip/countdown/toasts on macOS/iOS, tvOS stats-overlay line.
  • WP11 Android 1bed8242 — JNI poll shim, GamepadRouter/pointer/IME/mic/clipboard gating, overlay pill + toasts.
  • WP12 docs 18f59569 — owner-facing "Access levels" page + pairing-page section, honest about the limits (shared-desktop visibility, Moonlight ungoverned until a record exists, old clients get silent enforcement). Decky needs nothing.
  • WP13 GameStream c32fad8a — launch/resume gated on LAUNCH + expiry beside peer_is_paired, input classify-and-drop via owner_fp on the control tick, expiry cuts the session through the normal TERMINATION path. Absent registry record = ungoverned full control (existing Moonlight pairings unchanged); an existing record governs like the native plane.
  • Merge 97f81a6e — main's security sweep (#244) landed punktfunk_connect_ex10 as ABI v21, so this PR's access surface is v22 (ABI_VERSION 21→22, header regenerated); the sweep's GameStream peer-identity gate and the grant-mask check compose in the receive path; the sweep's "state which knock you're approving" fix (device name + fingerprint) is carried into the access approve dialog that replaced the plain name prompt.

Verified (pre-merge, per-WP): Linux (.25) full host suite 522 passed (sole failure = the box's known environmental hooks test) + clippy -D warnings non-vacuous; Windows (.133) host check+clippy (nvenc,amf-qsv) + client crates; Mac 405 core tests + fmt + header freshness + 5-slice xcframework + iOS/tvOS typechecks + 304 Swift tests; Android Kotlin compile/tests + 3-ABI cross-compile; web codegen/lint/storybook; docs build. Post-merge re-verification (Mac + web + Swift + .25 host suite) is running; results will land as a PR comment/commit if anything needs fixing.

Still open (tracked in the plan's §7): the on-glass guest rehearsal (release gate for the console phase), the real-Moonlight run (.181/Deck), D1–D4 confirmation (implemented as the proposed defaults), mgmt upsert so console Moonlight rows become editable, Android typed-close polish, and the release-notes honesty blurb re old clients. WP14 (TLS handshake rejection) stays deferred by design.

Implements the full per-client access plan (`punktfunk-planning/design/per-client-access.md` + its implementation plan, PR #8 there; §7 status table updated with these shas). A paired device now carries **what it may do** (six grant bits: gamepad / pointer / keyboard / clipboard / mic / launch, surfaced as three presets — Full control · Controller only · View only) and **for how long** (wall-clock expiry with live mid-session cut). Headline flow: a guest knocks, the owner clicks **"Approve as guest"** — controller-only for 4 h, pad #2 lights up, and the grant cleans itself up after the evening. Ten commits, one per work package, plus a merge of main's 2026-08-15 security sweep: - **WP1 core** `97fe3a0f` — grant vocabulary, exhaustive `classify(InputKind)` (a new InputKind breaks the build here — that's the default-deny), `AccessUpdate` (0x58), typed closes 0x69/0x6A, Welcome trailing `grants`/`expires_in_secs`. `WIRE_VERSION` stays 2 (trailing-append precedent). - **WP2 store** `25487a8b` — grants + expiry on `PairedClient` (serde-defaulted; old stores decode as full/permanent), per-fingerprint watch registry, and the security fix: `add()` is now name-only on re-pair, so **a guest re-pair can no longer escalate to full control** (tripwire test included). - **WP3–5 host enforcement** `19411d8d` — admission via `effective()` (expired ⇒ knock path, re-approval = re-grant), deadline task with T−5m/T−1m warnings and typed expiry close, one-relaxed-load classify-and-drop before `offer()`, deny-at-setup (no uinput pad / pad-audio / mic attach / clip coordinator without the bit), launch refused pre-Welcome, `CLIP_REASON_NOT_PERMITTED`, `access.granted/changed/expired` events. Quiet drops: counters + first-drop-per-class only. - **WP6 mgmt** `6753641c` — grants in `NativeClient`/`PendingDevice` (all optional — version-skewed consoles degrade), approve/arm with access, `PATCH /native/clients/{fingerprint}`, OpenAPI regen. - **WP7+8 console** `ab8fa46b` — one approve dialog (Full · Forever default + distinct "Approve as guest" secondary, two clicks total), arm-form access controls, Access column with a single shared countdown interval, edit dialog with partial PATCH; renders "—" against older hosts. - **WP9 desktop clients** `b2e6debb` — live grants on the connector (`access_grants`/`access_deadline_unix`/`next_access_update`/`end_reject`), capture funneled through the host's own `classify()`, chip + expiry toasts, typed end reasons. - **WP10 Apple** `ee6dff11` — new C ABI surface (`punktfunk_connection_grants` / `_access_expires_in` / `_end_reject`), class-gated send funnels, chip/countdown/toasts on macOS/iOS, tvOS stats-overlay line. - **WP11 Android** `1bed8242` — JNI poll shim, GamepadRouter/pointer/IME/mic/clipboard gating, overlay pill + toasts. - **WP12 docs** `18f59569` — owner-facing "Access levels" page + pairing-page section, honest about the limits (shared-desktop visibility, Moonlight ungoverned until a record exists, old clients get silent enforcement). Decky needs nothing. - **WP13 GameStream** `c32fad8a` — launch/resume gated on LAUNCH + expiry beside `peer_is_paired`, input classify-and-drop via `owner_fp` on the control tick, expiry cuts the session through the normal TERMINATION path. Absent registry record = ungoverned full control (existing Moonlight pairings unchanged); an existing record governs like the native plane. - **Merge** `97f81a6e` — main's security sweep (#244) landed `punktfunk_connect_ex10` as ABI v21, so **this PR's access surface is v22** (`ABI_VERSION` 21→22, header regenerated); the sweep's GameStream peer-identity gate and the grant-mask check compose in the receive path; the sweep's "state which knock you're approving" fix (device name + fingerprint) is carried into the access approve dialog that replaced the plain name prompt. **Verified** (pre-merge, per-WP): Linux (.25) full host suite 522 passed (sole failure = the box's known environmental hooks test) + clippy `-D warnings` non-vacuous; Windows (.133) host check+clippy (nvenc,amf-qsv) + client crates; Mac 405 core tests + fmt + header freshness + 5-slice xcframework + iOS/tvOS typechecks + 304 Swift tests; Android Kotlin compile/tests + 3-ABI cross-compile; web codegen/lint/storybook; docs build. Post-merge re-verification (Mac + web + Swift + .25 host suite) is running; results will land as a PR comment/commit if anything needs fixing. **Still open** (tracked in the plan's §7): the on-glass guest rehearsal (release gate for the console phase), the real-Moonlight run (.181/Deck), D1–D4 confirmation (implemented as the proposed defaults), mgmt upsert so console Moonlight rows become editable, Android typed-close polish, and the release-notes honesty blurb re old clients. WP14 (TLS handshake rejection) stays deferred by design.
enricobuehler added 10 commits 2026-08-15 09:47:23 +00:00
The new owner-facing Access levels page documents the Full/Controller/View
presets, the six Advanced toggles, wall-clock expiry with T-5m/T-1m warnings
and one-click re-grant, and the three honest limits: shared-desktop
visibility is not isolated, Moonlight rows are ungoverned until the
GameStream phase, and older clients are enforced without the chrome. The
pairing page gains the one-dialog approval flow (access level + expiry,
'Approve as guest') and the Access column note.
The wire layer of design/per-client-access.md: quic/access.rs carries the
GRANT_* bits (u32, reserved-must-be-zero), the three presets, and the
exhaustive InputKind -> GrantClass classifier whose wildcard-free match is
the default-deny mechanism — a new input kind now fails to compile until
someone decides its grant class.

AccessUpdate { grants, remaining_secs } rides the control stream as 0x58
(verified free; both peers' dispatch loops drop unknown ids with a warn,
so old clients just miss the courtesy). reject.rs grows the 0x69/0x6A
close codes -> RejectReason::{AccessExpired, LaunchNotPermitted}, mirrored
into PunktfunkStatus -30/-31 and the shared client-facing sentences.

The Welcome advertises grants + expires_in_secs as trailing fields one
link past mgmt_port, with the same placeholder discipline: emitting the
advert forces the cipher byte and the mgmt port so the two u32s land at a
deterministic offset, while a full-control permanent session stays
byte-identical to the pre-grants wire form. Absent fields decode to
GRANT_ALL / permanent — exactly what an old host enforces. No
WIRE_VERSION bump (trailing fields, per the v20 mgmt_port precedent), no
ABI bump (no new C symbols; the header gains prefixed defines and two
appended status values only). The host sends GRANT_ALL until WP2/WP3
wire the trust store in.
PairedClient grows grants/expires_unix/granted_unix (serde-defaulted; an
absent field means full/permanent, so pre-grants stores decode unchanged).
effective(fp, now) is the new authorization verb — None when unpaired OR
expired, reserved bits masked on read — while is_paired() stays the
expiry-blind listing verb (both documented in the facade header).

The security-critical change: add() is now name-only for an existing
fingerprint. It used to replace the record, so a guest limited to
Controller · tonight could re-run the pairing ceremony and silently walk
back to full control forever. The authorized grant paths take an explicit
Access (grants + absolute expiry): add_with_access, set_access, the
approve dialog via approve_pending(.., access), and the armed PIN window
via arm_for(.., access) — the ceremony reads armed_access() before the
single-use consume wipes it. A test now fails if add() ever escalates
again (plan §8 risk table).

NativePairing also gains the access watch registry: one watch channel per
fingerprint carrying (masked grants, raw deadline, revoked). Every
mutation — pair, edit, unpair — publishes through it, so a console edit
or unpair reaches every live session within one event (design §5.6);
sessions subscribe() at admission (WP3). Existing call sites pass None
everywhere: enforcement wiring is WP3, the mgmt/console fields are WP6/7.
ABI v21 (per-client access WP10): punktfunk_connection_grants and
punktfunk_connection_access_expires_in read the session's LIVE access
state (Welcome seed, latest-wins over every mid-session AccessUpdate),
and punktfunk_connection_end_reject surfaces the typed rejection a
mid-session close carried, so an access expiry renders its real
sentence instead of the generic host-error one. NEW symbols only; the
Rust-side live slots they read landed with the pf-client-core work.

Swift: PunktfunkConnection wraps the three (grant bits, the derived
AccessLevel labels, and class-gated send funnels — key/pointer/pad/pen/
mic events the grants exclude never leave the device); SessionModel
polls at the 1 Hz stats tick for the chip ("Controller only · ends in
1 h 58 m"), the T−5 m / T−1 m warning toasts, mic + clipboard hiding,
and the live release of an engaged capture on revoke; macOS gates
engage + the cursor grab and the iPad gates pointer lock on the bits;
tvOS states the level as a stats-overlay line instead of a chip. A
full-and-permanent session — every old host — renders exactly today's
UI.
The Android leg of per-client-access.md §7 (WP11). The bridge grows one
poll shim, nativeAccessState -> [grants, remainingSecs, updateSeq]: the
connector already folds every AccessUpdate latest-wins into its live
grants/deadline slots, so Kotlin polls the fold ~1 Hz alongside its
session-ended watchdog instead of holding a blocking event thread; the
seq counter is only how a fresh update (the host's T-5m/T-1m warnings)
is told apart from state the poll would re-read anyway. The countdown is
clamped to >= 1 once a deadline exists — 0 stays the permanent sentinel.

Kotlin gates what can't land rather than capturing it: GamepadRouter's
wire sends fold the GAMEPAD grant into the existing forwarding gate
(slots and the exit/mic/stats chords stay alive — they are local
controls that happen to sit on pad buttons, and the phone-gyro mirror
stands down through the same sendsEnabled read); without POINTER the
touch/stylus gesture layer is never installed, the mouse forwarder goes
inert and never grabs the pointer, and the TV remote can't enter pointer
mode; without KEYBOARD the VK path consumes without sending and the IME
summon (gesture and remote toggle) declines; without MIC no capture
opens — the recording indicator must not announce a mic nobody can hear
— and a mid-session revocation stops a running one; without CLIPBOARD
the sync never starts.

StreamScreen carries the Access chip top-end in the shared pill family
("Controller only · 1 h 58 m left"), composed only when there is
something to say — a full-control permanent session, which is every
session against an old host, looks exactly like today. The expiry
warnings surface as toasts, and a session that dies inside the final
countdown is worded with the shared rejection sentence ("Your access to
this host has expired") — recognized off the countdown because the
generic end-reason byte predates the typed close. ConnectErrors learns
the two new reject tokens (access-expired, launch-not-permitted).

Verified: cargo clippy -D warnings + fmt (host target), gradle
:kit:/:app:compileDebugKotlin, :kit:+:app: unit tests (new
SessionAccessTest pins the bit mirror and the preset labels), and the
release cargo-ndk cross-build of all three ABIs. The gradle
cargoNdkClippy leg could not complete on this machine — the shared disk
filled mid-run (environment, not code; the Rust delta is
target-independent and is covered by the host clippy + the ABI builds).
The connector now carries the session's LIVE access truth: the Welcome advert
seeds NativeClient::access_grants / access_deadline_unix (client-anchored, so
skew never moves the countdown), the control task folds every MSG_ACCESS_UPDATE
in latest-wins before waking next_access_update, and a typed mid-session close
latches as end_reject — an access expiry now ends as "your access to this host
has expired", not "the host ended the session with an error".

pf-client-core surfaces it as SessionEvent::Access { SessionAccess, notice }
(module `access`: derived preset labels, chip text, toast wording — the rules
the Apple/Android ports mirror), gates the mic uplink and clipboard bridge at
spawn on their grants (deny-at-setup, client half), and follows a live MIC edit
by stopping/starting the uplink without a reconnect.

The presenter gates capture on the mask (§7 "not capture what can't land"): no
pointer lock without POINTER, no keyboard grab without KEYBOARD, engage refuses
outright when neither is granted (the hint pill stays down), every wire send
funnels through the host's own classify(), and a live edit flushes what a
removed class still held. The overlay wears the chip — "Controller only · ends
in 1 h 58 m", top-right beside the mic badge, at every stats tier — and the
T−5 m / T−1 m warnings ride the pill slot as toasts. Full-control permanent
(every old host) renders exactly today's look.
Admission consults effective() (an expired record knocks into the pending
list; re-approval is the re-grant), the Welcome advertises the real mask +
remaining lifetime, and a per-session lifecycle task owns the deadline:
wall-clock re-evaluated every lap, AccessUpdate warnings at T−5m/T−1m,
console edits folded into the live Arc<AtomicU32> within one watch event,
and the typed 0x69 close on expiry / expire-now / unpair.

The datagram dispatch classifies every plane against that one atomic before
offer() (one relaxed load per event; per-class counters, one warn per class),
the input thread re-guards the pad-creating arms (deny-at-setup: no GAMEPAD,
no uinput/pad-audio), launch without LAUNCH is a typed 0x6A refusal before
the handshake, and clipboard ANDs the grant into the operator policy — new
CLIP_REASON_NOT_PERMITTED (5), coordinator never starts ungranted.

Events: access.granted / access.changed / access.expired from the facade
choke points and the deadline fire.
The management API is where grants become operable (WP6): the paired-client and
pending-device payloads carry grants/expiry/grant-time plus a derived
access_level preset name, the approve and arm requests take an optional access
choice (expiry RELATIVE in the API, stored absolute), and a new
PATCH /native/clients/{fingerprint} does partial access edits — omitted halves
keep their current value, clear_expiry makes access permanent. Reserved grant
bits are a 400, never silently cleared.
The grants registry serves both paired stores, keyed on fingerprint hex
(design §8): a Moonlight fingerprint with NO record is ungoverned — an
existing pairing keeps full control (back-compat) — while a record that
exists (created via the console) governs exactly as on the native plane,
via the new NativePairing::moonlight_effective (one store snapshot, so a
deletion can't race into reading expired).

nvhttp: /launch and /resume check LAUNCH + expiry beside peer_is_paired
(an expired record fails closed exactly like unpaired); /cancel gates on
expiry only — it is Moonlight's Quit App, owner-restricted already, and
denying a downgraded owner its own quit would only wedge the session.

Control thread: the session's owner_fp resolves to the same mask, folded
per 2 ms tick from the fingerprint's watch channel; every decoded event
passes one mask test against the exhaustive classifier before injection
(deny-at-setup for pads — no GAMEPAD, no uinput node), with per-class
counters, one warn per class, totals at session end. The deadline check
rides the same tick and ends the session through quit_session — the
host-side-ended arm's TERMINATION + disconnect is the whole message,
since GameStream has no AccessUpdate vocabulary (silent enforcement,
accepted by the design).
feat(web): access in one dialog — approve, arm, and the column that counts down (per-client access WP7+WP8)
android / android (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 0s
apple / distribute (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
ab8fa46b66
One component family (sections/Pairing/access.tsx) serves all three grant
moments: the approve dialog (Full + Forever defaults per D1, one-click
'Approve as guest' = Controller only + 4 h per D2/D4, stored-access pre-fill
on the expired-guest re-knock), the arm card, and the paired-row edit sheet
(partial PATCH: extend / expire now / make permanent / remove).

The Access column derives its chip + countdown client-side from expires_unix
on ONE shared interval (no refetch storms), keeps expired rows listed as
'Expired' (D3), gives Moonlight rows an honest 'Full (ungoverned)' chip and
no editor, and renders '—' against hosts older than the fields — access_level
is the presence sentinel, and every new field is read defensively.

35 new strings in en + de; stories for both dialogs, the column matrix
(incl. old-host and Expired), and the edit sheet, on a fixed fixture clock.
enricobuehler added 1 commit 2026-08-15 09:51:12 +00:00
Merge origin/main — the security sweep meets the access branch: ABI re-bumped to 22 (ex10 took 21), the peer gate and the grant mask compose, and the approve dialog states which knock it is
ci / bun-nix (pull_request) Successful in 58s
ci / web (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 1m58s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m7s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 14m14s
android / android (pull_request) Successful in 19m8s
ci / rust-arm64 (pull_request) Successful in 20m19s
ci / rust (pull_request) Failing after 20m24s
97f81a6ea6
enricobuehler merged commit a17571c6bf into main 2026-08-15 09:51:20 +00:00
enricobuehler deleted branch worktree-per-client-access-a0 2026-08-15 09:51:21 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#245