Oregami
Repositories/oxedyne/fe2o3

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
32use oxedyne_fe2o3_core::prelude::*;
33use oxedyne_fe2o3_hash::kdf::KeyDerivationScheme;
34use oxedyne_fe2o3_iop_hash::kdf::KeyDeriver;
35use oxedyne_fe2o3_jdat::prelude::*;
36use oxedyne_fe2o3_net::mail::{
37 store::MailUser,
38 user::UserStore,
39};
40
41use std::{
42 fs,
43 path::PathBuf,
44 sync::Arc,
45};
46
47
48/// One row in the user database.
49#[derive(Clone, Debug)]
50struct 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)]
68pub struct PasswdFileUserStore {
69 path: Arc<PathBuf>,
70}
71
72impl 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.
148fn 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
177impl 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)]
217mod 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}