Skip to main content

aven_core/sync/
invitation_text.rs

1//! Invitation text, shared by every client.
2//!
3//! Setup and device invitations share one payload: `U8(1) || U8(kind) ||
4//! U8(len(server)) || server || secret`, where kind 1 is a setup invitation
5//! with a 64-byte secret (setup ID, then setup secret) and kind 2 is a device
6//! invitation with a 96-byte secret (vault ID, inviter HPKE public key, PSK).
7//! The server is an origin encrypted sync accepts.
8//!
9//! The prefix picks the text alphabet, since base32 text is also valid
10//! base64url:
11//! - Device invitations are `AVEN:` and unpadded RFC 4648 base32 (`A`–`Z`,
12//!   `2`–`7`). Every character is in the QR alphanumeric set, so the QR code
13//!   needs no byte mode. Decoding ignores letter case.
14//! - Setup invitations are `aven-setup:` and unpadded base64url, which is
15//!   shorter; they are pasted, never scanned.
16//!
17//! Decoding ignores surrounding whitespace.
18use 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    /// The kind whose prefix `text` starts with, even if the rest doesn't
54    /// decode.
55    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
70/// A decoded invitation. Debug output is redacted.
71pub 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
83/// Encodes an invitation; `server` must be an accepted origin.
84pub 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
113/// Decodes either kind of invitation, refusing servers encrypted sync can't
114/// use.
115pub 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
168/// Decodes canonical unpadded base32: impossible lengths and nonzero
169/// trailing bits are refused.
170fn 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        // Nonzero trailing bits are not canonical.
269        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        // Setup text in the device alphabet is still refused.
273        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}