1use std::time::{Duration, SystemTime};
2
3use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as b64encode};
4use ruma::thirdparty::Medium;
5use serde::{Deserialize, Serialize};
6use subtle::ConstantTimeEq;
7use tuwunel_core::{
8 Err, Result, implement,
9 smallstr::SmallString,
10 utils::{
11 self,
12 hash::sha256,
13 time::{timepoint_from_now, timepoint_has_passed},
14 },
15};
16use tuwunel_database::{Cbor, Deserialized};
17
18use super::{Association, UiaaKey};
19
20type ClaimSid = SmallString<[u8; 43]>;
21
22const TOKEN_LENGTH: usize = 48;
24
25const MAX_VERIFY_ATTEMPTS: u32 = 5;
29
30const UIAA_SESSION_TTL: Duration = Duration::from_hours(24);
32
33#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
35enum PendingUse {
36 #[default]
37 Available,
38 Claimed(Box<UiaaKey>),
39 Spent,
40}
41
42#[derive(Clone, Debug, Deserialize, Serialize)]
46struct Pending {
47 client_secret: String,
48 medium: Medium,
49 address: String,
50 token: String,
51 send_attempt: u64,
52 attempts: u32,
53 validated_at: Option<SystemTime>,
54 expires_at: Option<SystemTime>,
55 #[serde(default)]
56 use_state: PendingUse,
57}
58
59#[derive(Clone, Debug)]
63pub struct PendingOutcome {
64 pub sid: String,
65 pub freshly_minted_token: Option<String>,
66}
67
68#[implement(super::Service)]
74#[tracing::instrument(level = "debug", skip(self, client_secret))]
75pub async fn create_or_reuse_pending(
76 &self,
77 client_secret: &str,
78 medium: Medium,
79 address: &str,
80 send_attempt: u64,
81 ttl: Duration,
82) -> Result<PendingOutcome> {
83 let sid = derive_sid(&medium, address, client_secret);
84 let _pending_lock = self.pending_mutex.lock(&sid).await;
85
86 match self.get_pending(&sid).await {
87 | Err(error) if error.is_not_found() => (),
88 | Err(error) => return Err(error),
89 | Ok(existing) if expired(&existing) => {
90 self.delete_pending_state(&sid, &existing).await?;
91 },
92 | Ok(existing) => {
93 if !matches!(existing.use_state, PendingUse::Available) {
94 return Err!(Request(ThreepidAuthFailed(
95 "The verification session has already been used"
96 )));
97 }
98
99 if existing.validated_at.is_none() && send_attempt <= existing.send_attempt {
100 return Ok(PendingOutcome { sid, freshly_minted_token: None });
101 }
102 },
103 }
104
105 let token = utils::random_string(TOKEN_LENGTH);
106 let expires_at = Some(timepoint_from_now(ttl)?);
107 let pending = Pending {
108 client_secret: client_secret.to_owned(),
109 medium,
110 address: address.to_owned(),
111 token: token.clone(),
112 send_attempt,
113 attempts: 0,
114 validated_at: None,
115 expires_at,
116 use_state: PendingUse::Available,
117 };
118
119 self.persist_pending(&sid, &pending);
120
121 Ok(PendingOutcome { sid, freshly_minted_token: Some(token) })
122}
123
124#[implement(super::Service)]
129#[tracing::instrument(level = "debug", skip(self, client_secret, token))]
130pub async fn validate_pending_token(
131 &self,
132 sid: &str,
133 client_secret: &str,
134 token: &str,
135) -> Result<()> {
136 let _pending_lock = self.pending_mutex.lock(sid).await;
137 let pending = self.get_pending(sid).await?;
138
139 if expired(&pending) {
140 self.delete_pending_state(sid, &pending).await?;
141
142 return Err!(Request(NotFound("The verification session has expired")));
143 }
144
145 if !matches!(pending.use_state, PendingUse::Available) {
146 return Err!(Request(ThreepidAuthFailed(
147 "The verification session has already been used"
148 )));
149 }
150
151 if pending.validated_at.is_some() {
152 return Err!(Request(ThreepidAuthFailed(
153 "The verification session has already been validated"
154 )));
155 }
156
157 let secret_ok = ct_eq(&pending.client_secret, client_secret);
158 let token_ok = ct_eq(&pending.token, token);
159
160 if !secret_ok || !token_ok {
161 let attempts = pending.attempts.saturating_add(1);
162 match attempts >= MAX_VERIFY_ATTEMPTS {
163 | true => self.delete_pending_state(sid, &pending).await?,
164 | false => self.persist_pending(sid, &Pending { attempts, ..pending }),
165 }
166
167 return Err!(Request(ThreepidAuthFailed("Invalid verification token")));
168 }
169
170 let validated_at = Some(SystemTime::now());
171 self.persist_pending(sid, &Pending { validated_at, ..pending });
172
173 Ok(())
174}
175
176#[implement(super::Service)]
181#[tracing::instrument(level = "debug", skip(self, client_secret, claim))]
182pub async fn claim_validated(
183 &self,
184 sid: &str,
185 client_secret: &str,
186 claim: UiaaKey,
187) -> Result<bool> {
188 let _pending_lock = self.pending_mutex.lock(sid).await;
189 let pending = match self.get_pending(sid).await {
190 | Ok(pending) => pending,
191 | Err(error) if error.is_not_found() => return Ok(false),
192 | Err(error) => return Err(error),
193 };
194
195 if expired(&pending) {
196 self.delete_pending_state(sid, &pending).await?;
197
198 return Ok(false);
199 }
200
201 if !ct_eq(&pending.client_secret, client_secret) {
202 return Ok(false);
203 }
204
205 if pending.validated_at.is_none() {
206 return Ok(false);
207 }
208
209 match &pending.use_state {
210 | PendingUse::Available => (),
211 | PendingUse::Claimed(owner) if owner.as_ref() == &claim => (),
212 | PendingUse::Claimed(_) | PendingUse::Spent => return Ok(false),
213 }
214
215 let _claim_lock = self.claim_mutex.lock(&claim).await;
216
217 if self
218 .claim_sid(&claim)
219 .await?
220 .is_some_and(|claimed_sid| claimed_sid != sid)
221 {
222 return Ok(false);
223 }
224
225 let expires_at = Some(timepoint_from_now(UIAA_SESSION_TTL)?).max(pending.expires_at);
226 let mut txn = self.db.database.txn();
227
228 txn.put_raw(&self.db.userdevicesessionid_threepid, &claim, sid);
229
230 let pending = Pending {
231 expires_at,
232 use_state: PendingUse::Claimed(Box::new(claim)),
233 ..pending
234 };
235
236 txn.raw_put(&self.db.threepidsid_pending, sid, Cbor(&pending));
237 txn.execute();
238
239 Ok(true)
240}
241
242#[implement(super::Service)]
247#[tracing::instrument(level = "debug", skip(self, claim))]
248pub async fn refresh_claim(&self, claim: &UiaaKey) -> Result<bool> {
249 let Some(sid) = self.claim_sid(claim).await? else {
250 return Ok(false);
251 };
252
253 let _pending_lock = self.pending_mutex.lock(sid.as_str()).await;
254 let _claim_lock = self.claim_mutex.lock(claim).await;
255
256 if self.claim_sid(claim).await?.as_deref() != Some(sid.as_str()) {
257 return Ok(false);
258 }
259
260 let pending = match self.get_pending(&sid).await {
261 | Ok(pending) => pending,
262 | Err(error) if error.is_not_found() => {
263 self.delete_claim_index(claim);
264
265 return Ok(false);
266 },
267 | Err(error) => return Err(error),
268 };
269
270 if expired(&pending) {
271 self.delete_pending_rows(&sid, Some(claim));
272
273 return Ok(false);
274 }
275
276 if !matches!(&pending.use_state, PendingUse::Claimed(owner) if owner.as_ref() == claim) {
277 self.delete_claim_index(claim);
278
279 return Ok(false);
280 }
281
282 let expires_at = Some(timepoint_from_now(UIAA_SESSION_TTL)?).max(pending.expires_at);
283 let pending = Pending { expires_at, ..pending };
284 let mut txn = self.db.database.txn();
285
286 txn.raw_put(&self.db.threepidsid_pending, &sid, Cbor(&pending));
287 txn.put_raw(&self.db.userdevicesessionid_threepid, claim, &sid);
288 txn.execute();
289
290 Ok(true)
291}
292
293#[implement(super::Service)]
298#[tracing::instrument(level = "debug", skip(self, claim))]
299pub async fn redeem_claim(&self, claim: &UiaaKey) -> Result<Association> {
300 let sid = self
301 .db
302 .userdevicesessionid_threepid
303 .qry(claim)
304 .await
305 .deserialized::<ClaimSid>()?;
306
307 let _pending_lock = self.pending_mutex.lock(sid.as_str()).await;
308 let _claim_lock = self.claim_mutex.lock(claim).await;
309 let current_sid = self
310 .db
311 .userdevicesessionid_threepid
312 .qry(claim)
313 .await
314 .deserialized::<ClaimSid>()?;
315
316 if current_sid != sid {
317 return Err!(Request(ThreepidAuthFailed("The verification session claim has changed")));
318 }
319
320 let pending = match self.get_pending(&sid).await {
321 | Ok(pending) => pending,
322 | Err(error) if error.is_not_found() => {
323 self.delete_claim_index(claim);
324
325 return Err(error);
326 },
327 | Err(error) => return Err(error),
328 };
329
330 if expired(&pending) {
331 self.delete_pending_rows(&sid, Some(claim));
332
333 return Err!(Request(NotFound("The verification session has expired")));
334 }
335
336 if !matches!(&pending.use_state, PendingUse::Claimed(owner) if owner.as_ref() == claim) {
337 self.delete_claim_index(claim);
338
339 return Err!(Request(ThreepidAuthFailed(
340 "The verification session is not owned by this transaction"
341 )));
342 }
343
344 let association = Association {
345 medium: pending.medium.clone(),
346 address: pending.address.clone(),
347 };
348
349 let pending = Pending { use_state: PendingUse::Spent, ..pending };
350 let mut txn = self.db.database.txn();
351
352 txn.raw_put(&self.db.threepidsid_pending, &sid, Cbor(&pending));
353 txn.del(&self.db.userdevicesessionid_threepid, claim);
354 txn.execute();
355
356 Ok(association)
357}
358
359#[implement(super::Service)]
364#[tracing::instrument(level = "debug", skip(self, client_secret))]
365pub async fn redeem_validated(&self, sid: &str, client_secret: &str) -> Result<Association> {
366 let _pending_lock = self.pending_mutex.lock(sid).await;
367 let pending = self.get_pending(sid).await?;
368
369 if expired(&pending) {
370 self.delete_pending_state(sid, &pending).await?;
371
372 return Err!(Request(NotFound("The verification session has expired")));
373 }
374
375 if !ct_eq(&pending.client_secret, client_secret) {
376 return Err!(Request(ThreepidAuthFailed("Client secret does not match")));
377 }
378
379 if pending.validated_at.is_none() {
380 return Err!(Request(ThreepidAuthFailed("The address has not been validated")));
381 }
382
383 if !matches!(pending.use_state, PendingUse::Available) {
384 return Err!(Request(ThreepidAuthFailed(
385 "The verification session has already been used"
386 )));
387 }
388
389 let association = Association {
390 medium: pending.medium.clone(),
391 address: pending.address.clone(),
392 };
393
394 self.persist_pending(sid, &Pending { use_state: PendingUse::Spent, ..pending });
395
396 Ok(association)
397}
398
399#[implement(super::Service)]
404#[tracing::instrument(level = "debug", skip(self, client_secret))]
405pub async fn session_validated(&self, sid: &str, client_secret: &str) -> bool {
406 let Ok(pending) = self.get_pending(sid).await else {
407 return false;
408 };
409
410 !expired(&pending)
411 && ct_eq(&pending.client_secret, client_secret)
412 && pending.validated_at.is_some()
413 && matches!(pending.use_state, PendingUse::Available)
414}
415
416#[implement(super::Service)]
417fn persist_pending(&self, sid: &str, pending: &Pending) {
418 self.db
419 .threepidsid_pending
420 .raw_put(sid, Cbor(pending));
421}
422
423#[implement(super::Service)]
424async fn delete_pending_state(&self, sid: &str, pending: &Pending) -> Result<()> {
425 let PendingUse::Claimed(claim) = &pending.use_state else {
426 self.delete_pending_rows(sid, None);
427
428 return Ok(());
429 };
430
431 let claim = claim.as_ref();
432 let _claim_lock = self.claim_mutex.lock(claim).await;
433 let claim = self
434 .claim_sid(claim)
435 .await?
436 .as_deref()
437 .is_some_and(|claimed_sid| claimed_sid == sid)
438 .then_some(claim);
439
440 self.delete_pending_rows(sid, claim);
441
442 Ok(())
443}
444
445#[implement(super::Service)]
446fn delete_pending_rows(&self, sid: &str, claim: Option<&UiaaKey>) {
447 let mut txn = self.db.database.txn();
448 txn.del_raw(&self.db.threepidsid_pending, sid);
449
450 if let Some(claim) = claim {
451 txn.del(&self.db.userdevicesessionid_threepid, claim);
452 }
453
454 txn.execute();
455}
456
457#[implement(super::Service)]
458fn delete_claim_index(&self, claim: &UiaaKey) { self.db.userdevicesessionid_threepid.del(claim); }
459
460#[implement(super::Service)]
461async fn claim_sid(&self, claim: &UiaaKey) -> Result<Option<ClaimSid>> {
462 self.db
463 .userdevicesessionid_threepid
464 .qry(claim)
465 .await
466 .deserialized::<ClaimSid>()
467 .map(Some)
468 .or_else(|error| error.is_not_found().then_some(None).ok_or(error))
469}
470
471#[implement(super::Service)]
472async fn get_pending(&self, sid: &str) -> Result<Pending> {
473 self.db
474 .threepidsid_pending
475 .get(sid)
476 .await
477 .deserialized::<Cbor<_>>()
478 .map(|Cbor(pending)| pending)
479}
480
481fn derive_sid(medium: &Medium, address: &str, client_secret: &str) -> String {
483 let parts = [medium.as_str().as_bytes(), address.as_bytes(), client_secret.as_bytes()];
484 let digest = sha256::delimited(parts.into_iter());
485
486 b64encode.encode(digest)
487}
488
489fn expired(pending: &Pending) -> bool {
490 pending
491 .expires_at
492 .is_some_and(timepoint_has_passed)
493}
494
495fn ct_eq(a: &str, b: &str) -> bool { a.as_bytes().ct_eq(b.as_bytes()).into() }
496
497#[cfg(test)]
498mod tests {
499 use std::time::SystemTime;
500
501 use ruma::{device_id, thirdparty::Medium, user_id};
502 use serde::Serialize;
503 use tuwunel_database::{Cbor, deserialize_from_slice, serialize_to_vec};
504
505 use super::{Pending, PendingUse};
506
507 #[derive(Serialize)]
508 struct LegacyPending {
509 client_secret: String,
510 medium: Medium,
511 address: String,
512 token: String,
513 send_attempt: u64,
514 attempts: u32,
515 validated_at: Option<SystemTime>,
516 expires_at: Option<SystemTime>,
517 }
518
519 fn pending(use_state: PendingUse) -> Pending {
520 Pending {
521 client_secret: "secret".into(),
522 medium: Medium::Email,
523 address: "user@example.com".into(),
524 token: "token".into(),
525 send_attempt: 1,
526 attempts: 0,
527 validated_at: None,
528 expires_at: None,
529 use_state,
530 }
531 }
532
533 fn round_trip(pending: Pending) -> Pending {
534 let encoded = serialize_to_vec(Cbor(pending)).expect("pending row should serialize");
535 let Cbor(pending): Cbor<Pending> =
536 deserialize_from_slice(&encoded).expect("pending row should deserialize");
537
538 pending
539 }
540
541 #[test]
542 fn legacy_pending_defaults_to_available() {
543 let legacy = LegacyPending {
544 client_secret: "secret".into(),
545 medium: Medium::Email,
546 address: "user@example.com".into(),
547 token: "token".into(),
548 send_attempt: 1,
549 attempts: 0,
550 validated_at: None,
551 expires_at: None,
552 };
553
554 let encoded =
555 serialize_to_vec(Cbor(legacy)).expect("legacy pending row should serialize");
556
557 let Cbor(pending): Cbor<Pending> =
558 deserialize_from_slice(&encoded).expect("legacy pending row should deserialize");
559
560 assert_eq!(pending.use_state, PendingUse::Available);
561 }
562
563 #[test]
564 fn claimed_pending_round_trip_preserves_exact_key() {
565 let claim = (
566 user_id!("@owner:example.org").to_owned(),
567 device_id!("DEVICE").to_owned(),
568 "0123456789abcdefghijklmnopqrstuv".into(),
569 );
570 let pending = round_trip(pending(PendingUse::Claimed(Box::new(claim.clone()))));
571
572 assert_eq!(pending.use_state, PendingUse::Claimed(Box::new(claim)));
573 }
574
575 #[test]
576 fn spent_pending_round_trip_preserves_tombstone() {
577 let pending = round_trip(pending(PendingUse::Spent));
578
579 assert_eq!(pending.use_state, PendingUse::Spent);
580 }
581}