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;
8use crate::push_service::response::error_mapper;
9use crate::push_service::response::SignalServiceResponse;
10
11use super::{Identified, SignalWebSocket, Unidentified};
12
13error_mapper! {
16 put_username_link_errors:
17 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 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 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 #[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 ServiceError::InvalidFrame {
135 reason: "undecryptable username link",
136 }
137 })?;
138
139 Ok(Some(validated_username))
140 }
141}
142
143fn 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 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
254pub 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 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}