1
0
mirror of https://github.com/bitwarden/server synced 2026-02-11 14:03:24 +00:00

Enhance seeder with additional cipher types and architectural refactorings (#6935)

This commit is contained in:
Mick Letofsky
2026-02-04 19:27:09 +01:00
committed by GitHub
parent 26b62bc766
commit 4eb9c4cf3c
67 changed files with 2968 additions and 959 deletions

View File

@@ -8,6 +8,7 @@ using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request;
using Bit.Core.Entities;
using Bit.Seeder.Recipes;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@@ -31,7 +32,8 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = new NoOpManglerService();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);

View File

@@ -10,6 +10,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Seeder.Recipes;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@@ -33,7 +34,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -71,7 +73,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -107,7 +110,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
@@ -141,7 +145,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
@@ -176,7 +181,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@@ -226,7 +232,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
@@ -268,7 +275,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@@ -314,7 +322,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@@ -360,7 +369,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
@@ -407,7 +417,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -459,7 +470,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
@@ -498,7 +510,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
@@ -541,7 +554,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var collectionsSeeder = new CollectionsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
@@ -591,7 +605,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = factory.GetService<IManglerService>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(

View File

@@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Tokens;
using Bit.Seeder.Recipes;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@@ -34,7 +35,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = new NoOpManglerService();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@@ -84,7 +86,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var db = factory.GetDatabaseContext();
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = new NoOpManglerService();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);

View File

@@ -7,6 +7,7 @@ using Bit.Core.Platform.PushRegistration.Internal;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
@@ -190,6 +191,9 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
TestDatabase.Migrate(services);
}
// Register NoOpManglerService for test data seeding (no mangling in tests)
services.TryAddSingleton<IManglerService, NoOpManglerService>();
// QUESTION: The normal licensing service should run fine on developer machines but not in CI
// should we have a fork here to leave the normal service for developers?
// TODO: Eventually add the license file to CI

View File

@@ -13,6 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Identity\Identity.csproj" />
<ProjectReference Include="..\..\util\Migrator\Migrator.csproj" />
<ProjectReference Include="..\..\util\Seeder\Seeder.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,175 @@
using Bit.Seeder.Data.Distributions;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class DistributionTests
{
[Fact]
public void Constructor_PercentagesSumToOne_Succeeds()
{
var distribution = new Distribution<string>(
("A", 0.50),
("B", 0.30),
("C", 0.20)
);
Assert.NotNull(distribution);
}
[Fact]
public void Constructor_PercentagesDoNotSumToOne_ThrowsArgumentException()
{
var exception = Assert.Throws<ArgumentException>(() => new Distribution<int>(
(1, 0.50),
(2, 0.40)
));
Assert.Contains("must sum to 1.0", exception.Message);
}
[Fact]
public void Constructor_PercentagesExceedOne_ThrowsArgumentException()
{
var exception = Assert.Throws<ArgumentException>(() => new Distribution<string>(
("X", 0.60),
("Y", 0.60)
));
Assert.Contains("must sum to 1.0", exception.Message);
}
[Fact]
public void Constructor_WithinToleranceOf001_Succeeds()
{
var distribution = new Distribution<string>(
("A", 0.333),
("B", 0.333),
("C", 0.333)
);
Assert.NotNull(distribution);
}
[Fact]
public void Select_ReturnsCorrectBuckets_ForEvenSplit()
{
var distribution = new Distribution<string>(
("A", 0.50),
("B", 0.50)
);
Assert.Equal("A", distribution.Select(0, 100));
Assert.Equal("A", distribution.Select(49, 100));
Assert.Equal("B", distribution.Select(50, 100));
Assert.Equal("B", distribution.Select(99, 100));
}
[Fact]
public void Select_ReturnsCorrectBuckets_ForThreeWaySplit()
{
var distribution = new Distribution<int>(
(1, 0.60),
(2, 0.30),
(3, 0.10)
);
Assert.Equal(1, distribution.Select(0, 100));
Assert.Equal(1, distribution.Select(59, 100));
Assert.Equal(2, distribution.Select(60, 100));
Assert.Equal(2, distribution.Select(89, 100));
Assert.Equal(3, distribution.Select(90, 100));
Assert.Equal(3, distribution.Select(99, 100));
}
[Fact]
public void Select_IndexBeyondTotal_ReturnsLastBucket()
{
var distribution = new Distribution<string>(
("A", 0.50),
("B", 0.50)
);
Assert.Equal("B", distribution.Select(150, 100));
}
[Fact]
public void Select_SmallTotal_HandlesRoundingGracefully()
{
var distribution = new Distribution<string>(
("A", 0.33),
("B", 0.33),
("C", 0.34)
);
Assert.Equal("A", distribution.Select(0, 10));
Assert.Equal("A", distribution.Select(2, 10));
Assert.Equal("B", distribution.Select(3, 10));
Assert.Equal("C", distribution.Select(9, 10));
}
[Fact]
public void GetCounts_ReturnsCorrectCounts_ForEvenSplit()
{
var distribution = new Distribution<string>(
("X", 0.50),
("Y", 0.50)
);
var counts = distribution.GetCounts(100).ToList();
Assert.Equal(2, counts.Count);
Assert.Equal(("X", 50), counts[0]);
Assert.Equal(("Y", 50), counts[1]);
}
[Fact]
public void GetCounts_LastBucketReceivesRemainder()
{
var distribution = new Distribution<string>(
("A", 0.33),
("B", 0.33),
("C", 0.34)
);
var counts = distribution.GetCounts(100).ToList();
Assert.Equal(3, counts.Count);
Assert.Equal(33, counts[0].Count);
Assert.Equal(33, counts[1].Count);
Assert.Equal(34, counts[2].Count);
}
[Fact]
public void GetCounts_TotalCountsMatchInput()
{
var distribution = new Distribution<int>(
(1, 0.25),
(2, 0.25),
(3, 0.25),
(4, 0.25)
);
var counts = distribution.GetCounts(1000).ToList();
var total = counts.Sum(c => c.Count);
Assert.Equal(1000, total);
}
[Fact]
public void Select_IsDeterministic_SameInputSameOutput()
{
var distribution = new Distribution<string>(
("Alpha", 0.40),
("Beta", 0.35),
("Gamma", 0.25)
);
for (int i = 0; i < 100; i++)
{
var first = distribution.Select(i, 100);
var second = distribution.Select(i, 100);
Assert.Equal(first, second);
}
}
}

View File

@@ -0,0 +1,262 @@
using Bit.Seeder.Data;
using Bit.Seeder.Data.Distributions;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Options;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class GeneratorContextTests
{
[Fact]
public void FromOptions_SameDomain_ProducesSameSeed()
{
var options1 = CreateOptions("acme.com", ciphers: 100);
var options2 = CreateOptions("acme.com", ciphers: 100);
var ctx1 = GeneratorContext.FromOptions(options1);
var ctx2 = GeneratorContext.FromOptions(options2);
Assert.Equal(ctx1.Seed, ctx2.Seed);
}
[Fact]
public void FromOptions_DifferentDomains_ProduceDifferentSeeds()
{
var ctx1 = GeneratorContext.FromOptions(CreateOptions("acme.com"));
var ctx2 = GeneratorContext.FromOptions(CreateOptions("contoso.com"));
Assert.NotEqual(ctx1.Seed, ctx2.Seed);
}
[Fact]
public void FromOptions_ExplicitSeed_OverridesDomainHash()
{
var options = new OrganizationVaultOptions
{
Name = "Test Org",
Domain = "example.com",
Users = 10,
Ciphers = 100,
Seed = 42
};
var ctx = GeneratorContext.FromOptions(options);
Assert.Equal(42, ctx.Seed);
}
[Fact]
public void Username_SameSeed_ProducesSameOutput()
{
var options = CreateOptions("test.com", ciphers: 100);
var ctx1 = GeneratorContext.FromOptions(options);
var ctx2 = GeneratorContext.FromOptions(options);
for (int i = 0; i < 50; i++)
{
var username1 = ctx1.Username.GenerateByIndex(i, totalHint: 100, domain: "test.com");
var username2 = ctx2.Username.GenerateByIndex(i, totalHint: 100, domain: "test.com");
Assert.Equal(username1, username2);
}
}
[Fact]
public void Username_DifferentSeeds_ProducesDifferentOutput()
{
var ctx1 = GeneratorContext.FromOptions(CreateOptions("alpha.com"));
var ctx2 = GeneratorContext.FromOptions(CreateOptions("beta.com"));
var username1 = ctx1.Username.GenerateByIndex(0, domain: "alpha.com");
var username2 = ctx2.Username.GenerateByIndex(0, domain: "beta.com");
Assert.NotEqual(username1, username2);
}
[Fact]
public void Folder_SameSeed_ProducesSameOutput()
{
var options = CreateOptions("test.com");
var ctx1 = GeneratorContext.FromOptions(options);
var ctx2 = GeneratorContext.FromOptions(options);
for (int i = 0; i < 20; i++)
{
var folder1 = ctx1.Folder.GetFolderName(i);
var folder2 = ctx2.Folder.GetFolderName(i);
Assert.Equal(folder1, folder2);
}
}
[Fact]
public void Card_SameSeed_ProducesSameOutput()
{
var options = CreateOptions("test.com");
var ctx1 = GeneratorContext.FromOptions(options);
var ctx2 = GeneratorContext.FromOptions(options);
for (int i = 0; i < 20; i++)
{
var card1 = ctx1.Card.GenerateByIndex(i);
var card2 = ctx2.Card.GenerateByIndex(i);
Assert.Equal(card1.CardholderName, card2.CardholderName);
Assert.Equal(card1.Number, card2.Number);
Assert.Equal(card1.ExpMonth, card2.ExpMonth);
Assert.Equal(card1.ExpYear, card2.ExpYear);
Assert.Equal(card1.Code, card2.Code);
}
}
[Fact]
public void Identity_SameSeed_ProducesSameOutput()
{
var options = CreateOptions("test.com");
var ctx1 = GeneratorContext.FromOptions(options);
var ctx2 = GeneratorContext.FromOptions(options);
for (int i = 0; i < 20; i++)
{
var identity1 = ctx1.Identity.GenerateByIndex(i);
var identity2 = ctx2.Identity.GenerateByIndex(i);
Assert.Equal(identity1.FirstName, identity2.FirstName);
Assert.Equal(identity1.LastName, identity2.LastName);
Assert.Equal(identity1.Email, identity2.Email);
}
}
/// <summary>
/// Limited to 5 iterations to avoid a Bogus.Password() infinite loop bug
/// that occurs with certain seed/index combinations in WiFi/Database note categories.
/// The workaround is a known test workaround that doesn't affect production code.
/// </summary>
[Fact]
public void SecureNote_SameSeed_ProducesSameOutput()
{
var options = CreateOptions("test.com");
var ctx1 = GeneratorContext.FromOptions(options);
var ctx2 = GeneratorContext.FromOptions(options);
for (var i = 0; i < 5; i++)
{
var (title1, content1) = ctx1.SecureNote.GenerateByIndex(i);
var (title2, content2) = ctx2.SecureNote.GenerateByIndex(i);
Assert.Equal(title1, title2);
Assert.Equal(content1, content2);
}
}
[Fact]
public void CipherCount_ReflectsOptionsValue()
{
var options = CreateOptions("test.com", ciphers: 500);
var ctx = GeneratorContext.FromOptions(options);
Assert.Equal(500, ctx.CipherCount);
}
[Fact]
public void Username_WithCorporatePattern_AppliesCorrectFormat()
{
var options = new OrganizationVaultOptions
{
Name = "Test Org",
Domain = "corp.com",
Users = 10,
Ciphers = 100,
UsernamePattern = UsernamePatternType.FDotLast,
UsernameDistribution = new Distribution<UsernameCategory>(
(UsernameCategory.CorporateEmail, 1.0)
)
};
var ctx = GeneratorContext.FromOptions(options);
var username = ctx.Username.GenerateByIndex(0, domain: "corp.com");
Assert.Matches(@"^[a-z]\.[a-z]+@corp\.com$", username);
}
[Fact]
public void Username_WithRegion_ProducesCulturallyAppropriateNames()
{
var europeOptions = new OrganizationVaultOptions
{
Name = "Euro Corp",
Domain = "euro.com",
Users = 10,
Ciphers = 100,
Region = GeographicRegion.Europe,
UsernameDistribution = new Distribution<UsernameCategory>(
(UsernameCategory.CorporateEmail, 1.0)
)
};
var ctx = GeneratorContext.FromOptions(europeOptions);
var username = ctx.Username.GenerateByIndex(0, domain: "euro.com");
Assert.Contains("@euro.com", username);
Assert.Matches(@"^[\p{L}]+\.[\p{L}]+@euro\.com$", username);
}
[Fact]
public void Generators_AreLazilyInitialized()
{
var options = CreateOptions("test.com");
var ctx = GeneratorContext.FromOptions(options);
_ = ctx.Seed;
_ = ctx.Username.GenerateByIndex(0);
Assert.NotNull(ctx.Username);
Assert.NotNull(ctx.Folder);
Assert.NotNull(ctx.Card);
Assert.NotNull(ctx.Identity);
}
[Fact]
public void AllGenerators_ProduceDifferentOutputForDifferentIndices()
{
var ctx = GeneratorContext.FromOptions(CreateOptions("test.com", ciphers: 100));
var usernames = Enumerable.Range(0, 50)
.Select(i => ctx.Username.GenerateByIndex(i, domain: "test.com"))
.ToHashSet();
Assert.True(usernames.Count > 40, "Should generate mostly unique usernames");
var folders = Enumerable.Range(0, 50)
.Select(i => ctx.Folder.GetFolderName(i))
.ToHashSet();
Assert.True(folders.Count > 30, "Should generate diverse folder names");
var cards = Enumerable.Range(0, 50)
.Select(i => ctx.Card.GenerateByIndex(i).Number)
.ToHashSet();
Assert.True(cards.Count > 40, "Should generate mostly unique card numbers");
var identities = Enumerable.Range(0, 50)
.Select(i => ctx.Identity.GenerateByIndex(i).Email)
.ToHashSet();
Assert.True(identities.Count > 40, "Should generate mostly unique identity emails");
}
private static OrganizationVaultOptions CreateOptions(string domain, int ciphers = 100)
{
return new OrganizationVaultOptions
{
Name = "Test Org",
Domain = domain,
Users = 10,
Ciphers = ciphers
};
}
}

View File

