diff --git a/Cargo.lock b/Cargo.lock index 8270ba9..fabeefd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -186,6 +195,25 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.5" @@ -195,6 +223,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -291,6 +330,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -330,6 +379,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -625,6 +683,16 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -883,6 +951,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -925,6 +1004,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.111" @@ -1085,6 +1170,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -1164,9 +1255,11 @@ dependencies = [ "jsonwebtoken", "log", "once_cell", + "pbkdf2", "rand 0.8.5", "serde", "serde_json", + "sha2", "thiserror 1.0.69", "tower-http", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index b8fcbdf..723b55b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ worker-macros = { version = "0.7.1", features = ['http'] } wasm-bindgen = "0.2" js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Crypto", "CryptoKey", "SubtleCrypto", "WorkerGlobalScope", "Pbkdf2Params"] } +web-sys = { version = "0.3", features = ["Crypto", "CryptoKey", "SubtleCrypto", "WorkerGlobalScope"] } console_error_panic_hook = "0.1.7" wasm-streams = "0.4" @@ -40,6 +40,8 @@ serde_json = "1.0" jsonwebtoken = "9.2" base64 = "0.21" base32 = "0.5" +pbkdf2 = "0.12" +sha2 = "0.10" hex = "0.4" rand = { version = "0.8" } getrandom = { version = "0.3", features = ["wasm_js"] } diff --git a/README.md b/README.md index ee11e98..a855fcb 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,35 @@ If the binding is missing, requests proceed without rate limiting (graceful degr ## Configuration +### Durable Objects (CPU Offloading) + +Cloudflare Workers Free plan has a very small per-request CPU budget. Two endpoints are particularly CPU-heavy: + +- `POST /api/ciphers/import`: large JSON payload (typically 500kB–1MB) + parsing + batch inserts. +- `POST /identity/connect/token`: server-side PBKDF2 for password verification. + +To keep the main Worker fast while still supporting these operations, Warden can **offload selected endpoints to Durable Objects (DO)**: + +- **Heavy DO (`HEAVY_DO`)**: implemented in Rust as `HeavyDo` (reuses the existing axum router) so CPU-heavy endpoints (import/login/password verification) can run with a higher CPU budget. + +**How to enable/disable** + +It's disabled by default. You can enable it by setting `HEAVY_DO_ENABLED=1` and adding the `HEAVY_DO` binding in `wrangler.toml`. + +> [!NOTE] +> Durable Objects can incur two types of billing: compute and storage. Storage is not used in this project, and the free plan allows 100,000 requests and 13,000 GB-s duration per day, which should be more than enough for most users. See [Cloudflare Durable Objects pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/) for details. +> If you choose to disable Durable Objects, you may need subscribe to a paid plan to avoid being throttled by Cloudflare. + +### Environment Variables + Configure environment variables in `wrangler.toml` under `[vars]`, or set them via Cloudflare Dashboard: +* **`HEAVY_DO_ENABLED`** (Optional, Default: `0`): + - Enable routing CPU-heavy endpoints to Durable Object (`HEAVY_DO`). + - Recommended on Free plan if you hit CPU time limits on import/login. +* **`PASSWORD_ITERATIONS`** (Optional, Default: `600000`): + - PBKDF2 iterations for server-side password hashing. + - Minimum is 600000. * **`TRASH_AUTO_DELETE_DAYS`** (Optional, Default: `30`): - Days to keep soft-deleted items before purge. - Set to `0` or negative to disable. diff --git a/migrations/0007_add_password_iterations.sql b/migrations/0007_add_password_iterations.sql new file mode 100644 index 0000000..9a8847a --- /dev/null +++ b/migrations/0007_add_password_iterations.sql @@ -0,0 +1,14 @@ +-- Migration: Add password_iterations column to users table +-- This column stores the per-user server-side PBKDF2 iteration count used for +-- hashing/storing the master password hash. +-- +-- Existing databases (pre-migration) used 100_000 iterations for all users. +-- We set the default accordingly so existing rows remain verifiable, and the +-- application will upgrade users to the configured minimum during login. +-- +-- Note: This migration is applied via GitHub Actions which handles +-- the "duplicate column" error gracefully for existing databases. + +ALTER TABLE users ADD COLUMN password_iterations INTEGER NOT NULL DEFAULT 100000; + + diff --git a/sql/schema.sql b/sql/schema.sql index ec318ad..128aa9b 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS users ( master_password_hash TEXT NOT NULL, master_password_hint TEXT, password_salt TEXT, -- Salt for server-side PBKDF2 hashing (NULL for legacy users pending migration) + password_iterations INTEGER NOT NULL DEFAULT 600000, -- Per-user server-side PBKDF2 iteration count (migrated on login) key TEXT NOT NULL, -- The encrypted symmetric key private_key TEXT NOT NULL, -- encrypted asymmetric private_key public_key TEXT NOT NULL, -- asymmetric public_key diff --git a/src/crypto.rs b/src/crypto.rs index e2e7103..27db579 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,6 +1,8 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use constant_time_eq::constant_time_eq; use js_sys::Uint8Array; +use pbkdf2::pbkdf2_hmac; +use sha2::Sha256; use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use web_sys::{Crypto, CryptoKey, SubtleCrypto}; @@ -8,10 +10,16 @@ use worker::js_sys; use crate::error::AppError; -/// Number of PBKDF2 iterations for server-side password hashing -const SERVER_PBKDF2_ITERATIONS: u32 = 100_000; -/// Salt length in bytes -const SALT_LENGTH: usize = 16; +/// Minimum PBKDF2 iterations for server-side password hashing (new global minimum). +/// +/// NOTE: Cloudflare Workers native WebCrypto PBKDF2 currently rejects iteration counts > 100_000. +/// We therefore run PBKDF2 in pure Rust/WASM so we can safely use higher iteration counts. +/// +/// This is CPU-expensive and is expected to be executed inside a Durable Object for Free plan deployments. +pub const MIN_SERVER_PBKDF2_ITERATIONS: u32 = 600_000; + +/// Salt length in bytes for server-side password hashing. +pub const PASSWORD_SALT_LENGTH: usize = 64; /// Derived key length in bits const KEY_LENGTH_BITS: u32 = 256; @@ -32,61 +40,30 @@ fn subtle_crypto() -> Result { Ok(get_crypto()?.subtle()) } -/// Derives a key using PBKDF2-SHA256. -pub async fn pbkdf2_sha256( +/// Derives a key using PBKDF2-HMAC-SHA256 (pure Rust). +pub fn pbkdf2_sha256( password: &[u8], salt: &[u8], iterations: u32, key_length_bits: u32, ) -> Result, AppError> { - let subtle = subtle_crypto()?; + if !key_length_bits.is_multiple_of(8) { + return Err(AppError::Crypto(format!( + "PBKDF2 key length must be a multiple of 8 bits (got {})", + key_length_bits + ))); + } - // Import the password as a raw key material - let password_array = Uint8Array::new_from_slice(password); - let password_obj = password_array.as_ref(); - let key_material = JsFuture::from( - subtle - .import_key_with_str( - "raw", - password_obj, - "PBKDF2", - false, - &js_sys::Array::of1(&JsValue::from_str("deriveBits")), - ) - .map_err(|e| AppError::Crypto(format!("PBKDF2 import_key failed: {:?}", e)))?, - ) - .await - .map_err(|e| AppError::Crypto(format!("PBKDF2 import_key await failed: {:?}", e)))?; - - let salt_array = Uint8Array::new_from_slice(salt); - // Define PBKDF2 parameters - let params = web_sys::Pbkdf2Params::new( - "PBKDF2", - JsValue::from_str("SHA-256").as_ref(), - iterations, - salt_array.as_ref(), - ); - - // Derive the bits - let derived_bits = JsFuture::from( - subtle - .derive_bits_with_object( - params.as_ref(), - &CryptoKey::from(key_material), - key_length_bits, - ) - .map_err(|e| AppError::Crypto(format!("PBKDF2 derive_bits failed: {:?}", e)))?, - ) - .await - .map_err(|e| AppError::Crypto(format!("PBKDF2 derive_bits await failed: {:?}", e)))?; - - Ok(js_sys::Uint8Array::new(&derived_bits).to_vec()) + let dk_len = (key_length_bits / 8) as usize; + let mut out = vec![0u8; dk_len]; + pbkdf2_hmac::(password, salt, iterations, &mut out); + Ok(out) } /// Generates a cryptographically secure random salt. pub fn generate_salt() -> Result { let crypto = get_crypto()?; - let salt = Uint8Array::new_with_length(SALT_LENGTH as u32); + let salt = Uint8Array::new_with_length(PASSWORD_SALT_LENGTH as u32); crypto .get_random_values_with_array_buffer_view(&salt) .map_err(|e| AppError::Crypto(format!("Failed to generate random salt: {:?}", e)))?; @@ -99,6 +76,7 @@ pub fn generate_salt() -> Result { pub async fn hash_password_for_storage( client_password_hash: &str, salt: &str, + iterations: u32, ) -> Result { let salt_bytes = BASE64 .decode(salt) @@ -107,10 +85,9 @@ pub async fn hash_password_for_storage( let derived = pbkdf2_sha256( client_password_hash.as_bytes(), &salt_bytes, - SERVER_PBKDF2_ITERATIONS, + iterations, KEY_LENGTH_BITS, - ) - .await?; + )?; Ok(BASE64.encode(derived)) } @@ -121,8 +98,9 @@ pub async fn verify_password( client_password_hash: &str, stored_hash: &str, salt: &str, + iterations: u32, ) -> Result { - let computed_hash = hash_password_for_storage(client_password_hash, salt).await?; + let computed_hash = hash_password_for_storage(client_password_hash, salt, iterations).await?; Ok(constant_time_eq( computed_hash.as_bytes(), stored_hash.as_bytes(), diff --git a/src/durable/heavy_do.rs b/src/durable/heavy_do.rs new file mode 100644 index 0000000..a4ad41b --- /dev/null +++ b/src/durable/heavy_do.rs @@ -0,0 +1,62 @@ +use axum::{extract::DefaultBodyLimit, Extension}; +use tower_http::cors::{Any, CorsLayer}; +use tower_service::Service; +use worker::{durable_object, DurableObject, Env, HttpRequest, Request, Response, Result, State}; + +use crate::{router, BaseUrl}; + +/// Durable Object used to run CPU-heavy API flows with a higher CPU budget. +/// +/// This DO intentionally reuses the existing axum router so we don't have to duplicate business +/// logic in a separate code path. +#[durable_object] +pub struct HeavyDo { + state: State, + env: Env, +} + +impl DurableObject for HeavyDo { + fn new(state: State, env: Env) -> Self { + Self { state, env } + } + + async fn fetch(&self, req: Request) -> Result { + // Set up logging/panic hook (idempotent). + console_error_panic_hook::set_once(); + let _ = console_log::init_with_level(log::Level::Debug); + + // Keep fields used to avoid "unused" warnings even if we don't currently rely on them. + let _ = &self.state; + + // Convert worker::Request -> worker::HttpRequest so we can reuse axum Router. + let http_req: HttpRequest = req.try_into()?; + + // Extract base URL for /api/config endpoint (matches src/lib.rs behavior). + let uri = http_req.uri().clone(); + let base_url = format!( + "{}://{}", + uri.scheme_str().unwrap_or("https"), + uri.authority().map(|a| a.as_str()).unwrap_or("localhost") + ); + + // Allow all origins for CORS, matching the main worker. + let cors = CorsLayer::new() + .allow_methods(Any) + .allow_headers(Any) + .allow_origin(Any); + + // Match the main worker's default body limit (5MB) for regular API requests. + const BODY_LIMIT: usize = 5 * 1024 * 1024; + + // Reuse the existing router stack. + let mut app = router::api_router(self.env.clone()) + .layer(Extension(BaseUrl(base_url))) + .layer(cors) + .layer(DefaultBodyLimit::max(BODY_LIMIT)); + + let http_resp = app.call(http_req).await?; + + // Convert http::Response -> worker::Response for the DO runtime. + http_resp.try_into() + } +} diff --git a/src/durable/mod.rs b/src/durable/mod.rs new file mode 100644 index 0000000..a5b78ce --- /dev/null +++ b/src/durable/mod.rs @@ -0,0 +1 @@ +pub mod heavy_do; diff --git a/src/entry.js b/src/entry.js index b91efd6..a587e9d 100644 --- a/src/entry.js +++ b/src/entry.js @@ -72,6 +72,60 @@ function base64UrlDecode(str) { return bytes; } +function isTruthy(value) { + if (value == null) return false; + const s = value.toString().trim().toLowerCase(); + return s === "1" || s === "true" || s === "yes" || s === "on"; +} + +function heavyDoEnabled(env) { + return isTruthy(getEnvVar(env, "HEAVY_DO_ENABLED", "0")); +} + +function getBearerToken(request) { + const auth = request.headers.get("Authorization") || request.headers.get("authorization"); + if (!auth) return null; + const m = auth.match(/^\s*Bearer\s+(.+?)\s*$/i); + return m ? m[1] : null; +} + +// Decode JWT payload WITHOUT verifying signature (used only for sharding DO instances). +// The Durable Object handler will perform full verification and reject invalid tokens. +function decodeJwtPayloadUnsafe(token) { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payloadB64 = parts[1]; + const payloadJson = new TextDecoder().decode(base64UrlDecode(payloadB64)); + return JSON.parse(payloadJson); + } catch { + return null; + } +} + +const AUTH_DO_EXACT_PATHS = new Set([ + // Login + "/identity/connect/token", + // Registration (server-side password hashing) + "/identity/accounts/register", + "/identity/accounts/register/finish", + // Password/KDF changes + "/api/accounts/password", + "/api/accounts/kdf", + // Dangerous ops requiring password verification + "/api/accounts/delete", + "/api/accounts", + "/api/ciphers/purge", + // Key rotation may verify master password + "/api/accounts/key-management/rotate-user-account-keys", +]); + +function isAuthDoPath(pathname) { + if (AUTH_DO_EXACT_PATHS.has(pathname)) return true; + if (pathname.startsWith("/api/two-factor")) return true; + return false; +} + // Parse azure-upload route: /api/ciphers/{id}/attachment/{attachment_id}/azure-upload function parseAzureUploadPath(path) { const parts = path.replace(/^\//, "").split("/"); @@ -502,6 +556,61 @@ export default { async fetch(request, env, ctx) { const url = new URL(request.url); + // Optional: route selected CPU-heavy endpoints to Durable Objects. + // This keeps the main Worker on a low-CPU path while allowing heavy work to complete. + if (heavyDoEnabled(env)) { + // Import: route to Rust Durable Object (HeavyDo) to reuse the existing Rust import logic. + if (request.method === "POST" && url.pathname === "/api/ciphers/import") { + if (!env.HEAVY_DO) { + return new Response(JSON.stringify({ error: "HEAVY_DO binding not configured" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // Shard by user id (JWT sub) WITHOUT verifying signature here (cheap). + const token = getBearerToken(request); + const sub = token ? decodeJwtPayloadUnsafe(token)?.sub : null; + const name = sub ? `import:${sub}` : "import:default"; + const id = env.HEAVY_DO.idFromName(name); + const stub = env.HEAVY_DO.get(id); + return stub.fetch(request); + } + + // Auth/password verification: run inside Rust DO (higher CPU budget). + if (isAuthDoPath(url.pathname)) { + if (!env.HEAVY_DO) { + return new Response(JSON.stringify({ error: "HEAVY_DO binding not configured" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + let name = "auth:default"; + + if (url.pathname === "/identity/connect/token") { + // Shard login DO by username (email) to avoid serializing all logins onto one DO instance. + try { + const bodyText = await request.clone().text(); + const params = new URLSearchParams(bodyText); + const username = (params.get("username") || "default").toLowerCase(); + name = `auth:login:${username}`; + } catch { + // Fall back to default shard. + } + } else { + // Other auth endpoints are JWT-authenticated; shard by sub. + const token = getBearerToken(request); + const sub = token ? decodeJwtPayloadUnsafe(token)?.sub : null; + if (sub) name = `auth:user:${sub}`; + } + + const id = env.HEAVY_DO.idFromName(name); + const stub = env.HEAVY_DO.get(id); + return stub.fetch(request); + } + } + // Intercept PUT requests to azure-upload endpoint if (request.method === "PUT") { const parsed = parseAzureUploadPath(url.pathname); @@ -552,3 +661,7 @@ export default { }, }; +// Re-export Rust Durable Object class implemented in WASM. +// wrangler.toml binds HEAVY_DO -> class_name = "HeavyDo". +export { HeavyDo } from "../build/index.js"; + diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index 697e144..d20578a 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use worker::{query, D1PreparedStatement, Env}; -use super::get_batch_size; +use super::{get_batch_size, server_password_iterations}; use crate::{ auth::Claims, crypto::{generate_salt, hash_password_for_storage}, @@ -227,8 +227,13 @@ pub async fn register( // Generate salt and hash the password with server-side PBKDF2 let password_salt = generate_salt()?; - let hashed_password = - hash_password_for_storage(&payload.master_password_hash, &password_salt).await?; + let password_iterations = server_password_iterations(&env) as i32; + let hashed_password = hash_password_for_storage( + &payload.master_password_hash, + &password_salt, + password_iterations as u32, + ) + .await?; let db = db::get_db(&env)?; let now = Utc::now().to_rfc3339(); @@ -249,6 +254,7 @@ pub async fn register( master_password_hash: hashed_password, master_password_hint: payload.master_password_hint, password_salt: Some(password_salt), + password_iterations, key: payload.user_symmetric_key, private_key: payload.user_asymmetric_keys.encrypted_private_key, public_key: payload.user_asymmetric_keys.public_key, @@ -264,14 +270,15 @@ pub async fn register( query!( &db, - "INSERT INTO users (id, name, email, master_password_hash, master_password_hint, password_salt, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, totp_recover, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + "INSERT INTO users (id, name, email, master_password_hash, master_password_hint, password_salt, password_iterations, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, totp_recover, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", user.id, user.name, user.email, user.master_password_hash, user.master_password_hint, user.password_salt, + user.password_iterations, user.key, user.private_key, user.public_key, @@ -536,8 +543,13 @@ pub async fn post_password( // Generate new salt and hash the new password let new_salt = generate_salt()?; - let new_hashed_password = - hash_password_for_storage(&payload.new_master_password_hash, &new_salt).await?; + let password_iterations = server_password_iterations(&env) as i32; + let new_hashed_password = hash_password_for_storage( + &payload.new_master_password_hash, + &new_salt, + password_iterations as u32, + ) + .await?; // Generate new security stamp and update timestamp let new_security_stamp = Uuid::new_v4().to_string(); @@ -546,9 +558,10 @@ pub async fn post_password( // Update user record query!( &db, - "UPDATE users SET master_password_hash = ?1, password_salt = ?2, key = ?3, master_password_hint = ?4, security_stamp = ?5, updated_at = ?6 WHERE id = ?7", + "UPDATE users SET master_password_hash = ?1, password_salt = ?2, password_iterations = ?3, key = ?4, master_password_hint = ?5, security_stamp = ?6, updated_at = ?7 WHERE id = ?8", new_hashed_password, new_salt, + password_iterations, payload.key, payload.master_password_hint, new_security_stamp, @@ -770,8 +783,13 @@ pub async fn post_rotatekey( // Generate new salt and hash the new password let new_salt = generate_salt()?; - let new_hashed_password = - hash_password_for_storage(&unlock_data.master_key_authentication_hash, &new_salt).await?; + let password_iterations = server_password_iterations(&env) as i32; + let new_hashed_password = hash_password_for_storage( + &unlock_data.master_key_authentication_hash, + &new_salt, + password_iterations as u32, + ) + .await?; // Generate new security stamp let new_security_stamp = Uuid::new_v4().to_string(); @@ -786,9 +804,10 @@ pub async fn post_rotatekey( // Update user record with new keys and password query!( &db, - "UPDATE users SET master_password_hash = ?1, password_salt = ?2, key = ?3, private_key = ?4, kdf_type = ?5, kdf_iterations = ?6, kdf_memory = ?7, kdf_parallelism = ?8, security_stamp = ?9, updated_at = ?10 WHERE id = ?11", + "UPDATE users SET master_password_hash = ?1, password_salt = ?2, password_iterations = ?3, key = ?4, private_key = ?5, kdf_type = ?6, kdf_iterations = ?7, kdf_memory = ?8, kdf_parallelism = ?9, security_stamp = ?10, updated_at = ?11 WHERE id = ?12", new_hashed_password, new_salt, + password_iterations, unlock_data.master_key_encrypted_user_key, payload.account_keys.user_key_encrypted_account_private_key, unlock_data.kdf_type, @@ -876,8 +895,13 @@ pub async fn post_kdf( // Generate new salt and hash the new password let new_salt = generate_salt()?; - let new_hashed_password = - hash_password_for_storage(payload.get_new_password_hash(), &new_salt).await?; + let password_iterations = server_password_iterations(&env) as i32; + let new_hashed_password = hash_password_for_storage( + payload.get_new_password_hash(), + &new_salt, + password_iterations as u32, + ) + .await?; // Generate new security stamp let new_security_stamp = Uuid::new_v4().to_string(); @@ -897,9 +921,10 @@ pub async fn post_kdf( // Update user record with new KDF settings and password query!( &db, - "UPDATE users SET master_password_hash = ?1, password_salt = ?2, key = ?3, kdf_type = ?4, kdf_iterations = ?5, kdf_memory = ?6, kdf_parallelism = ?7, security_stamp = ?8, updated_at = ?9 WHERE id = ?10", + "UPDATE users SET master_password_hash = ?1, password_salt = ?2, password_iterations = ?3, key = ?4, kdf_type = ?5, kdf_iterations = ?6, kdf_memory = ?7, kdf_parallelism = ?8, security_stamp = ?9, updated_at = ?10 WHERE id = ?11", new_hashed_password, new_salt, + password_iterations, new_key, kdf_type, kdf_iterations, diff --git a/src/handlers/identity.rs b/src/handlers/identity.rs index a5cfa88..ca40910 100644 --- a/src/handlers/identity.rs +++ b/src/handlers/identity.rs @@ -11,7 +11,7 @@ use crate::{ crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp}, db, error::AppError, - handlers::allow_totp_drift, + handlers::{allow_totp_drift, server_password_iterations}, models::twofactor::{RememberTokenData, TwoFactor, TwoFactorType}, models::user::User, }; @@ -408,19 +408,29 @@ pub async fn token( } } - // Migrate legacy user to PBKDF2 if password matches and no salt exists - let user = if verification.needs_migration() { - // Generate new salt and hash the password + // Migrate/upgrade server-side password hashing parameters on successful verification. + // + // - Legacy users (no salt) are upgraded to server-side PBKDF2. + // - Existing users are upgraded if their per-user iteration count is below the configured minimum. + let desired_iterations = server_password_iterations(&env) as i32; + let needs_upgrade = + verification.needs_migration() || user.password_iterations < desired_iterations; + + let user = if needs_upgrade { + // Generate new salt and hash the password using the desired iterations. let new_salt = generate_salt()?; - let new_hash = hash_password_for_storage(&password_hash, &new_salt).await?; + let new_hash = + hash_password_for_storage(&password_hash, &new_salt, desired_iterations as u32) + .await?; let now = Utc::now().to_rfc3339(); // Update user in database query!( &db, - "UPDATE users SET master_password_hash = ?1, password_salt = ?2, updated_at = ?3 WHERE id = ?4", + "UPDATE users SET master_password_hash = ?1, password_salt = ?2, password_iterations = ?3, updated_at = ?4 WHERE id = ?5", &new_hash, &new_salt, + desired_iterations, &now, &user.id ) @@ -433,6 +443,7 @@ pub async fn token( User { master_password_hash: new_hash, password_salt: Some(new_salt), + password_iterations: desired_iterations, updated_at: now, ..user } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index c53e9c6..f5b4904 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -25,6 +25,42 @@ pub(crate) fn get_batch_size(env: &worker::Env) -> usize { get_env_usize(env, "IMPORT_BATCH_SIZE", 30) } +/// Per-user server-side PBKDF2 iterations (PASSWORD_ITERATIONS). +/// +/// - Defaults to `MIN_SERVER_PBKDF2_ITERATIONS` (600k). +/// - Can be increased via env var, but will never be allowed below the minimum. +pub(crate) fn server_password_iterations(env: &worker::Env) -> u32 { + let min = crate::crypto::MIN_SERVER_PBKDF2_ITERATIONS; + + match env.var("PASSWORD_ITERATIONS") { + Ok(v) => { + let raw = v.to_string(); + match raw.parse::() { + Ok(iter) if iter >= min => iter, + Ok(iter) => { + log::warn!( + "PASSWORD_ITERATIONS={} is below the minimum {}; clamping to {}", + iter, + min, + min + ); + min + } + Err(err) => { + log::warn!( + "Invalid PASSWORD_ITERATIONS='{}' ({}); using minimum {}", + raw, + err, + min + ); + min + } + } + } + Err(_) => min, + } +} + /// Whether TOTP validation should allow ±1 time step drift. /// Controlled via AUTHENTICATOR_DISABLE_TIME_DRIFT (truthy -> disable drift). pub(crate) fn allow_totp_drift(env: &worker::Env) -> bool { diff --git a/src/lib.rs b/src/lib.rs index 9ddf828..26d1311 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ use worker::*; mod auth; mod crypto; mod db; +mod durable; mod error; mod handlers; mod models; diff --git a/src/models/user.rs b/src/models/user.rs index 43550eb..ab2987c 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -14,6 +14,7 @@ pub struct User { pub master_password_hash: String, pub master_password_hint: Option, pub password_salt: Option, // Salt for server-side PBKDF2 (NULL for legacy users) + pub password_iterations: i32, // Server-side PBKDF2 iterations used for master_password_hash pub key: String, pub private_key: String, pub public_key: String, @@ -53,7 +54,13 @@ impl User { provided_hash: &str, ) -> Result { if let Some(ref salt) = self.password_salt { - let is_valid = verify_password(provided_hash, &self.master_password_hash, salt).await?; + let is_valid = verify_password( + provided_hash, + &self.master_password_hash, + salt, + self.password_iterations as u32, + ) + .await?; Ok(if is_valid { PasswordVerification::MatchCurrentScheme } else { diff --git a/wrangler.toml b/wrangler.toml index dabc84e..f335369 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -5,6 +5,20 @@ keep_vars = true workers_dev = false preview_urls = false +[durable_objects] +bindings = [ + # Offload CPU-heavy endpoints to Durable Objects + # (100,000 requests, 13,000 GB-s duration / day in free plan), + # while keeping the main Worker fast for the rest. + # + # - HEAVY_DO: Rust DO, reuses existing axum router for CPU-heavy endpoints (import/login/password verify). + { name = "HEAVY_DO", class_name = "HeavyDo" } +] + +[[migrations]] +tag = "do_v1" +new_sqlite_classes = ["HeavyDo"] + [build] command = "cargo install --locked -q worker-build --version 0.7.1 && worker-build --release --locked" @@ -29,6 +43,15 @@ html_handling = "auto-trailing-slash" run_worker_first = ["/api/*", "/identity/*"] [vars] +# Enable offloading CPU-heavy endpoints (login PBKDF2 + import JSON parse) to Durable Objects. +# Defaults to disabled. +HEAVY_DO_ENABLED = "1" + +# Server-side password hashing PBKDF2 iterations (stored per-user). +# Defaults to 600000, and will be clamped to a minimum of 600000 even if set lower. +# Existing users created before this change are assumed to be at 100000 and will be upgraded on login. +# PASSWORD_ITERATIONS = "600000" + # Optional: Set the batch size for imports. Defaults to 30 if not set. # Set to 0 means no batching (all records imported in a single batch). # IMPORT_BATCH_SIZE = "30" @@ -73,6 +96,14 @@ keep_vars = true workers_dev = false preview_urls = false +[env.dev.vars] +HEAVY_DO_ENABLED = "1" + +[env.dev.durable_objects] +bindings = [ + { name = "HEAVY_DO", class_name = "HeavyDo" } +] + # Dev environment also needs cron triggers [env.dev.triggers] crons = ["0 3 * * *"]