diff --git a/Cargo.lock b/Cargo.lock index 81a6319..4a923d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,6 +378,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "content_inspector" version = "0.2.4" @@ -446,6 +452,43 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" @@ -510,10 +553,12 @@ dependencies = [ "clap_complete", "content_inspector", "digest_auth", + "ed25519-dalek", "form_urlencoded", "futures-util", "glob", "headers", + "hex", "http-body-util", "hyper", "hyper-util", @@ -549,6 +594,30 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -580,6 +649,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "flate2" version = "1.1.1" @@ -1347,6 +1422,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -1879,6 +1964,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.9" @@ -1915,6 +2009,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 51a1e1d..c1a8562 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,8 @@ http-body-util = "0.1" bytes = "1.5" pin-project-lite = "0.2" sha2 = "0.10.8" +ed25519-dalek = "2.2.0" +hex = "0.4.3" [features] default = ["tls"] diff --git a/assets/index.js b/assets/index.js index b87e354..7ddf6fd 100644 --- a/assets/index.js +++ b/assets/index.js @@ -347,6 +347,7 @@ async function setupIndexPage() { const $download = document.querySelector(".download"); $download.href = baseUrl() + "?zip"; $download.title = "Download folder as a .zip file"; + $download.classList.add("dlwt"); $download.classList.remove("hidden"); } @@ -367,6 +368,10 @@ async function setupIndexPage() { renderPathsTableHead(); renderPathsTableBody(); + + if (DATA.user) { + setupDownloadWithToken(); + } } /** @@ -449,13 +454,13 @@ function addPath(file, index) { if (DATA.allow_archive) { actionDownload = `
- ${ICONS.download} + ${ICONS.download}
`; } } else { actionDownload = `
- ${ICONS.download} + ${ICONS.download}
`; } if (DATA.allow_delete) { @@ -530,12 +535,39 @@ async function setupAuth() { $loginBtn.addEventListener("click", async () => { try { await checkAuth(); - } catch {} + } catch { } location.reload(); }); } } +function setupDownloadWithToken() { + document.querySelectorAll("a.dlwt").forEach(link => { + link.addEventListener("click", async e => { + e.preventDefault(); + try { + const link = e.currentTarget || e.target; + const originalHref = link.getAttribute("href"); + const tokengenUrl = new URL(originalHref); + tokengenUrl.searchParams.set("tokengen", ""); + const res = await fetch(tokengenUrl); + if (!res.ok) throw new Error("Failed to fetch token"); + const token = await res.text(); + const downloadUrl = new URL(originalHref); + downloadUrl.searchParams.set("token", token); + const tempA = document.createElement("a"); + tempA.href = downloadUrl.toString(); + tempA.download = ""; + document.body.appendChild(tempA); + tempA.click(); + document.body.removeChild(tempA); + } catch (err) { + alert(`Failed to download, ${err.message}`); + } + }); + }); +} + function setupSearch() { const $searchbar = document.querySelector(".searchbar"); $searchbar.classList.remove("hidden"); @@ -646,7 +678,7 @@ async function setupEditorPage() { $editor.value = decoder.decode(dataView); } } catch (err) { - alert(`Failed get file, ${err.message}`); + alert(`Failed to get file, ${err.message}`); } } diff --git a/src/auth.rs b/src/auth.rs index 0aaa8b5..87b1e42 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -2,11 +2,13 @@ use crate::{args::Args, server::Response, utils::unix_now}; use anyhow::{anyhow, bail, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; +use ed25519_dalek::{ed25519::signature::SignerMut, Signature, SigningKey}; use headers::HeaderValue; use hyper::{header::WWW_AUTHENTICATE, Method}; use indexmap::IndexMap; use lazy_static::lazy_static; use md5::Context; +use sha2::{Digest, Sha256}; use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -14,7 +16,8 @@ use std::{ use uuid::Uuid; const REALM: &str = "DUFS"; -const DIGEST_AUTH_TIMEOUT: u32 = 604800; // 7 days +const DIGEST_AUTH_TIMEOUT: u32 = 60 * 60 * 24 * 7; // 7 days +const TOKEN_EXPIRATION: u64 = 1000 * 60 * 60 * 24 * 3; // 3 days lazy_static! { static ref NONCESTARTHASH: Context = { @@ -105,11 +108,21 @@ impl AccessControl { path: &str, method: &Method, authorization: Option<&HeaderValue>, + token: Option<&String>, guard_options: bool, ) -> (Option, Option) { if self.users.is_empty() { return (None, Some(AccessPaths::new(AccessPerm::ReadWrite))); } + + if method == Method::GET { + if let Some(token) = token { + if let Ok((user, ap)) = self.verifty_token(token, path) { + return (Some(user), ap.guard(path, method)); + } + } + } + if let Some(authorization) = authorization { if let Some(user) = get_auth_user(authorization) { if let Some((pass, ap)) = self.users.get(&user) { @@ -135,6 +148,49 @@ impl AccessControl { (None, None) } + + pub fn generate_token(&self, path: &str, user: &str) -> Result { + let (pass, _) = self + .users + .get(user) + .ok_or_else(|| anyhow!("Not found user '{user}'"))?; + let exp = unix_now().as_millis() as u64 + TOKEN_EXPIRATION; + let message = format!("{path}:{exp}"); + let mut signing_key = derive_secret_key(user, pass); + let sig = signing_key.sign(message.as_bytes()).to_bytes(); + + let mut raw = Vec::with_capacity(64 + 8 + user.len()); + raw.extend_from_slice(&sig); + raw.extend_from_slice(&exp.to_be_bytes()); + raw.extend_from_slice(user.as_bytes()); + + Ok(hex::encode(raw)) + } + + fn verifty_token<'a>(&'a self, token: &str, path: &str) -> Result<(String, &'a AccessPaths)> { + let raw = hex::decode(token)?; + + let sig_bytes = &raw[..64]; + let exp_bytes = &raw[64..72]; + let user_bytes = &raw[72..]; + + let exp = u64::from_be_bytes(exp_bytes.try_into()?); + if unix_now().as_millis() as u64 > exp { + bail!("Token expired"); + } + + let user = std::str::from_utf8(user_bytes)?; + let (pass, ap) = self + .users + .get(user) + .ok_or_else(|| anyhow!("Not found user '{user}'"))?; + + let sig = Signature::from_bytes(&<[u8; 64]>::try_from(sig_bytes)?); + + let message = format!("{path}:{exp}"); + derive_secret_key(user, pass).verify(message.as_bytes(), &sig)?; + Ok((user.to_string(), ap)) + } } #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -422,6 +478,13 @@ pub fn check_auth( } } +fn derive_secret_key(user: &str, pass: &str) -> SigningKey { + let mut hasher = Sha256::new(); + hasher.update(format!("{user}:{pass}").as_bytes()); + let hash = hasher.finalize(); + SigningKey::from_bytes(&hash.into()) +} + /// Check if a nonce is still valid. /// Return an error if it was never valid fn validate_nonce(nonce: &[u8]) -> Result { @@ -433,7 +496,7 @@ fn validate_nonce(nonce: &[u8]) -> Result { //get time if let Ok(secs_nonce) = u32::from_str_radix(&n[..8], 16) { //check time - let now = unix_now()?; + let now = unix_now(); let secs_now = now.as_secs() as u32; if let Some(dur) = secs_now.checked_sub(secs_nonce) { @@ -513,7 +576,7 @@ fn to_headermap(header: &[u8]) -> Result, ()> { } fn create_nonce() -> Result { - let now = unix_now()?; + let now = unix_now(); let secs = now.as_secs() as u32; let mut h = NONCESTARTHASH.clone(); h.consume(secs.to_be_bytes()); diff --git a/src/server.rs b/src/server.rs index a100006..896f0ac 100644 --- a/src/server.rs +++ b/src/server.rs @@ -149,20 +149,6 @@ impl Server { let headers = req.headers(); let method = req.method().clone(); - let user_agent = headers - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_lowercase()) - .unwrap_or_default(); - - let is_microsoft_webdav = user_agent.starts_with("microsoft-webdav-miniredir/"); - - if is_microsoft_webdav { - // microsoft webdav requires this. - res.headers_mut() - .insert(CONNECTION, HeaderValue::from_static("close")); - } - let relative_path = match self.resolve_path(req_path) { Some(v) => v, None => { @@ -179,11 +165,34 @@ impl Server { return Ok(res); } + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(|v| v.to_lowercase()) + .unwrap_or_default(); + + let is_microsoft_webdav = user_agent.starts_with("microsoft-webdav-miniredir/"); + + if is_microsoft_webdav { + // microsoft webdav requires this. + res.headers_mut() + .insert(CONNECTION, HeaderValue::from_static("close")); + } + let authorization = headers.get(AUTHORIZATION); - let guard = - self.args - .auth - .guard(&relative_path, &method, authorization, is_microsoft_webdav); + + let query = req.uri().query().unwrap_or_default(); + let mut query_params: HashMap = form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let guard = self.args.auth.guard( + &relative_path, + &method, + authorization, + query_params.get("token"), + is_microsoft_webdav, + ); let (user, access_paths) = match guard { (None, None) => { @@ -197,11 +206,6 @@ impl Server { (x, Some(y)) => (x, y), }; - let query = req.uri().query().unwrap_or_default(); - let mut query_params: HashMap = form_urlencoded::parse(query.as_bytes()) - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - if detect_noscript(&user_agent) { query_params.insert("noscript".to_string(), String::new()); } @@ -214,6 +218,11 @@ impl Server { return Ok(res); } + if has_query_flag(&query_params, "tokengen") { + self.handle_tokengen(&relative_path, user, &mut res).await?; + return Ok(res); + } + let head_only = method == Method::HEAD; if self.args.path_is_file { @@ -996,6 +1005,24 @@ impl Server { Ok(()) } + async fn handle_tokengen( + &self, + relative_path: &str, + user: Option, + res: &mut Response, + ) -> Result<()> { + let output = self + .args + .auth + .generate_token(relative_path, &user.unwrap_or_default())?; + res.headers_mut() + .typed_insert(ContentType::from(mime_guess::mime::TEXT_PLAIN_UTF_8)); + res.headers_mut() + .typed_insert(ContentLength(output.len() as u64)); + *res.body_mut() = body_full(output); + Ok(()) + } + async fn handle_propfind_dir( &self, path: &Path, @@ -1271,7 +1298,7 @@ impl Server { let guard = self .args .auth - .guard(&dest_path, req.method(), authorization, false); + .guard(&dest_path, req.method(), authorization, None, false); match guard { (_, Some(_)) => {} diff --git a/src/utils.rs b/src/utils.rs index 600f1b3..1c0f822 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -8,10 +8,10 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -pub fn unix_now() -> Result { +pub fn unix_now() -> Duration { SystemTime::now() .duration_since(UNIX_EPOCH) - .with_context(|| "Invalid system time") + .expect("Unable to get unix epoch time") } pub fn encode_uri(v: &str) -> String { diff --git a/tests/auth.rs b/tests/auth.rs index c2de81d..8c54e1d 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -348,3 +348,19 @@ fn auth_shadow( Ok(()) } + +#[rstest] +fn token_auth(#[with(&["-a", "user:pass@/"])] server: TestServer) -> Result<(), Error> { + let url = format!("{}index.html", server.url()); + let resp = fetch!(b"GET", &url).send()?; + assert_eq!(resp.status(), 401); + let url = format!("{}index.html?tokengen", server.url()); + let resp = fetch!(b"GET", &url) + .basic_auth("user", Some("pass")) + .send()?; + let token = resp.text()?; + let url = format!("{}index.html?token={token}", server.url()); + let resp = fetch!(b"GET", &url).send()?; + assert_eq!(resp.status(), 200); + Ok(()) +}