libsignal_service/websocket/
usernames.rs1use 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 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 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 #[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 ServiceError::InvalidFrame {
125 reason: "undecryptable username link",
126 }
127 })?;
128
129 Ok(Some(validated_username))
130 }
131}
132
133fn 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 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
244pub 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 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}