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
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:
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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");
|
||||
|
||||
Reference in New Issue
Block a user