feat(pairing): delegated approval (§8b-1) — approve an unpaired device from the console
ci / web (push) Failing after 40s
ci / rust (push) Successful in 1m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 1m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Successful in 16s

An identified-but-unpaired device that knocks on a pairing-required host is now
held as a pending request the operator approves from the web console — pairing it
with no PIN fetched out of band — instead of a flat reject.

- core: Hello gains an optional trailing device name (len u8 || UTF-8, ≤64,
  same trailing-back-compat pattern as compositor/gamepad/bitrate). client-rs
  --name sends it; the connector sends None (fingerprint-derived label).
- native_pairing: in-memory pending queue (note_pending dedups by fingerprint,
  evicts the least-recently-active past a 32 cap, 10-min TTL); approve_pending
  pins the fingerprint, deny drops it. Names are sanitized (strip control/ANSI/
  bidi — untrusted wire input); add()/remove() roll back in-memory on a persist
  failure; pairing clears any stale pending knock.
- m3: the require_pairing gate records the knock (sanitized label) before
  rejecting; anonymous (certless) clients record nothing.
- mgmt: GET /native/pending, POST /native/pending/{id}/approve (optional {name})
  and /deny; OpenAPI + tests; docs/api/openapi.json regenerated.
- web: a "Waiting for approval" section on the Pairing page (live-poll, Approve/
  Deny, error-surfaced via QueryState); en+de strings.
- Also completes an in-progress NativeClient Sync refactor (receivers behind
  per-plane mutexes) that was left half-applied in the tree.

