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}