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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.0" version = "3.19.0"
@ -186,6 +195,25 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.5" version = "0.5.5"
@ -195,6 +223,17 @@ dependencies = [
"powerfmt", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@ -291,6 +330,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.16" version = "0.2.16"
@ -330,6 +379,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@ -625,6 +683,16 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 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]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@ -883,6 +951,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@ -925,6 +1004,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.111" version = "2.0.111"
@ -1085,6 +1170,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.22" version = "1.0.22"
@ -1164,9 +1255,11 @@ dependencies = [
"jsonwebtoken", "jsonwebtoken",
"log", "log",
"once_cell", "once_cell",
"pbkdf2",
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"thiserror 1.0.69", "thiserror 1.0.69",
"tower-http", "tower-http",
"tower-service", "tower-service",

View file

@ -21,7 +21,7 @@ worker-macros = { version = "0.7.1", features = ['http'] }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
js-sys = "0.3" js-sys = "0.3"
wasm-bindgen-futures = "0.4" 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" console_error_panic_hook = "0.1.7"
wasm-streams = "0.4" wasm-streams = "0.4"
@ -40,6 +40,8 @@ serde_json = "1.0"
jsonwebtoken = "9.2" jsonwebtoken = "9.2"
base64 = "0.21" base64 = "0.21"
base32 = "0.5" base32 = "0.5"
pbkdf2 = "0.12"
sha2 = "0.10"
hex = "0.4" hex = "0.4"
rand = { version = "0.8" } rand = { version = "0.8" }
getrandom = { version = "0.3", features = ["wasm_js"] } 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 ## 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: 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`): * **`TRASH_AUTO_DELETE_DAYS`** (Optional, Default: `30`):
- Days to keep soft-deleted items before purge. - Days to keep soft-deleted items before purge.
- Set to `0` or negative to disable. - 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_hash TEXT NOT NULL,
master_password_hint TEXT, master_password_hint TEXT,
password_salt TEXT, -- Salt for server-side PBKDF2 hashing (NULL for legacy users pending migration) 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 key TEXT NOT NULL, -- The encrypted symmetric key
private_key TEXT NOT NULL, -- encrypted asymmetric private_key private_key TEXT NOT NULL, -- encrypted asymmetric private_key
public_key TEXT NOT NULL, -- asymmetric public_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 base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use constant_time_eq::constant_time_eq; use constant_time_eq::constant_time_eq;
use js_sys::Uint8Array; use js_sys::Uint8Array;
use pbkdf2::pbkdf2_hmac;
use sha2::Sha256;
use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::{Crypto, CryptoKey, SubtleCrypto}; use web_sys::{Crypto, CryptoKey, SubtleCrypto};
@ -8,10 +10,16 @@ use worker::js_sys;
use crate::error::AppError; use crate::error::AppError;
/// Number of PBKDF2 iterations for server-side password hashing /// Minimum PBKDF2 iterations for server-side password hashing (new global minimum).
const SERVER_PBKDF2_ITERATIONS: u32 = 100_000; ///
/// Salt length in bytes /// NOTE: Cloudflare Workers native WebCrypto PBKDF2 currently rejects iteration counts > 100_000.
const SALT_LENGTH: usize = 16; /// 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 /// Derived key length in bits
const KEY_LENGTH_BITS: u32 = 256; const KEY_LENGTH_BITS: u32 = 256;
@ -32,61 +40,30 @@ fn subtle_crypto() -> Result<SubtleCrypto, AppError> {
Ok(get_crypto()?.subtle()) Ok(get_crypto()?.subtle())
} }
/// Derives a key using PBKDF2-SHA256. /// Derives a key using PBKDF2-HMAC-SHA256 (pure Rust).
pub async fn pbkdf2_sha256( pub fn pbkdf2_sha256(
password: &[u8], password: &[u8],
salt: &[u8], salt: &[u8],
iterations: u32, iterations: u32,
key_length_bits: u32, key_length_bits: u32,
) -> Result<Vec<u8>, AppError> { ) -> 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 dk_len = (key_length_bits / 8) as usize;
let password_array = Uint8Array::new_from_slice(password); let mut out = vec![0u8; dk_len];
let password_obj = password_array.as_ref(); pbkdf2_hmac::<Sha256>(password, salt, iterations, &mut out);
let key_material = JsFuture::from( Ok(out)
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())
} }
/// Generates a cryptographically secure random salt. /// Generates a cryptographically secure random salt.
pub fn generate_salt() -> Result<String, AppError> { pub fn generate_salt() -> Result<String, AppError> {
let crypto = get_crypto()?; 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 crypto
.get_random_values_with_array_buffer_view(&salt) .get_random_values_with_array_buffer_view(&salt)
.map_err(|e| AppError::Crypto(format!("Failed to generate random salt: {:?}", e)))?; .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( pub async fn hash_password_for_storage(
client_password_hash: &str, client_password_hash: &str,
salt: &str, salt: &str,
iterations: u32,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let salt_bytes = BASE64 let salt_bytes = BASE64
.decode(salt) .decode(salt)
@ -107,10 +85,9 @@ pub async fn hash_password_for_storage(
let derived = pbkdf2_sha256( let derived = pbkdf2_sha256(
client_password_hash.as_bytes(), client_password_hash.as_bytes(),
&salt_bytes, &salt_bytes,
SERVER_PBKDF2_ITERATIONS, iterations,
KEY_LENGTH_BITS, KEY_LENGTH_BITS,
) )?;
.await?;
Ok(BASE64.encode(derived)) Ok(BASE64.encode(derived))
} }
@ -121,8 +98,9 @@ pub async fn verify_password(
client_password_hash: &str, client_password_hash: &str,
stored_hash: &str, stored_hash: &str,
salt: &str, salt: &str,
iterations: u32,
) -> Result<bool, AppError> { ) -> 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( Ok(constant_time_eq(
computed_hash.as_bytes(), computed_hash.as_bytes(),
stored_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; 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 // Parse azure-upload route: /api/ciphers/{id}/attachment/{attachment_id}/azure-upload
function parseAzureUploadPath(path) { function parseAzureUploadPath(path) {
const parts = path.replace(/^\//, "").split("/"); const parts = path.replace(/^\//, "").split("/");
@ -502,6 +556,61 @@ export default {
async fetch(request, env, ctx) { async fetch(request, env, ctx) {
const url = new URL(request.url); 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 // Intercept PUT requests to azure-upload endpoint
if (request.method === "PUT") { if (request.method === "PUT") {
const parsed = parseAzureUploadPath(url.pathname); 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 uuid::Uuid;
use worker::{query, D1PreparedStatement, Env}; use worker::{query, D1PreparedStatement, Env};
use super::get_batch_size; use super::{get_batch_size, server_password_iterations};
use crate::{ use crate::{
auth::Claims, auth::Claims,
crypto::{generate_salt, hash_password_for_storage}, 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 // Generate salt and hash the password with server-side PBKDF2
let password_salt = generate_salt()?; let password_salt = generate_salt()?;
let hashed_password = let password_iterations = server_password_iterations(&env) as i32;
hash_password_for_storage(&payload.master_password_hash, &password_salt).await?; 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 db = db::get_db(&env)?;
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
@ -249,6 +254,7 @@ pub async fn register(
master_password_hash: hashed_password, master_password_hash: hashed_password,
master_password_hint: payload.master_password_hint, master_password_hint: payload.master_password_hint,
password_salt: Some(password_salt), password_salt: Some(password_salt),
password_iterations,
key: payload.user_symmetric_key, key: payload.user_symmetric_key,
private_key: payload.user_asymmetric_keys.encrypted_private_key, private_key: payload.user_asymmetric_keys.encrypted_private_key,
public_key: payload.user_asymmetric_keys.public_key, public_key: payload.user_asymmetric_keys.public_key,
@ -264,14 +270,15 @@ pub async fn register(
query!( query!(
&db, &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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
user.id, user.id,
user.name, user.name,
user.email, user.email,
user.master_password_hash, user.master_password_hash,
user.master_password_hint, user.master_password_hint,
user.password_salt, user.password_salt,
user.password_iterations,
user.key, user.key,
user.private_key, user.private_key,
user.public_key, user.public_key,
@ -536,8 +543,13 @@ pub async fn post_password(
// Generate new salt and hash the new password // Generate new salt and hash the new password
let new_salt = generate_salt()?; let new_salt = generate_salt()?;
let new_hashed_password = let password_iterations = server_password_iterations(&env) as i32;
hash_password_for_storage(&payload.new_master_password_hash, &new_salt).await?; 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 // Generate new security stamp and update timestamp
let new_security_stamp = Uuid::new_v4().to_string(); let new_security_stamp = Uuid::new_v4().to_string();
@ -546,9 +558,10 @@ pub async fn post_password(
// Update user record // Update user record
query!( query!(
&db, &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_hashed_password,
new_salt, new_salt,
password_iterations,
payload.key, payload.key,
payload.master_password_hint, payload.master_password_hint,
new_security_stamp, new_security_stamp,
@ -770,8 +783,13 @@ pub async fn post_rotatekey(
// Generate new salt and hash the new password // Generate new salt and hash the new password
let new_salt = generate_salt()?; let new_salt = generate_salt()?;
let new_hashed_password = let password_iterations = server_password_iterations(&env) as i32;
hash_password_for_storage(&unlock_data.master_key_authentication_hash, &new_salt).await?; 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 // Generate new security stamp
let new_security_stamp = Uuid::new_v4().to_string(); 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 // Update user record with new keys and password
query!( query!(
&db, &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_hashed_password,
new_salt, new_salt,
password_iterations,
unlock_data.master_key_encrypted_user_key, unlock_data.master_key_encrypted_user_key,
payload.account_keys.user_key_encrypted_account_private_key, payload.account_keys.user_key_encrypted_account_private_key,
unlock_data.kdf_type, unlock_data.kdf_type,
@ -876,8 +895,13 @@ pub async fn post_kdf(
// Generate new salt and hash the new password // Generate new salt and hash the new password
let new_salt = generate_salt()?; let new_salt = generate_salt()?;
let new_hashed_password = let password_iterations = server_password_iterations(&env) as i32;
hash_password_for_storage(payload.get_new_password_hash(), &new_salt).await?; let new_hashed_password = hash_password_for_storage(
payload.get_new_password_hash(),
&new_salt,
password_iterations as u32,
)
.await?;
// Generate new security stamp // Generate new security stamp
let new_security_stamp = Uuid::new_v4().to_string(); 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 // Update user record with new KDF settings and password
query!( query!(
&db, &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_hashed_password,
new_salt, new_salt,
password_iterations,
new_key, new_key,
kdf_type, kdf_type,
kdf_iterations, kdf_iterations,

View file

@ -11,7 +11,7 @@ use crate::{
crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp}, crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp},
db, db,
error::AppError, error::AppError,
handlers::allow_totp_drift, handlers::{allow_totp_drift, server_password_iterations},
models::twofactor::{RememberTokenData, TwoFactor, TwoFactorType}, models::twofactor::{RememberTokenData, TwoFactor, TwoFactorType},
models::user::User, models::user::User,
}; };
@ -408,19 +408,29 @@ pub async fn token(
} }
} }
// Migrate legacy user to PBKDF2 if password matches and no salt exists // Migrate/upgrade server-side password hashing parameters on successful verification.
let user = if verification.needs_migration() { //
// Generate new salt and hash the password // - 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_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(); let now = Utc::now().to_rfc3339();
// Update user in database // Update user in database
query!( query!(
&db, &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_hash,
&new_salt, &new_salt,
desired_iterations,
&now, &now,
&user.id &user.id
) )
@ -433,6 +443,7 @@ pub async fn token(
User { User {
master_password_hash: new_hash, master_password_hash: new_hash,
password_salt: Some(new_salt), password_salt: Some(new_salt),
password_iterations: desired_iterations,
updated_at: now, updated_at: now,
..user ..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) 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. /// Whether TOTP validation should allow ±1 time step drift.
/// Controlled via AUTHENTICATOR_DISABLE_TIME_DRIFT (truthy -> disable drift). /// Controlled via AUTHENTICATOR_DISABLE_TIME_DRIFT (truthy -> disable drift).
pub(crate) fn allow_totp_drift(env: &worker::Env) -> bool { pub(crate) fn allow_totp_drift(env: &worker::Env) -> bool {

View file

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

View file

@ -14,6 +14,7 @@ pub struct User {
pub master_password_hash: String, pub master_password_hash: String,
pub master_password_hint: Option<String>, pub master_password_hint: Option<String>,
pub password_salt: Option<String>, // Salt for server-side PBKDF2 (NULL for legacy users) 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 key: String,
pub private_key: String, pub private_key: String,
pub public_key: String, pub public_key: String,
@ -53,7 +54,13 @@ impl User {
provided_hash: &str, provided_hash: &str,
) -> Result<PasswordVerification, AppError> { ) -> Result<PasswordVerification, AppError> {
if let Some(ref salt) = self.password_salt { 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 { Ok(if is_valid {
PasswordVerification::MatchCurrentScheme PasswordVerification::MatchCurrentScheme
} else { } else {

View file

@ -5,6 +5,20 @@ keep_vars = true
workers_dev = false workers_dev = false
preview_urls = 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] [build]
command = "cargo install --locked -q worker-build --version 0.7.1 && worker-build --release --locked" 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/*"] run_worker_first = ["/api/*", "/identity/*"]
[vars] [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. # 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). # Set to 0 means no batching (all records imported in a single batch).
# IMPORT_BATCH_SIZE = "30" # IMPORT_BATCH_SIZE = "30"
@ -73,6 +96,14 @@ keep_vars = true
workers_dev = false workers_dev = false
preview_urls = 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 # Dev environment also needs cron triggers
[env.dev.triggers] [env.dev.triggers]
crons = ["0 3 * * *"] crons = ["0 3 * * *"]