diff --git a/util/RustSdk/RustSdkService.cs b/util/RustSdk/RustSdkService.cs
index 790b803470a6..598170c677e9 100644
--- a/util/RustSdk/RustSdkService.cs
+++ b/util/RustSdk/RustSdkService.cs
@@ -37,7 +37,7 @@ public class RustSdkService
PropertyNameCaseInsensitive = true
};
- public static unsafe UserKeys GenerateUserKeys(string email, string password)
+ public static unsafe UserKeys GenerateUserKeys(string email, string password, int kdfIterations = 5_000)
{
var emailBytes = StringToRustString(email);
var passwordBytes = StringToRustString(password);
@@ -45,7 +45,7 @@ public static unsafe UserKeys GenerateUserKeys(string email, string password)
fixed (byte* emailPtr = emailBytes)
fixed (byte* passwordPtr = passwordBytes)
{
- var resultPtr = NativeMethods.generate_user_keys(emailPtr, passwordPtr);
+ var resultPtr = NativeMethods.generate_user_keys(emailPtr, passwordPtr, (uint)kdfIterations);
var result = ParseResponse(resultPtr);
diff --git a/util/RustSdk/rust/src/lib.rs b/util/RustSdk/rust/src/lib.rs
index 9e1d3e9813b7..0b009ff2ecdc 100644
--- a/util/RustSdk/rust/src/lib.rs
+++ b/util/RustSdk/rust/src/lib.rs
@@ -18,14 +18,18 @@ use bitwarden_crypto::{
pub unsafe extern "C" fn generate_user_keys(
email: *const c_char,
password: *const c_char,
+ kdf_iterations: u32,
) -> *const c_char {
let email = CStr::from_ptr(email).to_str().unwrap();
let password = CStr::from_ptr(password).to_str().unwrap();
- let kdf = Kdf::PBKDF2 {
- iterations: NonZeroU32::new(5_000).unwrap(),
+ let iterations = match NonZeroU32::new(kdf_iterations) {
+ Some(iter) => iter,
+ None => return error_response("kdf_iterations must be non-zero"),
};
+ let kdf = Kdf::PBKDF2 { iterations };
+
let master_key = MasterKey::derive(password, email, &kdf).unwrap();
let master_password_hash =
@@ -49,6 +53,11 @@ pub unsafe extern "C" fn generate_user_keys(
result.into_raw()
}
+fn error_response(message: &str) -> *const c_char {
+ let json = serde_json::json!({ "error": message }).to_string();
+ CString::new(json).unwrap().into_raw()
+}
+
fn keypair(key: &SymmetricCryptoKey) -> RsaKeyPair {
const RSA_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXRVrCX+2hfOQS
diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs
index fa3779cd8979..ae61fbf7135f 100644
--- a/util/Seeder/Factories/UserSeeder.cs
+++ b/util/Seeder/Factories/UserSeeder.cs
@@ -19,12 +19,13 @@ internal static (User user, UserKeys keys) Create(
bool emailVerified = true,
bool premium = false,
UserKeys? keys = null,
- string? password = null)
+ string? password = null,
+ int kdfIterations = 5_000)
{
// When keys are provided, caller owns email/key consistency - don't mangle
var mangledEmail = keys == null ? manglerService.Mangle(email) : email;
- keys ??= RustSdkService.GenerateUserKeys(mangledEmail, password ?? DefaultPassword);
+ keys ??= RustSdkService.GenerateUserKeys(mangledEmail, password ?? DefaultPassword, kdfIterations);
var user = new User
{
@@ -40,7 +41,7 @@ internal static (User user, UserKeys keys) Create(
Premium = premium,
ApiKey = Guid.NewGuid().ToString("N")[..30],
Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 5_000
+ KdfIterations = kdfIterations
};
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
diff --git a/util/Seeder/Models/SeedPreset.cs b/util/Seeder/Models/SeedPreset.cs
index d9c22d940841..f32e6d1be1dd 100644
--- a/util/Seeder/Models/SeedPreset.cs
+++ b/util/Seeder/Models/SeedPreset.cs
@@ -10,6 +10,7 @@ internal record SeedPreset
public bool? Folders { get; init; }
public SeedPresetCiphers? Ciphers { get; init; }
public SeedPresetPersonalCiphers? PersonalCiphers { get; init; }
+ public int? KdfIterations { get; init; }
public SeedPresetDensity? Density { get; init; }
}
diff --git a/util/Seeder/Options/OrganizationVaultOptions.cs b/util/Seeder/Options/OrganizationVaultOptions.cs
index 320e4d1e6903..ebb55eb2a071 100644
--- a/util/Seeder/Options/OrganizationVaultOptions.cs
+++ b/util/Seeder/Options/OrganizationVaultOptions.cs
@@ -104,4 +104,10 @@ public class OrganizationVaultOptions
/// Billing plan type for the organization.
///
public PlanType PlanType { get; init; } = PlanType.EnterpriseAnnually;
+
+ ///
+ /// KDF iteration count for all seeded users. Defaults to 5,000 for fast seeding.
+ /// Use 600,000 for production-realistic e2e testing.
+ ///
+ public int KdfIterations { get; init; } = 5_000;
}
diff --git a/util/Seeder/Pipeline/RecipeOrchestrator.cs b/util/Seeder/Pipeline/RecipeOrchestrator.cs
index 74fcab9d794c..c1e9389a29f3 100644
--- a/util/Seeder/Pipeline/RecipeOrchestrator.cs
+++ b/util/Seeder/Pipeline/RecipeOrchestrator.cs
@@ -25,15 +25,21 @@ internal ExecutionResult Execute(
string presetName,
IPasswordHasher passwordHasher,
IManglerService manglerService,
- string? password = null)
+ string? password = null,
+ int? kdfIterations = null)
{
var reader = new SeedReader();
+ // Read preset to extract kdfIterations before building services.
+ // CLI --kdf-iterations takes precedence over the preset value.
+ var preset = reader.Read($"presets.{presetName}");
+ var effectiveKdf = kdfIterations ?? preset.KdfIterations ?? 5_000;
+
var services = new ServiceCollection();
services.AddSingleton(passwordHasher);
services.AddSingleton(manglerService);
services.AddSingleton(reader);
- services.AddSingleton(new SeederSettings(password));
+ services.AddSingleton(new SeederSettings(password, effectiveKdf));
services.AddSingleton(db);
PresetLoader.RegisterRecipe(presetName, reader, services);
@@ -56,7 +62,7 @@ internal ExecutionResult Execute(
var services = new ServiceCollection();
services.AddSingleton(passwordHasher);
services.AddSingleton(manglerService);
- services.AddSingleton(new SeederSettings(options.Password));
+ services.AddSingleton(new SeederSettings(options.Password, options.KdfIterations));
var recipeName = "from-options";
var builder = services.AddRecipe(recipeName);
diff --git a/util/Seeder/Pipeline/SeederContextExtensions.cs b/util/Seeder/Pipeline/SeederContextExtensions.cs
index 61ad50cecdda..4a4e724809b2 100644
--- a/util/Seeder/Pipeline/SeederContextExtensions.cs
+++ b/util/Seeder/Pipeline/SeederContextExtensions.cs
@@ -25,9 +25,12 @@ internal static SeederSettings GetSettings(this SeederContext context) =>
internal static string GetPassword(this SeederContext context) =>
context.GetSettings().Password ?? Factories.UserSeeder.DefaultPassword;
+
+ internal static int GetKdfIterations(this SeederContext context) =>
+ context.GetSettings().KdfIterations;
}
///
/// Runtime settings for a seeding operation, registered in DI.
///
-internal sealed record SeederSettings(string? Password = null);
+internal sealed record SeederSettings(string? Password = null, int KdfIterations = 5_000);
diff --git a/util/Seeder/Recipes/OrganizationRecipe.cs b/util/Seeder/Recipes/OrganizationRecipe.cs
index f996c1d7ddd7..33bdbcb81cf0 100644
--- a/util/Seeder/Recipes/OrganizationRecipe.cs
+++ b/util/Seeder/Recipes/OrganizationRecipe.cs
@@ -30,9 +30,9 @@ public class OrganizationRecipe(
/// Name of the embedded preset (e.g., "dunder-mifflin-full")
/// Optional password for all seeded accounts
/// The organization ID and summary statistics.
- public SeedResult Seed(string presetName, string? password = null)
+ public SeedResult Seed(string presetName, string? password = null, int? kdfIterations = null)
{
- var result = _orchestrator.Execute(presetName, passwordHasher, manglerService, password);
+ var result = _orchestrator.Execute(presetName, passwordHasher, manglerService, password, kdfIterations);
return new SeedResult(
result.OrganizationId,
diff --git a/util/Seeder/Seeds/schemas/preset.schema.json b/util/Seeder/Seeds/schemas/preset.schema.json
index 64067f499137..d20e24eb7fd5 100644
--- a/util/Seeder/Seeds/schemas/preset.schema.json
+++ b/util/Seeder/Seeds/schemas/preset.schema.json
@@ -34,7 +34,15 @@
},
"planType": {
"type": "string",
- "enum": ["free", "teams-monthly", "teams-annually", "enterprise-monthly", "enterprise-annually", "teams-starter", "families-annually"],
+ "enum": [
+ "free",
+ "teams-monthly",
+ "teams-annually",
+ "enterprise-monthly",
+ "enterprise-annually",
+ "teams-starter",
+ "families-annually"
+ ],
"description": "Billing plan type. Defaults to enterprise-annually if omitted."
}
}
@@ -177,6 +185,13 @@
}
}
},
+ "kdfIterations": {
+ "type": "integer",
+ "minimum": 5000,
+ "maximum": 1000000,
+ "default": 5000,
+ "description": "KDF iteration count for all seeded users. Defaults to 5,000 for fast seeding. Use 600,000 for production-realistic e2e testing."
+ },
"density": {
"type": "object",
"description": "Density profile controlling how users, groups, collections, and ciphers relate within the seeded organization.",
@@ -330,7 +345,12 @@
"properties": {
"preset": {
"type": "string",
- "enum": ["realistic", "loginOnly", "documentationHeavy", "developerFocused"],
+ "enum": [
+ "realistic",
+ "loginOnly",
+ "documentationHeavy",
+ "developerFocused"
+ ],
"description": "Named cipher type distribution. Mutually exclusive with custom weights."
},
"login": {
diff --git a/util/Seeder/Steps/CreateOwnerStep.cs b/util/Seeder/Steps/CreateOwnerStep.cs
index c63e58f206a8..3f7387ee3f75 100644
--- a/util/Seeder/Steps/CreateOwnerStep.cs
+++ b/util/Seeder/Steps/CreateOwnerStep.cs
@@ -14,9 +14,10 @@ public void Execute(SeederContext context)
{
var org = context.RequireOrganization();
var password = context.GetPassword();
+ var kdfIterations = context.GetKdfIterations();
var ownerEmail = context.GetMangler().Mangle($"owner@{context.RequireDomain()}");
- var userKeys = RustSdkService.GenerateUserKeys(ownerEmail, password);
- var (owner, _) = UserSeeder.Create(ownerEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys, password: password);
+ var userKeys = RustSdkService.GenerateUserKeys(ownerEmail, password, kdfIterations);
+ var (owner, _) = UserSeeder.Create(ownerEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys, password: password, kdfIterations: kdfIterations);
var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(owner.PublicKey!, context.RequireOrgKey());
diff --git a/util/Seeder/Steps/CreateRosterStep.cs b/util/Seeder/Steps/CreateRosterStep.cs
index 37a5128ecd8d..2f05ca99910e 100644
--- a/util/Seeder/Steps/CreateRosterStep.cs
+++ b/util/Seeder/Steps/CreateRosterStep.cs
@@ -18,6 +18,7 @@ public void Execute(SeederContext context)
var orgId = context.RequireOrgId();
var domain = context.RequireDomain();
var roster = context.GetSeedReader().Read($"rosters.{fixtureName}");
+ var kdfIterations = context.GetKdfIterations();
// Phase 1: Create users — build emailPrefix → orgUserId lookup
var userLookup = new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -38,8 +39,8 @@ public void Execute(SeederContext context)
var email = $"{emailPrefix}@{domain}";
var mangledEmail = context.GetMangler().Mangle(email);
var password = context.GetPassword();
- var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, password);
- var (user, _) = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys, password: password);
+ var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, password, kdfIterations);
+ var (user, _) = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys, password: password, kdfIterations: kdfIterations);
var userOrgKey = RustSdkService.GenerateUserOrganizationKey(user.PublicKey!, orgKey);
var orgUserType = ParseRole(rosterUser.Role);
var orgUser = org.CreateOrganizationUserWithKey(
diff --git a/util/Seeder/Steps/CreateUsersStep.cs b/util/Seeder/Steps/CreateUsersStep.cs
index 0b307d77579e..9900bdf70319 100644
--- a/util/Seeder/Steps/CreateUsersStep.cs
+++ b/util/Seeder/Steps/CreateUsersStep.cs
@@ -25,6 +25,7 @@ public void Execute(SeederContext context)
: UserStatusDistributions.AllConfirmed;
var password = context.GetPassword();
+ var kdfIterations = context.GetKdfIterations();
var mangler = context.GetMangler();
var passwordHasher = context.GetPasswordHasher();
@@ -41,8 +42,8 @@ public void Execute(SeederContext context)
Parallel.For(0, count, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, i =>
{
- var userKeys = RustSdkService.GenerateUserKeys(mangledEmails[i], password);
- var (user, _) = UserSeeder.Create(mangledEmails[i], passwordHasher, mangler, keys: userKeys, password: password);
+ var userKeys = RustSdkService.GenerateUserKeys(mangledEmails[i], password, kdfIterations);
+ var (user, _) = UserSeeder.Create(mangledEmails[i], passwordHasher, mangler, keys: userKeys, password: password, kdfIterations: kdfIterations);
var memberOrgKey = StatusRequiresOrgKey(statuses[i])
? RustSdkService.GenerateUserOrganizationKey(user.PublicKey!, orgKey)
diff --git a/util/SeederUtility/Commands/OrganizationArgs.cs b/util/SeederUtility/Commands/OrganizationArgs.cs
index f8dc7ab11fd6..18b8e8d22a7c 100644
--- a/util/SeederUtility/Commands/OrganizationArgs.cs
+++ b/util/SeederUtility/Commands/OrganizationArgs.cs
@@ -51,6 +51,9 @@ public class OrganizationArgs : IArgumentModel
[Option("plan-type", Description = "Billing plan type: free, teams-monthly, teams-annually, enterprise-monthly, enterprise-annually, teams-starter, families-annually. Defaults to enterprise-annually.")]
public string PlanType { get; set; } = "enterprise-annually";
+ [Option("kdf-iterations", Description = "KDF iteration count for all seeded users (default: 5000). Use 600000 for production-realistic e2e testing.")]
+ public int KdfIterations { get; set; } = 5_000;
+
public void Validate()
{
if (Users < 1)
@@ -79,6 +82,11 @@ public void Validate()
}
PlanFeatures.Parse(PlanType);
+
+ if (KdfIterations < 5_000)
+ {
+ throw new ArgumentException("KDF iterations must be at least 5,000.");
+ }
}
public OrganizationVaultOptions ToOptions() => new()
@@ -94,7 +102,8 @@ public void Validate()
Region = ParseGeographicRegion(Region),
Density = DensityProfiles.Parse(Density),
Password = Password,
- PlanType = PlanFeatures.Parse(PlanType)
+ PlanType = PlanFeatures.Parse(PlanType),
+ KdfIterations = KdfIterations
};
private static OrgStructureModel? ParseOrgStructure(string? structure)
diff --git a/util/SeederUtility/Commands/SeedArgs.cs b/util/SeederUtility/Commands/SeedArgs.cs
index 6bc2ebcd5bc4..6f103d151695 100644
--- a/util/SeederUtility/Commands/SeedArgs.cs
+++ b/util/SeederUtility/Commands/SeedArgs.cs
@@ -20,6 +20,9 @@ public class SeedArgs : IArgumentModel
[Option("password", Description = "Password for all seeded accounts (default: asdfasdfasdf)")]
public string? Password { get; set; }
+ [Option("kdf-iterations", Description = "KDF iteration count for all seeded users. Overrides the preset value if specified. Use 600000 for production-realistic e2e testing.")]
+ public int? KdfIterations { get; set; }
+
public void Validate()
{
if (List)
@@ -31,5 +34,10 @@ public void Validate()
{
throw new ArgumentException("--preset must be specified. Use --list to see available presets.");
}
+
+ if (KdfIterations.HasValue && KdfIterations.Value < 5_000)
+ {
+ throw new ArgumentException("KDF iterations must be at least 5,000.");
+ }
}
}
diff --git a/util/SeederUtility/Commands/SeedCommand.cs b/util/SeederUtility/Commands/SeedCommand.cs
index c0e0d45c6f03..8665fe784d1f 100644
--- a/util/SeederUtility/Commands/SeedCommand.cs
+++ b/util/SeederUtility/Commands/SeedCommand.cs
@@ -42,7 +42,7 @@ public void Execute(SeedArgs args)
var recipe = new OrganizationRecipe(db, mapper, passwordHasher, manglerService);
Console.WriteLine($"Seeding organization from preset '{args.Preset}'...");
- var result = recipe.Seed(args.Preset!, args.Password);
+ var result = recipe.Seed(args.Preset!, args.Password, args.KdfIterations);
PrintSeedResult(result);
}
diff --git a/util/SeederUtility/README.md b/util/SeederUtility/README.md
index 754fbdda4751..870821a7e89f 100644
--- a/util/SeederUtility/README.md
+++ b/util/SeederUtility/README.md
@@ -51,6 +51,9 @@ dotnet run -- organization -n FreeOrg -d free.example -u 1 -c 10 -g 1 --plan-typ
# Teams plan org
dotnet run -- organization -n TeamsOrg -d teams.example -u 20 -c 200 -g 5 --plan-type teams-annually
+
+# Production-realistic KDF iterations (600k) for e2e auth testing
+dotnet run -- organization -n E2eOrg -d e2e.example -u 5 -c 25 --kdf-iterations 600000 --mangle
```
### `seed` - Fixture-Based Seeding
@@ -71,5 +74,8 @@ dotnet run -- seed --preset qa.stark-free-basic --mangle
dotnet run -- seed --preset scale.xs-central-perk --mangle
dotnet run -- seed --preset qa.dunder-mifflin-enterprise-full --password "MyTestPassword1" --mangle
+
+# Override KDF iterations for a preset (overrides preset's kdfIterations value)
+dotnet run -- seed --preset qa.enterprise-basic --kdf-iterations 600000 --mangle
```