Skip to main content

aven_core/
pairing.rs

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}