feat: implement Argon2 KDF support in user authentication and registration

- Added new fields for Argon2 parameters (kdf_memory, kdf_parallelism) in the users table and related SQL migration.
- Updated API routes and handlers to support changing KDF settings.
- Enhanced KDF validation logic to accommodate both PBKDF2 and Argon2.
- Modified user registration and prelogin responses to include Argon2 parameters.
- Updated relevant data structures to reflect new KDF settings.
This commit is contained in:
qaz741wsd856 2025-12-04 14:44:40 +00:00
parent aab428a6d2
commit b519226cd6
6 changed files with 263 additions and 27 deletions

View file

@ -0,0 +1,16 @@
-- Migration: Add Argon2 KDF fields to users table
-- These columns store the client-side Argon2 KDF parameters:
-- - kdf_memory: Memory parameter in MB (15-1024)
-- - kdf_parallelism: Parallelism parameter (1-16)
--
-- These are client-side encryption settings only.
-- The server does not execute Argon2 - it only stores these values
-- and returns them to clients during prelogin.
--
-- Note: This migration is applied via GitHub Actions which handles
-- the "duplicate column" error gracefully for existing databases.
ALTER TABLE users ADD COLUMN kdf_memory INTEGER;
ALTER TABLE users ADD COLUMN kdf_parallelism INTEGER;

View file

