Skip to main content

tuwunel_service/threepid/
pending.rs

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
22/// Characters minted for the single-use, server-private validation token.
23const TOKEN_LENGTH: usize = 48;
24
25/// Failed-validation ceiling: the session self-destructs once this many wrong
26/// submissions have been counted, so the Nth burns and N-1 are tolerated. Caps
27/// token brute-force (mirrors the device-grant ceiling).
28const MAX_VERIFY_ATTEMPTS: u32 = 5;
29
30/// Persistence lifetime shared by UIAA sessions and their threepid claims.
31const UIAA_SESSION_TTL: Duration = Duration::from_hours(24);
32
33/// Single-use state of a validated pending session.
34#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
35enum PendingUse {
36	#[default]
37	Available,
38	Claimed(Box<UiaaKey>),
39	Spent,
40}
41
42/// CBOR value of a `threepidsid_pending` row. The whole row carries a TTL via
43/// `expires_at` so a validated-but-unconsumed session self-reaps rather than
44/// leaking.
45#[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/// Result of [`create_or_reuse_pending`]: the session id to hand the client,
60/// and the freshly minted token when a new message must be sent. A reused
61/// session yields `None`, signalling no new mail.
62#[derive(Clone, Debug)]
63pub struct PendingOutcome {
64	pub sid: String,
65	pub freshly_minted_token: Option<String>,
66}
67
68/// Open a pending verification, or reuse an in-flight one for the same
69/// request identity. The session id is derived from `(medium, address,
70/// client_secret)`, so a resubmit collides on the same row: a non-validated
71/// session whose `send_attempt` did not advance returns the same `sid` with no
72/// new token (and thus no new mail), per the send-attempt dedup rule.
73#[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/// Validate a submitted token against a pending session. A wrong
125/// `client_secret` or `token` counts toward the attempt ceiling and burns the
126/// session once exceeded; the caller learns nothing about session or token
127/// liveness beyond pass or fail.
128#[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/// Exclusively claim a validated pending session for one UIAA transaction.
177///
178/// Invalid or unavailable proofs return `false`; storage and decoding failures
179/// remain errors so registration cannot silently continue through them.
180#[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/// Refresh a claim that is still owned by one UIAA transaction.
243///
244/// Rewriting both rows keeps their persistence lifetime aligned with later
245/// successful stages that refresh the owning UIAA session.
246#[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/// Spends the validated threepid owned by one UIAA transaction.
294///
295/// Redemption atomically records the pending proof as spent and removes the
296/// claim index, so retries cannot yield the association again.
297#[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/// Spends an unclaimed validated session directly, returning its association.
360///
361/// The pending row remains as a spent tombstone until expiry so replayed
362/// requests fail closed instead of reusing a previously accepted proof.
363#[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/// Reports whether an unclaimed pending session is ready for UIAA.
400///
401/// This non-consuming gate maps wrong secrets, expired or unknown sessions,
402/// spent proofs, and storage failures to `false`, revealing no extra liveness.
403#[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
481/// Deterministic session id binding the request identity to one storage key.
482fn 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}