diff --git a/Makefile b/Makefile index b3e4d44..ca2d2f4 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ run: build check: cargo clippy --all-targets -- -D warnings + cargo test cd web && bun run check clean: diff --git a/README.md b/README.md index ffc7a30..5e5bf1e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,18 @@ mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are On a touch device modal editing is turned off and the same actions are buttons. +## Not losing things + +Every change is written to `localStorage` as it is typed and to the server a +couple of seconds after typing stops, so `:w` is a habit rather than a +necessity. If a tab dies mid-entry, opening that entry again restores the draft +and says so — `:e!` throws it away, the way vim handles a swap file. + +`:export` downloads the open entry as markdown. `:export!` downloads the whole +diary as a zip: one markdown file per entry named by its day, every embedded +file under its real name, and relative links between the two, so the archive +reads in any markdown viewer without this application. + ## Sharing `:share` mints a 192-bit random token and copies `https://your-host/s/`. @@ -81,6 +93,7 @@ Everything except the two share routes requires the session cookie. | `GET` | `/api/me` | current user | | `GET`/`POST` | `/api/entries` | list (`?q=` searches) / create | | `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete | +| `GET` | `/api/export` | every entry and file, as a zip | | `POST`/`DELETE` | `/api/entries/{id}/share` | mint / revoke a share token | | `GET`/`POST` | `/api/media` | list / upload (multipart) | | `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file | @@ -93,6 +106,13 @@ Put a TLS-terminating reverse proxy in front of it, point it at `DIARY_BIND`, and set `DIARY_SECURE_COOKIE=1`. Nothing else is needed: no database server, no runtime dependencies, no build tools on the host. +The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`, +`X-Content-Type-Options` and `X-Frame-Options`; a proxy that adds its own should +not weaken them. `no-referrer` matters in particular, because a share token +lives in the URL and would otherwise leak to any site a shared entry links to. +Uploaded files are only ever served as types that cannot execute — anything else +is handed back as an opaque download. + ### Docker ```sh diff --git a/src/auth.rs b/src/auth.rs index 32d3f96..edce2ca 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -94,3 +94,77 @@ impl FromRequestParts for Session { } } } + +#[cfg(test)] +mod tests { + use super::{credentials_match, issue_token, verify_token}; + use crate::config::Config; + + fn config(session_days: i64) -> Config { + Config { + username: "narl".into(), + password: "hunter2".into(), + secret: b"a-secret-of-at-least-16-bytes".to_vec(), + bind: "127.0.0.1:0".parse().unwrap(), + data_dir: ".".into(), + session_days, + max_upload_bytes: 1, + secure_cookie: false, + } + } + + #[test] + fn a_fresh_token_verifies() { + let config = config(30); + assert!(verify_token(&config, &issue_token(&config))); + } + + #[test] + fn an_expired_token_does_not() { + let config = config(-1); + assert!(!verify_token(&config, &issue_token(&config))); + } + + #[test] + fn rotating_the_secret_invalidates_every_session() { + let token = issue_token(&config(30)); + let mut rotated = config(30); + rotated.secret = b"a-different-secret-entirely".to_vec(); + assert!(!verify_token(&rotated, &token)); + } + + #[test] + fn a_tampered_token_does_not_verify() { + let config = config(30); + let token = issue_token(&config); + let (payload, signature) = token.split_once('.').unwrap(); + + // A far-future expiry the holder signed themselves. + let forged = base64::Engine::encode(&super::B64, "99999999999"); + assert!(!verify_token(&config, &format!("{forged}.{signature}"))); + + // The real expiry with the last byte of the signature flipped. + let mut bytes = signature.as_bytes().to_vec(); + bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' }; + let flipped = String::from_utf8(bytes).unwrap(); + assert!(!verify_token(&config, &format!("{payload}.{flipped}"))); + } + + #[test] + fn malformed_tokens_are_rejected_rather_than_panicking() { + let config = config(30); + for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] { + assert!(!verify_token(&config, token), "{token:?}"); + } + } + + #[test] + fn credentials_must_match_exactly() { + let config = config(30); + assert!(credentials_match(&config, "narl", "hunter2")); + assert!(!credentials_match(&config, "narl", "hunter")); + assert!(!credentials_match(&config, "narl", "hunter22")); + assert!(!credentials_match(&config, "nar", "hunter2")); + assert!(!credentials_match(&config, "", "")); + } +}