@ -10,8 +10,10 @@ CREATE TABLE IF NOT EXISTS users (
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
kdf_type INTEGER NOT NULL DEFAULT 0, -- 0 for PBKDF2 kdf_type INTEGER NOT NULL DEFAULT 0, -- 0 for PBKDF2, 1 for Argon2id
kdf_iterations INTEGER NOT NULL DEFAULT 600000, kdf_iterations INTEGER NOT NULL DEFAULT 600000,
kdf_memory INTEGER, -- Argon2 memory parameter in MB (15-1024), NULL for PBKDF2
kdf_parallelism INTEGER, -- Argon2 parallelism parameter (1-16), NULL for PBKDF2
security_stamp TEXT, security_stamp TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL

View file

@ -16,28 +16,70 @@ use crate::{
cipher::CipherData, cipher::CipherData,
sync::Profile, sync::Profile,
user::{ user::{
ChangePasswordRequest, DeleteAccountRequest, PreloginResponse, RegisterRequest, ChangeKdfRequest, ChangePasswordRequest, DeleteAccountRequest, PreloginResponse,
RotateKeyRequest, User, RegisterRequest, RotateKeyRequest, User,
}, },
}, },
}; };
const SUPPORTED_KDF_TYPE: i32 = 0; // PBKDF2 const KDF_TYPE_PBKDF2: i32 = 0;
const KDF_TYPE_ARGON2ID: i32 = 1;
const MIN_PBKDF2_ITERATIONS: i32 = 100_000; const MIN_PBKDF2_ITERATIONS: i32 = 100_000;
const DEFAULT_PBKDF2_ITERATIONS: i32 = 600_000; const DEFAULT_PBKDF2_ITERATIONS: i32 = 600_000;
fn ensure_supported_kdf(kdf_type: i32, iterations: i32) -> Result<(), AppError> { fn ensure_supported_kdf(
if kdf_type != SUPPORTED_KDF_TYPE { kdf_type: i32,
return Err(AppError::BadRequest( iterations: i32,
"Only the PBKDF2 key derivation function is supported".to_string(), memory: Option<i32>,
)); parallelism: Option<i32>,
} ) -> Result<(), AppError> {
match kdf_type {
if iterations < MIN_PBKDF2_ITERATIONS { KDF_TYPE_PBKDF2 => {
return Err(AppError::BadRequest(format!( if iterations < MIN_PBKDF2_ITERATIONS {
"PBKDF2 iterations must be at least {}", return Err(AppError::BadRequest(format!(
MIN_PBKDF2_ITERATIONS "PBKDF2 iterations must be at least {}",
))); MIN_PBKDF2_ITERATIONS
)));
}
}
KDF_TYPE_ARGON2ID => {
if iterations < 1 {
return Err(AppError::BadRequest(
"Argon2 KDF iterations must be at least 1".to_string(),
));
}
match memory {
Some(m) if (15..=1024).contains(&m) => {}
Some(_) => {
return Err(AppError::BadRequest(
"Argon2 memory must be between 15 MB and 1024 MB".to_string(),
));
}
None => {
return Err(AppError::BadRequest(
"Argon2 memory parameter is required".to_string(),
));
}
}
match parallelism {
Some(p) if (1..=16).contains(&p) => {}
Some(_) => {
return Err(AppError::BadRequest(
"Argon2 parallelism must be between 1 and 16".to_string(),
));
}
None => {
return Err(AppError::BadRequest(
"Argon2 parallelism parameter is required".to_string(),
));
}
}
}
_ => {
return Err(AppError::BadRequest(
"Unsupported KDF type. Only PBKDF2 (0) and Argon2id (1) are supported".to_string(),
));
}
} }
Ok(()) Ok(())
@ -53,11 +95,11 @@ pub async fn prelogin(
.ok_or_else(|| AppError::BadRequest("Missing email".to_string()))?; .ok_or_else(|| AppError::BadRequest("Missing email".to_string()))?;
let db = db::get_db(&env)?; let db = db::get_db(&env)?;
let stmt = db.prepare("SELECT kdf_type, kdf_iterations FROM users WHERE email = ?1"); let stmt = db.prepare("SELECT kdf_type, kdf_iterations, kdf_memory, kdf_parallelism FROM users WHERE email = ?1");
let query = stmt.bind(&[email.into()])?; let query = stmt.bind(&[email.into()])?;
let row: Option<Value> = query.first(None).await.map_err(|_| AppError::Database)?; let row: Option<Value> = query.first(None).await.map_err(|_| AppError::Database)?;
let (kdf_type, kdf_iterations) = if let Some(row) = row { let (kdf_type, kdf_iterations, kdf_memory, kdf_parallelism) = if let Some(row) = row {
let kdf_type = row let kdf_type = row
.get("kdf_type") .get("kdf_type")
.and_then(|value| value.as_i64()) .and_then(|value| value.as_i64())
@ -66,14 +108,24 @@ pub async fn prelogin(
.get("kdf_iterations") .get("kdf_iterations")
.and_then(|value| value.as_i64()) .and_then(|value| value.as_i64())
.map(|value| value as i32); .map(|value| value as i32);
(kdf_type, kdf_iterations) let kdf_memory = row
.get("kdf_memory")
.and_then(|value| value.as_i64())
.map(|value| value as i32);
let kdf_parallelism = row
.get("kdf_parallelism")
.and_then(|value| value.as_i64())
.map(|value| value as i32);
(kdf_type, kdf_iterations, kdf_memory, kdf_parallelism)
} else { } else {
(None, None) (None, None, None, None)
}; };
Ok(Json(PreloginResponse { Ok(Json(PreloginResponse {
kdf: kdf_type.unwrap_or(SUPPORTED_KDF_TYPE), kdf: kdf_type.unwrap_or(KDF_TYPE_PBKDF2),
kdf_iterations: kdf_iterations.unwrap_or(DEFAULT_PBKDF2_ITERATIONS), kdf_iterations: kdf_iterations.unwrap_or(DEFAULT_PBKDF2_ITERATIONS),
kdf_memory,
kdf_parallelism,
})) }))
} }
@ -96,7 +148,12 @@ pub async fn register(
return Err(AppError::Unauthorized("Not allowed to signup".to_string())); return Err(AppError::Unauthorized("Not allowed to signup".to_string()));
} }
ensure_supported_kdf(payload.kdf, payload.kdf_iterations)?; ensure_supported_kdf(
payload.kdf,
payload.kdf_iterations,
payload.kdf_memory,
payload.kdf_parallelism,
)?;
// 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()?;
@ -118,6 +175,8 @@ pub async fn register(
public_key: payload.user_asymmetric_keys.public_key, public_key: payload.user_asymmetric_keys.public_key,
kdf_type: payload.kdf, kdf_type: payload.kdf,
kdf_iterations: payload.kdf_iterations, kdf_iterations: payload.kdf_iterations,
kdf_memory: payload.kdf_memory,
kdf_parallelism: payload.kdf_parallelism,
security_stamp: Uuid::new_v4().to_string(), security_stamp: Uuid::new_v4().to_string(),
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
@ -125,8 +184,8 @@ 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, security_stamp, created_at, updated_at) "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, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
user.id, user.id,
user.name, user.name,
user.email, user.email,
@ -138,6 +197,8 @@ pub async fn register(
user.public_key, user.public_key,
user.kdf_type, user.kdf_type,
user.kdf_iterations, user.kdf_iterations,
user.kdf_memory,
user.kdf_parallelism,
user.security_stamp, user.security_stamp,
user.created_at, user.created_at,
user.updated_at user.updated_at
@ -349,8 +410,13 @@ pub async fn post_rotatekey(
)); ));
} }
// Only PBKDF2 is supported since Argon2-specific columns are not present in our schema. // Validate KDF parameters
ensure_supported_kdf(unlock_data.kdf_type, unlock_data.kdf_iterations)?; ensure_supported_kdf(
unlock_data.kdf_type,
unlock_data.kdf_iterations,
unlock_data.kdf_memory,
unlock_data.kdf_parallelism,
)?;
// Validate data integrity using D1 batch operations // Validate data integrity using D1 batch operations
// Step 1: Ensure all personal ciphers have id (required for key rotation) // Step 1: Ensure all personal ciphers have id (required for key rotation)
@ -513,13 +579,111 @@ 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, security_stamp = ?7, updated_at = ?8 WHERE id = ?9", "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",
new_hashed_password, new_hashed_password,
new_salt, new_salt,
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,
unlock_data.kdf_iterations, unlock_data.kdf_iterations,
unlock_data.kdf_memory,
unlock_data.kdf_parallelism,
new_security_stamp,
now,
user_id
)
.map_err(|_| AppError::Database)?
.run()
.await?;
Ok(Json(json!({})))
}
/// POST /accounts/kdf - Change KDF settings (PBKDF2 <-> Argon2id)
#[worker::send]
pub async fn post_kdf(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<ChangeKdfRequest>,
) -> Result<Json<Value>, AppError> {
let db = db::get_db(&env)?;
let user_id = &claims.sub;
// Get the user from the database
let user: Value = db
.prepare("SELECT * FROM users WHERE id = ?1")
.bind(&[user_id.clone().into()])?
.first(None)
.await
.map_err(|_| AppError::Database)?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?;
// Verify the current master password
let verification = user
.verify_master_password(&payload.master_password_hash)
.await?;
if !verification.is_valid() {
return Err(AppError::Unauthorized("Invalid password".to_string()));
}
// Validate that KDF settings match between authentication and unlock data
if payload.authentication_data.kdf != payload.unlock_data.kdf {
return Err(AppError::BadRequest(
"KDF settings must be equal for authentication and unlock".to_string(),
));
}
// Validate that salt (email) matches
if user.email != payload.authentication_data.salt
|| user.email != payload.unlock_data.salt
{
return Err(AppError::BadRequest(
"Invalid master password salt".to_string(),
));
}
// Validate new KDF parameters
let kdf = &payload.unlock_data.kdf;
ensure_supported_kdf(
kdf.kdf,
kdf.kdf_iterations,
kdf.kdf_memory,
kdf.kdf_parallelism,
)?;
// Generate new salt and hash the new password
let new_salt = generate_salt()?;
let new_hashed_password = hash_password_for_storage(
&payload.authentication_data.master_password_authentication_hash,
&new_salt,
)
.await?;
// Generate new security stamp
let new_security_stamp = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
// Determine kdf_memory and kdf_parallelism based on KDF type
let (kdf_memory, kdf_parallelism) = if kdf.kdf == KDF_TYPE_ARGON2ID {
(kdf.kdf_memory, kdf.kdf_parallelism)
} else {
// For PBKDF2, clear these fields
(None, None)
};
// 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",
new_hashed_password,
new_salt,
payload.unlock_data.master_key_wrapped_user_key,
kdf.kdf,
kdf.kdf_iterations,
kdf_memory,
kdf_parallelism,
new_security_stamp, new_security_stamp,
now, now,
user_id user_id

View file

@ -41,6 +41,10 @@ pub struct TokenResponse {
kdf: i32, kdf: i32,
#[serde(rename = "KdfIterations")] #[serde(rename = "KdfIterations")]
kdf_iterations: i32, kdf_iterations: i32,
#[serde(rename = "KdfMemory", skip_serializing_if = "Option::is_none")]
kdf_memory: Option<i32>,
#[serde(rename = "KdfParallelism", skip_serializing_if = "Option::is_none")]
kdf_parallelism: Option<i32>,
#[serde(rename = "ResetMasterPassword")] #[serde(rename = "ResetMasterPassword")]
reset_master_password: bool, reset_master_password: bool,
#[serde(rename = "ForcePasswordReset")] #[serde(rename = "ForcePasswordReset")]
@ -110,6 +114,8 @@ fn generate_tokens_and_response(
private_key: user.private_key, private_key: user.private_key,
kdf: user.kdf_type, kdf: user.kdf_type,
kdf_iterations: user.kdf_iterations, kdf_iterations: user.kdf_iterations,
kdf_memory: user.kdf_memory,
kdf_parallelism: user.kdf_parallelism,
force_password_reset: false, force_password_reset: false,
reset_master_password: false, reset_master_password: false,
user_decryption_options: UserDecryptionOptions { user_decryption_options: UserDecryptionOptions {

View file

@ -18,6 +18,8 @@ pub struct User {
pub public_key: String, pub public_key: String,
pub kdf_type: i32, pub kdf_type: i32,
pub kdf_iterations: i32, pub kdf_iterations: i32,
pub kdf_memory: Option<i32>, // Argon2 memory parameter (15-1024 MB)
pub kdf_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16)
pub security_stamp: String, pub security_stamp: String,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
@ -103,6 +105,10 @@ mod bool_from_int {
pub struct PreloginResponse { pub struct PreloginResponse {
pub kdf: i32, pub kdf: i32,
pub kdf_iterations: i32, pub kdf_iterations: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub kdf_memory: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kdf_parallelism: Option<i32>,
} }
// For /accounts/register request // For /accounts/register request
@ -117,6 +123,8 @@ pub struct RegisterRequest {
pub user_asymmetric_keys: KeyData, pub user_asymmetric_keys: KeyData,
pub kdf: i32, pub kdf: i32,
pub kdf_iterations: i32, pub kdf_iterations: i32,
pub kdf_memory: Option<i32>, // Argon2 memory parameter (15-1024 MB)
pub kdf_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16)
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -145,6 +153,44 @@ pub struct ChangePasswordRequest {
pub key: String, pub key: String,
} }
// For POST /accounts/kdf request - Change KDF settings
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KdfData {
#[serde(alias = "kdfType")]
pub kdf: i32,
#[serde(alias = "iterations")]
pub kdf_iterations: i32,
#[serde(alias = "memory")]
pub kdf_memory: Option<i32>,
#[serde(alias = "parallelism")]
pub kdf_parallelism: Option<i32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticationData {
pub salt: String,
pub kdf: KdfData,
pub master_password_authentication_hash: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnlockData {
pub salt: String,
pub kdf: KdfData,
pub master_key_wrapped_user_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangeKdfRequest {
pub master_password_hash: String,
pub authentication_data: AuthenticationData,
pub unlock_data: UnlockData,
}
// For POST /accounts/key-management/rotate-user-account-keys request // For POST /accounts/key-management/rotate-user-account-keys request
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View file

@ -30,6 +30,8 @@ pub fn api_router(env: Env) -> Router {
// Delete account // Delete account
.route("/api/accounts", delete(accounts::delete_account)) .route("/api/accounts", delete(accounts::delete_account))
.route("/api/accounts/delete", post(accounts::delete_account)) .route("/api/accounts/delete", post(accounts::delete_account))
// Set KDF
.route("/api/accounts/kdf", post(accounts::post_kdf))
// Change password // Change password
.route("/api/accounts/password", post(accounts::post_password)) .route("/api/accounts/password", post(accounts::post_password))
// Rotate encryption keys // Rotate encryption keys