diff --git a/README.md b/README.md index 847f63aa..503dafab 100644 --- a/README.md +++ b/README.md @@ -229,8 +229,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). -- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). -- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). +- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). +- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. - **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images. diff --git a/ROADMAP.md b/ROADMAP.md index cf086cce..ebde6116 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or + **Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that + carries the host's address); others type it under Join with a code (the popover, the home screen), where a link + works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`, + `collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links) - **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before @@ -271,6 +276,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the file leaves the server); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged | | 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged | | 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged | | 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index 8d3a892a..baf1882b 100644 --- a/apps/figcraft-server/src/abuse.rs +++ b/apps/figcraft-server/src/abuse.rs @@ -5,10 +5,16 @@ //! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most //! [`MAX_ADDRESSES`] addresses: when it is full the half used longest ago is forgotten, so a flood //! of addresses costs bounded memory (and forgotten addresses start with full buckets). +//! +//! Join codes are short, so guessing them is held back three ways: an address may try +//! [`CODES_PER_MINUTE`] codes a minute, [`WRONG_CODES`] wrong ones in a row lock it out of codes for +//! [`CODE_LOCKOUT`] (links still join), and everyone together may get [`WRONG_CODES_PER_HOUR`] +//! wrong an hour (an attacker with many addresses is slowed to that; while it lasts, people join +//! with their links). use std::collections::HashMap; use std::net::IpAddr; -use std::time::Instant; +use std::time::{Duration, Instant}; use figcraft_collab_net::hub::{Block, ip_blocks}; use figcraft_collab_net::net::Bucket; @@ -28,6 +34,15 @@ pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64; /// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all. pub const PUBLISHES_ALL: f64 = 32.0; +/// Join codes one address may try a minute (right or wrong). +pub const CODES_PER_MINUTE: f64 = 10.0; +/// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes. +pub const WRONG_CODES: u32 = 10; +/// How long an address is locked out of join codes. +pub const CODE_LOCKOUT: Duration = Duration::from_secs(15 * 60); +/// Wrong codes everyone together may try an hour. +pub const WRONG_CODES_PER_HOUR: f64 = 1_000.0; + /// The rates (see the server's settings). #[derive(Clone, Copy, Debug, PartialEq)] pub struct AbuseConfig { @@ -49,6 +64,8 @@ pub enum Action { Report, /// Downloading this many bytes. Download(usize), + /// Trying a join code. + Code, } struct Address { @@ -58,6 +75,12 @@ struct Address { presence: Bucket, reports: Bucket, downloads: Bucket, + codes: Bucket, + /// Wrong join codes in a row, and when the last one was. + wrong: u32, + wrong_at: Instant, + /// Locked out of join codes until then. + locked: Option, used: Instant, } @@ -67,6 +90,8 @@ pub struct Abuse { table: HashMap, /// Publishing without the key, everyone together. publishes: Bucket, + /// Wrong join codes, everyone together. + wrong_codes: Bucket, } /// `n` every `period` seconds on average, and all `n` at once at most. @@ -77,7 +102,44 @@ fn bucket(n: f64, period: f64) -> Bucket { impl Abuse { pub fn new(cfg: AbuseConfig) -> Self { let publishes = bucket(f64::from(cfg.publishes_per_hour) * PUBLISHES_ALL, 3600.0); - Self { cfg, table: HashMap::new(), publishes } + Self { cfg, table: HashMap::new(), publishes, wrong_codes: bucket(WRONG_CODES_PER_HOUR, 3600.0) } + } + + /// How long `ip` is still locked out of join codes (in any block it is in), if it is. + pub fn code_locked(&self, ip: IpAddr, now: Instant) -> Option { + let until = ip_blocks(ip).into_iter().filter_map(|(k, _)| self.table.get(&k)?.locked).max()?; + Some(until.saturating_duration_since(now)).filter(|d| !d.is_zero()) + } + + /// Everyone together has wrong codes left to try (see [`WRONG_CODES_PER_HOUR`]). + pub fn codes_open(&mut self, now: Instant) -> bool { + let open = self.wrong_codes.take(now); + if open { + self.wrong_codes.give(1.0); + } + open + } + + /// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones + /// before it; enough wrong ones in a row lock the address out. + pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) { + if !ok { + self.wrong_codes.take(now); + } + for (k, times) in ip_blocks(ip) { + let a = self.address(k, f64::from(times), now); + if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT { + a.wrong = 0; + } + if !ok { + a.wrong += 1; + a.wrong_at = now; + if a.wrong >= WRONG_CODES.saturating_mul(times) { + a.wrong = 0; + a.locked = Some(now + CODE_LOCKOUT); + } + } + } } /// Whether `ip` may do `what` now (counting it, in every block it is in, when it may). @@ -114,6 +176,10 @@ impl Abuse { presence: Bucket::new(f64::from(c.messages_per_second) * times * 2.0, f64::from(c.messages_per_second) * times * 8.0), reports: bucket(REPORTS_PER_HOUR * times, 3600.0), downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times), + codes: bucket(CODES_PER_MINUTE * times, 60.0), + wrong: 0, + wrong_at: now, + locked: None, used: now, }); a.used = now; @@ -128,6 +194,7 @@ impl Abuse { Action::Presence => (&mut a.presence, 1.0), Action::Report => (&mut a.reports, 1.0), Action::Download(n) => (&mut a.downloads, n as f64), + Action::Code => (&mut a.codes, 1.0), } } diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index 03365cbf..06f248e0 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -344,7 +344,7 @@ fn key(cfg: &Config, rotate: bool) -> Result { if rotate { out.push_str("A new server key: the old one no longer works.\n"); } - let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):"); + let _ = writeln!(out, "Publish to this address (in FigCraft: File > Publish to server…):"); let _ = writeln!(out, " {}?key={key}", public.url()); let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust."); if cfg.open_publish { @@ -375,7 +375,7 @@ fn used(f: &FileRow) -> u64 { fn files_table(mut files: Vec, sort: Sort, now: u64) -> String { if files.is_empty() { - return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into(); + return "No files yet (publish one from FigCraft: File > Publish to server…).\n".into(); } match sort { Sort::Id => {} diff --git a/apps/figcraft-server/src/codes.rs b/apps/figcraft-server/src/codes.rs new file mode 100644 index 00000000..d66853f5 --- /dev/null +++ b/apps/figcraft-server/src/codes.rs @@ -0,0 +1,86 @@ +//! Join codes on the server: a short name for one share link, typed in place of the link. +//! +//! A code is 45 random bits ([`figcraft_collab::code`]). Like a link's token it is never stored: +//! a link keeps the code's hash, to find the link by, and its own token wrapped with the code, to +//! hand to whoever types the code (they then join as with the link). Both come from the code +//! stretched with SHA-256 ([`STRETCH`] rounds), so a copy of the data directory doesn't give the +//! codes away cheaply; guessing against a running server is held back by the limits on wrong codes +//! (`abuse.rs`). + +use figcraft_collab::{code, same_secret}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Rounds of SHA-256 a code goes through before it is compared or used as a key. +pub const STRETCH: u32 = 10_000; + +// A copy of the data directory mustn't give codes away cheaply. +const _: () = assert!(STRETCH >= 10_000); + +/// Longest token a code can wrap (one SHA-256 output). +const MAX_TOKEN: usize = 32; + +/// What a link keeps of its join code (`links.json`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sealed { + /// The stretched code's hash, hex: what a typed code is looked up by. + pub hash: String, + /// The link's token, wrapped with the stretched code (hex). + pub token: String, +} + +/// A typed code, stretched: ready to look links up with (what it is found by, and the pad its +/// link's token is wrapped with). +pub struct Key { + hash: String, + pad: [u8; 32], +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +impl Key { + /// Stretch `code` (its symbols, as [`code::normalize`] gives them). + pub fn new(code: &str) -> Key { + let mut k: [u8; 32] = Sha256::new().chain_update(b"figcraft join code\0").chain_update(code.as_bytes()).finalize().into(); + for _ in 0..STRETCH { + k = Sha256::digest(k).into(); + } + let part = |what: &[u8]| -> [u8; 32] { Sha256::new().chain_update(what).chain_update(k).finalize().into() }; + Key { hash: hex(&part(b"find\0")), pad: part(b"wrap\0") } + } + + /// What a link with this code keeps as [`Sealed::hash`]. + pub fn hash(&self) -> &str { + &self.hash + } + + /// Wrap or unwrap `bytes` (the same operation: XOR with a pad only this code gives). + fn wrap(&self, bytes: &[u8]) -> Vec { + bytes.iter().zip(self.pad).map(|(b, p)| b ^ p).collect() + } + + /// Keep `token` under this code. + pub fn seal(&self, token: &str) -> Result { + if token.len() > MAX_TOKEN { + return Err("this link's token is too long for a join code".into()); + } + Ok(Sealed { hash: self.hash.clone(), token: hex(&self.wrap(token.as_bytes())) }) + } + + /// The token `sealed` keeps, when this is its code (compared in constant time). + pub fn open(&self, sealed: &Sealed) -> Option { + if !same_secret(&self.hash, &sealed.hash) || sealed.token.len() > 2 * MAX_TOKEN { + return None; + } + let wrapped: Option> = + (0..sealed.token.len() / 2).map(|i| sealed.token.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); + String::from_utf8(self.wrap(&wrapped?)).ok() + } +} + +/// A new code's symbols, from the operating system's random numbers. +pub fn new_code() -> Result { + figcraft_collab_net::token::random().map(code::online) +} diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs new file mode 100644 index 00000000..1fc7e4c3 --- /dev/null +++ b/apps/figcraft-server/src/codes_tests.rs @@ -0,0 +1,358 @@ +//! Join codes on the server: sharing online from one app and joining by code from another (real +//! links over loopback), codes kept only hashed, revoked with their link and gone with their file, +//! the limits on wrong codes, junk requests, and clients from before join codes. + +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +use figcraft_collab::Replica; +use figcraft_collab::code; +use figcraft_collab::message::{Admin, AdminReply, ClientMsg, Hello, OpenHosting, PROTOCOL, Resolve, ServerMsg}; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::{Document, NodeKind}; +use figcraft_engine::Session; +use figcraft_engine::live::Status; +use serde_json::{Value, json}; + +use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, WRONG_CODES, WRONG_CODES_PER_HOUR}; +use crate::codes::Key; +use crate::config::Config; +use crate::store::{CODE_GONE, CODES_BUSY, NO_CODE, Store}; +use crate::test_util::{Raw, TempDir, config, live, pump, session}; + +fn open(dir: &std::path::Path) -> Config { + Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) } +} + +fn snapshot(name: &str) -> Vec { + let mut d = Document::new(name); + let page = d.first_page().unwrap(); + d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); + d.repair(); + Replica::new(d).snapshot().unwrap() +} + +fn ip(s: &str) -> IpAddr { + s.parse().unwrap() +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// An app whose server for online sharing is the one at `url`. +fn app(name: &str, url: &str) -> Session { + let mut s = session(name); + ex(&mut s, "pref.publishServer", json!({"url": url})); + s +} + +fn layers(s: &Session) -> usize { + s.doc().map_or(0, |d| d.node_count()) +} + +fn failed(s: &Session) -> Option { + match &s.live().status { + Status::Failed { reason } => Some(reason.clone()), + _ => None, + } +} + +fn is_code(shown: &str) -> bool { + shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_))) +} + +#[test] +fn a_file_shared_online_is_joined_by_its_codes() { + let dir = TempDir::new("codes-share"); + let mut server = crate::start(&open(dir.path())).unwrap(); + let url = format!("ws://{}/", server.addr()); + let mut a = app("Ada", &url); + // Sharing: the file goes to the server in Preferences, and the server names its links. + let started = ex(&mut a, "collab.share", json!({})); + assert_eq!((&started["status"], &started["share"]["via"], &started["code"]), (&json!("connecting"), &json!("online"), &Value::Null)); + pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some()); + let shared = a.live_summary(); + let (edit, view) = (shared["share"]["editCode"].as_str().unwrap().to_string(), shared["share"]["viewCode"].as_str().unwrap().to_string()); + assert!(is_code(&edit) && is_code(&view) && edit != view, "{edit} {view}"); + assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise"); + let p = a.live().published.clone().unwrap(); + assert!(p.owner.is_some(), "the owner token is kept as before"); + // On disk: hashes only, of the codes as of the links' tokens. + let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap(); + for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] { + assert!(!links.contains(secret), "{links}"); + } + assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token"); + assert_eq!(links.matches("\"code\"").count(), 2); + // Someone types the edit code, the way people type. + let mut b = app("Bo", &url); + assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting"); + pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1); + assert_eq!(b.live_summary()["access"], "edit"); + assert_eq!(b.live_summary()["link"], p.edit.as_str(), "joined as with the link: reconnects need no code"); + let before = layers(&a); + ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30})); + pump(&mut [&mut a, &mut b], "Ada's edit", |s| layers(s[1]) == before + 1); + ex(&mut b, "shape.ellipse", json!({"x": 80, "y": 10, "width": 40, "height": 30})); + pump(&mut [&mut a, &mut b], "Bo's edit", |s| layers(s[0]) == before + 2); + // The view code lets someone look, not edit. + assert_eq!(ex(&mut a, "collab.share", json!({"access": "view"}))["code"], view.as_str()); + let mut c = app("Cy", &url); + ex(&mut c, "collab.join", json!({"code": view})); + pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2]) && layers(s[2]) == before + 2); + assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("view"), &json!(true))); + assert!(c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err()); + // A code nobody was given. + let mut d = app("Di", &url); + ex(&mut d, "collab.join", json!({"code": "000-000-000"})); + pump(&mut [&mut d], "the refusal", |s| failed(s[0]).is_some()); + assert_eq!(failed(&d).unwrap(), NO_CODE); + // Stop sharing: the file leaves the server, everyone is told, and the code no longer joins. + assert_eq!(ex(&mut a, "collab.stop", json!({}))["ended"], true); + let folder = dir.path().join("files").join(&p.file); + pump(&mut [&mut a, &mut b, &mut c], "the file gone", |s| !folder.exists() && failed(s[1]).is_some() && failed(s[2]).is_some()); + assert!(failed(&b).unwrap().contains("removed"), "{:?}", failed(&b)); + assert_eq!(layers(&b), before + 2, "Bo keeps the copy"); + let mut e = app("Ed", &url); + ex(&mut e, "collab.join", json!({"code": edit})); + pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some()); + assert_eq!(failed(&e).unwrap(), CODE_GONE); + server.stop(); +} + +#[test] +fn a_keyed_share_gets_codes_too_and_leaving_keeps_the_file() { + let dir = TempDir::new("codes-keyed"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let url = format!("ws://{}/", server.addr()); + let mut a = session("Ada"); + ex(&mut a, "collab.share", json!({"server": format!("{url}?key={}", crate::test_util::key(dir.path())), "name": "Team file"})); + pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some()); + let shown = a.live_summary()["code"].as_str().unwrap().to_string(); + assert!(is_code(&shown)); + assert!(!a.live_summary().to_string().contains("key="), "the key is nowhere in the summary"); + // No owner token: stopping leaves the session, the operator's file stays, and so does its code. + ex(&mut a, "collab.stop", json!({})); + let mut b = app("Bo", &url); + ex(&mut b, "collab.join", json!({"code": shown})); + pump(&mut [&mut b], "Bo in", |s| live(s[0])); + server.stop(); +} + +#[test] +fn codes_are_hashed_replaced_revoked_and_gone_with_the_file() { + let dir = TempDir::new("codes-store"); + let cfg = open(dir.path()); + let me = ip("192.0.2.7"); + let (file, edit_token, view_link, shown, view_code) = { + let mut store = Store::open(dir.path(), cfg.store()).unwrap(); + let (file, edit, view, _owner) = store.publish_open(me, "Poster", &snapshot("Poster")).unwrap(); + let shown = store.set_code(&file.id, &edit.token).unwrap(); + let view_code = store.set_code(&file.id, &view.token).unwrap(); + assert_ne!(shown, view_code, "a code per link"); + assert_eq!((shown.len(), code::normalize(&shown).as_deref()), (code::ONLINE_LEN, Ok(shown.as_str()))); + // The code gives the link's token back, and the token admits with the link's access. + assert_eq!(store.resolve_code(me, &shown).unwrap(), (file.id.clone(), edit.token.clone())); + let (f, t) = store.resolve_code(me, &code::grouped(&view_code).to_lowercase()).unwrap(); + assert_eq!(store.admit(&f, &t).unwrap().access, figcraft_collab::Access::View); + // A token that isn't one of the file's links gets no code. + assert!(store.set_code(&file.id, "AAAAAAAAAAAAAAAAAAAAAA").unwrap_err().contains("isn't one of this file's links")); + assert!(store.set_code("0000000000000000", &edit.token).is_err()); + (file.id, edit.token, view.link.id, shown, view_code) + }; + // What is kept: the stretched code's hash and the wrapped token, nothing that is either. + let links = std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap(); + assert!(!links.contains(&shown) && !links.contains(&edit_token), "{links}"); + assert!(links.contains(Key::new(&shown).hash())); + // A restart keeps the codes (they are in the data directory). + let mut store = Store::open(dir.path(), cfg.store()).unwrap(); + assert_eq!(store.resolve_code(me, &shown).unwrap().1, edit_token); + // A new code for the link takes the old one's place. + let newer = store.set_code(&file, &edit_token).unwrap(); + assert_ne!(newer, shown); + assert_eq!(store.resolve_code(me, &shown).unwrap_err(), CODE_GONE); + assert_eq!(store.resolve_code(me, &newer).unwrap().1, edit_token); + // Revoking a link revokes its code. + store.revoke(&file, &view_link).unwrap(); + assert_eq!(store.resolve_code(me, &view_code).unwrap_err(), CODE_GONE); + // The file expiring takes the rest. + store.age(&file, 31 * 86_400); + assert_eq!(store.sweep(), vec![file.clone()]); + assert_eq!(store.resolve_code(me, &newer).unwrap_err(), CODE_GONE); + // A code never handed out is unknown, not expired. + assert_eq!(store.resolve_code(ip("192.0.2.8"), "ZZZZZZZZZ").unwrap_err(), NO_CODE); +} + +#[test] +fn wrong_codes_lock_an_address_out_but_not_the_others() { + let dir = TempDir::new("codes-lockout"); + let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap(); + let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap(); + let shown = store.set_code(&file.id, &edit.token).unwrap(); + let guesser = ip("198.51.100.9"); + for n in 0..WRONG_CODES { + assert_eq!(store.resolve_code(guesser, &code::online(u64::from(n) + 1)).unwrap_err(), NO_CODE, "guess {n}"); + } + // Locked out: not even the right code is looked up for that address. + for typed in [code::online(77), shown.clone()] { + let e = store.resolve_code(guesser, &typed).unwrap_err(); + assert!(e.contains("Too many wrong codes") && e.contains("15 minutes") && e.contains("invite link"), "{e}"); + } + // Its link still joins, and other addresses' codes still work. + assert!(store.admit(&file.id, &edit.token).is_ok()); + assert_eq!(store.resolve_code(ip("198.51.100.10"), &shown).unwrap().0, file.id); + // An IPv6 guesser is counted by its /64. + for n in 0..WRONG_CODES { + let from: IpAddr = format!("2001:db8:1:2::{n:x}").parse().unwrap(); + let _ = store.resolve_code(figcraft_collab_net::hub::ip_key(from), &code::online(1000 + u64::from(n))); + } + let neighbour = figcraft_collab_net::hub::ip_key(ip("2001:db8:1:2:ffff::1")); + assert!(store.resolve_code(neighbour, &shown).unwrap_err().contains("Too many wrong codes")); +} + +#[test] +fn the_limits_on_codes_over_time() { + let t0 = Instant::now(); + let me = ip("198.51.100.9"); + let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 }); + // A right code forgives the wrong ones before it. + for _ in 0..WRONG_CODES - 1 { + abuse.code_tried(me, false, t0); + } + abuse.code_tried(me, true, t0); + for _ in 0..WRONG_CODES - 1 { + abuse.code_tried(me, false, t0); + } + assert_eq!(abuse.code_locked(me, t0), None); + // One more: locked for the lockout, then free again. + abuse.code_tried(me, false, t0); + assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT)); + assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some()); + assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None); + // Wrong codes long apart don't add up. + let other = ip("198.51.100.10"); + for n in 0..3 * WRONG_CODES { + abuse.code_tried(other, false, t0 + (CODE_LOCKOUT + Duration::from_secs(1)) * n); + } + assert_eq!(abuse.code_locked(other, t0 + CODE_LOCKOUT * 4 * WRONG_CODES), None); + // Codes tried a minute, right or wrong (these limits hold on private servers too). + let typist = ip("198.51.100.11"); + let tried = (0..100).filter(|_| abuse.allow(typist, Action::Code, t0)).count(); + assert_eq!(tried as f64, CODES_PER_MINUTE); + assert!(abuse.allow(typist, Action::Code, t0 + Duration::from_secs(7)), "one more every six seconds"); + // Everyone together: many addresses guessing pause codes for all (links still join). + let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 }); + assert!(abuse.codes_open(t0)); + for n in 0..WRONG_CODES_PER_HOUR as u32 { + abuse.code_tried(IpAddr::from([10, 1, (n >> 8) as u8, n as u8]), false, t0); + } + assert!(!abuse.codes_open(t0)); + assert!(abuse.codes_open(t0 + Duration::from_secs(60)), "it refills"); +} + +#[test] +fn everyone_guessing_pauses_codes() { + let dir = TempDir::new("codes-busy"); + let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap(); + let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap(); + let shown = store.set_code(&file.id, &edit.token).unwrap(); + // Junk costs no hashing, so a thousand wrong "codes" from a thousand addresses are quick. + for n in 0..WRONG_CODES_PER_HOUR as u32 { + assert_eq!(store.resolve_code(IpAddr::from([10, 2, (n >> 8) as u8, n as u8]), "not a code").unwrap_err(), NO_CODE); + } + assert_eq!(store.resolve_code(ip("192.0.2.99"), &shown).unwrap_err(), CODES_BUSY); + assert!(store.admit(&file.id, &edit.token).is_ok(), "links are not affected"); +} + +fn refused(raw: &mut Raw) -> String { + match raw.next() { + ServerMsg::Refused { reason } => reason, + other => panic!("expected a refusal, got {other:?}"), + } +} + +#[test] +fn junk_requests_are_refused_and_never_hurt_the_server() { + let dir = TempDir::new("codes-junk"); + let mut server = crate::start(&open(dir.path())).unwrap(); + let addr = server.addr(); + let url = format!("ws://{addr}/"); + let mut a = app("Ada", &url); + ex(&mut a, "collab.share", json!({})); + pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some()); + let p = a.live().published.clone().unwrap(); + let ask = |code: &str, protocol: u32| { + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::Resolve(Resolve { protocol, client_version: String::new(), code: code.to_string() })); + let why = refused(&mut raw); + assert!(raw.closed_within(Duration::from_secs(5)), "the connection closes after the answer"); + why + }; + // Junk in place of a code (each from this one address: after ten it is locked out). + for junk in ["", " ", "💥💥💥", "K7M-2QX", "../../etc/passwd", "K7M2QX9TDK7M2QX9TD"] { + assert_eq!(ask(junk, PROTOCOL), NO_CODE, "{junk:?}"); + } + assert_eq!(ask(&"Z".repeat(4000), PROTOCOL), NO_CODE); + assert!(ask("K7M2QX9TD", 1).contains("isn't supported")); + // Asking for a code needs the file's owner token (or the key), and one of its link tokens. + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() })); + assert!(matches!(raw.next(), ServerMsg::Hosting(_))); + raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: "AAAAAAAAAAAAAAAAAAAAAA".into() })); + assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own")))); + let mut raw = Raw::connect(addr); + let owner = p.owner.clone().unwrap(); + raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner })); + assert!(matches!(raw.next(), ServerMsg::Hosting(_))); + for token in ["", "AAAAAAAAAAAAAAAAAAAAAA", &"x".repeat(5000)] { + raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: token.to_string() })); + assert!( + matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("isn't one of this file's links"))) + ); + } + // A code in place of a link's token joins nothing: codes only resolve. + let mut raw = Raw::connect(addr); + let shown = a.live_summary()["code"].as_str().unwrap().replace('-', ""); + let hello = Hello { + protocol: PROTOCOL, + file: p.file.clone(), + token: shown, + name: "Mal".into(), + color: String::new(), + client_version: String::new(), + resume: None, + }; + raw.send(&ClientMsg::Hello(hello)); + assert!(refused(&mut raw).contains("isn't valid")); + // The session went on through all of it. + assert_eq!(a.live_summary()["status"], "live"); + server.stop(); +} + +#[test] +fn clients_from_before_join_codes_are_served_as_before() { + let dir = TempDir::new("codes-old-client"); + let mut server = crate::start(&open(dir.path())).unwrap(); + let url = format!("ws://{}/", server.addr()); + // An app that never asks for codes (`collab.publish`, what older apps do): links only, and + // nothing about codes is kept for the file. + let mut a = session("Ada"); + ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"})); + pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some()); + let p = a.live().published.clone().unwrap(); + assert_eq!((&a.live_summary()["code"], &a.live_summary()["share"]), (&Value::Null, &Value::Null)); + let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap(); + assert!(!links.contains("\"code\""), "{links}"); + // Its links join as ever. + let mut b = crate::test_util::join(&p.view, "Bo"); + pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1])); + server.stop(); + // A data directory from before join codes (links without the field) opens unchanged. + let old = r#"[{"id":"0123456789abcdef","access":"edit","label":"Published (edit)","created":1,"hash":"00"}]"#; + let path = dir.path().join("files").join(&p.file).join("links.json"); + std::fs::write(&path, old).unwrap(); + let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap(); + assert_eq!(store.links(&p.file).unwrap().len(), 1); + assert_eq!(store.resolve_code(ip("192.0.2.1"), "K7M2QX9TD").unwrap_err(), NO_CODE); +} diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index a07f0475..0ffcda05 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -10,6 +10,7 @@ pub mod abuse; pub mod cli; +pub mod codes; pub mod config; pub mod directory; pub mod disk; @@ -90,6 +91,8 @@ mod background_tests; #[cfg(test)] mod cli_tests; #[cfg(test)] +mod codes_tests; +#[cfg(test)] mod directory_tests; #[cfg(test)] mod hardening_tests; diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index b7f9a7c7..eaf6e301 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -6,7 +6,8 @@ //! /server.json where the running server listens (for the command line) //! /server.key the server key: managing files (publish, links, removal) //! /files//meta.json name, created, updated, sequence number -//! /files//links.json share links: id, access, label, created, SHA-256 of the token +//! /files//links.json share links: id, access, label, created, SHA-256 of the token, +//! and a join code's hash and wrapped token (see `codes`) //! /files//snapshot.bin the session state at the last compaction //! /files//log.bin what changed since (see `disk`) //! /files//library.bin its team library, when one was published (a `.figcraft` archive) @@ -28,7 +29,7 @@ //! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported, //! never deleted. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fs::{self, File, OpenOptions, TryLockError}; use std::io::{Cursor, Write}; use std::net::IpAddr; @@ -46,6 +47,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::abuse::{Abuse, AbuseConfig, Action}; +use crate::codes::{self, Sealed}; use crate::directory::{Directory, DirectoryConfig}; use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE}; @@ -58,6 +60,12 @@ pub const REPORTS: &str = "reports.json"; /// Most addresses blocked, and most reports kept (the oldest go first). const MAX_BLOCKED: usize = 100_000; const MAX_REPORTS: usize = 1_000; +/// Most join codes remembered as no longer valid. +const MAX_GONE_CODES: usize = 10_000; +/// What typing a join code that opens nothing is answered with, by why. +pub const NO_CODE: &str = "No session with that code"; +pub const CODE_GONE: &str = "That code has expired or its sharing was stopped"; +pub const CODES_BUSY: &str = "Join codes are paused on this server for a while (too many wrong ones were tried): join with the invite link"; /// Longest report reason kept (characters). const MAX_REASON: usize = 500; /// How often idle files are looked for (seconds), besides at startup. @@ -188,6 +196,9 @@ struct Link { created: u64, /// SHA-256 of the token, hex. hash: String, + /// Its join code, when one was handed out (never the code itself). + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, } impl Link { @@ -245,6 +256,11 @@ pub struct Store { usage: BTreeMap, blocked: BTreeSet, reports: Vec, + /// Hashes of join codes that no longer open anything (their link was revoked, their file + /// removed or expired, or a newer code took their place): typing one says so. + // shortcut: in memory only, so after a restart such a code reads as unknown; keep them in the + // data directory if people ask why their code "never existed". + gone_codes: VecDeque, /// When idle files are looked for next (seconds since 1970). next_sweep: u64, /// Compactions so far (tests count them). @@ -341,6 +357,7 @@ impl Store { usage: BTreeMap::new(), blocked: BTreeSet::new(), reports: Vec::new(), + gone_codes: VecDeque::new(), next_sweep: 0, compactions: 0, free: None, @@ -755,6 +772,101 @@ impl Store { Ok(NewLink { link: link.info(file), token }) } + /// The link of `entry` whose token `token` is (every link is compared, in constant time, so + /// timing doesn't tell which one matched). + fn link_of(entry: &Entry, token: &str) -> Option { + let presented = hash(token); + let mut found = None; + for (i, l) in entry.links.iter().enumerate() { + if same_secret(&presented, &l.hash) { + found = Some(i); + } + } + found + } + + /// These join codes no longer open anything: typing one says so. + fn forget_codes(&mut self, codes: impl IntoIterator) { + self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); + let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); + self.gone_codes.drain(..extra); + } + + /// Where the link with the join code `key` is: its file, its place among the file's links + /// and its token (every code is compared, so timing doesn't tell which one matched). + fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { + let mut found = None; + for (id, e) in &self.files { + for (i, l) in e.links.iter().enumerate() { + if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { + found = Some((id.clone(), i, token)); + } + } + } + found + } + + /// A new join code for the link of `file` whose token is `token` (it takes the place of the + /// link's earlier code): the code, shown this once. + pub fn set_code(&mut self, file: &str, token: &str) -> Result { + let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?; + // 45 random bits: one already in use is all but impossible, and never handed out twice. + let (code, key) = loop { + let code = codes::new_code()?; + let key = codes::Key::new(&code); + if self.find_code(&key).is_none() { + break (code, key); + } + }; + let sealed = key.seal(token)?; + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + let link = entry.links.get_mut(at).ok_or("that file has no such link")?; + let before = link.code.replace(sealed); + let id = link.id.clone(); + if let Err(e) = write_json(&path, &entry.links) { + if let Some(l) = entry.links.get_mut(at) { + l.code = before; + } + return Err(format!("couldn't keep the join code: {e}")); + } + self.forget_codes(before); + log::info!("event=code file={file} link={id}"); + Ok(code) + } + + /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against + /// the address (see `abuse`), and nothing is looked up for an address that is locked out. + pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> { + let now = Instant::now(); + if let Some(left) = self.abuse.code_locked(ip, now) { + return Err(format!( + "Too many wrong codes from your address: try again in {} minutes, or join with the invite link", + left.as_secs() / 60 + 1 + )); + } + if !self.abuse.allow(ip, Action::Code, now) { + return Err("Too many codes tried from your address: wait a minute and try again".into()); + } + if !self.abuse.codes_open(now) { + return Err(CODES_BUSY.into()); + } + // Anything that isn't a code's symbols is a wrong code too (and costs no hashing). + let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c)); + let found = key.as_ref().and_then(|k| self.find_code(k)); + self.abuse.code_tried(ip, found.is_some(), now); + match found { + Some((file, _, token)) => { + log::info!("event=code_used file={file} ip={ip}"); + Ok((file, token)) + } + None => { + let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash())); + Err(if gone { CODE_GONE } else { NO_CODE }.into()) + } + } + } + /// Revoke a link. pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { let path = self.dir(file).join(LINKS); @@ -766,6 +878,7 @@ impl Store { return Err(format!("couldn't revoke the link: {e}")); } log::info!("revoked link {link} of file {file}"); + self.forget_codes(gone.code); Ok(()) } @@ -774,6 +887,7 @@ impl Store { pub fn remove(&mut self, file: &str) -> Result<(), String> { // Closed first: an open log would keep the folder from being deleted on Windows. let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; + self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); drop(entry); let dir = self.dir(file); fs::remove_dir_all(&dir) @@ -1112,21 +1226,15 @@ fn write_json(path: &Path, value: &T) -> Result<(), Strin /// A new link and its token. fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> { let token = token::secret()?; - Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token)) + Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token)) } impl Backend for Store { fn admit(&mut self, file: &str, token: &str) -> Result { let entry = self.entry(file)?; - let presented = hash(token); - // Every link is compared (in constant time), so timing doesn't tell which one matched. - let mut found = None; - for l in &entry.links { - if same_secret(&presented, &l.hash) { - found = Some(Admission { access: l.access, link: Some(l.id.clone()) }); - } - } - found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) + let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); + link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) + .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) } fn open(&mut self, file: &str) -> Result { @@ -1261,6 +1369,7 @@ impl Backend for Store { | Admin::CreateLink { file, .. } | Admin::Revoke { file, .. } | Admin::Rename { file, .. } + | Admin::Code { file, .. } | Admin::Remove { file } => file, _ => return failed("that needs the server key"), }; @@ -1297,6 +1406,7 @@ impl Backend for Store { }, Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), + Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); @@ -1342,6 +1452,10 @@ impl Backend for Store { self.directory.handle(publisher, req) } + fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> { + self.resolve_code(ip, code) + } + fn closing(&self) -> Closing { Closing::Restarting } diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs new file mode 100644 index 00000000..1dea3daa --- /dev/null +++ b/crates/collab-net/src/code_tests.rs @@ -0,0 +1,281 @@ +//! Join codes over the network: a file shared on the local network joined by its code (two +//! engine sessions over loopback), what the browser's driver does with an online code, a server +//! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out +//! and resolving them is tested in its own crate, with the same link.) + +use std::net::{Ipv4Addr, TcpListener}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use figcraft_collab::code::{self, Code, Local}; +use figcraft_collab::message::{self, ClientMsg, PROTOCOL, ServerMsg}; +use figcraft_engine::Session; +use figcraft_engine::live::{Event, Status}; +use serde_json::json; +use tungstenite::{Bytes, Message}; + +use crate::driver::{Driver, Socket}; +use crate::guest::GuestConfig; +use crate::inbox::Inbox; +use crate::invite::{Endpoint, Invite}; +use crate::loopback_tests::{fast_limits, quick}; +use crate::{NetLink, manage}; + +fn session(name: &str) -> Session { + let mut s = Session::new(); + s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick))); + s.execute("pref.userName", &json!({"name": name})).unwrap(); + s +} + +/// Pump the sessions (as their windows would every frame) until `f` holds. +fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) { + let start = Instant::now(); + loop { + for s in all.iter_mut() { + s.live_poll(); + s.live_sync(); + } + if f(all) { + return; + } + let states: Vec = all.iter().map(|s| s.live_summary().to_string()).collect(); + assert!(start.elapsed() < Duration::from_secs(15), "timed out waiting for {what}: {states:#?}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn live(s: &Session) -> bool { + s.live().status == Status::Live && s.live().syncing() +} + +fn layers(s: &Session) -> usize { + s.doc().map_or(0, |d| d.node_count()) +} + +#[test] +fn a_local_share_is_joined_by_its_code_and_edits_sync() { + let (mut host, mut guest) = (session("Hana"), session("Ada")); + // This machine only, so the test needs no network; the code says where and the secret. + let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap(); + assert_eq!((&shared["status"], &shared["share"]["via"], &shared["share"]["access"]), (&json!("live"), &json!("local"), &json!("edit"))); + let shown = shared["code"].as_str().unwrap().to_string(); + assert_eq!(shown.len(), 23, "twenty symbols in fives: {shown}"); + let Code::Local(local) = code::parse(&shown).unwrap() else { panic!("not a local code") }; + assert_eq!(local.ip, Ipv4Addr::LOCALHOST); + assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port)); + assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session"); + assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network"); + // Typed the way people type: lower case, spaces for the dashes. + let typed = shown.to_lowercase().replace('-', " "); + assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting"); + pump(&mut [&mut host, &mut guest], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1); + assert_eq!(guest.live_summary()["access"], "edit"); + // Edits go both ways. + let before = layers(&host); + host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + pump(&mut [&mut host, &mut guest], "the host's edit", |s| layers(s[1]) == before + 1); + guest.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap(); + pump(&mut [&mut host, &mut guest], "the guest's edit", |s| layers(s[0]) == before + 2); + // The link joins the same session (an older app, or a paste). + let mut third = session("Bo"); + third.execute("collab.join", &json!({"link": shared["link"]})).unwrap(); + pump(&mut [&mut host, &mut guest, &mut third], "the link's join", |s| live(s[2]) && layers(s[2]) == before + 2); + // Stopping ends it: the code no longer joins. + assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); + pump(&mut [&mut host, &mut guest, &mut third], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active()); + let mut late = session("Cy"); + late.execute("collab.join", &json!({"code": shown})).unwrap(); + pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. })); + assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status); +} + +#[test] +fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { + let (mut host, mut guest) = (session("Hana"), session("Ada")); + let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap(); + let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; + // A code for the same address with another secret: well formed, refused by the host. + let guess = Local::new(local.ip, local.port, local.secret ^ 1).code(); + guest.execute("collab.join", &json!({"code": guess})).unwrap(); + pump(&mut [&mut host, &mut guest], "the refusal", |s| matches!(s[1].live().status, Status::Failed { .. })); + assert!(guest.live().status.label().contains("wrong session token"), "{:?}", guest.live().status); + assert!(host.live().peers.is_empty()); + // A typing mistake is caught before anything connects. + let mut typo: Vec = local.code().chars().collect(); + typo[7] = if typo[7] == 'A' { 'B' } else { 'A' }; + let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); + assert!(e.contains("typing mistake"), "{e}"); + // A view-only code doesn't exist here, and says where it does. + let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string(); + assert!(e.contains("share it online"), "{e}"); +} + +#[test] +fn links_can_name_a_join_code() { + let (invite, found) = Invite::parse_join("figcraft://join?server=wss://share.example/&code=k7m-2qx-9td").unwrap(); + assert_eq!((invite.server.url(), invite.file.as_str(), invite.token.as_str()), ("wss://share.example:443/".to_string(), "", "")); + assert_eq!(found.as_deref(), Some("K7M2QX9TD")); + let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap(); + assert_eq!((invite.server, found.as_deref()), (Endpoint::plain("127.0.0.1:47900"), Some("K7M2QX9TD"))); + // A link with a file and a token is read as before. + let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; + assert_eq!(Invite::parse_join(link).unwrap(), (Invite::parse(link).unwrap(), None)); + for bad in [ + "figcraft://join?server=wss://share.example/&code=", + "figcraft://join?server=wss://share.example/&code=K7M", + "figcraft://join?code=K7M2QX9TD", + ] { + assert!(Invite::parse_join(bad).is_err(), "{bad}"); + } + // Where a file's link is needed (team libraries), a code isn't one. + let e = Invite::parse("figcraft://join?server=wss://share.example/&code=K7M2QX9TD").unwrap_err(); + assert!(e.contains("join code") && !e.contains("K7M"), "{e}"); +} + +/// A server from before join codes: whatever it is asked first, it can't read it (what its +/// decoder says about a message it doesn't know). +fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let at = Endpoint::plain(listener.local_addr().unwrap().to_string()); + let t = std::thread::spawn(move || { + for stream in listener.incoming().take(3).flatten() { + let Ok(mut ws) = tungstenite::accept(stream) else { continue }; + let _ = ws.read(); + let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() }; + let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap()))); + let _ = ws.close(None); + while ws.read().is_ok() {} + } + }); + (at, t) +} + +#[test] +fn a_server_older_than_join_codes_says_so_and_the_links_still_work() { + let (server, thread) = old_server(); + let limits = fast_limits(); + // Asking it about a code: the join fails with what to do instead. + let e = manage::resolve(&server, "K7M2QX9TD", &limits, Duration::from_secs(5)).unwrap_err(); + assert!(e.starts_with(manage::REFUSED) && e.contains("couldn't be read"), "{e}"); + let mut guest = session("Ada"); + guest.execute("pref.publishServer", &json!({"url": server.url()})).unwrap(); + guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap(); + pump(&mut [&mut guest], "the answer", |s| matches!(s[0].live().status, Status::Failed { .. })); + let why = guest.live().status.label(); + assert!(why.contains("doesn't know join codes yet") && why.contains("invite link"), "{why}"); + // Asking it for codes: none, without an error (the app shows the links). + assert_eq!( + manage::codes(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &["AbCdEfGhIjKlMnOpQrStUv"], &limits, Duration::from_secs(5)), + None + ); + thread.join().unwrap(); +} + +#[test] +fn a_server_that_cant_be_reached_says_so() { + // A port nothing listens on. + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let mut guest = session("Ada"); + guest.execute("pref.publishServer", &json!({"url": format!("ws://127.0.0.1:{port}/")})).unwrap(); + guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap(); + pump(&mut [&mut guest], "the failure", |s| matches!(s[0].live().status, Status::Failed { .. })); + assert!(guest.live().status.label().starts_with("Couldn't reach the server"), "{:?}", guest.live().status); +} + +#[test] +fn an_app_hosted_session_knows_no_online_codes() { + let mut host = session("Hana"); + let shared = host.execute("collab.host", &json!({"port": 0})).unwrap(); + let server = Endpoint::plain(shared["address"].as_str().unwrap()); + let e = manage::resolve(&server, "K7M2QX9TD", &fast_limits(), Duration::from_secs(5)).unwrap_err(); + assert!(e.contains("hosted by an app"), "{e}"); + // Junk in place of a code is refused the same way, and the session goes on. + for junk in ["", "💥", &"Z".repeat(4000)] { + assert!(manage::resolve(&server, junk, &fast_limits(), Duration::from_secs(5)).is_err()); + } + assert_eq!(host.live_summary()["status"], "live"); +} + +/// A socket that records what the driver does (the browser's, scripted). +#[derive(Default)] +struct Script { + opened: Vec, + sent: Vec, + closes: usize, +} + +impl Socket for Script { + fn open(&mut self, url: &str) -> Result<(), String> { + self.opened.push(url.to_string()); + Ok(()) + } + fn send(&mut self, bytes: &[u8]) -> bool { + self.sent.push(message::decode(bytes, usize::MAX).unwrap()); + true + } + fn close(&mut self) { + self.closes += 1; + } +} + +/// The browser's driver joining with a code at a server. +fn driver(t0: web_time::Instant) -> (Driver