diff --git a/Cargo.lock b/Cargo.lock index 43022a7..1590cd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,6 +476,7 @@ dependencies = [ "sha2", "strum", "strum_macros", + "subtle", "tokio", "tokio-util", "url", diff --git a/Cargo.toml b/Cargo.toml index aeba02b..f5641da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ rocket_cors = "0.6.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" sha2 = "0.11.0" +subtle = "2.6.1" strum = "0.28.0" strum_macros = "0.28.0" tokio = "1.48.0" diff --git a/api-description.yaml b/api-description.yaml index b31fb13..6093071 100644 --- a/api-description.yaml +++ b/api-description.yaml @@ -83,11 +83,24 @@ paths: application/json: schema: type: "object" + required: + - uuid + - recovery_token properties: uuid: type: "string" format: "uuid" description: "Unique identifier for the file upload" + recovery_token: + type: "string" + description: + "Bearer credential for the cross-refresh-resume + status endpoint (`GET /fileupload/{uuid}/status`). + The client should store this alongside the UUID + (e.g. in IndexedDB) and present it in an + `X-Recovery-Token` header to recover from a + page refresh, tab crash, or navigate-away-and-back. + Hex-encoded 32-byte random." /fileupload/{uuid}: put: tags: @@ -242,6 +255,69 @@ paths: "422": description: "Data is missing to form complete file." + /fileupload/{uuid}/status: + get: + tags: + - "File upload" + summary: "Read upload state for cross-refresh resume" + description: + "Returns the rolling-token state of an in-flight upload so a + client that lost track of the session (page refresh, tab crash, + navigate-away-and-back) can rehydrate and feed the next chunk + PUT through the idempotent-retry path + (`PUT /fileupload/{uuid}`). Authenticates via the + `X-Recovery-Token` header issued at `upload_init`. **Behaviour + on resume conflict:** if two clients hold the same UUID and + recovery token (e.g. two tabs), the first chunk PUT to land + wins and the second sees a 4xx as soon as it tries to advance + past the now-stale state — single-active-resumer semantics, no + lease enforcement on the server side. A successful call also + resets the session's idle eviction deadline so the very next + chunk PUT does not 404 because the rehydrate window aged out." + operationId: "uploadStatus" + parameters: + - in: "header" + name: "X-Recovery-Token" + description: + "Bearer credential issued in the `recovery_token` field of + the `upload_init` response. Compared in constant time on the + server. Missing / empty → 401." + schema: + type: "string" + required: true + - in: "path" + name: "uuid" + required: true + description: "The unique identifier received when initializing file upload." + schema: + type: "string" + format: "uuid" + responses: + "200": + description: "Successful operation." + content: + application/json: + schema: + $ref: "#/components/schemas/UploadStatus" + "401": + description: + "Missing or empty `X-Recovery-Token` header. Note: a + *valid-format* token that simply does not match the stored + value returns 404 with the same body shape as an evicted + session, deliberately, so attackers cannot probe for live + UUIDs by varying the token." + "404": + description: + "The upload session is not known to the server, OR the + recovery token does not match the stored value. The two + cases are deliberately collapsed — same response shape as + `PUT /fileupload/{uuid}` — to avoid leaking session + existence." + content: + application/json: + schema: + $ref: "#/components/schemas/UploadSessionNotFound" + /usage: get: tags: @@ -394,3 +470,37 @@ components: `invalid_uuid` means the path UUID is malformed. `file_missing` means the in-memory session exists but the on-disk file is gone (server-state inconsistency)." + UploadStatus: + type: "object" + required: + - uploaded + - cryptify_token + properties: + uploaded: + type: "integer" + format: "int64" + description: + "Total bytes the server has committed for this upload so far. + The client should resume from this offset." + cryptify_token: + type: "string" + description: + "Current value of the rolling token. The client must send this + as `cryptifytoken` on the next chunk PUT." + prev_token: + type: "string" + description: + "Token the client sent on the most recently committed chunk + (i.e. the value of `cryptify_token` *before* that chunk + advanced it). Combined with `prev_offset`, lets the client + re-issue the most recent chunk on the idempotent-retry path + (PUT `/fileupload/{uuid}`) if it is unsure whether the chunk + was committed. Omitted until at least one chunk has been + committed." + prev_offset: + type: "integer" + format: "int64" + description: + "Byte offset where the most recently committed chunk started + (i.e. `uploaded - chunk_len`). Omitted until at least one + chunk has been committed." diff --git a/src/main.rs b/src/main.rs index 0d96d43..6fcca81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,7 +35,7 @@ use rocket::{ }; use rocket::http::Method; -use rocket_cors::{AllowedOrigins, CorsOptions}; +use rocket_cors::{AllowedHeaders, AllowedOrigins, CorsOptions}; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -68,6 +68,11 @@ fn default_true() -> bool { #[serde(rename = "camelCase")] struct InitResponse { uuid: String, + /// Bearer credential for the cross-refresh-resume status endpoint + /// (`GET /fileupload/{uuid}/status`). The client stores this alongside + /// the UUID — typically in IndexedDB — and sends it back in an + /// `X-Recovery-Token` header on resume. Hex-encoded 32-byte random. + recovery_token: String, } struct CryptifyToken(String); @@ -269,6 +274,7 @@ async fn upload_init( } let init_cryptify_token = bytes_to_hex(&rand::random::<[u8; 32]>()); + let recovery_token = bytes_to_hex(&rand::random::<[u8; 32]>()); store.create( uuid.clone(), @@ -286,11 +292,15 @@ async fn upload_init( api_key_tenant: api_key.tenant, api_key_validation_failed: api_key.validation_failed, last_chunk: None, + recovery_token: recovery_token.clone(), }, ); Ok(InitResponder { - inner: Json(InitResponse { uuid }), + inner: Json(InitResponse { + uuid, + recovery_token, + }), cryptify_token: CryptifyToken(init_cryptify_token), }) } @@ -789,6 +799,104 @@ async fn upload_finalize( Ok(()) } +/// Snapshot of an in-flight upload's rolling-token state, returned by +/// `GET /fileupload/{uuid}/status`. The client uses this to rehydrate a +/// session it lost track of (page refresh, tab crash) and feed the next +/// chunk PUT through the idempotent-retry path from #145. `prev_token` +/// and `prev_offset` are `None` until at least one chunk has been +/// committed — in that case the client just resumes from offset 0 with +/// `cryptify_token`. +#[derive(Serialize)] +struct UploadStatusResponse { + uploaded: u64, + cryptify_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + prev_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + prev_offset: Option, +} + +/// Constant-time compare of the recovery token. Hex-encoded equal-length +/// strings, but `subtle::ConstantTimeEq` makes the timing independent of +/// where the bytes start to differ — defeats timing oracles even though +/// 32 bytes of high-entropy random aren't realistically guessable. +fn recovery_tokens_match(presented: &str, expected: &str) -> bool { + use subtle::ConstantTimeEq; + if presented.len() != expected.len() { + return false; + } + presented.as_bytes().ct_eq(expected.as_bytes()).into() +} + +#[get("/fileupload//status")] +async fn upload_status( + store: &State, + uuid: &str, + recovery_token: RecoveryTokenHeader, +) -> Result, Error> { + // Two-step auth-versus-existence ordering: present a 401 for missing / + // malformed credentials regardless of UUID; once the credential is + // structurally present, an unknown UUID *or* a value mismatch both + // surface as 404 with `upload_session_not_found`. That collapsing is + // deliberate — otherwise an attacker with a guessable UUID could send + // any value and read 401 vs 404 to confirm which UUIDs have live + // sessions. + let state = store + .get(uuid) + .ok_or_else(|| Error::upload_session_not_found(uuid, "expired_or_unknown"))?; + let state = state.lock().await; + + if !recovery_tokens_match(&recovery_token.0, &state.recovery_token) { + // Same body shape as evicted/unknown so the response doesn't leak + // session existence to a token-guessing attacker. + return Err(Error::upload_session_not_found(uuid, "expired_or_unknown")); + } + + let (prev_token, prev_offset) = match state.last_chunk.as_ref() { + Some(last) => (Some(last.prev_token.clone()), Some(last.prev_uploaded)), + None => (None, None), + }; + let response = UploadStatusResponse { + uploaded: state.uploaded, + cryptify_token: state.cryptify_token.clone(), + prev_token, + prev_offset, + }; + + drop(state); + // The whole point of cross-refresh resume is to hand control back to + // the client mid-upload — push the eviction deadline so the very next + // chunk PUT doesn't 404 because the rehydrate window aged out. + store.touch(uuid); + Ok(Json(response)) +} + +/// Extractor for the `X-Recovery-Token` header. Missing or malformed +/// header → 401 from the route handler. Deliberately not reusing the +/// `Authorization: Bearer …` scheme: that channel already carries +/// `PG-…` API-key credentials for the upload-tier flow, and crossing +/// the two would invite middleware misrouting. +struct RecoveryTokenHeader(String); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for RecoveryTokenHeader { + type Error = (); + async fn from_request(request: &'r rocket::Request<'_>) -> rocket::request::Outcome { + let token = request.headers().get_one("X-Recovery-Token").and_then(|t| { + let trimmed = t.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }); + match token { + Some(t) => rocket::request::Outcome::Success(RecoveryTokenHeader(t)), + None => rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, ())), + } + } +} + #[derive(Serialize)] #[serde(rename_all = "snake_case")] struct UsageResponse { @@ -873,6 +981,17 @@ async fn rocket() -> _ { .map(From::from) .collect(), ) + // Browser preflight needs to allow our custom request headers. + // `Authorization` is here for the Bearer-API-key tier flow; + // `cryptifytoken`, `content-range`, and `content-type` ride on + // chunk PUTs; `x-recovery-token` authenticates GET /…/status. + .allowed_headers(AllowedHeaders::some(&[ + "Authorization", + "Content-Type", + "Content-Range", + "CryptifyToken", + "X-Recovery-Token", + ])) .expose_headers(["cryptifytoken"].iter().map(ToString::to_string).collect()) .max_age(Some(86400)) .to_cors() @@ -884,7 +1003,14 @@ async fn rocket() -> _ { .attach(cors) .mount( "/", - routes![health, upload_init, upload_chunk, upload_finalize, usage], + routes![ + health, + upload_init, + upload_chunk, + upload_finalize, + upload_status, + usage + ], ) .mount("/filedownload", FileServer::from(config.data_dir())) .attach(AdHoc::config::()) @@ -1133,6 +1259,354 @@ mod tests { let _ = std::fs::remove_dir_all(&data_dir); } + // Builds a rocket instance with both upload_init and upload_status + // mounted. Used for the cross-refresh-resume status-endpoint tests. + async fn status_client(data_dir: &std::path::Path) -> Client { + use rocket::figment::{providers::Serialized, Figment}; + + std::fs::create_dir_all(data_dir).expect("create test data_dir"); + + let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": data_dir.to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }), + )); + + let rocket = rocket::custom(figment) + .mount("/", routes![upload_init, upload_status]) + .attach(AdHoc::config::()) + .manage(Store::new()); + + Client::tracked(rocket).await.expect("valid rocket") + } + + /// Variant of `status_client` that also attaches the production cors + /// fairing, so tests can exercise browser-preflight behaviour for the + /// new `/status` route. + async fn status_client_with_cors(data_dir: &std::path::Path) -> Client { + use rocket::figment::{providers::Serialized, Figment}; + + std::fs::create_dir_all(data_dir).expect("create test data_dir"); + + let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": data_dir.to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }), + )); + + let cors = CorsOptions::default() + .allowed_origins(AllowedOrigins::all()) + .allowed_methods( + vec![Method::Get, Method::Post, Method::Put] + .into_iter() + .map(From::from) + .collect(), + ) + .allowed_headers(AllowedHeaders::some(&[ + "Authorization", + "Content-Type", + "Content-Range", + "CryptifyToken", + "X-Recovery-Token", + ])) + .to_cors() + .expect("valid cors"); + + let rocket = rocket::custom(figment) + .attach(cors) + .mount("/", routes![upload_init, upload_status]) + .attach(AdHoc::config::()) + .manage(Store::new()); + + Client::tracked(rocket).await.expect("valid rocket") + } + + /// Init an upload via the test client and return `(uuid, recovery_token)`. + async fn init_upload(client: &Client) -> (String, String) { + let res = client + .post("/fileupload/init") + .header(rocket::http::ContentType::JSON) + .body( + r#"{"recipient":"alice@example.com","mailContent":"hi","mailLang":"EN","confirm":false}"#, + ) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + let body: serde_json::Value = res.into_json().await.expect("init body"); + let uuid = body["uuid"].as_str().expect("uuid in init body").to_owned(); + let recovery_token = body["recovery_token"] + .as_str() + .expect("recovery_token in init body") + .to_owned(); + (uuid, recovery_token) + } + + #[rocket::async_test] + async fn status_returns_initial_state_after_init() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, recovery_token) = init_upload(&client).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", recovery_token)) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + + let body: serde_json::Value = res.into_json().await.expect("status body"); + assert_eq!(body["uploaded"].as_u64(), Some(0)); + assert!(body["cryptify_token"].as_str().is_some()); + // No chunk committed yet — prev_token / prev_offset are absent. + assert!(body.get("prev_token").is_none()); + assert!(body.get("prev_offset").is_none()); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn status_returns_401_when_recovery_header_missing() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, _) = init_upload(&client).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .dispatch() + .await; + assert_eq!(res.status(), Status::Unauthorized); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn status_returns_401_when_recovery_header_blank() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, _) = init_upload(&client).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", " ")) + .dispatch() + .await; + assert_eq!(res.status(), Status::Unauthorized); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Wrong recovery token must return the same shape as an unknown UUID + // — otherwise an attacker can probe for live UUIDs. + #[rocket::async_test] + async fn status_returns_404_for_token_mismatch_same_as_unknown_uuid() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, _) = init_upload(&client).await; + + // Real UUID, wrong token. + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", "00".repeat(32))) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + let body_real: serde_json::Value = res.into_json().await.expect("404 body"); + assert_eq!( + body_real["error"].as_str(), + Some("upload_session_not_found") + ); + + // Unknown UUID, any token. + let res = client + .get(format!( + "/fileupload/{}/status", + uuid::Uuid::new_v4().hyphenated() + )) + .header(Header::new("X-Recovery-Token", "ff".repeat(32))) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + let body_fake: serde_json::Value = res.into_json().await.expect("404 body"); + assert_eq!( + body_fake["error"].as_str(), + Some("upload_session_not_found") + ); + assert_eq!(body_real["error"], body_fake["error"]); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn recovery_tokens_match_constant_time_helper() { + // The function under test is the constant-time wrapper itself — + // we can't observe timing in a unit test, but we can pin the + // value-equality semantics so a future refactor doesn't silently + // turn it into `presented == expected`. + assert!(recovery_tokens_match("abc123", "abc123")); + assert!(!recovery_tokens_match("abc123", "abc124")); + assert!(!recovery_tokens_match("abc123", "abc12")); // length mismatch + assert!(!recovery_tokens_match("", "abc")); + assert!(recovery_tokens_match("", "")); + } + + // Browser preflight regression: design AC for #146 explicitly required + // a CORS smoke test so the `X-Recovery-Token` allow-list entry can't + // silently regress. Sends an `OPTIONS /fileupload/{uuid}/status` + // preflight and asserts the response advertises `X-Recovery-Token` + // among `Access-Control-Allow-Headers`. + #[rocket::async_test] + async fn status_preflight_advertises_x_recovery_token() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client_with_cors(&data_dir).await; + + let res = client + .req( + rocket::http::Method::Options, + "/fileupload/00000000-0000-0000-0000-000000000000/status", + ) + .header(Header::new("Origin", "https://example.com")) + .header(Header::new("Access-Control-Request-Method", "GET")) + .header(Header::new( + "Access-Control-Request-Headers", + "X-Recovery-Token", + )) + .dispatch() + .await; + + // rocket_cors echoes successful preflights back as 2xx. + assert!( + res.status().code < 400, + "expected 2xx preflight, got {}", + res.status() + ); + let allow_headers = res + .headers() + .get_one("Access-Control-Allow-Headers") + .expect("CORS allow-headers in preflight response"); + // Header names compare case-insensitively per RFC 7230, but the + // standard cors fairing emits the names verbatim from our config. + let allow_headers_lc = allow_headers.to_ascii_lowercase(); + assert!( + allow_headers_lc.contains("x-recovery-token"), + "Access-Control-Allow-Headers `{}` should include x-recovery-token", + allow_headers + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Design AC for #146: a successful `/status` call must reset the idle + // eviction deadline (otherwise rehydrate succeeds, then the very next + // chunk PUT 404s because the session aged out between the GET and the + // PUT). Inspect the deadline directly via the test-only accessor. + #[rocket::async_test] + async fn status_extends_eviction_deadline() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, recovery_token) = init_upload(&client).await; + + let store = client.rocket().state::().expect("Store managed"); + let before = store + .deadline_for(&uuid) + .expect("session has a deadline after init"); + + // tokio::time::Instant has millisecond resolution on most + // platforms; sleep enough that a fresh `now() + ttl` is strictly + // later than the value captured at init. + rocket::tokio::time::sleep(Duration::from_millis(10)).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", recovery_token)) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + + let after = store + .deadline_for(&uuid) + .expect("session still alive after status call"); + assert!( + after > before, + "successful /status should extend the eviction deadline" + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Negative complement: failed auth (wrong recovery token) must NOT + // extend the deadline. Otherwise an attacker with a known UUID could + // keep a session alive past its eviction window just by hitting + // `/status` with bogus tokens. + #[rocket::async_test] + async fn status_does_not_extend_deadline_on_token_mismatch() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, _) = init_upload(&client).await; + + let store = client.rocket().state::().expect("Store managed"); + let before = store + .deadline_for(&uuid) + .expect("session has a deadline after init"); + + rocket::tokio::time::sleep(Duration::from_millis(10)).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", "00".repeat(32))) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + + let after = store + .deadline_for(&uuid) + .expect("session still alive (token mismatch doesn't evict)"); + assert_eq!( + before, after, + "failed-auth /status must not extend the deadline" + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + fn empty_filestate(uploaded: u64, current_token: &str) -> FileState { FileState { uploaded, @@ -1148,6 +1622,7 @@ mod tests { api_key_tenant: None, api_key_validation_failed: false, last_chunk: None, + recovery_token: String::new(), } } diff --git a/src/store.rs b/src/store.rs index dd07973..e920633 100644 --- a/src/store.rs +++ b/src/store.rs @@ -56,6 +56,14 @@ pub struct FileState { /// advancing the rolling-token chain or double-writing the chunk. /// `None` until at least one chunk has been successfully committed. pub last_chunk: Option, + /// Bearer token for the cross-refresh-resume status endpoint + /// (`GET /fileupload/{uuid}/status`). Issued at `upload_init` and + /// returned to the client alongside the first `cryptifytoken`. The + /// path UUID alone isn't authoritative (URLs leak), so any read of + /// session state requires the client to present this token in an + /// `X-Recovery-Token` header. Compared in constant time to defeat + /// timing oracles. Hex-encoded 32-byte random. + pub recovery_token: String, } /// Replay record of the most recently committed chunk. See @@ -186,6 +194,17 @@ impl Store { } } + /// Test-only accessor for the current eviction deadline of `id`. + /// Lets route-level integration tests assert that a successful + /// `GET /fileupload/{uuid}/status` reset the idle window via + /// `Store::touch` (the design AC for #146 explicitly calls this + /// out). Returns `None` if no session exists for `id`. + #[cfg(test)] + pub fn deadline_for(&self, id: &str) -> Option { + let state = self.shared.state.lock().unwrap(); + state.expiration_keys.get(id).map(|(when, _)| *when) + } + pub fn record_upload(&self, email: String, bytes: u64, now: i64) { let mut state = self.shared.state.lock().unwrap(); let entry = state.usage.entry(email).or_default(); @@ -352,6 +371,7 @@ mod tests { api_key_tenant: None, api_key_validation_failed: false, last_chunk: None, + recovery_token: String::new(), } }