refactor: offload cpu heavy endpoint to durable object and enhance password hashing with per-user iterations

This commit is contained in:
qaz741wsd856 2025-12-26 15:22:50 +00:00
parent 3c5e22a55b
commit 84067f41f0
15 changed files with 476 additions and 74 deletions

93
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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 500kB1MB) + 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.

View file

@ -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;

View file

@ -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

View file

@ -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<SubtleCrypto, AppError> {
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<Vec<u8>, 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::<Sha256>(password, salt, iterations, &mut out);
Ok(out)
}
/// Generates a cryptographically secure random salt.
pub fn generate_salt() -> Result<String, AppError> {
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<String, AppError> {
pub async fn hash_password_for_storage(
client_password_hash: &str,
salt: &str,
iterations: u32,
) -> Result<String, AppError> {
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<bool, AppError> {
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(),

62
src/durable/heavy_do.rs Normal file
View file

@ -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<Response> {
// 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()
}
}

1
src/durable/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod heavy_do;

View file

@ -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";

View file

@ -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,

View file

@ -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
}

View file

@ -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::<u32>() {
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 {

View file

@ -8,6 +8,7 @@ use worker::*;
mod auth;
mod crypto;
mod db;
mod durable;
mod error;
mod handlers;
mod models;

View file

@ -14,6 +14,7 @@ pub struct User {
pub master_password_hash: String,
pub master_password_hint: Option<String>,
pub password_salt: Option<String>, // 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<PasswordVerification, AppError> {
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 {

View file

@ -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 * * *"]