diff --git a/README.md b/README.md index 6b6c5f1..c58c909 100644 --- a/README.md +++ b/README.md @@ -546,6 +546,13 @@ the path holds a URL that answers `404`. The server stores nothing but a SHA-256 of the key and compares in constant time, and a wrong key is answered exactly like an unknown token, so guessing never confirms that a token is live. +The page sends the key on to the server once, in the body of the request that +reads the entry, never in a URL. That answer gives the browser a cookie scoped +to this one link's path, good for 12 hours, and the entry's pictures and +videos are fetched with it — so not even this server's reverse proxy logs the +key. A fresh key from `:link`, or `:unshare`, voids the cookies handed out +under the old one. + Because only the hash is kept, a link exists in full exactly once: in the moment `:share` copies it. It cannot be re-read from the entry afterwards. `:link` mints a fresh key for the same token — which retires the previous link, making @@ -593,8 +600,8 @@ sealed or not. | `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault | | `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one | | `DELETE` | `/api/ssh-keys/{id}` | remove a key, which ends its mounts | -| `GET` | `/api/share/{token}/{key}` | public: read a shared document | -| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document | +| `POST` | `/api/share/{token}` | public: read a shared document, body `{"key"}`; sets the share's media cookie | +| `GET` | `/api/share/{token}/media/{id}` | public: media inside a shared document, with that cookie | ## Deploying diff --git a/src/auth.rs b/src/auth.rs index b8cc7cf..47d22f0 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -21,7 +21,7 @@ pub const SECURE_COOKIE_NAME: &str = "__Host-diary_session"; type HmacSha256 = Hmac; -fn sign(secret: &[u8], payload: &str) -> String { +pub(crate) fn sign(secret: &[u8], payload: &str) -> String { let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length"); mac.update(payload.as_bytes()); B64.encode(mac.finalize().into_bytes()) diff --git a/src/main.rs b/src/main.rs index 6ebfd5c..5180aeb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,11 +338,11 @@ async fn deadline( /// Below the ten seconds `docker stop` waits before it sends SIGKILL. const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(8); -/// A request path as it may go into the log: a share link's token and key are -/// the whole of its secret, and the key is kept out of every log on purpose — -/// it travels in the URL fragment so that no server ever sees it, except this -/// one's API, which must not write it down either. Query strings, which carry -/// search terms, are never logged at all. +/// A request path as it may go into the log: a share link's token is half of +/// its secret. The other half, the key, never travels in a path — it rides in +/// the URL fragment, which no server sees, and then in the body of the read +/// that trades it for a cookie. Query strings, which carry search terms, are +/// never logged at all. fn loggable(path: &str) -> &str { if path.starts_with("/api/share/") { "/api/share/…" diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 7f7459c..913ccb1 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -88,8 +88,8 @@ pub fn api_router(max_upload: usize) -> Router { post(media::upload).layer(uploads).get(media::list), ) .route("/media/{id}", get(media::serve).delete(media::remove)) - .route("/share/{token}/{key}", get(share::read)) - .route("/share/{token}/{key}/media/{id}", get(share::serve_media)) + .route("/share/{token}", post(share::read)) + .route("/share/{token}/media/{id}", get(share::serve_media)) .route("/ssh-keys", get(ssh_keys::list).post(ssh_keys::add)) .route( "/ssh-keys/{id}", @@ -114,8 +114,8 @@ pub(crate) mod tests { "/health", "/login", "/logout", - "/share/{token}/{key}", - "/share/{token}/{key}/media/{id}", + "/share/{token}", + "/share/{token}/media/{id}", ]; async fn state() -> AppState { @@ -193,10 +193,7 @@ pub(crate) mod tests { let app = super::api_router(1024 * 1024).with_state(state().await); for (method, path) in routes { - let uri = path - .replace("{id}", "1") - .replace("{token}", "t") - .replace("{key}", "k"); + let uri = path.replace("{id}", "1").replace("{token}", "t"); let response = app .clone() .oneshot( diff --git a/src/routes/share.rs b/src/routes/share.rs index f5b0e26..4454bc4 100644 --- a/src/routes/share.rs +++ b/src/routes/share.rs @@ -1,18 +1,20 @@ use axum::{ extract::{Path, State}, - response::Response, + http::{header, HeaderMap}, + response::{AppendHeaders, IntoResponse, Response}, Json, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine}; use rand::RngCore; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use sha2::{Digest, Sha256}; use sqlx::Row; use subtle::ConstantTimeEq; use crate::{ - auth::Session, + auth::{self, Session}, + config::Config, error::{AppError, AppResult}, routes::media, state::AppState, @@ -119,12 +121,71 @@ pub async fn disable( Ok(Json(json!({ "ok": true }))) } -/// Public: no session required. The credential is the pair — the token from the -/// link's path and the key the reader's browser lifted out of its fragment. +/// The cookie a share page gets for its media. `` and `