1use std::fmt;
19
20use anyhow::{Result, ensure};
21use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
22use zeroize::Zeroizing;
23
24use super::client::{MAX_SERVER_BYTES, server_origin};
25
26pub const DEVICE_PREFIX: &str = "AVEN:";
27pub const SETUP_PREFIX: &str = "aven-setup:";
28const VERSION: u8 = 1;
29const MAX_PAYLOAD_BYTES: usize = 3 + MAX_SERVER_BYTES + Kind::Device.secret_len();
30const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Kind {
34 Setup,
35 Device,
36}
37
38impl Kind {
39 pub const fn secret_len(self) -> usize {
40 match self {
41 Self::Setup => 64,
42 Self::Device => 96,
43 }
44 }
45
46 fn byte(self) -> u8 {
47 match self {
48 Self::Setup => 1,
49 Self::Device => 2,
50 }
51 }
52
53 pub fn of_prefix(text: &str) -> Option<Self> {
56 let text = text.trim();
57 if text
58 .get(..DEVICE_PREFIX.len())
59 .is_some_and(|start| start.eq_ignore_ascii_case(DEVICE_PREFIX))
60 {
61 Some(Self::Device)
62 } else if text.starts_with(SETUP_PREFIX) {
63 Some(Self::Setup)
64 } else {
65 None
66 }
67 }
68}
69
70pub struct InvitationText {
72 pub kind: Kind,
73 pub server: String,
74 pub secret: Zeroizing<Vec<u8>>,
75}
76
77impl fmt::Debug for InvitationText {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 f.write_str("InvitationText([REDACTED])")
80 }
81}
82
83pub fn encode(kind: Kind, server: &str, secret: &[u8]) -> Result<Zeroizing<String>> {
85 ensure!(
86 server_origin(server)? == server,
87 "error sync-server-url-invalid"
88 );
89 ensure!(
90 secret.len() == kind.secret_len(),
91 "error sync-invitation-secret"
92 );
93 let mut payload = Zeroizing::new(Vec::with_capacity(3 + server.len() + secret.len()));
94 payload.extend([VERSION, kind.byte(), server.len() as u8]);
95 payload.extend_from_slice(server.as_bytes());
96 payload.extend_from_slice(secret);
97 let mut text = Zeroizing::new(String::with_capacity(
98 SETUP_PREFIX.len() + payload.len() * 8 / 5 + 1,
99 ));
100 match kind {
101 Kind::Device => {
102 text.push_str(DEVICE_PREFIX);
103 base32_encode(&payload, &mut text);
104 }
105 Kind::Setup => {
106 text.push_str(SETUP_PREFIX);
107 URL_SAFE_NO_PAD.encode_string(payload.as_slice(), &mut text);
108 }
109 }
110 Ok(text)
111}
112
113pub fn decode(text: &str) -> Option<InvitationText> {
116 let kind = Kind::of_prefix(text)?;
117 let text = text.trim();
118 let prefix_len = match kind {
119 Kind::Device => DEVICE_PREFIX.len(),
120 Kind::Setup => SETUP_PREFIX.len(),
121 };
122 let encoded = &text[prefix_len..];
123 if encoded.len() > MAX_PAYLOAD_BYTES.div_ceil(5) * 8 {
124 return None;
125 }
126 let payload = match kind {
127 Kind::Device => base32_decode(&Zeroizing::new(encoded.to_ascii_uppercase()))?,
128 Kind::Setup => Zeroizing::new(URL_SAFE_NO_PAD.decode(encoded).ok()?),
129 };
130 let [version, kind_byte, server_len, rest @ ..] = payload.as_slice() else {
131 return None;
132 };
133 let server_len = usize::from(*server_len);
134 if *version != VERSION
135 || *kind_byte != kind.byte()
136 || rest.len() != server_len + kind.secret_len()
137 {
138 return None;
139 }
140 let (server, secret) = rest.split_at(server_len);
141 let server = std::str::from_utf8(server).ok()?;
142 if server_origin(server).ok()? != server {
143 return None;
144 }
145 Some(InvitationText {
146 kind,
147 server: server.to_string(),
148 secret: Zeroizing::new(secret.to_vec()),
149 })
150}
151
152fn base32_encode(bytes: &[u8], out: &mut String) {
153 let mut buffer = 0u16;
154 let mut bits = 0;
155 for &byte in bytes {
156 buffer = (buffer << 8) | u16::from(byte);
157 bits += 8;
158 while bits >= 5 {
159 bits -= 5;
160 out.push(ALPHABET[usize::from((buffer >> bits) & 31)] as char);
161 }
162 }
163 if bits > 0 {
164 out.push(ALPHABET[usize::from((buffer << (5 - bits)) & 31)] as char);
165 }
166}
167
168fn base32_decode(text: &str) -> Option<Zeroizing<Vec<u8>>> {
171 if matches!(text.len() % 8, 1 | 3 | 6) {
172 return None;
173 }
174 let mut out = Zeroizing::new(Vec::with_capacity(text.len() * 5 / 8));
175 let mut buffer = 0u16;
176 let mut bits = 0;
177 for &symbol in text.as_bytes() {
178 let value = ALPHABET.iter().position(|&c| c == symbol)? as u16;
179 buffer = (buffer << 5) | value;
180 bits += 5;
181 if bits >= 8 {
182 bits -= 8;
183 out.push((buffer >> bits) as u8);
184 buffer &= (1 << bits) - 1;
185 }
186 }
187 (buffer == 0).then_some(out)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 const SERVER: &str = "https://sync.example.com";
195
196 fn secret(kind: Kind) -> Vec<u8> {
197 (0..kind.secret_len()).map(|i| (i / 32 + 5) as u8).collect()
198 }
199
200 fn payload(version: u8, kind: u8, server: &str, secret: &[u8]) -> Vec<u8> {
201 let mut payload = vec![version, kind, server.len() as u8];
202 payload.extend(server.as_bytes());
203 payload.extend(secret);
204 payload
205 }
206
207 fn device_text(payload: &[u8]) -> String {
208 let mut text = DEVICE_PREFIX.to_string();
209 base32_encode(payload, &mut text);
210 text
211 }
212
213 fn setup_text(payload: &[u8]) -> String {
214 format!("{SETUP_PREFIX}{}", URL_SAFE_NO_PAD.encode(payload))
215 }
216
217 #[test]
218 fn round_trips_with_the_documented_layout() {
219 let device = encode(Kind::Device, SERVER, &secret(Kind::Device)).unwrap();
220 assert_eq!(
221 device.as_str(),
222 device_text(&payload(1, 2, SERVER, &secret(Kind::Device)))
223 );
224 assert!(
225 device
226 .bytes()
227 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b':')
228 );
229 let setup = encode(Kind::Setup, SERVER, &secret(Kind::Setup)).unwrap();
230 assert_eq!(
231 setup.as_str(),
232 setup_text(&payload(1, 1, SERVER, &secret(Kind::Setup)))
233 );
234
235 for (kind, text) in [(Kind::Device, &device), (Kind::Setup, &setup)] {
236 let decoded = decode(&format!(" {}\n", text.as_str())).unwrap();
237 assert_eq!(decoded.kind, kind);
238 assert_eq!(decoded.server, SERVER);
239 assert_eq!(*decoded.secret, secret(kind));
240 assert_eq!(format!("{decoded:?}"), "InvitationText([REDACTED])");
241 assert_eq!(Kind::of_prefix(text), Some(kind));
242 }
243 assert_eq!(
244 decode(&device.to_ascii_lowercase()).unwrap().kind,
245 Kind::Device
246 );
247 assert_eq!(Kind::of_prefix(" aven:x"), Some(Kind::Device));
248 assert_eq!(Kind::of_prefix("hello"), None);
249 }
250
251 #[test]
252 fn refuses_truncation_and_bad_alphabets() {
253 for kind in [Kind::Device, Kind::Setup] {
254 let text = encode(kind, SERVER, &secret(kind)).unwrap();
255 let prefix_len = text.find(':').unwrap() + 1;
256 for end in [prefix_len, prefix_len + 1, text.len() / 2, text.len() - 1] {
257 assert!(decode(&text[..end]).is_none(), "{kind:?} {end}");
258 assert_eq!(Kind::of_prefix(&text[..end]), Some(kind));
259 }
260 assert!(decode(&format!("{}A", text.as_str())).is_none());
261 }
262 let text = encode(Kind::Device, SERVER, &secret(Kind::Device)).unwrap();
263 let body = &text[DEVICE_PREFIX.len()..];
264 for bad in ["1", "8", "=", "-", " "] {
265 let broken = format!("{DEVICE_PREFIX}{bad}{}", &body[1..]);
266 assert!(decode(&broken).is_none(), "{bad}");
267 }
268 let last = text.chars().last().unwrap();
270 let bumped = ALPHABET[(ALPHABET.iter().position(|&c| c as char == last).unwrap() + 1) % 32];
271 assert!(decode(&format!("{}{}", &text[..text.len() - 1], bumped as char)).is_none());
272 assert!(decode(&format!("{SETUP_PREFIX}{body}")).is_none());
274 }
275
276 #[test]
277 fn refuses_other_versions_kinds_and_lengths() {
278 let device = secret(Kind::Device);
279 let setup = secret(Kind::Setup);
280 assert!(decode(&device_text(&payload(2, 2, SERVER, &device))).is_none());
281 assert!(decode(&device_text(&payload(1, 1, SERVER, &device))).is_none());
282 assert!(decode(&device_text(&payload(1, 1, SERVER, &setup))).is_none());
283 assert!(decode(&setup_text(&payload(1, 2, SERVER, &device))).is_none());
284 assert!(decode(&setup_text(&payload(1, 1, SERVER, &device))).is_none());
285 let mut long = payload(1, 2, SERVER, &device);
286 long.push(0);
287 assert!(decode(&device_text(&long)).is_none());
288 assert!(encode(Kind::Setup, SERVER, &device).is_err());
289 }
290
291 #[test]
292 fn accepts_http_server_origins() {
293 for server in [
294 "http://127.0.0.1:3746",
295 "http://100.100.20.30:3746",
296 "http://sync.private.example:3746",
297 ] {
298 for kind in [Kind::Device, Kind::Setup] {
299 let text = encode(kind, server, &secret(kind)).unwrap();
300 assert_eq!(decode(&text).unwrap().server, server);
301 }
302 }
303 }
304
305 #[test]
306 fn refuses_servers_encrypted_sync_cannot_use() {
307 for server in [
308 "ftp://sync.example.net",
309 "https://sync.example.net/x",
310 "https://sync.example.net/",
311 "http://sync.aventasks.dev",
312 ] {
313 assert!(encode(Kind::Device, server, &secret(Kind::Device)).is_err());
314 let text = device_text(&payload(1, 2, server, &secret(Kind::Device)));
315 assert!(decode(&text).is_none(), "{server}");
316 let text = setup_text(&payload(1, 1, server, &secret(Kind::Setup)));
317 assert!(decode(&text).is_none(), "{server}");
318 }
319 let longest = format!("https://{}", "a".repeat(MAX_SERVER_BYTES - 8));
320 for kind in [Kind::Device, Kind::Setup] {
321 let text = encode(kind, &longest, &secret(kind)).unwrap();
322 assert_eq!(decode(&text).unwrap().server, longest);
323 }
324 let error =
325 encode(Kind::Device, &format!("{longest}a"), &secret(Kind::Device)).unwrap_err();
326 assert!(format!("{error:#}").contains("error sync-server-url-too-long"));
327 }
328}