diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs index a70be7d557..f93f47a35a 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs @@ -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(); var passwordHasher = factory.GetService>(); - 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); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index 322fd62bd7..f7eb584b75 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + 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(); var passwordHasher = factory.GetService>(); - var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = factory.GetService(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs index 025eacc432..1bea3dd720 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs @@ -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(); var passwordHasher = factory.GetService>(); - 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(); var passwordHasher = factory.GetService>(); - 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); diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index dbea807259..9a5911d432 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -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 : WebApplicationFactory TestDatabase.Migrate(services); } + // Register NoOpManglerService for test data seeding (no mangling in tests) + services.TryAddSingleton(); + // 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 diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index 9a2e159b22..8ed593d058 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -13,6 +13,7 @@ + diff --git a/test/SeederApi.IntegrationTest/DistributionTests.cs b/test/SeederApi.IntegrationTest/DistributionTests.cs new file mode 100644 index 0000000000..808e31c273 --- /dev/null +++ b/test/SeederApi.IntegrationTest/DistributionTests.cs @@ -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( + ("A", 0.50), + ("B", 0.30), + ("C", 0.20) + ); + + Assert.NotNull(distribution); + } + + [Fact] + public void Constructor_PercentagesDoNotSumToOne_ThrowsArgumentException() + { + var exception = Assert.Throws(() => new Distribution( + (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(() => new Distribution( + ("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( + ("A", 0.333), + ("B", 0.333), + ("C", 0.333) + ); + + Assert.NotNull(distribution); + } + + [Fact] + public void Select_ReturnsCorrectBuckets_ForEvenSplit() + { + var distribution = new Distribution( + ("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( + (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( + ("A", 0.50), + ("B", 0.50) + ); + + Assert.Equal("B", distribution.Select(150, 100)); + } + + [Fact] + public void Select_SmallTotal_HandlesRoundingGracefully() + { + var distribution = new Distribution( + ("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( + ("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( + ("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( + (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( + ("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); + } + } +} diff --git a/test/SeederApi.IntegrationTest/GeneratorContextTests.cs b/test/SeederApi.IntegrationTest/GeneratorContextTests.cs new file mode 100644 index 0000000000..4b068b58ca --- /dev/null +++ b/test/SeederApi.IntegrationTest/GeneratorContextTests.cs @@ -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); + } + } + + /// + /// 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. + /// + [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.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.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 + }; + } +} diff --git a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs index 7ca7a0b913..c2d458deb6 100644 --- a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs +++ b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs @@ -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(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(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(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(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(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(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(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(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(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); + } + } diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 379f60ea1a..60936ca67d 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -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(); var passwordHasher = scopedServices.GetRequiredService>(); - var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); + var manglerService = scopedServices.GetRequiredService(); + 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(); var recipe = new OrganizationWithVaultRecipe( scopedServices.GetRequiredService(), scopedServices.GetRequiredService(), - scopedServices.GetRequiredService>()); + scopedServices.GetRequiredService>(), + 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}"); + } } } diff --git a/util/DbSeederUtility/README.md b/util/DbSeederUtility/README.md index 4bd3c389d6..154cbd018b 100644 --- a/util/DbSeederUtility/README.md +++ b/util/DbSeederUtility/README.md @@ -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 diff --git a/util/DbSeederUtility/ServiceCollectionExtension.cs b/util/DbSeederUtility/ServiceCollectionExtension.cs index ca454c50f3..26a198d03a 100644 --- a/util/DbSeederUtility/ServiceCollectionExtension.cs +++ b/util/DbSeederUtility/ServiceCollectionExtension.cs @@ -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(); + } + else + { + services.TryAddSingleton(); + } } } diff --git a/util/DbSeederUtility/VaultOrganizationArgs.cs b/util/DbSeederUtility/VaultOrganizationArgs.cs index 8ec7762073..8aad49a03d 100644 --- a/util/DbSeederUtility/VaultOrganizationArgs.cs +++ b/util/DbSeederUtility/VaultOrganizationArgs.cs @@ -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) diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index a5a4105f03..21e3f03fb1 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -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`. Async, returns `SceneResult` 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`. 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 diff --git a/util/Seeder/Data/BogusNameProvider.cs b/util/Seeder/Data/BogusNameProvider.cs deleted file mode 100644 index 4a41b6b120..0000000000 --- a/util/Seeder/Data/BogusNameProvider.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Bit.Seeder.Data.Enums; -using Bogus; -using Bogus.DataSets; - -namespace Bit.Seeder.Data; - -/// -/// Provides locale-aware name generation using the Bogus library. -/// Maps GeographicRegion to appropriate Bogus locales for culturally-appropriate names. -/// -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)]; - } -} diff --git a/util/Seeder/Data/CipherUsernameGenerator.cs b/util/Seeder/Data/CipherUsernameGenerator.cs deleted file mode 100644 index 21a726a8ff..0000000000 --- a/util/Seeder/Data/CipherUsernameGenerator.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Bit.Seeder.Data.Enums; - -namespace Bit.Seeder.Data; - -/// -/// 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. -/// -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); - } - - /// - /// Generates username using index for deterministic selection across cipher iterations. - /// - 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); - } - - /// - /// Combines deterministic index with random offset for controlled variety. - /// - 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]; -} diff --git a/util/Seeder/Data/Distributions/CipherTypeDistributions.cs b/util/Seeder/Data/Distributions/CipherTypeDistributions.cs new file mode 100644 index 0000000000..f2205158a7 --- /dev/null +++ b/util/Seeder/Data/Distributions/CipherTypeDistributions.cs @@ -0,0 +1,50 @@ +using Bit.Core.Vault.Enums; + +namespace Bit.Seeder.Data.Distributions; + +/// +/// Pre-configured cipher type distributions for seeding scenarios. +/// +public static class CipherTypeDistributions +{ + /// + /// Realistic enterprise mix based on typical usage patterns. + /// 60% Login, 15% SecureNote, 12% Card, 10% Identity, 3% SshKey + /// + public static Distribution Realistic { get; } = new( + (CipherType.Login, 0.60), + (CipherType.SecureNote, 0.15), + (CipherType.Card, 0.12), + (CipherType.Identity, 0.10), + (CipherType.SSHKey, 0.03) + ); + + /// + /// Login-only distribution for backward compatibility or login-focused testing. + /// + public static Distribution LoginOnly { get; } = new( + (CipherType.Login, 1.0) + ); + + /// + /// Heavy on secure notes for documentation-focused organizations. + /// + public static Distribution DocumentationHeavy { get; } = new( + (CipherType.Login, 0.40), + (CipherType.SecureNote, 0.40), + (CipherType.Card, 0.10), + (CipherType.Identity, 0.07), + (CipherType.SSHKey, 0.03) + ); + + /// + /// Developer-focused with more SSH keys. + /// + public static Distribution DeveloperFocused { get; } = new( + (CipherType.Login, 0.50), + (CipherType.SecureNote, 0.20), + (CipherType.Card, 0.05), + (CipherType.Identity, 0.05), + (CipherType.SSHKey, 0.20) + ); +} diff --git a/util/Seeder/Data/Distributions/Distribution.cs b/util/Seeder/Data/Distributions/Distribution.cs new file mode 100644 index 0000000000..8a44a46e32 --- /dev/null +++ b/util/Seeder/Data/Distributions/Distribution.cs @@ -0,0 +1,65 @@ +namespace Bit.Seeder.Data.Distributions; + +/// +/// Provides deterministic, percentage-based item selection for test data generation. +/// Replaces duplicated distribution logic in GetRealisticStatus, GetFolderCountForUser, etc. +/// +/// The type of values in the distribution. +public sealed class Distribution +{ + private readonly (T Value, double Percentage)[] _buckets; + + /// + /// Creates a distribution from percentage buckets. + /// + /// Value-percentage pairs that must sum to 1.0 (within 0.001 tolerance). + /// Thrown when percentages don't sum to 1.0. + 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; + } + + /// + /// Selects a value deterministically based on index position within a total count. + /// Items 0 to (total * percentage1 - 1) get value1, and so on. + /// + /// Zero-based index of the item. + /// Total number of items being distributed. For best accuracy, use totals >= 100. + /// The value assigned to this index position. + 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; + } + + /// + /// Returns all values with their calculated counts for a given total. + /// The last bucket receives any remainder from rounding. + /// + /// Total number of items to distribute. + /// Sequence of value-count pairs. + 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); + } +} diff --git a/util/Seeder/Data/Distributions/FolderCountDistributions.cs b/util/Seeder/Data/Distributions/FolderCountDistributions.cs new file mode 100644 index 0000000000..c8811f2aa8 --- /dev/null +++ b/util/Seeder/Data/Distributions/FolderCountDistributions.cs @@ -0,0 +1,19 @@ +namespace Bit.Seeder.Data.Distributions; + +/// +/// Pre-configured folder count distributions for user vault seeding. +/// +public static class FolderCountDistributions +{ + /// + /// 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. + /// + 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) + ); +} diff --git a/util/Seeder/Data/Distributions/PasswordDistributions.cs b/util/Seeder/Data/Distributions/PasswordDistributions.cs new file mode 100644 index 0000000000..8d6e79841a --- /dev/null +++ b/util/Seeder/Data/Distributions/PasswordDistributions.cs @@ -0,0 +1,21 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data.Distributions; + +/// +/// Pre-configured password strength distributions for seeding scenarios. +/// +public static class PasswordDistributions +{ + /// + /// Realistic distribution based on breach data and security research. + /// 25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong + /// + public static Distribution Realistic { get; } = new( + (PasswordStrength.VeryWeak, 0.25), + (PasswordStrength.Weak, 0.30), + (PasswordStrength.Fair, 0.25), + (PasswordStrength.Strong, 0.15), + (PasswordStrength.VeryStrong, 0.05) + ); +} diff --git a/util/Seeder/Data/Distributions/UserStatusDistributions.cs b/util/Seeder/Data/Distributions/UserStatusDistributions.cs new file mode 100644 index 0000000000..2402ff4393 --- /dev/null +++ b/util/Seeder/Data/Distributions/UserStatusDistributions.cs @@ -0,0 +1,36 @@ +using Bit.Core.Enums; + +namespace Bit.Seeder.Data.Distributions; + +/// +/// Pre-configured user status distributions for seeding scenarios. +/// +public static class UserStatusDistributions +{ + /// + /// Realistic organization membership distribution. + /// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked + /// + public static Distribution Realistic { get; } = new( + (OrganizationUserStatusType.Confirmed, 0.85), + (OrganizationUserStatusType.Invited, 0.05), + (OrganizationUserStatusType.Accepted, 0.05), + (OrganizationUserStatusType.Revoked, 0.05) + ); + + /// + /// All users confirmed - for simpler testing scenarios. + /// + public static Distribution AllConfirmed { get; } = new( + (OrganizationUserStatusType.Confirmed, 1.0) + ); + + /// + /// New organization with many pending invites. + /// + public static Distribution NewOrganization { get; } = new( + (OrganizationUserStatusType.Confirmed, 0.30), + (OrganizationUserStatusType.Invited, 0.50), + (OrganizationUserStatusType.Accepted, 0.20) + ); +} diff --git a/util/Seeder/Data/Distributions/UsernameDistributions.cs b/util/Seeder/Data/Distributions/UsernameDistributions.cs new file mode 100644 index 0000000000..ca43f77714 --- /dev/null +++ b/util/Seeder/Data/Distributions/UsernameDistributions.cs @@ -0,0 +1,65 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data.Distributions; + +/// +/// Pre-configured username category distributions for seeding scenarios. +/// Pass to CipherUsernameGenerator for different username mixes. +/// +public static class UsernameDistributions +{ + /// + /// Realistic enterprise mix with variety. + /// 45% Corporate email, varied personal/legacy/social. + /// + public static Distribution 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) + ); + + /// + /// Corporate-only: 100% corporate email format. + /// Use for strict enterprise environments. + /// + public static Distribution CorporateOnly { get; } = new( + (UsernameCategory.CorporateEmail, 1.0) + ); + + /// + /// Consumer-focused: personal emails and social handles. + /// Use for B2C application testing. + /// + public static Distribution Consumer { get; } = new( + (UsernameCategory.PersonalEmail, 0.40), + (UsernameCategory.SocialHandle, 0.25), + (UsernameCategory.UsernameOnly, 0.20), + (UsernameCategory.PhoneNumber, 0.15) + ); + + /// + /// Legacy enterprise: older systems with employee IDs. + /// Use for testing migrations from legacy systems. + /// + public static Distribution LegacyEnterprise { get; } = new( + (UsernameCategory.CorporateEmail, 0.30), + (UsernameCategory.EmployeeId, 0.30), + (UsernameCategory.LegacySystem, 0.25), + (UsernameCategory.RandomAlphanumeric, 0.15) + ); + + /// + /// Developer-focused: mix of corporate and technical identifiers. + /// + public static Distribution Developer { get; } = new( + (UsernameCategory.CorporateEmail, 0.35), + (UsernameCategory.UsernameOnly, 0.25), + (UsernameCategory.SocialHandle, 0.20), + (UsernameCategory.RandomAlphanumeric, 0.20) + ); +} diff --git a/util/Seeder/Data/Enums/PasswordStrength.cs b/util/Seeder/Data/Enums/PasswordStrength.cs index bd7f72e2b6..b5ae892721 100644 --- a/util/Seeder/Data/Enums/PasswordStrength.cs +++ b/util/Seeder/Data/Enums/PasswordStrength.cs @@ -5,21 +5,28 @@ /// public enum PasswordStrength { - /// Score 0: Too guessable (< 10³ guesses) + /// + /// Score 0: Too guessable (< 10³ guesses) + /// VeryWeak = 0, - /// Score 1: Very guessable (< 10⁶ guesses) + /// + /// Score 1: Very guessable (< 10⁶ guesses) + /// Weak = 1, - /// Score 2: Somewhat guessable (< 10⁸ guesses) + /// + /// Score 2: Somewhat guessable (< 10⁸ guesses) + /// Fair = 2, - /// Score 3: Safely unguessable (< 10¹⁰ guesses) + /// + /// Score 3: Safely unguessable (< 10¹⁰ guesses) + /// Strong = 3, - /// Score 4: Very unguessable (≥ 10¹⁰ guesses) - VeryStrong = 4, - - /// Realistic distribution based on breach data statistics. - Realistic = 99 + /// + /// Score 4: Very unguessable (≥ 10¹⁰ guesses) + /// + VeryStrong = 4 } diff --git a/util/Seeder/Data/Enums/UsernameCategory.cs b/util/Seeder/Data/Enums/UsernameCategory.cs new file mode 100644 index 0000000000..c194498750 --- /dev/null +++ b/util/Seeder/Data/Enums/UsernameCategory.cs @@ -0,0 +1,48 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Categories of username formats found in real-world credential vaults. +/// Used with Distribution<UsernameCategory> for realistic username generation. +/// +public enum UsernameCategory +{ + /// + /// Corporate email format: john.smith@acme.com + /// + CorporateEmail, + + /// + /// Personal email: jsmith99@fake-gmail.com + /// + PersonalEmail, + + /// + /// Social media handle: @john_smith_42 + /// + SocialHandle, + + /// + /// Plain username: johnsmith, jdoe1985 + /// + UsernameOnly, + + /// + /// Employee identifier: EMP001234, E-12345 + /// + EmployeeId, + + /// + /// Phone number as username: 15551234567 + /// + PhoneNumber, + + /// + /// Legacy system format: JSMITH01, DOEJ + /// + LegacySystem, + + /// + /// Random alphanumeric: xK7mP9qR2n + /// + RandomAlphanumeric +} diff --git a/util/Seeder/Data/Enums/UsernamePatternType.cs b/util/Seeder/Data/Enums/UsernamePatternType.cs index 2c8083ca9d..21ca33bb58 100644 --- a/util/Seeder/Data/Enums/UsernamePatternType.cs +++ b/util/Seeder/Data/Enums/UsernamePatternType.cs @@ -5,16 +5,33 @@ /// public enum UsernamePatternType { - /// first.last@domain.com + /// + /// first.last@domain.com + /// FirstDotLast, - /// f.last@domain.com + + /// + /// f.last@domain.com + /// FDotLast, - /// flast@domain.com + + /// + /// flast@domain.com + /// FLast, - /// last.first@domain.com + + /// + /// last.first@domain.com + /// LastDotFirst, - /// first_last@domain.com + + /// + /// first_last@domain.com + /// First_Last, - /// lastf@domain.com + + /// + /// lastf@domain.com + /// LastFirst } diff --git a/util/Seeder/Data/GeneratorContext.cs b/util/Seeder/Data/GeneratorContext.cs new file mode 100644 index 0000000000..e6471158ff --- /dev/null +++ b/util/Seeder/Data/GeneratorContext.cs @@ -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; + +/// +/// Centralized context for all data generators in a seeding operation. +/// Lazy-initializes generators on first access to avoid creating unused instances. +/// +/// +/// Adding a new generator: +/// 1. Add private nullable field +/// 2. Add public property with lazy initialization +/// 3. Use in Recipe via _ctx.NewGenerator.Method() +/// +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; + } + + /// + /// Creates a GeneratorContext from vault options, deriving seed from domain if not specified. + /// + 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); + } + + /// + /// Derives a stable 32-bit seed from a domain string using SHA256. + /// Same input always produces same output for deterministic generation. + /// + private static int DeriveStableSeed(string domain) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(domain)); + return BitConverter.ToInt32(bytes, 0); + } + + /// + /// The seed used for deterministic generation. Exposed for distribution calculations. + /// + public int Seed => _seed; + + /// + /// Total cipher count from options. Used for distribution calculations. + /// + 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); +} diff --git a/util/Seeder/Data/Generators/CardDataGenerator.cs b/util/Seeder/Data/Generators/CardDataGenerator.cs new file mode 100644 index 0000000000..4ccbdef1a4 --- /dev/null +++ b/util/Seeder/Data/Generators/CardDataGenerator.cs @@ -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 _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; + } + + /// + /// Generates a deterministic card based on index. + /// + 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); +} diff --git a/util/Seeder/Data/Generators/CipherUsernameGenerator.cs b/util/Seeder/Data/Generators/CipherUsernameGenerator.cs new file mode 100644 index 0000000000..407df69359 --- /dev/null +++ b/util/Seeder/Data/Generators/CipherUsernameGenerator.cs @@ -0,0 +1,235 @@ +using Bit.Seeder.Data.Distributions; +using Bit.Seeder.Data.Enums; +using Bogus; + +namespace Bit.Seeder.Data.Generators; + +/// +/// 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. +/// +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 _distribution; + + private readonly UsernamePatternType _corporateEmailPattern; + + private readonly string[] _firstNames; + + private readonly string[] _lastNames; + + /// + /// Creates a username generator with the specified distribution and settings. + /// + /// Seed for deterministic generation. + /// Distribution of username categories. Use UsernameDistributions.Realistic for defaults. + /// Geographic region for culturally-appropriate name generation. + /// Pattern for corporate emails (default: first.last@domain). + internal CipherUsernameGenerator( + int seed, + Distribution 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(); + } + + /// + /// Generates a deterministic username based on index and optional domain. + /// Category is selected based on the configured distribution. + /// + /// Index for deterministic selection. + /// Total number of items (for distribution calculation). Default: 1000. + /// Corporate domain (used for CorporateEmail category). + 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]; + } +} diff --git a/util/Seeder/Data/FolderNameGenerator.cs b/util/Seeder/Data/Generators/FolderNameGenerator.cs similarity index 65% rename from util/Seeder/Data/FolderNameGenerator.cs rename to util/Seeder/Data/Generators/FolderNameGenerator.cs index 173fae3116..28caefaeae 100644 --- a/util/Seeder/Data/FolderNameGenerator.cs +++ b/util/Seeder/Data/Generators/FolderNameGenerator.cs @@ -1,18 +1,14 @@ using Bogus; -namespace Bit.Seeder.Data; +namespace Bit.Seeder.Data.Generators; -/// -/// Generates deterministic folder names using Bogus Commerce.Department(). -/// Pre-generates a pool of business-themed names for consistent index-based access. -/// 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 /// /// Gets a folder name by index, wrapping around if index exceeds pool size. /// - public string GetFolderName(int index) => _folderNames[index % _folderNames.Length]; + internal string GetFolderName(int index) => _folderNames[index % _folderNames.Length]; } diff --git a/util/Seeder/Data/Generators/IdentityDataGenerator.cs b/util/Seeder/Data/Generators/IdentityDataGenerator.cs new file mode 100644 index 0000000000..432614c912 --- /dev/null +++ b/util/Seeder/Data/Generators/IdentityDataGenerator.cs @@ -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 _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"] + }; + + /// + /// Generates a deterministic identity based on index. + /// + 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" + }; +} diff --git a/util/Seeder/Data/Generators/SecureNoteDataGenerator.cs b/util/Seeder/Data/Generators/SecureNoteDataGenerator.cs new file mode 100644 index 0000000000..d745e2cc34 --- /dev/null +++ b/util/Seeder/Data/Generators/SecureNoteDataGenerator.cs @@ -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" + ]; + + /// + /// Generates a deterministic secure note based on index for reproducible test data. + /// + /// Tuple of (name, notes) for the secure note cipher. + 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() + }; +} diff --git a/util/Seeder/Data/Generators/SshKeyDataGenerator.cs b/util/Seeder/Data/Generators/SshKeyDataGenerator.cs new file mode 100644 index 0000000000..f85a114d72 --- /dev/null +++ b/util/Seeder/Data/Generators/SshKeyDataGenerator.cs @@ -0,0 +1,142 @@ +using System.Security.Cryptography; +using Bit.Seeder.Models; + +namespace Bit.Seeder.Data.Generators; + +/// +/// Generates structurally-valid but intentionally unusable SSH keys for test vault data. +/// +/// +/// +/// Security by Design: 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. +/// +/// +/// Why realistic structure? 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. +/// +/// +/// Context: 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. +/// +/// +/// Note: Keys are NOT deterministically seeded - RSA.Create() uses system RNG. +/// The pool provides variety but not cross-run reproducibility. +/// +/// +internal static class SshKeyDataGenerator +{ + private const int _poolSize = 500; + + private static readonly Lazy<(string Private, string Public, string Fingerprint)[]> _keyPool = + new(() => GenerateKeyPool(_poolSize)); + + /// + /// Generates a deterministic SSH key based on index from the pre-generated pool. + /// + 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 { "-----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); + } +} diff --git a/util/Seeder/Data/README.md b/util/Seeder/Data/README.md index 7c16242a0c..2c9751b982 100644 --- a/util/Seeder/Data/README.md +++ b/util/Seeder/Data/README.md @@ -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.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 diff --git a/util/Seeder/Data/Companies.cs b/util/Seeder/Data/Static/Companies.cs similarity index 95% rename from util/Seeder/Data/Companies.cs rename to util/Seeder/Data/Static/Companies.cs index d37c2f810a..69e023217d 100644 --- a/util/Seeder/Data/Companies.cs +++ b/util/Seeder/Data/Static/Companies.cs @@ -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( /// 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) diff --git a/util/Seeder/Data/OrgStructures.cs b/util/Seeder/Data/Static/OrgStructures.cs similarity index 90% rename from util/Seeder/Data/OrgStructures.cs rename to util/Seeder/Data/Static/OrgStructures.cs index 668653cd37..48e49cd118 100644 --- a/util/Seeder/Data/OrgStructures.cs +++ b/util/Seeder/Data/Static/OrgStructures.cs @@ -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); /// 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, diff --git a/util/Seeder/Data/Passwords.cs b/util/Seeder/Data/Static/Passwords.cs similarity index 61% rename from util/Seeder/Data/Passwords.cs rename to util/Seeder/Data/Static/Passwords.cs index 1717c2b408..198082d553 100644 --- a/util/Seeder/Data/Passwords.cs +++ b/util/Seeder/Data/Static/Passwords.cs @@ -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; /// /// Password collections by zxcvbn strength level (0-4) for realistic test data. @@ -10,7 +11,7 @@ internal static class Passwords /// /// Score 0 - Too guessable: keyboard walks, simple sequences, single words. /// - 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 /// /// Score 1 - Very guessable: common patterns with minor complexity. /// - 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 /// /// Score 2 - Somewhat guessable: meets basic complexity but predictable patterns. /// - 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 /// /// Score 3 - Safely unguessable: good entropy, mixed character types. /// - 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 /// /// Score 4 - Very unguessable: high entropy, long passphrases, random strings. /// - 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 ]; /// All passwords combined for mixed/random selection. - public static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong]; + internal static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong]; - /// - /// 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 - /// - 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 }; /// - /// 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. /// - public static string GetRealisticPassword(int index) + internal static string GetPassword(int index, int total, Distribution distribution) { - var strength = GetRealisticStrength(index); - var passwords = GetByStrength(strength); - return passwords[index % passwords.Length]; - } - - /// - /// Gets a password strength following realistic distribution. - /// Deterministic based on index for reproducible results. - /// - 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]; } diff --git a/util/Seeder/Data/UsernamePatterns.cs b/util/Seeder/Data/UsernamePatterns.cs deleted file mode 100644 index c435cacd93..0000000000 --- a/util/Seeder/Data/UsernamePatterns.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Bit.Seeder.Data.Enums; - -namespace Bit.Seeder.Data; - -internal sealed record UsernamePattern( - UsernamePatternType Type, - string FormatDescription, - Func Generate); - -/// -/// Username pattern implementations for different email conventions. -/// -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 - }; -} diff --git a/util/Seeder/Factories/CardCipherSeeder.cs b/util/Seeder/Factories/CardCipherSeeder.cs new file mode 100644 index 0000000000..28355bbd62 --- /dev/null +++ b/util/Seeder/Factories/CardCipherSeeder.cs @@ -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); + } +} diff --git a/util/Seeder/Factories/CipherEncryption.cs b/util/Seeder/Factories/CipherEncryption.cs new file mode 100644 index 0000000000..17cf6d2c0f --- /dev/null +++ b/util/Seeder/Factories/CipherEncryption.cs @@ -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(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 + }; + } +} diff --git a/util/Seeder/Factories/CipherSeeder.cs b/util/Seeder/Factories/CipherSeeder.cs deleted file mode 100644 index 9d4c039b2c..0000000000 --- a/util/Seeder/Factories/CipherSeeder.cs +++ /dev/null @@ -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; - -/// -/// Creates encrypted ciphers for seeding vaults via the Rust SDK. -/// -/// -/// Supported cipher types: -/// -/// Login - -/// -/// Future: Card, Identity, SecureNote will follow the same pattern—public Create method + private Transform method. -/// -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(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 - }; - } -} - diff --git a/util/Seeder/Factories/CollectionSeeder.cs b/util/Seeder/Factories/CollectionSeeder.cs index 231fe86b43..8314d49195 100644 --- a/util/Seeder/Factories/CollectionSeeder.cs +++ b/util/Seeder/Factories/CollectionSeeder.cs @@ -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 - }; - } } diff --git a/util/Seeder/Factories/CollectionUserSeeder.cs b/util/Seeder/Factories/CollectionUserSeeder.cs new file mode 100644 index 0000000000..0636143e89 --- /dev/null +++ b/util/Seeder/Factories/CollectionUserSeeder.cs @@ -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 + }; + } +} diff --git a/util/Seeder/Factories/FolderSeeder.cs b/util/Seeder/Factories/FolderSeeder.cs index 8cf7413bbc..1c145cfc74 100644 --- a/util/Seeder/Factories/FolderSeeder.cs +++ b/util/Seeder/Factories/FolderSeeder.cs @@ -4,19 +4,9 @@ using Bit.RustSDK; namespace Bit.Seeder.Factories; -/// -/// Factory for creating Folder entities with encrypted names. -/// Folders are per-user constructs encrypted with the user's symmetric key. -/// -internal sealed class FolderSeeder +internal static class FolderSeeder { - /// - /// Creates a folder with an encrypted name. - /// - /// The user who owns this folder. - /// The user's symmetric key (not org key). - /// The plaintext folder name to encrypt. - public static Folder CreateFolder(Guid userId, string userKeyBase64, string name) + internal static Folder Create(Guid userId, string userKeyBase64, string name) { return new Folder { diff --git a/util/Seeder/Factories/GroupSeeder.cs b/util/Seeder/Factories/GroupSeeder.cs index 7ee7df9484..2b63ff7a34 100644 --- a/util/Seeder/Factories/GroupSeeder.cs +++ b/util/Seeder/Factories/GroupSeeder.cs @@ -3,18 +3,9 @@ using Bit.Core.Utilities; namespace Bit.Seeder.Factories; -/// -/// Creates groups and group-user relationships for seeding. -/// -public static class GroupSeeder +internal static class GroupSeeder { - /// - /// Creates a group entity for an organization. - /// - /// The organization ID. - /// The group name. - /// A new Group entity (not persisted). - 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 }; } - - /// - /// Creates a group-user relationship entity. - /// - /// The group ID. - /// The organization user ID. - /// A new GroupUser entity (not persisted). - public static GroupUser CreateGroupUser(Guid groupId, Guid organizationUserId) - { - return new GroupUser - { - GroupId = groupId, - OrganizationUserId = organizationUserId - }; - } } diff --git a/util/Seeder/Factories/GroupUserSeeder.cs b/util/Seeder/Factories/GroupUserSeeder.cs new file mode 100644 index 0000000000..e47f592e1b --- /dev/null +++ b/util/Seeder/Factories/GroupUserSeeder.cs @@ -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 + }; + } +} diff --git a/util/Seeder/Factories/IdentityCipherSeeder.cs b/util/Seeder/Factories/IdentityCipherSeeder.cs new file mode 100644 index 0000000000..1f0f5975bb --- /dev/null +++ b/util/Seeder/Factories/IdentityCipherSeeder.cs @@ -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); + } +} diff --git a/util/Seeder/Factories/LoginCipherSeeder.cs b/util/Seeder/Factories/LoginCipherSeeder.cs new file mode 100644 index 0000000000..76fcf57764 --- /dev/null +++ b/util/Seeder/Factories/LoginCipherSeeder.cs @@ -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); + } +} diff --git a/util/Seeder/Factories/OrganizationDomainSeeder.cs b/util/Seeder/Factories/OrganizationDomainSeeder.cs index 2bc41f8514..c1d498655f 100644 --- a/util/Seeder/Factories/OrganizationDomainSeeder.cs +++ b/util/Seeder/Factories/OrganizationDomainSeeder.cs @@ -1,23 +1,15 @@ -using Bit.Infrastructure.EntityFramework.Models; +using Bit.Core.Utilities; +using Bit.Infrastructure.EntityFramework.Models; namespace Bit.Seeder.Factories; -/// -/// Creates organization domain entities for seeding. -/// -public static class OrganizationDomainSeeder +internal static class OrganizationDomainSeeder { - /// - /// Creates a verified organization domain entity. - /// - /// The organization ID. - /// The domain name (e.g., "example.com"). - /// A new verified OrganizationDomain entity (not persisted). - 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"), diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 30b790c343..27a8bb491a 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -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 { /// /// Creates an OrganizationUser with a dynamically provided encrypted org key. /// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey(). /// - 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, diff --git a/util/Seeder/Factories/SecureNoteCipherSeeder.cs b/util/Seeder/Factories/SecureNoteCipherSeeder.cs new file mode 100644 index 0000000000..3fb6dab2ea --- /dev/null +++ b/util/Seeder/Factories/SecureNoteCipherSeeder.cs @@ -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); + } +} diff --git a/util/Seeder/Factories/SshKeyCipherSeeder.cs b/util/Seeder/Factories/SshKeyCipherSeeder.cs new file mode 100644 index 0000000000..fa70a8bcbc --- /dev/null +++ b/util/Seeder/Factories/SshKeyCipherSeeder.cs @@ -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); + } +} diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index a860506e29..9afca627c8 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -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 passwordHasher, MangleId mangleId) -{ - private string MangleEmail(string email) + internal static User Create( + string email, + IPasswordHasher 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; - } - - /// - /// Default test password used for all seeded users. - /// - public const string DefaultPassword = "asdfasdfasdf"; - - /// - /// Creates a user with hardcoded keys (no email mangling, no SDK calls). - /// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs. - /// - 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, - }; - } - - /// - /// Creates a user with SDK-generated cryptographic keys (no email mangling). - /// The user can log in with email and password = "asdfasdfasdf". - /// - public static User CreateUserWithSdkKeys( - string email, - IPasswordHasher passwordHasher) - { - var keys = RustSdkService.GenerateUserKeys(email, DefaultPassword); - return CreateUserFromKeys(email, keys, passwordHasher); - } - - /// - /// 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). - /// - public static User CreateUserFromKeys( - string email, - UserKeys keys, - IPasswordHasher 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 GetMangleMap(User user, UserData expectedUserData) - { - var mangleMap = new Dictionary - { - { expectedUserData.Email, user.Email }, - }; - - return mangleMap; - } } diff --git a/util/Seeder/MangleId.cs b/util/Seeder/MangleId.cs deleted file mode 100644 index 8cc862c28c..0000000000 --- a/util/Seeder/MangleId.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Globalization; - -namespace Bit.Seeder; - -/// -/// 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. -/// -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; -} diff --git a/util/Seeder/Models/CipherViewDto.cs b/util/Seeder/Models/CipherViewDto.cs index bd6ccfd6bf..0b94d0b0f4 100644 --- a/util/Seeder/Models/CipherViewDto.cs +++ b/util/Seeder/Models/CipherViewDto.cs @@ -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; } + +/// +/// Card cipher data for SDK encryption. Uses record for composition via `with` expressions. +/// +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; } +} + +/// +/// Identity cipher data for SDK encryption. Uses record for composition via `with` expressions. +/// +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; } +} + +/// +/// SecureNote cipher data for SDK encryption. Minimal structure - content is in cipher.Notes. +/// +public record SecureNoteViewDto +{ + [JsonPropertyName("type")] + public int Type { get; init; } = 0; // Generic = 0 +} + +/// +/// SSH Key cipher data for SDK encryption. Uses record for composition via `with` expressions. +/// +public record SshKeyViewDto +{ + [JsonPropertyName("privateKey")] + public string? PrivateKey { get; init; } + + [JsonPropertyName("publicKey")] + public string? PublicKey { get; init; } + + /// SDK expects "fingerprint" field name. + [JsonPropertyName("fingerprint")] + public string? Fingerprint { get; init; } +} diff --git a/util/Seeder/Models/EncryptedCipherDto.cs b/util/Seeder/Models/EncryptedCipherDto.cs index 5b5b6aa56c..f10d7bb464 100644 --- a/util/Seeder/Models/EncryptedCipherDto.cs +++ b/util/Seeder/Models/EncryptedCipherDto.cs @@ -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? 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; } +} diff --git a/util/Seeder/Models/EncryptedCipherDtoExtensions.cs b/util/Seeder/Models/EncryptedCipherDtoExtensions.cs new file mode 100644 index 0000000000..b98fdb08c9 --- /dev/null +++ b/util/Seeder/Models/EncryptedCipherDtoExtensions.cs @@ -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? ToFields(this EncryptedCipherDto e) => + e.Fields?.Select(f => new CipherFieldData + { + Name = f.Name, + Value = f.Value, + Type = (FieldType)f.Type, + LinkedId = f.LinkedId + }); +} diff --git a/util/Seeder/Options/OrganizationVaultOptions.cs b/util/Seeder/Options/OrganizationVaultOptions.cs index ff1be02f7c..6afd13c1d9 100644 --- a/util/Seeder/Options/OrganizationVaultOptions.cs +++ b/util/Seeder/Options/OrganizationVaultOptions.cs @@ -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; } /// - /// Username pattern for cipher logins. + /// Username pattern for corporate email format (e.g., first.last@domain). + /// Only applies to CorporateEmail category usernames. /// public UsernamePatternType UsernamePattern { get; init; } = UsernamePatternType.FirstDotLast; /// - /// 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 for a typical enterprise mix (45% corporate). + /// Defaults to Realistic if not specified. /// - public PasswordStrength PasswordStrength { get; init; } = PasswordStrength.Realistic; + public Distribution UsernameDistribution { get; init; } = UsernameDistributions.Realistic; + + /// + /// Distribution of password strengths for cipher logins. + /// Use for breach-data distribution + /// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong). + /// Defaults to Realistic if not specified. + /// + public Distribution PasswordDistribution { get; init; } = PasswordDistributions.Realistic; /// /// Geographic region for culturally-appropriate name generation in cipher usernames. /// Defaults to Global (mixed locales from all regions). /// public GeographicRegion? Region { get; init; } + + /// + /// When specified, ciphers are distributed according to the percentages. + /// Use for a typical enterprise mix. + /// + public Distribution CipherTypeDistribution { get; init; } = CipherTypeDistributions.Realistic; + + /// + /// Seed for deterministic data generation. When null, derived from Domain hash. + /// + public int? Seed { get; init; } } diff --git a/util/Seeder/README.md b/util/Seeder/README.md index 3b38c3d731..b3eeeb42aa 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -102,8 +102,8 @@ The Seeder is organized around six core patterns, each with a specific responsib - Implement `IScene` or `IScene` - 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` +- `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: diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index f6a21ab4ac..720ce73cce 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -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 passwordHasher) +public class OrganizationWithUsersRecipe( + DatabaseContext db, + IMapper mapper, + IPasswordHasher 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(); 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 diff --git a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs index 6b729273f1..f73a3c7228 100644 --- a/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -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 passwordHasher) + IPasswordHasher passwordHasher, + IManglerService manglerService) { + private const int _minimumOrgSeats = 1000; + + private GeneratorContext _ctx = null!; /// /// Tracks a user with their symmetric key for folder encryption. @@ -42,15 +52,17 @@ public class OrganizationWithVaultRecipe( /// The organization ID. 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 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 collectionIds, int cipherCount, - UsernamePatternType usernamePattern, - PasswordStrength passwordStrength, - GeographicRegion? region) + Distribution passwordDistribution, + Distribution 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); } } - - /// - /// Returns a realistic user status based on index position. - /// Distribution: 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked. - /// - private static OrganizationUserStatusType GetRealisticStatus(int index, int totalUsers) + private Cipher CreateLoginCipher( + int index, + Guid organizationId, + string orgKeyBase64, + Company[] companies, + int cipherCount, + Distribution 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); } /// /// Creates personal vault folders for users with realistic distribution. - /// Folders are encrypted with each user's individual symmetric key. /// private void CreateFolders(List 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( } } - /// - /// 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) - /// - 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) + /// + /// Returns a deterministic value in [min, max) based on index and seed. + /// + 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 } } diff --git a/util/Seeder/Scenes/SingleUserScene.cs b/util/Seeder/Scenes/SingleUserScene.cs index f7cec192fd..9e6fe7b6c6 100644 --- a/util/Seeder/Scenes/SingleUserScene.cs +++ b/util/Seeder/Scenes/SingleUserScene.cs @@ -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; } - } /// /// Creates a single user using the provided account details. /// -public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene +public class SingleUserScene( + IPasswordHasher passwordHasher, + IUserRepository userRepository, + IManglerService manglerService) : IScene { public class Request { @@ -31,22 +36,27 @@ public class SingleUserScene(UserSeeder userSeeder, IUserRepository userReposito public async Task> 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(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( + 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()); } } diff --git a/util/Seeder/Seeder.csproj b/util/Seeder/Seeder.csproj index b38c2cf1e1..1551eaba6c 100644 --- a/util/Seeder/Seeder.csproj +++ b/util/Seeder/Seeder.csproj @@ -27,4 +27,8 @@ + + + + diff --git a/util/Seeder/Services/IManglerService.cs b/util/Seeder/Services/IManglerService.cs new file mode 100644 index 0000000000..d76d48e862 --- /dev/null +++ b/util/Seeder/Services/IManglerService.cs @@ -0,0 +1,24 @@ +namespace Bit.Seeder.Services; + +/// +/// Service for mangling strings to ensure test isolation and collision-free data. +/// +public interface IManglerService +{ + /// + /// Mangles a string value for test isolation. + /// Automatically tracks the original → mangled mapping. + /// + string Mangle(string value); + + /// + /// Returns a copy of tracked mangle mappings (original → mangled). + /// Used by Scenes to populate SceneResult.MangleMap. + /// + Dictionary GetMangleMap(); + + /// + /// Indicates whether mangling is enabled. + /// + bool IsEnabled { get; } +} diff --git a/util/Seeder/Services/ManglerService.cs b/util/Seeder/Services/ManglerService.cs new file mode 100644 index 0000000000..18198bc9f8 --- /dev/null +++ b/util/Seeder/Services/ManglerService.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace Bit.Seeder.Services; + +/// +/// Scoped stateful implementation that mangles strings with a unique prefix. +/// Each instance generates its own MangleId and tracks all manglings in an internal map. +/// +public class ManglerService : IManglerService +{ + private readonly MangleId _mangleId = new(); + private readonly Dictionary _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 GetMangleMap() + { + return _mangleMap.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value); + } + + public bool IsEnabled => true; + + /// + /// 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. + /// + private class MangleId + { + private readonly string _value = Random.Shared.NextInt64().ToString("x", CultureInfo.InvariantCulture)[..8]; + + public override string ToString() => _value; + } +} diff --git a/util/Seeder/Services/NoOpManglerService.cs b/util/Seeder/Services/NoOpManglerService.cs new file mode 100644 index 0000000000..344dddc43d --- /dev/null +++ b/util/Seeder/Services/NoOpManglerService.cs @@ -0,0 +1,14 @@ +namespace Bit.Seeder.Services; + +/// +/// No-op implementation for dev/human-readable mode. +/// Returns original values unchanged. Registered as singleton since it has no state. +/// +public class NoOpManglerService : IManglerService +{ + public string Mangle(string value) => value; + + public Dictionary GetMangleMap() => new(); + + public bool IsEnabled => false; +} diff --git a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs index 052da28dfc..6679533a1a 100644 --- a/util/SeederApi/Extensions/ServiceCollectionExtensions.cs +++ b/util/SeederApi/Extensions/ServiceCollectionExtensions.cs @@ -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; } + + /// + /// Registers the appropriate mangler service based on execution context. + /// + public static IServiceCollection AddManglerService( + this IServiceCollection services, + GlobalSettings globalSettings) + { + if (globalSettings.TestPlayIdTrackingEnabled) + { + services.TryAddScoped(); + } + else + { + services.TryAddSingleton(); + } + + return services; + } } diff --git a/util/SeederApi/Startup.cs b/util/SeederApi/Startup.cs index 5caf0208e3..a38ff8256b 100644 --- a/util/SeederApi/Startup.cs +++ b/util/SeederApi/Startup.cs @@ -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(); services.AddScoped, PasswordHasher>(); - services.AddScoped(); - services.AddSeederApiServices(); - - services.AddScoped(_ => new MangleId()); services.AddScenes(); services.AddQueries(); diff --git a/util/SeederApi/appsettings.json b/util/SeederApi/appsettings.json index 79388a1bb0..7b8ac3c39e 100644 --- a/util/SeederApi/appsettings.json +++ b/util/SeederApi/appsettings.json @@ -1,6 +1,7 @@ { "globalSettings": { - "projectName": "SeederApi" + "projectName": "SeederApi", + "testPlayIdTrackingEnabled": true }, "Logging": { "LogLevel": {