aede_accounts/
lib.rs

1//! Stable personal owners, account administration and protected credentials.
2//!
3//! The first administrator owns `local`, so existing personal data stays put.
4//! Other owners are generated independently of their changeable login names.
5//! Filesystem persistence and its writer lock belong to the installation
6//! adapter; this crate contains no disk, transport or catalog dependency.
7//! Sessions belong to the server and are never persisted in this store.
8//! Optional persistent client keys retain only salted secret verifiers and
9//! are independently revocable; account-generation changes purge them.
10
11#![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
24/// Account-store format, independent of the catalog and personal stores.
25pub const FORMAT_VERSION: u32 = 1;
26/// Account store inside the data directory.
27pub const ACCOUNTS_FILE: &str = "accounts.json";
28/// Stable owner of the installation's first administrator and legacy personal data.
29pub const LOCAL_OWNER: &str = "local";
30/// Maximum number of accounts in one installation.
31pub const MAX_ACCOUNTS: usize = 256;
32/// Maximum accepted UTF-8 password length in bytes.
33pub const MAX_PASSWORD_BYTES: usize = 1024;
34const MAX_REVISION: u64 = (1 << 53) - 1;
35const MEMORY_KIB: u32 = 19 * 1024;
36// Unknown names still pay the same bounded Argon2 work. This dummy verifier
37// does not authenticate anyone, regardless of the verification result.
38const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$YWFhYWFhYWFhYWFhYWFhYQ$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
39
40/// Installation privileges; every account may access only its own opinions.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Role {
43    /// May manage accounts and run administrative installation operations.
44    Administrator,
45    /// May browse the shared catalog and manage their own personal data.
46    User,
47    /// May browse the shared catalog and read their own personal data only.
48    Auditor,
49}
50
51impl Role {
52    /// Stable spelling used in persisted and HTTP documents.
53    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    /// Parse an explicit role without silently selecting a default.
62    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/// An account with an immutable personal-data owner and private verifier.
73#[derive(Clone, PartialEq, Eq)]
74pub struct Account {
75    /// Stable owner of annotations, history and collections.
76    pub id: String,
77    /// Canonical lowercase login name, independent of ownership.
78    pub username: String,
79    /// Installation privileges.
80    pub role: Role,
81    /// Disabled accounts retain their personal data and cannot authenticate.
82    pub enabled: bool,
83    /// Creation time in Unix seconds.
84    pub created_at: u64,
85    /// Most recent administration time in Unix seconds.
86    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    /// Credential generation captured by a session; changes invalidate it.
105    pub fn revision(&self) -> u64 {
106        self.revision
107    }
108}
109
110/// Independently versioned account store; an empty store is never published.
111#[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    /// Create the first administrator, retaining the existing `local` owner.
120    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    /// Account metadata in creation order; verifiers remain private.
138    pub fn all(&self) -> &[Account] {
139        &self.records
140    }
141
142    /// Epoch captured by every session; restoring a backup rotates it.
143    pub fn epoch(&self) -> &str {
144        &self.epoch
145    }
146
147    /// Find a canonical login name, accepting an uppercase spelling.
148    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    /// Authenticate without distinguishing missing, disabled or wrong-password accounts.
154    /// Password work must be scheduled on a bounded blocking worker by HTTP callers.
155    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    /// Validate the owner and generation captured by a session after a reload.
170    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    /// Add an enabled account with a generated, immutable personal owner.
180    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    /// Change privileges while retaining at least one enabled administrator.
212    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    /// Disable or enable access, preserving every personal record.
217    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    /// Rename a login without moving personal data.
222    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    /// Replace a salted password verifier and invalidate this account's sessions and API keys.
234    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    /// Revoke one account's sessions and API keys without changing its password or opinions.
243    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    /// Invalidate all sessions and API keys, including credentials from before a backup restore.
257    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
302/// Validate and canonicalize a login name without treating it as a path or owner.
303/// Accept 1–64 ASCII letters, digits, dots, underscores or hyphens, with at
304/// least one letter or digit. The canonical spelling is lowercase.
305pub 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
347/// Generate an opaque 256-bit secret from the operating system's secure RNG.
348/// Failures are reported; there is no timestamp or deterministic fallback.
349pub 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
362/// Serialize the credential store for protected persistence and full backups only.
363/// Never expose this document as an account-list or catalog API response.
364pub 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/// A refused credential document, with a stable reason for persistence adapters.
388#[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    /// The validation reason, without any stored verifier or credential material.
399    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
412// Legacy storage represents all JSON numbers as f64, including integral
413// spellings such as 1.0 and 1e0. Preserve that contract without truncation.
414fn 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
448/// Refuse malformed, duplicate or administrator-less credentials as one whole store.
449/// Stored hash parameters are fixed to the bounded policy supported by this format.
450pub 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;