Skip to main content

ecr_core/
pairing.rs

1//! What a pairing QR carries.
2//!
3//! A phone that has just been installed knows neither where the server is nor
4//! how to prove itself to it, and typing a tailnet hostname and a 64-character
5//! hex token on a soft keyboard is the worst part of setting ecr up. One code
6//! carries both.
7//!
8//! The format is a URI so that it is unambiguous when something other than ecr
9//! scans it, and so a future field can be added without the reader having to
10//! guess what it is looking at:
11//!
12//! ```text
13//! ecr://pair?url=http%3A%2F%2Fbox%3A8383&token=8f1c…
14//! ```
15//!
16//! A bare token with no scheme is still accepted, because that is exactly what
17//! `ecr token new --qr` produced before this existed and codes already printed
18//! or photographed have to keep working.
19
20use std::fmt;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Pairing {
24    /// Absent when the code carries only a token, which is every code printed
25    /// before this format and any produced without a reachable address.
26    pub url: Option<String>,
27    pub token: String,
28}
29
30const SCHEME: &str = "ecr://pair?";
31
32impl Pairing {
33    pub fn token_only(token: impl Into<String>) -> Self {
34        Self {
35            url: None,
36            token: token.into(),
37        }
38    }
39
40    pub fn new(url: impl Into<String>, token: impl Into<String>) -> Self {
41        Self {
42            url: Some(url.into()),
43            token: token.into(),
44        }
45    }
46
47    /// `None` when there is nothing usable in the text, so a reader can say
48    /// "that is not an ecr code" rather than pairing with an empty token.
49    pub fn parse(text: &str) -> Option<Self> {
50        let text = text.trim();
51        if text.is_empty() {
52            return None;
53        }
54
55        let Some(query) = text.strip_prefix(SCHEME) else {
56            // The old format, and the only thing a bare string can be.
57            return (!text.contains(char::is_whitespace)).then(|| Self::token_only(text));
58        };
59
60        let mut url = None;
61        let mut token = None;
62        for field in query.split('&') {
63            let Some((key, value)) = field.split_once('=') else {
64                continue;
65            };
66            match key {
67                "url" => url = decode(value),
68                "token" => token = decode(value),
69                _ => {}
70            }
71        }
72
73        let token = token.filter(|t| !t.is_empty())?;
74        Some(Self {
75            url: url.filter(|u| !u.is_empty()),
76            token,
77        })
78    }
79}
80
81impl fmt::Display for Pairing {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        // The token alone stays bare rather than becoming a one-field URI: a
84        // code that is only a token is what every older ecr wrote, and writing
85        // it the old way keeps a new server pairing an older client.
86        let Some(url) = &self.url else {
87            return f.write_str(&self.token);
88        };
89        write!(
90            f,
91            "{SCHEME}url={}&token={}",
92            encode(url),
93            encode(&self.token)
94        )
95    }
96}
97
98/// Percent-encoding, unreserved characters only. Deliberately small: the only
99/// things encoded here are a URL and a hex token, and a dependency for that
100/// would reach `ecr-core`, which has none by design.
101fn encode(text: &str) -> String {
102    let mut out = String::with_capacity(text.len());
103    for byte in text.bytes() {
104        match byte {
105            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
106                out.push(byte as char)
107            }
108            _ => out.push_str(&format!("%{byte:02X}")),
109        }
110    }
111    out
112}
113
114fn decode(text: &str) -> Option<String> {
115    let mut out = Vec::with_capacity(text.len());
116    let mut bytes = text.bytes();
117
118    while let Some(byte) = bytes.next() {
119        if byte != b'%' {
120            out.push(byte);
121            continue;
122        }
123        let hex = [bytes.next()?, bytes.next()?];
124        let hex = std::str::from_utf8(&hex).ok()?;
125        out.push(u8::from_str_radix(hex, 16).ok()?);
126    }
127
128    String::from_utf8(out).ok()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    const TOKEN: &str = "8f1c2d3e4f5a6b7c";
136
137    #[test]
138    fn a_pairing_survives_a_round_trip() {
139        let pairing = Pairing::new("http://box:8383", TOKEN);
140
141        assert_eq!(Pairing::parse(&pairing.to_string()), Some(pairing));
142    }
143
144    #[test]
145    fn the_url_is_encoded_so_its_punctuation_cannot_end_a_field() {
146        let encoded = Pairing::new("http://box:8383/", TOKEN).to_string();
147
148        assert!(!encoded.contains("//box"), "{encoded}");
149        assert!(encoded.starts_with("ecr://pair?url="));
150    }
151
152    #[test]
153    fn a_url_containing_an_ampersand_still_round_trips() {
154        // Not a realistic mail server address, but the reason the value is
155        // encoded at all: an unescaped `&` would truncate it into two fields.
156        let pairing = Pairing::new("http://box:8383/?a=1&b=2", TOKEN);
157
158        assert_eq!(Pairing::parse(&pairing.to_string()), Some(pairing));
159    }
160
161    #[test]
162    fn a_bare_token_is_still_a_pairing() {
163        // What `ecr token new --qr` printed before the address was included.
164        assert_eq!(Pairing::parse(TOKEN), Some(Pairing::token_only(TOKEN)));
165    }
166
167    #[test]
168    fn a_token_only_pairing_is_written_the_old_way() {
169        assert_eq!(Pairing::token_only(TOKEN).to_string(), TOKEN);
170    }
171
172    #[test]
173    fn a_code_without_a_token_is_not_a_pairing() {
174        assert_eq!(Pairing::parse("ecr://pair?url=http%3A%2F%2Fbox"), None);
175        assert_eq!(Pairing::parse("ecr://pair?token="), None);
176    }
177
178    #[test]
179    fn something_that_is_not_a_code_at_all_is_refused() {
180        assert_eq!(Pairing::parse(""), None);
181        assert_eq!(Pairing::parse("   "), None);
182        assert_eq!(Pairing::parse("some words here"), None);
183    }
184
185    #[test]
186    fn an_unknown_field_is_ignored_rather_than_failing() {
187        let parsed = Pairing::parse("ecr://pair?url=http%3A%2F%2Fbox&token=abc&name=phone");
188
189        assert_eq!(parsed, Some(Pairing::new("http://box", "abc")));
190    }
191
192    #[test]
193    fn a_truncated_escape_is_refused_rather_than_guessed() {
194        assert_eq!(Pairing::parse("ecr://pair?token=ab%"), None);
195        assert_eq!(Pairing::parse("ecr://pair?token=ab%2"), None);
196    }
197}