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 ```