1use 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";
34pub const ROLES: [&str; 3] = ["member", "steer", "observe"];
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Invite {
39 pub collective: String,
40 pub admin: String,
42 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#[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 #[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
88pub 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
109pub 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
116pub 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
131pub 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); }
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#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Ticket {
171 pub invite: Invite,
172 pub relay: Relay,
174 pub hints: Vec<Hint>,
175}
176
177impl Ticket {
178 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 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
253pub 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
270pub fn placeholder(name: &str) -> String {
272 format!("~/CHANGE-ME/repos-of-{name}")
273}
274
275pub 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
290pub 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
297pub 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); 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
315fn 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]); 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 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 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 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}