1use std::collections::{HashMap, HashSet};
8use std::net::IpAddr;
9use std::path::Path;
10
11use figment::{
12 providers::{Format, Toml},
13 Figment,
14};
15use reishi_quinn::{PqPublicKey, PublicKey};
16use serde::Deserialize;
17use tracing::warn;
18
19use quincy::config::{decode_base64_key, AddressRange, Bandwidth};
20use quincy::error::{AuthError, Result};
21
22#[derive(Clone, Debug)]
37pub struct UsersFile {
38 pub users: HashMap<String, UserEntry>,
40 noise_key_to_user: HashMap<PublicKey, String>,
42 noise_pq_key_to_user: HashMap<PqPublicKey, String>,
44 cert_fingerprint_to_user: HashMap<String, String>,
46}
47
48#[derive(Deserialize)]
50struct RawUsersFile {
51 #[serde(default)]
52 users: HashMap<String, UserEntry>,
53}
54
55#[derive(Clone, Debug, Deserialize)]
57pub struct UserEntry {
58 #[serde(default)]
60 pub authorized_keys: Vec<String>,
61 #[serde(default)]
64 pub authorized_certs: Vec<String>,
65 #[serde(default)]
69 pub bandwidth_limit: Option<Bandwidth>,
70 #[serde(default)]
77 pub address_pool: Vec<AddressRange>,
78}
79
80impl UsersFile {
81 pub fn load(path: &Path) -> Result<Self> {
91 if !path.exists() {
92 return Err(AuthError::StoreUnavailable.into());
93 }
94
95 let figment = Figment::new().merge(Toml::file(path));
96 let raw: RawUsersFile = figment.extract().map_err(|_| AuthError::StoreUnavailable)?;
97
98 Self::from_raw(raw)
99 }
100
101 pub fn parse(content: &str) -> Result<Self> {
111 let figment = Figment::new().merge(Toml::string(content));
112 let raw: RawUsersFile = figment.extract().map_err(|_| AuthError::StoreUnavailable)?;
113
114 Self::from_raw(raw)
115 }
116
117 fn validate_fingerprint(fingerprint: &str, username: &str) -> Result<()> {
126 let Some(hex_part) = fingerprint.strip_prefix("sha256:") else {
127 return Err(AuthError::InvalidUserStore {
128 reason: format!(
129 "user '{username}': invalid fingerprint format '{fingerprint}' \
130 (must start with 'sha256:')"
131 ),
132 }
133 .into());
134 };
135
136 if hex_part.len() != 64 || !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
137 return Err(AuthError::InvalidUserStore {
138 reason: format!(
139 "user '{username}': invalid fingerprint format '{fingerprint}' \
140 (expected 'sha256:' followed by exactly 64 hex characters)"
141 ),
142 }
143 .into());
144 }
145
146 Ok(())
147 }
148
149 fn from_raw(raw: RawUsersFile) -> Result<Self> {
158 let mut noise_key_to_user = HashMap::new();
159 let mut noise_pq_key_to_user = HashMap::new();
160 let mut cert_fingerprint_to_user = HashMap::new();
161
162 for (username, entry) in &raw.users {
163 for key_b64 in &entry.authorized_keys {
164 let mut decoded = false;
165
166 if let Ok(bytes) = decode_base64_key::<{ PublicKey::LEN }>(key_b64) {
168 let pubkey = PublicKey::from_bytes(*bytes);
169 if let Some(existing) = noise_key_to_user.get(&pubkey) {
170 return Err(AuthError::InvalidUserStore {
171 reason: format!(
172 "duplicate Noise X25519 key for users '{existing}' and '{username}'"
173 ),
174 }
175 .into());
176 }
177 noise_key_to_user.insert(pubkey, username.clone());
178 decoded = true;
179 }
180
181 if let Ok(bytes) = decode_base64_key::<{ PqPublicKey::LEN }>(key_b64) {
183 let pq_pubkey = PqPublicKey::from_bytes(*bytes);
184 if let Some(existing) = noise_pq_key_to_user.get(&pq_pubkey) {
185 return Err(AuthError::InvalidUserStore {
186 reason: format!(
187 "duplicate Noise PQ key for users '{existing}' and '{username}'"
188 ),
189 }
190 .into());
191 }
192 noise_pq_key_to_user.insert(pq_pubkey, username.clone());
193 decoded = true;
194 }
195
196 if !decoded {
197 warn!(
198 "Ignoring unrecognized key for user '{username}': \
199 not a valid X25519 ({} bytes) or PQ ({} bytes) public key",
200 PublicKey::LEN,
201 PqPublicKey::LEN,
202 );
203 }
204 }
205
206 for fp in &entry.authorized_certs {
207 Self::validate_fingerprint(fp, username)?;
208
209 let normalized = fp.to_lowercase();
210 if let Some(existing) = cert_fingerprint_to_user.get(&normalized) {
211 return Err(AuthError::InvalidUserStore {
212 reason: format!(
213 "duplicate certificate fingerprint '{normalized}' \
214 for users '{existing}' and '{username}'"
215 ),
216 }
217 .into());
218 }
219 cert_fingerprint_to_user.insert(normalized, username.clone());
220 }
221 }
222
223 let mut all_pool_addresses: HashMap<IpAddr, String> = HashMap::new();
225 for (username, entry) in &raw.users {
226 let mut user_addresses: HashSet<IpAddr> = HashSet::new();
227 for range in &entry.address_pool {
228 for addr in range.into_inner() {
229 if !user_addresses.insert(addr) {
230 return Err(AuthError::InvalidUserStore {
231 reason: format!(
232 "user '{username}': duplicate address {addr} in address_pool"
233 ),
234 }
235 .into());
236 }
237 if let Some(existing) = all_pool_addresses.get(&addr) {
238 return Err(AuthError::InvalidUserStore {
239 reason: format!(
240 "address {addr} claimed by both users '{existing}' and '{username}'"
241 ),
242 }
243 .into());
244 }
245 all_pool_addresses.insert(addr, username.clone());
246 }
247 }
248 }
249
250 Ok(Self {
251 users: raw.users,
252 noise_key_to_user,
253 noise_pq_key_to_user,
254 cert_fingerprint_to_user,
255 })
256 }
257
258 pub fn find_user_by_noise_pubkey(&self, pubkey: &PublicKey) -> Option<&str> {
266 self.noise_key_to_user.get(pubkey).map(|s| s.as_str())
267 }
268
269 pub fn find_user_by_noise_pq_pubkey(&self, pq_pubkey: &PqPublicKey) -> Option<&str> {
277 self.noise_pq_key_to_user.get(pq_pubkey).map(|s| s.as_str())
278 }
279
280 pub fn find_user_by_cert_fingerprint(&self, fingerprint: &str) -> Option<&str> {
288 self.cert_fingerprint_to_user
289 .get(&fingerprint.to_lowercase())
290 .map(|s| s.as_str())
291 }
292
293 pub fn collect_noise_public_keys(&self) -> HashSet<PublicKey> {
300 self.noise_key_to_user.keys().cloned().collect()
301 }
302
303 pub fn collect_noise_pq_public_keys(&self) -> HashSet<PqPublicKey> {
308 self.noise_pq_key_to_user.keys().cloned().collect()
309 }
310
311 pub fn collect_cert_fingerprints(&self) -> HashSet<String> {
316 self.cert_fingerprint_to_user.keys().cloned().collect()
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 const SAMPLE_USERS_TOML: &str = r#"
325 [users.alice]
326 authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
327 authorized_certs = ["sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"]
328 bandwidth_limit = "10 mbps"
329
330 [users.bob]
331 authorized_keys = ["AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
332 authorized_certs = []
333 "#;
334
335 #[test]
336 fn parse_users_file() {
337 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
338 assert_eq!(users.users.len(), 2);
339 assert!(users.users.contains_key("alice"));
340 assert!(users.users.contains_key("bob"));
341 }
342
343 #[test]
344 fn parse_empty_users_file() {
345 let users = UsersFile::parse("[users]").expect("valid TOML");
346 assert!(users.users.is_empty());
347 }
348
349 #[test]
350 fn parse_users_file_no_users_section() {
351 let users = UsersFile::parse("").expect("valid TOML with defaults");
352 assert!(users.users.is_empty());
353 }
354
355 #[test]
356 fn find_user_by_noise_pubkey_found() {
357 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
358 let key = PublicKey::from_bytes([0u8; 32]);
360 assert_eq!(users.find_user_by_noise_pubkey(&key), Some("alice"));
361 }
362
363 #[test]
364 fn find_user_by_noise_pubkey_not_found() {
365 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
366 let key = PublicKey::from_bytes([0xFFu8; 32]);
367 assert_eq!(users.find_user_by_noise_pubkey(&key), None);
368 }
369
370 #[test]
371 fn find_user_by_cert_fingerprint_found() {
372 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
373 assert_eq!(
374 users.find_user_by_cert_fingerprint(
375 "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
376 ),
377 Some("alice")
378 );
379 }
380
381 #[test]
382 fn find_user_by_cert_fingerprint_not_found() {
383 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
384 assert_eq!(
385 users.find_user_by_cert_fingerprint("sha256:nonexistent"),
386 None
387 );
388 }
389
390 #[test]
391 fn collect_noise_public_keys() {
392 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
393 let keys = users.collect_noise_public_keys();
394 assert_eq!(keys.len(), 2);
396 }
397
398 #[test]
399 fn collect_cert_fingerprints() {
400 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
401 let fps = users.collect_cert_fingerprints();
402 assert_eq!(fps.len(), 1);
403 assert!(
404 fps.contains("sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890")
405 );
406 }
407
408 #[test]
409 fn user_entry_defaults() {
410 let toml = r#"
411 [users.charlie]
412 "#;
413 let users = UsersFile::parse(toml).expect("valid TOML");
414 let charlie = users.users.get("charlie").expect("charlie exists");
415 assert!(charlie.authorized_keys.is_empty());
416 assert!(charlie.authorized_certs.is_empty());
417 }
418
419 #[test]
420 fn load_nonexistent_file() {
421 let result = UsersFile::load(Path::new("/nonexistent/users.toml"));
422 assert!(result.is_err());
423 }
424
425 #[test]
426 fn indices_built_for_empty_users() {
427 let users = UsersFile::parse("").expect("valid TOML");
428 assert!(users.noise_key_to_user.is_empty());
429 assert!(users.noise_pq_key_to_user.is_empty());
430 assert!(users.cert_fingerprint_to_user.is_empty());
431 }
432
433 #[test]
434 fn indices_built_correctly() {
435 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
436
437 assert_eq!(users.noise_key_to_user.len(), 2);
439 assert_eq!(users.cert_fingerprint_to_user.len(), 1);
440
441 let alice_key = PublicKey::from_bytes([0u8; 32]);
442 assert_eq!(users.noise_key_to_user.get(&alice_key).unwrap(), "alice");
443 assert_eq!(
444 users
445 .cert_fingerprint_to_user
446 .get("sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890")
447 .unwrap(),
448 "alice"
449 );
450 }
451
452 #[test]
453 fn duplicate_noise_key_rejected() {
454 let toml = r#"
456 [users.alice]
457 authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
458
459 [users.bob]
460 authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
461 "#;
462 let result = UsersFile::parse(toml);
463 assert!(result.is_err());
464 let err = result.unwrap_err().to_string();
465 assert!(err.contains("duplicate Noise X25519 key"), "error: {err}");
466 }
467
468 #[test]
469 fn duplicate_cert_fingerprint_rejected() {
470 let fp = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
471 let toml = format!(
472 r#"
473 [users.alice]
474 authorized_certs = ["{fp}"]
475
476 [users.bob]
477 authorized_certs = ["{fp}"]
478 "#
479 );
480 let result = UsersFile::parse(&toml);
481 assert!(result.is_err());
482 let err = result.unwrap_err().to_string();
483 assert!(
484 err.contains("duplicate certificate fingerprint"),
485 "error: {err}"
486 );
487 }
488
489 #[test]
490 fn duplicate_cert_fingerprint_case_insensitive() {
491 let toml = r#"
493 [users.alice]
494 authorized_certs = ["sha256:ABCDEF1234567890abcdef1234567890abcdef1234567890abcdef1234567890"]
495
496 [users.bob]
497 authorized_certs = ["sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"]
498 "#;
499 let result = UsersFile::parse(toml);
500 assert!(result.is_err());
501 let err = result.unwrap_err().to_string();
502 assert!(
503 err.contains("duplicate certificate fingerprint"),
504 "error: {err}"
505 );
506 }
507
508 #[test]
509 fn fingerprint_normalized_to_lowercase() {
510 let toml = r#"
511 [users.alice]
512 authorized_certs = ["sha256:ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"]
513 "#;
514 let users = UsersFile::parse(toml).expect("valid TOML");
515 assert_eq!(
517 users.find_user_by_cert_fingerprint(
518 "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
519 ),
520 Some("alice")
521 );
522 }
523
524 #[test]
525 fn find_user_by_cert_fingerprint_mixed_case() {
526 let toml = r#"
527 [users.alice]
528 authorized_certs = ["sha256:ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"]
529 "#;
530 let users = UsersFile::parse(toml).expect("valid TOML");
531 assert_eq!(
533 users.find_user_by_cert_fingerprint(
534 "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
535 ),
536 Some("alice")
537 );
538 assert_eq!(
540 users.find_user_by_cert_fingerprint(
541 "sha256:AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890"
542 ),
543 Some("alice")
544 );
545 }
546
547 #[test]
548 fn fingerprint_missing_sha256_prefix_rejected() {
549 let toml = r#"
550 [users.alice]
551 authorized_certs = ["abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"]
552 "#;
553 let result = UsersFile::parse(toml);
554 assert!(result.is_err());
555 let err = result.unwrap_err().to_string();
556 assert!(err.contains("must start with 'sha256:'"), "error: {err}");
557 }
558
559 #[test]
560 fn fingerprint_wrong_hex_length_rejected() {
561 let toml = r#"
562 [users.alice]
563 authorized_certs = ["sha256:abcdef"]
564 "#;
565 let result = UsersFile::parse(toml);
566 assert!(result.is_err());
567 let err = result.unwrap_err().to_string();
568 assert!(err.contains("exactly 64 hex characters"), "error: {err}");
569 }
570
571 #[test]
572 fn fingerprint_non_hex_chars_rejected() {
573 let toml = r#"
574 [users.alice]
575 authorized_certs = ["sha256:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
576 "#;
577 let result = UsersFile::parse(toml);
578 assert!(result.is_err());
579 let err = result.unwrap_err().to_string();
580 assert!(err.contains("exactly 64 hex characters"), "error: {err}");
581 }
582
583 #[test]
584 fn fingerprint_valid_formats_accepted() {
585 let toml = r#"
587 [users.alice]
588 authorized_certs = ["sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
589 "#;
590 assert!(UsersFile::parse(toml).is_ok());
591
592 let toml = r#"
594 [users.alice]
595 authorized_certs = ["sha256:0123456789ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef"]
596 "#;
597 assert!(UsersFile::parse(toml).is_ok());
598 }
599
600 #[test]
601 fn parse_user_entry_with_bandwidth_limit() {
602 let users = UsersFile::parse(SAMPLE_USERS_TOML).expect("valid TOML");
603 let alice = users.users.get("alice").expect("alice exists");
604 assert_eq!(
605 alice.bandwidth_limit,
606 Some(Bandwidth::from_bytes_per_second(1_250_000))
607 );
608 let bob = users.users.get("bob").expect("bob exists");
609 assert_eq!(bob.bandwidth_limit, None);
610 }
611
612 #[test]
613 fn same_key_for_same_user_rejected() {
614 let toml = r#"
616 [users.alice]
617 authorized_keys = [
618 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
619 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
620 ]
621 "#;
622 let result = UsersFile::parse(toml);
623 assert!(result.is_err());
624 let err = result.unwrap_err().to_string();
625 assert!(err.contains("duplicate Noise X25519 key"), "error: {err}");
626 }
627
628 #[test]
629 fn parse_user_entry_with_address_pool() {
630 let toml = r#"
631 [users.alice]
632 authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
633 address_pool = ["10.0.0.100/32", "10.0.0.101 - 10.0.0.103"]
634 "#;
635 let users = UsersFile::parse(toml).expect("valid TOML");
636 let alice = users.users.get("alice").expect("alice exists");
637 assert_eq!(alice.address_pool.len(), 2);
638 }
639
640 #[test]
641 fn parse_user_entry_without_address_pool() {
642 let toml = r#"
643 [users.alice]
644 authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
645 "#;
646 let users = UsersFile::parse(toml).expect("valid TOML");
647 let alice = users.users.get("alice").expect("alice exists");
648 assert!(alice.address_pool.is_empty());
649 }
650
651 #[test]
652 fn overlapping_address_pools_between_users_rejected() {
653 let toml = r#"
654 [users.alice]
655 authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
656 address_pool = ["10.0.0.100/31"]
657
658 [users.bob]
659 authorized_keys = ["AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
660 address_pool = ["10.0.0.100 - 10.0.0.101"]
661 "#;
662 let result = UsersFile::parse(toml);
663 assert!(result.is_err());
664 let err = result.unwrap_err().to_string();
665 assert!(err.contains("claimed by both users"), "error: {err}");
666 }
667
668 #[test]
669 fn duplicate_addresses_within_user_pool_rejected() {
670 let toml = r#"
671 [users.alice]
672 authorized_keys = ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
673 address_pool = ["10.0.0.100/32", "10.0.0.100/32"]
674 "#;
675 let result = UsersFile::parse(toml);
676 assert!(result.is_err());
677 let err = result.unwrap_err().to_string();
678 assert!(err.contains("duplicate address"), "error: {err}");
679 }
680}