Skip to main content

okbot_peer/
manifest.rs

1//! Team manifests (ADR 0014): who the members are, how to reach them, and which may call which.
2//!
3//! A manifest fetched over the network is signed by the team key: the `.sig` file next to it holds
4//! one line per signature, `ed25519:<public key> <base64url signature>`, over the exact bytes of
5//! the manifest. Two lines (old and new key) let the owner rotate the team key.
6
7use 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
19/// How long `okbot team sign` makes a manifest valid by default.
20pub const DEFAULT_VALIDITY_DAYS: i64 = 365;
21
22/// The longest a signed manifest may stay valid.
23pub const MAX_VALIDITY_DAYS: i64 = 3650;
24
25/// Longer than this, `okbot team sign` warns: a member kept on an old manifest (a newer one blocked
26/// on its way) trusts it until it expires, revoked members included.
27pub const SHORT_VALIDITY_DAYS: i64 = 90;
28
29/// Members warn when their manifest expires within this many days.
30pub const EXPIRY_WARNING_DAYS: i64 = 14;
31
32/// A team manifest.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct Manifest {
36    /// The team itself.
37    pub team: TeamInfo,
38    /// Members by name.
39    #[serde(default)]
40    pub members: BTreeMap<String, Member>,
41    /// Which members may call which.
42    #[serde(default)]
43    pub links: Vec<Link>,
44    /// People the team knows, by person id.
45    #[serde(default)]
46    pub people: BTreeMap<String, TeamPerson>,
47}
48
49/// `[team]`.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct TeamInfo {
53    /// The team id (a lowercase slug), the `<team>` of `peer:<team>`.
54    pub id: String,
55    /// Grows with every change; a lower version is refused.
56    pub version: u64,
57    /// After this the manifest is not used (required when signed).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub expires: Option<Timestamp>,
60    /// What the team is for.
61    #[serde(default, skip_serializing_if = "String::is_empty")]
62    pub description: String,
63}
64
65/// `[members.<name>]`.
66#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct Member {
69    /// Its public key (read from `dir` when that is set).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub key: Option<PublicKey>,
72    /// Its X25519 key, for what passes through a relay (read from `dir` when that is set).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub box_key: Option<crate::seal::BoxPublic>,
75    /// Its A2A endpoint (`https://…/a2a/<team>`).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub url: Option<String>,
78    /// The member that relays requests to it.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub relay: Option<String>,
81    /// Its bot directory on this machine (local teams).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub dir: Option<PathBuf>,
84    /// Team roles.
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub roles: Vec<String>,
87    /// Skills, for `skill:<name>` targets and the directory.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub skills: Vec<String>,
90    /// What it does.
91    #[serde(default, skip_serializing_if = "String::is_empty")]
92    pub description: String,
93}
94
95/// `[[links]]`: members matching `from` may call members matching `to`. Selectors are a member
96/// name, `role:<name>` or `*`.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct Link {
100    /// Callers.
101    pub from: Vec<String>,
102    /// Callees.
103    pub to: Vec<String>,
104}
105
106/// `[people.<id>]`.
107#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct TeamPerson {
110    /// Display name.
111    #[serde(default, skip_serializing_if = "String::is_empty")]
112    pub name: String,
113    /// Chat accounts, `platform:instance/user-id`.
114    #[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    /// An empty manifest for a new team.
134    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    /// Parses and checks a manifest.
149    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    /// The manifest as TOML.
156    pub fn to_toml(&self) -> String {
157        toml::to_string(self).expect("a manifest serializes")
158    }
159
160    /// Checks names, reachability and references.
161    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    /// `from` may call `to` (both members, different, and a link allows it).
251    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    /// Members `from` may call, in name order.
262    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    /// Members matching `target` that `from` may call, in name order.
271    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    /// The person whose accounts include `account`.
286    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    /// The member whose key is `key`.
294    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    /// Whether this manifest may replace `current`: a higher version, or the same one unchanged.
302    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    /// Whole days from `now` until the manifest expires, if it expires.
318    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    /// The manifest is not expired at `now`. Signed manifests must say when they expire.
325    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
337/// Signs manifest text: one `.sig` line.
338pub 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
346/// Checks that one line of `signatures` is `team_key`'s signature of `text`, then parses it.
347pub 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        // Rotation: signed by the old and the new key, both pins accept it.
478        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}