diff --git a/util/DbSeederUtility/DbSeederUtility.csproj b/util/DbSeederUtility/DbSeederUtility.csproj
new file mode 100644
index 0000000000..ab3ac933c6
--- /dev/null
+++ b/util/DbSeederUtility/DbSeederUtility.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ Bit.DbSeederUtility
+ DbSeeder
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
\ No newline at end of file
diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs
new file mode 100644
index 0000000000..6f3552da19
--- /dev/null
+++ b/util/DbSeederUtility/Program.cs
@@ -0,0 +1,83 @@
+using CommandDotNet;
+using Bit.Seeder.Commands;
+using Bit.Seeder.Settings;
+
+namespace Bit.DbSeederUtility;
+
+public class Program
+{
+ private static int Main(string[] args)
+ {
+ // Ensure global settings are loaded
+ var globalSettings = GlobalSettingsFactory.GlobalSettings;
+
+ // Set the current directory to the seeder directory for consistent seed paths
+ var seederDirectory = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "seeder"));
+ Directory.SetCurrentDirectory(seederDirectory);
+
+ return new AppRunner()
+ .Run(args);
+ }
+
+ [Command("generate", Description = "Generate seed data as JSON files")]
+ public int Generate(
+ [Option('u', "users", Description = "Number of users to generate")]
+ int users,
+
+ [Option('c', "ciphers-per-user", Description = "Number of ciphers per user to generate")]
+ int ciphersPerUser,
+
+ [Option('n', "seed-name", Description = "Name for the seed data files")]
+ string seedName
+ )
+ {
+ // Execute the generate command
+ var generateCommand = new GenerateCommand();
+ return generateCommand.Execute(users, ciphersPerUser, seedName, false) ? 0 : 1;
+ }
+
+ [Command("load", Description = "Load seed data from JSON files into the database")]
+ public int Load(
+ [Option('n', "seed-name", Description = "Name of the seed data to load")]
+ string seedName,
+
+ [Option('t', "timestamp", Description = "Specific timestamp of the seed data to load (defaults to most recent)")]
+ string? timestamp = null,
+
+ [Option('d', "dry-run", Description = "Validate the seed data without actually loading it")]
+ bool dryRun = false
+ )
+ {
+ // Execute the load command
+ var loadCommand = new LoadCommand();
+ return loadCommand.Execute(seedName, timestamp, dryRun) ? 0 : 1;
+ }
+
+ [Command("generate-direct-load", Description = "Generate seed data and load it directly into the database without creating JSON files")]
+ public int GenerateDirectLoad(
+ [Option('u', "users", Description = "Number of users to generate")]
+ int users,
+
+ [Option('c', "ciphers-per-user", Description = "Number of ciphers per user to generate")]
+ int ciphersPerUser,
+
+ [Option('n', "seed-name", Description = "Name identifier for this seed operation")]
+ string seedName
+ )
+ {
+ // Execute the generate command with loadImmediately=true
+ var generateCommand = new GenerateCommand();
+ return generateCommand.Execute(users, ciphersPerUser, seedName, true) ? 0 : 1;
+ }
+
+ [Command("extract", Description = "Extract data from the database into seed files")]
+ public int Extract(
+ [Option('n', "seed-name", Description = "Name for the extracted seed")]
+ string seedName
+ )
+ {
+ // Execute the extract command
+ var extractCommand = new ExtractCommand();
+ return extractCommand.Execute(seedName) ? 0 : 1;
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/Commands/ExtractCommand.cs b/util/seeder/Commands/ExtractCommand.cs
new file mode 100644
index 0000000000..7cf951d24e
--- /dev/null
+++ b/util/seeder/Commands/ExtractCommand.cs
@@ -0,0 +1,119 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Bit.Seeder.Services;
+using Bit.Seeder.Settings;
+using Microsoft.AspNetCore.DataProtection;
+using Bit.Core;
+using Microsoft.Extensions.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Bit.Core.Enums;
+
+namespace Bit.Seeder.Commands
+{
+ public class ExtractCommand
+ {
+ public bool Execute(string seedName)
+ {
+ try
+ {
+ // Create service provider with necessary services
+ var services = new ServiceCollection();
+ ConfigureServices(services);
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Get the necessary services
+ var seederService = serviceProvider.GetRequiredService();
+ var logger = serviceProvider.GetRequiredService>();
+
+ logger.LogInformation($"Extracting data from database to seed: {seedName}");
+
+ // Execute the extract operation
+ seederService.ExtractSeedsAsync(seedName).GetAwaiter().GetResult();
+
+ logger.LogInformation("Seed extraction completed successfully");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error extracting seeds: {ex.Message}");
+ if (ex.InnerException != null)
+ {
+ Console.WriteLine($"Inner exception: {ex.InnerException.Message}");
+ }
+ Console.WriteLine($"Stack trace: {ex.StackTrace}");
+ return false;
+ }
+ }
+
+ private void ConfigureServices(ServiceCollection services)
+ {
+ // Add logging
+ services.AddLogging(builder =>
+ {
+ builder.AddConsole();
+ builder.SetMinimumLevel(LogLevel.Information);
+ });
+
+ // Add JSON file configuration for other app settings not in GlobalSettings
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
+ .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true, reloadOnChange: true)
+ .AddEnvironmentVariables()
+ .AddUserSecrets("Bit.Seeder")
+ .Build();
+
+ services.AddSingleton(configuration);
+
+ // Get global settings
+ var globalSettings = GlobalSettingsFactory.GlobalSettings;
+ services.AddSingleton(globalSettings);
+
+ // Configure database provider
+ var provider = globalSettings.DatabaseProvider.ToLowerInvariant() switch
+ {
+ "postgres" or "postgresql" => SupportedDatabaseProviders.Postgres,
+ "mysql" or "mariadb" => SupportedDatabaseProviders.MySql,
+ "sqlite" => SupportedDatabaseProviders.Sqlite,
+ _ => SupportedDatabaseProviders.SqlServer
+ };
+
+ var connectionString = provider switch
+ {
+ SupportedDatabaseProviders.Postgres => globalSettings.PostgreSql?.ConnectionString,
+ SupportedDatabaseProviders.MySql => globalSettings.MySql?.ConnectionString,
+ SupportedDatabaseProviders.Sqlite => globalSettings.Sqlite?.ConnectionString,
+ _ => globalSettings.SqlServer?.ConnectionString
+ };
+
+ // Register database context
+ services.AddDbContext(options =>
+ {
+ switch (provider)
+ {
+ case SupportedDatabaseProviders.Postgres:
+ options.UseNpgsql(connectionString);
+ break;
+ case SupportedDatabaseProviders.MySql:
+ options.UseMySql(connectionString!, ServerVersion.AutoDetect(connectionString!));
+ break;
+ case SupportedDatabaseProviders.Sqlite:
+ options.UseSqlite(connectionString!);
+ break;
+ default:
+ options.UseSqlServer(connectionString!);
+ break;
+ }
+ });
+
+ // Add Data Protection services
+ services.AddDataProtection()
+ .SetApplicationName("Bitwarden");
+
+ // Register other services
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ }
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/Commands/GenerateCommand.cs b/util/seeder/Commands/GenerateCommand.cs
new file mode 100644
index 0000000000..9c67efb202
--- /dev/null
+++ b/util/seeder/Commands/GenerateCommand.cs
@@ -0,0 +1,77 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Bit.Seeder.Services;
+using Bit.Seeder.Settings;
+using Microsoft.AspNetCore.DataProtection;
+using Bit.Core;
+
+namespace Bit.Seeder.Commands
+{
+ public class GenerateCommand
+ {
+ public bool Execute(int users, int ciphersPerUser, string seedName, bool loadImmediately = false)
+ {
+ try
+ {
+ // Create service provider with necessary services
+ var services = new ServiceCollection();
+ ConfigureServices(services);
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Get the seeder service
+ var seederService = serviceProvider.GetRequiredService();
+ var logger = serviceProvider.GetRequiredService>();
+
+ logger.LogInformation($"Generating seeds for {users} users with {ciphersPerUser} ciphers per user");
+ logger.LogInformation($"Seed name: {seedName}");
+
+ // Execute the appropriate action based on whether we need to load immediately
+ if (loadImmediately)
+ {
+ // Generate and load directly without creating intermediate files
+ seederService.GenerateAndLoadSeedsAsync(users, ciphersPerUser, seedName).GetAwaiter().GetResult();
+ logger.LogInformation("Seeds generated and loaded directly to database");
+ }
+ else
+ {
+ // Only generate seeds and save to files
+ string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ seederService.GenerateSeedsAsync(users, ciphersPerUser, seedName).GetAwaiter().GetResult();
+ logger.LogInformation("Seed generation completed successfully");
+ }
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error generating seeds: {ex.Message}");
+ if (ex.InnerException != null)
+ {
+ Console.WriteLine($"Inner exception: {ex.InnerException.Message}");
+ }
+ Console.WriteLine($"Stack trace: {ex.StackTrace}");
+ return false;
+ }
+ }
+
+ private void ConfigureServices(ServiceCollection services)
+ {
+ // Load configuration using the GlobalSettingsFactory
+ var globalSettings = GlobalSettingsFactory.GlobalSettings;
+
+ // Register services
+ services.AddLogging(builder => builder.AddConsole());
+ services.AddSingleton(globalSettings);
+
+ // Add Data Protection services
+ services.AddDataProtection()
+ .SetApplicationName("Bitwarden");
+
+ // Register DatabaseContext and services
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddDbContext();
+ }
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/Commands/LoadCommand.cs b/util/seeder/Commands/LoadCommand.cs
new file mode 100644
index 0000000000..5664046de3
--- /dev/null
+++ b/util/seeder/Commands/LoadCommand.cs
@@ -0,0 +1,87 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Bit.Seeder.Services;
+using Bit.Seeder.Settings;
+using Microsoft.AspNetCore.DataProtection;
+using Bit.Core;
+
+namespace Bit.Seeder.Commands
+{
+ public class LoadCommand
+ {
+ public bool Execute(string seedName, string? timestamp = null, bool dryRun = false)
+ {
+ try
+ {
+ // Create service provider with necessary services
+ var services = new ServiceCollection();
+ ConfigureServices(services);
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Get the necessary services
+ var seederService = serviceProvider.GetRequiredService();
+ var databaseService = serviceProvider.GetRequiredService();
+ var logger = serviceProvider.GetRequiredService>();
+
+ logger.LogInformation($"Loading seeds named: {seedName}");
+ if (!string.IsNullOrEmpty(timestamp))
+ {
+ logger.LogInformation($"Using specific timestamp: {timestamp}");
+ }
+
+ if (dryRun)
+ {
+ logger.LogInformation("DRY RUN: No actual changes will be made to the database");
+ // Perform validation here if needed
+ logger.LogInformation("Seed loading validation completed");
+ return true;
+ }
+
+ // Execute the load operation
+ seederService.LoadSeedsAsync(seedName, timestamp).GetAwaiter().GetResult();
+
+ logger.LogInformation("Seed loading completed successfully");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ // Display the full exception details
+ Console.WriteLine($"Error loading seeds: {ex.Message}");
+
+ if (ex.InnerException != null)
+ {
+ Console.WriteLine($"Inner exception: {ex.InnerException.Message}");
+
+ if (ex.InnerException.InnerException != null)
+ {
+ Console.WriteLine($"Inner inner exception: {ex.InnerException.InnerException.Message}");
+ }
+ }
+
+ Console.WriteLine($"Stack trace: {ex.StackTrace}");
+
+ return false;
+ }
+ }
+
+ private void ConfigureServices(ServiceCollection services)
+ {
+ // Load configuration using the GlobalSettingsFactory
+ var globalSettings = GlobalSettingsFactory.GlobalSettings;
+
+ // Register services
+ services.AddLogging(builder => builder.AddConsole());
+ services.AddSingleton(globalSettings);
+
+ // Add Data Protection services
+ services.AddDataProtection()
+ .SetApplicationName("Bitwarden");
+
+ // Register DatabaseContext and services
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddDbContext();
+ }
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/DataProtection-Keys/key-c9676c44-16a7-4b38-b750-4ea5443f1b87.xml b/util/seeder/DataProtection-Keys/key-c9676c44-16a7-4b38-b750-4ea5443f1b87.xml
new file mode 100644
index 0000000000..9d94531cbf
--- /dev/null
+++ b/util/seeder/DataProtection-Keys/key-c9676c44-16a7-4b38-b750-4ea5443f1b87.xml
@@ -0,0 +1,16 @@
+
+
+ 2025-03-13T17:13:50.8174933Z
+ 2025-03-13T17:13:50.8130955Z
+ 2025-06-11T17:13:50.8130955Z
+
+
+
+
+
+
+ VCxxS1xKEWQ9+XRXgMchHML7POXcwRkCHswj7JMjjN36RkERENo+ky/1mazB4RZ6BjjwXsAyjhSz2eGts/0BnQ==
+
+
+
+
\ No newline at end of file
diff --git a/util/seeder/README.md b/util/seeder/README.md
new file mode 100644
index 0000000000..e6a9094d1e
--- /dev/null
+++ b/util/seeder/README.md
@@ -0,0 +1,98 @@
+# Bitwarden Database Seeder
+
+The Bitwarden Database Seeder utility is a tool for generating and loading test data for Bitwarden databases.
+
+## Features
+
+- Generate random user accounts with associated ciphers (passwords, secure notes, etc.)
+- Load generated seeds into the database
+- Configure database connections using the same configuration approach as the Bitwarden Migrator
+
+## Usage
+
+The seeder utility can be run in two ways:
+
+1. Using `dotnet run` from the source:
+ ```
+ cd util/DbSeederUtility
+ dotnet run -- generate-seeds --users 10 --ciphers 5 --output ./seeds
+ dotnet run -- load-seeds --path ./seeds
+ ```
+
+2. As a standalone executable:
+ ```
+ DbSeeder.exe generate-seeds --users 10 --ciphers 5 --output ./seeds
+ DbSeeder.exe load-seeds --path ./seeds
+ ```
+
+## Commands
+
+### Generate Seeds
+
+Generates random seed data for users and ciphers.
+
+```
+DbSeeder.exe generate-seeds [options]
+
+Options:
+ -u, --users Number of users to generate (default: 10)
+ -c, --ciphers Number of ciphers per user (default: 5)
+ -o, --output Output directory for seed files (default: seeds)
+```
+
+### Load Seeds
+
+Loads generated seed data into the database.
+
+```
+DbSeeder.exe load-seeds [options]
+
+Options:
+ -p, --path Path to the directory containing seed data
+ --dry-run Preview the operation without making changes
+```
+
+## Configuration
+
+The utility uses the same configuration approach as other Bitwarden utilities:
+
+1. **User Secrets** - For local development
+2. **appsettings.json** - For general settings
+3. **Environment variables** - For deployment environments
+
+### Database Configuration
+
+Configure the database connection in user secrets or appsettings.json:
+
+```json
+{
+ "globalSettings": {
+ "databaseProvider": "postgresql", // or "sqlserver", "mysql", "sqlite"
+ "postgreSql": {
+ "connectionString": "Host=localhost;Port=5432;Database=vault_dev;Username=postgres;Password=YOURPASSWORD;Include Error Detail=true"
+ },
+ "sqlServer": {
+ "connectionString": "Data Source=localhost;Initial Catalog=vault_dev;Integrated Security=SSPI;MultipleActiveResultSets=true"
+ }
+ }
+}
+```
+
+## Building the Executable
+
+To build the standalone executable:
+
+```
+cd util
+.\publish-seeder.ps1
+```
+
+This will create the executable in the `util/Seeder/publish/win-x64` directory. You can also specify a different runtime:
+
+```
+.\publish-seeder.ps1 -runtime linux-x64
+```
+
+## Integration with Bitwarden Server
+
+The seeder utility is designed to work seamlessly with Bitwarden Server. It uses the same database models and configuration approach as the server, ensuring compatibility with the core repository.
\ No newline at end of file
diff --git a/util/seeder/Seeder.csproj b/util/seeder/Seeder.csproj
new file mode 100644
index 0000000000..a7acb3acf7
--- /dev/null
+++ b/util/seeder/Seeder.csproj
@@ -0,0 +1,43 @@
+
+
+
+
+ net8.0
+ enable
+ enable
+ Bit.Seeder
+ Bit.Seeder
+ Core library for generating and managing test data for Bitwarden
+ library
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/util/seeder/Services/DatabaseContext.cs b/util/seeder/Services/DatabaseContext.cs
new file mode 100644
index 0000000000..b44304fecd
--- /dev/null
+++ b/util/seeder/Services/DatabaseContext.cs
@@ -0,0 +1,76 @@
+using Microsoft.EntityFrameworkCore;
+using Bit.Core.Entities;
+using Bit.Core.Vault.Entities;
+using Bit.Core.Enums;
+using Bit.Seeder.Settings;
+
+namespace Bit.Seeder.Services;
+
+public class DatabaseContext : DbContext
+{
+ private readonly GlobalSettings _globalSettings;
+
+ public DatabaseContext(GlobalSettings globalSettings)
+ {
+ _globalSettings = globalSettings;
+ }
+
+ public DbSet Users { get; set; }
+ public DbSet Ciphers { get; set; }
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ var provider = _globalSettings.DatabaseProvider ?? string.Empty;
+ Console.WriteLine($"Database Provider: '{provider}'");
+
+ // Output all available connection strings for debugging
+ Console.WriteLine($"SqlServer ConnectionString available: {!string.IsNullOrEmpty(_globalSettings.SqlServer?.ConnectionString)}");
+ Console.WriteLine($"PostgreSql ConnectionString available: {!string.IsNullOrEmpty(_globalSettings.PostgreSql?.ConnectionString)}");
+ Console.WriteLine($"MySql ConnectionString available: {!string.IsNullOrEmpty(_globalSettings.MySql?.ConnectionString)}");
+ Console.WriteLine($"Sqlite ConnectionString available: {!string.IsNullOrEmpty(_globalSettings.Sqlite?.ConnectionString)}");
+
+ var connectionString = _globalSettings.DatabaseProvider switch
+ {
+ "postgres" => _globalSettings.PostgreSql?.ConnectionString,
+ "mysql" => _globalSettings.MySql?.ConnectionString,
+ "sqlite" => _globalSettings.Sqlite?.ConnectionString,
+ _ => _globalSettings.SqlServer?.ConnectionString
+ };
+
+ Console.WriteLine($"Using connection string: {connectionString}");
+
+ switch (_globalSettings.DatabaseProvider)
+ {
+ case "postgres":
+ optionsBuilder.UseNpgsql(connectionString);
+ break;
+ case "mysql":
+ optionsBuilder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString));
+ break;
+ case "sqlite":
+ optionsBuilder.UseSqlite(connectionString);
+ break;
+ default:
+ optionsBuilder.UseSqlServer(connectionString);
+ break;
+ }
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Cipher");
+ entity.Property(e => e.Type).HasConversion();
+ entity.Property(e => e.Reprompt).HasConversion();
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("User");
+ entity.Property(e => e.Kdf).HasConversion();
+ });
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/Services/DatabaseService.cs b/util/seeder/Services/DatabaseService.cs
new file mode 100644
index 0000000000..dccc6ab6bc
--- /dev/null
+++ b/util/seeder/Services/DatabaseService.cs
@@ -0,0 +1,85 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Bit.Core.Entities;
+using Bit.Core.Vault.Entities;
+
+namespace Bit.Seeder.Services;
+
+public class DatabaseService : IDatabaseService
+{
+ private readonly DatabaseContext _dbContext;
+ private readonly ILogger _logger;
+
+ public DatabaseService(DatabaseContext dbContext, ILogger logger)
+ {
+ _dbContext = dbContext;
+ _logger = logger;
+ }
+
+ public async Task ClearDatabaseAsync()
+ {
+ _logger.LogInformation("Clearing database...");
+
+ var ciphers = await _dbContext.Ciphers.ToListAsync();
+ _dbContext.Ciphers.RemoveRange(ciphers);
+
+ var users = await _dbContext.Users.ToListAsync();
+ _dbContext.Users.RemoveRange(users);
+
+ await _dbContext.SaveChangesAsync();
+
+ _logger.LogInformation("Database cleared successfully.");
+ }
+
+ public async Task SaveUsersAsync(IEnumerable users)
+ {
+ _logger.LogInformation("Saving users to database...");
+
+ foreach (var user in users)
+ {
+ await _dbContext.Users.AddAsync(user);
+ }
+
+ await _dbContext.SaveChangesAsync();
+
+ _logger.LogInformation($"Successfully saved {users.Count()} users to database.");
+ }
+
+ public async Task SaveCiphersAsync(IEnumerable ciphers)
+ {
+ _logger.LogInformation("Saving ciphers to database...");
+
+ foreach (var cipher in ciphers)
+ {
+ await _dbContext.Ciphers.AddAsync(cipher);
+ }
+
+ await _dbContext.SaveChangesAsync();
+
+ _logger.LogInformation($"Successfully saved {ciphers.Count()} ciphers to database.");
+ }
+
+ public async Task> GetUsersAsync()
+ {
+ _logger.LogInformation("Retrieving all users from database...");
+ var users = await _dbContext.Users.ToListAsync();
+ _logger.LogInformation($"Successfully retrieved {users.Count} users from database.");
+ return users;
+ }
+
+ public async Task> GetCiphersAsync()
+ {
+ _logger.LogInformation("Retrieving all ciphers from database...");
+ var ciphers = await _dbContext.Ciphers.ToListAsync();
+ _logger.LogInformation($"Successfully retrieved {ciphers.Count} ciphers from database.");
+ return ciphers;
+ }
+
+ public async Task> GetCiphersByUserIdAsync(Guid userId)
+ {
+ _logger.LogInformation($"Retrieving ciphers for user {userId} from database...");
+ var ciphers = await _dbContext.Ciphers.Where(c => c.UserId == userId).ToListAsync();
+ _logger.LogInformation($"Successfully retrieved {ciphers.Count} ciphers for user {userId}.");
+ return ciphers;
+ }
+}
diff --git a/util/seeder/Services/EncryptionService.cs b/util/seeder/Services/EncryptionService.cs
new file mode 100644
index 0000000000..9fc195de99
--- /dev/null
+++ b/util/seeder/Services/EncryptionService.cs
@@ -0,0 +1,66 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.DataProtection;
+using Bit.Core;
+
+namespace Bit.Seeder.Services;
+
+public class EncryptionService : IEncryptionService
+{
+ private readonly ILogger _logger;
+ private readonly IDataProtector _dataProtector;
+
+ public EncryptionService(
+ ILogger logger,
+ IDataProtectionProvider dataProtectionProvider)
+ {
+ _logger = logger;
+ _dataProtector = dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose);
+ }
+
+ public string HashPassword(string password)
+ {
+ _logger.LogDebug("Hashing password using Data Protection");
+
+ // The real Bitwarden implementation uses BCrypt first and then protects that value
+ // For simplicity we're just protecting the raw password since this is only for seeding test data
+ var protectedPassword = _dataProtector.Protect(password);
+
+ // Prefix with "P|" to match Bitwarden's password format
+ return string.Concat(Constants.DatabaseFieldProtectedPrefix, protectedPassword);
+ }
+
+ public byte[] DeriveKey(string password, string salt)
+ {
+ _logger.LogDebug("Deriving key");
+
+ using var pbkdf2 = new Rfc2898DeriveBytes(
+ Encoding.UTF8.GetBytes(password),
+ Encoding.UTF8.GetBytes(salt),
+ 100000,
+ HashAlgorithmName.SHA256);
+
+ return pbkdf2.GetBytes(32);
+ }
+
+ public string EncryptString(string plaintext, byte[] key)
+ {
+ _logger.LogDebug("Encrypting string");
+
+ using var aes = Aes.Create();
+ aes.Key = key;
+ aes.GenerateIV();
+
+ using var encryptor = aes.CreateEncryptor();
+ var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
+
+ var cipherBytes = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length);
+
+ var result = new byte[aes.IV.Length + cipherBytes.Length];
+ Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
+ Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length);
+
+ return Convert.ToBase64String(result);
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/Services/IDatabaseService.cs b/util/seeder/Services/IDatabaseService.cs
new file mode 100644
index 0000000000..4755bfad1b
--- /dev/null
+++ b/util/seeder/Services/IDatabaseService.cs
@@ -0,0 +1,14 @@
+using Bit.Core.Entities;
+using Bit.Core.Vault.Entities;
+
+namespace Bit.Seeder.Services;
+
+public interface IDatabaseService
+{
+ Task ClearDatabaseAsync();
+ Task SaveUsersAsync(IEnumerable users);
+ Task SaveCiphersAsync(IEnumerable ciphers);
+ Task> GetUsersAsync();
+ Task> GetCiphersAsync();
+ Task> GetCiphersByUserIdAsync(Guid userId);
+}
diff --git a/util/seeder/Services/IDatabaseService.cs.bak b/util/seeder/Services/IDatabaseService.cs.bak
new file mode 100644
index 0000000000..d312322442
--- /dev/null
+++ b/util/seeder/Services/IDatabaseService.cs.bak
@@ -0,0 +1,11 @@
+using Bit.Core.Entities;
+using Bit.Core.Vault.Entities;
+
+namespace Bit.Seeder.Services;
+
+public interface IDatabaseService
+{
+ Task ClearDatabaseAsync();
+ Task SaveUsersAsync(IEnumerable users);
+ Task SaveCiphersAsync(IEnumerable ciphers);
+}
\ No newline at end of file
diff --git a/util/seeder/Services/IEncryptionService.cs b/util/seeder/Services/IEncryptionService.cs
new file mode 100644
index 0000000000..8a29568569
--- /dev/null
+++ b/util/seeder/Services/IEncryptionService.cs
@@ -0,0 +1,8 @@
+namespace Bit.Seeder.Services;
+
+public interface IEncryptionService
+{
+ string HashPassword(string password);
+ byte[] DeriveKey(string password, string salt);
+ string EncryptString(string plaintext, byte[] key);
+}
\ No newline at end of file
diff --git a/util/seeder/Services/ISeederService.cs b/util/seeder/Services/ISeederService.cs
new file mode 100644
index 0000000000..1a8370c046
--- /dev/null
+++ b/util/seeder/Services/ISeederService.cs
@@ -0,0 +1,9 @@
+namespace Bit.Seeder.Services;
+
+public interface ISeederService
+{
+ Task GenerateSeedsAsync(int userCount, int ciphersPerUser, string seedName);
+ Task LoadSeedsAsync(string seedName, string? timestamp = null);
+ Task GenerateAndLoadSeedsAsync(int userCount, int ciphersPerUser, string seedName);
+ Task ExtractSeedsAsync(string seedName);
+}
diff --git a/util/seeder/Services/SeederService.cs b/util/seeder/Services/SeederService.cs
new file mode 100644
index 0000000000..8efc10e44a
--- /dev/null
+++ b/util/seeder/Services/SeederService.cs
@@ -0,0 +1,450 @@
+using System.Text.Json;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
+using Bogus;
+using Microsoft.Extensions.Logging;
+
+namespace Bit.Seeder.Services;
+
+public class SeederService : ISeederService
+{
+ private readonly IEncryptionService _encryptionService;
+ private readonly IDatabaseService _databaseService;
+ private readonly ILogger _logger;
+ private readonly Faker _faker;
+ private readonly string _defaultPassword = "password";
+
+ public SeederService(
+ IEncryptionService encryptionService,
+ IDatabaseService databaseService,
+ ILogger logger)
+ {
+ _encryptionService = encryptionService;
+ _databaseService = databaseService;
+ _logger = logger;
+ _faker = new Faker();
+
+ // Set the random seed to ensure reproducible data
+ Randomizer.Seed = new Random(42);
+ }
+
+ public async Task GenerateSeedsAsync(int userCount, int ciphersPerUser, string outputName)
+ {
+ _logger.LogInformation("Generating seeds: {UserCount} users with {CiphersPerUser} ciphers each", userCount, ciphersPerUser);
+
+ // Create timestamped folder under a named folder in seeds directory
+ var seedsBaseDir = Path.Combine(Directory.GetCurrentDirectory(), "seeds");
+ Directory.CreateDirectory(seedsBaseDir);
+
+ var namedDir = Path.Combine(seedsBaseDir, outputName);
+ Directory.CreateDirectory(namedDir);
+
+ var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ var outputDir = Path.Combine(namedDir, timestamp);
+ Directory.CreateDirectory(outputDir);
+
+ // Create users and ciphers subdirectories
+ Directory.CreateDirectory(Path.Combine(outputDir, "users"));
+ Directory.CreateDirectory(Path.Combine(outputDir, "ciphers"));
+
+ _logger.LogInformation("Seed output directory: {OutputDir}", outputDir);
+
+ // Generate users
+ var users = GenerateUsers(userCount);
+
+ // Generate ciphers for each user
+ var allCiphers = new List();
+ foreach (var user in users)
+ {
+ var ciphers = GenerateCiphers(user, ciphersPerUser);
+ allCiphers.AddRange(ciphers);
+
+ // Save each user's ciphers to a file
+ var cipherFilePath = Path.Combine(outputDir, "ciphers", $"{user.Id}.json");
+ await File.WriteAllTextAsync(cipherFilePath, JsonSerializer.Serialize(ciphers, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ }));
+ }
+
+ // Save users to a file
+ var userFilePath = Path.Combine(outputDir, "users", "users.json");
+ await File.WriteAllTextAsync(userFilePath, JsonSerializer.Serialize(users, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ }));
+
+ _logger.LogInformation("Successfully generated {UserCount} users and {CipherCount} ciphers", users.Count, allCiphers.Count);
+ _logger.LogInformation("Seed data saved to directory: {OutputDir}", outputDir);
+ }
+
+ public async Task GenerateAndLoadSeedsAsync(int userCount, int ciphersPerUser, string seedName)
+ {
+ _logger.LogInformation("Generating and loading seeds directly: {UserCount} users with {CiphersPerUser} ciphers each",
+ userCount, ciphersPerUser);
+
+ // Generate users directly without saving to files
+ var users = GenerateUsers(userCount);
+
+ // Clear the database first
+ await _databaseService.ClearDatabaseAsync();
+
+ // Save users to database
+ await _databaseService.SaveUsersAsync(users);
+ _logger.LogInformation("Saved {UserCount} users directly to database", users.Count);
+
+ // Generate and save ciphers for each user
+ int totalCiphers = 0;
+ foreach (var user in users)
+ {
+ var ciphers = GenerateCiphers(user, ciphersPerUser);
+ await _databaseService.SaveCiphersAsync(ciphers);
+ totalCiphers += ciphers.Count;
+ _logger.LogInformation("Saved {CipherCount} ciphers for user {UserId} directly to database",
+ ciphers.Count, user.Id);
+ }
+
+ _logger.LogInformation("Successfully generated and loaded {UserCount} users and {CipherCount} ciphers directly to database",
+ users.Count, totalCiphers);
+ }
+
+ public async Task LoadSeedsAsync(string seedName, string? timestamp = null)
+ {
+ // Construct path to seeds directory
+ var seedsBaseDir = Path.Combine(Directory.GetCurrentDirectory(), "seeds");
+ var namedDir = Path.Combine(seedsBaseDir, seedName);
+
+ if (!Directory.Exists(namedDir))
+ {
+ _logger.LogError("Seed directory not found: {SeedDir}", namedDir);
+ return;
+ }
+
+ string seedDir;
+
+ // If timestamp is specified, use that exact directory
+ if (!string.IsNullOrEmpty(timestamp))
+ {
+ seedDir = Path.Combine(namedDir, timestamp);
+ if (!Directory.Exists(seedDir))
+ {
+ _logger.LogError("Timestamp directory not found: {TimestampDir}", seedDir);
+ return;
+ }
+ }
+ else
+ {
+ // Otherwise, find the most recent timestamped directory
+ var timestampDirs = Directory.GetDirectories(namedDir);
+ if (timestampDirs.Length == 0)
+ {
+ _logger.LogError("No seed data found in directory: {SeedDir}", namedDir);
+ return;
+ }
+
+ // Sort by directory name (which is a timestamp) in descending order
+ Array.Sort(timestampDirs);
+ Array.Reverse(timestampDirs);
+
+ // Use the most recent one
+ seedDir = timestampDirs[0];
+ _logger.LogInformation("Using most recent seed data from: {SeedDir}", seedDir);
+ }
+
+ _logger.LogInformation("Loading seeds from directory: {SeedDir}", seedDir);
+
+ // Clear database first
+ await _databaseService.ClearDatabaseAsync();
+
+ // Load users
+ var userFilePath = Path.Combine(seedDir, "users", "users.json");
+ if (!File.Exists(userFilePath))
+ {
+ _logger.LogError("User file not found: {UserFilePath}", userFilePath);
+ return;
+ }
+
+ var userJson = await File.ReadAllTextAsync(userFilePath);
+ var users = JsonSerializer.Deserialize>(userJson, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? new List();
+
+ if (users.Count == 0)
+ {
+ _logger.LogError("No users found in user file");
+ return;
+ }
+
+ // Save users to database
+ await _databaseService.SaveUsersAsync(users);
+
+ // Load and save ciphers for each user
+ var cipherDir = Path.Combine(seedDir, "ciphers");
+ if (!Directory.Exists(cipherDir))
+ {
+ _logger.LogError("Cipher directory not found: {CipherDir}", cipherDir);
+ return;
+ }
+
+ var cipherFiles = Directory.GetFiles(cipherDir, "*.json");
+ foreach (var cipherFile in cipherFiles)
+ {
+ var cipherJson = await File.ReadAllTextAsync(cipherFile);
+ var ciphers = JsonSerializer.Deserialize>(cipherJson, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? new List();
+
+ if (ciphers.Count > 0)
+ {
+ await _databaseService.SaveCiphersAsync(ciphers);
+ }
+ }
+
+ _logger.LogInformation("Successfully loaded seed data into database");
+ }
+
+ public async Task ExtractSeedsAsync(string seedName)
+ {
+ _logger.LogInformation("Extracting seed data from database for seed name: {SeedName}", seedName);
+
+ // Create timestamped folder under a named folder in seeds directory
+ var seedsBaseDir = Path.Combine(Directory.GetCurrentDirectory(), "seeds");
+ Directory.CreateDirectory(seedsBaseDir);
+
+ var namedDir = Path.Combine(seedsBaseDir, seedName);
+ Directory.CreateDirectory(namedDir);
+
+ var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ var outputDir = Path.Combine(namedDir, timestamp);
+ Directory.CreateDirectory(outputDir);
+
+ // Create users and ciphers subdirectories
+ Directory.CreateDirectory(Path.Combine(outputDir, "users"));
+ Directory.CreateDirectory(Path.Combine(outputDir, "ciphers"));
+
+ _logger.LogInformation("Seed output directory: {OutputDir}", outputDir);
+
+ try
+ {
+ // Get all users from the database
+ var users = await _databaseService.GetUsersAsync();
+ if (users == null || users.Count == 0)
+ {
+ _logger.LogWarning("No users found in the database");
+ return;
+ }
+
+ _logger.LogInformation("Extracted {Count} users from database", users.Count);
+
+ // Save users to a file
+ var userFilePath = Path.Combine(outputDir, "users", "users.json");
+ await File.WriteAllTextAsync(userFilePath, JsonSerializer.Serialize(users, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ }));
+
+ int totalCiphers = 0;
+ // Get ciphers for each user
+ foreach (var user in users)
+ {
+ var ciphers = await _databaseService.GetCiphersByUserIdAsync(user.Id);
+ if (ciphers != null && ciphers.Count > 0)
+ {
+ // Save ciphers to a file
+ var cipherFilePath = Path.Combine(outputDir, "ciphers", $"{user.Id}.json");
+ await File.WriteAllTextAsync(cipherFilePath, JsonSerializer.Serialize(ciphers, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ }));
+ totalCiphers += ciphers.Count;
+ }
+ }
+
+ _logger.LogInformation("Successfully extracted {UserCount} users and {CipherCount} ciphers", users.Count, totalCiphers);
+ _logger.LogInformation("Seed data saved to directory: {OutputDir}", outputDir);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error extracting seed data: {Message}", ex.Message);
+ throw;
+ }
+ }
+
+ private List GenerateUsers(int count)
+ {
+ _logger.LogInformation("Generating {Count} users", count);
+
+ var users = new List();
+
+ for (int i = 0; i < count; i++)
+ {
+ var userId = Guid.NewGuid();
+ var email = _faker.Internet.Email(provider: "example.com");
+ var name = _faker.Name.FullName();
+ var masterPassword = _encryptionService.HashPassword(_defaultPassword);
+ var masterPasswordHint = "It's the word 'password'";
+ var key = _encryptionService.DeriveKey(_defaultPassword, email);
+
+ var user = new User
+ {
+ Id = userId,
+ Email = email,
+ Name = name,
+ MasterPassword = masterPassword,
+ MasterPasswordHint = masterPasswordHint,
+ SecurityStamp = Guid.NewGuid().ToString(),
+ EmailVerified = true,
+ ApiKey = Guid.NewGuid().ToString("N").Substring(0, 30),
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 100000,
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Key = _encryptionService.EncryptString(Convert.ToBase64String(key), key)
+ };
+
+ users.Add(user);
+ }
+
+ return users;
+ }
+
+ private List GenerateCiphers(User user, int count)
+ {
+ _logger.LogInformation("Generating {Count} ciphers for user {UserId}", count, user.Id);
+
+ var ciphers = new List();
+ var key = _encryptionService.DeriveKey(_defaultPassword, user.Email);
+
+ for (int i = 0; i < count; i++)
+ {
+ var cipherId = Guid.NewGuid();
+ CipherType type;
+ string name;
+ string? notes = null;
+
+ var typeRandom = _faker.Random.Int(1, 4);
+ type = (CipherType)typeRandom;
+
+ switch (type)
+ {
+ case CipherType.Login:
+ name = $"Login - {_faker.Internet.DomainName()}";
+ var loginData = new
+ {
+ Name = name,
+ Notes = notes,
+ Username = _faker.Internet.UserName(),
+ Password = _faker.Internet.Password(),
+ Uris = new[]
+ {
+ new { Uri = $"https://{_faker.Internet.DomainName()}" }
+ }
+ };
+
+ var loginDataJson = JsonSerializer.Serialize(loginData);
+
+ ciphers.Add(new Cipher
+ {
+ Id = cipherId,
+ UserId = user.Id,
+ Type = type,
+ Data = _encryptionService.EncryptString(loginDataJson, key),
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Reprompt = CipherRepromptType.None
+ });
+ break;
+
+ case CipherType.SecureNote:
+ name = $"Note - {_faker.Lorem.Word()}";
+ notes = _faker.Lorem.Paragraph();
+ var secureNoteData = new
+ {
+ Name = name,
+ Notes = notes,
+ Type = 0 // Text
+ };
+
+ var secureNoteDataJson = JsonSerializer.Serialize(secureNoteData);
+
+ ciphers.Add(new Cipher
+ {
+ Id = cipherId,
+ UserId = user.Id,
+ Type = type,
+ Data = _encryptionService.EncryptString(secureNoteDataJson, key),
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Reprompt = CipherRepromptType.None
+ });
+ break;
+
+ case CipherType.Card:
+ name = $"Card - {_faker.Finance.CreditCardNumber().Substring(0, 4)}";
+ var cardData = new
+ {
+ Name = name,
+ Notes = notes,
+ CardholderName = _faker.Name.FullName(),
+ Number = _faker.Finance.CreditCardNumber(),
+ ExpMonth = _faker.Random.Int(1, 12).ToString(),
+ ExpYear = _faker.Random.Int(DateTime.UtcNow.Year, DateTime.UtcNow.Year + 10).ToString(),
+ Code = _faker.Random.Int(100, 999).ToString()
+ };
+
+ var cardDataJson = JsonSerializer.Serialize(cardData);
+
+ ciphers.Add(new Cipher
+ {
+ Id = cipherId,
+ UserId = user.Id,
+ Type = type,
+ Data = _encryptionService.EncryptString(cardDataJson, key),
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Reprompt = CipherRepromptType.None
+ });
+ break;
+
+ case CipherType.Identity:
+ name = $"Identity - {_faker.Name.FullName()}";
+ var identityData = new
+ {
+ Name = name,
+ Notes = notes,
+ Title = _faker.Name.Prefix(),
+ FirstName = _faker.Name.FirstName(),
+ MiddleName = _faker.Name.FirstName(),
+ LastName = _faker.Name.LastName(),
+ Email = _faker.Internet.Email(),
+ Phone = _faker.Phone.PhoneNumber(),
+ Address1 = _faker.Address.StreetAddress(),
+ City = _faker.Address.City(),
+ State = _faker.Address.State(),
+ PostalCode = _faker.Address.ZipCode(),
+ Country = _faker.Address.CountryCode()
+ };
+
+ var identityDataJson = JsonSerializer.Serialize(identityData);
+
+ ciphers.Add(new Cipher
+ {
+ Id = cipherId,
+ UserId = user.Id,
+ Type = type,
+ Data = _encryptionService.EncryptString(identityDataJson, key),
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Reprompt = CipherRepromptType.None
+ });
+ break;
+ }
+ }
+
+ return ciphers;
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/Services/SeederService.cs.bak b/util/seeder/Services/SeederService.cs.bak
new file mode 100644
index 0000000000..1ac1a83278
--- /dev/null
+++ b/util/seeder/Services/SeederService.cs.bak
@@ -0,0 +1,383 @@
+using System.Text.Json;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Vault.Entities;
+using Bit.Core.Vault.Enums;
+using Bogus;
+using Microsoft.Extensions.Logging;
+
+namespace Bit.Seeder.Services;
+
+public class SeederService : ISeederService
+{
+ private readonly IEncryptionService _encryptionService;
+ private readonly IDatabaseService _databaseService;
+ private readonly ILogger _logger;
+ private readonly Faker _faker;
+ private readonly string _defaultPassword = "password";
+
+ public SeederService(
+ IEncryptionService encryptionService,
+ IDatabaseService databaseService,
+ ILogger logger)
+ {
+ _encryptionService = encryptionService;
+ _databaseService = databaseService;
+ _logger = logger;
+ _faker = new Faker();
+
+ // Set the random seed to ensure reproducible data
+ Randomizer.Seed = new Random(42);
+ }
+
+ public async Task GenerateSeedsAsync(int userCount, int ciphersPerUser, string outputName)
+ {
+ _logger.LogInformation("Generating seeds: {UserCount} users with {CiphersPerUser} ciphers each", userCount, ciphersPerUser);
+
+ // Create timestamped folder under a named folder in seeds directory
+ var seedsBaseDir = Path.Combine(Directory.GetCurrentDirectory(), "seeds");
+ Directory.CreateDirectory(seedsBaseDir);
+
+ var namedDir = Path.Combine(seedsBaseDir, outputName);
+ Directory.CreateDirectory(namedDir);
+
+ var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ var outputDir = Path.Combine(namedDir, timestamp);
+ Directory.CreateDirectory(outputDir);
+
+ // Create users and ciphers subdirectories
+ Directory.CreateDirectory(Path.Combine(outputDir, "users"));
+ Directory.CreateDirectory(Path.Combine(outputDir, "ciphers"));
+
+ _logger.LogInformation("Seed output directory: {OutputDir}", outputDir);
+
+ // Generate users
+ var users = GenerateUsers(userCount);
+
+ // Generate ciphers for each user
+ var allCiphers = new List();
+ foreach (var user in users)
+ {
+ var ciphers = GenerateCiphers(user, ciphersPerUser);
+ allCiphers.AddRange(ciphers);
+
+ // Save each user's ciphers to a file
+ var cipherFilePath = Path.Combine(outputDir, "ciphers", $"{user.Id}.json");
+ await File.WriteAllTextAsync(cipherFilePath, JsonSerializer.Serialize(ciphers, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ }));
+ }
+
+ // Save users to a file
+ var userFilePath = Path.Combine(outputDir, "users", "users.json");
+ await File.WriteAllTextAsync(userFilePath, JsonSerializer.Serialize(users, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ }));
+
+ _logger.LogInformation("Successfully generated {UserCount} users and {CipherCount} ciphers", users.Count, allCiphers.Count);
+ _logger.LogInformation("Seed data saved to directory: {OutputDir}", outputDir);
+ }
+
+ public async Task GenerateAndLoadSeedsAsync(int userCount, int ciphersPerUser, string seedName)
+ {
+ _logger.LogInformation("Generating and loading seeds directly: {UserCount} users with {CiphersPerUser} ciphers each",
+ userCount, ciphersPerUser);
+
+ // Generate users directly without saving to files
+ var users = GenerateUsers(userCount);
+
+ // Clear the database first
+ await _databaseService.ClearDatabaseAsync();
+
+ // Save users to database
+ await _databaseService.SaveUsersAsync(users);
+ _logger.LogInformation("Saved {UserCount} users directly to database", users.Count);
+
+ // Generate and save ciphers for each user
+ int totalCiphers = 0;
+ foreach (var user in users)
+ {
+ var ciphers = GenerateCiphers(user, ciphersPerUser);
+ await _databaseService.SaveCiphersAsync(ciphers);
+ totalCiphers += ciphers.Count;
+ _logger.LogInformation("Saved {CipherCount} ciphers for user {UserId} directly to database",
+ ciphers.Count, user.Id);
+ }
+
+ _logger.LogInformation("Successfully generated and loaded {UserCount} users and {CipherCount} ciphers directly to database",
+ users.Count, totalCiphers);
+ }
+
+ public async Task LoadSeedsAsync(string seedName, string? timestamp = null)
+ {
+ // Construct path to seeds directory
+ var seedsBaseDir = Path.Combine(Directory.GetCurrentDirectory(), "seeds");
+ var namedDir = Path.Combine(seedsBaseDir, seedName);
+
+ if (!Directory.Exists(namedDir))
+ {
+ _logger.LogError("Seed directory not found: {SeedDir}", namedDir);
+ return;
+ }
+
+ string seedDir;
+
+ // If timestamp is specified, use that exact directory
+ if (!string.IsNullOrEmpty(timestamp))
+ {
+ seedDir = Path.Combine(namedDir, timestamp);
+ if (!Directory.Exists(seedDir))
+ {
+ _logger.LogError("Timestamp directory not found: {TimestampDir}", seedDir);
+ return;
+ }
+ }
+ else
+ {
+ // Otherwise, find the most recent timestamped directory
+ var timestampDirs = Directory.GetDirectories(namedDir);
+ if (timestampDirs.Length == 0)
+ {
+ _logger.LogError("No seed data found in directory: {SeedDir}", namedDir);
+ return;
+ }
+
+ // Sort by directory name (which is a timestamp) in descending order
+ Array.Sort(timestampDirs);
+ Array.Reverse(timestampDirs);
+
+ // Use the most recent one
+ seedDir = timestampDirs[0];
+ _logger.LogInformation("Using most recent seed data from: {SeedDir}", seedDir);
+ }
+
+ _logger.LogInformation("Loading seeds from directory: {SeedDir}", seedDir);
+
+ // Clear database first
+ await _databaseService.ClearDatabaseAsync();
+
+ // Load users
+ var userFilePath = Path.Combine(seedDir, "users", "users.json");
+ if (!File.Exists(userFilePath))
+ {
+ _logger.LogError("User file not found: {UserFilePath}", userFilePath);
+ return;
+ }
+
+ var userJson = await File.ReadAllTextAsync(userFilePath);
+ var users = JsonSerializer.Deserialize>(userJson, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? new List();
+
+ if (users.Count == 0)
+ {
+ _logger.LogError("No users found in user file");
+ return;
+ }
+
+ // Save users to database
+ await _databaseService.SaveUsersAsync(users);
+
+ // Load and save ciphers for each user
+ var cipherDir = Path.Combine(seedDir, "ciphers");
+ if (!Directory.Exists(cipherDir))
+ {
+ _logger.LogError("Cipher directory not found: {CipherDir}", cipherDir);
+ return;
+ }
+
+ var cipherFiles = Directory.GetFiles(cipherDir, "*.json");
+ foreach (var cipherFile in cipherFiles)
+ {
+ var cipherJson = await File.ReadAllTextAsync(cipherFile);
+ var ciphers = JsonSerializer.Deserialize>(cipherJson, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? new List();
+
+ if (ciphers.Count > 0)
+ {
+ await _databaseService.SaveCiphersAsync(ciphers);
+ }
+ }
+
+ _logger.LogInformation("Successfully loaded seed data into database");
+ }
+
+ private List GenerateUsers(int count)
+ {
+ _logger.LogInformation("Generating {Count} users", count);
+
+ var users = new List();
+
+ for (int i = 0; i < count; i++)
+ {
+ var userId = Guid.NewGuid();
+ var email = _faker.Internet.Email(provider: "example.com");
+ var name = _faker.Name.FullName();
+ var masterPassword = _encryptionService.HashPassword(_defaultPassword);
+ var masterPasswordHint = "It's the word 'password'";
+ var key = _encryptionService.DeriveKey(_defaultPassword, email);
+
+ var user = new User
+ {
+ Id = userId,
+ Email = email,
+ Name = name,
+ MasterPassword = masterPassword,
+ MasterPasswordHint = masterPasswordHint,
+ SecurityStamp = Guid.NewGuid().ToString(),
+ EmailVerified = true,
+ ApiKey = Guid.NewGuid().ToString("N").Substring(0, 30),
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 100000,
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Key = _encryptionService.EncryptString(Convert.ToBase64String(key), key)
+ };
+
+ users.Add(user);
+ }
+
+ return users;
+ }
+
+ private List GenerateCiphers(User user, int count)
+ {
+ _logger.LogInformation("Generating {Count} ciphers for user {UserId}", count, user.Id);
+
+ var ciphers = new List();
+ var key = _encryptionService.DeriveKey(_defaultPassword, user.Email);
+
+ for (int i = 0; i < count; i++)
+ {
+ var cipherId = Guid.NewGuid();
+ CipherType type;
+ string name;
+ string? notes = null;
+
+ var typeRandom = _faker.Random.Int(1, 4);
+ type = (CipherType)typeRandom;
+
+ switch (type)
+ {
+ case CipherType.Login:
+ name = $"Login - {_faker.Internet.DomainName()}";
+ var loginData = new
+ {
+ Name = name,
+ Notes = notes,
+ Username = _faker.Internet.UserName(),
+ Password = _faker.Internet.Password(),
+ Uris = new[]
+ {
+ new { Uri = $"https://{_faker.Internet.DomainName()}" }
+ }
+ };
+
+ var loginDataJson = JsonSerializer.Serialize(loginData);
+
+ ciphers.Add(new Cipher
+ {
+ Id = cipherId,
+ UserId = user.Id,
+ Type = type,
+ Data = _encryptionService.EncryptString(loginDataJson, key),
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Reprompt = CipherRepromptType.None
+ });
+ break;
+
+ case CipherType.SecureNote:
+ name = $"Note - {_faker.Lorem.Word()}";
+ notes = _faker.Lorem.Paragraph();
+ var secureNoteData = new
+ {
+ Name = name,
+ Notes = notes,
+ Type = 0 // Text
+ };
+
+ var secureNoteDataJson = JsonSerializer.Serialize(secureNoteData);
+
+ ciphers.Add(new Cipher
+ {
+ Id = cipherId,
+ UserId = user.Id,
+ Type = type,
+ Data = _encryptionService.EncryptString(secureNoteDataJson, key),
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Reprompt = CipherRepromptType.None
+ });
+ break;
+
+ case CipherType.Card:
+ name = $"Card - {_faker.Finance.CreditCardNumber().Substring(0, 4)}";
+ var cardData = new
+ {
+ Name = name,
+ Notes = notes,
+ CardholderName = _faker.Name.FullName(),
+ Number = _faker.Finance.CreditCardNumber(),
+ ExpMonth = _faker.Random.Int(1, 12).ToString(),
+ ExpYear = _faker.Random.Int(DateTime.UtcNow.Year, DateTime.UtcNow.Year + 10).ToString(),
+ Code = _faker.Random.Int(100, 999).ToString()
+ };
+
+ var cardDataJson = JsonSerializer.Serialize(cardData);
+
+ ciphers.Add(new Cipher
+ {
+ Id = cipherId,
+ UserId = user.Id,
+ Type = type,
+ Data = _encryptionService.EncryptString(cardDataJson, key),
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Reprompt = CipherRepromptType.None
+ });
+ break;
+
+ case CipherType.Identity:
+ name = $"Identity - {_faker.Name.FullName()}";
+ var identityData = new
+ {
+ Name = name,
+ Notes = notes,
+ Title = _faker.Name.Prefix(),
+ FirstName = _faker.Name.FirstName(),
+ MiddleName = _faker.Name.FirstName(),
+ LastName = _faker.Name.LastName(),
+ Email = _faker.Internet.Email(),
+ Phone = _faker.Phone.PhoneNumber(),
+ Address1 = _faker.Address.StreetAddress(),
+ City = _faker.Address.City(),
+ State = _faker.Address.State(),
+ PostalCode = _faker.Address.ZipCode(),
+ Country = _faker.Address.CountryCode()
+ };
+
+ var identityDataJson = JsonSerializer.Serialize(identityData);
+
+ ciphers.Add(new Cipher
+ {
+ Id = cipherId,
+ UserId = user.Id,
+ Type = type,
+ Data = _encryptionService.EncryptString(identityDataJson, key),
+ CreationDate = DateTime.UtcNow,
+ RevisionDate = DateTime.UtcNow,
+ Reprompt = CipherRepromptType.None
+ });
+ break;
+ }
+ }
+
+ return ciphers;
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/Settings/GlobalSettings.cs b/util/seeder/Settings/GlobalSettings.cs
new file mode 100644
index 0000000000..895ccb8c1f
--- /dev/null
+++ b/util/seeder/Settings/GlobalSettings.cs
@@ -0,0 +1,16 @@
+namespace Bit.Seeder.Settings;
+
+public class GlobalSettings
+{
+ public bool SelfHosted { get; set; }
+ public string DatabaseProvider { get; set; } = string.Empty;
+ public SqlSettings SqlServer { get; set; } = new SqlSettings();
+ public SqlSettings PostgreSql { get; set; } = new SqlSettings();
+ public SqlSettings MySql { get; set; } = new SqlSettings();
+ public SqlSettings Sqlite { get; set; } = new SqlSettings();
+
+ public class SqlSettings
+ {
+ public string ConnectionString { get; set; } = string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/Settings/GlobalSettingsFactory.cs b/util/seeder/Settings/GlobalSettingsFactory.cs
new file mode 100644
index 0000000000..e7c6043cc7
--- /dev/null
+++ b/util/seeder/Settings/GlobalSettingsFactory.cs
@@ -0,0 +1,86 @@
+using Microsoft.Extensions.Configuration;
+
+namespace Bit.Seeder.Settings;
+
+public static class GlobalSettingsFactory
+{
+ private static GlobalSettings? _globalSettings;
+
+ public static GlobalSettings GlobalSettings
+ {
+ get
+ {
+ if (_globalSettings == null)
+ {
+ _globalSettings = LoadGlobalSettings();
+ }
+
+ return _globalSettings;
+ }
+ }
+
+ private static GlobalSettings LoadGlobalSettings()
+ {
+ Console.WriteLine("Loading global settings...");
+
+ var configBuilder = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
+ .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true, reloadOnChange: true)
+ .AddUserSecrets("bitwarden-Api") // Load user secrets from the API project
+ .AddEnvironmentVariables();
+
+ var configuration = configBuilder.Build();
+ var globalSettingsSection = configuration.GetSection("globalSettings");
+
+ // Debug: Print all settings from globalSettings section
+ foreach (var setting in globalSettingsSection.GetChildren())
+ {
+ Console.WriteLine($"Found setting: {setting.Key}");
+ foreach (var child in setting.GetChildren())
+ {
+ Console.WriteLine($" - {setting.Key}.{child.Key}");
+ }
+ }
+
+ var settings = new GlobalSettings();
+ globalSettingsSection.Bind(settings);
+
+ // Output the loaded settings
+ Console.WriteLine($"Loaded DatabaseProvider: {settings.DatabaseProvider}");
+ Console.WriteLine($"PostgreSql settings loaded: {settings.PostgreSql != null}");
+ Console.WriteLine($"SqlServer settings loaded: {settings.SqlServer != null}");
+ Console.WriteLine($"MySql settings loaded: {settings.MySql != null}");
+ Console.WriteLine($"Sqlite settings loaded: {settings.Sqlite != null}");
+
+ // Check for case sensitivity issue with PostgreSql/postgresql keys
+ var postgresqlValue = globalSettingsSection.GetSection("postgresql")?.Value;
+ var postgreSqlValue = globalSettingsSection.GetSection("postgreSql")?.Value;
+ Console.WriteLine($"Raw check - postgresql setting exists: {postgresqlValue != null}");
+ Console.WriteLine($"Raw check - postgreSql setting exists: {postgreSqlValue != null}");
+
+ return settings;
+ }
+}
+
+// Non-static version that can accept command-line arguments
+public class GlobalSettingsFactoryWithArgs
+{
+ public GlobalSettings GlobalSettings { get; }
+
+ public GlobalSettingsFactoryWithArgs(string[] args)
+ {
+ GlobalSettings = new GlobalSettings();
+
+ var config = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
+ .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true, reloadOnChange: true)
+ .AddUserSecrets("bitwarden-Api")
+ .AddCommandLine(args)
+ .AddEnvironmentVariables()
+ .Build();
+
+ config.GetSection("globalSettings").Bind(GlobalSettings);
+ }
+}
\ No newline at end of file
diff --git a/util/seeder/appsettings.json b/util/seeder/appsettings.json
new file mode 100644
index 0000000000..8976344175
--- /dev/null
+++ b/util/seeder/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "globalSettings": {
+ "selfHosted": true,
+ "databaseProvider": "postgres",
+ "sqlServer": {
+ "connectionString": "Server=localhost;Database=vault_dev;User Id=SA;Password=Th3P455word?;Encrypt=True;TrustServerCertificate=True"
+ },
+ "postgreSql": {
+ "connectionString": "Host=localhost;Username=postgres;Password=Th3P455word?;Database=vault_dev;Include Error Detail=true"
+ },
+ "mysql": {
+ "connectionString": "server=localhost;uid=root;pwd=Th3P455word?;database=vault_dev"
+ },
+ "sqlite": {
+ "connectionString": "Data Source=./bitwarden_test.sqlite"
+ }
+ }
+}
\ No newline at end of file