Adversarially reviewed (4 lenses + 3-vote verify); the confirmed findings are
fixed here. Validated live on the GNOME box: knock (with a wire name, and a
malicious ANSI/bidi name that got neutralized) → pending → approve → the same
identity streams real video. Full workspace tests + clippy + fmt green; web tsc
clean. Roadmap §8b-1 marked done; §8b-2 (peer-push approval) is the client
follow-up. See docs-site pairing page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 19:14:05 +00:00
parent 9758751a4d
commit 99b4de32ee
14 changed files with 1250 additions and 109 deletions
+22 -4
View File
@@ -3,9 +3,10 @@ title: Pairing & Trust
description: How a client and host establish trust — PIN pairing once, pinned reconnects after.
---
punktfunk has no accounts and no cloud. Trust is established directly between a client and a host, on
your network, with a one-time **PIN pairing**. After that, the device reconnects automatically on a
pinned cryptographic identity.
punktfunk has no accounts and no cloud. Trust is established directly between a client and a host,
on your network, with a one-time pairing — either an **approval click in the host's console** or a
**PIN ceremony**. After that, the device reconnects automatically on a pinned cryptographic
identity.
## How it works
@@ -17,7 +18,24 @@ pinned cryptographic identity.
- After pairing, the host stores the client's identity in its allow-list, and the client stores the
host's fingerprint. Reconnects are automatic — no PIN.
## Arming pairing on the host
## Approving a device from the console (no PIN)
The fastest way to admit a new device: just **try to connect** from it. On a pairing-required host,
the attempt shows up in the web console's Pairing page under **Waiting for approval** — with the
device's name and identity fingerprint. Click **Approve** (and optionally give it a label like
"Living Room TV"), and the device is paired on the spot: its next connect goes straight through. No
PIN to read or type.
**Deny** just dismisses the request (the device can knock again later — it's "not now", not a
blocklist). Requests expire on their own after a few minutes.
This works because approval happens on the host's authenticated management surface — only someone
with console access can admit a device.
## Pairing with a PIN
The PIN ceremony is the other path — useful for the *first* device (before the console has admitted
anything) or when you're at the client and the console isn't handy.
Pairing has to be **armed** on the host before a client can pair (so a random device can't pair
itself). Two ways:
+18 -19
View File
@@ -144,7 +144,7 @@ mostly-mechanical port. Recommended start: **Phase 0** — capture an existing m
stack end to end; **Phase 1** wires SudoVDA for the native-resolution output. Deferred only because
it's unbuildable on the Linux dev box; the trait boundaries are already in the right places.
## 8. Pairing & trust hardening *(next)*
## 8. Pairing & trust hardening *(§8a + §8b-1 done; §8b-2 next)*
The unified host + web-console pairing (arm a window → display the host PIN → user enters it on the
client) is built and live. Two changes harden it from "works" to "secure by default":
@@ -154,24 +154,23 @@ client) is built and live. Two changes harden it from "works" to "secure by defa
is via the SPAKE2 PIN ceremony (one online guess, no offline attack) armed from the web console.
Validated live: unpaired → "this host requires pairing", then web-armed PIN → "client trusted".
Deployed to the dev box + Bazzite.
- **Delegated pairing approval** *(next — the ergonomic enabler for "mandatory": pair a device
without fetching the host PIN out of band).* Target flow:
1. Device A is already paired (authenticated) to Host X.
2. The user tries to connect Device B to Host X.
3. Host X surfaces a request: *"Allow Device B to pair with Host X?"*
4. The user approves/denies; on approve, Host X admits Device B — binding B's certificate
fingerprint — with no PIN typed.
Two buildable layers:
- **§8b-1 (host + web — achievable now):** an unpaired B that connects to an approval-enabled host
is held as a **pending request** `{id, name, fingerprint, requested_at}` in `NativePairing`
instead of a flat reject; mgmt gains `GET /native/pending` + `POST /native/pending/{id}/{approve,
deny}`; the web console lists pending requests with Approve/Deny. The **operator approves from
the console** — delegated approval via the management surface.
- **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired
**Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and
replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI
is a client-agent task.
- ✅ **§8b-1 Delegated approval via the console — done (2026-06-12)** *(the ergonomic enabler for
"mandatory": pair a device without fetching the host PIN out of band).* An identified-but-unpaired
device that knocks on a pairing-required host is held as a **pending request** in `NativePairing`
(in-memory, deduped by fingerprint, 32-entry cap, 10-min expiry — a LAN scanner can't grow it,
and an anonymous client with no certificate records nothing). The mgmt API gains
`GET /native/pending` + `POST /native/pending/{id}/approve` (optional `{name}` to relabel) +
`POST /native/pending/{id}/deny`; the web console's Pairing page shows a **Waiting for approval**
section (live-polling) with Approve/Deny — approve pins the fingerprint on the spot, no PIN.
The `Hello` carries an optional trailing **device name** (same back-compat pattern as
compositor/gamepad/bitrate; `client-rs --name` sends it, fingerprint-derived label otherwise) so
the pending list is human-readable. End-to-end tested (knock → pending → approve → same identity
streams) + unit/mgmt tests.
- **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired
**Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and
replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI
is a client-agent task. The Apple connector should also start sending the `Hello` device name
(needs a connect-ABI name parameter; the wire field is already live).
PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online.
+7
View File
@@ -29,6 +29,13 @@ All three appliances advertise over mDNS (`_punktfunk._udp`) and require PIN pai
## Progress log
### 2026-06-12
- **Delegated pairing approval (§8b-1)** — an unpaired device that tries to connect to a
pairing-required host now shows up as a **pending request** in the web console's Pairing page;
one click approves it (optionally relabeling) and pairs its certificate fingerprint — no PIN
fetched out of band. New mgmt endpoints (`/native/pending` + approve/deny), an in-memory pending
queue in `NativePairing` (fp-deduped, capped, 10-min expiry), and an optional **device name** in
the `Hello` (back-compat trailing field; `client-rs --name` sends it). End-to-end tested.
§8b-2 (approve from a paired device's own app) is the client-side follow-up.
- **CI + deployment landed** (see the [CI & Docker](/docs/ci) guide). Gitea Actions, three
workflows: Rust workspace checks inside the new `punktfunk-rust-ci` builder image (Ubuntu 26.04,
full link-dep stack incl. a libcuda stub — 141/141 tests green in-container), web + docs-site