diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs
index 32aecff..c795eb5 100644
--- a/crates/punktfunk-client-rs/src/main.rs
+++ b/crates/punktfunk-client-rs/src/main.rs
@@ -371,6 +371,9 @@ async fn session(args: Args) -> Result<()> {
compositor: args.compositor,
gamepad: args.gamepad,
bitrate_kbps: args.bitrate_kbps,
+ // `--name` (also the pairing label) — shown in the host's pending-approval list when
+ // this client knocks on a pairing-required host.
+ name: Some(args.name.clone()),
}
.encode(),
)
diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs
index e80dda0..d86a808 100644
--- a/crates/punktfunk-core/src/client.rs
+++ b/crates/punktfunk-core/src/client.rs
@@ -108,11 +108,15 @@ pub struct AudioPacket {
}
pub struct NativeClient {
- frames: Receiver,
- audio: Receiver,
- rumble: Receiver<(u16, u16, u16)>,
+ // Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust
+ // embedders can share one `Arc` across their plane threads (the same
+ // one-thread-per-plane contract the C ABI documents — the lock is uncontended there,
+ // and two threads racing one plane now serialize instead of being undefined).
+ frames: Mutex>,
+ audio: Mutex>,
+ rumble: Mutex>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
- hidout: Receiver,
+ hidout: Mutex>,
input_tx: tokio::sync::mpsc::UnboundedSender,
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec)>,
@@ -234,10 +238,10 @@ impl NativeClient {
};
*mode_slot.lock().unwrap() = negotiated;
Ok(NativeClient {
- frames: frame_rx,
- audio: audio_rx,
- rumble: rumble_rx,
- hidout: hidout_rx,
+ frames: Mutex::new(frame_rx),
+ audio: Mutex::new(audio_rx),
+ rumble: Mutex::new(rumble_rx),
+ hidout: Mutex::new(hidout_rx),
input_tx,
mic_tx,
rich_input_tx,
@@ -419,7 +423,7 @@ impl NativeClient {
/// (`&self` here supports the cross-plane sharing; a plane's queue is still
/// single-consumer by contract).
pub fn next_frame(&self, timeout: Duration) -> Result {
- match self.frames.recv_timeout(timeout) {
+ match self.frames.lock().unwrap().recv_timeout(timeout) {
Ok(f) => Ok(f),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -430,7 +434,7 @@ impl NativeClient {
/// [`PunktfunkError::Closed`] once the session ended. Drain on a dedicated audio thread —
/// packets arrive every 5 ms.
pub fn next_audio(&self, timeout: Duration) -> Result {
- match self.audio.recv_timeout(timeout) {
+ match self.audio.lock().unwrap().recv_timeout(timeout) {
Ok(p) => Ok(p),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -440,7 +444,7 @@ impl NativeClient {
/// Pull the next rumble update `(pad, low, high)`; same semantics as
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop.
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
- match self.rumble.recv_timeout(timeout) {
+ match self.rumble.lock().unwrap().recv_timeout(timeout) {
Ok(r) => Ok(r),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -452,7 +456,7 @@ impl NativeClient {
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
/// `GCDualSenseAdaptiveTrigger` API). Only the DualSense host backend emits these.
pub fn next_hidout(&self, timeout: Duration) -> Result {
- match self.hidout.recv_timeout(timeout) {
+ match self.hidout.lock().unwrap().recv_timeout(timeout) {
Ok(h) => Ok(h),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
@@ -579,6 +583,9 @@ async fn worker_main(args: WorkerArgs) {
compositor,
gamepad,
bitrate_kbps,
+ // No device name yet: the connect ABI has no name parameter (pairing does). The
+ // host falls back to a fingerprint-derived label in its pending-approval list.
+ name: None,
}
.encode(),
)
diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs
index a4d0757..fe0df30 100644
--- a/crates/punktfunk-core/src/quic.rs
+++ b/crates/punktfunk-core/src/quic.rs
@@ -38,7 +38,7 @@ pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
/// `client → host`: open the session, requesting a display mode (the host creates its
/// virtual output at exactly this size/refresh — native resolution end to end).
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hello {
pub abi_version: u32,
pub mode: Mode,
@@ -57,8 +57,17 @@ pub struct Hello {
/// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form —
/// omitted by older clients (decodes to `0`, i.e. host default).
pub bitrate_kbps: u32,
+ /// Human-readable device name ("Enrico's MacBook"), shown by the host when this device knocks
+ /// on a pairing-required host (the delegated-approval pending list) and stored on approval.
+ /// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by
+ /// older clients (decodes to `None`; the host falls back to a fingerprint-derived label).
+ pub name: Option,
}
+/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
+/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
+pub const HELLO_NAME_MAX: usize = 64;
+
/// `host → client`: the complete session offer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Welcome {
@@ -463,6 +472,22 @@ impl Hello {
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26
+ if let Some(name) = &self.name {
+ // Appended at offset 26: len u8 || UTF-8. This is the LAST trailing field — `None`
+ // emits nothing (so a no-name Hello is byte-identical to the bitrate-era form), which
+ // means a *future* field can't simply follow `name` at a fixed offset; it would need
+ // its own presence flag. Truncate to a char boundary within HELLO_NAME_MAX.
+ let mut n = name.as_str();
+ while n.len() > HELLO_NAME_MAX {
+ let mut cut = HELLO_NAME_MAX;
+ while !n.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ n = &n[..cut];
+ }
+ b.push(n.len() as u8);
+ b.extend_from_slice(n.as_bytes());
+ }
b
}
@@ -492,6 +517,17 @@ impl Hello {
.get(22..26)
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0),
+ // Optional trailing device name: len u8 || UTF-8. Absent / oversized / non-UTF-8 →
+ // `None` (never fail the handshake over a label).
+ name: b.get(26).and_then(|&len| {
+ let len = len as usize;
+ if len == 0 || len > HELLO_NAME_MAX {
+ return None;
+ }
+ b.get(27..27 + len)
+ .and_then(|s| std::str::from_utf8(s).ok())
+ .map(String::from)
+ }),
})
}
}
@@ -1406,6 +1442,7 @@ mod tests {
compositor: CompositorPref::Kwin,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 25_000,
+ name: Some("Test Device".into()),
};
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start {
@@ -1470,6 +1507,7 @@ mod tests {
compositor: CompositorPref::Mutter,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 80_000,
+ name: None,
};
let enc = h.encode();
assert_eq!(enc.len(), 26);
@@ -1526,6 +1564,51 @@ mod tests {
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
}
+ #[test]
+ fn hello_name_roundtrip_and_back_compat() {
+ let base = Hello {
+ abi_version: 2,
+ mode: Mode {
+ width: 1280,
+ height: 720,
+ refresh_hz: 60,
+ },
+ compositor: CompositorPref::Auto,
+ gamepad: GamepadPref::Auto,
+ bitrate_kbps: 0,
+ name: Some("Enrico's MacBook".into()),
+ };
+ let enc = base.encode();
+ assert_eq!(
+ Hello::decode(&enc).unwrap().name.as_deref(),
+ Some("Enrico's MacBook")
+ );
+ // A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
+ // host reading a bitrate-era Hello decodes name = None.
+ assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
+ // No name → wire form is byte-identical to the bitrate-era message (26 bytes).
+ let unnamed = Hello {
+ name: None,
+ ..base.clone()
+ };
+ assert_eq!(unnamed.encode().len(), 26);
+ // Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
+ let long = Hello {
+ name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
+ ..base.clone()
+ };
+ let dec = Hello::decode(&long.encode()).unwrap();
+ let n = dec.name.expect("truncated name still present");
+ assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
+ // A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
+ let mut bad_len = unnamed.encode();
+ bad_len.push(40); // claims 40 name bytes, none follow
+ assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
+ let mut bad_utf8 = unnamed.encode();
+ bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
+ assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
+ }
+
#[test]
fn reconfigure_roundtrip() {
let rq = Reconfigure {
@@ -1632,6 +1715,7 @@ mod tests {
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
+ name: None,
}
.encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs
index 45dc987..37e2a28 100644
--- a/crates/punktfunk-host/src/m3.rs
+++ b/crates/punktfunk-host/src/m3.rs
@@ -454,13 +454,34 @@ async fn serve_session(
punktfunk_core::ABI_VERSION
);
if opts.require_pairing {
- let known = endpoint::peer_fingerprint(&conn)
- .map(|fp| np.is_paired(&fingerprint_hex(&fp)))
+ let fp = endpoint::peer_fingerprint(&conn);
+ let known = fp
+ .as_ref()
+ .map(|fp| np.is_paired(&fingerprint_hex(fp)))
.unwrap_or(false);
- anyhow::ensure!(
- known,
- "unpaired client rejected (this host requires pairing — run the PIN ceremony first)"
- );
+ if !known {
+ // Delegated approval (§8b-1): an identified-but-unpaired knock becomes a pending
+ // request the operator can approve from the console — no PIN fetched out of band.
+ // The label is the client's Hello name, else fingerprint-derived. An anonymous
+ // client (no certificate) has no identity to approve, so nothing is recorded.
+ if let Some(fp) = &fp {
+ let fp_hex = fingerprint_hex(fp);
+ // Sanitize the wire-supplied name before it reaches the log (untrusted: an
+ // unpaired device could embed terminal escapes / bidi overrides); note_pending
+ // stores the same sanitized form and derives a fingerprint label when empty.
+ let label = crate::native_pairing::sanitize_device_name(
+ hello.name.as_deref().unwrap_or(""),
+ &fp_hex,
+ );
+ tracing::info!(name = %label, fingerprint = %fp_hex,
+ "unpaired device knocked — held for approval in the console");
+ np.note_pending(&label, &fp_hex);
+ }
+ anyhow::bail!(
+ "unpaired client rejected (this host requires pairing — approve the device \
+ in the console, or run the PIN ceremony)"
+ );
+ }
}
crate::encode::validate_dimensions(
crate::encode::Codec::H265,
@@ -2206,6 +2227,100 @@ mod tests {
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
}
+ /// Delegated approval (§8b-1) end to end in-process: an identified-but-unpaired client's
+ /// knock on a pairing-required host is held as a pending request (fingerprint-derived label —
+ /// the connector sends no Hello name); approving it pairs the fingerprint, and the same
+ /// identity then gets a session with no PIN ceremony.
+ #[test]
+ fn delegated_approval_admits_after_knock() {
+ use punktfunk_core::client::NativeClient;
+ use punktfunk_core::quic::endpoint;
+
+ let store =
+ std::env::temp_dir().join(format!("pf-approval-test-{}.json", std::process::id()));
+ let _ = std::fs::remove_file(&store);
+ let np = Arc::new(NativePairing::load_with(Some(store.clone()), None, false).unwrap());
+ let np_host = np.clone();
+ let host = std::thread::spawn(move || {
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .enable_all()
+ .build()
+ .unwrap();
+ rt.block_on(serve(
+ M3Options {
+ port: 19779,
+ source: M3Source::Synthetic,
+ seconds: 0,
+ frames: 25,
+ max_sessions: 2, // the knock + the post-approval session
+ max_concurrent: 1,
+ require_pairing: true,
+ allow_pairing: false,
+ pairing_pin: None,
+ paired_store: None, // unused: the shared `np` IS the store handle
+ },
+ np_host,
+ ))
+ });
+ std::thread::sleep(std::time::Duration::from_millis(500));
+ let timeout = std::time::Duration::from_secs(10);
+ let (cert, key) = endpoint::generate_identity().unwrap();
+ let mode = punktfunk_core::Mode {
+ width: 1280,
+ height: 720,
+ refresh_hz: 60,
+ };
+
+ // 1: the knock — an identified-but-unpaired connect is rejected, but lands in pending.
+ assert!(
+ NativeClient::connect(
+ "127.0.0.1",
+ 19779,
+ mode,
+ CompositorPref::Auto,
+ GamepadPref::Auto,
+ 0,
+ None,
+ Some((cert.clone(), key.clone())),
+ timeout
+ )
+ .is_err(),
+ "unpaired knock must still be rejected"
+ );
+ let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
+ let pend = np.pending();
+ assert_eq!(pend.len(), 1, "the knock must be held for approval");
+ assert_eq!(pend[0].fingerprint, expected_fp);
+ assert!(
+ pend[0].name.starts_with("device "),
+ "no Hello name → fingerprint-derived label, got {:?}",
+ pend[0].name
+ );
+
+ // 2: approve (with an operator label) → the same identity now gets a session, no PIN.
+ let approved = np
+ .approve_pending(pend[0].id, Some("Approved Device"))
+ .unwrap()
+ .expect("pending id must approve");
+ assert_eq!(approved.fingerprint, expected_fp);
+ let client = NativeClient::connect(
+ "127.0.0.1",
+ 19779,
+ mode,
+ CompositorPref::Auto,
+ GamepadPref::Auto,
+ 0,
+ None,
+ Some((cert, key)),
+ timeout,
+ )
+ .expect("approved identity gets a session");
+ drop(client);
+ let _ = std::fs::remove_file(&store);
+ host.join().unwrap().unwrap();
+ }
+
/// The PIN pairing ceremony + the --require-pairing gate, end to end in-process:
/// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired
/// identity gets a session on a pairing-required host; an anonymous client does not.
diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs
index 42c3789..f4ac8de 100644
--- a/crates/punktfunk-host/src/mgmt.rs
+++ b/crates/punktfunk-host/src/mgmt.rs
@@ -145,6 +145,9 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) {
.routes(routes!(disarm_native_pairing))
.routes(routes!(list_native_clients))
.routes(routes!(unpair_native_client))
+ .routes(routes!(list_pending_devices))
+ .routes(routes!(approve_pending_device))
+ .routes(routes!(deny_pending_device))
.routes(routes!(stop_session))
.routes(routes!(request_idr)),
)
@@ -379,6 +382,29 @@ struct NativeClient {
fingerprint: String,
}
+/// An unpaired device that tried to connect while the host requires pairing — awaiting
+/// **delegated approval** (approve it here instead of fetching the host PIN out of band).
+#[derive(Serialize, ToSchema)]
+struct PendingDevice {
+ /// Id to address approve/deny (per-process; entries expire after ~10 minutes).
+ id: u32,
+ /// Best-effort device label (the client's own name, else fingerprint-derived).
+ #[schema(example = "Enrico's MacBook")]
+ name: String,
+ /// Hex SHA-256 of the device's certificate — what approval pins.
+ fingerprint: String,
+ /// Seconds since the device last knocked.
+ age_secs: u64,
+}
+
+/// Approve-pending-device request body. Send `{}` to keep the device's own name.
+#[derive(Deserialize, ToSchema)]
+struct ApprovePending {
+ /// Operator-chosen label for the device (defaults to the name it knocked with).
+ #[schema(example = "Living Room TV")]
+ name: Option,
+}
+
/// Error envelope for every non-2xx response.
#[derive(Serialize, Deserialize, ToSchema)]
struct ApiError {
@@ -885,6 +911,116 @@ async fn unpair_native_client(
}
}
+/// List devices awaiting pairing approval
+///
+/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
+/// it without a PIN (delegated approval); entries expire after ~10 minutes.
+#[utoipa::path(
+ get,
+ path = "/native/pending",
+ tag = "native",
+ operation_id = "listPendingDevices",
+ responses(
+ (status = OK, description = "Devices awaiting approval (empty when none, or when the \
+ native host is not enabled)", body = Vec),
+ (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
+ )
+)]
+async fn list_pending_devices(State(st): State>) -> Json> {
+ let pending = st
+ .native
+ .as_ref()
+ .map(|np| np.pending())
+ .unwrap_or_default();
+ Json(
+ pending
+ .into_iter()
+ .map(|p| PendingDevice {
+ id: p.id,
+ name: p.name,
+ fingerprint: p.fingerprint,
+ age_secs: p.age_secs,
+ })
+ .collect(),
+ )
+}
+
+/// Approve a pending device
+///
+/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
+/// relabel it via the body; send `{}` to keep the name it knocked with.
+#[utoipa::path(
+ post,
+ path = "/native/pending/{id}/approve",
+ tag = "native",
+ operation_id = "approvePendingDevice",
+ params(("id" = u32, Path, description = "Pending-request id from the pending list")),
+ request_body = ApprovePending,
+ responses(
+ (status = OK, description = "Device paired", body = NativeClient),
+ (status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
+ (status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
+ (status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
+ (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
+ )
+)]
+async fn approve_pending_device(
+ State(st): State>,
+ Path(id): Path,
+ ApiJson(req): ApiJson,
+) -> Response {
+ let Some(np) = &st.native else {
+ return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
+ };
+ match np.approve_pending(id, req.name.as_deref()) {
+ Ok(Some(client)) => {
+ tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
+ "management API: pending device approved (delegated pairing)");
+ Json(NativeClient {
+ name: client.name,
+ fingerprint: client.fingerprint,
+ })
+ .into_response()
+ }
+ Ok(None) => api_error(
+ StatusCode::NOT_FOUND,
+ "no pending request with that id (it may have expired — have the device retry)",
+ ),
+ Err(e) => api_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ &format!("could not persist trust store: {e}"),
+ ),
+ }
+}
+
+/// Deny a pending device
+///
+/// Drops the request. Not a blocklist — the device's next attempt knocks again.
+#[utoipa::path(
+ post,
+ path = "/native/pending/{id}/deny",
+ tag = "native",
+ operation_id = "denyPendingDevice",
+ params(("id" = u32, Path, description = "Pending-request id from the pending list")),
+ responses(
+ (status = NO_CONTENT, description = "Request dropped"),
+ (status = NOT_FOUND, description = "No pending request with that id", body = ApiError),
+ (status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
+ (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
+ )
+)]
+async fn deny_pending_device(State(st): State>, Path(id): Path) -> Response {
+ let Some(np) = &st.native else {
+ return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
+ };
+ if np.deny_pending(id) {
+ tracing::info!(id, "management API: pending device denied");
+ StatusCode::NO_CONTENT.into_response()
+ } else {
+ api_error(StatusCode::NOT_FOUND, "no pending request with that id")
+ }
+}
+
/// Stop the active session
///
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
@@ -1344,6 +1480,77 @@ mod tests {
assert_eq!(b["armed"], false);
}
+ #[tokio::test]
+ async fn pending_devices_approve_and_deny() {
+ let np = Arc::new(
+ crate::native_pairing::NativePairing::load_with(
+ Some(
+ std::env::temp_dir()
+ .join(format!("pf-mgmt-pending-{}.json", std::process::id())),
+ ),
+ None,
+ false,
+ )
+ .unwrap(),
+ );
+ let app = test_app_native(test_state(), np.clone());
+
+ // Empty queue.
+ let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
+ assert_eq!(s, StatusCode::OK);
+ assert_eq!(b.as_array().unwrap().len(), 0);
+
+ // Two devices knock (what the QUIC gate records); they appear in the list.
+ np.note_pending("Enrico's MacBook", "aa11");
+ np.note_pending("device bb22cc33", "bb22");
+ let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
+ assert_eq!(b.as_array().unwrap().len(), 2);
+ assert_eq!(b[0]["name"], "Enrico's MacBook");
+ let approve_id = b[0]["id"].as_u64().unwrap();
+ let deny_id = b[1]["id"].as_u64().unwrap();
+
+ // Approve the first with an operator label → paired under that name, gone from pending.
+ let (s, b) = send(
+ &app,
+ post_json(
+ &format!("/api/v1/native/pending/{approve_id}/approve"),
+ serde_json::json!({"name": "Office MacBook"}),
+ ),
+ )
+ .await;
+ assert_eq!(s, StatusCode::OK);
+ assert_eq!(b["name"], "Office MacBook");
+ assert_eq!(b["fingerprint"], "aa11");
+ assert!(np.is_paired("AA11"), "approval pins the fingerprint");
+
+ // Deny the second → dropped, not paired; a re-deny is 404.
+ let deny = post_json(
+ &format!("/api/v1/native/pending/{deny_id}/deny"),
+ serde_json::json!({}),
+ );
+ assert_eq!(send(&app, deny).await.0, StatusCode::NO_CONTENT);
+ assert!(!np.is_paired("bb22"));
+ let (s, _) = send(
+ &app,
+ post_json(
+ &format!("/api/v1/native/pending/{deny_id}/deny"),
+ serde_json::json!({}),
+ ),
+ )
+ .await;
+ assert_eq!(s, StatusCode::NOT_FOUND);
+
+ // Queue is empty again; approving a stale id is 404 (keep `{}` = device's own name).
+ let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
+ assert_eq!(b.as_array().unwrap().len(), 0);
+ let (s, _) = send(
+ &app,
+ post_json("/api/v1/native/pending/123/approve", serde_json::json!({})),
+ )
+ .await;
+ assert_eq!(s, StatusCode::NOT_FOUND);
+ }
+
#[tokio::test]
async fn native_endpoints_report_disabled_without_native_host() {
let app = test_app(test_state(), None);
@@ -1357,5 +1564,22 @@ mod tests {
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
+ // Pending list reads as an empty array (like /native/clients), not a 503.
+ let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
+ assert_eq!(s, StatusCode::OK);
+ assert_eq!(b.as_array().unwrap().len(), 0);
+ // Approve/deny without a native host are 503.
+ let (s, _) = send(
+ &app,
+ post_json("/api/v1/native/pending/0/approve", serde_json::json!({})),
+ )
+ .await;
+ assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
+ let (s, _) = send(
+ &app,
+ post_json("/api/v1/native/pending/0/deny", serde_json::json!({})),
+ )
+ .await;
+ assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
}
}
diff --git a/crates/punktfunk-host/src/native_pairing.rs b/crates/punktfunk-host/src/native_pairing.rs
index 3403aea..4fcbdd7 100644
--- a/crates/punktfunk-host/src/native_pairing.rs
+++ b/crates/punktfunk-host/src/native_pairing.rs
@@ -47,10 +47,47 @@ struct Armed {
expires_at: Option,
}
-/// Shared native-pairing state: the arming PIN window + the persistent trust store.
+/// An unpaired (but identified) device that knocked on a pairing-required host — held for
+/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
+/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
+/// again), and they expire after [`PENDING_TTL`].
+struct Pending {
+ id: u32,
+ name: String,
+ fp_hex: String,
+ requested_at: Instant,
+}
+
+#[derive(Default)]
+struct PendingState {
+ next_id: u32,
+ items: Vec,
+}
+
+/// A pending-approval snapshot for the management API / web console.
+pub struct PendingRequest {
+ /// Per-process id used to address approve/deny (stable for the entry's lifetime).
+ pub id: u32,
+ /// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
+ pub name: String,
+ /// Hex SHA-256 of the knocking client's certificate — what approval pins.
+ pub fingerprint: String,
+ /// Seconds since the (most recent) knock.
+ pub age_secs: u64,
+}
+
+/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
+/// approvable days later when the operator no longer remembers the context).
+const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
+/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
+const PENDING_CAP: usize = 32;
+
+/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
+/// pending-approval queue.
pub struct NativePairing {
arm: Mutex,
paired: Mutex,
+ pending: Mutex,
}
/// A snapshot for the management API / web console.
@@ -92,6 +129,48 @@ fn random_pin() -> String {
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
}
+/// Sanitize a client-supplied device name before it's stored, listed, or logged. The name comes
+/// straight off the wire (the `Hello`/`PairRequest` of an *unpaired* device), so it's untrusted: a
+/// hostile LAN device could embed terminal escapes / control characters (log + console injection) or
+/// bidi overrides (`U+202E` etc.) to make a malicious device *look* like a trusted one in the
+/// approval UI. Strip C0/C1 controls and Unicode bidi/format controls, collapse whitespace, trim, and
+/// cap the length; an empty/all-control name falls back to a fingerprint-derived label.
+pub(crate) fn sanitize_device_name(name: &str, fp_hex: &str) -> String {
+ let cleaned: String = name
+ .chars()
+ .map(|c| if c == '\t' || c == '\n' { ' ' } else { c })
+ .filter(|&c| {
+ !c.is_control()
+ // Bidi/format controls that could spoof or reorder the displayed name.
+ && !('\u{202A}'..='\u{202E}').contains(&c) // LRE..RLO/PDF
+ && !('\u{2066}'..='\u{2069}').contains(&c) // LRI..PDI
+ && c != '\u{200E}' // LRM
+ && c != '\u{200F}' // RLM
+ && c != '\u{061C}' // ALM
+ && c != '\u{FEFF}' // BOM / zero-width no-break space
+ })
+ .collect();
+ // Collapse internal whitespace runs, trim, cap at the wire limit.
+ let collapsed = cleaned.split_whitespace().collect::>().join(" ");
+ let mut trimmed = collapsed.as_str();
+ while trimmed.len() > NAME_MAX {
+ let mut cut = NAME_MAX;
+ while !trimmed.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ trimmed = &trimmed[..cut];
+ }
+ let trimmed = trimmed.trim();
+ if trimmed.is_empty() {
+ format!("device {}", &fp_hex[..8.min(fp_hex.len())])
+ } else {
+ trimmed.to_string()
+ }
+}
+
+/// Max stored device-name length (matches the `Hello` wire cap, `quic::HELLO_NAME_MAX`).
+const NAME_MAX: usize = 64;
+
impl NativePairing {
/// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start`
/// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin`
@@ -117,6 +196,7 @@ impl NativePairing {
Ok(NativePairing {
arm: Mutex::new(arm),
paired: Mutex::new(PairedState { path, clients }),
+ pending: Mutex::new(PendingState::default()),
})
}
@@ -172,15 +252,33 @@ impl NativePairing {
self.paired.lock().unwrap().clients.contains(fp_hex)
}
- /// Record a successful pairing (re-pairing the same fingerprint just updates the name).
+ /// Record a successful pairing (re-pairing the same fingerprint just updates the name —
+ /// matched case-insensitively, like every other fingerprint comparison here). The name is
+ /// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
+ /// diverges from disk. Also clears any pending knock for this fingerprint (it's now paired).
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
- let mut p = self.paired.lock().unwrap();
- p.clients.clients.retain(|c| c.fingerprint != fp_hex);
- p.clients.clients.push(PairedClient {
- name: name.to_string(),
- fingerprint: fp_hex.to_string(),
- });
- save(&p)
+ let name = sanitize_device_name(name, fp_hex);
+ {
+ let mut p = self.paired.lock().unwrap();
+ let snapshot = p.clients.clients.clone(); // restore on a failed save
+ p.clients
+ .clients
+ .retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
+ p.clients.clients.push(PairedClient {
+ name,
+ fingerprint: fp_hex.to_string(),
+ });
+ if let Err(e) = save(&p) {
+ p.clients.clients = snapshot;
+ return Err(e);
+ }
+ }
+ // A device that knocked and is now paired shouldn't linger in the approval list.
+ let mut pending = self.pending.lock().unwrap();
+ pending
+ .items
+ .retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
+ Ok(())
}
/// The paired clients (for the management API's device list).
@@ -188,19 +286,122 @@ impl NativePairing {
self.paired.lock().unwrap().clients.clients.clone()
}
- /// Remove a paired client by fingerprint. Returns whether one was removed.
+ /// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
+ /// failure the in-memory store is rolled back (it never diverges from disk).
pub fn remove(&self, fp_hex: &str) -> Result {
let mut p = self.paired.lock().unwrap();
let before = p.clients.clients.len();
+ let snapshot = p.clients.clients.clone();
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
let removed = p.clients.clients.len() != before;
if removed {
- save(&p)?;
+ if let Err(e) = save(&p) {
+ p.clients.clients = snapshot;
+ return Err(e);
+ }
}
Ok(removed)
}
+
+ // -- Delegated approval (roadmap §8b-1) --------------------------------
+
+ /// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]).
+ fn expire_pending(pending: &mut PendingState) {
+ pending
+ .items
+ .retain(|p| p.requested_at.elapsed() < PENDING_TTL);
+ }
+
+ /// Record an unpaired device's knock for delegated approval. Re-knocks from the same
+ /// fingerprint refresh the existing entry in place (same id; a connect-retry loop must not spam
+ /// the list); a fresh fingerprint gets a new id, evicting the **least-recently-active** entry
+ /// past [`PENDING_CAP`]. The name is sanitized (untrusted; see [`sanitize_device_name`]).
+ pub fn note_pending(&self, name: &str, fp_hex: &str) {
+ let name = sanitize_device_name(name, fp_hex);
+ let mut pending = self.pending.lock().unwrap();
+ Self::expire_pending(&mut pending);
+ if let Some(p) = pending
+ .items
+ .iter_mut()
+ .find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
+ {
+ p.requested_at = Instant::now();
+ p.name = name;
+ return;
+ }
+ if pending.items.len() >= PENDING_CAP {
+ // Evict the least-recently-active entry. NOT index 0: the in-place refresh above means
+ // Vec order no longer tracks recency, so pick the minimum `requested_at` explicitly.
+ if let Some(at) = pending
+ .items
+ .iter()
+ .enumerate()
+ .min_by_key(|(_, p)| p.requested_at)
+ .map(|(i, _)| i)
+ {
+ pending.items.remove(at);
+ }
+ }
+ let id = pending.next_id;
+ pending.next_id = pending.next_id.wrapping_add(1);
+ pending.items.push(Pending {
+ id,
+ name,
+ fp_hex: fp_hex.to_string(),
+ requested_at: Instant::now(),
+ });
+ }
+
+ /// The devices currently awaiting approval (for the management API).
+ pub fn pending(&self) -> Vec {
+ let mut pending = self.pending.lock().unwrap();
+ Self::expire_pending(&mut pending);
+ pending
+ .items
+ .iter()
+ .map(|p| PendingRequest {
+ id: p.id,
+ name: p.name.clone(),
+ fingerprint: p.fp_hex.clone(),
+ age_secs: p.requested_at.elapsed().as_secs(),
+ })
+ .collect()
+ }
+
+ /// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
+ /// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
+ /// (or expired) id.
+ pub fn approve_pending(
+ &self,
+ id: u32,
+ name_override: Option<&str>,
+ ) -> Result
)}
- {submit.isError && {m.pairing_failed()}
}
+ {submit.isError && (
+ {m.pairing_failed()}
+ )}
)}
- )
+ );
}