Skip to main content

libsignal_service/websocket/
usernames.rs

1use crate::utils::serde_base64_url_safe_no_pad;
2use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
3use libsignal_core::{Aci, ServiceIdKind};
4use reqwest::Method;
5use serde::Serialize;
6
7use crate::content::ServiceError;
8
9use super::{Identified, SignalWebSocket, Unidentified};
10
11impl SignalWebSocket<Unidentified> {
12    pub async fn look_up_username(
13        &mut self,
14        username: &usernames::Username,
15    ) -> Result<Option<Aci>, ServiceError> {
16        self.look_up_username_hash(&username.hash()).await
17    }
18
19    // Based on libsignal-net
20    pub async fn look_up_username_hash(
21        &mut self,
22        hash: &[u8],
23    ) -> Result<Option<Aci>, ServiceError> {
24        #[derive(serde::Deserialize)]
25        struct UsernameHashResponse {
26            uuid: String,
27        }
28
29        let response = self
30            .http_request(
31                Method::GET,
32                format!(
33                    "/v1/accounts/username_hash/{}",
34                    BASE64_URL_SAFE_NO_PAD.encode(hash)
35                ),
36            )?
37            .send()
38            .await?;
39
40        if response.status() == 404 {
41            tracing::debug!("username not found");
42            return Ok(None);
43        }
44
45        let result: UsernameHashResponse =
46            response.service_error_for_status().await?.json().await?;
47
48        Ok(Some(
49            Aci::parse_from_service_id_string(&result.uuid).ok_or_else(
50                || ServiceError::InvalidAddressType(ServiceIdKind::Aci),
51            )?,
52        ))
53    }
54
55    /// Looks up the encrypted username stored at a username link handle and
56    /// decrypts it.
57    ///
58    /// `link` must be a full `https://signal.me/#eu/<payload>` URL. The payload
59    /// is the URL-safe base64 encoding of the 32-byte link entropy followed by
60    /// the 16-byte link handle UUID.
61    // Based on libsignal-net
62    pub async fn look_up_username_link(
63        &mut self,
64        link: &url::Url,
65    ) -> Result<Option<usernames::Username>, ServiceError> {
66        #[derive(serde::Deserialize)]
67        struct UsernameLinkResponse {
68            #[serde(rename = "usernameLinkEncryptedValue")]
69            #[serde(with = "serde_base64_url_safe_no_pad")]
70            encrypted_username: Vec<u8>,
71        }
72
73        let (uuid, entropy) = parse_username_link(link)?;
74
75        let response = self
76            .http_request(
77                Method::GET,
78                format!("/v1/accounts/username_link/{uuid}"),
79            )?
80            .send()
81            .await?;
82
83        if response.status() == 404 {
84            tracing::debug!("username link not found");
85            return Ok(None);
86        }
87
88        let result: UsernameLinkResponse =
89            response.service_error_for_status().await?.json().await?;
90
91        let plaintext_username =
92            usernames::decrypt_username(&entropy, &result.encrypted_username)
93                .map_err(|error| {
94                tracing::error!(%error, "undecryptable username");
95                ServiceError::InvalidFrame {
96                    reason: "undecryptable username link",
97                }
98            })?;
99
100        let validated_username = usernames::Username::new(&plaintext_username).map_err(|e| {
101            // Exhaustively match UsernameError to make sure there's nothing we shouldn't log.
102            #[allow(clippy::let_unit_value)]
103            let _username_error_carries_no_information_that_would_be_bad_to_log = match e {
104                usernames::UsernameError::MissingSeparator
105                | usernames::UsernameError::NicknameCannotBeEmpty
106                | usernames::UsernameError::NicknameCannotStartWithDigit
107                | usernames::UsernameError::BadNicknameCharacter
108                | usernames::UsernameError::NicknameTooShort
109                | usernames::UsernameError::NicknameTooLong
110                | usernames::UsernameError::DiscriminatorCannotBeEmpty
111                | usernames::UsernameError::DiscriminatorCannotBeZero
112                | usernames::UsernameError::DiscriminatorCannotBeSingleDigit
113                | usernames::UsernameError::DiscriminatorCannotHaveLeadingZeros
114                | usernames::UsernameError::BadDiscriminatorCharacter
115                | usernames::UsernameError::DiscriminatorTooLarge => {}
116            };
117            tracing::warn!(error=%e, "username link decrypted to an invalid username");
118            tracing::debug!(error=%e,
119                "username link decrypted to '{plaintext_username}', which is not valid"
120            );
121            // The user didn't ever type this username, so the precise way in which it's invalid
122            // isn't important. Treat this equivalent to having found garbage data in the link. This
123            // simplifies error handling for callers.
124            ServiceError::InvalidFrame {
125                reason: "undecryptable username link",
126            }
127        })?;
128
129        Ok(Some(validated_username))
130    }
131}
132
133/// Splits a username link into its link handle UUID and link entropy.
134///
135/// `link` must be a full `https://signal.me/#eu/<payload>` URL. The payload is
136/// URL-safe base64 (no padding) of the 32-byte entropy followed by the 16-byte
137/// handle UUID.
138fn parse_username_link(
139    link: &url::Url,
140) -> Result<
141    (
142        uuid::Uuid,
143        [u8; usernames::constants::USERNAME_LINK_ENTROPY_SIZE],
144    ),
145    ServiceError,
146> {
147    if link.scheme() != "https" || link.host_str() != Some("signal.me") {
148        return Err(ServiceError::InvalidFrame {
149            reason: "username link base is not https://signal.me",
150        });
151    }
152
153    let fragment =
154        link.fragment().ok_or_else(|| ServiceError::InvalidFrame {
155            reason: "username link missing fragment",
156        })?;
157    let mut segments = fragment.split('/');
158    if segments.next() != Some("eu") {
159        return Err(ServiceError::InvalidFrame {
160            reason: "username link must start with #eu/",
161        });
162    }
163    let payload =
164        segments.next().ok_or_else(|| ServiceError::InvalidFrame {
165            reason: "username link payload missing",
166        })?;
167    if segments.next().is_some() {
168        return Err(ServiceError::InvalidFrame {
169            reason: "username link has extra path segments",
170        });
171    }
172
173    let bytes = BASE64_URL_SAFE_NO_PAD.decode(payload)?;
174
175    let (entropy, rest) = bytes
176        .split_first_chunk::<{ usernames::constants::USERNAME_LINK_ENTROPY_SIZE }>()
177        .ok_or_else(|| ServiceError::InvalidFrame {
178            reason: "username link payload shorter than entropy",
179        })?;
180
181    let handle_uuid = uuid::Uuid::from_slice(rest).map_err(|_| {
182        ServiceError::InvalidFrame {
183            reason: "username link payload missing handle UUID",
184        }
185    })?;
186
187    Ok((handle_uuid, *entropy))
188}
189
190impl SignalWebSocket<Identified> {
191    /// Sets the account's username link to encrypt `username` and returns the
192    /// shareable `https://signal.me/#eu/...` link.
193    ///
194    /// Generates fresh entropy and, unless `keep_link_handle` is `true` and the
195    /// account already has a link handle, a fresh server-assigned handle.
196    // Based on libsignal-net
197    pub async fn set_username_link(
198        &mut self,
199        username: &usernames::Username,
200        keep_link_handle: bool,
201    ) -> Result<url::Url, ServiceError> {
202        #[derive(Serialize)]
203        #[serde(rename_all = "camelCase")]
204        struct SetUsernameLinkRequest {
205            #[serde(with = "serde_base64_url_safe_no_pad")]
206            username_link_encrypted_value: Vec<u8>,
207            keep_link_handle: bool,
208        }
209
210        #[derive(serde::Deserialize)]
211        #[serde(rename_all = "camelCase")]
212        struct UsernameLinkHandleResponse {
213            username_link_handle: uuid::Uuid,
214        }
215
216        let (entropy, ciphertext) = usernames::create_for_username(
217            &mut rand::rng(),
218            username.to_string(),
219            None,
220        )
221        .map_err(|_e| ServiceError::InvalidFrame {
222            reason: "username too long to encrypt",
223        })?;
224
225        let response = self
226            .http_request(Method::PUT, "/v1/accounts/username_link")?
227            .send_json(SetUsernameLinkRequest {
228                username_link_encrypted_value: ciphertext,
229                keep_link_handle,
230            })
231            .await?
232            .service_error_for_status()
233            .await?;
234
235        let result: UsernameLinkHandleResponse = response.json().await?;
236
237        Ok(generate_username_link(
238            result.username_link_handle,
239            &entropy,
240        ))
241    }
242}
243
244/// Builds the `https://signal.me/#eu/<base64url>` link from its parts.
245///
246/// `entropy` is the 32-byte link entropy; `handle` is the server-assigned
247/// link handle UUID. The payload is the URL-safe base64 (no padding) of
248/// `entropy || handle`.
249pub fn generate_username_link(
250    handle: uuid::Uuid,
251    entropy: &[u8; usernames::constants::USERNAME_LINK_ENTROPY_SIZE],
252) -> url::Url {
253    let mut payload = entropy.to_vec();
254    payload.extend_from_slice(handle.as_bytes());
255    let mut result = String::from("https://signal.me/#eu/");
256    BASE64_URL_SAFE_NO_PAD.encode_string(&payload, &mut result);
257
258    url::Url::parse(&result).expect("can only generate valid URLs")
259}
260
261#[cfg(test)]
262mod test {
263    use super::*;
264
265    #[test]
266    fn generate_and_parse_link_round_trip() {
267        let entropy = [0x42; usernames::constants::USERNAME_LINK_ENTROPY_SIZE];
268        let handle = uuid::uuid!("9d0652a3-dcc3-4d11-975f-74d61598733f");
269        // deliberately chosen to round-trip cleanly through URL-safe base64
270        let link = generate_username_link(handle, &entropy);
271        assert!(link.as_str().starts_with("https://signal.me/#eu/"));
272
273        let (parsed_handle, parsed_entropy) =
274            parse_username_link(&link).unwrap();
275        assert_eq!(parsed_handle, handle);
276        assert_eq!(parsed_entropy, entropy);
277    }
278
279    #[test]
280    fn parse_link_rejects_wrong_base() {
281        let entropy = [0x42; usernames::constants::USERNAME_LINK_ENTROPY_SIZE];
282        let handle = uuid::uuid!("9d0652a3-dcc3-4d11-975f-74d61598733f");
283        let bad_link = generate_username_link(handle, &entropy)
284            .to_string()
285            .replace("signal.me", "example.com");
286        let bad_link = url::Url::parse(&bad_link).unwrap();
287        assert!(parse_username_link(&bad_link).is_err());
288    }
289
290    #[test]
291    fn parse_link_rejects_missing_eu_marker() {
292        let bad_link = url::Url::parse(
293            "https://signal.me/#foo/R_rHg5IQLE60Qad5l8rV-6x2TMcVnDYvOV-igYXJj6GK1NuNeE9LKI3V_VZ8IH2p",
294        )
295        .unwrap();
296        assert!(parse_username_link(&bad_link).is_err());
297    }
298
299    #[test]
300    fn parse_link_rejects_extra_fragment_segments() {
301        let bad_link =
302            url::Url::parse("https://signal.me/#eu/payload/extra").unwrap();
303        assert!(parse_username_link(&bad_link).is_err());
304    }
305}