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. resolution of the shared secret (from `registration_shared_secret` or its
7//!    `_file` companion), performed at each use;
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	time::{Duration, Instant},
17};
18
19use tuwunel_core::{
20	implement,
21	utils::{self, Secret, is_secret_set, resolve_secret},
22};
23
24type Nonces = BTreeMap<String, Instant>;
25
26const NONCE_LENGTH: usize = 32;
27const NONCE_TTL: Duration = Duration::from_mins(1);
28const NONCE_CAP: usize = 2048;
29
30#[implement(super::Service)]
31pub fn issue_register_nonce(&self) -> String {
32	let nonce = utils::random_string(NONCE_LENGTH);
33	let mut nonces = self
34		.register_nonces
35		.lock()
36		.expect("nonce mutex not poisoned");
37
38	gc_expired(&mut nonces);
39	if nonces.len() >= NONCE_CAP {
40		drop_oldest(&mut nonces);
41	}
42
43	nonces.insert(nonce.clone(), Instant::now());
44	nonce
45}
46
47/// Consume `nonce` if it exists and has not expired. The entry is removed
48/// either way; `true` means the caller may proceed.
49#[implement(super::Service)]
50pub fn consume_register_nonce(&self, nonce: &str) -> bool {
51	let mut nonces = self
52		.register_nonces
53		.lock()
54		.expect("nonce mutex not poisoned");
55
56	nonces
57		.remove(nonce)
58		.is_some_and(|issued| issued.elapsed() < NONCE_TTL)
59}
60
61/// Answered without opening the secret file, since the nonce endpoint this
62/// gates takes no authentication and is not rate limited.
63#[implement(super::Service)]
64pub fn register_is_enabled(&self) -> bool {
65	let config = &self.services.server.config;
66
67	is_secret_set(
68		config.registration_shared_secret_file.as_deref(),
69		config.registration_shared_secret.as_deref(),
70	)
71}
72
73/// Reads `registration_shared_secret_file` on every call, so a rotated secret
74/// takes effect without a restart.
75#[implement(super::Service)]
76pub fn register_shared_secret(&self) -> Option<Secret> {
77	let config = &self.services.server.config;
78
79	resolve_secret(
80		config.registration_shared_secret_file.as_deref(),
81		config.registration_shared_secret.as_deref(),
82		"registration shared secret",
83	)
84}
85
86fn drop_oldest(nonces: &mut Nonces) {
87	nonces
88		.iter()
89		.min_by_key(|(_, issued)| **issued)
90		.map(|(k, _)| k.clone())
91		.as_ref()
92		.map(|oldest| nonces.remove(oldest));
93}
94
95fn gc_expired(nonces: &mut Nonces) { nonces.retain(|_, issued| issued.elapsed() < NONCE_TTL); }