1#![warn(missing_docs)]
12
13use std::collections::BTreeSet;
14
15use argon2::password_hash::rand_core::{OsRng, RngCore};
16use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
17use argon2::{Algorithm, Argon2, Params, Version};
18
19use serde_json::Value as Json;
20
21mod keys;
22pub use keys::{ApiKey, MAX_API_KEY_LABEL_BYTES, MAX_API_KEYS, MAX_API_KEYS_PER_ACCOUNT};
23
24pub const FORMAT_VERSION: u32 = 1;
26pub const ACCOUNTS_FILE: &str = "accounts.json";
28pub const LOCAL_OWNER: &str = "local";
30pub const MAX_ACCOUNTS: usize = 256;
32pub const MAX_PASSWORD_BYTES: usize = 1024;
34const MAX_REVISION: u64 = (1 << 53) - 1;
35const MEMORY_KIB: u32 = 19 * 1024;
36const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$YWFhYWFhYWFhYWFhYWFhYQ$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Role {
43 Administrator,
45 User,
47 Auditor,
49}
50
51impl Role {
52 pub fn as_str(self) -> &'static str {
54 match self {
55 Self::Administrator => "admin",
56 Self::User => "user",
57 Self::Auditor => "auditor",
58 }
59 }
60
61 pub fn parse(value: &str) -> Option<Self> {
63 match value {
64 "admin" => Some(Self::Administrator),
65 "user" => Some(Self::User),
66 "auditor" => Some(Self::Auditor),
67 _ => None,
68 }
69 }
70}
71
72#[derive(Clone, PartialEq, Eq)]
74pub struct Account {
75 pub id: String,
77 pub username: String,
79 pub role: Role,
81 pub enabled: bool,
83 pub created_at: u64,
85 pub updated_at: u64,
87 revision: u64,
88 password_hash: String,
89}
90
91impl std::fmt::Debug for Account {
92 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 formatter
94 .debug_struct("Account")
95 .field("id", &self.id)
96 .field("username", &self.username)
97 .field("role", &self.role)
98 .field("enabled", &self.enabled)
99 .finish_non_exhaustive()
100 }
101}
102
103impl Account {
104 pub fn revision(&self) -> u64 {
106 self.revision
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Accounts {
113 epoch: String,
114 records: Vec<Account>,
115 api_keys: Vec<ApiKey>,
116}
117
118impl Accounts {
119 pub fn bootstrap(username: &str, password: &str, now: u64) -> Result<Self, String> {
121 Ok(Self {
122 epoch: random_token()?,
123 records: vec![Account {
124 id: LOCAL_OWNER.into(),
125 username: login_name(username)?,
126 role: Role::Administrator,
127 enabled: true,
128 created_at: now,
129 updated_at: now,
130 revision: 1,
131 password_hash: hash_password(password)?,
132 }],
133 api_keys: Vec::new(),
134 })
135 }
136
137 pub fn all(&self) -> &[Account] {
139 &self.records
140 }
141
142 pub fn epoch(&self) -> &str {
144 &self.epoch
145 }
146
147 pub fn find(&self, username: &str) -> Option<&Account> {
149 let name = login_name(username).ok()?;
150 self.records.iter().find(|account| account.username == name)
151 }
152
153 pub fn authenticate(&self, username: &str, password: &str) -> Option<&Account> {
156 if password.len() > MAX_PASSWORD_BYTES {
157 return None;
158 }
159 let account = self.find(username);
160 let hash = account.map_or(DUMMY_HASH, |account| account.password_hash.as_str());
161 let parsed = PasswordHash::new(hash).ok()?;
162 let matches = hasher()
163 .ok()?
164 .verify_password(password.as_bytes(), &parsed)
165 .is_ok();
166 account.filter(|account| account.enabled && matches)
167 }
168
169 pub fn session_account(&self, owner: &str, epoch: &str, revision: u64) -> Option<&Account> {
171 if self.epoch != epoch {
172 return None;
173 }
174 self.records
175 .iter()
176 .find(|account| account.id == owner && account.enabled && account.revision == revision)
177 }
178
179 pub fn create(
181 &mut self,
182 username: &str,
183 password: &str,
184 role: Role,
185 now: u64,
186 ) -> Result<(), String> {
187 let username = login_name(username)?;
188 if self.records.len() >= MAX_ACCOUNTS {
189 return Err("the installation already has 256 accounts".into());
190 }
191 if self.find(&username).is_some() {
192 return Err("this login name is already in use".into());
193 }
194 let id = format!("account-{}", random_token()?);
195 if self.records.iter().any(|account| account.id == id) {
196 return Err("could not generate a unique account owner; retry".into());
197 }
198 self.records.push(Account {
199 id,
200 username,
201 role,
202 enabled: true,
203 created_at: now,
204 updated_at: now,
205 revision: 1,
206 password_hash: hash_password(password)?,
207 });
208 Ok(())
209 }
210
211 pub fn set_role(&mut self, username: &str, role: Role, now: u64) -> Result<(), String> {
213 self.edit(username, now, |account| account.role = role)
214 }
215
216 pub fn set_enabled(&mut self, username: &str, enabled: bool, now: u64) -> Result<(), String> {
218 self.edit(username, now, |account| account.enabled = enabled)
219 }
220
221 pub fn rename(&mut self, username: &str, replacement: &str, now: u64) -> Result<(), String> {
223 let replacement = login_name(replacement)?;
224 if self
225 .find(&replacement)
226 .is_some_and(|other| !other.username.eq_ignore_ascii_case(username))
227 {
228 return Err("this login name is already in use".into());
229 }
230 self.edit(username, now, |account| account.username = replacement)
231 }
232
233 pub fn set_password(&mut self, username: &str, password: &str, now: u64) -> Result<(), String> {
235 if self.find(username).is_none() {
236 return Err("no account has this login name".into());
237 }
238 let hash = hash_password(password)?;
239 self.edit(username, now, |account| account.password_hash = hash)
240 }
241
242 pub fn revoke(&mut self, username: &str, now: u64) -> Result<(), String> {
244 let account = self
245 .records
246 .iter_mut()
247 .find(|account| account.username.eq_ignore_ascii_case(username))
248 .ok_or("no account has this login name")?;
249 account.revision = next_revision(account.revision)?;
250 account.updated_at = now;
251 let owner = account.id.clone();
252 self.purge_api_keys(&owner);
253 Ok(())
254 }
255
256 pub fn revoke_all(&mut self) -> Result<(), String> {
258 self.epoch = random_token()?;
259 self.api_keys.clear();
260 Ok(())
261 }
262
263 fn edit(
264 &mut self,
265 username: &str,
266 now: u64,
267 edit: impl FnOnce(&mut Account),
268 ) -> Result<(), String> {
269 let index = self
270 .records
271 .iter()
272 .position(|account| account.username.eq_ignore_ascii_case(username))
273 .ok_or("no account has this login name")?;
274 let mut candidate = self.records[index].clone();
275 edit(&mut candidate);
276 if candidate == self.records[index] {
277 return Ok(());
278 }
279 let keeps_administrator = (candidate.enabled && candidate.role == Role::Administrator)
280 || self.records.iter().enumerate().any(|(other, account)| {
281 other != index && account.enabled && account.role == Role::Administrator
282 });
283 if !keeps_administrator {
284 return Err("keep at least one enabled administrator".into());
285 }
286 candidate.revision = next_revision(candidate.revision)?;
287 candidate.updated_at = now;
288 let owner = candidate.id.clone();
289 self.records[index] = candidate;
290 self.purge_api_keys(&owner);
291 Ok(())
292 }
293}
294
295fn next_revision(previous: u64) -> Result<u64, String> {
296 previous
297 .checked_add(1)
298 .filter(|next| *next <= MAX_REVISION)
299 .ok_or_else(|| "account generation is exhausted".into())
300}
301
302pub fn login_name(name: &str) -> Result<String, String> {
306 if name.is_empty()
307 || name.len() > 64
308 || !name
309 .bytes()
310 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
311 || !name.bytes().any(|byte| byte.is_ascii_alphanumeric())
312 {
313 return Err(
314 "login names need 1–64 ASCII letters, digits, dots, underscores or hyphens, including at least one letter or digit".into(),
315 );
316 }
317 Ok(name.to_ascii_lowercase())
318}
319
320fn hasher() -> Result<Argon2<'static>, String> {
321 let params =
322 Params::new(MEMORY_KIB, 2, 1, Some(32)).map_err(|_| "invalid password hash policy")?;
323 Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
324}
325
326fn hash_password(password: &str) -> Result<String, String> {
327 if password.chars().count() < 15
328 || password.len() > MAX_PASSWORD_BYTES
329 || password.contains('\0')
330 {
331 return Err(
332 "passwords need at least 15 characters and at most 1024 UTF-8 bytes, without NUL"
333 .into(),
334 );
335 }
336 let mut bytes = [0_u8; 16];
337 OsRng
338 .try_fill_bytes(&mut bytes)
339 .map_err(|_| "secure random generation failed")?;
340 let salt = SaltString::encode_b64(&bytes).map_err(|_| "could not encode password salt")?;
341 hasher()?
342 .hash_password(password.as_bytes(), &salt)
343 .map(|hash| hash.to_string())
344 .map_err(|_| "password hashing failed".into())
345}
346
347pub fn random_token() -> Result<String, String> {
350 use std::fmt::Write;
351 let mut bytes = [0_u8; 32];
352 OsRng
353 .try_fill_bytes(&mut bytes)
354 .map_err(|_| "secure random generation failed")?;
355 let mut token = String::with_capacity(64);
356 for byte in bytes {
357 write!(&mut token, "{byte:02x}").map_err(|_| "could not encode random token")?;
358 }
359 Ok(token)
360}
361
362pub fn to_json(accounts: &Accounts) -> Json {
365 let mut root = serde_json::json!({
366 "format_version": FORMAT_VERSION,
367 "epoch": accounts.epoch,
368 "account": accounts.records.iter().map(|account| {
369 serde_json::json!({
370 "id": account.id,
371 "username": account.username,
372 "role": account.role.as_str(),
373 "enabled": account.enabled,
374 "created_at": account.created_at,
375 "updated_at": account.updated_at,
376 "revision": account.revision,
377 "password_hash": account.password_hash,
378 })
379 }).collect::<Vec<_>>(),
380 });
381 if !accounts.api_keys.is_empty() {
382 root["api_keys"] = keys::to_json(&accounts.api_keys);
383 }
384 root
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub struct InvalidStore {
390 reason: &'static str,
391}
392
393impl InvalidStore {
394 fn new(reason: &'static str) -> Self {
395 Self { reason }
396 }
397
398 pub fn reason(&self) -> &'static str {
400 self.reason
401 }
402}
403
404impl std::fmt::Display for InvalidStore {
405 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 write!(formatter, "inconsistent accounts: {}", self.reason)
407 }
408}
409
410impl std::error::Error for InvalidStore {}
411
412fn json_u64(value: &Json) -> Option<u64> {
415 value.as_f64().and_then(|number| {
416 (number.is_finite() && number >= 0.0 && number < u64::MAX as f64 && number.fract() == 0.0)
417 .then_some(number as u64)
418 })
419}
420
421fn valid_token(value: &str) -> bool {
422 value.len() == 64
423 && value
424 .bytes()
425 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
426}
427
428fn valid_hash(hash: &str) -> bool {
429 if hash.len() > 256 {
430 return false;
431 }
432 let Ok(parsed) = PasswordHash::new(hash) else {
433 return false;
434 };
435 let Ok(params) = Params::try_from(&parsed) else {
436 return false;
437 };
438 parsed.algorithm.as_str() == "argon2id"
439 && parsed.version == Some(19)
440 && params.m_cost() == MEMORY_KIB
441 && params.t_cost() == 2
442 && params.p_cost() == 1
443 && parsed.hash.is_some_and(|hash| hash.len() == 32)
444 && parsed.salt.is_some_and(|salt| salt.len() == 22)
445 && parsed.params.iter().count() == 3
446}
447
448pub fn from_json(root: &Json) -> Result<Accounts, InvalidStore> {
451 let invalid = || InvalidStore::new("malformed account store or unsupported version");
452 if root
453 .get("format_version")
454 .and_then(json_u64)
455 .and_then(|value| u32::try_from(value).ok())
456 != Some(FORMAT_VERSION)
457 {
458 return Err(invalid());
459 }
460 let epoch = root
461 .get("epoch")
462 .and_then(Json::as_str)
463 .filter(|epoch| valid_token(epoch))
464 .ok_or_else(invalid)?;
465 let rows = root
466 .get("account")
467 .and_then(Json::as_array)
468 .filter(|rows| !rows.is_empty() && rows.len() <= MAX_ACCOUNTS)
469 .ok_or_else(invalid)?;
470 let mut records = Vec::with_capacity(rows.len());
471 let mut names = BTreeSet::new();
472 let mut owners = BTreeSet::new();
473 for row in rows {
474 let string = |key| row.get(key).and_then(Json::as_str).ok_or_else(invalid);
475 let number = |key| {
476 row.get(key)
477 .and_then(json_u64)
478 .filter(|n| *n <= MAX_REVISION)
479 .ok_or_else(invalid)
480 };
481 let id = string("id")?;
482 if id != LOCAL_OWNER && !id.strip_prefix("account-").is_some_and(valid_token) {
483 return Err(invalid());
484 }
485 let username = string("username")?;
486 if login_name(username).ok().as_deref() != Some(username)
487 || !names.insert(username)
488 || !owners.insert(id)
489 {
490 return Err(invalid());
491 }
492 let hash = string("password_hash")?;
493 if !valid_hash(hash) {
494 return Err(invalid());
495 }
496 let revision = number("revision")?;
497 if revision == 0 {
498 return Err(invalid());
499 }
500 records.push(Account {
501 id: id.into(),
502 username: username.into(),
503 role: Role::parse(string("role")?).ok_or_else(invalid)?,
504 enabled: row
505 .get("enabled")
506 .and_then(Json::as_bool)
507 .ok_or_else(invalid)?,
508 created_at: number("created_at")?,
509 updated_at: number("updated_at")?,
510 revision,
511 password_hash: hash.into(),
512 });
513 }
514 if !records
515 .iter()
516 .any(|account| account.enabled && account.role == Role::Administrator)
517 {
518 return Err(invalid());
519 }
520 let api_keys = keys::from_json(root.get("api_keys"), &records)?;
521 Ok(Accounts {
522 epoch: epoch.into(),
523 records,
524 api_keys,
525 })
526}
527
528#[cfg(test)]
529#[path = "accounts_tests.rs"]
530mod tests;
531
532#[cfg(test)]
533#[path = "accounts_test_support.rs"]
534mod test_support;