1use std::collections::BTreeMap;
8use std::path::PathBuf;
9
10use base64::Engine as _;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
12use jiff::Timestamp;
13use okbot_core::PeerTarget;
14use serde::{Deserialize, Serialize};
15
16use crate::Error;
17use crate::key::{PublicKey, SigningKey};
18
19pub const DEFAULT_VALIDITY_DAYS: i64 = 365;
21
22pub const MAX_VALIDITY_DAYS: i64 = 3650;
24
25pub const SHORT_VALIDITY_DAYS: i64 = 90;
28
29pub const EXPIRY_WARNING_DAYS: i64 = 14;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct Manifest {
36 pub team: TeamInfo,
38 #[serde(default)]
40 pub members: BTreeMap<String, Member>,
41 #[serde(default)]
43 pub links: Vec<Link>,
44 #[serde(default)]
46 pub people: BTreeMap<String, TeamPerson>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct TeamInfo {
53 pub id: String,
55 pub version: u64,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub expires: Option<Timestamp>,
60 #[serde(default, skip_serializing_if = "String::is_empty")]
62 pub description: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct Member {
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub key: Option<PublicKey>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub box_key: Option<crate::seal::BoxPublic>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub url: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub relay: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub dir: Option<PathBuf>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub roles: Vec<String>,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub skills: Vec<String>,
90 #[serde(default, skip_serializing_if = "String::is_empty")]
92 pub description: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct Link {
100 pub from: Vec<String>,
102 pub to: Vec<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct TeamPerson {
110 #[serde(default, skip_serializing_if = "String::is_empty")]
112 pub name: String,
113 #[serde(default)]
115 pub accounts: Vec<String>,
116}
117
118fn slug(kind: &str, value: &str) -> Result<(), Error> {
119 let ok = !value.is_empty()
120 && value
121 .bytes()
122 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_');
123 if ok {
124 Ok(())
125 } else {
126 Err(Error::Manifest(format!(
127 "{kind} `{value}` may only contain a-z, 0-9, `-` and `_`"
128 )))
129 }
130}
131
132impl Manifest {
133 pub fn new(id: &str) -> Self {
135 Self {
136 team: TeamInfo {
137 id: id.to_string(),
138 version: 0,
139 expires: None,
140 description: String::new(),
141 },
142 members: BTreeMap::new(),
143 links: Vec::new(),
144 people: BTreeMap::new(),
145 }
146 }
147
148 pub fn parse(text: &str) -> Result<Self, Error> {
150 let manifest: Self = toml::from_str(text).map_err(|e| Error::Manifest(e.to_string()))?;
151 manifest.check()?;
152 Ok(manifest)
153 }
154
155 pub fn to_toml(&self) -> String {
157 toml::to_string(self).expect("a manifest serializes")
158 }
159
160 pub fn check(&self) -> Result<(), Error> {
162 slug("team id", &self.team.id)?;
163 for (name, member) in &self.members {
164 slug("member name", name)?;
165 for role in &member.roles {
166 slug("role", role)?;
167 }
168 let ways = usize::from(member.url.is_some())
169 + usize::from(member.relay.is_some())
170 + usize::from(member.dir.is_some());
171 if ways != 1 {
172 return Err(Error::Manifest(format!(
173 "member `{name}` needs exactly one of `url`, `relay` and `dir`"
174 )));
175 }
176 if member.dir.is_none() && member.key.is_none() {
177 return Err(Error::Manifest(format!("member `{name}` needs a `key`")));
178 }
179 if let Some(url) = &member.url
180 && !(url.starts_with("https://") || url.starts_with("http://127.0.0.1"))
181 {
182 return Err(Error::Manifest(format!(
183 "member `{name}`: `url` must be https:// (or http://127.0.0.1 for tests)"
184 )));
185 }
186 if let Some(relay) = &member.relay {
187 if member.box_key.is_none() {
188 return Err(Error::Manifest(format!(
189 "member `{name}` is reached through a relay and needs a `box_key` \
190 (`okbot peer id` prints it), so the relay cannot read its messages"
191 )));
192 }
193 let via = self.members.get(relay);
194 if relay == name || via.is_none_or(|m| m.url.is_none()) {
195 return Err(Error::Manifest(format!(
196 "member `{name}`: relay `{relay}` must be another member with a `url`"
197 )));
198 }
199 }
200 }
201 let mut keys = std::collections::HashSet::new();
202 for (name, member) in &self.members {
203 if let Some(key) = &member.key
204 && !keys.insert(key)
205 {
206 return Err(Error::Manifest(format!(
207 "member `{name}` has the key of another member"
208 )));
209 }
210 }
211 for link in &self.links {
212 for selector in link.from.iter().chain(&link.to) {
213 if selector != "*"
214 && !selector.starts_with("role:")
215 && !self.members.contains_key(selector)
216 {
217 return Err(Error::Manifest(format!(
218 "link selector `{selector}` is not a member, `role:<name>` or `*`"
219 )));
220 }
221 }
222 }
223 for (id, person) in &self.people {
224 slug("person id", id)?;
225 for account in &person.accounts {
226 let valid = account.split_once('/').is_some_and(|(channel, user)| {
227 !user.is_empty() && channel.parse::<okbot_core::ChannelId>().is_ok()
228 });
229 if !valid {
230 return Err(Error::Manifest(format!(
231 "person `{id}`: `{account}` is not `platform:instance/user-id`"
232 )));
233 }
234 }
235 }
236 Ok(())
237 }
238
239 fn selects(&self, selector: &str, member: &str) -> bool {
240 match selector.strip_prefix("role:") {
241 _ if selector == "*" => true,
242 Some(role) => self
243 .members
244 .get(member)
245 .is_some_and(|m| m.roles.iter().any(|r| r == role)),
246 None => selector == member,
247 }
248 }
249
250 pub fn may_call(&self, from: &str, to: &str) -> bool {
252 from != to
253 && self.members.contains_key(from)
254 && self.members.contains_key(to)
255 && self.links.iter().any(|l| {
256 l.from.iter().any(|s| self.selects(s, from))
257 && l.to.iter().any(|s| self.selects(s, to))
258 })
259 }
260
261 pub fn callees(&self, from: &str) -> Vec<&str> {
263 self.members
264 .keys()
265 .map(String::as_str)
266 .filter(|to| self.may_call(from, to))
267 .collect()
268 }
269
270 pub fn route(&self, from: &str, target: &PeerTarget) -> Vec<&str> {
272 self.callees(from)
273 .into_iter()
274 .filter(|name| {
275 let member = &self.members[*name];
276 match target {
277 PeerTarget::Member(n) => n == name,
278 PeerTarget::Role(r) => member.roles.contains(r),
279 PeerTarget::Skill(s) => member.skills.contains(s),
280 }
281 })
282 .collect()
283 }
284
285 pub fn person_of(&self, account: &str) -> Option<&str> {
287 self.people
288 .iter()
289 .find(|(_, p)| p.accounts.iter().any(|a| a == account))
290 .map(|(id, _)| id.as_str())
291 }
292
293 pub fn member_by_key(&self, key: &PublicKey) -> Option<&str> {
295 self.members
296 .iter()
297 .find(|(_, m)| m.key.as_ref() == Some(key))
298 .map(|(name, _)| name.as_str())
299 }
300
301 pub fn may_replace(&self, current: &Self) -> Result<(), Error> {
303 if self.team.id != current.team.id {
304 return Err(Error::Manifest("the manifest is for another team".into()));
305 }
306 if self.team.version < current.team.version
307 || (self.team.version == current.team.version && self != current)
308 {
309 return Err(Error::Manifest(format!(
310 "version {} does not follow version {}",
311 self.team.version, current.team.version
312 )));
313 }
314 Ok(())
315 }
316
317 pub fn days_left(&self, now: Timestamp) -> Option<i64> {
319 self.team
320 .expires
321 .map(|e| e.duration_since(now).as_secs().div_euclid(86_400))
322 }
323
324 pub fn check_fresh(&self, now: Timestamp, signed: bool) -> Result<(), Error> {
326 match self.team.expires {
327 Some(expires) if expires <= now => Err(Error::Manifest(format!(
328 "the manifest of team `{}` expired at {expires}; the owner must sign it again",
329 self.team.id
330 ))),
331 None if signed => Err(Error::Manifest("a signed manifest needs `expires`".into())),
332 _ => Ok(()),
333 }
334 }
335}
336
337pub fn sign(text: &str, key: &SigningKey) -> String {
339 format!(
340 "{} {}\n",
341 key.public(),
342 B64.encode(key.sign(text.as_bytes()))
343 )
344}
345
346pub fn verify(text: &str, signatures: &str, team_key: &PublicKey) -> Result<Manifest, Error> {
348 let signed = signatures.lines().any(|line| {
349 let mut parts = line.split_whitespace();
350 let (Some(key), Some(signature)) = (parts.next(), parts.next()) else {
351 return false;
352 };
353 key.parse::<PublicKey>().ok().as_ref() == Some(team_key)
354 && B64
355 .decode(signature)
356 .is_ok_and(|s| team_key.verify(text.as_bytes(), &s))
357 });
358 if !signed {
359 return Err(Error::Manifest(
360 "the manifest is not signed by the team key".into(),
361 ));
362 }
363 Manifest::parse(text)
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 const TEXT: &str = r#"
371[team]
372id = "acme"
373version = 3
374expires = "2030-01-01T00:00:00Z"
375
376[members.lead]
377key = "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
378url = "https://lead.example.com/a2a/acme"
379roles = ["leader"]
380
381[members.research]
382key = "ed25519:AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
383box_key = "x25519:AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
384relay = "lead"
385roles = ["member"]
386skills = ["web-research"]
387
388[members.ops]
389key = "ed25519:AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
390url = "https://ops.example.com/a2a/acme"
391roles = ["member"]
392skills = ["infrastructure"]
393
394[[links]]
395from = ["role:leader"]
396to = ["*"]
397
398[[links]]
399from = ["*"]
400to = ["role:leader"]
401
402[people.lan]
403name = "Lan"
404accounts = ["zalo:shop/123"]
405"#;
406
407 #[test]
408 fn links_decide_who_may_call_whom() {
409 let m = Manifest::parse(TEXT).unwrap();
410 assert!(m.may_call("lead", "ops"));
411 assert!(m.may_call("research", "lead"));
412 assert!(
413 !m.may_call("research", "ops"),
414 "members do not call each other"
415 );
416 assert!(!m.may_call("lead", "lead"), "never itself");
417 assert!(!m.may_call("lead", "stranger"));
418 assert_eq!(m.callees("lead"), ["ops", "research"]);
419 assert_eq!(
420 m.route("lead", &PeerTarget::Skill("web-research".into())),
421 ["research"]
422 );
423 assert_eq!(
424 m.route("lead", &PeerTarget::Role("member".into())),
425 ["ops", "research"]
426 );
427 assert_eq!(
428 m.route("ops", &PeerTarget::Member("research".into())),
429 [] as [&str; 0]
430 );
431 assert_eq!(m.person_of("zalo:shop/123"), Some("lan"));
432 assert_eq!(m.person_of("zalo:shop/9"), None);
433 }
434
435 #[test]
436 fn invalid_manifests_are_refused() {
437 let broken = |from: &str, to: &str| TEXT.replacen(from, to, 1);
438 for text in [
439 broken("relay = \"lead\"", "relay = \"research\""),
440 broken("relay = \"lead\"", "url = \"https://x\"\nrelay = \"lead\""),
441 broken(
442 "url = \"https://lead.example.com/a2a/acme\"",
443 "url = \"http://lead\"",
444 ),
445 broken("to = [\"*\"]", "to = [\"nobody\"]"),
446 broken("id = \"acme\"", "id = \"Acme\""),
447 broken("zalo:shop/123", "zalo"),
448 broken("version = 3", "version = 3\nowner = \"x\""),
449 broken(
450 "box_key = \"x25519:AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\n",
451 "",
452 ),
453 broken(
454 "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
455 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
456 ),
457 ] {
458 assert!(Manifest::parse(&text).is_err(), "{text}");
459 }
460 }
461
462 #[test]
463 fn signatures_and_versions() {
464 let (team, _) = SigningKey::generate().unwrap();
465 let sig = sign(TEXT, &team);
466 let m = verify(TEXT, &sig, &team.public()).unwrap();
467 assert!(
468 verify(
469 &TEXT.replace("version = 3", "version = 4"),
470 &sig,
471 &team.public()
472 )
473 .is_err()
474 );
475 let (other, _) = SigningKey::generate().unwrap();
476 assert!(verify(TEXT, &sig, &other.public()).is_err());
477 let both = format!("{}{}", sign(TEXT, &other), sign(TEXT, &team));
479 assert!(verify(TEXT, &both, &team.public()).is_ok());
480 assert!(verify(TEXT, &both, &other.public()).is_ok());
481
482 let mut newer = m.clone();
483 newer.team.version = 4;
484 assert!(newer.may_replace(&m).is_ok());
485 assert!(m.may_replace(&newer).is_err(), "no rollback");
486 assert!(m.may_replace(&m).is_ok());
487 let mut changed = m.clone();
488 changed.links.clear();
489 assert!(
490 changed.may_replace(&m).is_err(),
491 "same version, other content"
492 );
493
494 let at = |s: &str| s.parse::<Timestamp>().unwrap();
495 assert!(m.check_fresh(at("2029-12-31T00:00:00Z"), true).is_ok());
496 assert!(m.check_fresh(at("2030-01-01T00:00:00Z"), true).is_err());
497 assert_eq!(m.days_left(at("2029-12-17T12:00:00Z")), Some(14));
498 assert_eq!(m.days_left(at("2029-12-31T23:00:00Z")), Some(0));
499 let mut forever = m;
500 forever.team.expires = None;
501 assert!(
502 forever
503 .check_fresh(at("2029-01-01T00:00:00Z"), true)
504 .is_err()
505 );
506 assert!(
507 forever
508 .check_fresh(at("2029-01-01T00:00:00Z"), false)
509 .is_ok()
510 );
511 }
512
513 #[test]
514 fn round_trips_through_toml() {
515 let m = Manifest::parse(TEXT).unwrap();
516 assert_eq!(Manifest::parse(&m.to_toml()).unwrap(), m);
517 }
518}