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