@@ -110,7 +110,7 @@ public class RustSdkCipherTests
},
Fields =
[
new FieldViewDto { Name = "API Key", Value = "sk-secret-api-key-12345", Type = 1 },
new FieldViewDto { Name = "API Key", Value = "sk_test_FAKE_api_key_12345", Type = 1 },
new FieldViewDto { Name = "Client ID", Value = "client-id-xyz", Type = 0 }
]
};
@@ -128,7 +128,7 @@ public class RustSdkCipherTests
Assert.NotNull(decrypted?.Fields);
Assert.Equal(2, decrypted.Fields.Count);
Assert.Equal("API Key", decrypted.Fields[0].Name);
Assert.Equal("sk-secret-api-key-12345", decrypted.Fields[0].Value);
Assert.Equal("sk_test_FAKE_api_key_12345", decrypted.Fields[0].Value);
}
[Fact]
@@ -138,10 +138,10 @@ public class RustSdkCipherTests
var orgId = Guid.NewGuid();
// Create cipher using the seeder
var cipher = CipherSeeder.CreateOrganizationLoginCipher(
orgId,
var cipher = LoginCipherSeeder.Create(
orgKeys.Key,
name: "GitHub Account",
organizationId: orgId,
username: "developer@example.com",
password: "SecureP@ss123!",
uri: "https://github.com",
@@ -175,16 +175,16 @@ public class RustSdkCipherTests
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var cipher = CipherSeeder.CreateOrganizationLoginCipherWithFields(
Guid.NewGuid(),
var cipher = LoginCipherSeeder.Create(
orgKeys.Key,
name: "API Service",
organizationId: Guid.NewGuid(),
username: "service@example.com",
password: "SvcP@ss!",
uri: "https://api.example.com",
fields: [
("API Key", "sk-live-abc123", 1), // Hidden field
("Environment", "production", 0) // Text field
("API Key", "sk_test_FAKE_abc123", 1),
("Environment", "production", 0)
]);
var loginData = JsonSerializer.Deserialize<CipherLoginData>(cipher.Data);
@@ -204,7 +204,7 @@ public class RustSdkCipherTests
Assert.Equal(Core.Vault.Enums.FieldType.Text, fields[1].Type);
Assert.DoesNotContain("API Key", cipher.Data);
Assert.DoesNotContain("sk-live-abc123", cipher.Data);
Assert.DoesNotContain("sk_test_FAKE_abc123", cipher.Data);
}
private static CipherViewDto CreateTestLoginCipher()
@@ -223,4 +223,268 @@ public class RustSdkCipherTests
};
}
[Fact]
public void EncryptDecrypt_CardCipher_RoundtripPreservesPlaintext()
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var originalCipher = new CipherViewDto
{
Name = "My Visa Card",
Notes = "Primary card for online purchases",
Type = CipherTypes.Card,
Card = new CardViewDto
{
CardholderName = "John Doe",
Brand = "Visa",
Number = "4111111111111111",
ExpMonth = "12",
ExpYear = "2028",
Code = "123"
}
};
var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions);
var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key);
Assert.DoesNotContain("\"error\"", encryptedJson);
Assert.DoesNotContain("4111111111111111", encryptedJson);
Assert.DoesNotContain("John Doe", encryptedJson);
var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key);
var decrypted = JsonSerializer.Deserialize<CipherViewDto>(decryptedJson, SdkJsonOptions);
Assert.NotNull(decrypted?.Card);
Assert.Equal("4111111111111111", decrypted.Card.Number);
Assert.Equal("John Doe", decrypted.Card.CardholderName);
Assert.Equal("123", decrypted.Card.Code);
}
[Fact]
public void CipherSeeder_CardCipher_ProducesServerCompatibleFormat()
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var orgId = Guid.NewGuid();
var card = new CardViewDto
{
CardholderName = "Jane Smith",
Brand = "Mastercard",
Number = "5500000000000004",
ExpMonth = "06",
ExpYear = "2027",
Code = "456"
};
var cipher = CardCipherSeeder.Create(orgKeys.Key, name: "Business Card", card: card, organizationId: orgId, notes: "Company expenses");
Assert.Equal(orgId, cipher.OrganizationId);
Assert.Equal(Core.Vault.Enums.CipherType.Card, cipher.Type);
var cardData = JsonSerializer.Deserialize<CipherCardData>(cipher.Data);
Assert.NotNull(cardData);
var encStringPrefix = "2.";
Assert.StartsWith(encStringPrefix, cardData.Name);
Assert.StartsWith(encStringPrefix, cardData.CardholderName);
Assert.StartsWith(encStringPrefix, cardData.Number);
Assert.StartsWith(encStringPrefix, cardData.Code);
Assert.DoesNotContain("5500000000000004", cipher.Data);
Assert.DoesNotContain("Jane Smith", cipher.Data);
}
[Fact]
public void EncryptDecrypt_IdentityCipher_RoundtripPreservesPlaintext()
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var originalCipher = new CipherViewDto
{
Name = "Personal Identity",
Type = CipherTypes.Identity,
Identity = new IdentityViewDto
{
Title = "Mr",
FirstName = "John",
MiddleName = "Robert",
LastName = "Doe",
Email = "john.doe@example.com",
Phone = "+1-555-123-4567",
SSN = "123-45-6789",
Address1 = "123 Main Street",
City = "Anytown",
State = "CA",
PostalCode = "90210",
Country = "US"
}
};
var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions);
var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key);
Assert.DoesNotContain("\"error\"", encryptedJson);
Assert.DoesNotContain("123-45-6789", encryptedJson);
Assert.DoesNotContain("john.doe@example.com", encryptedJson);
var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key);
var decrypted = JsonSerializer.Deserialize<CipherViewDto>(decryptedJson, SdkJsonOptions);
Assert.NotNull(decrypted?.Identity);
Assert.Equal("John", decrypted.Identity.FirstName);
Assert.Equal("123-45-6789", decrypted.Identity.SSN);
Assert.Equal("john.doe@example.com", decrypted.Identity.Email);
}
[Fact]
public void CipherSeeder_IdentityCipher_ProducesServerCompatibleFormat()
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var orgId = Guid.NewGuid();
var identity = new IdentityViewDto
{
Title = "Dr",
FirstName = "Alice",
LastName = "Johnson",
Email = "alice@company.com",
SSN = "987-65-4321",
PassportNumber = "X12345678"
};
var cipher = IdentityCipherSeeder.Create(orgKeys.Key, name: "Dr. Alice Johnson", identity: identity, organizationId: orgId);
Assert.Equal(orgId, cipher.OrganizationId);
Assert.Equal(Core.Vault.Enums.CipherType.Identity, cipher.Type);
var identityData = JsonSerializer.Deserialize<CipherIdentityData>(cipher.Data);
Assert.NotNull(identityData);
var encStringPrefix = "2.";
Assert.StartsWith(encStringPrefix, identityData.Name);
Assert.StartsWith(encStringPrefix, identityData.FirstName);
Assert.StartsWith(encStringPrefix, identityData.SSN);
Assert.DoesNotContain("987-65-4321", cipher.Data);
Assert.DoesNotContain("Alice", cipher.Data);
}
[Fact]
public void EncryptDecrypt_SecureNoteCipher_RoundtripPreservesPlaintext()
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var originalCipher = new CipherViewDto
{
Name = "API Secrets",
Notes = "sk_test_FAKE_abc123xyz789key",
Type = CipherTypes.SecureNote,
SecureNote = new SecureNoteViewDto { Type = 0 }
};
var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions);
var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key);
Assert.DoesNotContain("\"error\"", encryptedJson);
Assert.DoesNotContain("sk_test_FAKE_abc123xyz789key", encryptedJson);
var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key);
var decrypted = JsonSerializer.Deserialize<CipherViewDto>(decryptedJson, SdkJsonOptions);
Assert.NotNull(decrypted);
Assert.Equal("API Secrets", decrypted.Name);
Assert.Equal("sk_test_FAKE_abc123xyz789key", decrypted.Notes);
}
[Fact]
public void CipherSeeder_SecureNoteCipher_ProducesServerCompatibleFormat()
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var orgId = Guid.NewGuid();
var cipher = SecureNoteCipherSeeder.Create(
orgKeys.Key,
name: "Production Secrets",
organizationId: orgId,
notes: "DATABASE_URL=postgres://user:FAKE_secret@db.example.com/prod");
Assert.Equal(orgId, cipher.OrganizationId);
Assert.Equal(Core.Vault.Enums.CipherType.SecureNote, cipher.Type);
var noteData = JsonSerializer.Deserialize<CipherSecureNoteData>(cipher.Data);
Assert.NotNull(noteData);
Assert.Equal(Core.Vault.Enums.SecureNoteType.Generic, noteData.Type);
var encStringPrefix = "2.";
Assert.StartsWith(encStringPrefix, noteData.Name);
Assert.StartsWith(encStringPrefix, noteData.Notes);
Assert.DoesNotContain("postgres://", cipher.Data);
Assert.DoesNotContain("secret", cipher.Data);
}
[Fact]
public void EncryptDecrypt_SshKeyCipher_RoundtripPreservesPlaintext()
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var originalCipher = new CipherViewDto
{
Name = "Dev Server Key",
Type = CipherTypes.SshKey,
SshKey = new SshKeyViewDto
{
PrivateKey = "-----BEGIN FAKE RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END FAKE RSA PRIVATE KEY-----",
PublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user@host",
Fingerprint = "SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8"
}
};
var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions);
var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key);
Assert.DoesNotContain("\"error\"", encryptedJson);
Assert.DoesNotContain("BEGIN FAKE RSA PRIVATE KEY", encryptedJson);
Assert.DoesNotContain("ssh-rsa AAAAB3", encryptedJson);
var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key);
var decrypted = JsonSerializer.Deserialize<CipherViewDto>(decryptedJson, SdkJsonOptions);
Assert.NotNull(decrypted?.SshKey);
Assert.Contains("BEGIN FAKE RSA PRIVATE KEY", decrypted.SshKey.PrivateKey);
Assert.StartsWith("ssh-rsa", decrypted.SshKey.PublicKey);
Assert.StartsWith("SHA256:", decrypted.SshKey.Fingerprint);
}
[Fact]
public void CipherSeeder_SshKeyCipher_ProducesServerCompatibleFormat()
{
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var orgId = Guid.NewGuid();
var sshKey = new SshKeyViewDto
{
PrivateKey = "-----BEGIN FAKE OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA...\n-----END FAKE OPENSSH PRIVATE KEY-----",
PublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample test@machine",
Fingerprint = "SHA256:examplefingerprint123"
};
var cipher = SshKeyCipherSeeder.Create(orgKeys.Key, name: "Production Deploy Key", sshKey: sshKey, organizationId: orgId);
Assert.Equal(orgId, cipher.OrganizationId);
Assert.Equal(Core.Vault.Enums.CipherType.SSHKey, cipher.Type);
var sshData = JsonSerializer.Deserialize<CipherSSHKeyData>(cipher.Data);
Assert.NotNull(sshData);
var encStringPrefix = "2.";
Assert.StartsWith(encStringPrefix, sshData.Name);
Assert.StartsWith(encStringPrefix, sshData.PrivateKey);
Assert.StartsWith(encStringPrefix, sshData.PublicKey);
Assert.StartsWith(encStringPrefix, sshData.KeyFingerprint);
Assert.DoesNotContain("BEGIN FAKE OPENSSH PRIVATE KEY", cipher.Data);
Assert.DoesNotContain("ssh-ed25519", cipher.Data);
}
}

View File

@@ -2,6 +2,7 @@
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Recipes;
using Bit.Seeder.Services;
using CommandDotNet;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
@@ -38,7 +39,8 @@ public class Program
var mapper = scopedServices.GetRequiredService<IMapper>();
var passwordHasher = scopedServices.GetRequiredService<IPasswordHasher<User>>();
var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var manglerService = scopedServices.GetRequiredService<IManglerService>();
var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
recipe.Seed(name: name, domain: domain, users: users);
}
@@ -48,17 +50,31 @@ public class Program
args.Validate();
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
ServiceCollectionExtension.ConfigureServices(services, enableMangling: args.Mangle);
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var manglerService = scopedServices.GetRequiredService<IManglerService>();
var recipe = new OrganizationWithVaultRecipe(
scopedServices.GetRequiredService<DatabaseContext>(),
scopedServices.GetRequiredService<IMapper>(),
scopedServices.GetRequiredService<IPasswordHasher<User>>());
scopedServices.GetRequiredService<IPasswordHasher<User>>(),
manglerService);
recipe.Seed(args.ToOptions());
if (!manglerService.IsEnabled)
{
return;
}
var map = manglerService.GetMangleMap();
Console.WriteLine("--- Mangled Data Map ---");
foreach (var (original, mangled) in map)
{
Console.WriteLine($"{original} -> {mangled}");
}
}
}

View File

