181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
217 lines
10 KiB
Rust
217 lines
10 KiB
Rust
//! Hardening: pairing lockout, device lifecycle, audit log, request ids and readiness.
|
|
|
|
mod common;
|
|
|
|
use common::{ADMIN, Server, temp_dir};
|
|
use notescraft_sync_server::now_ms;
|
|
use serde_json::{Value, json};
|
|
|
|
const LOCKOUT_KIND: &str = "pairing_locked_out";
|
|
const FAILED_KIND: &str = "pairing_failed";
|
|
|
|
fn kinds_of(rows: &[Value]) -> Vec<String> {
|
|
rows.iter().map(|row| row["kind"].as_str().expect("kind").to_owned()).collect()
|
|
}
|
|
|
|
fn body_text(body: &[u8]) -> String {
|
|
String::from_utf8_lossy(body).into_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn lockout_after_five_bad_codes_answers_429_even_with_a_good_code() {
|
|
let server = Server::start_temp("lockout");
|
|
let (vault, _) = server.paired("keeper");
|
|
for _ in 0..5 {
|
|
let (status, _) = server.json("POST", "/v1/devices", None, &[], Some(&json!({"code": "bad", "name": "ghost"})));
|
|
assert_eq!(status, 401);
|
|
}
|
|
|
|
// A correct code is refused, too, with Retry-After.
|
|
let (_, code) = server.json("POST", &format!("/v1/vaults/{vault}/pairing"), Some(ADMIN), &[], None);
|
|
server.leak(code["code"].as_str().expect("code"));
|
|
let request = serde_json::to_vec(&json!({"code": code["code"], "name": "late"})).expect("body");
|
|
let (status, heads, body) = server.send_full("POST", "/v1/devices", None, &[("Content-Type", "application/json")], Some(&request));
|
|
assert_eq!(status, 429, "{}", body_text(&body));
|
|
let retry_after = heads.iter().find(|(name, _)| name == "retry-after").map(|(_, value)| value.as_str()).expect("retry-after header");
|
|
assert!(retry_after.parse::<u64>().is_ok_and(|seconds| (1..=600).contains(&seconds)), "retry-after: {retry_after}");
|
|
|
|
// The lockout is audited; failed redemptions are, and none leaks the code.
|
|
let kinds = kinds_of(&server.audit());
|
|
assert_eq!(kinds.iter().filter(|k| *k == FAILED_KIND).count(), 5);
|
|
assert_eq!(kinds.iter().filter(|k| k.as_str() == LOCKOUT_KIND).count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn pairing_works_again_once_the_lockout_window_has_passed() {
|
|
let server = Server::start_temp("lockout-window");
|
|
let state = server.state.clone();
|
|
// Vault and code come first, then failure stamps a second before the code existed.
|
|
let vault = Server::fresh_vault_id("lockout-window");
|
|
let (status, code) = server.json("POST", &format!("/v1/vaults/{vault}/pairing"), Some(ADMIN), &[], None);
|
|
assert_eq!(status, 200);
|
|
let good = code["code"].as_str().expect("code").to_owned();
|
|
let base = now_ms().saturating_sub(1000);
|
|
for _ in 0..notescraft_sync_server::PAIRING_MAX_FAILURES {
|
|
let error = match state.pair_device("garbage", "ghost", None, base) {
|
|
Err(error) => error,
|
|
Ok(paired) => panic!("redeemed without a code: {paired:?}"),
|
|
};
|
|
assert_eq!(error.status.as_u16(), 401);
|
|
}
|
|
|
|
let error = match state.pair_device(&good, "late", None, base) {
|
|
Err(error) => error,
|
|
Ok(paired) => panic!("redeemed while locked: {paired:?}"),
|
|
};
|
|
assert_eq!(error.status.as_u16(), 429);
|
|
assert!(error.retry_after.is_some());
|
|
|
|
// Nothing was consumed; with `now` past the window the same code redeems.
|
|
let paired = state.pair_device(&good, "late", None, base + notescraft_sync_server::PAIRING_WINDOW_MS + 1).expect("redeemed");
|
|
assert_eq!(paired.vault_id, vault);
|
|
}
|
|
|
|
#[test]
|
|
fn rotation_gives_a_new_token_and_the_old_one_stops_working() {
|
|
let server = Server::start_temp("rotate");
|
|
let (vault, token) = server.paired("phone");
|
|
let changes = format!("/v1/vaults/{vault}/changes?since=0");
|
|
assert_eq!(server.send("GET", &changes, Some(&token), &[], None).0, 200);
|
|
|
|
let (status, rotated) = server.json("POST", &format!("/v1/vaults/{vault}/devices/me/rotate"), Some(&token), &[], None);
|
|
assert_eq!(status, 200);
|
|
let fresh = rotated["token"].as_str().expect("token").to_owned();
|
|
server.leak(&fresh);
|
|
assert_ne!(fresh, token);
|
|
assert_ne!(rotated["device_id"].as_str().expect("device id"), "");
|
|
|
|
assert_eq!(server.send("GET", &changes, Some(&token), &[], None).0, 401);
|
|
assert_eq!(server.send("GET", &changes, Some(&fresh), &[], None).0, 200);
|
|
// The rotation is audited with the device id, never the token.
|
|
let kinds = kinds_of(&server.audit());
|
|
assert!(kinds.iter().any(|k| k == "token_rotated"));
|
|
server.assert_no_secrets(&serde_json::to_string(&server.audit()).expect("rows"));
|
|
}
|
|
|
|
#[test]
|
|
fn revocation_kills_the_device_token_and_the_list_hides_secrets() {
|
|
let server = Server::start_temp("revoke");
|
|
let (vault, phone) = server.paired("phone");
|
|
let laptop = server.pair_to(&vault, "laptop");
|
|
server.leak(&laptop);
|
|
let list_url = format!("/v1/vaults/{vault}/devices");
|
|
|
|
let (_, devices) = server.json("GET", &list_url, Some(&laptop), &[], None);
|
|
let rows = devices.as_array().expect("device rows");
|
|
assert_eq!(rows.len(), 2);
|
|
let laptop_row = rows.iter().find(|row| row["name"] == "laptop").expect("laptop row");
|
|
let phone_row = rows.iter().find(|row| row["name"] == "phone").expect("phone row");
|
|
assert_eq!(laptop_row["current"], true);
|
|
assert_eq!(phone_row["current"], false);
|
|
server.assert_no_secrets(&serde_json::to_string(rows).expect("rows text"));
|
|
for wanted in ["id", "name", "created_ms", "last_seen_ms", "current"] {
|
|
assert!(laptop_row.get(wanted).is_some(), "missing field {wanted}");
|
|
}
|
|
|
|
let laptop_id = laptop_row["id"].as_str().expect("id").to_owned();
|
|
let (status, _) = server.send("DELETE", &format!("{list_url}/{laptop_id}"), Some(&phone), &[], None);
|
|
assert_eq!(status, 204);
|
|
assert_eq!(server.send("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(&laptop), &[], None).0, 401);
|
|
assert_eq!(server.send("DELETE", &format!("{list_url}/{laptop_id}"), Some(&phone), &[], None).0, 404);
|
|
|
|
// A device may revoke itself.
|
|
let phone_id = phone_row["id"].as_str().expect("id").to_owned();
|
|
assert_eq!(server.json("GET", &list_url, Some(&phone), &[], None).1.as_array().expect("rows").len(), 1);
|
|
let (status, _) = server.send("DELETE", &format!("{list_url}/{phone_id}"), Some(&phone), &[], None);
|
|
assert_eq!(status, 204);
|
|
assert_eq!(server.send("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(&phone), &[], None).0, 401);
|
|
}
|
|
|
|
#[test]
|
|
fn audit_records_events_in_order_and_without_secrets() {
|
|
let server = Server::start_temp("audit");
|
|
let (vault, token) = server.paired("phone");
|
|
server.leak(&token);
|
|
let (status, rotated) = server.json("POST", &format!("/v1/vaults/{vault}/devices/me/rotate"), Some(&token), &[], None);
|
|
assert_eq!(status, 200);
|
|
server.leak(rotated["token"].as_str().expect("token"));
|
|
|
|
// One failed redemption, so `pairing_failed` is in the log too.
|
|
let (status, _) = server.json("POST", "/v1/devices", None, &[], Some(&json!({"code": "nope", "name": "ghost"})));
|
|
assert_eq!(status, 401);
|
|
assert_eq!(server.send("GET", "/v1/audit?limit=100", Some("not-admin"), &[], None).0, 401);
|
|
|
|
let rows = server.audit();
|
|
let kinds = kinds_of(&rows);
|
|
for wanted in ["vault_created", "pairing_created", "device_paired", FAILED_KIND, "token_rotated"] {
|
|
assert!(kinds.iter().any(|k| k == wanted), "no {wanted} row; kinds: {kinds:?}");
|
|
}
|
|
// Newest first.
|
|
let ids: Vec<i64> = rows.iter().map(|row| row["id"].as_i64().expect("id")).collect();
|
|
let sorted: Vec<i64> = ids.clone();
|
|
assert_eq!(ids, sorted, "ids in insertion order");
|
|
let mut descending = ids.clone();
|
|
descending.sort_unstable();
|
|
descending.reverse();
|
|
assert_eq!(ids, descending);
|
|
// The failed row names the reason, never the code.
|
|
let failed = rows.iter().find(|row| row["kind"] == FAILED_KIND).expect("failed row");
|
|
assert_eq!(failed["detail"], "unknown_or_expired");
|
|
server.assert_no_secrets(&serde_json::to_string(&rows).expect("rows text"));
|
|
}
|
|
#[test]
|
|
fn every_answer_carries_an_echoed_or_generated_request_id() {
|
|
let server = Server::start_temp("request-id");
|
|
let id_of =
|
|
|heads: &[(String, String)]| heads.iter().find(|(name, _)| name == "x-request-id").map(|(_, value)| value.clone()).expect("x-request-id header");
|
|
let hex16 = |id: String| id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit());
|
|
|
|
let (status, heads, _) = server.send_full("GET", "/healthz", None, &[], None);
|
|
assert_eq!(status, 200);
|
|
assert!(hex16(id_of(&heads)), "generated id is 16 hex chars: {}", id_of(&heads));
|
|
|
|
// A valid incoming id (at most 64 chars of [A-Za-z0-9-]) is echoed back.
|
|
let valid = "Echo-Id-01234567890123456789012345678901234567890123456789";
|
|
assert_eq!(valid.len(), 58);
|
|
let (status, heads, _) = server.send_full("GET", "/healthz", None, &[("X-Request-Id", valid)], None);
|
|
assert_eq!((status, id_of(&heads)), (200, valid.to_owned()));
|
|
// Boundary: exactly 64 characters is still valid.
|
|
let boundary = "a".repeat(64);
|
|
let (status, heads, _) = server.send_full("GET", "/healthz", None, &[("X-Request-Id", boundary.as_str())], None);
|
|
assert_eq!((status, id_of(&heads)), (200, boundary));
|
|
|
|
// Invalid ids are replaced by a generated one.
|
|
for bad in ["", "no/slash", "no space", "a".repeat(65).as_str()] {
|
|
let (status, heads, _) = server.send_full("GET", "/healthz", None, &[("X-Request-Id", bad)], None);
|
|
assert_eq!(status, 200);
|
|
let id = id_of(&heads);
|
|
assert_ne!(id, bad);
|
|
assert!(hex16(id.clone()), "generated replacement: {id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn readyz_checks_database_and_blob_folder() {
|
|
let data = temp_dir("readyz");
|
|
let server = common::start(&data);
|
|
let (status, reply) = server.json("GET", "/readyz", None, &[], None);
|
|
assert_eq!((status, reply["ok"].clone()), (200, Value::Bool(true)));
|
|
assert_eq!(reply.get("failing"), None);
|
|
|
|
// A read-only blob folder fails the readiness probe (unix only).
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let blobs = data.join("blobs");
|
|
std::fs::set_permissions(&blobs, std::fs::Permissions::from_mode(0o555)).expect("chmod blobs");
|
|
let (status, reply) = server.json("GET", "/readyz", None, &[], None);
|
|
assert_eq!(status, 503);
|
|
assert_eq!(reply["ok"], Value::Bool(false));
|
|
assert_eq!(reply["failing"], "blobs");
|
|
std::fs::set_permissions(&blobs, std::fs::Permissions::from_mode(0o755)).expect("chmod back");
|
|
let (status, _) = server.json("GET", "/readyz", None, &[], None);
|
|
assert_eq!(status, 200);
|
|
std::fs::remove_dir_all(&data).expect("cleanup");
|
|
}
|
|
}
|