Skip to main content

ecr_server/
auth.rs

1use rand::Rng;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5use subtle::ConstantTimeEq;
6
7const TOKEN_BYTES: usize = 32;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10#[serde(default)]
11pub struct TokenStore {
12    pub tokens: Vec<DeviceToken>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DeviceToken {
17    pub name: String,
18    pub hash: String,
19    pub created: String,
20}
21
22impl TokenStore {
23    pub fn default_path() -> PathBuf {
24        dirs::config_dir()
25            .unwrap_or_else(|| PathBuf::from("."))
26            .join("ecr")
27            .join("tokens.toml")
28    }
29
30    pub fn load(path: &Path) -> anyhow::Result<Self> {
31        match std::fs::read_to_string(path) {
32            Ok(text) => Ok(toml::from_str(&text)?),
33            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
34            Err(e) => Err(e.into()),
35        }
36    }
37
38    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
39        if let Some(parent) = path.parent() {
40            std::fs::create_dir_all(parent)?;
41        }
42        std::fs::write(path, toml::to_string_pretty(self)?)?;
43
44        #[cfg(unix)]
45        {
46            use std::os::unix::fs::PermissionsExt;
47            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
48        }
49        Ok(())
50    }
51
52    pub fn issue(&mut self, name: &str) -> anyhow::Result<String> {
53        let token = generate_token()?;
54        self.tokens.retain(|t| t.name != name);
55        self.tokens.push(DeviceToken {
56            name: name.to_string(),
57            hash: hash_token(&token),
58            created: chrono::Utc::now().to_rfc3339(),
59        });
60        Ok(token)
61    }
62
63    pub fn adopt(&mut self, name: &str, token: &str) {
64        self.tokens.retain(|t| t.name != name);
65        self.tokens.push(DeviceToken {
66            name: name.to_string(),
67            hash: hash_token(token),
68            created: chrono::Utc::now().to_rfc3339(),
69        });
70    }
71
72    pub fn revoke(&mut self, name: &str) -> bool {
73        let before = self.tokens.len();
74        self.tokens.retain(|t| t.name != name);
75        before != self.tokens.len()
76    }
77
78    pub fn verify(&self, presented: &str) -> Option<&DeviceToken> {
79        let presented = hash_token(presented);
80        self.tokens
81            .iter()
82            .find(|t| t.hash.as_bytes().ct_eq(presented.as_bytes()).unwrap_u8() == 1)
83    }
84
85    pub fn is_empty(&self) -> bool {
86        self.tokens.is_empty()
87    }
88}
89
90fn generate_token() -> anyhow::Result<String> {
91    let mut bytes = [0u8; TOKEN_BYTES];
92    rand::rng().fill_bytes(&mut bytes);
93    Ok(hex::encode(bytes))
94}
95
96fn hash_token(token: &str) -> String {
97    hex::encode(Sha256::digest(token.as_bytes()))
98}
99
100pub fn bearer(header: Option<&str>) -> Option<&str> {
101    let value = header?.trim();
102    let (scheme, token) = value.split_once(' ')?;
103    scheme
104        .eq_ignore_ascii_case("bearer")
105        .then(|| token.trim())
106        .filter(|t| !t.is_empty())
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn an_issued_token_verifies() {
115        let mut store = TokenStore::default();
116        let token = store.issue("phone").unwrap();
117
118        assert!(store.verify(&token).is_some());
119        assert_eq!(store.verify(&token).unwrap().name, "phone");
120    }
121
122    #[test]
123    fn the_plaintext_token_is_never_stored() {
124        let mut store = TokenStore::default();
125        let token = store.issue("phone").unwrap();
126
127        assert!(!store.tokens[0].hash.contains(&token));
128        assert_eq!(store.tokens[0].hash.len(), 64);
129    }
130
131    #[test]
132    fn a_wrong_token_does_not_verify() {
133        let mut store = TokenStore::default();
134        store.issue("phone").unwrap();
135
136        assert!(store.verify("deadbeef").is_none());
137        assert!(store.verify("").is_none());
138    }
139
140    #[test]
141    fn issuing_the_same_name_twice_replaces_the_old_token() {
142        let mut store = TokenStore::default();
143        let first = store.issue("phone").unwrap();
144        let second = store.issue("phone").unwrap();
145
146        assert_eq!(store.tokens.len(), 1);
147        assert!(store.verify(&first).is_none());
148        assert!(store.verify(&second).is_some());
149    }
150
151    #[test]
152    fn revoking_removes_the_token() {
153        let mut store = TokenStore::default();
154        let token = store.issue("phone").unwrap();
155
156        assert!(store.revoke("phone"));
157        assert!(store.verify(&token).is_none());
158        assert!(!store.revoke("phone"));
159    }
160
161    #[test]
162    fn tokens_are_unique_per_issue() {
163        let mut store = TokenStore::default();
164        let a = store.issue("a").unwrap();
165        let b = store.issue("b").unwrap();
166
167        assert_ne!(a, b);
168        assert_eq!(a.len(), TOKEN_BYTES * 2);
169    }
170
171    #[test]
172    fn parses_a_bearer_header() {
173        assert_eq!(bearer(Some("Bearer abc123")), Some("abc123"));
174        assert_eq!(bearer(Some("bearer abc123")), Some("abc123"));
175    }
176
177    #[test]
178    fn rejects_headers_that_are_not_bearer_tokens() {
179        assert_eq!(bearer(None), None);
180        assert_eq!(bearer(Some("Basic abc")), None);
181        assert_eq!(bearer(Some("Bearer")), None);
182        assert_eq!(bearer(Some("Bearer   ")), None);
183    }
184
185    #[test]
186    fn round_trips_through_a_file() {
187        let dir = tempfile::tempdir().unwrap();
188        let path = dir.path().join("tokens.toml");
189
190        let mut store = TokenStore::default();
191        let token = store.issue("laptop").unwrap();
192        store.save(&path).unwrap();
193
194        let loaded = TokenStore::load(&path).unwrap();
195        assert!(loaded.verify(&token).is_some());
196    }
197
198    #[test]
199    fn a_missing_file_loads_as_empty() {
200        let store = TokenStore::load(Path::new("/nonexistent/tokens.toml")).unwrap();
201        assert!(store.is_empty());
202    }
203}