@@ -39,6 +39,22 @@ DbSeeder.exe vault-organization -n TestOrg -u 10 -d test.com -c 50 -o Spotify
# Generate a small test organization with ciphers for manual testing
DbSeeder.exe vault-organization -n DevOrg -u 2 -d dev.local -c 10
# Generate an organization using a traditional structure
dotnet run --project DbSeederUtility.csproj -- vault-organization -n Test001 -d test001.com -u 50 -c 1000 -g 15 -o Traditional -m
# Generate an organization using a modern structure with a small vault
dotnet run --project DbSeederUtility.csproj -- vault-organization -n Test002 -d test002.com -u 500 -c 10000 -g 85 -o Modern -m
# Generate an organization using a spotify structure with a large vault
dotnet run --project DbSeederUtility.csproj -- vault-organization -n Test003 -d test003.com -u 8000 -c 100000 -g 125 -o Spotify -m
# Generate an organization using a traditional structure with a very small vault with European regional data
dotnet run --project DbSeederUtility.csproj -- vault-organization -n “TestOneEurope” -u 10 -c 100 -g 5 -d testOneEurope.com -o Traditional --region Europe
# Generate an organization using a traditional structure with a very small vault with Asia Pacific regional data
dotnet run --project DbSeederUtility.csproj -- vault-organization -n “TestOneAsiaPacific” -u 17 -c 600 -g 12 -d testOneAsiaPacific.com -o Traditional --region AsiaPacific
```
## Dependencies

View File

@@ -1,15 +1,17 @@
using Bit.Core.Entities;
using Bit.Seeder.Services;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Bit.DbSeederUtility;
public static class ServiceCollectionExtension
{
public static void ConfigureServices(ServiceCollection services)
public static void ConfigureServices(ServiceCollection services, bool enableMangling = false)
{
// Load configuration using the GlobalSettingsFactory
var globalSettings = GlobalSettingsFactory.GlobalSettings;
@@ -29,5 +31,14 @@ public static class ServiceCollectionExtension
.SetApplicationName("Bitwarden");
services.AddDatabaseRepositories(globalSettings);
if (enableMangling)
{
services.TryAddScoped<IManglerService, ManglerService>();
}
else
{
services.TryAddSingleton<IManglerService, NoOpManglerService>();
}
}
}

View File

@@ -34,6 +34,9 @@ public class VaultOrganizationArgs : IArgumentModel
[Option('r', "region", Description = "Geographic region for names: NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, or Global")]
public string? Region { get; set; }
[Option("mangle", Description = "Enable mangling for test isolation")]
public bool Mangle { get; set; } = false;
public void Validate()
{
if (Users < 1)

View File

@@ -1,39 +1,23 @@
# Seeder - Claude Code Context
# Bitwarden Seeder Library - Claude Code Configuration
## Ubiquitous Language
## Quick Reference
The Seeder follows six core patterns:
**For detailed pattern descriptions (Factories, Recipes, Models, Scenes, Queries, Data), read `README.md`.**
1. **Factories** - Create ONE entity with encryption. Named `{Entity}Seeder` with `Create{Type}{Entity}()` methods. Do not interact with database.
**For detailed usages of the Seeder library, read `util/DbSeederUtility/README.md` and `util/SeederApi/README.md`**
2. **Recipes** - Orchestrate MANY entities. Named `{DomainConcept}Recipe`. **MUST have `Seed()` method** as primary interface, not `AddToOrganization()` or similar. Use parameters for variations, not separate methods. Compose Factories internally.
## Commands
3. **Models** - DTOs bridging SDK ↔ Server format. Named `{Entity}ViewDto` (plaintext), `Encrypted{Entity}Dto` (SDK format). Pure data, no logic.
```bash
# Build
dotnet build util/Seeder/Seeder.csproj
4. **Scenes** - Complete test scenarios with ID mangling. Implement `IScene<TReques, TResult>`. Async, returns `SceneResult<TResult>` with MangleMap and result property populated with `TResult`. Named `{Scenario}Scene`.
# Run tests
dotnet test test/SeederApi.IntegrationTest/
5. **Queries** - Read-only data retrieval. Implement `IQuery<TRequest, TResult>`. Synchronous, no DB modifications. Named `{DataToRetrieve}Query`.
6. **Data** - Static, filterable test data collections (Companies, Passwords, Names, OrgStructures). Deterministic, composable. Enums provide public API.
## The Recipe Contract
Recipes follow strict rules (like a cooking recipe that you follow completely):
1. A Recipe SHALL have exactly one public method named `Seed()`
2. A Recipe MUST produce one cohesive result (like baking one complete cake)
3. A Recipe MAY have overloaded `Seed()` methods with different parameters
4. A Recipe SHALL use private helper methods for internal steps
5. A Recipe SHALL use BulkCopy for performance when creating multiple entities
6. A Recipe SHALL compose Factories for individual entity creation
7. A Recipe SHALL NOT expose implementation details as public methods
**Current violations** (to be refactored):
- `CiphersRecipe` - Uses `AddLoginCiphersToOrganization()` instead of `Seed()`
- `CollectionsRecipe` - Uses `AddFromStructure()` and `AddToOrganization()` instead of `Seed()`
- `GroupsRecipe` - Uses `AddToOrganization()` instead of `Seed()`
- `OrganizationDomainRecipe` - Uses `AddVerifiedDomainToOrganization()` instead of `Seed()`
# Run single test
dotnet test test/SeederApi.IntegrationTest/ --filter "FullyQualifiedName~TestMethodName"
```
## Pattern Decision Tree
@@ -41,175 +25,60 @@ Recipes follow strict rules (like a cooking recipe that you follow completely):
Need to create test data?
├─ ONE entity with encryption? → Factory
├─ MANY entities as cohesive operation? → Recipe
├─ Complete test scenario with ID mangling to be used by the Seeder API? → Scene
├─ Complete test scenario with ID mangling for SeederApi? → Scene
├─ READ existing seeded data? → Query
└─ Data transformation SDK ↔ Server? → Model
```
## When to Use the Seeder
## The Recipe Contract
✅ Use for:
Recipes follow strict rules:
- Local development database setup
- Integration test data creation
- Performance testing with realistic encrypted data
❌ Do NOT use for:
- Production data
- Copying real user vaults (use backup/restore instead)
1. A Recipe SHALL have exactly one public method named `Seed()`
2. A Recipe MUST produce one cohesive result
3. A Recipe MAY have overloaded `Seed()` methods with different parameters
4. A Recipe SHALL use private helper methods for internal steps
5. A Recipe SHALL use BulkCopy for performance when creating multiple entities
6. A Recipe SHALL compose Factories for individual entity creation
7. A Recipe SHALL NOT expose implementation details as public methods
## Zero-Knowledge Architecture
**Critical Principle:** Unencrypted vault data never leaves the client. The server never sees plaintext.
**Critical:** Unencrypted vault data never leaves the client. The server never sees plaintext.
### Why Seeder Uses the Rust SDK
The Seeder uses the Rust SDK via FFI because it must behave like a real Bitwarden client:
The Seeder must behave exactly like any other Bitwarden client. Since the server:
1. Generate encryption keys (like client account setup)
2. Encrypt vault data client-side (same SDK as real clients)
3. Store only encrypted result
- Never receives plaintext
- Cannot perform encryption (doesn't have keys)
- Only stores/retrieves encrypted blobs
...the Seeder cannot simply write plaintext to the database. It must:
1. Generate encryption keys (like a client does during account setup)
2. Encrypt vault data client-side (using the same SDK the real clients use)
3. Store only the encrypted result
This is why we use the Rust SDK via FFI - it's the same cryptographic implementation used by the official clients.
## Cipher Encryption Architecture
### The Two-State Pattern
Bitwarden uses a clean separation between encrypted and decrypted data:
| State | SDK Type | Description | Stored in DB? |
| --------- | ------------ | ------------------------- | ------------- |
| Plaintext | `CipherView` | Decrypted, human-readable | Never |
| Encrypted | `Cipher` | EncString values | Yes |
**Encryption flow:**
## Data Flow
```
CipherView (plaintext) → encrypt_composite() → Cipher (encrypted)
CipherViewDto → Rust SDK encrypt_cipher → EncryptedCipherDto → TransformToServer → Server Cipher Entity
```
**Decryption flow:**
Shared logic: `CipherEncryption.cs`, `EncryptedCipherDtoExtensions.cs`
```
Cipher (encrypted) → decrypt() → CipherView (plaintext)
```
## Rust SDK Version Alignment
### SDK vs Server Format Difference
| Component | Version Source |
| ----------- | ----------------------------------------- |
| Server Shim | `util/RustSdk/rust/Cargo.toml` git rev |
| Clients | `@bitwarden/sdk-internal` in clients repo |
**Critical:** The SDK and server use different JSON structures.
Before modifying SDK integration, run `RustSdkCipherTests` to validate roundtrip encryption.
**SDK Cipher (nested):**
## Deterministic Data Generation
```json
{
"name": "2.abc...",
"login": {
"username": "2.def...",
"password": "2.ghi..."
}
}
```
**Server Cipher.Data (flat CipherLoginData):**
```json
{
"Name": "2.abc...",
"Username": "2.def...",
"Password": "2.ghi..."
}
```
### Data Flow in Seeder
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ CipherViewDto │────▶│ Rust SDK │────▶│ EncryptedCipherDto │
│ (plaintext) │ │ encrypt_cipher │ │ (SDK Cipher) │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
┌───────────────────────┐
│ TransformToServer │
│ (flatten nested → │
│ flat structure) │
└───────────────────────┘
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Server Cipher │◀────│ CipherLoginData │◀────│ Flattened JSON │
│ Entity │ │ (serialized) │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
```
### Key Hierarchy
Bitwarden uses a two-level encryption hierarchy:
1. **User/Organization Key** - Encrypts the cipher's individual key
2. **Cipher Key** (optional) - Encrypts the actual cipher data
For seeding, we use the organization's symmetric key directly (no per-cipher key).
## Rust SDK FFI
### Error Handling
SDK functions return JSON with an `"error"` field on failure:
```json
{ "error": "Failed to parse CipherView JSON" }
```
Always check for `"error"` in the response before parsing.
## Testing
Integration tests in `test/SeederApi.IntegrationTest` verify:
1. **Roundtrip encryption** - Encrypt then decrypt preserves plaintext
2. **Server format compatibility** - Output matches CipherLoginData structure
3. **Field encryption** - Custom fields are properly encrypted
4. **Security** - Plaintext never appears in encrypted output
## Common Patterns
### Creating a Cipher
Same domain = same seed = reproducible data:
```csharp
var sdk = new RustSdkService();
var seeder = new CipherSeeder(sdk);
var cipher = seeder.CreateOrganizationLoginCipher(
organizationId,
orgKey, // Base64-encoded symmetric key
name: "My Login",
username: "user@example.com",
password: "secret123");
```
### Bulk Cipher Creation
```csharp
var recipe = new CiphersRecipe(dbContext, sdkService);
var cipherIds = recipe.AddLoginCiphersToOrganization(
organizationId,
orgKey,
collectionIds,
count: 100);
_seed = options.Seed ?? StableHash.ToInt32(options.Domain);
```
## Security Reminders
- Generated test passwords are intentionally weak (`asdfasdfasdf`)
- Never commit database dumps containing seeded data to version control
- Seeded keys are for testing only - regenerate for each test run
- Test password: `asdfasdfasdf`
- Never commit database dumps with seeded data
- Seeded keys are for testing only

View File

@@ -1,78 +0,0 @@
using Bit.Seeder.Data.Enums;
using Bogus;
using Bogus.DataSets;
namespace Bit.Seeder.Data;
/// <summary>
/// Provides locale-aware name generation using the Bogus library.
/// Maps GeographicRegion to appropriate Bogus locales for culturally-appropriate names.
/// </summary>
internal sealed class BogusNameProvider
{
private readonly Faker _faker;
public BogusNameProvider(GeographicRegion region, int? seed = null)
{
var locale = MapRegionToLocale(region, seed);
_faker = seed.HasValue
? new Faker(locale) { Random = new Randomizer(seed.Value) }
: new Faker(locale);
}
public string FirstName() => _faker.Name.FirstName();
public string FirstName(Name.Gender gender) => _faker.Name.FirstName(gender);
public string LastName() => _faker.Name.LastName();
private static string MapRegionToLocale(GeographicRegion region, int? seed) => region switch
{
GeographicRegion.NorthAmerica => "en_US",
GeographicRegion.Europe => GetRandomEuropeanLocale(seed),
GeographicRegion.AsiaPacific => GetRandomAsianLocale(seed),
GeographicRegion.LatinAmerica => GetRandomLatinAmericanLocale(seed),
GeographicRegion.MiddleEast => GetRandomMiddleEastLocale(seed),
GeographicRegion.Africa => GetRandomAfricanLocale(seed),
GeographicRegion.Global => "en",
_ => "en"
};
private static string GetRandomEuropeanLocale(int? seed)
{
var locales = new[] { "en_GB", "de", "fr", "es", "it", "nl", "pl", "pt_PT", "sv" };
return PickLocale(locales, seed);
}
private static string GetRandomAsianLocale(int? seed)
{
var locales = new[] { "ja", "ko", "zh_CN", "zh_TW", "vi" };
return PickLocale(locales, seed);
}
private static string GetRandomLatinAmericanLocale(int? seed)
{
var locales = new[] { "es_MX", "pt_BR", "es" };
return PickLocale(locales, seed);
}
private static string GetRandomMiddleEastLocale(int? seed)
{
// Bogus has limited Middle East support; use available Arabic/Turkish locales
var locales = new[] { "ar", "tr", "fa" };
return PickLocale(locales, seed);
}
private static string GetRandomAfricanLocale(int? seed)
{
// Bogus has limited African support; use South African English and French (West Africa)
var locales = new[] { "en_ZA", "fr" };
return PickLocale(locales, seed);
}
private static string PickLocale(string[] locales, int? seed)
{
var random = seed.HasValue ? new Random(seed.Value) : Random.Shared;
return locales[random.Next(locales.Length)];
}
}

View File

@@ -1,67 +0,0 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
/// <summary>
/// Generates deterministic usernames for companies using configurable patterns.
/// Uses Bogus library for locale-aware name generation while maintaining determinism
/// through pre-generated arrays indexed by a seed.
/// </summary>
internal sealed class CipherUsernameGenerator
{
private const int _namePoolSize = 1500;
private readonly Random _random;
private readonly UsernamePattern _pattern;
private readonly string[] _firstNames;
private readonly string[] _lastNames;
public CipherUsernameGenerator(
int seed,
UsernamePatternType patternType = UsernamePatternType.FirstDotLast,
GeographicRegion? region = null)
{
_random = new Random(seed);
_pattern = UsernamePatterns.GetPattern(patternType);
// Pre-generate arrays from Bogus for deterministic index-based access
var provider = new BogusNameProvider(region ?? GeographicRegion.Global, seed);
_firstNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.FirstName()).ToArray();
_lastNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.LastName()).ToArray();
}
public string Generate(Company company)
{
var firstName = _firstNames[_random.Next(_firstNames.Length)];
var lastName = _lastNames[_random.Next(_lastNames.Length)];
return _pattern.Generate(firstName, lastName, company.Domain);
}
/// <summary>
/// Generates username using index for deterministic selection across cipher iterations.
/// </summary>
public string GenerateByIndex(Company company, int index)
{
var firstName = _firstNames[index % _firstNames.Length];
var lastName = _lastNames[(index * 7) % _lastNames.Length]; // Prime multiplier for variety
return _pattern.Generate(firstName, lastName, company.Domain);
}
/// <summary>
/// Combines deterministic index with random offset for controlled variety.
/// </summary>
public string GenerateVaried(Company company, int index)
{
var offset = _random.Next(10);
var firstName = _firstNames[(index + offset) % _firstNames.Length];
var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length];
return _pattern.Generate(firstName, lastName, company.Domain);
}
public string GetFirstName(int index) => _firstNames[index % _firstNames.Length];
public string GetLastName(int index) => _lastNames[(index * 7) % _lastNames.Length];
}

View File

@@ -0,0 +1,50 @@
using Bit.Core.Vault.Enums;
namespace Bit.Seeder.Data.Distributions;
/// <summary>
/// Pre-configured cipher type distributions for seeding scenarios.
/// </summary>
public static class CipherTypeDistributions
{
/// <summary>
/// Realistic enterprise mix based on typical usage patterns.
/// 60% Login, 15% SecureNote, 12% Card, 10% Identity, 3% SshKey
/// </summary>
public static Distribution<CipherType> Realistic { get; } = new(
(CipherType.Login, 0.60),
(CipherType.SecureNote, 0.15),
(CipherType.Card, 0.12),
(CipherType.Identity, 0.10),
(CipherType.SSHKey, 0.03)
);
/// <summary>
/// Login-only distribution for backward compatibility or login-focused testing.
/// </summary>
public static Distribution<CipherType> LoginOnly { get; } = new(
(CipherType.Login, 1.0)
);
/// <summary>
/// Heavy on secure notes for documentation-focused organizations.
/// </summary>
public static Distribution<CipherType> DocumentationHeavy { get; } = new(
(CipherType.Login, 0.40),
(CipherType.SecureNote, 0.40),
(CipherType.Card, 0.10),
(CipherType.Identity, 0.07),
(CipherType.SSHKey, 0.03)
);
/// <summary>
/// Developer-focused with more SSH keys.
/// </summary>
public static Distribution<CipherType> DeveloperFocused { get; } = new(
(CipherType.Login, 0.50),
(CipherType.SecureNote, 0.20),
(CipherType.Card, 0.05),
(CipherType.Identity, 0.05),
(CipherType.SSHKey, 0.20)
);
}

View File

@@ -0,0 +1,65 @@
namespace Bit.Seeder.Data.Distributions;
/// <summary>
/// Provides deterministic, percentage-based item selection for test data generation.
/// Replaces duplicated distribution logic in GetRealisticStatus, GetFolderCountForUser, etc.
/// </summary>
/// <typeparam name="T">The type of values in the distribution.</typeparam>
public sealed class Distribution<T>
{
private readonly (T Value, double Percentage)[] _buckets;
/// <summary>
/// Creates a distribution from percentage buckets.
/// </summary>
/// <param name="buckets">Value-percentage pairs that must sum to 1.0 (within 0.001 tolerance).</param>
/// <exception cref="ArgumentException">Thrown when percentages don't sum to 1.0.</exception>
public Distribution(params (T Value, double Percentage)[] buckets)
{
var total = buckets.Sum(b => b.Percentage);
if (Math.Abs(total - 1.0) > 0.001)
{
throw new ArgumentException($"Percentages must sum to 1.0, got {total}");
}
_buckets = buckets;
}
/// <summary>
/// Selects a value deterministically based on index position within a total count.
/// Items 0 to (total * percentage1 - 1) get value1, and so on.
/// </summary>
/// <param name="index">Zero-based index of the item.</param>
/// <param name="total">Total number of items being distributed. For best accuracy, use totals >= 100.</param>
/// <returns>The value assigned to this index position.</returns>
public T Select(int index, int total)
{
var cumulative = 0;
foreach (var (value, percentage) in _buckets)
{
cumulative += (int)(total * percentage);
if (index < cumulative)
{
return value;
}
}
return _buckets[^1].Value;
}
/// <summary>
/// Returns all values with their calculated counts for a given total.
/// The last bucket receives any remainder from rounding.
/// </summary>
/// <param name="total">Total number of items to distribute.</param>
/// <returns>Sequence of value-count pairs.</returns>
public IEnumerable<(T Value, int Count)> GetCounts(int total)
{
var remaining = total;
for (var i = 0; i < _buckets.Length - 1; i++)
{
var count = (int)(total * _buckets[i].Percentage);
yield return (_buckets[i].Value, count);
remaining -= count;
}
yield return (_buckets[^1].Value, remaining);
}
}

View File

@@ -0,0 +1,19 @@
namespace Bit.Seeder.Data.Distributions;
/// <summary>
/// Pre-configured folder count distributions for user vault seeding.
/// </summary>
public static class FolderCountDistributions
{
/// <summary>
/// Realistic distribution of folders per user.
/// 35% have zero, 35% have 1-3, 20% have 4-7, 10% have 10-15.
/// Values are (Min, Max) ranges for deterministic selection.
/// </summary>
public static Distribution<(int Min, int Max)> Realistic { get; } = new(
((0, 1), 0.35),
((1, 4), 0.35),
((4, 8), 0.20),
((10, 16), 0.10)
);
}

View File

@@ -0,0 +1,21 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data.Distributions;
/// <summary>
/// Pre-configured password strength distributions for seeding scenarios.
/// </summary>
public static class PasswordDistributions
{
/// <summary>
/// Realistic distribution based on breach data and security research.
/// 25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong
/// </summary>
public static Distribution<PasswordStrength> Realistic { get; } = new(
(PasswordStrength.VeryWeak, 0.25),
(PasswordStrength.Weak, 0.30),
(PasswordStrength.Fair, 0.25),
(PasswordStrength.Strong, 0.15),
(PasswordStrength.VeryStrong, 0.05)
);
}

View File

@@ -0,0 +1,36 @@
using Bit.Core.Enums;
namespace Bit.Seeder.Data.Distributions;
/// <summary>
/// Pre-configured user status distributions for seeding scenarios.
/// </summary>
public static class UserStatusDistributions
{
/// <summary>
/// Realistic organization membership distribution.
/// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked
/// </summary>
public static Distribution<OrganizationUserStatusType> Realistic { get; } = new(
(OrganizationUserStatusType.Confirmed, 0.85),
(OrganizationUserStatusType.Invited, 0.05),
(OrganizationUserStatusType.Accepted, 0.05),
(OrganizationUserStatusType.Revoked, 0.05)
);
/// <summary>
/// All users confirmed - for simpler testing scenarios.
/// </summary>
public static Distribution<OrganizationUserStatusType> AllConfirmed { get; } = new(
(OrganizationUserStatusType.Confirmed, 1.0)
);
/// <summary>
/// New organization with many pending invites.
/// </summary>
public static Distribution<OrganizationUserStatusType> NewOrganization { get; } = new(
(OrganizationUserStatusType.Confirmed, 0.30),
(OrganizationUserStatusType.Invited, 0.50),
(OrganizationUserStatusType.Accepted, 0.20)
);
}

View File

@@ -0,0 +1,65 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data.Distributions;
/// <summary>
/// Pre-configured username category distributions for seeding scenarios.
/// Pass to CipherUsernameGenerator for different username mixes.
/// </summary>
public static class UsernameDistributions
{
/// <summary>
/// Realistic enterprise mix with variety.
/// 45% Corporate email, varied personal/legacy/social.
/// </summary>
public static Distribution<UsernameCategory> Realistic { get; } = new(
(UsernameCategory.CorporateEmail, 0.45),
(UsernameCategory.PersonalEmail, 0.15),
(UsernameCategory.SocialHandle, 0.10),
(UsernameCategory.UsernameOnly, 0.10),
(UsernameCategory.EmployeeId, 0.08),
(UsernameCategory.PhoneNumber, 0.05),
(UsernameCategory.LegacySystem, 0.04),
(UsernameCategory.RandomAlphanumeric, 0.03)
);
/// <summary>
/// Corporate-only: 100% corporate email format.
/// Use for strict enterprise environments.
/// </summary>
public static Distribution<UsernameCategory> CorporateOnly { get; } = new(
(UsernameCategory.CorporateEmail, 1.0)
);
/// <summary>
/// Consumer-focused: personal emails and social handles.
/// Use for B2C application testing.
/// </summary>
public static Distribution<UsernameCategory> Consumer { get; } = new(
(UsernameCategory.PersonalEmail, 0.40),
(UsernameCategory.SocialHandle, 0.25),
(UsernameCategory.UsernameOnly, 0.20),
(UsernameCategory.PhoneNumber, 0.15)
);
/// <summary>
/// Legacy enterprise: older systems with employee IDs.
/// Use for testing migrations from legacy systems.
/// </summary>
public static Distribution<UsernameCategory> LegacyEnterprise { get; } = new(
(UsernameCategory.CorporateEmail, 0.30),
(UsernameCategory.EmployeeId, 0.30),
(UsernameCategory.LegacySystem, 0.25),
(UsernameCategory.RandomAlphanumeric, 0.15)
);
/// <summary>
/// Developer-focused: mix of corporate and technical identifiers.
/// </summary>
public static Distribution<UsernameCategory> Developer { get; } = new(
(UsernameCategory.CorporateEmail, 0.35),
(UsernameCategory.UsernameOnly, 0.25),
(UsernameCategory.SocialHandle, 0.20),
(UsernameCategory.RandomAlphanumeric, 0.20)
);
}

View File

@@ -5,21 +5,28 @@
/// </summary>
public enum PasswordStrength
{
/// <summary>Score 0: Too guessable (&lt; 10³ guesses)</summary>
/// <summary>
/// Score 0: Too guessable (&lt; 10³ guesses)
/// </summary>
VeryWeak = 0,
/// <summary>Score 1: Very guessable (&lt; 10⁶ guesses)</summary>
/// <summary>
/// Score 1: Very guessable (&lt; 10⁶ guesses)
/// </summary>
Weak = 1,
/// <summary>Score 2: Somewhat guessable (&lt; 10⁸ guesses)</summary>
/// <summary>
/// Score 2: Somewhat guessable (&lt; 10⁸ guesses)
/// </summary>
Fair = 2,
/// <summary>Score 3: Safely unguessable (&lt; 10¹⁰ guesses)</summary>
/// <summary>
/// Score 3: Safely unguessable (&lt; 10¹⁰ guesses)
/// </summary>
Strong = 3,
/// <summary>Score 4: Very unguessable (≥ 10¹⁰ guesses)</summary>
VeryStrong = 4,
/// <summary>Realistic distribution based on breach data statistics.</summary>
Realistic = 99
/// <summary>
/// Score 4: Very unguessable (≥ 10¹⁰ guesses)
/// </summary>
VeryStrong = 4
}

View File

@@ -0,0 +1,48 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Categories of username formats found in real-world credential vaults.
/// Used with Distribution&lt;UsernameCategory&gt; for realistic username generation.
/// </summary>
public enum UsernameCategory
{
/// <summary>
/// Corporate email format: john.smith@acme.com
/// </summary>
CorporateEmail,
/// <summary>
/// Personal email: jsmith99@fake-gmail.com
/// </summary>
PersonalEmail,
/// <summary>
/// Social media handle: @john_smith_42
/// </summary>
SocialHandle,
/// <summary>
/// Plain username: johnsmith, jdoe1985
/// </summary>
UsernameOnly,
/// <summary>
/// Employee identifier: EMP001234, E-12345
/// </summary>
EmployeeId,
/// <summary>
/// Phone number as username: 15551234567
/// </summary>
PhoneNumber,
/// <summary>
/// Legacy system format: JSMITH01, DOEJ
/// </summary>
LegacySystem,
/// <summary>
/// Random alphanumeric: xK7mP9qR2n
/// </summary>
RandomAlphanumeric
}

View File

@@ -5,16 +5,33 @@
/// </summary>
public enum UsernamePatternType
{
/// <summary>first.last@domain.com</summary>
/// <summary>
/// first.last@domain.com
/// </summary>
FirstDotLast,
/// <summary>f.last@domain.com</summary>
/// <summary>
/// f.last@domain.com
/// </summary>
FDotLast,
/// <summary>flast@domain.com</summary>
/// <summary>
/// flast@domain.com
/// </summary>
FLast,
/// <summary>last.first@domain.com</summary>
/// <summary>
/// last.first@domain.com
/// </summary>
LastDotFirst,
/// <summary>first_last@domain.com</summary>
/// <summary>
/// first_last@domain.com
/// </summary>
First_Last,
/// <summary>lastf@domain.com</summary>
/// <summary>
/// lastf@domain.com
/// </summary>
LastFirst
}

View File

@@ -0,0 +1,87 @@
using System.Security.Cryptography;
using System.Text;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Data.Generators;
using Bit.Seeder.Options;
namespace Bit.Seeder.Data;
/// <summary>
/// Centralized context for all data generators in a seeding operation.
/// Lazy-initializes generators on first access to avoid creating unused instances.
/// </summary>
/// <remarks>
/// Adding a new generator:
/// 1. Add private nullable field
/// 2. Add public property with lazy initialization
/// 3. Use in Recipe via _ctx.NewGenerator.Method()
/// </remarks>
internal sealed class GeneratorContext
{
private readonly int _seed;
private readonly GeographicRegion _region;
private readonly OrganizationVaultOptions _options;
private GeneratorContext(int seed, GeographicRegion region, OrganizationVaultOptions options)
{
_seed = seed;
_region = region;
_options = options;
}
/// <summary>
/// Creates a GeneratorContext from vault options, deriving seed from domain if not specified.
/// </summary>
public static GeneratorContext FromOptions(OrganizationVaultOptions options)
{
var seed = options.Seed ?? DeriveStableSeed(options.Domain);
var region = options.Region ?? GeographicRegion.Global;
return new GeneratorContext(seed, region, options);
}
/// <summary>
/// Derives a stable 32-bit seed from a domain string using SHA256.
/// Same input always produces same output for deterministic generation.
/// </summary>
private static int DeriveStableSeed(string domain)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(domain));
return BitConverter.ToInt32(bytes, 0);
}
/// <summary>
/// The seed used for deterministic generation. Exposed for distribution calculations.
/// </summary>
public int Seed => _seed;
/// <summary>
/// Total cipher count from options. Used for distribution calculations.
/// </summary>
public int CipherCount => _options.Ciphers;
private CipherUsernameGenerator? _username;
public CipherUsernameGenerator Username => _username ??= new(
_seed,
_options.UsernameDistribution,
_region,
_options.UsernamePattern);
private FolderNameGenerator? _folder;
public FolderNameGenerator Folder => _folder ??= new(_seed);
private CardDataGenerator? _card;
public CardDataGenerator Card => _card ??= new(_seed, _region);
private IdentityDataGenerator? _identity;
public IdentityDataGenerator Identity => _identity ??= new(_seed, _region);
private SecureNoteDataGenerator? _secureNote;
public SecureNoteDataGenerator SecureNote => _secureNote ??= new(_seed);
}

View File

@@ -0,0 +1,75 @@
using System.Globalization;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Models;
using Bogus;
namespace Bit.Seeder.Data.Generators;
internal sealed class CardDataGenerator
{
private readonly int _seed;
private readonly GeographicRegion _region;
private static readonly Dictionary<GeographicRegion, string[]> _regionalBrands = new()
{
[GeographicRegion.NorthAmerica] = ["Visa", "Mastercard", "Amex", "Discover"],
[GeographicRegion.Europe] = ["Visa", "Mastercard", "Maestro", "Amex"],
[GeographicRegion.AsiaPacific] = ["Visa", "Mastercard", "JCB", "UnionPay"],
[GeographicRegion.LatinAmerica] = ["Visa", "Mastercard", "Elo", "Amex"],
[GeographicRegion.MiddleEast] = ["Visa", "Mastercard", "Amex"],
[GeographicRegion.Africa] = ["Visa", "Mastercard"],
[GeographicRegion.Global] = ["Visa", "Mastercard", "Amex", "Discover", "JCB", "UnionPay", "Maestro", "Elo"]
};
internal CardDataGenerator(int seed, GeographicRegion region = GeographicRegion.Global)
{
_seed = seed;
_region = region;
}
/// <summary>
/// Generates a deterministic card based on index.
/// </summary>
internal CardViewDto GenerateByIndex(int index)
{
var seededFaker = new Faker { Random = new Randomizer(_seed + index) };
var brands = _regionalBrands[_region];
var brand = brands[index % brands.Length];
return new CardViewDto
{
CardholderName = seededFaker.Name.FullName(),
Brand = brand,
Number = GenerateNumber(brand, seededFaker),
ExpMonth = ((index % 12) + 1).ToString("D2", CultureInfo.InvariantCulture),
ExpYear = (DateTime.Now.Year + (index % 5) + 1).ToString(CultureInfo.InvariantCulture),
Code = GenerateCode(brand, seededFaker)
};
}
private static string GenerateNumber(string brand, Faker faker) => brand switch
{
// North American / Global
"Visa" => "4" + faker.Random.ReplaceNumbers("###############"),
"Mastercard" => faker.PickRandom("51", "52", "53", "54", "55") + faker.Random.ReplaceNumbers("##############"),
"Amex" => faker.PickRandom("34", "37") + faker.Random.ReplaceNumbers("#############"),
"Discover" => "6011" + faker.Random.ReplaceNumbers("############"),
// Europe
"Maestro" => faker.PickRandom("5018", "5020", "5038", "5893", "6304") + faker.Random.ReplaceNumbers("############"),
// Asia Pacific
"JCB" => "35" + faker.Random.ReplaceNumbers("##############"),
"UnionPay" => "62" + faker.Random.ReplaceNumbers("##############"),
// Latin America
"Elo" => faker.PickRandom("4011", "4312", "4389", "5041", "5066", "5067", "6277", "6362", "6363") + faker.Random.ReplaceNumbers("############"),
_ => faker.Finance.CreditCardNumber()
};
private static string GenerateCode(string brand, Faker faker) =>
brand == "Amex"
? faker.Random.Int(1000, 9999).ToString(CultureInfo.InvariantCulture)
: faker.Random.Int(100, 999).ToString(CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,235 @@
using Bit.Seeder.Data.Distributions;
using Bit.Seeder.Data.Enums;
using Bogus;
namespace Bit.Seeder.Data.Generators;
/// <summary>
/// Generates diverse usernames based on configurable category distributions.
/// Supports corporate emails, personal emails, social handles, employee IDs, and more.
/// Includes locale-aware name generation for culturally-appropriate usernames.
/// </summary>
internal sealed class CipherUsernameGenerator
{
private const int NamePoolSize = 1500;
private static readonly string[] PersonalEmailDomains =
["fake-gmail.com", "fake-yahoo.com", "fake-outlook.com", "fake-hotmail.com", "fake-icloud.com"];
private static readonly string[] SocialPlatformPrefixes =
["@", "@", "@", ""]; // 75% chance of @ prefix
private static readonly string[] EuropeanLocales =
["en_GB", "de", "fr", "es", "it", "nl", "pl", "pt_PT", "sv"];
private static readonly string[] AsianLocales =
["ja", "ko", "zh_CN", "zh_TW", "vi"];
private static readonly string[] LatinAmericanLocales =
["es_MX", "pt_BR", "es"];
private static readonly string[] MiddleEastLocales =
["ar", "tr", "fa"];
private static readonly string[] AfricanLocales =
["en_ZA", "fr"];
private readonly int _seed;
private readonly Distribution<UsernameCategory> _distribution;
private readonly UsernamePatternType _corporateEmailPattern;
private readonly string[] _firstNames;
private readonly string[] _lastNames;
/// <summary>
/// Creates a username generator with the specified distribution and settings.
/// </summary>
/// <param name="seed">Seed for deterministic generation.</param>
/// <param name="distribution">Distribution of username categories. Use UsernameDistributions.Realistic for defaults.</param>
/// <param name="region">Geographic region for culturally-appropriate name generation.</param>
/// <param name="corporateEmailPattern">Pattern for corporate emails (default: first.last@domain).</param>
internal CipherUsernameGenerator(
int seed,
Distribution<UsernameCategory> distribution,
GeographicRegion region = GeographicRegion.Global,
UsernamePatternType corporateEmailPattern = UsernamePatternType.FirstDotLast)
{
_seed = seed;
_distribution = distribution;
_corporateEmailPattern = corporateEmailPattern;
// Build locale-aware name pools
var locale = MapRegionToLocale(region, seed);
var faker = new Faker(locale) { Random = new Randomizer(seed) };
_firstNames = Enumerable.Range(0, NamePoolSize).Select(_ => faker.Name.FirstName()).ToArray();
_lastNames = Enumerable.Range(0, NamePoolSize).Select(_ => faker.Name.LastName()).ToArray();
}
/// <summary>
/// Generates a deterministic username based on index and optional domain.
/// Category is selected based on the configured distribution.
/// </summary>
/// <param name="index">Index for deterministic selection.</param>
/// <param name="totalHint">Total number of items (for distribution calculation). Default: 1000.</param>
/// <param name="domain">Corporate domain (used for CorporateEmail category).</param>
internal string GenerateByIndex(int index, int totalHint = 1000, string? domain = null)
{
var category = _distribution.Select(index, totalHint);
var seededFaker = new Faker { Random = new Randomizer(_seed + index) };
var offset = GetDeterministicOffset(index);
var firstName = _firstNames[(index + offset) % _firstNames.Length];
var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length];
return category switch
{
UsernameCategory.CorporateEmail => GenerateCorporateEmail(firstName, lastName, domain ?? "example.com"),
UsernameCategory.PersonalEmail => GeneratePersonalEmail(seededFaker, firstName, lastName, index),
UsernameCategory.SocialHandle => GenerateSocialHandle(seededFaker, firstName, lastName, index),
UsernameCategory.UsernameOnly => GenerateUsernameOnly(seededFaker, firstName, lastName, index),
UsernameCategory.EmployeeId => GenerateEmployeeId(seededFaker, index),
UsernameCategory.PhoneNumber => GeneratePhoneNumber(seededFaker, index),
UsernameCategory.LegacySystem => GenerateLegacySystem(firstName, lastName, index),
UsernameCategory.RandomAlphanumeric => GenerateRandomAlphanumeric(seededFaker),
_ => GenerateCorporateEmail(firstName, lastName, domain ?? "example.com")
};
}
private string GenerateCorporateEmail(string firstName, string lastName, string domain)
{
var first = firstName.ToLowerInvariant();
var last = lastName.ToLowerInvariant();
var f = char.ToLowerInvariant(firstName[0]);
return _corporateEmailPattern switch
{
UsernamePatternType.FirstDotLast => $"{first}.{last}@{domain}",
UsernamePatternType.FDotLast => $"{f}.{last}@{domain}",
UsernamePatternType.FLast => $"{f}{last}@{domain}",
UsernamePatternType.LastDotFirst => $"{last}.{first}@{domain}",
UsernamePatternType.First_Last => $"{first}_{last}@{domain}",
UsernamePatternType.LastFirst => $"{last}{f}@{domain}",
_ => $"{first}.{last}@{domain}"
};
}
private static string GeneratePersonalEmail(Faker faker, string firstName, string lastName, int index)
{
var domain = PersonalEmailDomains[index % PersonalEmailDomains.Length];
var style = index % 5;
return style switch
{
0 => $"{firstName.ToLowerInvariant()}.{lastName.ToLowerInvariant()}{faker.Random.Int(1, 99)}@{domain}",
1 => $"{firstName.ToLowerInvariant()}{faker.Random.Int(1970, 2005)}@{domain}",
2 => $"{char.ToLowerInvariant(firstName[0])}{lastName.ToLowerInvariant()}{faker.Random.Int(1, 999)}@{domain}",
3 => $"{lastName.ToLowerInvariant()}.{firstName.ToLowerInvariant()}@{domain}",
_ => $"{firstName.ToLowerInvariant()}_{faker.Random.Int(100, 9999)}@{domain}"
};
}
private static string GenerateSocialHandle(Faker faker, string firstName, string lastName, int index)
{
var prefix = SocialPlatformPrefixes[index % SocialPlatformPrefixes.Length];
var style = index % 6;
var handle = style switch
{
0 => $"{firstName.ToLowerInvariant()}_{lastName.ToLowerInvariant()}",
1 => $"{firstName.ToLowerInvariant()}{faker.Random.Int(1, 999)}",
2 => $"{char.ToLowerInvariant(firstName[0])}{lastName.ToLowerInvariant()}",
3 => $"{firstName.ToLowerInvariant()}_{faker.Random.Int(10, 99)}",
4 => $"the_{firstName.ToLowerInvariant()}",
_ => $"{lastName.ToLowerInvariant()}{char.ToLowerInvariant(firstName[0])}{faker.Random.Int(1, 99)}"
};
return $"{prefix}{handle}";
}
private static string GenerateUsernameOnly(Faker faker, string firstName, string lastName, int index)
{
var style = index % 5;
return style switch
{
0 => $"{firstName.ToLowerInvariant()}{lastName.ToLowerInvariant()}",
1 => $"{firstName.ToLowerInvariant()}.{lastName.ToLowerInvariant()}",
2 => $"{char.ToLowerInvariant(firstName[0])}{lastName.ToLowerInvariant()}{faker.Random.Int(1, 99)}",
3 => $"{firstName.ToLowerInvariant()}{faker.Random.Int(1980, 2010)}",
_ => $"{lastName.ToLowerInvariant()}_{firstName.ToLowerInvariant()}"
};
}
private static string GenerateEmployeeId(Faker faker, int index)
{
var style = index % 4;
return style switch
{
0 => $"EMP{100000 + index:D6}",
1 => $"E-{faker.Random.Int(10000, 99999)}",
2 => $"USR{faker.Random.Int(10000, 99999):D5}",
_ => $"{faker.Random.AlphaNumeric(2).ToUpperInvariant()}{faker.Random.Int(1000, 9999)}"
};
}
private static string GeneratePhoneNumber(Faker faker, int index)
{
// No + prefix per requirements
var areaCode = 200 + (index % 800); // Valid US area codes start at 200
var exchange = faker.Random.Int(200, 999);
var subscriber = faker.Random.Int(1000, 9999);
return $"1{areaCode}{exchange}{subscriber}";
}
private static string GenerateLegacySystem(string firstName, string lastName, int index)
{
var style = index % 4;
return style switch
{
0 => $"{lastName.ToUpperInvariant()[..Math.Min(6, lastName.Length)]}{char.ToUpperInvariant(firstName[0])}{(index % 100):D2}",
1 => $"{char.ToUpperInvariant(firstName[0])}{lastName.ToUpperInvariant()[..Math.Min(7, lastName.Length)]}",
2 => $"{lastName.ToUpperInvariant()[..Math.Min(4, lastName.Length)]}{firstName.ToUpperInvariant()[..Math.Min(2, firstName.Length)]}{index % 10}",
_ => $"U{(10000 + index):D5}"
};
}
private static string GenerateRandomAlphanumeric(Faker faker)
{
return faker.Random.AlphaNumeric(10);
}
private int GetDeterministicOffset(int index)
{
unchecked
{
var hash = _seed;
hash = hash * 397 ^ index;
return ((hash % 10) + 10) % 10;
}
}
private static string MapRegionToLocale(GeographicRegion region, int seed) => region switch
{
GeographicRegion.NorthAmerica => "en_US",
GeographicRegion.Europe => PickLocale(EuropeanLocales, seed),
GeographicRegion.AsiaPacific => PickLocale(AsianLocales, seed),
GeographicRegion.LatinAmerica => PickLocale(LatinAmericanLocales, seed),
GeographicRegion.MiddleEast => PickLocale(MiddleEastLocales, seed),
GeographicRegion.Africa => PickLocale(AfricanLocales, seed),
GeographicRegion.Global => "en",
_ => "en"
};
private static string PickLocale(string[] locales, int seed)
{
var length = locales.Length;
var index = ((seed % length) + length) % length;
return locales[index];
}
}

View File

@@ -1,18 +1,14 @@
using Bogus;
namespace Bit.Seeder.Data;
namespace Bit.Seeder.Data.Generators;
/// <summary>
/// Generates deterministic folder names using Bogus Commerce.Department().
/// Pre-generates a pool of business-themed names for consistent index-based access.
/// </summary>
internal sealed class FolderNameGenerator
{
private const int _namePoolSize = 50;
private readonly string[] _folderNames;
public FolderNameGenerator(int seed)
internal FolderNameGenerator(int seed)
{
var faker = new Faker { Random = new Randomizer(seed) };
@@ -27,5 +23,5 @@ internal sealed class FolderNameGenerator
/// <summary>
/// Gets a folder name by index, wrapping around if index exceeds pool size.
/// </summary>
public string GetFolderName(int index) => _folderNames[index % _folderNames.Length];
internal string GetFolderName(int index) => _folderNames[index % _folderNames.Length];
}

View File

@@ -0,0 +1,92 @@
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Models;
using Bogus;
namespace Bit.Seeder.Data.Generators;
internal sealed class IdentityDataGenerator(int seed, GeographicRegion region = GeographicRegion.Global)
{
private readonly int _seed = seed;
private readonly GeographicRegion _region = region;
private static readonly Dictionary<GeographicRegion, string[]> _regionalTitles = new()
{
[GeographicRegion.NorthAmerica] = ["Mr", "Mrs", "Ms", "Dr", "Prof"],
[GeographicRegion.Europe] = ["Mr", "Mrs", "Ms", "Dr", "Prof", "Sir", "Dame"],
[GeographicRegion.AsiaPacific] = ["Mr", "Mrs", "Ms", "Dr"],
[GeographicRegion.LatinAmerica] = ["Sr", "Sra", "Srta", "Dr", "Prof"],
[GeographicRegion.MiddleEast] = ["Mr", "Mrs", "Ms", "Dr", "Sheikh", "Sheikha"],
[GeographicRegion.Africa] = ["Mr", "Mrs", "Ms", "Dr", "Chief"],
[GeographicRegion.Global] = ["Mr", "Mrs", "Ms", "Dr", "Prof"]
};
/// <summary>
/// Generates a deterministic identity based on index.
/// </summary>
internal IdentityViewDto GenerateByIndex(int index)
{
var seededFaker = new Faker(MapRegionToLocale(_region)) { Random = new Randomizer(_seed + index) };
var person = seededFaker.Person;
var titles = _regionalTitles[_region];
return new IdentityViewDto
{
Title = titles[index % titles.Length],
FirstName = person.FirstName,
MiddleName = index % 3 == 0 ? seededFaker.Name.FirstName() : null,
LastName = person.LastName,
Address1 = seededFaker.Address.StreetAddress(),
Address2 = index % 5 == 0 ? seededFaker.Address.SecondaryAddress() : null,
Address3 = null,
City = seededFaker.Address.City(),
State = seededFaker.Address.StateAbbr(),
PostalCode = seededFaker.Address.ZipCode(),
Country = GetCountryCode(seededFaker),
Company = index % 2 == 0 ? seededFaker.Company.CompanyName() : null,
Email = person.Email,
Phone = seededFaker.Phone.PhoneNumber(),
SSN = GenerateNationalIdByIndex(index),
Username = person.UserName,
PassportNumber = index % 3 == 0 ? GeneratePassportNumberByIndex(index) : null,
LicenseNumber = index % 2 == 0 ? GenerateLicenseNumberByIndex(index) : null
};
}
private string GenerateNationalIdByIndex(int index) => _region switch
{
GeographicRegion.NorthAmerica => $"{100 + (index % 899):D3}-{10 + (index % 90):D2}-{1000 + (index % 9000):D4}",
GeographicRegion.Europe => $"AB {10 + (index % 90):D2} {10 + ((index + 1) % 90):D2} {10 + ((index + 2) % 90):D2} C",
GeographicRegion.AsiaPacific => $"{1000 + (index % 9000):D4}-{1000 + ((index + 1) % 9000):D4}-{1000 + ((index + 2) % 9000):D4}",
GeographicRegion.LatinAmerica => $"{100 + (index % 900):D3}.{100 + ((index + 1) % 900):D3}.{100 + ((index + 2) % 900):D3}-{10 + (index % 90):D2}",
_ => $"{100 + (index % 899):D3}-{10 + (index % 90):D2}-{1000 + (index % 9000):D4}"
};
private static string GeneratePassportNumberByIndex(int index) =>
$"{(char)('A' + index % 26)}{10000000 + index}";
private static string GenerateLicenseNumberByIndex(int index) =>
$"DL{1000000 + index}";
private string GetCountryCode(Faker faker) => _region switch
{
GeographicRegion.NorthAmerica => faker.PickRandom("US", "CA"),
GeographicRegion.Europe => faker.PickRandom("GB", "DE", "FR", "ES", "IT", "NL"),
GeographicRegion.AsiaPacific => faker.PickRandom("JP", "CN", "IN", "AU", "KR", "SG"),
GeographicRegion.LatinAmerica => faker.PickRandom("BR", "MX", "AR", "CO", "CL"),
GeographicRegion.MiddleEast => faker.PickRandom("AE", "SA", "IL", "TR"),
GeographicRegion.Africa => faker.PickRandom("ZA", "NG", "EG", "KE"),
_ => faker.Address.CountryCode()
};
private static string MapRegionToLocale(GeographicRegion region) => region switch
{
GeographicRegion.NorthAmerica => "en_US",
GeographicRegion.Europe => "en_GB",
GeographicRegion.AsiaPacific => "en",
GeographicRegion.LatinAmerica => "es",
GeographicRegion.MiddleEast => "en",
GeographicRegion.Africa => "en",
_ => "en"
};
}

View File

@@ -0,0 +1,167 @@
using System.Globalization;
using Bogus;
namespace Bit.Seeder.Data.Generators;
internal sealed class SecureNoteDataGenerator(int seed)
{
private readonly int _seed = seed;
private static readonly string[] _noteCategories =
[
"API Keys & Secrets",
"License Keys",
"Recovery Codes",
"Network Credentials",
"Server Information",
"Documentation",
"WiFi Passwords",
"Database Credentials",
"Cloud Console Access",
"Meeting Room Codes",
"Vendor Portal",
"Building Access",
"Expense System",
"Coffee Machine",
"Parking Garage"
];
/// <summary>
/// Generates a deterministic secure note based on index for reproducible test data.
/// </summary>
/// <returns>Tuple of (name, notes) for the secure note cipher.</returns>
internal (string name, string notes) GenerateByIndex(int index)
{
var category = _noteCategories[index % _noteCategories.Length];
var seededFaker = new Faker { Random = new Randomizer(_seed + index) };
return (GenerateNoteName(category, seededFaker), GenerateNoteContent(category, seededFaker));
}
private static string GenerateNoteName(string category, Faker faker) => category switch
{
"API Keys & Secrets" => $"{faker.Company.CompanyName()} API Key",
"License Keys" => $"{faker.Commerce.ProductName()} License",
"Recovery Codes" => $"{faker.Internet.DomainName()} Recovery Codes",
"Network Credentials" => $"{faker.Company.CompanyName()} VPN",
"Server Information" => $"{faker.Hacker.Noun()}-{faker.Random.Int(1, 99)} Server Info",
"Documentation" => $"{faker.Commerce.Department()} Docs",
"WiFi Passwords" => $"{faker.PickRandom("Office", "Guest", "Executive", "Lab", "Warehouse")} WiFi - Floor {faker.Random.Int(1, 12)}",
"Database Credentials" => $"{faker.PickRandom("Production", "Staging", "Analytics", "Reporting")} {faker.PickRandom("MySQL", "PostgreSQL", "MongoDB", "Redis")}",
"Cloud Console Access" => $"{faker.PickRandom("AWS", "Azure", "GCP", "DigitalOcean")} - {faker.Company.CompanyName()}",
"Meeting Room Codes" => $"{faker.Address.City()} Conference Room",
"Vendor Portal" => $"{faker.Company.CompanyName()} Vendor Portal",
"Building Access" => $"{faker.Address.StreetName()} Office Access",
"Expense System" => $"{faker.PickRandom("Concur", "Expensify", "SAP", "Corporate Card")} Access",
"Coffee Machine" => $"{faker.PickRandom("Break Room", "Executive Lounge", "Cafeteria", "Kitchen")} Coffee Machine",
"Parking Garage" => $"{faker.Address.StreetName()} Parking",
_ => faker.Lorem.Sentence(3)
};
private static string GenerateNoteContent(string category, Faker faker) => category switch
{
"API Keys & Secrets" => $"""
API Key: sk_test_FAKE_{faker.Random.AlphaNumeric(32)}
Created: {faker.Date.Past():yyyy-MM-dd}
Environment: {faker.PickRandom("production", "staging", "development")}
""",
"License Keys" => $"""
License: {faker.Random.AlphaNumeric(5).ToUpper(CultureInfo.InvariantCulture)}-{faker.Random.AlphaNumeric(5).ToUpper(CultureInfo.InvariantCulture)}-{faker.Random.AlphaNumeric(5).ToUpper(CultureInfo.InvariantCulture)}
Expires: {faker.Date.Future():yyyy-MM-dd}
Seats: {faker.Random.Int(1, 100)}
""",
"Recovery Codes" => string.Join("\n",
Enumerable.Range(1, 10).Select(i => $"{i}. {faker.Random.AlphaNumeric(8).ToLower(CultureInfo.InvariantCulture)}")),
"Network Credentials" => $"""
Host: vpn.{faker.Internet.DomainName()}
Port: {faker.PickRandom(443, 1194, 500)}
Protocol: {faker.PickRandom("OpenVPN", "IKEv2", "WireGuard")}
""",
"Server Information" => $"""
Host: {faker.Internet.Ip()}
SSH Port: 22
OS: {faker.PickRandom("Ubuntu 22.04", "Debian 12", "CentOS 9")}
""",
"Documentation" => faker.Lorem.Paragraphs(2),
"WiFi Passwords" => $"""
Network: {faker.Company.CompanyName()}-{faker.PickRandom("Corp", "Guest", "IoT", "Secure")}
Password: {faker.Internet.Password(12)}
Security: {faker.PickRandom("WPA2-Enterprise", "WPA3", "WPA2-PSK")}
Note: {faker.PickRandom("Rotates quarterly", "Ask IT for guest access", "Do not share externally")}
""",
"Database Credentials" => $"""
Host: {faker.Hacker.Noun()}-db-{faker.Random.Int(1, 9)}.{faker.Internet.DomainName()}
Port: {faker.PickRandom(3306, 5432, 27017, 6379)}
Database: {faker.Hacker.Noun()}_{faker.PickRandom("prod", "staging", "analytics")}
Username: svc_{faker.Hacker.Noun()}_{faker.Random.Int(100, 999)}
Password: {faker.Internet.Password(24)}
""",
"Cloud Console Access" => $"""
Console: {faker.PickRandom("https://console.aws.amazon.com", "https://portal.azure.com", "https://console.cloud.google.com")}
Account ID: {faker.Random.Int(100000000, 999999999)}
IAM User: {faker.Internet.UserName()}
MFA Device: {faker.PickRandom("Yubikey", "Google Authenticator", "Authy", "1Password")}
Role: {faker.PickRandom("AdministratorAccess", "PowerUserAccess", "ReadOnlyAccess", "BillingAccess")}
""",
"Meeting Room Codes" => $"""
Room: {faker.Address.City()} {faker.PickRandom("A", "B", "C", "")}{faker.Random.Int(100, 450)}
Capacity: {faker.PickRandom(4, 6, 8, 12, 20)} people
PIN: {faker.Random.Int(1000, 9999)}
Zoom Room ID: {faker.Random.Int(100, 999)}-{faker.Random.Int(100, 999)}-{faker.Random.Int(1000, 9999)}
AV Contact: x{faker.Random.Int(1000, 9999)}
""",
"Vendor Portal" => $"""
URL: https://vendor.{faker.Internet.DomainName()}/portal
Company ID: {faker.Random.AlphaNumeric(8).ToUpper(CultureInfo.InvariantCulture)}
Username: {faker.Internet.Email()}
Password: {faker.Internet.Password(16)}
Support: {faker.Phone.PhoneNumber("1-800-###-####")}
Account Rep: {faker.Name.FullName()}
""",
"Building Access" => $"""
Address: {faker.Address.StreetAddress()}, {faker.Address.City()}
Alarm Code: {faker.Random.Int(1000, 9999)}#
Disarm Window: {faker.Random.Int(30, 90)} seconds
Emergency Contact: {faker.Phone.PhoneNumber()}
After Hours: {faker.PickRandom("Call security at x5555", "Use side entrance", "Badge required 24/7")}
""",
"Expense System" => $"""
System: {faker.PickRandom("Concur", "Expensify", "SAP Concur", "Certify")}
Employee ID: {faker.Random.AlphaNumeric(6).ToUpper(CultureInfo.InvariantCulture)}
Approval Limit: ${faker.Random.Int(500, 5000):N0}
Corporate Card: **** **** **** {faker.Random.Int(1000, 9999)}
PIN: {faker.Random.Int(1000, 9999)}
Billing Code: {faker.Random.Int(10000, 99999)}-{faker.Random.Int(100, 999)}
""",
"Coffee Machine" => $"""
Machine: {faker.PickRandom("Jura", "Breville", "De'Longhi", "Nespresso")} {faker.Commerce.ProductAdjective()}
Premium Code: {faker.Random.Int(1000, 9999)}
Maintenance: {faker.PickRandom("Facilities", "Office Manager", "Self-service")}
Bean Refill: {faker.PickRandom("Tuesdays", "Wednesdays", "Weekly", "As needed")}
Secret Menu: Double-tap for extra shot
""",
"Parking Garage" => $"""
Location: {faker.Address.StreetAddress()}
Gate Code: #{faker.Random.Int(1000, 9999)}
Assigned Spot: {faker.PickRandom("A", "B", "C", "P")}{faker.Random.Int(1, 4)}-{faker.Random.Int(100, 450)}
Monthly Pass: {faker.Random.AlphaNumeric(10).ToUpper(CultureInfo.InvariantCulture)}
Validation: {faker.PickRandom("Get ticket stamped at reception", "Use company app", "Auto-validated by badge")}
Emergency Exit: {faker.PickRandom("Stairwell B", "North ramp", "Elevator to lobby")}
""",
_ => faker.Lorem.Paragraph()
};
}

View File

@@ -0,0 +1,142 @@
using System.Security.Cryptography;
using Bit.Seeder.Models;
namespace Bit.Seeder.Data.Generators;
/// <summary>
/// Generates structurally-valid but intentionally unusable SSH keys for test vault data.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Security by Design:</strong> These keys are deliberately marked with "FAKE" in the
/// PEM headers (e.g., "-----BEGIN FAKE RSA PRIVATE KEY-----") to ensure they cannot be
/// mistaken for or used as real credentials. The keys are cryptographically valid in structure
/// but are explicitly labeled to prevent any accidental production use.
/// </para>
/// <para>
/// <strong>Why realistic structure?</strong> Clients validate SSH key format for
/// display purposes (fingerprint rendering, key type detection, copy-to-clipboard formatting).
/// Using placeholder strings like "FAKE_KEY_HERE" would fail client-side validation and not
/// exercise the full code path during integration testing.
/// </para>
/// <para>
/// <strong>Context:</strong> This generator is part of the Seeder, which creates test data for
/// local development and integration testing only. All generated keys are encrypted with
/// organization keys before database storage, maintaining zero-knowledge architecture even
/// for test data.
/// </para>
/// <para>
/// <strong>Note:</strong> Keys are NOT deterministically seeded - RSA.Create() uses system RNG.
/// The pool provides variety but not cross-run reproducibility.
/// </para>
/// </remarks>
internal static class SshKeyDataGenerator
{
private const int _poolSize = 500;
private static readonly Lazy<(string Private, string Public, string Fingerprint)[]> _keyPool =
new(() => GenerateKeyPool(_poolSize));
/// <summary>
/// Generates a deterministic SSH key based on index from the pre-generated pool.
/// </summary>
internal static SshKeyViewDto GenerateByIndex(int index)
{
var poolLength = _keyPool.Value.Length;
var poolIndex = ((index % poolLength) + poolLength) % poolLength;
var (Private, Public, Fingerprint) = _keyPool.Value[poolIndex];
return new SshKeyViewDto
{
PrivateKey = Private,
PublicKey = Public,
Fingerprint = Fingerprint
};
}
private static (string, string, string)[] GenerateKeyPool(int count)
{
var keys = new (string, string, string)[count];
for (var i = 0; i < count; i++)
{
using var rsa = RSA.Create(2048);
keys[i] = (ExportPrivateKey(rsa), ExportPublicKey(rsa), ComputeFingerprint(rsa));
}
return keys;
}
private static string ExportPrivateKey(RSA rsa)
{
var privateKeyBytes = rsa.ExportRSAPrivateKey();
var base64 = Convert.ToBase64String(privateKeyBytes);
var lines = new List<string> { "-----BEGIN FAKE RSA PRIVATE KEY-----" };
for (var i = 0; i < base64.Length; i += 64)
{
lines.Add(base64.Substring(i, Math.Min(64, base64.Length - i)));
}
lines.Add("-----END FAKE RSA PRIVATE KEY-----");
return string.Join("\n", lines);
}
private static string ExportPublicKey(RSA rsa)
{
var parameters = rsa.ExportParameters(false);
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
WriteString(writer, "ssh-rsa");
WriteBigInteger(writer, parameters.Exponent!);
WriteBigInteger(writer, parameters.Modulus!);
var keyBlob = Convert.ToBase64String(ms.ToArray());
return $"ssh-rsa {keyBlob} test@seeder";
}
private static string ComputeFingerprint(RSA rsa)
{
var parameters = rsa.ExportParameters(false);
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
WriteString(writer, "ssh-rsa");
WriteBigInteger(writer, parameters.Exponent!);
WriteBigInteger(writer, parameters.Modulus!);
var hash = SHA256.HashData(ms.ToArray());
return $"SHA256:{Convert.ToBase64String(hash).TrimEnd('=')}";
}
private static void WriteString(BinaryWriter writer, string value)
{
var bytes = System.Text.Encoding.ASCII.GetBytes(value);
WriteBytes(writer, bytes);
}
private static void WriteBigInteger(BinaryWriter writer, byte[] value)
{
if (value.Length > 0 && (value[0] & 0x80) != 0)
{
var padded = new byte[value.Length + 1];
padded[0] = 0;
Array.Copy(value, 0, padded, 1, value.Length);
WriteBytes(writer, padded);
}
else
{
WriteBytes(writer, value);
}
}
private static void WriteBytes(BinaryWriter writer, byte[] bytes)
{
var length = BitConverter.GetBytes(bytes.Length);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(length);
}
writer.Write(length);
writer.Write(bytes);
}
}

View File

@@ -13,6 +13,37 @@ Foundation layer for all cipher generation—data and patterns that future ciphe
---
## Generators
Seeded, deterministic data generation for cipher content. Orchestrated by `GeneratorContext` which lazy-initializes on first access.
| Generator | Output | Method |
|-----------|--------|--------|
| `CipherUsernameGenerator` | Emails, handles | `GenerateByIndex(index, totalHint, domain)` |
| `CardDataGenerator` | Card numbers, names | `GenerateByIndex(index)` |
| `IdentityDataGenerator` | Full identity profiles | `GenerateByIndex(index)` |
| `FolderNameGenerator` | Folder names | `GetFolderName(index)` |
| `SecureNoteDataGenerator` | Note title + content | `GenerateByIndex(index)` |
| `SshKeyDataGenerator` | RSA key pairs | `GenerateByIndex(index)` |
**Adding a generator:** See `GeneratorContext.cs` remarks for the 3-step pattern.
---
## Distributions
Percentage-based deterministic selection via `Distribution<T>.Select(index, total)`.
| Distribution | Values | Usage |
|--------------|--------|-------|
| `PasswordDistributions.Realistic` | 25% VeryWeak → 5% VeryStrong | Password strength mix |
| `UsernameDistributions.Realistic` | 45% corporate, 30% personal, etc. | Username category mix |
| `CipherTypeDistributions.Realistic` | 70% Login, 15% Card, etc. | Cipher type mix |
| `UserStatusDistributions.Realistic` | 85% Confirmed, 5% each other | Org user status mix |
| `FolderCountDistributions.Realistic` | 35% zero, 35% 1-3, etc. | Folders per user |
---
## Current Capabilities
### Login Ciphers
@@ -37,9 +68,10 @@ Foundation layer for all cipher generation—data and patterns that future ciphe
| Cipher Type | Data Needed | Status |
| ----------- | ---------------------------------------------------- | ----------- |
| Login | Companies, Names, Passwords, Patterns | ✅ Complete |
| Card | Card networks, bank names, realistic numbers | ⬜ Planned |
| Identity | Full identity profiles (name, address, SSN patterns) | ⬜ Planned |
| SecureNote | Note templates, categories, content generators | ⬜ Planned |
| Card | Card networks, bank names, realistic numbers | ✅ Complete |
| Identity | Full identity profiles (name, address, SSN patterns) | ✅ Complete |
| SecureNote | Note templates, categories, content generators | ✅ Complete |
| SSH Key | RSA key pairs, fingerprints | ✅ Complete |
### Phase 2: Spec-Driven Generation

View File

@@ -1,6 +1,6 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
namespace Bit.Seeder.Data.Static;
internal sealed record Company(
string Domain,
@@ -14,7 +14,7 @@ internal sealed record Company(
/// </summary>
internal static class Companies
{
public static readonly Company[] NorthAmerica =
internal static readonly Company[] NorthAmerica =
[
// CRM & Sales
new("salesforce.com", "Salesforce", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica),
@@ -47,7 +47,7 @@ internal static class Companies
new("spotify.com", "Spotify", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica)
];
public static readonly Company[] Europe =
internal static readonly Company[] Europe =
[
// Enterprise Software
new("sap.com", "SAP", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe),
@@ -72,7 +72,7 @@ internal static class Companies
new("adyen.com", "Adyen", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.Europe)
];
public static readonly Company[] AsiaPacific =
internal static readonly Company[] AsiaPacific =
[
// Chinese Tech Giants
new("alibaba.com", "Alibaba", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.AsiaPacific),
@@ -96,9 +96,9 @@ internal static class Companies
new("flipkart.com", "Flipkart", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific)
];
public static readonly Company[] All = [.. NorthAmerica, .. Europe, .. AsiaPacific];
internal static readonly Company[] All = [.. NorthAmerica, .. Europe, .. AsiaPacific];
public static Company[] Filter(
internal static Company[] Filter(
CompanyType? type = null,
GeographicRegion? region = null,
CompanyCategory? category = null)

View File

@@ -1,6 +1,6 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
namespace Bit.Seeder.Data.Static;
internal sealed record OrgUnit(string Name, string[]? SubUnits = null);
@@ -11,7 +11,7 @@ internal sealed record OrgStructure(OrgStructureModel Model, OrgUnit[] Units);
/// </summary>
internal static class OrgStructures
{
public static readonly OrgStructure Traditional = new(OrgStructureModel.Traditional,
internal static readonly OrgStructure Traditional = new(OrgStructureModel.Traditional,
[
new("Executive", ["CEO Office", "Strategy", "Board Relations"]),
new("Finance", ["Accounting", "FP&A", "Treasury", "Tax", "Audit"]),
@@ -27,7 +27,7 @@ internal static class OrgStructures
new("Product", ["Product Management", "UX Design", "User Research", "Product Analytics"])
]);
public static readonly OrgStructure Spotify = new(OrgStructureModel.Spotify,
internal static readonly OrgStructure Spotify = new(OrgStructureModel.Spotify,
[
// Tribes
new("Payments Tribe", ["Checkout Squad", "Fraud Prevention Squad", "Billing Squad", "Payment Methods Squad"]),
@@ -48,7 +48,7 @@ internal static class OrgStructures
new("Developer Experience Guild")
]);
public static readonly OrgStructure Modern = new(OrgStructureModel.Modern,
internal static readonly OrgStructure Modern = new(OrgStructureModel.Modern,
[
// Feature Teams
new("Auth Team", ["Identity", "SSO", "MFA", "Passwordless"]),
@@ -72,9 +72,9 @@ internal static class OrgStructures
new("Quality", ["Testing Strategy", "Release Quality", "Production Health"])
]);
public static readonly OrgStructure[] All = [Traditional, Spotify, Modern];
internal static readonly OrgStructure[] All = [Traditional, Spotify, Modern];
public static OrgStructure GetStructure(OrgStructureModel model) => model switch
internal static OrgStructure GetStructure(OrgStructureModel model) => model switch
{
OrgStructureModel.Traditional => Traditional,
OrgStructureModel.Spotify => Spotify,

View File

@@ -1,6 +1,7 @@
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Data.Distributions;
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
namespace Bit.Seeder.Data.Static;
/// <summary>
/// Password collections by zxcvbn strength level (0-4) for realistic test data.
@@ -10,7 +11,7 @@ internal static class Passwords
/// <summary>
/// Score 0 - Too guessable: keyboard walks, simple sequences, single words.
/// </summary>
public static readonly string[] VeryWeak =
internal static readonly string[] VeryWeak =
[
"password", "123456", "qwerty", "abc123", "letmein",
"admin", "welcome", "monkey", "dragon", "master",
@@ -23,7 +24,7 @@ internal static class Passwords
/// <summary>
/// Score 1 - Very guessable: common patterns with minor complexity.
/// </summary>
public static readonly string[] Weak =
internal static readonly string[] Weak =
[
"Password1", "Qwerty123", "Welcome1", "Admin123", "Letmein1",
"Dragon123", "Master123", "Shadow123", "Michael1", "Jennifer1",
@@ -36,7 +37,7 @@ internal static class Passwords
/// <summary>
/// Score 2 - Somewhat guessable: meets basic complexity but predictable patterns.
/// </summary>
public static readonly string[] Fair =
internal static readonly string[] Fair =
[
"Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!",
"Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login2024!",
@@ -49,7 +50,7 @@ internal static class Passwords
/// <summary>
/// Score 3 - Safely unguessable: good entropy, mixed character types.
/// </summary>
public static readonly string[] Strong =
internal static readonly string[] Strong =
[
"k#9Lm$vQ2@xR7nP!", "Yx8&mK3$pL5#wQ9@", "Nv4%jH7!bT2@sF6#",
"Rm9#cX5$gW1@zK8!", "Qp3@hY6#nL9$tB2!", "Wz7!mF4@kS8#xC1$",
@@ -64,7 +65,7 @@ internal static class Passwords
/// <summary>
/// Score 4 - Very unguessable: high entropy, long passphrases, random strings.
/// </summary>
public static readonly string[] VeryStrong =
internal static readonly string[] VeryStrong =
[
"Kx9#mL4$pQ7@wR2!vN5hT8", "Yz3@hT8#bF1$cS6!nM9wK4", "Wv5!rK2@jG9#tX4$mL7nB3",
"Qn7$sB3@yH6#pC1!zF8kW2", "Tm2@xD5#kW9$vL4!rJ7gN1", "Pf4!nC8@bR3#yL6$hS9mV2",
@@ -77,71 +78,24 @@ internal static class Passwords
];
/// <summary>All passwords combined for mixed/random selection.</summary>
public static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong];
internal static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong];
/// <summary>
/// Realistic distribution based on breach data and security research.
/// Sources: NordPass annual reports, Have I Been Pwned analysis, academic studies.
/// Distribution: 25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong
/// </summary>
private static readonly (PasswordStrength Strength, int CumulativePercent)[] RealisticDistribution =
[
(PasswordStrength.VeryWeak, 25), // 25% - most common breached passwords
(PasswordStrength.Weak, 55), // 30% - simple patterns with numbers
(PasswordStrength.Fair, 80), // 25% - meets basic requirements
(PasswordStrength.Strong, 95), // 15% - good passwords
(PasswordStrength.VeryStrong, 100) // 5% - password manager users
];
public static string[] GetByStrength(PasswordStrength strength) => strength switch
internal static string[] GetByStrength(PasswordStrength strength) => strength switch
{
PasswordStrength.VeryWeak => VeryWeak,
PasswordStrength.Weak => Weak,
PasswordStrength.Fair => Fair,
PasswordStrength.Strong => Strong,
PasswordStrength.VeryStrong => VeryStrong,
PasswordStrength.Realistic => All, // For direct array access, use All
_ => Strong
};
/// <summary>
/// Gets a password with realistic strength distribution.
/// Uses deterministic selection based on index for reproducible test data.
/// Gets a password using the provided distribution to select strength.
/// </summary>
public static string GetRealisticPassword(int index)
internal static string GetPassword(int index, int total, Distribution<PasswordStrength> distribution)
{
var strength = GetRealisticStrength(index);
var passwords = GetByStrength(strength);
return passwords[index % passwords.Length];
}
/// <summary>
/// Gets a password strength following realistic distribution.
/// Deterministic based on index for reproducible results.
/// </summary>
public static PasswordStrength GetRealisticStrength(int index)
{
// Use modulo 100 for percentage-based bucket selection
var bucket = index % 100;
foreach (var (strength, cumulativePercent) in RealisticDistribution)
{
if (bucket < cumulativePercent)
{
return strength;
}
}
return PasswordStrength.Strong; // Fallback
}
public static string GetPassword(PasswordStrength strength, int index)
{
if (strength == PasswordStrength.Realistic)
{
return GetRealisticPassword(index);
}
var strength = distribution.Select(index, total);
var passwords = GetByStrength(strength);
return passwords[index % passwords.Length];
}

View File

@@ -1,57 +0,0 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
internal sealed record UsernamePattern(
UsernamePatternType Type,
string FormatDescription,
Func<string, string, string, string> Generate);
/// <summary>
/// Username pattern implementations for different email conventions.
/// </summary>
internal static class UsernamePatterns
{
public static readonly UsernamePattern FirstDotLast = new(
UsernamePatternType.FirstDotLast,
"first.last@domain",
(first, last, domain) => $"{first.ToLowerInvariant()}.{last.ToLowerInvariant()}@{domain}");
public static readonly UsernamePattern FDotLast = new(
UsernamePatternType.FDotLast,
"f.last@domain",
(first, last, domain) => $"{char.ToLowerInvariant(first[0])}.{last.ToLowerInvariant()}@{domain}");
public static readonly UsernamePattern FLast = new(
UsernamePatternType.FLast,
"flast@domain",
(first, last, domain) => $"{char.ToLowerInvariant(first[0])}{last.ToLowerInvariant()}@{domain}");
public static readonly UsernamePattern LastDotFirst = new(
UsernamePatternType.LastDotFirst,
"last.first@domain",
(first, last, domain) => $"{last.ToLowerInvariant()}.{first.ToLowerInvariant()}@{domain}");
public static readonly UsernamePattern First_Last = new(
UsernamePatternType.First_Last,
"first_last@domain",
(first, last, domain) => $"{first.ToLowerInvariant()}_{last.ToLowerInvariant()}@{domain}");
public static readonly UsernamePattern LastFirst = new(
UsernamePatternType.LastFirst,
"lastf@domain",
(first, last, domain) => $"{last.ToLowerInvariant()}{char.ToLowerInvariant(first[0])}@{domain}");
public static readonly UsernamePattern[] All = [FirstDotLast, FDotLast, FLast, LastDotFirst, First_Last, LastFirst];
public static UsernamePattern GetPattern(UsernamePatternType type) => type switch
{
UsernamePatternType.FirstDotLast => FirstDotLast,
UsernamePatternType.FDotLast => FDotLast,
UsernamePatternType.FLast => FLast,
UsernamePatternType.LastDotFirst => LastDotFirst,
UsernamePatternType.First_Last => First_Last,
UsernamePatternType.LastFirst => LastFirst,
_ => FirstDotLast
};
}

View File

@@ -0,0 +1,29 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
internal static class CardCipherSeeder
{
internal static Cipher Create(
string encryptionKey,
string name,
CardViewDto card,
Guid? organizationId = null,
Guid? userId = null,
string? notes = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = name,
Notes = notes,
Type = CipherTypes.Card,
Card = card
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToCardData(), CipherType.Card, organizationId, userId);
}
}

View File

@@ -0,0 +1,55 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.RustSDK;
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
internal static class CipherEncryption
{
private static readonly JsonSerializerOptions SdkJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private static readonly JsonSerializerOptions ServerJsonOptions = new()
{
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
internal static EncryptedCipherDto Encrypt(CipherViewDto cipherView, string keyBase64)
{
var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions);
var encryptedJson = RustSdkService.EncryptCipher(viewJson, keyBase64);
return JsonSerializer.Deserialize<EncryptedCipherDto>(encryptedJson, SdkJsonOptions)
?? throw new InvalidOperationException("Failed to parse encrypted cipher");
}
internal static Cipher CreateEntity(
EncryptedCipherDto encrypted,
object data,
CipherType cipherType,
Guid? organizationId,
Guid? userId)
{
var dataJson = JsonSerializer.Serialize(data, ServerJsonOptions);
return new Cipher
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
UserId = userId,
Type = cipherType,
Data = dataJson,
Key = encrypted.Key,
Reprompt = (CipherRepromptType?)encrypted.Reprompt,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow
};
}
}

View File

@@ -1,146 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Models.Data;
using Bit.RustSDK;
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
/// <summary>
/// Creates encrypted ciphers for seeding vaults via the Rust SDK.
/// </summary>
/// <remarks>
/// Supported cipher types:
/// <list type="bullet">
/// <item><description>Login - <see cref="CreateOrganizationLoginCipher"/></description></item>
/// </list>
/// Future: Card, Identity, SecureNote will follow the same pattern—public Create method + private Transform method.
/// </remarks>
public class CipherSeeder
{
private static readonly JsonSerializerOptions SdkJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private static readonly JsonSerializerOptions ServerJsonOptions = new()
{
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static Cipher CreateOrganizationLoginCipher(
Guid organizationId,
string orgKeyBase64,
string name,
string? username = null,
string? password = null,
string? uri = null,
string? notes = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = name,
Notes = notes,
Type = CipherTypes.Login,
Login = new LoginViewDto
{
Username = username,
Password = password,
Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }]
}
};
return EncryptAndTransform(cipherView, orgKeyBase64, organizationId);
}
public static Cipher CreateOrganizationLoginCipherWithFields(
Guid organizationId,
string orgKeyBase64,
string name,
string? username,
string? password,
string? uri,
IEnumerable<(string name, string value, int type)> fields)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = name,
Type = CipherTypes.Login,
Login = new LoginViewDto
{
Username = username,
Password = password,
Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }]
},
Fields = fields.Select(f => new FieldViewDto
{
Name = f.name,
Value = f.value,
Type = f.type
}).ToList()
};
return EncryptAndTransform(cipherView, orgKeyBase64, organizationId);
}
private static Cipher EncryptAndTransform(CipherViewDto cipherView, string keyBase64, Guid organizationId)
{
var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions);
var encryptedJson = RustSdkService.EncryptCipher(viewJson, keyBase64);
var encryptedDto = JsonSerializer.Deserialize<EncryptedCipherDto>(encryptedJson, SdkJsonOptions)
?? throw new InvalidOperationException("Failed to parse encrypted cipher");
return TransformLoginToServerCipher(encryptedDto, organizationId);
}
private static Cipher TransformLoginToServerCipher(EncryptedCipherDto encrypted, Guid organizationId)
{
var loginData = new CipherLoginData
{
Name = encrypted.Name,
Notes = encrypted.Notes,
Username = encrypted.Login?.Username,
Password = encrypted.Login?.Password,
Totp = encrypted.Login?.Totp,
PasswordRevisionDate = encrypted.Login?.PasswordRevisionDate,
Uris = encrypted.Login?.Uris?.Select(u => new CipherLoginData.CipherLoginUriData
{
Uri = u.Uri,
UriChecksum = u.UriChecksum,
Match = u.Match.HasValue ? (UriMatchType?)u.Match : null
}),
Fields = encrypted.Fields?.Select(f => new CipherFieldData
{
Name = f.Name,
Value = f.Value,
Type = (FieldType)f.Type,
LinkedId = f.LinkedId
})
};
var dataJson = JsonSerializer.Serialize(loginData, ServerJsonOptions);
return new Cipher
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
UserId = null,
Type = CipherType.Login,
Data = dataJson,
Key = encrypted.Key,
Reprompt = (CipherRepromptType?)encrypted.Reprompt,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow
};
}
}

View File

@@ -1,36 +1,20 @@
using Bit.Core.Entities;
using Bit.Core.Utilities;
using Bit.RustSDK;
namespace Bit.Seeder.Factories;
public class CollectionSeeder
internal static class CollectionSeeder
{
public static Collection CreateCollection(Guid organizationId, string orgKey, string name)
internal static Collection Create(Guid organizationId, string orgKey, string name)
{
return new Collection
{
Id = Guid.NewGuid(),
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
Name = RustSdkService.EncryptString(name, orgKey),
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow
};
}
public static CollectionUser CreateCollectionUser(
Guid collectionId,
Guid organizationUserId,
bool readOnly = false,
bool hidePasswords = false,
bool manage = false)
{
return new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = organizationUserId,
ReadOnly = readOnly,
HidePasswords = hidePasswords,
Manage = manage
};
}
}

View File

@@ -0,0 +1,23 @@
using Bit.Core.Entities;
namespace Bit.Seeder.Factories;
internal static class CollectionUserSeeder
{
internal static CollectionUser Create(
Guid collectionId,
Guid organizationUserId,
bool readOnly = false,
bool hidePasswords = false,
bool manage = false)
{
return new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = organizationUserId,
ReadOnly = readOnly,
HidePasswords = hidePasswords,
Manage = manage
};
}
}

View File

@@ -4,19 +4,9 @@ using Bit.RustSDK;
namespace Bit.Seeder.Factories;
/// <summary>
/// Factory for creating Folder entities with encrypted names.
/// Folders are per-user constructs encrypted with the user's symmetric key.
/// </summary>
internal sealed class FolderSeeder
internal static class FolderSeeder
{
/// <summary>
/// Creates a folder with an encrypted name.
/// </summary>
/// <param name="userId">The user who owns this folder.</param>
/// <param name="userKeyBase64">The user's symmetric key (not org key).</param>
/// <param name="name">The plaintext folder name to encrypt.</param>
public static Folder CreateFolder(Guid userId, string userKeyBase64, string name)
internal static Folder Create(Guid userId, string userKeyBase64, string name)
{
return new Folder
{

View File

@@ -3,18 +3,9 @@ using Bit.Core.Utilities;
namespace Bit.Seeder.Factories;
/// <summary>
/// Creates groups and group-user relationships for seeding.
/// </summary>
public static class GroupSeeder
internal static class GroupSeeder
{
/// <summary>
/// Creates a group entity for an organization.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="name">The group name.</param>
/// <returns>A new Group entity (not persisted).</returns>
public static Group CreateGroup(Guid organizationId, string name)
internal static Group Create(Guid organizationId, string name)
{
return new Group
{
@@ -23,19 +14,4 @@ public static class GroupSeeder
Name = name
};
}
/// <summary>
/// Creates a group-user relationship entity.
/// </summary>
/// <param name="groupId">The group ID.</param>
/// <param name="organizationUserId">The organization user ID.</param>
/// <returns>A new GroupUser entity (not persisted).</returns>
public static GroupUser CreateGroupUser(Guid groupId, Guid organizationUserId)
{
return new GroupUser
{
GroupId = groupId,
OrganizationUserId = organizationUserId
};
}
}

View File

@@ -0,0 +1,15 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Seeder.Factories;
internal static class GroupUserSeeder
{
internal static GroupUser Create(Guid groupId, Guid organizationUserId)
{
return new GroupUser
{
GroupId = groupId,
OrganizationUserId = organizationUserId
};
}
}

View File

@@ -0,0 +1,29 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
internal static class IdentityCipherSeeder
{
internal static Cipher Create(
string encryptionKey,
string name,
IdentityViewDto identity,
Guid? organizationId = null,
Guid? userId = null,
string? notes = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = name,
Notes = notes,
Type = CipherTypes.Identity,
Identity = identity
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToIdentityData(), CipherType.Identity, organizationId, userId);
}
}

View File

@@ -0,0 +1,43 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
internal static class LoginCipherSeeder
{
internal static Cipher Create(
string encryptionKey,
string name,
Guid? organizationId = null,
Guid? userId = null,
string? username = null,
string? password = null,
string? uri = null,
string? notes = null,
IEnumerable<(string name, string value, int type)>? fields = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = name,
Notes = notes,
Type = CipherTypes.Login,
Login = new LoginViewDto
{
Username = username,
Password = password,
Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }]
},
Fields = fields?.Select(f => new FieldViewDto
{
Name = f.name,
Value = f.value,
Type = f.type
}).ToList()
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToLoginData(), CipherType.Login, organizationId, userId);
}
}

View File

@@ -1,23 +1,15 @@
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Models;
namespace Bit.Seeder.Factories;
/// <summary>
/// Creates organization domain entities for seeding.
/// </summary>
public static class OrganizationDomainSeeder
internal static class OrganizationDomainSeeder
{
/// <summary>
/// Creates a verified organization domain entity.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="domainName">The domain name (e.g., "example.com").</param>
/// <returns>A new verified OrganizationDomain entity (not persisted).</returns>
public static OrganizationDomain CreateVerifiedDomain(Guid organizationId, string domainName)
internal static OrganizationDomain Create(Guid organizationId, string domainName)
{
var domain = new OrganizationDomain
{
Id = Guid.NewGuid(),
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
DomainName = domainName,
Txt = Guid.NewGuid().ToString("N"),

View File

@@ -2,16 +2,17 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Seeder.Factories;
public class OrganizationSeeder
internal static class OrganizationSeeder
{
public static Organization CreateEnterprise(string name, string domain, int seats, string? publicKey = null, string? privateKey = null)
internal static Organization Create(string name, string domain, int seats, string? publicKey = null, string? privateKey = null)
{
return new Organization
{
Id = Guid.NewGuid(),
Id = CoreHelpers.GenerateComb(),
Name = name,
BillingEmail = $"billing@{domain}",
Plan = "Enterprise (Annually)",
@@ -46,13 +47,13 @@ public class OrganizationSeeder
}
}
public static class OrganizationExtensions
internal static class OrganizationExtensions
{
/// <summary>
/// Creates an OrganizationUser with a dynamically provided encrypted org key.
/// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey().
/// </summary>
public static OrganizationUser CreateOrganizationUserWithKey(
internal static OrganizationUser CreateOrganizationUserWithKey(
this Organization organization,
User user,
OrganizationUserType type,
@@ -64,7 +65,7 @@ public static class OrganizationExtensions
return new OrganizationUser
{
Id = Guid.NewGuid(),
Id = CoreHelpers.GenerateComb(),
OrganizationId = organization.Id,
UserId = shouldLinkUserId ? user.Id : null,
Email = shouldLinkUserId ? null : user.Email,

View File

@@ -0,0 +1,28 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
internal static class SecureNoteCipherSeeder
{
internal static Cipher Create(
string encryptionKey,
string name,
Guid? organizationId = null,
Guid? userId = null,
string? notes = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = name,
Notes = notes,
Type = CipherTypes.SecureNote,
SecureNote = new SecureNoteViewDto { Type = 0 }
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToSecureNoteData(), CipherType.SecureNote, organizationId, userId);
}
}

View File

@@ -0,0 +1,29 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
internal static class SshKeyCipherSeeder
{
internal static Cipher Create(
string encryptionKey,
string name,
SshKeyViewDto sshKey,
Guid? organizationId = null,
Guid? userId = null,
string? notes = null)
{
var cipherView = new CipherViewDto
{
OrganizationId = organizationId,
Name = name,
Notes = notes,
Type = CipherTypes.SshKey,
SshKey = sshKey
};
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
return CipherEncryption.CreateEntity(encrypted, encrypted.ToSshKeyData(), CipherType.SSHKey, organizationId, userId);
}
}

View File

@@ -2,124 +2,46 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.RustSDK;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;
namespace Bit.Seeder.Factories;
public struct UserData
internal static class UserSeeder
{
public string Email;
}
internal const string DefaultPassword = "asdfasdfasdf";
public class UserSeeder(IPasswordHasher<Bit.Core.Entities.User> passwordHasher, MangleId mangleId)
{
private string MangleEmail(string email)
internal static User Create(
string email,
IPasswordHasher<User> passwordHasher,
IManglerService manglerService,
bool emailVerified = true,
bool premium = false,
UserKeys? keys = null)
{
return $"{mangleId}+{email}";
}
// When keys are provided, caller owns email/key consistency - don't mangle
var mangledEmail = keys == null ? manglerService.Mangle(email) : email;
public User CreateUser(string email, bool emailVerified = false, bool premium = false)
{
email = MangleEmail(email);
var keys = RustSdkService.GenerateUserKeys(email, DefaultPassword);
keys ??= RustSdkService.GenerateUserKeys(mangledEmail, DefaultPassword);
var user = new User
{
Id = CoreHelpers.GenerateComb(),
Email = email,
Email = mangledEmail,
EmailVerified = emailVerified,
MasterPassword = null,
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
Key = keys.EncryptedUserKey,
PublicKey = keys.PublicKey,
PrivateKey = keys.PrivateKey,
Premium = premium,
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 5_000,
};
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
return user;
}
/// <summary>
/// Default test password used for all seeded users.
/// </summary>
public const string DefaultPassword = "asdfasdfasdf";
/// <summary>
/// Creates a user with hardcoded keys (no email mangling, no SDK calls).
/// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs.
/// </summary>
public static User CreateUserNoMangle(string email)
{
return new User
{
Id = Guid.NewGuid(),
Email = email,
MasterPassword = "AQAAAAIAAYagAAAAEBATmF66OHMpHuHKc1CsGZQ1ltHUHyhYK+7e4re3bVFi16SOpLpDfzdFswnvFQs2Rg==",
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
Key = "2.z/eLKFhd62qy9RzXu3UHgA==|fF6yNupiCIguFKSDTB3DoqcGR0Xu4j+9VlnMyT5F3PaWIcGhzQKIzxdB95nhslaCQv3c63M7LBnvzVo1J9SUN85RMbP/57bP1HvhhU1nvL8=|IQPtf8v7k83MFZEhazSYXSdu98BBU5rqtvC4keVWyHM=",
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Ww2chogqCpaAR7Uw448am4b7vDFXiM5kXjFlGfXBlrAdAqTTggEvTDlMNYqPlCo+mBM6iFmTTUY9rpZBvFskMnKvsvpJ47/fehAH2o2e3Ulv/5NFevaVCMCmpkBDtbMbO1A4a3btdRtCP8DsKWMefHauEpaoLxNTLWnOIZVfCMjsSgx2EvULHAZPTtbFwm4+UVKniM4ds4jvOsD85h4jn2aLs/jWJXFfxN8iVSqEqpC2TBvsPdyHb49xQoWWfF0Z6BiNqeNGKEU9Uos1pjL+kzhEzzSpH31PZT/ufJ/oo4+93wrUt57hb6f0jxiXhwd5yQ+9F6wVwpbfkq0IwhjOwIDAQAB",
PrivateKey = "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=",
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 600_000,
};
}
/// <summary>
/// Creates a user with SDK-generated cryptographic keys (no email mangling).
/// The user can log in with email and password = "asdfasdfasdf".
/// </summary>
public static User CreateUserWithSdkKeys(
string email,
IPasswordHasher<User> passwordHasher)
{
var keys = RustSdkService.GenerateUserKeys(email, DefaultPassword);
return CreateUserFromKeys(email, keys, passwordHasher);
}
/// <summary>
/// Creates a user from pre-generated keys (no email mangling).
/// Use this when you need to retain the user's symmetric key for subsequent operations
/// (e.g., encrypting folders with the user's key).
/// </summary>
public static User CreateUserFromKeys(
string email,
UserKeys keys,
IPasswordHasher<User> passwordHasher)
{
var user = new User
{
Id = CoreHelpers.GenerateComb(),
Email = email,
EmailVerified = true,
MasterPassword = null,
SecurityStamp = Guid.NewGuid().ToString(),
Key = keys.EncryptedUserKey,
PublicKey = keys.PublicKey,
PrivateKey = keys.PrivateKey,
Premium = false,
Premium = premium,
ApiKey = Guid.NewGuid().ToString("N")[..30],
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 5_000,
KdfIterations = 5_000
};
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
return user;
}
public Dictionary<string, string?> GetMangleMap(User user, UserData expectedUserData)
{
var mangleMap = new Dictionary<string, string?>
{
{ expectedUserData.Email, user.Email },
};
return mangleMap;
}
}

View File

@@ -1,21 +0,0 @@
using System.Globalization;
namespace Bit.Seeder;
/// <summary>
/// Helper for generating unique identifier suffixes to prevent collisions in test data.
/// "Mangling" adds a random suffix to test data identifiers (usernames, emails, org names, etc.)
/// to ensure uniqueness across multiple test runs and parallel test executions.
/// </summary>
public class MangleId
{
public readonly string Value;
public MangleId()
{
// Generate a short random string (6 char) to use as the mangle ID
Value = Random.Shared.NextInt64().ToString("x", CultureInfo.InvariantCulture).Substring(0, 8);
}
public override string ToString() => Value;
}

View File

@@ -32,16 +32,16 @@ public class CipherViewDto
public LoginViewDto? Login { get; set; }
[JsonPropertyName("identity")]
public object? Identity { get; set; }
public IdentityViewDto? Identity { get; set; }
[JsonPropertyName("card")]
public object? Card { get; set; }
public CardViewDto? Card { get; set; }
[JsonPropertyName("secureNote")]
public object? SecureNote { get; set; }
public SecureNoteViewDto? SecureNote { get; set; }
[JsonPropertyName("sshKey")]
public object? SshKey { get; set; }
public SshKeyViewDto? SshKey { get; set; }
[JsonPropertyName("favorite")]
public bool Favorite { get; set; }
@@ -151,3 +151,112 @@ public static class RepromptTypes
public const int None = 0;
public const int Password = 1;
}
/// <summary>
/// Card cipher data for SDK encryption. Uses record for composition via `with` expressions.
/// </summary>
public record CardViewDto
{
[JsonPropertyName("cardholderName")]
public string? CardholderName { get; init; }
[JsonPropertyName("brand")]
public string? Brand { get; init; }
[JsonPropertyName("number")]
public string? Number { get; init; }
[JsonPropertyName("expMonth")]
public string? ExpMonth { get; init; }
[JsonPropertyName("expYear")]
public string? ExpYear { get; init; }
[JsonPropertyName("code")]
public string? Code { get; init; }
}
/// <summary>
/// Identity cipher data for SDK encryption. Uses record for composition via `with` expressions.
/// </summary>
public record IdentityViewDto
{
[JsonPropertyName("title")]
public string? Title { get; init; }
[JsonPropertyName("firstName")]
public string? FirstName { get; init; }
[JsonPropertyName("middleName")]
public string? MiddleName { get; init; }
[JsonPropertyName("lastName")]
public string? LastName { get; init; }
[JsonPropertyName("address1")]
public string? Address1 { get; init; }
[JsonPropertyName("address2")]
public string? Address2 { get; init; }
[JsonPropertyName("address3")]
public string? Address3 { get; init; }
[JsonPropertyName("city")]
public string? City { get; init; }
[JsonPropertyName("state")]
public string? State { get; init; }
[JsonPropertyName("postalCode")]
public string? PostalCode { get; init; }
[JsonPropertyName("country")]
public string? Country { get; init; }
[JsonPropertyName("company")]
public string? Company { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
[JsonPropertyName("phone")]
public string? Phone { get; init; }
[JsonPropertyName("ssn")]
public string? SSN { get; init; }
[JsonPropertyName("username")]
public string? Username { get; init; }
[JsonPropertyName("passportNumber")]
public string? PassportNumber { get; init; }
[JsonPropertyName("licenseNumber")]
public string? LicenseNumber { get; init; }
}
/// <summary>
/// SecureNote cipher data for SDK encryption. Minimal structure - content is in cipher.Notes.
/// </summary>
public record SecureNoteViewDto
{
[JsonPropertyName("type")]
public int Type { get; init; } = 0; // Generic = 0
}
/// <summary>
/// SSH Key cipher data for SDK encryption. Uses record for composition via `with` expressions.
/// </summary>
public record SshKeyViewDto
{
[JsonPropertyName("privateKey")]
public string? PrivateKey { get; init; }
[JsonPropertyName("publicKey")]
public string? PublicKey { get; init; }
/// <summary>SDK expects "fingerprint" field name.</summary>
[JsonPropertyName("fingerprint")]
public string? Fingerprint { get; init; }
}

View File

@@ -25,6 +25,18 @@ public class EncryptedCipherDto
[JsonPropertyName("login")]
public EncryptedLoginDto? Login { get; set; }
[JsonPropertyName("card")]
public EncryptedCardDto? Card { get; set; }
[JsonPropertyName("identity")]
public EncryptedIdentityDto? Identity { get; set; }
[JsonPropertyName("secureNote")]
public EncryptedSecureNoteDto? SecureNote { get; set; }
[JsonPropertyName("sshKey")]
public EncryptedSshKeyDto? SshKey { get; set; }
[JsonPropertyName("fields")]
public List<EncryptedFieldDto>? Fields { get; set; }
@@ -94,3 +106,99 @@ public class EncryptedFieldDto
[JsonPropertyName("linkedId")]
public int? LinkedId { get; set; }
}
public class EncryptedCardDto
{
[JsonPropertyName("cardholderName")]
public string? CardholderName { get; set; }
[JsonPropertyName("brand")]
public string? Brand { get; set; }
[JsonPropertyName("number")]
public string? Number { get; set; }
[JsonPropertyName("expMonth")]
public string? ExpMonth { get; set; }
[JsonPropertyName("expYear")]
public string? ExpYear { get; set; }
[JsonPropertyName("code")]
public string? Code { get; set; }
}
public class EncryptedIdentityDto
{
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("firstName")]
public string? FirstName { get; set; }
[JsonPropertyName("middleName")]
public string? MiddleName { get; set; }
[JsonPropertyName("lastName")]
public string? LastName { get; set; }
[JsonPropertyName("address1")]
public string? Address1 { get; set; }
[JsonPropertyName("address2")]
public string? Address2 { get; set; }
[JsonPropertyName("address3")]
public string? Address3 { get; set; }
[JsonPropertyName("city")]
public string? City { get; set; }
[JsonPropertyName("state")]
public string? State { get; set; }
[JsonPropertyName("postalCode")]
public string? PostalCode { get; set; }
[JsonPropertyName("country")]
public string? Country { get; set; }
[JsonPropertyName("company")]
public string? Company { get; set; }
[JsonPropertyName("email")]
public string? Email { get; set; }
[JsonPropertyName("phone")]
public string? Phone { get; set; }
[JsonPropertyName("ssn")]
public string? SSN { get; set; }
[JsonPropertyName("username")]
public string? Username { get; set; }
[JsonPropertyName("passportNumber")]
public string? PassportNumber { get; set; }
[JsonPropertyName("licenseNumber")]
public string? LicenseNumber { get; set; }
}
public class EncryptedSecureNoteDto
{
[JsonPropertyName("type")]
public int Type { get; set; }
}
public class EncryptedSshKeyDto
{
[JsonPropertyName("privateKey")]
public string? PrivateKey { get; set; }
[JsonPropertyName("publicKey")]
public string? PublicKey { get; set; }
[JsonPropertyName("fingerprint")]
public string? Fingerprint { get; set; }
}

View File

@@ -0,0 +1,90 @@
using Bit.Core.Enums;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Models.Data;
namespace Bit.Seeder.Models;
internal static class EncryptedCipherDtoExtensions
{
internal static CipherLoginData ToLoginData(this EncryptedCipherDto e) => new()
{
Name = e.Name,
Notes = e.Notes,
Username = e.Login?.Username,
Password = e.Login?.Password,
Totp = e.Login?.Totp,
PasswordRevisionDate = e.Login?.PasswordRevisionDate,
Uris = e.Login?.Uris?.Select(u => new CipherLoginData.CipherLoginUriData
{
Uri = u.Uri,
UriChecksum = u.UriChecksum,
Match = u.Match.HasValue ? (UriMatchType?)u.Match : null
}),
Fields = e.ToFields()
};
internal static CipherCardData ToCardData(this EncryptedCipherDto e) => new()
{
Name = e.Name,
Notes = e.Notes,
CardholderName = e.Card?.CardholderName,
Brand = e.Card?.Brand,
Number = e.Card?.Number,
ExpMonth = e.Card?.ExpMonth,
ExpYear = e.Card?.ExpYear,
Code = e.Card?.Code,
Fields = e.ToFields()
};
internal static CipherIdentityData ToIdentityData(this EncryptedCipherDto e) => new()
{
Name = e.Name,
Notes = e.Notes,
Title = e.Identity?.Title,
FirstName = e.Identity?.FirstName,
MiddleName = e.Identity?.MiddleName,
LastName = e.Identity?.LastName,
Address1 = e.Identity?.Address1,
Address2 = e.Identity?.Address2,
Address3 = e.Identity?.Address3,
City = e.Identity?.City,
State = e.Identity?.State,
PostalCode = e.Identity?.PostalCode,
Country = e.Identity?.Country,
Company = e.Identity?.Company,
Email = e.Identity?.Email,
Phone = e.Identity?.Phone,
SSN = e.Identity?.SSN,
Username = e.Identity?.Username,
PassportNumber = e.Identity?.PassportNumber,
LicenseNumber = e.Identity?.LicenseNumber,
Fields = e.ToFields()
};
internal static CipherSecureNoteData ToSecureNoteData(this EncryptedCipherDto e) => new()
{
Name = e.Name,
Notes = e.Notes,
Type = (SecureNoteType)(e.SecureNote?.Type ?? 0),
Fields = e.ToFields()
};
internal static CipherSSHKeyData ToSshKeyData(this EncryptedCipherDto e) => new()
{
Name = e.Name,
Notes = e.Notes,
PrivateKey = e.SshKey?.PrivateKey,
PublicKey = e.SshKey?.PublicKey,
KeyFingerprint = e.SshKey?.Fingerprint,
Fields = e.ToFields()
};
private static IEnumerable<CipherFieldData>? ToFields(this EncryptedCipherDto e) =>
e.Fields?.Select(f => new CipherFieldData
{
Name = f.Name,
Value = f.Value,
Type = (FieldType)f.Type,
LinkedId = f.LinkedId
});
}

View File

@@ -1,4 +1,6 @@
using Bit.Seeder.Data.Enums;
using Bit.Core.Vault.Enums;
using Bit.Seeder.Data.Distributions;
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Options;
@@ -45,19 +47,40 @@ public class OrganizationVaultOptions
public OrgStructureModel? StructureModel { get; init; }
/// <summary>
/// Username pattern for cipher logins.
/// Username pattern for corporate email format (e.g., first.last@domain).
/// Only applies to CorporateEmail category usernames.
/// </summary>
public UsernamePatternType UsernamePattern { get; init; } = UsernamePatternType.FirstDotLast;
/// <summary>
/// Password strength for cipher logins. Defaults to Realistic distribution
/// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong).
/// Distribution of username categories (corporate email, personal email, social handles, etc.).
/// Use <see cref="UsernameDistributions.Realistic"/> for a typical enterprise mix (45% corporate).
/// Defaults to Realistic if not specified.
/// </summary>
public PasswordStrength PasswordStrength { get; init; } = PasswordStrength.Realistic;
public Distribution<UsernameCategory> UsernameDistribution { get; init; } = UsernameDistributions.Realistic;
/// <summary>
/// Distribution of password strengths for cipher logins.
/// Use <see cref="PasswordDistributions.Realistic"/> for breach-data distribution
/// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong).
/// Defaults to Realistic if not specified.
/// </summary>
public Distribution<PasswordStrength> PasswordDistribution { get; init; } = PasswordDistributions.Realistic;
/// <summary>
/// Geographic region for culturally-appropriate name generation in cipher usernames.
/// Defaults to Global (mixed locales from all regions).
/// </summary>
public GeographicRegion? Region { get; init; }
/// <summary>
/// When specified, ciphers are distributed according to the percentages.
/// Use <see cref="CipherTypeDistributions.Realistic"/> for a typical enterprise mix.
/// </summary>
public Distribution<CipherType> CipherTypeDistribution { get; init; } = CipherTypeDistributions.Realistic;
/// <summary>
/// Seed for deterministic data generation. When null, derived from Domain hash.
/// </summary>
public int? Seed { get; init; }
}

View File

@@ -102,8 +102,8 @@ The Seeder is organized around six core patterns, each with a specific responsib
- Implement `IScene<TRequest>` or `IScene<TRequest, TResult>`
- Create complete, realistic test scenarios
- Handle uniqueness constraint mangling for test isolation
- Return `SceneResult` with mangle map and optional additional operation result data for test assertions
- Receive `IManglerService` via DI for test isolation—service handles mangling automatically
- Return `SceneResult` with MangleMap (original→mangled) for test assertions
- Async operations
- CAN modify database state
@@ -117,7 +117,7 @@ The Seeder is organized around six core patterns, each with a specific responsib
**When to use:** Need to READ existing seeded data for verification or follow-up operations.
** Example:** Inviting a user to an organization produces a magic link to accept the invite, a query should be used to retrieve that link because it is easier than interfacing with an external smtp catcher.
**Example:** Inviting a user to an organization produces a magic link to accept the invite, a query should be used to retrieve that link because it is easier than interfacing with an external smtp catcher.
**Key characteristics:**
@@ -144,6 +144,35 @@ The Seeder is organized around six core patterns, each with a specific responsib
- Composable across regions
- Enums provide the public API (CompanyType, PasswordStrength, etc.)
**Folder structure:** See `Data/README.md` for Generators and Distributions details.
- `Static/` - Read-only data arrays (Companies, Passwords, Names, OrgStructures)
- `Generators/` - Seeded data generators via `GeneratorContext`
- `Distributions/` - Percentage-based selection via `Distribution<T>`
- `Enums/` - Public API enums
#### Services
**Purpose:** Injectable services that provide cross-cutting functionality via dependency injection.
**`IManglerService`** - Context-aware string mangling for test isolation:
- `Mangle(string)` - Transforms strings with unique prefixes for collision-free test data
- `GetMangleMap()` - Returns dictionary of original → mangled mappings for assertions
- `IsEnabled` - Indicates whether mangling is active
**Implementations:**
- `ManglerService` - Scoped stateful service that adds unique prefixes (`{prefix}+user@domain` for emails, `{prefix}-value` for strings)
- `NoOpManglerService` - Singleton no-op service that returns values unchanged
**Configuration:**
- SeederApi: Enabled when `GlobalSettings.TestPlayIdTrackingEnabled` is true
- DbSeederUtility: Enabled with `--mangle` CLI flag
---
## Rust SDK Integration
The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption:

View File

@@ -4,6 +4,7 @@ using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Factories;
using Bit.Seeder.Services;
using LinqToDB.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization;
@@ -12,7 +13,11 @@ using EfUser = Bit.Infrastructure.EntityFramework.Models.User;
namespace Bit.Seeder.Recipes;
public class OrganizationWithUsersRecipe(DatabaseContext db, IMapper mapper, IPasswordHasher<User> passwordHasher)
public class OrganizationWithUsersRecipe(
DatabaseContext db,
IMapper mapper,
IPasswordHasher<User> passwordHasher,
IManglerService manglerService)
{
public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed)
{
@@ -20,11 +25,11 @@ public class OrganizationWithUsersRecipe(DatabaseContext db, IMapper mapper, IPa
// Generate organization keys
var orgKeys = RustSdkService.GenerateOrganizationKeys();
var organization = OrganizationSeeder.CreateEnterprise(
var organization = OrganizationSeeder.Create(
name, domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey);
// Create owner with SDK-generated keys
var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{domain}", passwordHasher);
var ownerUser = UserSeeder.Create($"owner@{domain}", passwordHasher, manglerService);
var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
@@ -33,7 +38,7 @@ public class OrganizationWithUsersRecipe(DatabaseContext db, IMapper mapper, IPa
var additionalOrgUsers = new List<OrganizationUser>();
for (var i = 0; i < users; i++)
{
var additionalUser = UserSeeder.CreateUserWithSdkKeys($"user{i}@{domain}", passwordHasher);
var additionalUser = UserSeeder.Create($"user{i}@{domain}", passwordHasher, manglerService);
additionalUsers.Add(additionalUser);
// Generate org key for confirmed/revoked users

View File

@@ -1,12 +1,18 @@
using AutoMapper;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data;
using Bit.Seeder.Data.Distributions;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Data.Generators;
using Bit.Seeder.Data.Static;
using Bit.Seeder.Factories;
using Bit.Seeder.Options;
using Bit.Seeder.Services;
using LinqToDB.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using EfFolder = Bit.Infrastructure.EntityFramework.Vault.Models.Folder;
@@ -27,8 +33,12 @@ namespace Bit.Seeder.Recipes;
public class OrganizationWithVaultRecipe(
DatabaseContext db,
IMapper mapper,
IPasswordHasher<User> passwordHasher)
IPasswordHasher<User> passwordHasher,
IManglerService manglerService)
{
private const int _minimumOrgSeats = 1000;
private GeneratorContext _ctx = null!;
/// <summary>
/// Tracks a user with their symmetric key for folder encryption.
@@ -42,15 +52,17 @@ public class OrganizationWithVaultRecipe(
/// <returns>The organization ID.</returns>
public Guid Seed(OrganizationVaultOptions options)
{
var seats = Math.Max(options.Users + 1, 1000);
_ctx = GeneratorContext.FromOptions(options);
var seats = Math.Max(options.Users + 1, _minimumOrgSeats);
var orgKeys = RustSdkService.GenerateOrganizationKeys();
// Create organization via factory
var organization = OrganizationSeeder.CreateEnterprise(
var organization = OrganizationSeeder.Create(
options.Name, options.Domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey);
// Create owner user via factory
var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{options.Domain}", passwordHasher);
var ownerUser = UserSeeder.Create($"owner@{options.Domain}", passwordHasher, manglerService);
var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
@@ -63,12 +75,13 @@ public class OrganizationWithVaultRecipe(
for (var i = 0; i < options.Users; i++)
{
var email = $"user{i}@{options.Domain}";
var userKeys = RustSdkService.GenerateUserKeys(email, UserSeeder.DefaultPassword);
var memberUser = UserSeeder.CreateUserFromKeys(email, userKeys, passwordHasher);
var mangledEmail = manglerService.Mangle(email);
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, UserSeeder.DefaultPassword);
var memberUser = UserSeeder.Create(mangledEmail, passwordHasher, manglerService, keys: userKeys);
memberUsersWithKeys.Add(new UserWithKey(memberUser, userKeys.Key));
var status = useRealisticMix
? GetRealisticStatus(i, options.Users)
? UserStatusDistributions.Realistic.Select(i, options.Users)
: OrganizationUserStatusType.Confirmed;
var memberOrgKey = (status == OrganizationUserStatusType.Confirmed ||
@@ -102,7 +115,7 @@ public class OrganizationWithVaultRecipe(
var collectionIds = CreateCollections(organization.Id, orgKeys.Key, options.StructureModel, confirmedOrgUserIds);
CreateGroups(organization.Id, options.Groups, confirmedOrgUserIds);
CreateCiphers(organization.Id, orgKeys.Key, collectionIds, options.Ciphers, options.UsernamePattern, options.PasswordStrength, options.Region);
CreateCiphers(organization.Id, orgKeys.Key, collectionIds, options.Ciphers, options.PasswordDistribution, options.CipherTypeDistribution);
CreateFolders(memberUsersWithKeys);
return organization.Id;
@@ -120,12 +133,12 @@ public class OrganizationWithVaultRecipe(
{
var structure = OrgStructures.GetStructure(structureModel.Value);
collections = structure.Units
.Select(unit => CollectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name))
.Select(unit => CollectionSeeder.Create(organizationId, orgKeyBase64, unit.Name))
.ToList();
}
else
{
collections = [CollectionSeeder.CreateCollection(organizationId, orgKeyBase64, "Default Collection")];
collections = [CollectionSeeder.Create(organizationId, orgKeyBase64, "Default Collection")];
}
db.BulkCopy(collections);
@@ -138,7 +151,7 @@ public class OrganizationWithVaultRecipe(
{
var maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count);
return Enumerable.Range(0, maxAssignments)
.Select(j => CollectionSeeder.CreateCollectionUser(
.Select(j => CollectionUserSeeder.Create(
collections[(userIndex + j) % collections.Count].Id,
orgUserId,
readOnly: j > 0,
@@ -154,7 +167,7 @@ public class OrganizationWithVaultRecipe(
private void CreateGroups(Guid organizationId, int groupCount, List<Guid> orgUserIds)
{
var groupList = Enumerable.Range(0, groupCount)
.Select(i => GroupSeeder.CreateGroup(organizationId, $"Group {i + 1}"))
.Select(i => GroupSeeder.Create(organizationId, $"Group {i + 1}"))
.ToList();
db.BulkCopy(groupList);
@@ -163,7 +176,7 @@ public class OrganizationWithVaultRecipe(
if (groupList.Count > 0 && orgUserIds.Count > 0)
{
var groupUsers = orgUserIds
.Select((orgUserId, i) => GroupSeeder.CreateGroupUser(
.Select((orgUserId, i) => GroupUserSeeder.Create(
groupList[i % groupList.Count].Id,
orgUserId))
.ToList();
@@ -176,24 +189,29 @@ public class OrganizationWithVaultRecipe(
string orgKeyBase64,
List<Guid> collectionIds,
int cipherCount,
UsernamePatternType usernamePattern,
PasswordStrength passwordStrength,
GeographicRegion? region)
Distribution<PasswordStrength> passwordDistribution,
Distribution<CipherType> typeDistribution)
{
if (cipherCount == 0)
{
return;
}
var companies = Companies.All;
var usernameGenerator = new CipherUsernameGenerator(organizationId.GetHashCode(), usernamePattern, region);
var cipherList = Enumerable.Range(0, cipherCount)
.Select(i =>
{
var company = companies[i % companies.Length];
return CipherSeeder.CreateOrganizationLoginCipher(
organizationId,
orgKeyBase64,
name: $"{company.Name} ({company.Category})",
username: usernameGenerator.GenerateVaried(company, i),
password: Passwords.GetPassword(passwordStrength, i),
uri: $"https://{company.Domain}");
var cipherType = typeDistribution.Select(i, cipherCount);
return cipherType switch
{
CipherType.Login => CreateLoginCipher(i, organizationId, orgKeyBase64, companies, cipherCount, passwordDistribution),
CipherType.Card => CreateCardCipher(i, organizationId, orgKeyBase64),
CipherType.Identity => CreateIdentityCipher(i, organizationId, orgKeyBase64),
CipherType.SecureNote => CreateSecureNoteCipher(i, organizationId, orgKeyBase64),
CipherType.SSHKey => CreateSshKeyCipher(i, organizationId, orgKeyBase64),
_ => throw new ArgumentException($"Unsupported cipher type: {cipherType}")
};
})
.ToList();
@@ -224,46 +242,77 @@ public class OrganizationWithVaultRecipe(
};
}
return new[] { primary };
return [primary];
}).ToList();
db.BulkCopy(collectionCiphers);
}
}
/// <summary>
/// Returns a realistic user status based on index position.
/// Distribution: 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked.
/// </summary>
private static OrganizationUserStatusType GetRealisticStatus(int index, int totalUsers)
private Cipher CreateLoginCipher(
int index,
Guid organizationId,
string orgKeyBase64,
Company[] companies,
int cipherCount,
Distribution<PasswordStrength> passwordDistribution)
{
// Calculate bucket boundaries
var confirmedCount = (int)(totalUsers * 0.85);
var invitedCount = (int)(totalUsers * 0.05);
var acceptedCount = (int)(totalUsers * 0.05);
// Revoked gets the remainder
var company = companies[index % companies.Length];
return LoginCipherSeeder.Create(
orgKeyBase64,
name: $"{company.Name} ({company.Category})",
organizationId: organizationId,
username: _ctx.Username.GenerateByIndex(index, totalHint: _ctx.CipherCount, domain: company.Domain),
password: Passwords.GetPassword(index, cipherCount, passwordDistribution),
uri: $"https://{company.Domain}");
}
if (index < confirmedCount)
private Cipher CreateCardCipher(int index, Guid organizationId, string orgKeyBase64)
{
var card = _ctx.Card.GenerateByIndex(index);
return CardCipherSeeder.Create(
orgKeyBase64,
name: $"{card.CardholderName}'s {card.Brand}",
card: card,
organizationId: organizationId);
}
private Cipher CreateIdentityCipher(int index, Guid organizationId, string orgKeyBase64)
{
var identity = _ctx.Identity.GenerateByIndex(index);
var name = $"{identity.FirstName} {identity.LastName}";
if (!string.IsNullOrEmpty(identity.Company))
{
return OrganizationUserStatusType.Confirmed;
name += $" ({identity.Company})";
}
return IdentityCipherSeeder.Create(
orgKeyBase64,
name: name,
identity: identity,
organizationId: organizationId);
}
if (index < confirmedCount + invitedCount)
{
return OrganizationUserStatusType.Invited;
}
private Cipher CreateSecureNoteCipher(int index, Guid organizationId, string orgKeyBase64)
{
var (name, notes) = _ctx.SecureNote.GenerateByIndex(index);
return SecureNoteCipherSeeder.Create(
orgKeyBase64,
name: name,
organizationId: organizationId,
notes: notes);
}
if (index < confirmedCount + invitedCount + acceptedCount)
{
return OrganizationUserStatusType.Accepted;
}
return OrganizationUserStatusType.Revoked;
private Cipher CreateSshKeyCipher(int index, Guid organizationId, string orgKeyBase64)
{
var sshKey = SshKeyDataGenerator.GenerateByIndex(index);
return SshKeyCipherSeeder.Create(
orgKeyBase64,
name: $"SSH Key {index + 1}",
sshKey: sshKey,
organizationId: organizationId);
}
/// <summary>
/// Creates personal vault folders for users with realistic distribution.
/// Folders are encrypted with each user's individual symmetric key.
/// </summary>
private void CreateFolders(List<UserWithKey> usersWithKeys)
{
@@ -272,19 +321,15 @@ public class OrganizationWithVaultRecipe(
return;
}
var seed = usersWithKeys[0].User.Id.GetHashCode();
var random = new Random(seed);
var folderNameGenerator = new FolderNameGenerator(seed);
var allFolders = usersWithKeys
.SelectMany((uwk, userIndex) =>
{
var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, random);
var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, _ctx.Seed);
return Enumerable.Range(0, folderCount)
.Select(folderIndex => FolderSeeder.CreateFolder(
.Select(folderIndex => FolderSeeder.Create(
uwk.User.Id,
uwk.SymmetricKey,
folderNameGenerator.GetFolderName(userIndex * 15 + folderIndex)));
_ctx.Folder.GetFolderName(userIndex * 15 + folderIndex)));
})
.ToList();
@@ -295,32 +340,24 @@ public class OrganizationWithVaultRecipe(
}
}
/// <summary>
/// Returns folder count based on user index position in the distribution.
/// Distribution: 35% Zero, 35% Few (1-3), 20% Some (4-7), 10% TooMany (10-15)
/// </summary>
private static int GetFolderCountForUser(int userIndex, int totalUsers, Random random)
private static int GetFolderCountForUser(int userIndex, int totalUsers, int seed)
{
var zeroCount = (int)(totalUsers * 0.35);
var fewCount = (int)(totalUsers * 0.35);
var someCount = (int)(totalUsers * 0.20);
// TooMany gets the remainder
var (min, max) = FolderCountDistributions.Realistic.Select(userIndex, totalUsers);
return GetDeterministicValueInRange(userIndex, seed, min, max);
}
if (userIndex < zeroCount)
/// <summary>
/// Returns a deterministic value in [min, max) based on index and seed.
/// </summary>
private static int GetDeterministicValueInRange(int index, int seed, int min, int max)
{
unchecked
{
return 0; // Zero folders
var hash = seed;
hash = hash * 397 ^ index;
hash = hash * 397 ^ min;
var range = max - min;
return min + ((hash % range) + range) % range;
}
if (userIndex < zeroCount + fewCount)
{
return random.Next(1, 4); // Few: 1-3 folders
}
if (userIndex < zeroCount + fewCount + someCount)
{
return random.Next(4, 8); // Some: 4-7 folders
}
return random.Next(10, 16); // TooMany: 10-15 folders
}
}

View File

@@ -1,6 +1,9 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Seeder.Factories;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;
namespace Bit.Seeder.Scenes;
@@ -13,13 +16,15 @@ public struct SingleUserSceneResult
public string PublicKey { get; init; }
public string PrivateKey { get; init; }
public string ApiKey { get; init; }
}
/// <summary>
/// Creates a single user using the provided account details.
/// </summary>
public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene<SingleUserScene.Request, SingleUserSceneResult>
public class SingleUserScene(
IPasswordHasher<User> passwordHasher,
IUserRepository userRepository,
IManglerService manglerService) : IScene<SingleUserScene.Request, SingleUserSceneResult>
{
public class Request
{
@@ -31,22 +36,27 @@ public class SingleUserScene(UserSeeder userSeeder, IUserRepository userReposito
public async Task<SceneResult<SingleUserSceneResult>> SeedAsync(Request request)
{
var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
// Pass service to factory - factory will call Mangle()
var user = UserSeeder.Create(
request.Email,
passwordHasher,
manglerService,
request.EmailVerified,
request.Premium);
await userRepository.CreateAsync(user);
return new SceneResult<SingleUserSceneResult>(result: new SingleUserSceneResult
{
UserId = user.Id,
Kdf = user.Kdf.ToString(),
KdfIterations = user.KdfIterations,
Key = user.Key!,
PublicKey = user.PublicKey!,
PrivateKey = user.PrivateKey!,
ApiKey = user.ApiKey!,
}, mangleMap: userSeeder.GetMangleMap(user, new UserData
{
Email = request.Email,
}));
return new SceneResult<SingleUserSceneResult>(
result: new SingleUserSceneResult
{
UserId = user.Id,
Kdf = user.Kdf.ToString(),
KdfIterations = user.KdfIterations,
Key = user.Key!,
PublicKey = user.PublicKey!,
PrivateKey = user.PrivateKey!,
ApiKey = user.ApiKey!,
},
mangleMap: manglerService.GetMangleMap());
}
}

View File

@@ -27,4 +27,8 @@
<Compile Remove="..\..\Program.cs" />
</ItemGroup>
<!-- Allow integration tests to access internal seeders for validation -->
<ItemGroup>
<InternalsVisibleTo Include="SeederApi.IntegrationTest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,24 @@
namespace Bit.Seeder.Services;
/// <summary>
/// Service for mangling strings to ensure test isolation and collision-free data.
/// </summary>
public interface IManglerService
{
/// <summary>
/// Mangles a string value for test isolation.
/// Automatically tracks the original → mangled mapping.
/// </summary>
string Mangle(string value);
/// <summary>
/// Returns a copy of tracked mangle mappings (original → mangled).
/// Used by Scenes to populate SceneResult.MangleMap.
/// </summary>
Dictionary<string, string?> GetMangleMap();
/// <summary>
/// Indicates whether mangling is enabled.
/// </summary>
bool IsEnabled { get; }
}

View File

@@ -0,0 +1,43 @@
using System.Globalization;
namespace Bit.Seeder.Services;
/// <summary>
/// Scoped stateful implementation that mangles strings with a unique prefix.
/// Each instance generates its own MangleId and tracks all manglings in an internal map.
/// </summary>
public class ManglerService : IManglerService
{
private readonly MangleId _mangleId = new();
private readonly Dictionary<string, string> _mangleMap = new();
public string Mangle(string value)
{
var atIndex = value.IndexOf('@');
var mangled = atIndex >= 0
? $"{_mangleId}+{value[..atIndex]}{value[atIndex..]}"
: $"{_mangleId}-{value}";
_mangleMap[value] = mangled;
return mangled;
}
public Dictionary<string, string?> GetMangleMap()
{
return _mangleMap.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value);
}
public bool IsEnabled => true;
/// <summary>
/// Helper for generating unique identifier suffixes to prevent collisions in test data.
/// "Mangling" adds a random suffix to test data identifiers (usernames, emails, org names, etc.)
/// to ensure uniqueness across multiple test runs and parallel test executions.
/// </summary>
private class MangleId
{
private readonly string _value = Random.Shared.NextInt64().ToString("x", CultureInfo.InvariantCulture)[..8];
public override string ToString() => _value;
}
}

View File

@@ -0,0 +1,14 @@
namespace Bit.Seeder.Services;
/// <summary>
/// No-op implementation for dev/human-readable mode.
/// Returns original values unchanged. Registered as singleton since it has no state.
/// </summary>
public class NoOpManglerService : IManglerService
{
public string Mangle(string value) => value;
public Dictionary<string, string?> GetMangleMap() => new();
public bool IsEnabled => false;
}

View File

@@ -1,5 +1,7 @@
using System.Reflection;
using Bit.Core.Settings;
using Bit.Seeder;
using Bit.Seeder.Services;
using Bit.SeederApi.Commands;
using Bit.SeederApi.Commands.Interfaces;
using Bit.SeederApi.Execution;
@@ -73,4 +75,23 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers the appropriate mangler service based on execution context.
/// </summary>
public static IServiceCollection AddManglerService(
this IServiceCollection services,
GlobalSettings globalSettings)
{
if (globalSettings.TestPlayIdTrackingEnabled)
{
services.TryAddScoped<IManglerService, ManglerService>();
}
else
{
services.TryAddSingleton<IManglerService, NoOpManglerService>();
}
return services;
}
}

View File

@@ -1,7 +1,5 @@
using System.Globalization;
using Bit.Core.Settings;
using Bit.Seeder;
using Bit.Seeder.Factories;
using Bit.SeederApi.Extensions;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Identity;
@@ -32,16 +30,13 @@ public class Startup
services.AddTokenizers();
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
services.AddManglerService(globalSettings);
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddScoped<IPasswordHasher<Core.Entities.User>, PasswordHasher<Core.Entities.User>>();
services.AddScoped<UserSeeder>();
services.AddSeederApiServices();
services.AddScoped<MangleId>(_ => new MangleId());
services.AddScenes();
services.AddQueries();

View File

@@ -1,6 +1,7 @@
{
"globalSettings": {
"projectName": "SeederApi"
"projectName": "SeederApi",
"testPlayIdTrackingEnabled": true
},
"Logging": {
"LogLevel": {