use std::collections::HashMap;
use std::io;
use std::path::Path;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Permission {
Operator,
Trader,
Custodian,
ReadOnly,
Replication,
}
impl Permission {
pub fn can_trade(self) -> bool {
matches!(self, Permission::Trader)
}
pub fn is_operator(self) -> bool {
matches!(self, Permission::Operator)
}
pub fn can_manage_funds(self) -> bool {
matches!(self, Permission::Custodian)
}
pub fn is_replication(self) -> bool {
matches!(self, Permission::Replication)
}
pub fn may_connect_as_client(self) -> bool {
!self.is_replication()
}
}
#[derive(Debug)]
pub struct AuthorizedKeys {
keys: HashMap<[u8; 32], Permission>,
}
impl AuthorizedKeys {
pub fn load(path: &Path) -> io::Result<Self> {
let content = std::fs::read_to_string(path)?;
Self::parse(&content).map_err(|e| io::Error::other(format!("{path:?}: {e}")))
}
pub fn parse(content: &str) -> Result<Self, String> {
let mut keys = HashMap::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
let perm_str = parts
.next()
.ok_or_else(|| format!("line {}: missing permission", line_num + 1))?;
let key_b64 = parts
.next()
.ok_or_else(|| format!("line {}: missing public key", line_num + 1))?;
let permission = match perm_str {
"operator" => Permission::Operator,
"trader" => Permission::Trader,
"custodian" => Permission::Custodian,
"readonly" => Permission::ReadOnly,
"replication" => Permission::Replication,
other => {
return Err(format!(
"line {}: unknown permission '{}' (expected operator/trader/custodian/readonly/replication)",
line_num + 1,
other
));
}
};
let key_bytes = BASE64
.decode(key_b64)
.map_err(|e| format!("line {}: invalid base64: {e}", line_num + 1))?;
if key_bytes.len() != 32 {
return Err(format!(
"line {}: public key must be 32 bytes, got {}",
line_num + 1,
key_bytes.len()
));
}
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
if keys.insert(key, permission).is_some() {
return Err(format!(
"line {}: public key already listed on an earlier line",
line_num + 1
));
}
}
Ok(Self { keys })
}
pub fn lookup(&self, public_key: &[u8; 32]) -> Option<Permission> {
self.keys.get(public_key).copied()
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permission_can_trade() {
assert!(!Permission::Operator.can_trade());
assert!(Permission::Trader.can_trade());
assert!(!Permission::Custodian.can_trade());
assert!(!Permission::ReadOnly.can_trade());
assert!(!Permission::Replication.can_trade());
}
#[test]
fn permission_is_operator() {
assert!(Permission::Operator.is_operator());
assert!(!Permission::Trader.is_operator());
assert!(!Permission::Custodian.is_operator());
assert!(!Permission::ReadOnly.is_operator());
assert!(!Permission::Replication.is_operator());
}
#[test]
fn permission_can_manage_funds() {
assert!(!Permission::Operator.can_manage_funds());
assert!(!Permission::Trader.can_manage_funds());
assert!(Permission::Custodian.can_manage_funds());
assert!(!Permission::ReadOnly.can_manage_funds());
assert!(!Permission::Replication.can_manage_funds());
}
#[test]
fn only_replication_may_not_connect_as_client() {
assert!(Permission::Operator.may_connect_as_client());
assert!(Permission::Trader.may_connect_as_client());
assert!(Permission::Custodian.may_connect_as_client());
assert!(Permission::ReadOnly.may_connect_as_client());
assert!(!Permission::Replication.may_connect_as_client());
}
#[test]
fn permission_is_replication() {
assert!(!Permission::Operator.is_replication());
assert!(!Permission::Trader.is_replication());
assert!(!Permission::Custodian.is_replication());
assert!(!Permission::ReadOnly.is_replication());
assert!(Permission::Replication.is_replication());
}
#[test]
fn parse_valid_keys_file() {
let content = "\
# Auth keys file
operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ops-team
trader AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= market-maker-1
readonly AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI= monitoring
";
let keys = AuthorizedKeys::parse(content).unwrap();
assert_eq!(keys.len(), 3);
let admin_key = BASE64
.decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.unwrap();
let mut k = [0u8; 32];
k.copy_from_slice(&admin_key);
assert_eq!(keys.lookup(&k), Some(Permission::Operator));
}
#[test]
fn parse_skips_comments_and_blanks() {
let content = "\
# comment
# indented comment
operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test
";
let keys = AuthorizedKeys::parse(content).unwrap();
assert_eq!(keys.len(), 1);
}
#[test]
fn parse_rejects_unknown_permission() {
let content = "superuser AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test\n";
let result = AuthorizedKeys::parse(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("unknown permission"));
}
#[test]
fn parse_rejects_wrong_key_length() {
let content = "operator AQID test\n"; let result = AuthorizedKeys::parse(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("32 bytes"));
}
#[test]
fn lookup_missing_key_returns_none() {
let keys = AuthorizedKeys::parse("").unwrap();
assert!(keys.lookup(&[0u8; 32]).is_none());
}
#[test]
fn replication_key_parsed_from_file() {
let content = "replication AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= replica-1\n";
let keys = AuthorizedKeys::parse(content).unwrap();
let pub_key = [0u8; 32];
assert_eq!(keys.lookup(&pub_key), Some(Permission::Replication));
}
#[test]
fn custodian_key_parsed_from_file() {
let content = "custodian AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= treasury\n";
let keys = AuthorizedKeys::parse(content).unwrap();
let pub_key = [0u8; 32];
assert_eq!(keys.lookup(&pub_key), Some(Permission::Custodian));
}
#[test]
fn duplicate_key_is_refused() {
for content in [
"\
operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= first
readonly AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= second
",
"\
trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= first
trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= again
",
] {
let err = AuthorizedKeys::parse(content).unwrap_err();
assert!(
err.starts_with("line 2:") && err.contains("already listed"),
"unexpected error: {err}"
);
}
}
#[test]
fn empty_file_produces_empty_keys() {
let keys = AuthorizedKeys::parse("").unwrap();
assert!(keys.is_empty());
assert_eq!(keys.len(), 0);
assert!(keys.lookup(&[0u8; 32]).is_none());
}
#[test]
fn parse_rejects_invalid_base64() {
let content = "operator not-valid-base64!!! test\n";
let result = AuthorizedKeys::parse(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid base64"));
}
#[test]
fn parse_rejects_missing_key_field() {
let content = "admin\n";
let result = AuthorizedKeys::parse(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("missing public key"));
}
#[test]
fn comments_only_file_produces_empty_keys() {
let content = "\
# only comments
# nothing else
# indented
";
let keys = AuthorizedKeys::parse(content).unwrap();
assert!(keys.is_empty());
}
#[test]
fn load_nonexistent_file_is_error() {
let result = AuthorizedKeys::load(std::path::Path::new("/nonexistent/path/keys.txt"));
assert!(result.is_err());
}
#[test]
fn load_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("keys.txt");
std::fs::write(
&path,
"trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test\n",
)
.unwrap();
let keys = AuthorizedKeys::load(&path).unwrap();
assert_eq!(keys.len(), 1);
}
}