Skip to main content

aven_core/sync/client/
invitation.rs

1//! Setup and device invitations, encoded as described in
2//! `crate::sync::invitation_text`. Debug output is redacted.
3use std::fmt;
4
5use anyhow::Result;
6use zeroize::Zeroizing;
7
8use crate::sync::invitation_text::{self, Kind};
9use crate::sync::seed_claim::{Secret, membership::Invitation};
10
11/// Authority to claim an unclaimed server's storage and start a sync.
12pub struct SetupInvitation {
13    pub server: String,
14    pub setup_id: [u8; 32],
15    pub secret: Secret,
16}
17
18/// Authority to join the sync whose device issued it.
19pub struct DeviceInvitation {
20    pub server: String,
21    pub invitation: Invitation,
22}
23
24impl fmt::Debug for SetupInvitation {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.write_str("SetupInvitation([REDACTED])")
27    }
28}
29
30impl fmt::Debug for DeviceInvitation {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        f.write_str("DeviceInvitation([REDACTED])")
33    }
34}
35
36impl SetupInvitation {
37    pub fn encode(&self) -> Result<Zeroizing<String>> {
38        let mut secret = Zeroizing::new(self.setup_id.to_vec());
39        secret.extend_from_slice(self.secret.expose());
40        invitation_text::encode(Kind::Setup, &self.server, &secret)
41    }
42
43    pub fn decode(text: &str) -> Result<Self> {
44        Self::decode_fields(text)
45            .ok_or_else(|| anyhow::anyhow!("error sync-setup-invitation-invalid"))
46    }
47
48    fn decode_fields(text: &str) -> Option<Self> {
49        let decoded = invitation_text::decode(text).filter(|d| d.kind == Kind::Setup)?;
50        Some(Self {
51            setup_id: decoded.secret[..32].try_into().ok()?,
52            secret: Secret::new(decoded.secret[32..].try_into().ok()?),
53            server: decoded.server,
54        })
55    }
56}
57
58impl DeviceInvitation {
59    pub fn encode(&self) -> Result<Zeroizing<String>> {
60        // Protected storage order is vault, inviter HPKE public key, PSK,
61        // matching the invitation text.
62        let fields = self.invitation.protected_storage_bytes();
63        invitation_text::encode(Kind::Device, &self.server, &fields)
64    }
65
66    pub fn decode(text: &str) -> Result<Self> {
67        Self::decode_fields(text)
68            .ok_or_else(|| anyhow::anyhow!("error sync-device-invitation-invalid"))
69    }
70
71    fn decode_fields(text: &str) -> Option<Self> {
72        let decoded = invitation_text::decode(text).filter(|d| d.kind == Kind::Device)?;
73        Some(Self {
74            invitation: Invitation::from_protected_storage(&decoded.secret).ok()?,
75            server: decoded.server,
76        })
77    }
78}
79
80/// What pasted text looks like as an invitation. Holds only the server
81/// origin, never the secret.
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub enum InvitationCheck {
84    #[default]
85    Empty,
86    Setup(String),
87    Device(String),
88    /// Has an invitation prefix but doesn't decode.
89    Incomplete,
90    Unknown,
91}
92
93impl InvitationCheck {
94    pub fn of(text: &str) -> Self {
95        if text.trim().is_empty() {
96            return Self::Empty;
97        }
98        match invitation_text::decode(text) {
99            Some(decoded) if decoded.kind == Kind::Setup => Self::Setup(decoded.server),
100            Some(decoded) => Self::Device(decoded.server),
101            None if Kind::of_prefix(text).is_some() => Self::Incomplete,
102            None => Self::Unknown,
103        }
104    }
105}
106
107/// Encoded setup and device invitations for `server`, for tests outside
108/// this module.
109#[cfg(any(test, feature = "test-support"))]
110pub fn sample_invitations(server: &str) -> (Zeroizing<String>, Zeroizing<String>) {
111    let setup = SetupInvitation {
112        server: server.into(),
113        setup_id: [3; 32],
114        secret: Secret::new([4; 32]),
115    };
116    let mut storage = vec![5; 32];
117    storage.extend([6; 32]);
118    storage.extend([7; 32]);
119    let device = DeviceInvitation {
120        server: server.into(),
121        invitation: Invitation::from_protected_storage(&storage).expect("sample invitation"),
122    };
123    (
124        setup.encode().expect("sample invitation"),
125        device.encode().expect("sample invitation"),
126    )
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn invitations_round_trip_and_report_their_kind() {
135        let server = "https://sync.example.net";
136        let (setup_text, device_text) = sample_invitations(server);
137
138        assert!(setup_text.starts_with(invitation_text::SETUP_PREFIX));
139        let decoded = SetupInvitation::decode(&format!("  {}\n", setup_text.as_str())).unwrap();
140        assert_eq!(decoded.server, server);
141        assert_eq!(decoded.setup_id, [3; 32]);
142        assert_eq!(decoded.secret.expose(), &[4; 32]);
143        assert_eq!(format!("{decoded:?}"), "SetupInvitation([REDACTED])");
144        assert!(DeviceInvitation::decode(&setup_text).is_err());
145
146        assert!(device_text.starts_with(invitation_text::DEVICE_PREFIX));
147        let decoded = DeviceInvitation::decode(&device_text.to_ascii_lowercase()).unwrap();
148        assert_eq!(decoded.server, server);
149        assert_eq!(decoded.encode().unwrap(), device_text);
150        assert!(SetupInvitation::decode(&device_text).is_err());
151
152        assert_eq!(
153            InvitationCheck::of(&setup_text),
154            InvitationCheck::Setup(server.into())
155        );
156        assert_eq!(
157            InvitationCheck::of(&device_text),
158            InvitationCheck::Device(server.into())
159        );
160        assert_eq!(
161            InvitationCheck::of(&setup_text[..60]),
162            InvitationCheck::Incomplete
163        );
164        assert_eq!(
165            InvitationCheck::of(&device_text[..40].to_ascii_lowercase()),
166            InvitationCheck::Incomplete
167        );
168        assert_eq!(InvitationCheck::of("hello"), InvitationCheck::Unknown);
169        assert_eq!(InvitationCheck::of("  "), InvitationCheck::Empty);
170    }
171}