Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions util/RustSdk/RustSdkService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ 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);

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);

Expand Down
13 changes: 11 additions & 2 deletions util/RustSdk/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions util/Seeder/Factories/UserSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method really needs xml docs to help unwrap parameter actions. With this latest, we need to call out that if keys are provided, the kdfIterations must match whatever generated those keys or nothing will decrypt properly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed; I will replace the input params with one of the POCO Dtos this week

{
// 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
{
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions util/Seeder/Models/SeedPreset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}

Expand Down
6 changes: 6 additions & 0 deletions util/Seeder/Options/OrganizationVaultOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,10 @@ public class OrganizationVaultOptions
/// Billing plan type for the organization.
/// </summary>
public PlanType PlanType { get; init; } = PlanType.EnterpriseAnnually;

/// <summary>
/// KDF iteration count for all seeded users. Defaults to 5,000 for fast seeding.
/// Use 600,000 for production-realistic e2e testing.
/// </summary>
public int KdfIterations { get; init; } = 5_000;
}
12 changes: 9 additions & 3 deletions util/Seeder/Pipeline/RecipeOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,21 @@ internal ExecutionResult Execute(
string presetName,
IPasswordHasher<User> 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<Models.SeedPreset>($"presets.{presetName}");
var effectiveKdf = kdfIterations ?? preset.KdfIterations ?? 5_000;

var services = new ServiceCollection();
services.AddSingleton(passwordHasher);
services.AddSingleton(manglerService);
services.AddSingleton<ISeedReader>(reader);
services.AddSingleton(new SeederSettings(password));
services.AddSingleton(new SeederSettings(password, effectiveKdf));
services.AddSingleton(db);

PresetLoader.RegisterRecipe(presetName, reader, services);
Expand All @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion util/Seeder/Pipeline/SeederContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
/// Runtime settings for a seeding operation, registered in DI.
/// </summary>
internal sealed record SeederSettings(string? Password = null);
internal sealed record SeederSettings(string? Password = null, int KdfIterations = 5_000);
4 changes: 2 additions & 2 deletions util/Seeder/Recipes/OrganizationRecipe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ public class OrganizationRecipe(
/// <param name="presetName">Name of the embedded preset (e.g., "dunder-mifflin-full")</param>
/// <param name="password">Optional password for all seeded accounts</param>
/// <returns>The organization ID and summary statistics.</returns>
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,
Expand Down
24 changes: 22 additions & 2 deletions util/Seeder/Seeds/schemas/preset.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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": {
Expand Down
5 changes: 3 additions & 2 deletions util/Seeder/Steps/CreateOwnerStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
5 changes: 3 additions & 2 deletions util/Seeder/Steps/CreateRosterStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public void Execute(SeederContext context)
var orgId = context.RequireOrgId();
var domain = context.RequireDomain();
var roster = context.GetSeedReader().Read<SeedRoster>($"rosters.{fixtureName}");
var kdfIterations = context.GetKdfIterations();

// Phase 1: Create users β€” build emailPrefix β†’ orgUserId lookup
var userLookup = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions util/Seeder/Steps/CreateUsersStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion util/SeederUtility/Commands/OrganizationArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions util/SeederUtility/Commands/SeedArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically there's a maximum as well...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it approaching infinity ;-)

{
throw new ArgumentException("KDF iterations must be at least 5,000.");
}
}
}
2 changes: 1 addition & 1 deletion util/SeederUtility/Commands/SeedCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions util/SeederUtility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Loading