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:
parent
aab428a6d2
commit
b519226cd6
6 changed files with 263 additions and 27 deletions
16
migrations/0002_add_argon2_fields.sql
Normal file
16
migrations/0002_add_argon2_fields.sql
Normal 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;
|
||||
|
||||
|
|
@ -10,8 +10,10 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
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
|
||||
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_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,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
|
|
|
|||
|
|
@ -16,28 +16,70 @@ use crate::{
|
|||
cipher::CipherData,
|
||||
sync::Profile,
|
||||
user::{
|
||||
ChangePasswordRequest, DeleteAccountRequest, PreloginResponse, RegisterRequest,
|
||||
RotateKeyRequest, User,
|
||||
ChangeKdfRequest, ChangePasswordRequest, DeleteAccountRequest, PreloginResponse,
|
||||
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 DEFAULT_PBKDF2_ITERATIONS: i32 = 600_000;
|
||||
|
||||
fn ensure_supported_kdf(kdf_type: i32, iterations: i32) -> Result<(), AppError> {
|
||||
if kdf_type != SUPPORTED_KDF_TYPE {
|
||||
return Err(AppError::BadRequest(
|
||||
"Only the PBKDF2 key derivation function is supported".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if iterations < MIN_PBKDF2_ITERATIONS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"PBKDF2 iterations must be at least {}",
|
||||
MIN_PBKDF2_ITERATIONS
|
||||
)));
|
||||
fn ensure_supported_kdf(
|
||||
kdf_type: i32,
|
||||
iterations: i32,
|
||||
memory: Option<i32>,
|
||||
parallelism: Option<i32>,
|
||||
) -> Result<(), AppError> {
|
||||
match kdf_type {
|
||||
KDF_TYPE_PBKDF2 => {
|
||||
if iterations < MIN_PBKDF2_ITERATIONS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"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(())
|
||||
|
|
@ -53,11 +95,11 @@ pub async fn prelogin(
|
|||
.ok_or_else(|| AppError::BadRequest("Missing email".to_string()))?;
|
||||
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 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
|
||||
.get("kdf_type")
|
||||
.and_then(|value| value.as_i64())
|
||||
|
|
@ -66,14 +108,24 @@ pub async fn prelogin(
|
|||
.get("kdf_iterations")
|
||||
.and_then(|value| value.as_i64())
|
||||
.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 {
|
||||
(None, None)
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
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_memory,
|
||||
kdf_parallelism,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +148,12 @@ pub async fn register(
|
|||
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
|
||||
let password_salt = generate_salt()?;
|
||||
|
|
@ -118,6 +175,8 @@ pub async fn register(
|
|||
public_key: payload.user_asymmetric_keys.public_key,
|
||||
kdf_type: payload.kdf,
|
||||
kdf_iterations: payload.kdf_iterations,
|
||||
kdf_memory: payload.kdf_memory,
|
||||
kdf_parallelism: payload.kdf_parallelism,
|
||||
security_stamp: Uuid::new_v4().to_string(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
|
|
@ -125,8 +184,8 @@ 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, security_stamp, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
"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, ?15, ?16)",
|
||||
user.id,
|
||||
user.name,
|
||||
user.email,
|
||||
|
|
@ -138,6 +197,8 @@ pub async fn register(
|
|||
user.public_key,
|
||||
user.kdf_type,
|
||||
user.kdf_iterations,
|
||||
user.kdf_memory,
|
||||
user.kdf_parallelism,
|
||||
user.security_stamp,
|
||||
user.created_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.
|
||||
ensure_supported_kdf(unlock_data.kdf_type, unlock_data.kdf_iterations)?;
|
||||
// Validate KDF parameters
|
||||
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
|
||||
// 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
|
||||
query!(
|
||||
&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_salt,
|
||||
unlock_data.master_key_encrypted_user_key,
|
||||
payload.account_keys.user_key_encrypted_account_private_key,
|
||||
unlock_data.kdf_type,
|
||||
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,
|
||||
now,
|
||||
user_id
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ pub struct TokenResponse {
|
|||
kdf: i32,
|
||||
#[serde(rename = "KdfIterations")]
|
||||
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")]
|
||||
reset_master_password: bool,
|
||||
#[serde(rename = "ForcePasswordReset")]
|
||||
|
|
@ -110,6 +114,8 @@ fn generate_tokens_and_response(
|
|||
private_key: user.private_key,
|
||||
kdf: user.kdf_type,
|
||||
kdf_iterations: user.kdf_iterations,
|
||||
kdf_memory: user.kdf_memory,
|
||||
kdf_parallelism: user.kdf_parallelism,
|
||||
force_password_reset: false,
|
||||
reset_master_password: false,
|
||||
user_decryption_options: UserDecryptionOptions {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ pub struct User {
|
|||
pub public_key: String,
|
||||
pub kdf_type: 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 created_at: String,
|
||||
pub updated_at: String,
|
||||
|
|
@ -103,6 +105,10 @@ mod bool_from_int {
|
|||
pub struct PreloginResponse {
|
||||
pub kdf: 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
|
||||
|
|
@ -117,6 +123,8 @@ pub struct RegisterRequest {
|
|||
pub user_asymmetric_keys: KeyData,
|
||||
pub kdf: 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)]
|
||||
|
|
@ -145,6 +153,44 @@ pub struct ChangePasswordRequest {
|
|||
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
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub fn api_router(env: Env) -> Router {
|
|||
// Delete account
|
||||
.route("/api/accounts", delete(accounts::delete_account))
|
||||
.route("/api/accounts/delete", post(accounts::delete_account))
|
||||
// Set KDF
|
||||
.route("/api/accounts/kdf", post(accounts::post_kdf))
|
||||
// Change password
|
||||
.route("/api/accounts/password", post(accounts::post_password))
|
||||
// Rotate encryption keys
|
||||
|
|
|
|||
Loading…
Reference in a new issue