Skip to main content

borg_mesh/
invite.rs

1//! Onboarding by ticket: `borg mesh invite` prints one string (and a QR code of it), `borg mesh
2//! join <ticket>` on the new machine does the rest.
3//!
4//! - **Invite**: collective, the issuing admin's key, a random id, the role, an expiry; signed by
5//!   that admin. The admin's node keeps what it issued in `<store>/mesh/invites.json` (uses left,
6//!   who joined with it); the file, not the ticket, decides whether an invite is still good, so an
7//!   admin can withdraw one (`borg mesh invites --revoke ID`) after handing it out.
8//! - **Ticket**: the invite plus where the admin can be reached: its relay setting (so the joiner
9//!   uses the same relays) and hints (direct addresses, its home relay; addr.rs). Encoded compactly
10//!   as `BORG1` + base32 (uppercase, so a QR code uses its dense alphanumeric mode).
11//! - **Redeem** (the admin's node, sync.rs `admit`): issued by this node, signature good, not
12//!   expired, uses left, not withdrawn, the key not revoked. Then the key is added to the signed
13//!   membership with the invite's role (membership.rs `invite`: the chain semantics are unchanged)
14//!   and one use is spent. The key that spent an invite may present it again (a lost answer) and is
15//!   welcomed without spending another use; any other key is refused once it is spent.
16//!
17//! A ticket is a bearer secret until it is spent: whoever holds it can join with the role it
18//! carries. Hence one use and seven days by default.
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use data_encoding::BASE32_NOPAD;
24use serde::{Deserialize, Serialize};
25
26use crate::addr::{self, Hint, Relay, Target};
27use crate::identity::{self, Identity};
28use crate::membership::Membership;
29use crate::{from_hex, to_hex};
30
31pub mod join_config;
32
33pub const PREFIX: &str = "BORG1";
34/// The roles a ticket may carry; admins are made by name (`borg mesh invite <key> --role admin`).
35pub const ROLES: [&str; 3] = ["member", "steer", "observe"];
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Invite {
39    pub collective: String,
40    /// The admin that issued it; only that admin's node redeems it.
41    pub admin: String,
42    /// Random, 16 bytes hex.
43    pub id: String,
44    pub role: String,
45    pub expires_ms: i64,
46    pub sig: String,
47}
48
49fn body(i: &Invite) -> Vec<u8> {
50    let mut b = b"borg-invite-v1\n".to_vec();
51    b.extend(serde_json::to_vec(&(&i.collective, &i.admin, &i.id, &i.role, i.expires_ms)).expect("invite serialises"));
52    b
53}
54
55impl Invite {
56    pub fn verify(&self) -> Result<(), String> {
57        identity::verify(&self.admin, &body(self), &self.sig).map_err(|e| format!("invite: {e}"))
58    }
59}
60
61/// What the admin's node keeps about an invite it issued.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Pending {
64    pub role: String,
65    pub expires_ms: i64,
66    pub uses_left: u32,
67    pub created_ms: i64,
68    /// The keys that joined with it.
69    #[serde(default)]
70    pub joined: Vec<String>,
71}
72
73pub fn path(home: &Path) -> PathBuf {
74    identity::dir(home).join("invites.json")
75}
76
77pub fn load(home: &Path) -> BTreeMap<String, Pending> {
78    std::fs::read_to_string(path(home)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
79}
80
81fn save(home: &Path, all: &BTreeMap<String, Pending>) -> Result<(), String> {
82    std::fs::create_dir_all(identity::dir(home)).map_err(|e| e.to_string())?;
83    let (p, tmp) = (path(home), path(home).with_extension("json.tmp"));
84    std::fs::write(&tmp, serde_json::to_string_pretty(all).unwrap()).map_err(|e| format!("{}: {e}", tmp.display()))?;
85    std::fs::rename(&tmp, &p).map_err(|e| format!("{}: {e}", p.display()))
86}
87
88/// Issues an invite for `role`, valid for `ttl_ms` and `uses` joins; only an admin of `m` can.
89pub fn issue(home: &Path, admin: &Identity, m: &Membership, role: &str, ttl_ms: i64, uses: u32, now: i64) -> Result<Invite, String> {
90    m.verify()?;
91    if m.role_of(&admin.public()) != Some("admin") {
92        return Err(format!("only an admin of {} can invite", m.collective));
93    }
94    if !ROLES.contains(&role) {
95        return Err(format!("role {role:?}: a ticket carries one of {}", ROLES.join(", ")));
96    }
97    if ttl_ms <= 0 || uses == 0 {
98        return Err("an invite needs a positive lifetime and at least one use".into());
99    }
100    let id = to_hex(&blake3::hash(&iroh::SecretKey::generate().to_bytes()).as_bytes()[..16]);
101    let mut inv = Invite { collective: m.collective.clone(), admin: admin.public(), id, role: role.into(), expires_ms: now + ttl_ms, sig: String::new() };
102    inv.sig = admin.sign(&body(&inv));
103    let mut all = load(home);
104    all.insert(inv.id.clone(), Pending { role: role.into(), expires_ms: inv.expires_ms, uses_left: uses, created_ms: now, joined: Vec::new() });
105    save(home, &all)?;
106    Ok(inv)
107}
108
109/// The invites that can still be used, soonest to expire first.
110pub fn pending(home: &Path, now: i64) -> Vec<(String, Pending)> {
111    let mut v: Vec<(String, Pending)> = load(home).into_iter().filter(|(_, p)| p.uses_left > 0 && p.expires_ms > now).collect();
112    v.sort_by_key(|(_, p)| p.expires_ms);
113    v
114}
115
116/// Withdraws an invite (a unique prefix of its id is enough); a ticket for it is refused from now on.
117pub fn withdraw(home: &Path, id: &str) -> Result<String, String> {
118    let mut all = load(home);
119    let hits: Vec<String> = all.keys().filter(|k| !id.is_empty() && k.starts_with(id)).cloned().collect();
120    match hits.as_slice() {
121        [one] => {
122            all.remove(one);
123            save(home, &all)?;
124            Ok(one.clone())
125        }
126        [] => Err(format!("no invite {id}")),
127        _ => Err(format!("{id} names {} invites: give more of the id", hits.len())),
128    }
129}
130
131/// The admin's side of a join (module doc). Returns the new membership, saved.
132pub fn redeem(home: &Path, admin: &Identity, collective: &str, inv: &Invite, joiner: &str, now: i64) -> Result<Membership, String> {
133    if inv.collective != collective {
134        return Err(format!("the invite is for {:?}, this node serves {collective:?}", inv.collective));
135    }
136    if inv.admin != admin.public() {
137        return Err("the invite was issued by another admin's node: ask that admin".into());
138    }
139    inv.verify()?;
140    let mut all = load(home);
141    let Some(p) = all.get_mut(&inv.id) else { return Err("unknown or withdrawn invite".into()) };
142    if p.role != inv.role || p.expires_ms != inv.expires_ms {
143        return Err("the invite does not match the one issued".into());
144    }
145    let mut m = Membership::load(home)?.ok_or("this node holds no membership")?;
146    if p.joined.iter().any(|k| k == joiner) && m.role_of(joiner).is_some() {
147        return Ok(m); // the same key again: its first answer was lost
148    }
149    if inv.expires_ms <= now {
150        return Err("the invite has expired: ask the admin for a new one".into());
151    }
152    if p.uses_left == 0 {
153        return Err("the invite was already used: ask the admin for a new one".into());
154    }
155    if m.revoked.iter().any(|r| r.key == joiner) {
156        return Err(format!("{joiner} was revoked: an admin has to re-invite it by key"));
157    }
158    if m.role_of(joiner).is_none() {
159        m.invite(admin, joiner, &inv.role)?;
160        m.save(home)?;
161    }
162    p.uses_left -= 1;
163    p.joined.push(joiner.into());
164    save(home, &all)?;
165    Ok(m)
166}
167
168/// An invite and where its admin is.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Ticket {
171    pub invite: Invite,
172    /// The admin's relay setting (addr.rs): the joiner's config takes it over.
173    pub relay: Relay,
174    pub hints: Vec<Hint>,
175}
176
177impl Ticket {
178    /// The admin as a dial target.
179    pub fn target(&self) -> Target {
180        Target { key: self.invite.admin.clone(), hints: self.hints.clone() }
181    }
182
183    pub fn encode(&self) -> String {
184        let i = &self.invite;
185        let mut b = vec![1u8];
186        put(&mut b, i.collective.as_bytes());
187        b.extend(from_hex::<32>(&i.admin).expect("admin key"));
188        b.extend(from_hex::<16>(&i.id).expect("invite id"));
189        put(&mut b, i.role.as_bytes());
190        b.extend(i.expires_ms.to_be_bytes());
191        b.extend(from_hex::<64>(&i.sig).expect("signature"));
192        let relay = if self.relay == Relay::Default { String::new() } else { self.relay.setting() };
193        put(&mut b, relay.as_bytes());
194        let hints: Vec<String> = self.hints.iter().take(255).map(|h| h.to_string()).collect();
195        b.push(hints.len() as u8);
196        for h in &hints {
197            put(&mut b, h.as_bytes());
198        }
199        format!("{PREFIX}{}", BASE32_NOPAD.encode(&b))
200    }
201
202    /// Decodes and checks the signature (whether it is still good only the admin's node knows).
203    pub fn decode(s: &str) -> Result<Ticket, String> {
204        let s: String = s.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_uppercase();
205        let body = s.strip_prefix(PREFIX).ok_or(format!("not a borg ticket (expected it to start with {PREFIX})"))?;
206        let b = BASE32_NOPAD.decode(body.as_bytes()).map_err(|e| format!("ticket: {e} (copied completely?)"))?;
207        let mut r = Reader { b: &b, at: 0 };
208        if r.take(1)? != [1] {
209            return Err("ticket: unknown version; update borg".into());
210        }
211        let collective = r.text()?;
212        let admin = to_hex(r.take(32)?);
213        let id = to_hex(r.take(16)?);
214        let role = r.text()?;
215        let expires_ms = i64::from_be_bytes(r.take(8)?.try_into().unwrap());
216        let sig = to_hex(r.take(64)?);
217        let relay = Relay::parse(&r.text()?)?;
218        let n = r.take(1)?[0];
219        let hints = (0..n).map(|_| r.text().and_then(|h| addr::hints(&h))).collect::<Result<Vec<_>, _>>()?.into_iter().flatten().collect();
220        if r.at != b.len() {
221            return Err("ticket: trailing bytes".into());
222        }
223        let invite = Invite { collective, admin, id, role, expires_ms, sig };
224        invite.verify()?;
225        Ok(Ticket { invite, relay, hints })
226    }
227}
228
229fn put(b: &mut Vec<u8>, s: &[u8]) {
230    let s = &s[..s.len().min(255)];
231    b.push(s.len() as u8);
232    b.extend(s);
233}
234
235struct Reader<'a> {
236    b: &'a [u8],
237    at: usize,
238}
239
240impl<'a> Reader<'a> {
241    fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
242        let s = self.b.get(self.at..self.at + n).ok_or("ticket: truncated (copied completely?)")?;
243        self.at += n;
244        Ok(s)
245    }
246
247    fn text(&mut self) -> Result<String, String> {
248        let n = self.take(1)?[0] as usize;
249        String::from_utf8(self.take(n)?.to_vec()).map_err(|e| format!("ticket: {e}"))
250    }
251}
252
253/// Where `serve` last found this node reachable (`<store>/mesh/addr.json`): what `invite` puts in
254/// a ticket, since `invite` runs while `serve` holds the socket.
255pub fn reach_path(home: &Path) -> PathBuf {
256    identity::dir(home).join("addr.json")
257}
258
259pub fn save_reach(home: &Path, hints: &[Hint]) -> Result<(), String> {
260    std::fs::create_dir_all(identity::dir(home)).map_err(|e| e.to_string())?;
261    let p = reach_path(home);
262    std::fs::write(&p, serde_json::json!({ "hints": addr::join_hints(hints) }).to_string()).map_err(|e| format!("{}: {e}", p.display()))
263}
264
265pub fn load_reach(home: &Path) -> Vec<Hint> {
266    let v: Option<serde_json::Value> = std::fs::read_to_string(reach_path(home)).ok().and_then(|t| serde_json::from_str(&t).ok());
267    v.and_then(|v| v.get("hints")?.as_str().map(str::to_string)).and_then(|h| addr::hints(&h).ok()).unwrap_or_default()
268}
269
270/// The path `join` writes for a new collective: routes nothing (it does not exist) until replaced.
271pub fn placeholder(name: &str) -> String {
272    format!("~/CHANGE-ME/repos-of-{name}")
273}
274
275/// `7d`, `12h`, `30m`, `45s` (or plain seconds) in ms.
276pub fn duration_ms(s: &str) -> Result<i64, String> {
277    let s = s.trim();
278    let (n, unit) = s.find(|c: char| !c.is_ascii_digit()).map_or((s, "s"), |i| s.split_at(i));
279    let n: i64 = n.parse().map_err(|_| format!("duration {s:?}: expected e.g. 7d, 12h, 30m"))?;
280    let unit = match unit {
281        "d" => borg::clock::DAY_MS,
282        "h" => 3_600_000,
283        "m" => 60_000,
284        "s" => 1000,
285        _ => return Err(format!("duration {s:?}: unit must be d, h, m or s")),
286    };
287    n.checked_mul(unit).filter(|ms| *ms > 0).ok_or(format!("duration {s:?} out of range"))
288}
289
290/// The ticket as a QR code in unicode half-blocks, light modules drawn (for a dark terminal).
291pub fn qr_text(ticket: &str) -> Result<String, String> {
292    use qrcode::render::unicode::Dense1x2;
293    let code = qrcode::QrCode::with_error_correction_level(ticket.as_bytes(), qrcode::EcLevel::L).map_err(|e| format!("qr: {e}"))?;
294    Ok(code.render::<Dense1x2>().dark_color(Dense1x2::Light).light_color(Dense1x2::Dark).quiet_zone(true).build())
295}
296
297/// The ticket as a QR code PNG (black on white, 8 px per module, 4 modules of quiet zone).
298pub fn qr_png(ticket: &str) -> Result<Vec<u8>, String> {
299    let code = qrcode::QrCode::with_error_correction_level(ticket.as_bytes(), qrcode::EcLevel::L).map_err(|e| format!("qr: {e}"))?;
300    let (w, colors) = (code.width(), code.to_colors());
301    let (scale, quiet) = (8usize, 4usize);
302    let side = (w + 2 * quiet) * scale;
303    let mut raw = Vec::with_capacity(side * (side + 1));
304    for y in 0..side {
305        raw.push(0u8); // filter: none
306        for x in 0..side {
307            let (mx, my) = ((x / scale).wrapping_sub(quiet), (y / scale).wrapping_sub(quiet));
308            let dark = mx < w && my < w && colors[my * w + mx] == qrcode::Color::Dark;
309            raw.push(if dark { 0 } else { 255 });
310        }
311    }
312    Ok(png_gray(side as u32, &raw))
313}
314
315/// A minimal 8-bit greyscale PNG: `raw` is the filtered scanlines, stored uncompressed (zlib store).
316fn png_gray(side: u32, raw: &[u8]) -> Vec<u8> {
317    fn chunk(out: &mut Vec<u8>, kind: &[u8], data: &[u8]) {
318        out.extend((data.len() as u32).to_be_bytes());
319        let mut c = kind.to_vec();
320        c.extend(data);
321        out.extend(&c);
322        out.extend(crc32(&c).to_be_bytes());
323    }
324    let mut z = vec![0x78, 0x01];
325    let blocks: Vec<&[u8]> = raw.chunks(65_535).collect();
326    for (i, b) in blocks.iter().enumerate() {
327        z.push((i + 1 == blocks.len()) as u8);
328        z.extend((b.len() as u16).to_le_bytes());
329        z.extend((!(b.len() as u16)).to_le_bytes());
330        z.extend(*b);
331    }
332    let (mut a, mut b) = (1u32, 0u32);
333    for &x in raw {
334        a = (a + x as u32) % 65_521;
335        b = (b + a) % 65_521;
336    }
337    z.extend(((b << 16) | a).to_be_bytes());
338    let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
339    let mut ihdr = side.to_be_bytes().to_vec();
340    ihdr.extend(side.to_be_bytes());
341    ihdr.extend([8, 0, 0, 0, 0]); // 8 bit, greyscale, deflate, no filter method, no interlace
342    chunk(&mut out, b"IHDR", &ihdr);
343    chunk(&mut out, b"IDAT", &z);
344    chunk(&mut out, b"IEND", &[]);
345    out
346}
347
348fn crc32(data: &[u8]) -> u32 {
349    let mut c = 0xffff_ffffu32;
350    for &x in data {
351        c ^= x as u32;
352        for _ in 0..8 {
353            c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
354        }
355    }
356    !c
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    fn admin_store(tag: &str) -> (PathBuf, Identity, Membership) {
364        let home = std::env::temp_dir().join(format!("borg-mesh-invite-{tag}-{}", std::process::id()));
365        let _ = std::fs::remove_dir_all(&home);
366        let me = Identity::load_or_create(&home).unwrap();
367        let m = Membership::found("corp", &me);
368        m.save(&home).unwrap();
369        (home, me, m)
370    }
371
372    fn key(tag: &str) -> String {
373        let home = std::env::temp_dir().join(format!("borg-mesh-invite-key-{tag}-{}", std::process::id()));
374        let _ = std::fs::remove_dir_all(&home);
375        Identity::load_or_create(&home).unwrap().public()
376    }
377
378    /// Derived: one day (clock.rs `DAY_MS`), the tests' unit.
379    const DAY: i64 = borg::clock::DAY_MS;
380
381    #[test]
382    fn a_ticket_round_trips_and_a_tampered_one_is_refused() {
383        let (home, admin, m) = admin_store("codec");
384        let inv = issue(&home, &admin, &m, "steer", 7 * DAY, 1, 1000).unwrap();
385        let t = Ticket { invite: inv, relay: Relay::Default, hints: addr::hints("192.168.1.5:7878,https://relay.example.org").unwrap() };
386        let s = t.encode();
387        assert!(s.starts_with(PREFIX) && s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()), "{s}");
388        assert!(s.len() < 400, "compact: {} chars", s.len());
389        assert_eq!(Ticket::decode(&s).unwrap(), t);
390        assert_eq!(Ticket::decode(&format!("  {}\n", s.to_lowercase())).unwrap(), t, "case and whitespace from a copy-paste are fine");
391        let custom = Ticket { relay: Relay::parse("https://relay.corp.example").unwrap(), hints: Vec::new(), ..t.clone() };
392        assert_eq!(Ticket::decode(&custom.encode()).unwrap(), custom);
393        assert!(Ticket::decode(&s[..s.len() - 10]).is_err(), "truncated");
394        assert!(Ticket::decode("hello").is_err());
395
396        // Another role under the same signature: the signature no longer verifies.
397        let forged = Ticket { invite: Invite { role: "member".into(), ..t.invite.clone() }, ..t.clone() };
398        assert!(Ticket::decode(&forged.encode()).unwrap_err().contains("bad signature"));
399        let later = Invite { expires_ms: t.invite.expires_ms + DAY, ..t.invite.clone() };
400        assert!(later.verify().is_err(), "the expiry is signed too");
401    }
402
403    #[test]
404    fn only_an_admin_issues_and_only_ticket_roles() {
405        let (home, admin, mut m) = admin_store("issue");
406        assert!(issue(&home, &admin, &m, "admin", DAY, 1, 0).is_err());
407        assert!(issue(&home, &admin, &m, "member", 0, 1, 0).is_err());
408        let other = Identity::load_or_create(&home.join("other")).unwrap();
409        m.invite(&admin, &other.public(), "member").unwrap();
410        assert!(issue(&home, &other, &m, "member", DAY, 1, 0).unwrap_err().contains("only an admin"));
411    }
412
413    #[test]
414    fn single_use_expiry_and_withdrawal() {
415        let (home, admin, m) = admin_store("redeem");
416        let (bob, carol) = (key("bob"), key("carol"));
417        let inv = issue(&home, &admin, &m, "member", DAY, 1, 1000).unwrap();
418        assert_eq!(pending(&home, 1000).len(), 1);
419
420        let joined = redeem(&home, &admin, "corp", &inv, &bob, 2000).unwrap();
421        assert_eq!(joined.role_of(&bob), Some("member"));
422        assert_eq!(Membership::load(&home).unwrap().unwrap().role_of(&bob), Some("member"), "saved, re-signed");
423        assert!(pending(&home, 2000).is_empty(), "spent");
424        assert!(redeem(&home, &admin, "corp", &inv, &carol, 3000).unwrap_err().contains("already used"));
425        assert!(redeem(&home, &admin, "corp", &inv, &bob, 3000).is_ok(), "bob again (his answer was lost): welcome, no use spent");
426
427        let short = issue(&home, &admin, &m, "observe", 1000, 1, 10_000).unwrap();
428        assert!(redeem(&home, &admin, "corp", &short, &carol, 11_000).unwrap_err().contains("expired"));
429
430        let gone = issue(&home, &admin, &m, "member", DAY, 3, 10_000).unwrap();
431        assert_eq!(withdraw(&home, &gone.id[..8]).unwrap(), gone.id);
432        assert!(redeem(&home, &admin, "corp", &gone, &carol, 11_000).unwrap_err().contains("withdrawn"));
433
434        let other_collective = Invite { collective: "else".into(), ..inv.clone() };
435        assert!(redeem(&home, &admin, "corp", &other_collective, &carol, 3000).is_err());
436        let (home2, admin2, _) = admin_store("redeem-other-admin");
437        assert!(redeem(&home2, &admin2, "corp", &inv, &carol, 3000).unwrap_err().contains("another admin"));
438    }
439
440    #[test]
441    fn a_revoked_key_cannot_rejoin_with_a_leftover_ticket() {
442        let (home, admin, m) = admin_store("revoked");
443        let bob = key("revoked-bob");
444        let inv = issue(&home, &admin, &m, "member", DAY, 2, 1000).unwrap();
445        redeem(&home, &admin, "corp", &inv, &bob, 2000).unwrap();
446        let mut m = Membership::load(&home).unwrap().unwrap();
447        m.revoke(&admin, &bob, 3000).unwrap();
448        m.save(&home).unwrap();
449        // His own earlier use does not count: he is no longer listed.
450        assert!(redeem(&home, &admin, "corp", &inv, &bob, 4000).unwrap_err().contains("revoked"));
451    }
452
453    #[test]
454    fn durations_and_qr() {
455        assert_eq!(duration_ms("7d").unwrap(), 7 * DAY);
456        assert_eq!(duration_ms("90").unwrap(), 90_000);
457        assert!(duration_ms("7w").is_err() && duration_ms("0d").is_err() && duration_ms("d").is_err());
458        let qr = qr_text("BORG1ABC").unwrap();
459        assert!(qr.lines().count() > 10 && qr.contains('█'));
460        let png = qr_png("BORG1ABC").unwrap();
461        assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
462        assert_eq!(crc32(b"IEND"), 0xae42_6082, "the CRC every PNG ends with");
463    }
464}