Skip to main content

tuwunel_service/admin/
register.rs

1//! Synapse-compatible shared-secret registration backend.
2//!
3//! Pairs with the HTTP handlers in `tuwunel_api::client::admin` that serve
4//! `/_synapse/admin/v1/register`. Owns:
5//!
6//! 1. the resolved shared secret (from `registration_shared_secret` or its
7//!    `_file` companion);
8//! 2. a short-lived in-memory nonce store with a 60-second TTL.
9//!
10//! The nonce store lives in RAM rather than RocksDB on purpose: each entry's
11//! useful lifespan is shorter than a single block-cache eviction tick, and
12//! the working set is bounded by [`NONCE_CAP`] regardless of traffic.
13
14use std::{
15	collections::BTreeMap,
16	fs,
17	time::{Duration, Instant},
18};
19
20use tuwunel_core::{Config, error, implement, utils};
21
22type Nonces = BTreeMap<String, Instant>;
23
24const NONCE_LENGTH: usize = 32;
25const NONCE_TTL: Duration = Duration::from_mins(1);
26const NONCE_CAP: usize = 2048;
27
28#[implement(super::Service)]
29pub fn issue_register_nonce(&self) -> String {
30	let nonce = utils::random_string(NONCE_LENGTH);
31	let mut nonces = self
32		.register_nonces
33		.lock()
34		.expect("nonce mutex not poisoned");
35
36	gc_expired(&mut nonces);
37	if nonces.len() >= NONCE_CAP {
38		drop_oldest(&mut nonces);
39	}
40
41	nonces.insert(nonce.clone(), Instant::now());
42	nonce
43}
44
45/// Consume `nonce` if it exists and has not expired. The entry is removed
46/// either way; `true` means the caller may proceed.
47#[implement(super::Service)]
48pub fn consume_register_nonce(&self, nonce: &str) -> bool {
49	let mut nonces = self
50		.register_nonces
51		.lock()
52		.expect("nonce mutex not poisoned");
53
54	nonces
55		.remove(nonce)
56		.is_some_and(|issued| issued.elapsed() < NONCE_TTL)
57}
58
59#[implement(super::Service)]
60#[inline]
61pub fn register_shared_secret(&self) -> Option<&str> { self.register_shared_secret.as_deref() }
62
63#[implement(super::Service)]
64#[inline]
65pub fn register_is_enabled(&self) -> bool { self.register_shared_secret.is_some() }
66
67pub(super) fn resolve_shared_secret(config: &Config) -> Option<String> {
68	config
69		.registration_shared_secret_file
70		.as_ref()
71		.and_then(|path| {
72			fs::read_to_string(path)
73				.inspect_err(|e| {
74					error!("Failed to read the registration shared secret file: {e}");
75				})
76				.ok()
77				.as_deref()
78				.map(str::trim)
79				.map(ToOwned::to_owned)
80		})
81		.or_else(|| config.registration_shared_secret.clone())
82		.filter(|s| !s.is_empty())
83}
84
85fn drop_oldest(nonces: &mut Nonces) {
86	nonces
87		.iter()
88		.min_by_key(|(_, issued)| **issued)
89		.map(|(k, _)| k.clone())
90		.as_ref()
91		.map(|oldest| nonces.remove(oldest));
92}
93
94fn gc_expired(nonces: &mut Nonces) { nonces.retain(|_, issued| issued.elapsed() < NONCE_TTL); }