refactor: offload cpu heavy endpoint to durable object and enhance password hashing with per-user iterations
This commit is contained in:
parent
3c5e22a55b
commit
84067f41f0
15 changed files with 476 additions and 74 deletions
93
Cargo.lock
generated
93
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
27
README.md
27
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.
|
||||
|
|
|
|||
14
migrations/0007_add_password_iterations.sql
Normal file
14
migrations/0007_add_password_iterations.sql
Normal 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;
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
62
src/durable/heavy_do.rs
Normal 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
1
src/durable/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod heavy_do;
|
||||
113
src/entry.js
113
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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use worker::*;
|
|||
mod auth;
|
||||
mod crypto;
|
||||
mod db;
|
||||
mod durable;
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod models;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 * * *"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue