1use std::any::TypeId;
18use std::collections::HashMap;
19use std::fmt;
20use std::io;
21use std::path::Path;
22
23use base64::Engine;
24use base64::engine::general_purpose::STANDARD as BASE64;
25
26const OPERATOR_TOKEN: &str = "operator";
28const REPLICATION_TOKEN: &str = "replication";
31
32pub trait Role: Copy + Eq + fmt::Debug + Send + Sync + 'static {
60 const ROLES: &'static [(&'static str, Self)];
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum NoRoles {}
73
74impl Role for NoRoles {
75 const ROLES: &'static [(&'static str, Self)] = &[];
76}
77
78pub fn validate_roles<R: Role>() -> Result<(), String> {
89 let roles = R::ROLES;
90 if roles.len() > RoleId::CAPACITY {
91 return Err(format!(
92 "role table: {} roles, more than the {} a role index holds",
93 roles.len(),
94 RoleId::CAPACITY
95 ));
96 }
97 for (i, &(token, role)) in roles.iter().enumerate() {
101 if !is_valid_token(token) {
102 return Err(format!(
103 "role table: '{token}' is not a valid role token (lowercase ASCII \
104 letters, digits, '-' and '_', starting with a letter)"
105 ));
106 }
107 if token == OPERATOR_TOKEN || token == REPLICATION_TOKEN {
108 return Err(format!(
109 "role table: '{token}' is the runtime's own role token"
110 ));
111 }
112 for &(earlier_token, earlier_role) in &roles[..i] {
113 if earlier_token == token {
114 return Err(format!("role table: token '{token}' is listed twice"));
115 }
116 if earlier_role == role {
117 return Err(format!(
118 "role table: {role:?} is listed twice, as '{earlier_token}' and '{token}'"
119 ));
120 }
121 }
122 }
123 Ok(())
124}
125
126fn is_valid_token(token: &str) -> bool {
128 let mut bytes = token.bytes();
129 matches!(bytes.next(), Some(b'a'..=b'z'))
130 && bytes.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_'))
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub struct RoleId(
142 u8,
146);
147
148impl RoleId {
149 const CAPACITY: usize = u8::MAX as usize + 1;
151
152 pub(crate) fn index(self) -> usize {
154 usize::from(self.0)
155 }
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub enum ClientRole<R> {
171 Operator,
173 App(R),
175}
176
177impl<R> ClientRole<R> {
178 pub fn is_operator(&self) -> bool {
180 matches!(self, ClientRole::Operator)
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum KeyRole {
194 Replication,
197 Client(ClientRole<RoleId>),
199}
200
201impl KeyRole {
202 pub fn is_replication(self) -> bool {
205 matches!(self, KeyRole::Replication)
206 }
207
208 pub fn client(self) -> Option<ClientRole<RoleId>> {
218 match self {
219 KeyRole::Replication => None,
220 KeyRole::Client(role) => Some(role),
221 }
222 }
223}
224
225#[derive(Debug)]
234pub struct AuthorizedKeys {
235 keys: HashMap<[u8; 32], KeyRole>,
237 app_tokens: Box<[&'static str]>,
241 role_type: TypeId,
245}
246
247impl AuthorizedKeys {
248 pub fn load<R: Role>(path: &Path) -> io::Result<Self> {
264 let content = std::fs::read_to_string(path)?;
265 Self::parse::<R>(&content).map_err(|e| io::Error::other(format!("{path:?}: {e}")))
266 }
267
268 pub fn parse<R: Role>(content: &str) -> Result<Self, String> {
272 validate_roles::<R>()?;
273 let mut keys = HashMap::new();
274
275 for (line_num, line) in content.lines().enumerate() {
276 let line = line.trim();
277 if line.is_empty() || line.starts_with('#') {
278 continue;
279 }
280
281 let mut parts = line.split_whitespace();
282 let role_token = parts
283 .next()
284 .ok_or_else(|| format!("line {}: missing role", line_num + 1))?;
285 let key_b64 = parts
286 .next()
287 .ok_or_else(|| format!("line {}: missing public key", line_num + 1))?;
288
289 let role = role_for_token::<R>(role_token).ok_or_else(|| {
290 let expected: Vec<&str> = [OPERATOR_TOKEN, REPLICATION_TOKEN]
291 .into_iter()
292 .chain(R::ROLES.iter().map(|&(token, _)| token))
293 .collect();
294 format!(
295 "line {}: unknown role '{role_token}' (expected {})",
296 line_num + 1,
297 expected.join(", ")
298 )
299 })?;
300
301 let key_bytes = BASE64
302 .decode(key_b64)
303 .map_err(|e| format!("line {}: invalid base64: {e}", line_num + 1))?;
304
305 if key_bytes.len() != 32 {
306 return Err(format!(
307 "line {}: public key must be 32 bytes, got {}",
308 line_num + 1,
309 key_bytes.len()
310 ));
311 }
312
313 let mut key = [0u8; 32];
314 key.copy_from_slice(&key_bytes);
315 if keys.insert(key, role).is_some() {
316 return Err(format!(
317 "line {}: public key already listed on an earlier line",
318 line_num + 1
319 ));
320 }
321 }
322
323 Ok(Self {
324 keys,
325 app_tokens: R::ROLES.iter().map(|&(token, _)| token).collect(),
326 role_type: TypeId::of::<R>(),
327 })
328 }
329
330 pub fn lookup(&self, public_key: &[u8; 32]) -> Option<KeyRole> {
333 self.keys.get(public_key).copied()
334 }
335
336 pub fn token(&self, role: KeyRole) -> &'static str {
340 match role {
341 KeyRole::Replication => REPLICATION_TOKEN,
342 KeyRole::Client(ClientRole::Operator) => OPERATOR_TOKEN,
343 KeyRole::Client(ClientRole::App(id)) => {
344 self.app_tokens.get(id.index()).copied().unwrap_or("?")
345 }
346 }
347 }
348
349 pub(crate) fn is_for<R: Role>(&self) -> bool {
351 self.role_type == TypeId::of::<R>()
352 }
353
354 pub fn len(&self) -> usize {
356 self.keys.len()
357 }
358
359 pub fn is_empty(&self) -> bool {
361 self.keys.is_empty()
362 }
363}
364
365fn role_for_token<R: Role>(token: &str) -> Option<KeyRole> {
369 match token {
370 OPERATOR_TOKEN => Some(KeyRole::Client(ClientRole::Operator)),
371 REPLICATION_TOKEN => Some(KeyRole::Replication),
372 _ => R::ROLES
373 .iter()
374 .position(|&(app_token, _)| app_token == token)
375 .map(|index| {
376 let id = u8::try_from(index).expect("role table validated to fit a u8 index");
379 KeyRole::Client(ClientRole::App(RoleId(id)))
380 }),
381 }
382}
383
384#[cfg(test)]
385pub(crate) mod tests {
386 use super::*;
387
388 const ZERO_KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
390
391 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
394 pub(crate) enum DeskRole {
395 Trader,
396 Custodian,
397 ReadOnly,
398 }
399
400 impl Role for DeskRole {
401 const ROLES: &'static [(&'static str, Self)] = &[
402 ("trader", DeskRole::Trader),
403 ("custodian", DeskRole::Custodian),
404 ("readonly", DeskRole::ReadOnly),
405 ];
406 }
407
408 fn listing<R: Role>(token: &str) -> Result<AuthorizedKeys, String> {
410 AuthorizedKeys::parse::<R>(&format!("{token} {ZERO_KEY} test\n"))
411 }
412
413 macro_rules! role_table {
417 ($name:ident, $table:expr) => {
418 #[allow(dead_code)]
420 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
421 enum $name {
422 A,
423 B,
424 }
425 impl Role for $name {
426 const ROLES: &'static [(&'static str, Self)] = $table;
427 }
428 };
429 }
430
431 #[test]
432 fn a_valid_role_table_is_accepted() {
433 validate_roles::<DeskRole>().unwrap();
434 validate_roles::<NoRoles>().unwrap();
435 role_table!(Mixed, &[("desk-2", Mixed::A), ("read_only9", Mixed::B)]);
436 validate_roles::<Mixed>().unwrap();
437 }
438
439 #[test]
440 fn a_runtime_token_in_the_role_table_is_refused() {
441 role_table!(ClaimsOperator, &[("operator", ClaimsOperator::A)]);
442 role_table!(ClaimsReplication, &[("replication", ClaimsReplication::A)]);
443 for err in [
444 validate_roles::<ClaimsOperator>().unwrap_err(),
445 validate_roles::<ClaimsReplication>().unwrap_err(),
446 ] {
447 assert!(err.contains("is the runtime's own role token"), "{err}");
448 }
449 }
450
451 #[test]
452 fn a_token_listed_twice_is_refused() {
453 role_table!(Twice, &[("desk", Twice::A), ("desk", Twice::B)]);
454 let err = validate_roles::<Twice>().unwrap_err();
455 assert_eq!(err, "role table: token 'desk' is listed twice");
456 }
457
458 #[test]
461 fn a_role_listed_twice_is_refused() {
462 role_table!(Alias, &[("desk", Alias::A), ("trading-desk", Alias::A)]);
463 let err = validate_roles::<Alias>().unwrap_err();
464 assert_eq!(
465 err,
466 "role table: A is listed twice, as 'desk' and 'trading-desk'"
467 );
468 }
469
470 #[test]
471 fn a_token_outside_the_charset_is_refused() {
472 role_table!(Capital, &[("Trader", Capital::A)]);
473 role_table!(Empty, &[("", Empty::A)]);
474 role_table!(Space, &[("read only", Space::A)]);
475 role_table!(Comment, &[("#desk", Comment::A)]);
476 role_table!(LeadingDigit, &[("9desk", LeadingDigit::A)]);
477 role_table!(Colon, &[("desk:1", Colon::A)]);
478 for err in [
479 validate_roles::<Capital>().unwrap_err(),
480 validate_roles::<Empty>().unwrap_err(),
481 validate_roles::<Space>().unwrap_err(),
482 validate_roles::<Comment>().unwrap_err(),
483 validate_roles::<LeadingDigit>().unwrap_err(),
484 validate_roles::<Colon>().unwrap_err(),
485 ] {
486 assert!(err.contains("is not a valid role token"), "{err}");
487 }
488 }
489
490 #[test]
494 fn a_table_longer_than_a_role_id_indexes_is_refused() {
495 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
496 struct Numbered(u16);
497
498 const TABLE: [(&str, Numbered); RoleId::CAPACITY + 1] = {
500 let mut table = [("desk", Numbered(0)); RoleId::CAPACITY + 1];
501 let mut i = 0;
502 while i < table.len() {
503 table[i].1 = Numbered(i as u16);
504 i += 1;
505 }
506 table
507 };
508 impl Role for Numbered {
509 const ROLES: &'static [(&'static str, Self)] = &TABLE;
510 }
511
512 let err = validate_roles::<Numbered>().unwrap_err();
513 assert_eq!(
514 err,
515 "role table: 257 roles, more than the 256 a role index holds"
516 );
517 }
518
519 #[test]
522 fn parsing_validates_the_role_table_even_for_an_empty_file() {
523 role_table!(ClaimsOperator, &[("operator", ClaimsOperator::A)]);
524 let err = AuthorizedKeys::parse::<ClaimsOperator>("").unwrap_err();
525 assert!(err.contains("is the runtime's own role token"), "{err}");
526 }
527
528 #[test]
529 fn client_role_is_operator() {
530 assert!(ClientRole::<DeskRole>::Operator.is_operator());
531 assert!(!ClientRole::App(DeskRole::Trader).is_operator());
532 }
533
534 #[test]
535 fn only_replication_has_no_client_role() {
536 assert_eq!(KeyRole::Replication.client(), None);
537 assert!(KeyRole::Replication.is_replication());
538 let operator = KeyRole::Client(ClientRole::Operator);
539 assert_eq!(operator.client(), Some(ClientRole::Operator));
540 assert!(!operator.is_replication());
541 }
542
543 #[test]
546 fn runtime_tokens_parse_to_the_runtime_roles() {
547 for keys in [
548 [
549 listing::<DeskRole>("operator"),
550 listing::<DeskRole>("replication"),
551 ],
552 [
553 listing::<NoRoles>("operator"),
554 listing::<NoRoles>("replication"),
555 ],
556 ] {
557 let [operator, replication] = keys.map(Result::unwrap);
558 assert_eq!(
559 operator.lookup(&[0u8; 32]),
560 Some(KeyRole::Client(ClientRole::Operator))
561 );
562 assert_eq!(replication.lookup(&[0u8; 32]), Some(KeyRole::Replication));
563 }
564 }
565
566 #[test]
569 fn every_app_role_round_trips_through_its_token() {
570 for (index, &(token, _)) in DeskRole::ROLES.iter().enumerate() {
571 let keys = listing::<DeskRole>(token).unwrap();
572 let role = keys.lookup(&[0u8; 32]).unwrap();
573 let Some(ClientRole::App(id)) = role.client() else {
574 panic!("{token} must parse to an application role, got {role:?}");
575 };
576 assert_eq!(id.index(), index);
577 assert_eq!(keys.token(role), token);
578 }
579 }
580
581 #[test]
582 fn the_runtime_roles_are_named_by_their_tokens() {
583 let keys = AuthorizedKeys::parse::<NoRoles>("").unwrap();
584 assert_eq!(keys.token(KeyRole::Replication), "replication");
585 assert_eq!(
586 keys.token(KeyRole::Client(ClientRole::Operator)),
587 "operator"
588 );
589 }
590
591 #[test]
593 fn no_roles_admits_only_the_runtime_tokens() {
594 let err = listing::<NoRoles>("trader").unwrap_err();
595 assert_eq!(
596 err,
597 "line 1: unknown role 'trader' (expected operator, replication)"
598 );
599 }
600
601 #[test]
603 fn a_token_in_another_case_is_unknown() {
604 let err = listing::<DeskRole>("Trader").unwrap_err();
605 assert!(err.contains("unknown role 'Trader'"), "{err}");
606 }
607
608 #[test]
609 fn a_table_knows_the_role_type_it_was_parsed_for() {
610 let keys = AuthorizedKeys::parse::<DeskRole>("").unwrap();
611 assert!(keys.is_for::<DeskRole>());
612 assert!(!keys.is_for::<NoRoles>());
613 }
614
615 #[test]
618 fn parse_valid_keys_file() {
619 let content = "\
620# Auth keys file
621operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ops-team
622trader AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= market-maker-1
623readonly AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI= monitoring
624";
625 let keys = AuthorizedKeys::parse::<DeskRole>(content).unwrap();
626 assert_eq!(keys.len(), 3);
627
628 let admin_key = BASE64
629 .decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
630 .unwrap();
631 let mut k = [0u8; 32];
632 k.copy_from_slice(&admin_key);
633 assert_eq!(keys.lookup(&k), Some(KeyRole::Client(ClientRole::Operator)));
634 }
635
636 #[test]
637 fn parse_skips_comments_and_blanks() {
638 let content = "\
639# comment
640 # indented comment
641
642operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test
643";
644 let keys = AuthorizedKeys::parse::<NoRoles>(content).unwrap();
645 assert_eq!(keys.len(), 1);
646 }
647
648 #[test]
651 fn parse_rejects_unknown_role_naming_every_valid_one() {
652 let err = listing::<DeskRole>("superuser").unwrap_err();
653 assert_eq!(
654 err,
655 "line 1: unknown role 'superuser' \
656 (expected operator, replication, trader, custodian, readonly)"
657 );
658 }
659
660 #[test]
661 fn parse_rejects_wrong_key_length() {
662 let content = "operator AQID test\n"; let result = AuthorizedKeys::parse::<NoRoles>(content);
664 assert!(result.is_err());
665 assert!(result.unwrap_err().contains("32 bytes"));
666 }
667
668 #[test]
669 fn lookup_missing_key_returns_none() {
670 let keys = AuthorizedKeys::parse::<NoRoles>("").unwrap();
671 assert!(keys.lookup(&[0u8; 32]).is_none());
672 }
673
674 #[test]
678 fn duplicate_key_is_refused() {
679 for content in [
680 "\
681operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= first
682readonly AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= second
683",
684 "\
685trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= first
686trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= again
687",
688 ] {
689 let err = AuthorizedKeys::parse::<DeskRole>(content).unwrap_err();
690 assert!(
691 err.starts_with("line 2:") && err.contains("already listed"),
692 "unexpected error: {err}"
693 );
694 }
695 }
696
697 #[test]
698 fn empty_file_produces_empty_keys() {
699 let keys = AuthorizedKeys::parse::<NoRoles>("").unwrap();
700 assert!(keys.is_empty());
701 assert_eq!(keys.len(), 0);
702 assert!(keys.lookup(&[0u8; 32]).is_none());
703 }
704
705 #[test]
706 fn parse_rejects_invalid_base64() {
707 let content = "operator not-valid-base64!!! test\n";
708 let result = AuthorizedKeys::parse::<NoRoles>(content);
709 assert!(result.is_err());
710 assert!(result.unwrap_err().contains("invalid base64"));
711 }
712
713 #[test]
714 fn parse_rejects_missing_key_field() {
715 let content = "admin\n";
716 let result = AuthorizedKeys::parse::<NoRoles>(content);
717 assert!(result.is_err());
718 assert!(result.unwrap_err().contains("missing public key"));
719 }
720
721 #[test]
722 fn comments_only_file_produces_empty_keys() {
723 let content = "\
724# only comments
725# nothing else
726 # indented
727";
728 let keys = AuthorizedKeys::parse::<NoRoles>(content).unwrap();
729 assert!(keys.is_empty());
730 }
731
732 #[test]
733 fn load_nonexistent_file_is_error() {
734 let result =
735 AuthorizedKeys::load::<NoRoles>(std::path::Path::new("/nonexistent/path/keys.txt"));
736 assert!(result.is_err());
737 }
738
739 #[test]
740 fn load_from_file() {
741 let dir = tempfile::tempdir().unwrap();
742 let path = dir.path().join("keys.txt");
743 std::fs::write(
744 &path,
745 "trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test\n",
746 )
747 .unwrap();
748 let keys = AuthorizedKeys::load::<DeskRole>(&path).unwrap();
749 assert_eq!(keys.len(), 1);
750 }
751}