1use qrcode::{EcLevel, QrCode, render::svg};
18use serde::Serialize;
19use std::{error::Error, fmt};
20use url::{Host, Url};
21
22pub const MAX_LINK_BYTES: usize = 1024;
24pub const MAX_SCAN_DIMENSION: usize = 960;
26
27#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
29#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
30pub struct PairingLink {
31 pub relay: String,
33 pub code: String,
35 pub allow_plain: bool,
38}
39
40#[derive(Clone, Debug, PartialEq, Serialize)]
44pub struct QrObservation {
45 pub bounds: [[f32; 2]; 4],
47 pub pairing: Option<PairingLink>,
49}
50
51#[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 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
143pub 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#[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#[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}