oxedyne/fe2o3/fe2o3_mail/src/passwd.rs
10.6 KiB, 17 runs
created by r1870400018:9818, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! `passwd`-style file-backed `UserStore`. |
| 2 | //! |
| 3 | //! Reads a JDAT file mapping email addresses to Argon2id-hashed |
| 4 | //! passwords plus the relative directory under which the matching |
| 5 | //! Maildir tree lives. Designed to be hand-edited by an administrator |
| 6 | //! and to be hot-reloaded on every authentication so password changes |
| 7 | //! take effect without restarting the server. |
| 8 | //! |
| 9 | //! File format (`users.jdat`): |
| 10 | //! |
| 11 | //! ```jdat |
| 12 | //! { |
| 13 | //! "users": [ |
| 14 | //! { |
| 15 | //! "address": "postmaster@example.com", |
| 16 | //! "delivery_dir": "example.com/postmaster", |
| 17 | //! "argon2id": "$argon2id$v=19$m=4096,t=3,p=1$<salt>$<hash>", |
| 18 | //! "send_as": ["news@example.com", "noreply@example.com"] |
| 19 | //! } |
| 20 | //! ] |
| 21 | //! } |
| 22 | //! ``` |
| 23 | //! |
| 24 | //! The `argon2id` value is the encoded form produced by |
| 25 | //! `oxedyne_fe2o3_hash::kdf::KeyDerivationScheme`. |
| 26 | //! |
| 27 | //! `send_as`, optional, lists the addresses besides its own that the account may send as on |
| 28 | //! submission: the identities a mail client sends through this one login. The submission server |
| 29 | //! refuses any other sender, in the envelope or in the header `From`, since 2026-09-23. Being |
| 30 | //! hot-reloaded, a change to it takes effect at the next authentication. |
| 31 | |
| 32 | use oxedyne_fe2o3_core::prelude::*; |
| 33 | use oxedyne_fe2o3_hash::kdf::KeyDerivationScheme; |
| 34 | use oxedyne_fe2o3_iop_hash::kdf::KeyDeriver; |
| 35 | use oxedyne_fe2o3_jdat::prelude::*; |
| 36 | use oxedyne_fe2o3_net::mail::{ |
| 37 | store::MailUser, |
| 38 | user::UserStore, |
| 39 | }; |
| 40 | |
| 41 | use std::{ |
| 42 | fs, |
| 43 | path::PathBuf, |
| 44 | sync::Arc, |
| 45 | }; |
| 46 | |
| 47 | |
| 48 | /// One row in the user database. |
| 49 | #[derive(Clone, Debug)] |
| 50 | struct PasswdEntry { |
| 51 | /// Lowercased full email address (`local@domain`). |
| 52 | address: String, |
| 53 | /// Relative path under the Maildir root that holds this user's |
| 54 | /// mailbox tree. |
| 55 | delivery_dir: String, |
| 56 | /// Encoded Argon2id hash (output of |
| 57 | /// `KeyDerivationScheme::encode_to_string`). |
| 58 | encoded_hash: String, |
| 59 | send_as: Vec<String>, // lower-cased, besides `address` |
| 60 | } |
| 61 | |
| 62 | /// File-backed user store. |
| 63 | /// |
| 64 | /// Cheaply cloneable -- the file path is wrapped in an `Arc` and the |
| 65 | /// store reloads on every call. For tens of users this is fast enough |
| 66 | /// and avoids any reload coordination. |
| 67 | #[derive(Clone, Debug)] |
| 68 | pub struct PasswdFileUserStore { |
| 69 | path: Arc<PathBuf>, |
| 70 | } |
| 71 | |
| 72 | impl PasswdFileUserStore { |
| 73 | /// Build a store backed by the given JDAT file. |
| 74 | pub fn new(path: PathBuf) -> Self { |
| 75 | Self { path: Arc::new(path) } |
| 76 | } |
| 77 | |
| 78 | /// Read and parse every entry in the file. |
| 79 | fn load(&self) -> Outcome<Vec<PasswdEntry>> { |
| 80 | let text = match fs::read_to_string(self.path.as_path()) { |
| 81 | Ok(s) => s, |
| 82 | Err(e) => return Err(err!(e, |
| 83 | "Reading user file {:?}.", self.path; |
| 84 | IO, File, Read)), |
| 85 | }; |
| 86 | let dat = res!(Dat::decode_string(&text)); |
| 87 | let map = match dat { |
| 88 | Dat::Map(m) => m, |
| 89 | _ => return Err(err!( |
| 90 | "User file {:?} top-level must be a map.", self.path; |
| 91 | Invalid, Input, Mismatch)), |
| 92 | }; |
| 93 | let users = match map.get(&dat!("users")) { |
| 94 | Some(Dat::List(l)) => l.clone(), |
| 95 | _ => return Err(err!( |
| 96 | "User file {:?} has no 'users' list.", self.path; |
| 97 | Invalid, Input, Missing)), |
| 98 | }; |
| 99 | let mut out = Vec::with_capacity(users.len()); |
| 100 | for entry in users { |
| 101 | let m = match entry { |
| 102 | Dat::Map(m) => m, |
| 103 | _ => return Err(err!( |
| 104 | "Each user entry must be a map."; |
| 105 | Invalid, Input, Mismatch)), |
| 106 | }; |
| 107 | let address = match m.get(&dat!("address")) { |
| 108 | Some(Dat::Str(s)) => s.to_lowercase(), |
| 109 | _ => return Err(err!( |
| 110 | "User entry missing 'address'."; |
| 111 | Invalid, Input, Missing)), |
| 112 | }; |
| 113 | let delivery_dir = match m.get(&dat!("delivery_dir")) { |
| 114 | Some(Dat::Str(s)) => s.clone(), |
| 115 | _ => return Err(err!( |
| 116 | "User entry missing 'delivery_dir'."; |
| 117 | Invalid, Input, Missing)), |
| 118 | }; |
| 119 | let encoded_hash = match m.get(&dat!("argon2id")) { |
| 120 | Some(Dat::Str(s)) => s.clone(), |
| 121 | _ => return Err(err!( |
| 122 | "User entry missing 'argon2id'."; |
| 123 | Invalid, Input, Missing)), |
| 124 | }; |
| 125 | let send_as = res!(read_send_as(m.get(&dat!("send_as")), &address)); |
| 126 | out.push(PasswdEntry { address, delivery_dir, encoded_hash, send_as }); |
| 127 | } |
| 128 | Ok(out) |
| 129 | } |
| 130 | |
| 131 | fn entry_to_user(e: &PasswdEntry) -> MailUser { |
| 132 | let (local, domain) = match e.address.rfind('@') { |
| 133 | Some(i) => (e.address[..i].to_string(), e.address[i + 1..].to_string()), |
| 134 | None => (e.address.clone(), String::new()), |
| 135 | }; |
| 136 | MailUser { |
| 137 | local, |
| 138 | domain, |
| 139 | delivery_key: e.delivery_dir.clone(), |
| 140 | send_as: e.send_as.clone(), |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /// An entry's `send_as` list, lower-cased. Absent is an empty list. Anything that is not a list |
| 146 | /// of addresses is refused rather than skipped, since a skipped entry is an identity whose mail |
| 147 | /// is refused with no word of why. |
| 148 | fn read_send_as(d: Option<&Dat>, address: &str) -> Outcome<Vec<String>> { |
| 149 | let items: Vec<Dat> = match d { |
| 150 | None => return Ok(Vec::new()), |
| 151 | Some(Dat::List(l)) => l.clone(), |
| 152 | Some(Dat::Vek(v)) => v.iter().cloned().collect(), |
| 153 | Some(other) => return Err(err!( |
| 154 | "User entry {}: 'send_as' must be a list of addresses, got {:?}.", |
| 155 | address, other.kind(); |
| 156 | Invalid, Input, Mismatch)), |
| 157 | }; |
| 158 | let mut out = Vec::with_capacity(items.len()); |
| 159 | for item in items { |
| 160 | let a = match item { |
| 161 | Dat::Str(s) => s.trim().to_lowercase(), |
| 162 | other => return Err(err!( |
| 163 | "User entry {}: a 'send_as' member is a {:?}, not an address.", |
| 164 | address, other.kind(); |
| 165 | Invalid, Input, Mismatch)), |
| 166 | }; |
| 167 | match a.rfind('@') { |
| 168 | Some(i) if i > 0 && i + 1 < a.len() && !a.contains(char::is_whitespace) => out.push(a), |
| 169 | _ => return Err(err!( |
| 170 | "User entry {}: 'send_as' member {:?} is not an address.", address, a; |
| 171 | Invalid, Input)), |
| 172 | } |
| 173 | } |
| 174 | Ok(out) |
| 175 | } |
| 176 | |
| 177 | impl UserStore for PasswdFileUserStore { |
| 178 | |
| 179 | fn authenticate( |
| 180 | &self, |
| 181 | address: &str, |
| 182 | password: &str, |
| 183 | ) |
| 184 | -> Outcome<Option<MailUser>> |
| 185 | { |
| 186 | let entries = res!(self.load()); |
| 187 | let lc = address.to_lowercase(); |
| 188 | let entry = match entries.iter().find(|e| e.address == lc) { |
| 189 | Some(e) => e, |
| 190 | None => return Ok(None), |
| 191 | }; |
| 192 | // Decode and verify the Argon2id hash. The encoded form is the |
| 193 | // same as `KeyDerivationScheme::encode_to_string` and round- |
| 194 | // trips through `KeyDerivationScheme::from_encoded_string`. |
| 195 | let mut kdf = res!(KeyDerivationScheme::from_str("Argon2id_v0x13")); |
| 196 | if let Err(e) = kdf.decode_from_string(&entry.encoded_hash) { |
| 197 | warn!("Failed to decode Argon2id hash for {}: {}", lc, e); |
| 198 | return Ok(None); |
| 199 | } |
| 200 | let ok = res!(kdf.verify(password.as_bytes())); |
| 201 | if !ok { return Ok(None); } |
| 202 | Ok(Some(Self::entry_to_user(entry))) |
| 203 | } |
| 204 | |
| 205 | fn lookup(&self, address: &str) -> Outcome<Option<MailUser>> { |
| 206 | let entries = res!(self.load()); |
| 207 | let lc = address.to_lowercase(); |
| 208 | if let Some(e) = entries.iter().find(|e| e.address == lc) { |
| 209 | return Ok(Some(Self::entry_to_user(e))); |
| 210 | } |
| 211 | Ok(None) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | |
| 216 | #[cfg(test)] |
| 217 | mod tests { |
| 218 | use super::*; |
| 219 | |
| 220 | /// A users file in a fresh scratch directory, removed by the caller. |
| 221 | fn users_file(tag: &str, body: &str) -> Outcome<PathBuf> { |
| 222 | let nanos = std::time::SystemTime::now() |
| 223 | .duration_since(std::time::UNIX_EPOCH) |
| 224 | .map(|d| d.as_nanos()) |
| 225 | .unwrap_or(0); |
| 226 | let dir = std::env::temp_dir().join(fmt!( |
| 227 | "fe2o3_mail_passwd_{}_{}_{}", tag, std::process::id(), nanos)); |
| 228 | res!(fs::create_dir_all(&dir), IO, File); |
| 229 | let path = dir.join("users.jdat"); |
| 230 | res!(fs::write(&path, body), IO, File); |
| 231 | Ok(path) |
| 232 | } |
| 233 | |
| 234 | fn entry(send_as: &str) -> String { |
| 235 | fmt!("{{ \"users\": [ {{ \"address\": \"Hello@Example.com\", \"delivery_dir\": \"example.com/hello\", \ |
| 236 | \"argon2id\": \"x\"{} }} ] }}", send_as) |
| 237 | } |
| 238 | |
| 239 | /// The identities an entry lists reach the account, lower-cased, and an entry without the |
| 240 | /// field sends as itself alone. |
| 241 | #[test] |
| 242 | fn send_as_reaches_the_account() -> Outcome<()> { |
| 243 | let path = res!(users_file("listed", &entry( |
| 244 | ", \"send_as\": [\"News@Example.com\", \"noreply@example.com\"]"))); |
| 245 | let store = PasswdFileUserStore::new(path.clone()); |
| 246 | let user = match res!(store.lookup("hello@example.com")) { |
| 247 | Some(u) => u, |
| 248 | None => return Err(err!("The listed account did not resolve."; Test)), |
| 249 | }; |
| 250 | assert_eq!(user.send_as, vec![fmt!("news@example.com"), fmt!("noreply@example.com")]); |
| 251 | assert!(user.may_send_as("hello@example.com")); |
| 252 | assert!(user.may_send_as("NEWS@example.com")); |
| 253 | assert!(!user.may_send_as("ceo@example.com")); |
| 254 | |
| 255 | res!(fs::write(&path, entry("")), IO, File); |
| 256 | let user = match res!(store.lookup("hello@example.com")) { |
| 257 | Some(u) => u, |
| 258 | None => return Err(err!("The plain account did not resolve."; Test)), |
| 259 | }; |
| 260 | assert!(user.send_as.is_empty()); |
| 261 | assert!(!user.may_send_as("news@example.com"), "the reload must drop the identity"); |
| 262 | if let Some(dir) = path.parent() { |
| 263 | let _ = fs::remove_dir_all(dir); |
| 264 | } |
| 265 | Ok(()) |
| 266 | } |
| 267 | |
| 268 | /// A `send_as` that is not a list of addresses fails the load and says which entry. |
| 269 | #[test] |
| 270 | fn a_bad_send_as_is_refused_by_entry() -> Outcome<()> { |
| 271 | for bad in [ |
| 272 | ", \"send_as\": \"news@example.com\"", |
| 273 | ", \"send_as\": [\"news\"]", |
| 274 | ", \"send_as\": [\"news @example.com\"]", |
| 275 | ", \"send_as\": [(u8|3)]", |
| 276 | ] { |
| 277 | let path = res!(users_file("bad", &entry(bad))); |
| 278 | let store = PasswdFileUserStore::new(path.clone()); |
| 279 | match store.lookup("hello@example.com") { |
| 280 | Ok(u) => return Err(err!("{:?} loaded as {:?}.", bad, u; Test)), |
| 281 | Err(e) => assert!(fmt!("{}", e).contains("hello@example.com"), |
| 282 | "the refusal must name the entry: {}", e), |
| 283 | } |
| 284 | if let Some(dir) = path.parent() { |
| 285 | let _ = fs::remove_dir_all(dir); |
| 286 | } |
| 287 | } |
| 288 | Ok(()) |
| 289 | } |
| 290 | } |