Skip to main content

konjure_lang/live/
pairing.rs

1//! Camera-readable join links for the browser and native live-session hosts.
2//!
3//! QR codes carry an expiring participant invitation, never a room credential.
4//! Parsing does not join a room or grant permission for plaintext transport.
5//! The host must still validate admission and the user must opt into local HTTP.
6//!
7//! ```
8//! use konjure_lang::live::pairing::{PairingLink, qr_svg};
9//! let url = "https://kreate.alkem.dev/#join=0123456789&relay=https%3A%2F%2Flive.example";
10//! let link = PairingLink::parse(url)?;
11//! assert_eq!(link.relay, "https://live.example");
12//! assert!(!link.allow_plain);
13//! assert!(qr_svg(url)?.contains("<svg"));
14//! # Ok::<(), konjure_lang::live::pairing::PairingError>(())
15//! ```
16
17use qrcode::{EcLevel, QrCode, render::svg};
18use serde::Serialize;
19use std::{error::Error, fmt};
20use url::{Host, Url};
21
22/// Maximum encoded invitation length; bounds parsing and QR density.
23pub const MAX_LINK_BYTES: usize = 1024;
24/// Maximum frame dimension accepted by the optional native decoder.
25pub const MAX_SCAN_DIMENSION: usize = 960;
26
27/// Validated participant invitation. This contains no owner or session token.
28#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
29#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
30pub struct PairingLink {
31    /// HTTP(S) relay origin; paths, credentials, queries and fragments are rejected.
32    pub relay: String,
33    /// Ten hexadecimal digits identifying a short-lived invitation.
34    pub code: String,
35    /// The invitation uses local HTTP and requires an explicit user opt-in.
36    /// This flag describes the link; it does not itself authorize the transport.
37    pub allow_plain: bool,
38}
39
40/// Camera-space QR candidate with an optional checked invitation. Corner order
41/// is top-left, top-right, bottom-right, bottom-left in normalized upright image
42/// coordinates. These are image observations, not world or eye calibration.
43#[derive(Clone, Debug, PartialEq, Serialize)]
44pub struct QrObservation {
45    /// Four corners of the detected code in the supplied upright frame.
46    pub bounds: [[f32; 2]; 4],
47    /// Present only after decoding and validating a Konjure participant invitation.
48    pub pairing: Option<PairingLink>,
49}
50
51/// A malformed invitation or frame, without echoing camera payloads or credentials.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct PairingError(&'static str);
54
55impl fmt::Display for PairingError {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        formatter.write_str(self.0)
58    }
59}
60impl Error for PairingError {}
61
62fn local_host(url: &Url) -> bool {
63    match url.host() {
64        Some(Host::Ipv4(ip)) => ip.is_private() || ip.is_loopback() || ip.is_link_local(),
65        Some(Host::Ipv6(ip)) => {
66            ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local()
67        }
68        Some(Host::Domain("localhost")) => true,
69        _ => false,
70    }
71}
72
73fn web_url(source: &str) -> Result<Url, PairingError> {
74    if source.chars().any(char::is_whitespace) || source.contains('\\') {
75        return Err(PairingError("Invalid characters in pairing URL"));
76    }
77    let url = Url::parse(source).map_err(|_| PairingError("Invalid pairing URL"))?;
78    if !matches!(url.scheme(), "https" | "http")
79        || url.host().is_none()
80        || !url.username().is_empty()
81        || url.password().is_some()
82        || url.port() == Some(0)
83        || url.query().is_some()
84    {
85        return Err(PairingError(
86            "Pairing requires an HTTP(S) URL without credentials or query",
87        ));
88    }
89    if url.scheme() == "http" && !local_host(&url) {
90        return Err(PairingError(
91            "HTTP pairing is limited to local network addresses",
92        ));
93    }
94    Ok(url)
95}
96
97impl PairingLink {
98    /// Parses a standalone app, Kreate or retained demo `#join=…&relay=…` invitation.
99    /// Rejects ambiguous, unrelated, oversized and credential-bearing QR payloads.
100    pub fn parse(source: &str) -> Result<Self, PairingError> {
101        if source.len() > MAX_LINK_BYTES {
102            return Err(PairingError("Pairing URL exceeds 1024 bytes"));
103        }
104        let url = web_url(source)?;
105        if url.path() != "/"
106            && !url.path().ends_with("/playground/")
107            && !url.path().ends_with("/kreate/")
108        {
109            return Err(PairingError("This is not a Konjure invitation"));
110        }
111        let mut code = None;
112        let mut relay = None;
113        for (key, value) in url::form_urlencoded::parse(url.fragment().unwrap_or("").as_bytes()) {
114            match key.as_ref() {
115                "join" if code.is_none() => code = Some(value.into_owned()),
116                "relay" if relay.is_none() => relay = Some(value.into_owned()),
117                _ => return Err(PairingError("Unknown or repeated pairing field")),
118            }
119        }
120        let code = code.ok_or(PairingError("Missing pairing code"))?;
121        if code.len() != 10 || !code.bytes().all(|byte| byte.is_ascii_hexdigit()) {
122            return Err(PairingError(
123                "Pairing code must contain ten hexadecimal digits",
124            ));
125        }
126        let relay = web_url(&relay.ok_or(PairingError("Missing live relay"))?)?;
127        if relay.path() != "/" || relay.fragment().is_some() {
128            return Err(PairingError("Live relay must be a server origin"));
129        }
130        if url.scheme() == "https" && relay.scheme() != "https" {
131            return Err(PairingError(
132                "An HTTPS website requires an HTTPS live relay",
133            ));
134        }
135        Ok(Self {
136            relay: relay.origin().ascii_serialization(),
137            code: code.to_ascii_lowercase(),
138            allow_plain: relay.scheme() == "http",
139        })
140    }
141}
142
143/// Encodes a checked join URL as an opaque black/white SVG with a four-module
144/// quiet zone. Rendering stays in Rust; browsers only display the returned image.
145pub fn qr_svg(source: &str) -> Result<String, PairingError> {
146    PairingLink::parse(source)?;
147    let qr = QrCode::with_error_correction_level(source.as_bytes(), EcLevel::M)
148        .map_err(|_| PairingError("Pairing URL cannot be encoded as a QR code"))?;
149    Ok(qr
150        .render::<svg::Color<'_>>()
151        .quiet_zone(true)
152        .min_dimensions(256, 256)
153        .dark_color(svg::Color("#000000"))
154        .light_color(svg::Color("#ffffff"))
155        .build())
156}
157
158/// Detects a valid invitation in a tightly packed, upright luminance frame.
159/// White is 255, black is 0. Hosts own capture orientation and schedule bounded
160/// background calls; this function never accesses a camera or starts a network request.
161#[cfg(feature = "qr-scanner")]
162pub fn scan_luma(
163    pixels: &[u8],
164    width: usize,
165    height: usize,
166) -> Result<Option<PairingLink>, PairingError> {
167    Ok(observe_luma(pixels, width, height)?.and_then(|observation| observation.pairing))
168}
169
170/// Finds a QR candidate even before its payload can be decoded, allowing native
171/// hosts to highlight the target. Unrecognized payloads never become invitations.
172#[cfg(feature = "qr-scanner")]
173pub fn observe_luma(
174    pixels: &[u8],
175    width: usize,
176    height: usize,
177) -> Result<Option<QrObservation>, PairingError> {
178    if width == 0
179        || height == 0
180        || width > MAX_SCAN_DIMENSION
181        || height > MAX_SCAN_DIMENSION
182        || width.checked_mul(height) != Some(pixels.len())
183    {
184        return Err(PairingError("Invalid or oversized QR camera frame"));
185    }
186    let mut image =
187        rqrr::PreparedImage::prepare_from_greyscale(width, height, |x, y| pixels[y * width + x]);
188    let mut candidate = None;
189    for grid in image.detect_grids().into_iter().take(8) {
190        let observation = QrObservation {
191            bounds: grid.bounds.map(|point| {
192                [
193                    (point.x as f32 / width as f32).clamp(0.0, 1.0),
194                    (point.y as f32 / height as f32).clamp(0.0, 1.0),
195                ]
196            }),
197            pairing: grid
198                .decode()
199                .ok()
200                .and_then(|(_, text)| PairingLink::parse(&text).ok()),
201        };
202        if observation.pairing.is_some() {
203            return Ok(Some(observation));
204        }
205        if candidate.is_none() {
206            candidate = Some(observation);
207        }
208    }
209    Ok(candidate)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    const LINK: &str =
216        "https://konjure.alkem.dev/playground/#join=0123456789&relay=https%3A%2F%2Flive.example";
217
218    #[test]
219    fn accepts_browser_links_and_marks_local_transport_without_authorizing_it() {
220        assert_eq!(
221            PairingLink::parse(LINK).unwrap().relay,
222            "https://live.example"
223        );
224        let kreate = LINK.replace("/playground/", "/kreate/");
225        assert_eq!(PairingLink::parse(&kreate), PairingLink::parse(LINK));
226        assert!(qr_svg(&kreate).unwrap().contains("<svg"));
227        let standalone = LINK.replace("konjure.alkem.dev/playground/", "kreate.alkem.dev/");
228        assert_eq!(PairingLink::parse(&standalone), PairingLink::parse(LINK));
229        assert!(qr_svg(&standalone).unwrap().contains("<svg"));
230        let local_app =
231            "http://127.0.0.1:4323/#join=ABCDEF0123&relay=http%3A%2F%2F127.0.0.1%3A4323";
232        assert!(PairingLink::parse(local_app).unwrap().allow_plain);
233        let local = "http://192.168.1.20:4323/playground/#join=ABCDEF0123&relay=http%3A%2F%2F192.168.1.20%3A4323";
234        let link = PairingLink::parse(local).unwrap();
235        assert!(link.allow_plain);
236        assert_eq!(link.code, "abcdef0123");
237    }
238
239    #[test]
240    fn rejects_ambiguous_credentials_unsafe_transports_and_non_pairing_codes() {
241        for link in [
242            LINK.replace("playground/", "docs/"),
243            LINK.replace("0123456789", "1234"),
244            LINK.replace("0123456789", "012345678z"),
245            LINK.replace("https://konjure", "https://user:secret@konjure"),
246            LINK.replace("/playground/#", "/playground/?token=secret#"),
247            format!("{LINK}&join=abcdef0123"),
248            format!("{LINK}&token=secret"),
249            format!("{LINK}&relay=https%3A%2F%2Fevil.example"),
250            LINK.replace("https%3A%2F%2Flive.example", "http%3A%2F%2F127.0.0.1"),
251            LINK.replace(
252                "https%3A%2F%2Flive.example",
253                "https%3A%2F%2Fu%3Ap%40live.example",
254            ),
255            LINK.replace("live.example", "live.example%2Fpath"),
256            LINK.replace("live.example", "live.example%3Ftoken%3Dsecret"),
257            LINK.replace("live.example", "live.example%23secret"),
258            LINK.replace("live.example", "live.example%3A0"),
259            LINK.replace("https://konjure", "http://konjure"),
260            "javascript:alert(1)".to_owned(),
261            " ".repeat(MAX_LINK_BYTES + 1),
262        ] {
263            assert!(PairingLink::parse(&link).is_err(), "accepted {link}");
264            assert!(qr_svg(&link).is_err());
265        }
266    }
267
268    #[cfg(feature = "qr-scanner")]
269    #[test]
270    fn camera_decoder_roundtrips_rotated_and_dimmed_encoder_output() {
271        let qr = QrCode::with_error_correction_level(LINK.as_bytes(), EcLevel::M).unwrap();
272        let scale = 5;
273        let side = (qr.width() + 8) * scale;
274        let mut pixels = vec![225_u8; side * side];
275        for y in 0..qr.width() {
276            for x in 0..qr.width() {
277                if qr[(x, y)] == qrcode::Color::Dark {
278                    for dy in 0..scale {
279                        for dx in 0..scale {
280                            pixels[((y + 4) * scale + dy) * side + (x + 4) * scale + dx] = 25;
281                        }
282                    }
283                }
284            }
285        }
286        for _ in 0..4 {
287            let observation = observe_luma(&pixels, side, side).unwrap().unwrap();
288            assert!(
289                observation
290                    .bounds
291                    .iter()
292                    .flatten()
293                    .all(|value| (0.0..=1.0).contains(value))
294            );
295            assert_eq!(
296                scan_luma(&pixels, side, side).unwrap(),
297                Some(PairingLink::parse(LINK).unwrap())
298            );
299            let mut rotated = vec![0; pixels.len()];
300            for y in 0..side {
301                for x in 0..side {
302                    rotated[x * side + side - y - 1] = pixels[y * side + x];
303                }
304            }
305            pixels = rotated;
306        }
307        assert!(
308            scan_luma(&vec![255; side * side], side, side)
309                .unwrap()
310                .is_none()
311        );
312        assert!(scan_luma(&pixels, side, side - 1).is_err());
313        assert!(scan_luma(&[], 0, 0).is_err());
314        assert!(scan_luma(&[], usize::MAX, 2).is_err());
315    }
316}