1use std::fmt;
2
3use base64::Engine as _;
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use serde::{Deserialize, Serialize};
6use url::{Host, Url};
7
8const PAIRING_INVITATION_URI_PREFIX: &str = "aven://pair/v1/";
9const PAIRING_INVITATION_ROOT: &str = "aven://pair/";
10const MAX_PAIRING_PAYLOAD_BYTES: usize = 4096;
11const MAX_ENCODED_PAYLOAD_CHARS: usize = (MAX_PAIRING_PAYLOAD_BYTES * 4).div_ceil(3);
12const REDACTED: &str = "[REDACTED]";
13
14#[derive(Clone, PartialEq, Eq)]
15pub struct PairingInvitation {
16 server_url: String,
17 server_origin: String,
18 auth_token: String,
19}
20
21impl PairingInvitation {
22 pub fn new(server_url: String, auth_token: String) -> Result<Self, PairingInvitationError> {
23 let url = Url::parse(&server_url).map_err(|_| PairingInvitationError::InvalidServerUrl)?;
24 if !crate::sync::wire::sync_server_url_is_valid_url(&url) {
25 return Err(PairingInvitationError::InvalidServerUrl);
26 }
27 if server_host_is_loopback(url.host()) {
28 return Err(PairingInvitationError::LoopbackServer);
29 }
30
31 let auth_token = auth_token.trim().to_string();
32 if auth_token.is_empty() {
33 return Err(PairingInvitationError::EmptyAuthToken);
34 }
35
36 let invitation = Self {
37 server_url: url.to_string(),
38 server_origin: url.origin().ascii_serialization(),
39 auth_token,
40 };
41 invitation.serialized_payload()?;
42 Ok(invitation)
43 }
44
45 pub fn decode(uri: &str) -> Result<Self, PairingInvitationError> {
46 let rest = uri
47 .strip_prefix(PAIRING_INVITATION_ROOT)
48 .ok_or(PairingInvitationError::InvalidInvitation)?;
49 let (version, payload) = rest
50 .split_once('/')
51 .ok_or(PairingInvitationError::InvalidInvitation)?;
52 version
53 .strip_prefix('v')
54 .filter(|digits| !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
55 .ok_or(PairingInvitationError::InvalidInvitation)?;
56 if version != "v1" {
57 return Err(PairingInvitationError::UnsupportedVersion);
58 }
59 if payload.is_empty()
60 || payload
61 .bytes()
62 .any(|byte| matches!(byte, b'/' | b'?' | b'#'))
63 {
64 return Err(PairingInvitationError::InvalidInvitation);
65 }
66 if payload.len() > MAX_ENCODED_PAYLOAD_CHARS {
67 return Err(PairingInvitationError::PayloadTooLarge);
68 }
69
70 let decoded = URL_SAFE_NO_PAD
71 .decode(payload)
72 .map_err(|_| PairingInvitationError::InvalidInvitation)?;
73 if decoded.len() > MAX_PAIRING_PAYLOAD_BYTES {
74 return Err(PairingInvitationError::PayloadTooLarge);
75 }
76 let wire: PairingInvitationWire = serde_json::from_slice(&decoded)
77 .map_err(|_| PairingInvitationError::InvalidInvitation)?;
78 Self::new(wire.server_url, wire.auth_token)
79 }
80
81 pub fn encode(&self) -> Result<String, PairingInvitationError> {
82 let payload = self.serialized_payload()?;
83 Ok(format!(
84 "{PAIRING_INVITATION_URI_PREFIX}{}",
85 URL_SAFE_NO_PAD.encode(payload)
86 ))
87 }
88
89 pub fn server_url(&self) -> &str {
90 &self.server_url
91 }
92
93 pub fn server_origin(&self) -> &str {
94 &self.server_origin
95 }
96
97 pub fn auth_token(&self) -> &str {
98 &self.auth_token
99 }
100
101 fn serialized_payload(&self) -> Result<Vec<u8>, PairingInvitationError> {
102 let payload = serde_json::to_vec(&PairingInvitationWire {
103 server_url: self.server_url.clone(),
104 auth_token: self.auth_token.clone(),
105 })
106 .map_err(|_| PairingInvitationError::InvalidInvitation)?;
107 if payload.len() > MAX_PAIRING_PAYLOAD_BYTES {
108 return Err(PairingInvitationError::PayloadTooLarge);
109 }
110 Ok(payload)
111 }
112}
113
114fn server_host_is_loopback(host: Option<Host<&str>>) -> bool {
115 match host {
116 Some(Host::Ipv4(address)) => address.is_loopback(),
117 Some(Host::Ipv6(address)) => {
118 address.is_loopback()
119 || address
120 .to_ipv4_mapped()
121 .is_some_and(|mapped| mapped.is_loopback())
122 }
123 Some(Host::Domain(domain)) => {
124 let domain = domain.trim_end_matches('.');
125 domain.eq_ignore_ascii_case("localhost")
126 || domain
127 .rsplit_once('.')
128 .is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case("localhost"))
129 }
130 None => false,
131 }
132}
133
134impl fmt::Debug for PairingInvitation {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 formatter
137 .debug_struct("PairingInvitation")
138 .field("server_origin", &self.server_origin)
139 .field("auth_token", &REDACTED)
140 .finish()
141 }
142}
143
144impl fmt::Display for PairingInvitation {
145 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146 write!(
147 formatter,
148 "Aven pairing invitation for {} with {REDACTED} token",
149 self.server_origin
150 )
151 }
152}
153
154#[derive(Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156struct PairingInvitationWire {
157 server_url: String,
158 auth_token: String,
159}
160
161#[non_exhaustive]
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum PairingInvitationError {
164 InvalidInvitation,
165 UnsupportedVersion,
166 PayloadTooLarge,
167 EmptyAuthToken,
168 InvalidServerUrl,
169 LoopbackServer,
170}
171
172impl fmt::Display for PairingInvitationError {
173 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174 formatter.write_str(match self {
175 Self::InvalidInvitation => "invalid pairing invitation",
176 Self::UnsupportedVersion => "unsupported pairing invitation version",
177 Self::PayloadTooLarge => "pairing invitation payload is too large",
178 Self::EmptyAuthToken => "pairing invitation auth token is empty",
179 Self::InvalidServerUrl => "pairing invitation server URL is invalid",
180 Self::LoopbackServer => "pairing invitation server URL is loopback",
181 })
182 }
183}
184
185impl std::error::Error for PairingInvitationError {}
186
187#[cfg(test)]
188mod tests {
189 use base64::Engine as _;
190 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
191 use serde::Serialize;
192
193 use super::{
194 MAX_ENCODED_PAYLOAD_CHARS, MAX_PAIRING_PAYLOAD_BYTES, PAIRING_INVITATION_URI_PREFIX,
195 PairingInvitation, PairingInvitationError, REDACTED,
196 };
197
198 const TEST_SERVER: &str = "https://sync.example.test:8443/aven";
199 const TEST_TOKEN: &str = "pairing-token-fixture-0123456789";
200 const BOUNDARY_SERVER: &str = "https://sync.example.test/aven";
201
202 #[derive(Serialize)]
203 struct TestWire<'a> {
204 server_url: &'a str,
205 auth_token: String,
206 }
207
208 fn fixture() -> PairingInvitation {
209 PairingInvitation::new(TEST_SERVER.to_string(), TEST_TOKEN.to_string()).unwrap()
210 }
211
212 fn raw_json_with_size(target: usize) -> Vec<u8> {
213 let empty = serde_json::to_vec(&TestWire {
214 server_url: BOUNDARY_SERVER,
215 auth_token: String::new(),
216 })
217 .unwrap();
218 let bytes = serde_json::to_vec(&TestWire {
219 server_url: BOUNDARY_SERVER,
220 auth_token: "t".repeat(target - empty.len()),
221 })
222 .unwrap();
223 assert_eq!(bytes.len(), target);
224 bytes
225 }
226
227 fn raw_uri(json: &[u8]) -> String {
228 format!(
229 "{PAIRING_INVITATION_URI_PREFIX}{}",
230 URL_SAFE_NO_PAD.encode(json)
231 )
232 }
233
234 #[test]
235 fn version_one_encoding_is_stable_and_round_trips() {
236 let invitation = fixture();
237 let encoded = invitation.encode().unwrap();
238 assert_eq!(
239 encoded,
240 "aven://pair/v1/eyJzZXJ2ZXJfdXJsIjoiaHR0cHM6Ly9zeW5jLmV4YW1wbGUudGVzdDo4NDQzL2F2ZW4iLCJhdXRoX3Rva2VuIjoicGFpcmluZy10b2tlbi1maXh0dXJlLTAxMjM0NTY3ODkifQ"
241 );
242 assert_eq!(PairingInvitation::decode(&encoded).unwrap(), invitation);
243 assert_eq!(invitation.server_url(), TEST_SERVER);
244 assert_eq!(invitation.server_origin(), "https://sync.example.test:8443");
245 assert_eq!(invitation.auth_token(), TEST_TOKEN);
246 }
247
248 #[test]
249 fn server_url_is_canonical_across_host_url_parsers() {
250 let invitation = PairingInvitation::new(
251 r"https://sync.example.test\@redirect.example.test/private".to_string(),
252 TEST_TOKEN.to_string(),
253 )
254 .unwrap();
255
256 assert_eq!(
257 invitation.server_url(),
258 "https://sync.example.test/@redirect.example.test/private"
259 );
260 assert_eq!(invitation.server_origin(), "https://sync.example.test");
261 }
262
263 #[test]
264 fn unsupported_versions_are_distinct_from_invalid_invitations() {
265 let payload = fixture()
266 .encode()
267 .unwrap()
268 .strip_prefix(PAIRING_INVITATION_URI_PREFIX)
269 .unwrap()
270 .to_string();
271 for uri in [
272 String::new(),
273 "https://sync.example.test/aven".to_string(),
274 format!("aven://PAIR/v1/{payload}"),
275 format!("aven://pair/v/{payload}"),
276 format!("aven://pair/vx/{payload}"),
277 format!("aven://pair/v1/{payload}/extra"),
278 format!("aven://pair/v1/{payload}?source=paste"),
279 format!("aven://pair/v1/{payload}#fragment"),
280 format!("{PAIRING_INVITATION_URI_PREFIX}not*base64"),
281 raw_uri(br#"["#),
282 ] {
283 assert_eq!(
284 PairingInvitation::decode(&uri).unwrap_err(),
285 PairingInvitationError::InvalidInvitation,
286 "uri={uri}"
287 );
288 }
289 for version in ["v0", "v01", "v2"] {
290 assert_eq!(
291 PairingInvitation::decode(&format!("aven://pair/{version}/{payload}")).unwrap_err(),
292 PairingInvitationError::UnsupportedVersion
293 );
294 }
295 }
296
297 #[test]
298 fn invalid_values_and_literal_loopback_hosts_are_rejected() {
299 let cases = [
300 (
301 "https://sync.example.test",
302 " ",
303 PairingInvitationError::EmptyAuthToken,
304 ),
305 ("not a url", "t", PairingInvitationError::InvalidServerUrl),
306 (
307 "ftp://sync.example.test",
308 "t",
309 PairingInvitationError::InvalidServerUrl,
310 ),
311 (
312 "https://user@sync.example.test",
313 "t",
314 PairingInvitationError::InvalidServerUrl,
315 ),
316 (
317 "https://sync.example.test/?value=1",
318 "t",
319 PairingInvitationError::InvalidServerUrl,
320 ),
321 (
322 "http://127.0.0.1:8080",
323 "t",
324 PairingInvitationError::LoopbackServer,
325 ),
326 (
327 "http://127.5.5.5",
328 "t",
329 PairingInvitationError::LoopbackServer,
330 ),
331 (
332 "http://[::1]:8080",
333 "t",
334 PairingInvitationError::LoopbackServer,
335 ),
336 (
337 "http://[::ffff:127.0.0.1]",
338 "t",
339 PairingInvitationError::LoopbackServer,
340 ),
341 (
342 "http://localhost:8080",
343 "t",
344 PairingInvitationError::LoopbackServer,
345 ),
346 (
347 "http://localhost.:8080",
348 "t",
349 PairingInvitationError::LoopbackServer,
350 ),
351 (
352 "http://app.localhost:8080",
353 "t",
354 PairingInvitationError::LoopbackServer,
355 ),
356 ];
357 for (server, token, expected) in cases {
358 assert_eq!(
359 PairingInvitation::new(server.to_string(), token.to_string()).unwrap_err(),
360 expected,
361 "server={server}"
362 );
363 }
364 }
365
366 #[test]
367 fn private_http_public_https_and_trimmed_tokens_round_trip() {
368 for server in [
369 "http://192.168.1.20:8080/aven",
370 "https://sync.example.com/aven",
371 ] {
372 let invitation =
373 PairingInvitation::new(server.to_string(), " token \n".to_string()).unwrap();
374 assert_eq!(invitation.auth_token(), "token");
375 assert_eq!(
376 PairingInvitation::decode(&invitation.encode().unwrap()).unwrap(),
377 invitation
378 );
379 }
380
381 let padded =
382 raw_uri(br#"{"server_url":"https://sync.example.test/aven","auth_token":" token "}"#);
383 assert_eq!(
384 PairingInvitation::decode(&padded).unwrap().auth_token(),
385 "token"
386 );
387 }
388
389 #[test]
390 fn payload_bounds_are_checked_before_decode_allocation() {
391 let fitting_json = raw_json_with_size(MAX_PAIRING_PAYLOAD_BYTES);
392 let fitting = PairingInvitation::decode(&raw_uri(&fitting_json)).unwrap();
393 assert!(fitting.encode().is_ok());
394
395 let oversized_json = raw_json_with_size(MAX_PAIRING_PAYLOAD_BYTES + 1);
396 assert_eq!(
397 PairingInvitation::decode(&raw_uri(&oversized_json)).unwrap_err(),
398 PairingInvitationError::PayloadTooLarge
399 );
400 assert_eq!(
401 PairingInvitation::decode(&format!(
402 "{PAIRING_INVITATION_URI_PREFIX}{}",
403 "*".repeat(MAX_ENCODED_PAYLOAD_CHARS + 1)
404 ))
405 .unwrap_err(),
406 PairingInvitationError::PayloadTooLarge
407 );
408 }
409
410 #[test]
411 fn formatting_and_errors_reveal_only_the_server_origin() {
412 let invitation = PairingInvitation::new(
413 format!("https://sync.example.test/{TEST_TOKEN}"),
414 TEST_TOKEN.to_string(),
415 )
416 .unwrap();
417 let encoded = invitation.encode().unwrap();
418 let payload = encoded.strip_prefix(PAIRING_INVITATION_URI_PREFIX).unwrap();
419
420 for rendered in [format!("{invitation:?}"), invitation.to_string()] {
421 assert!(rendered.contains("https://sync.example.test"));
422 assert!(!rendered.contains(TEST_TOKEN));
423 assert!(!rendered.contains(&encoded));
424 assert!(!rendered.contains(payload));
425 assert!(rendered.contains(REDACTED));
426 }
427
428 let error = PairingInvitation::decode(&format!("aven://pair/v2/{payload}")).unwrap_err();
429 for rendered in [format!("{error:?}"), error.to_string()] {
430 assert!(!rendered.contains(TEST_TOKEN));
431 assert!(!rendered.contains(&encoded));
432 assert!(!rendered.contains(payload));
433 }
434 }
435}