mirror of
https://github.com/bitwarden/server
synced 2026-02-11 14:03:24 +00:00
Enhance seeder with additional cipher types and architectural refactorings (#6935)
This commit is contained in:
@@ -8,6 +8,7 @@ using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Bit.Seeder.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
@@ -31,7 +32,8 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = new NoOpManglerService();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Bit.Seeder.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
@@ -33,7 +34,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@@ -71,7 +73,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@@ -107,7 +110,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
@@ -141,7 +145,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
@@ -176,7 +181,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
@@ -226,7 +232,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
@@ -268,7 +275,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
@@ -314,7 +322,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
@@ -360,7 +369,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var domainSeeder = new OrganizationDomainRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
@@ -407,7 +417,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@@ -459,7 +470,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
@@ -498,7 +510,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var domainSeeder = new OrganizationDomainRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
@@ -541,7 +554,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
@@ -591,7 +605,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = factory.GetService<IManglerService>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
|
||||
@@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Bit.Seeder.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
@@ -34,7 +35,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = new NoOpManglerService();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
@@ -84,7 +86,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
|
||||
var db = factory.GetDatabaseContext();
|
||||
var mapper = factory.GetService<IMapper>();
|
||||
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = new NoOpManglerService();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using Bit.Core.Platform.PushRegistration.Internal;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Seeder.Services;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
@@ -190,6 +191,9 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
|
||||
TestDatabase.Migrate(services);
|
||||
}
|
||||
|
||||
// Register NoOpManglerService for test data seeding (no mangling in tests)
|
||||
services.TryAddSingleton<IManglerService, NoOpManglerService>();
|
||||
|
||||
// QUESTION: The normal licensing service should run fine on developer machines but not in CI
|
||||
// should we have a fork here to leave the normal service for developers?
|
||||
// TODO: Eventually add the license file to CI
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Identity\Identity.csproj" />
|
||||
<ProjectReference Include="..\..\util\Migrator\Migrator.csproj" />
|
||||
<ProjectReference Include="..\..\util\Seeder\Seeder.csproj" />
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
175
test/SeederApi.IntegrationTest/DistributionTests.cs
Normal file
175
test/SeederApi.IntegrationTest/DistributionTests.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.SeederApi.IntegrationTest;
|
||||
|
||||
public class DistributionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_PercentagesSumToOne_Succeeds()
|
||||
{
|
||||
var distribution = new Distribution<string>(
|
||||
("A", 0.50),
|
||||
("B", 0.30),
|
||||
("C", 0.20)
|
||||
);
|
||||
|
||||
Assert.NotNull(distribution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PercentagesDoNotSumToOne_ThrowsArgumentException()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentException>(() => new Distribution<int>(
|
||||
(1, 0.50),
|
||||
(2, 0.40)
|
||||
));
|
||||
|
||||
Assert.Contains("must sum to 1.0", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PercentagesExceedOne_ThrowsArgumentException()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentException>(() => new Distribution<string>(
|
||||
("X", 0.60),
|
||||
("Y", 0.60)
|
||||
));
|
||||
|
||||
Assert.Contains("must sum to 1.0", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithinToleranceOf001_Succeeds()
|
||||
{
|
||||
var distribution = new Distribution<string>(
|
||||
("A", 0.333),
|
||||
("B", 0.333),
|
||||
("C", 0.333)
|
||||
);
|
||||
|
||||
Assert.NotNull(distribution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_ReturnsCorrectBuckets_ForEvenSplit()
|
||||
{
|
||||
var distribution = new Distribution<string>(
|
||||
("A", 0.50),
|
||||
("B", 0.50)
|
||||
);
|
||||
|
||||
Assert.Equal("A", distribution.Select(0, 100));
|
||||
Assert.Equal("A", distribution.Select(49, 100));
|
||||
Assert.Equal("B", distribution.Select(50, 100));
|
||||
Assert.Equal("B", distribution.Select(99, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_ReturnsCorrectBuckets_ForThreeWaySplit()
|
||||
{
|
||||
var distribution = new Distribution<int>(
|
||||
(1, 0.60),
|
||||
(2, 0.30),
|
||||
(3, 0.10)
|
||||
);
|
||||
|
||||
Assert.Equal(1, distribution.Select(0, 100));
|
||||
Assert.Equal(1, distribution.Select(59, 100));
|
||||
Assert.Equal(2, distribution.Select(60, 100));
|
||||
Assert.Equal(2, distribution.Select(89, 100));
|
||||
Assert.Equal(3, distribution.Select(90, 100));
|
||||
Assert.Equal(3, distribution.Select(99, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_IndexBeyondTotal_ReturnsLastBucket()
|
||||
{
|
||||
var distribution = new Distribution<string>(
|
||||
("A", 0.50),
|
||||
("B", 0.50)
|
||||
);
|
||||
|
||||
Assert.Equal("B", distribution.Select(150, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_SmallTotal_HandlesRoundingGracefully()
|
||||
{
|
||||
var distribution = new Distribution<string>(
|
||||
("A", 0.33),
|
||||
("B", 0.33),
|
||||
("C", 0.34)
|
||||
);
|
||||
|
||||
Assert.Equal("A", distribution.Select(0, 10));
|
||||
Assert.Equal("A", distribution.Select(2, 10));
|
||||
Assert.Equal("B", distribution.Select(3, 10));
|
||||
Assert.Equal("C", distribution.Select(9, 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCounts_ReturnsCorrectCounts_ForEvenSplit()
|
||||
{
|
||||
var distribution = new Distribution<string>(
|
||||
("X", 0.50),
|
||||
("Y", 0.50)
|
||||
);
|
||||
|
||||
var counts = distribution.GetCounts(100).ToList();
|
||||
|
||||
Assert.Equal(2, counts.Count);
|
||||
Assert.Equal(("X", 50), counts[0]);
|
||||
Assert.Equal(("Y", 50), counts[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCounts_LastBucketReceivesRemainder()
|
||||
{
|
||||
var distribution = new Distribution<string>(
|
||||
("A", 0.33),
|
||||
("B", 0.33),
|
||||
("C", 0.34)
|
||||
);
|
||||
|
||||
var counts = distribution.GetCounts(100).ToList();
|
||||
|
||||
Assert.Equal(3, counts.Count);
|
||||
Assert.Equal(33, counts[0].Count);
|
||||
Assert.Equal(33, counts[1].Count);
|
||||
Assert.Equal(34, counts[2].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCounts_TotalCountsMatchInput()
|
||||
{
|
||||
var distribution = new Distribution<int>(
|
||||
(1, 0.25),
|
||||
(2, 0.25),
|
||||
(3, 0.25),
|
||||
(4, 0.25)
|
||||
);
|
||||
|
||||
var counts = distribution.GetCounts(1000).ToList();
|
||||
var total = counts.Sum(c => c.Count);
|
||||
|
||||
Assert.Equal(1000, total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_IsDeterministic_SameInputSameOutput()
|
||||
{
|
||||
var distribution = new Distribution<string>(
|
||||
("Alpha", 0.40),
|
||||
("Beta", 0.35),
|
||||
("Gamma", 0.25)
|
||||
);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var first = distribution.Select(i, 100);
|
||||
var second = distribution.Select(i, 100);
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
}
|
||||
}
|
||||
262
test/SeederApi.IntegrationTest/GeneratorContextTests.cs
Normal file
262
test/SeederApi.IntegrationTest/GeneratorContextTests.cs
Normal file
@@ -0,0 +1,262 @@
|
||||
using Bit.Seeder.Data;
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.SeederApi.IntegrationTest;
|
||||
|
||||
public class GeneratorContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromOptions_SameDomain_ProducesSameSeed()
|
||||
{
|
||||
var options1 = CreateOptions("acme.com", ciphers: 100);
|
||||
var options2 = CreateOptions("acme.com", ciphers: 100);
|
||||
|
||||
var ctx1 = GeneratorContext.FromOptions(options1);
|
||||
var ctx2 = GeneratorContext.FromOptions(options2);
|
||||
|
||||
Assert.Equal(ctx1.Seed, ctx2.Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromOptions_DifferentDomains_ProduceDifferentSeeds()
|
||||
{
|
||||
var ctx1 = GeneratorContext.FromOptions(CreateOptions("acme.com"));
|
||||
var ctx2 = GeneratorContext.FromOptions(CreateOptions("contoso.com"));
|
||||
|
||||
Assert.NotEqual(ctx1.Seed, ctx2.Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromOptions_ExplicitSeed_OverridesDomainHash()
|
||||
{
|
||||
var options = new OrganizationVaultOptions
|
||||
{
|
||||
Name = "Test Org",
|
||||
Domain = "example.com",
|
||||
Users = 10,
|
||||
Ciphers = 100,
|
||||
Seed = 42
|
||||
};
|
||||
|
||||
var ctx = GeneratorContext.FromOptions(options);
|
||||
|
||||
Assert.Equal(42, ctx.Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Username_SameSeed_ProducesSameOutput()
|
||||
{
|
||||
var options = CreateOptions("test.com", ciphers: 100);
|
||||
|
||||
var ctx1 = GeneratorContext.FromOptions(options);
|
||||
var ctx2 = GeneratorContext.FromOptions(options);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var username1 = ctx1.Username.GenerateByIndex(i, totalHint: 100, domain: "test.com");
|
||||
var username2 = ctx2.Username.GenerateByIndex(i, totalHint: 100, domain: "test.com");
|
||||
Assert.Equal(username1, username2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Username_DifferentSeeds_ProducesDifferentOutput()
|
||||
{
|
||||
var ctx1 = GeneratorContext.FromOptions(CreateOptions("alpha.com"));
|
||||
var ctx2 = GeneratorContext.FromOptions(CreateOptions("beta.com"));
|
||||
|
||||
var username1 = ctx1.Username.GenerateByIndex(0, domain: "alpha.com");
|
||||
var username2 = ctx2.Username.GenerateByIndex(0, domain: "beta.com");
|
||||
|
||||
Assert.NotEqual(username1, username2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Folder_SameSeed_ProducesSameOutput()
|
||||
{
|
||||
var options = CreateOptions("test.com");
|
||||
|
||||
var ctx1 = GeneratorContext.FromOptions(options);
|
||||
var ctx2 = GeneratorContext.FromOptions(options);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var folder1 = ctx1.Folder.GetFolderName(i);
|
||||
var folder2 = ctx2.Folder.GetFolderName(i);
|
||||
Assert.Equal(folder1, folder2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Card_SameSeed_ProducesSameOutput()
|
||||
{
|
||||
var options = CreateOptions("test.com");
|
||||
|
||||
var ctx1 = GeneratorContext.FromOptions(options);
|
||||
var ctx2 = GeneratorContext.FromOptions(options);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var card1 = ctx1.Card.GenerateByIndex(i);
|
||||
var card2 = ctx2.Card.GenerateByIndex(i);
|
||||
|
||||
Assert.Equal(card1.CardholderName, card2.CardholderName);
|
||||
Assert.Equal(card1.Number, card2.Number);
|
||||
Assert.Equal(card1.ExpMonth, card2.ExpMonth);
|
||||
Assert.Equal(card1.ExpYear, card2.ExpYear);
|
||||
Assert.Equal(card1.Code, card2.Code);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identity_SameSeed_ProducesSameOutput()
|
||||
{
|
||||
var options = CreateOptions("test.com");
|
||||
|
||||
var ctx1 = GeneratorContext.FromOptions(options);
|
||||
var ctx2 = GeneratorContext.FromOptions(options);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var identity1 = ctx1.Identity.GenerateByIndex(i);
|
||||
var identity2 = ctx2.Identity.GenerateByIndex(i);
|
||||
|
||||
Assert.Equal(identity1.FirstName, identity2.FirstName);
|
||||
Assert.Equal(identity1.LastName, identity2.LastName);
|
||||
Assert.Equal(identity1.Email, identity2.Email);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Limited to 5 iterations to avoid a Bogus.Password() infinite loop bug
|
||||
/// that occurs with certain seed/index combinations in WiFi/Database note categories.
|
||||
/// The workaround is a known test workaround that doesn't affect production code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SecureNote_SameSeed_ProducesSameOutput()
|
||||
{
|
||||
var options = CreateOptions("test.com");
|
||||
|
||||
var ctx1 = GeneratorContext.FromOptions(options);
|
||||
var ctx2 = GeneratorContext.FromOptions(options);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var (title1, content1) = ctx1.SecureNote.GenerateByIndex(i);
|
||||
var (title2, content2) = ctx2.SecureNote.GenerateByIndex(i);
|
||||
|
||||
Assert.Equal(title1, title2);
|
||||
Assert.Equal(content1, content2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipherCount_ReflectsOptionsValue()
|
||||
{
|
||||
var options = CreateOptions("test.com", ciphers: 500);
|
||||
|
||||
var ctx = GeneratorContext.FromOptions(options);
|
||||
|
||||
Assert.Equal(500, ctx.CipherCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Username_WithCorporatePattern_AppliesCorrectFormat()
|
||||
{
|
||||
var options = new OrganizationVaultOptions
|
||||
{
|
||||
Name = "Test Org",
|
||||
Domain = "corp.com",
|
||||
Users = 10,
|
||||
Ciphers = 100,
|
||||
UsernamePattern = UsernamePatternType.FDotLast,
|
||||
UsernameDistribution = new Distribution<UsernameCategory>(
|
||||
(UsernameCategory.CorporateEmail, 1.0)
|
||||
)
|
||||
};
|
||||
|
||||
var ctx = GeneratorContext.FromOptions(options);
|
||||
|
||||
var username = ctx.Username.GenerateByIndex(0, domain: "corp.com");
|
||||
|
||||
Assert.Matches(@"^[a-z]\.[a-z]+@corp\.com$", username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Username_WithRegion_ProducesCulturallyAppropriateNames()
|
||||
{
|
||||
var europeOptions = new OrganizationVaultOptions
|
||||
{
|
||||
Name = "Euro Corp",
|
||||
Domain = "euro.com",
|
||||
Users = 10,
|
||||
Ciphers = 100,
|
||||
Region = GeographicRegion.Europe,
|
||||
UsernameDistribution = new Distribution<UsernameCategory>(
|
||||
(UsernameCategory.CorporateEmail, 1.0)
|
||||
)
|
||||
};
|
||||
|
||||
var ctx = GeneratorContext.FromOptions(europeOptions);
|
||||
|
||||
var username = ctx.Username.GenerateByIndex(0, domain: "euro.com");
|
||||
|
||||
Assert.Contains("@euro.com", username);
|
||||
Assert.Matches(@"^[\p{L}]+\.[\p{L}]+@euro\.com$", username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generators_AreLazilyInitialized()
|
||||
{
|
||||
var options = CreateOptions("test.com");
|
||||
var ctx = GeneratorContext.FromOptions(options);
|
||||
|
||||
_ = ctx.Seed;
|
||||
_ = ctx.Username.GenerateByIndex(0);
|
||||
|
||||
Assert.NotNull(ctx.Username);
|
||||
Assert.NotNull(ctx.Folder);
|
||||
Assert.NotNull(ctx.Card);
|
||||
Assert.NotNull(ctx.Identity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllGenerators_ProduceDifferentOutputForDifferentIndices()
|
||||
{
|
||||
var ctx = GeneratorContext.FromOptions(CreateOptions("test.com", ciphers: 100));
|
||||
|
||||
var usernames = Enumerable.Range(0, 50)
|
||||
.Select(i => ctx.Username.GenerateByIndex(i, domain: "test.com"))
|
||||
.ToHashSet();
|
||||
Assert.True(usernames.Count > 40, "Should generate mostly unique usernames");
|
||||
|
||||
var folders = Enumerable.Range(0, 50)
|
||||
.Select(i => ctx.Folder.GetFolderName(i))
|
||||
.ToHashSet();
|
||||
Assert.True(folders.Count > 30, "Should generate diverse folder names");
|
||||
|
||||
var cards = Enumerable.Range(0, 50)
|
||||
.Select(i => ctx.Card.GenerateByIndex(i).Number)
|
||||
.ToHashSet();
|
||||
Assert.True(cards.Count > 40, "Should generate mostly unique card numbers");
|
||||
|
||||
var identities = Enumerable.Range(0, 50)
|
||||
.Select(i => ctx.Identity.GenerateByIndex(i).Email)
|
||||
.ToHashSet();
|
||||
Assert.True(identities.Count > 40, "Should generate mostly unique identity emails");
|
||||
}
|
||||
|
||||
private static OrganizationVaultOptions CreateOptions(string domain, int ciphers = 100)
|
||||
{
|
||||
return new OrganizationVaultOptions
|
||||
{
|
||||
Name = "Test Org",
|
||||
Domain = domain,
|
||||
Users = 10,
|
||||
Ciphers = ciphers
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ public class RustSdkCipherTests
|
||||
},
|
||||
Fields =
|
||||
[
|
||||
new FieldViewDto { Name = "API Key", Value = "sk-secret-api-key-12345", Type = 1 },
|
||||
new FieldViewDto { Name = "API Key", Value = "sk_test_FAKE_api_key_12345", Type = 1 },
|
||||
new FieldViewDto { Name = "Client ID", Value = "client-id-xyz", Type = 0 }
|
||||
]
|
||||
};
|
||||
@@ -128,7 +128,7 @@ public class RustSdkCipherTests
|
||||
Assert.NotNull(decrypted?.Fields);
|
||||
Assert.Equal(2, decrypted.Fields.Count);
|
||||
Assert.Equal("API Key", decrypted.Fields[0].Name);
|
||||
Assert.Equal("sk-secret-api-key-12345", decrypted.Fields[0].Value);
|
||||
Assert.Equal("sk_test_FAKE_api_key_12345", decrypted.Fields[0].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -138,10 +138,10 @@ public class RustSdkCipherTests
|
||||
var orgId = Guid.NewGuid();
|
||||
|
||||
// Create cipher using the seeder
|
||||
var cipher = CipherSeeder.CreateOrganizationLoginCipher(
|
||||
orgId,
|
||||
var cipher = LoginCipherSeeder.Create(
|
||||
orgKeys.Key,
|
||||
name: "GitHub Account",
|
||||
organizationId: orgId,
|
||||
username: "developer@example.com",
|
||||
password: "SecureP@ss123!",
|
||||
uri: "https://github.com",
|
||||
@@ -175,16 +175,16 @@ public class RustSdkCipherTests
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
|
||||
var cipher = CipherSeeder.CreateOrganizationLoginCipherWithFields(
|
||||
Guid.NewGuid(),
|
||||
var cipher = LoginCipherSeeder.Create(
|
||||
orgKeys.Key,
|
||||
name: "API Service",
|
||||
organizationId: Guid.NewGuid(),
|
||||
username: "service@example.com",
|
||||
password: "SvcP@ss!",
|
||||
uri: "https://api.example.com",
|
||||
fields: [
|
||||
("API Key", "sk-live-abc123", 1), // Hidden field
|
||||
("Environment", "production", 0) // Text field
|
||||
("API Key", "sk_test_FAKE_abc123", 1),
|
||||
("Environment", "production", 0)
|
||||
]);
|
||||
|
||||
var loginData = JsonSerializer.Deserialize<CipherLoginData>(cipher.Data);
|
||||
@@ -204,7 +204,7 @@ public class RustSdkCipherTests
|
||||
Assert.Equal(Core.Vault.Enums.FieldType.Text, fields[1].Type);
|
||||
|
||||
Assert.DoesNotContain("API Key", cipher.Data);
|
||||
Assert.DoesNotContain("sk-live-abc123", cipher.Data);
|
||||
Assert.DoesNotContain("sk_test_FAKE_abc123", cipher.Data);
|
||||
}
|
||||
|
||||
private static CipherViewDto CreateTestLoginCipher()
|
||||
@@ -223,4 +223,268 @@ public class RustSdkCipherTests
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncryptDecrypt_CardCipher_RoundtripPreservesPlaintext()
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
|
||||
var originalCipher = new CipherViewDto
|
||||
{
|
||||
Name = "My Visa Card",
|
||||
Notes = "Primary card for online purchases",
|
||||
Type = CipherTypes.Card,
|
||||
Card = new CardViewDto
|
||||
{
|
||||
CardholderName = "John Doe",
|
||||
Brand = "Visa",
|
||||
Number = "4111111111111111",
|
||||
ExpMonth = "12",
|
||||
ExpYear = "2028",
|
||||
Code = "123"
|
||||
}
|
||||
};
|
||||
|
||||
var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions);
|
||||
var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key);
|
||||
|
||||
Assert.DoesNotContain("\"error\"", encryptedJson);
|
||||
Assert.DoesNotContain("4111111111111111", encryptedJson);
|
||||
Assert.DoesNotContain("John Doe", encryptedJson);
|
||||
|
||||
var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key);
|
||||
var decrypted = JsonSerializer.Deserialize<CipherViewDto>(decryptedJson, SdkJsonOptions);
|
||||
|
||||
Assert.NotNull(decrypted?.Card);
|
||||
Assert.Equal("4111111111111111", decrypted.Card.Number);
|
||||
Assert.Equal("John Doe", decrypted.Card.CardholderName);
|
||||
Assert.Equal("123", decrypted.Card.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipherSeeder_CardCipher_ProducesServerCompatibleFormat()
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
var orgId = Guid.NewGuid();
|
||||
|
||||
var card = new CardViewDto
|
||||
{
|
||||
CardholderName = "Jane Smith",
|
||||
Brand = "Mastercard",
|
||||
Number = "5500000000000004",
|
||||
ExpMonth = "06",
|
||||
ExpYear = "2027",
|
||||
Code = "456"
|
||||
};
|
||||
|
||||
var cipher = CardCipherSeeder.Create(orgKeys.Key, name: "Business Card", card: card, organizationId: orgId, notes: "Company expenses");
|
||||
|
||||
Assert.Equal(orgId, cipher.OrganizationId);
|
||||
Assert.Equal(Core.Vault.Enums.CipherType.Card, cipher.Type);
|
||||
|
||||
var cardData = JsonSerializer.Deserialize<CipherCardData>(cipher.Data);
|
||||
Assert.NotNull(cardData);
|
||||
|
||||
var encStringPrefix = "2.";
|
||||
Assert.StartsWith(encStringPrefix, cardData.Name);
|
||||
Assert.StartsWith(encStringPrefix, cardData.CardholderName);
|
||||
Assert.StartsWith(encStringPrefix, cardData.Number);
|
||||
Assert.StartsWith(encStringPrefix, cardData.Code);
|
||||
|
||||
Assert.DoesNotContain("5500000000000004", cipher.Data);
|
||||
Assert.DoesNotContain("Jane Smith", cipher.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncryptDecrypt_IdentityCipher_RoundtripPreservesPlaintext()
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
|
||||
var originalCipher = new CipherViewDto
|
||||
{
|
||||
Name = "Personal Identity",
|
||||
Type = CipherTypes.Identity,
|
||||
Identity = new IdentityViewDto
|
||||
{
|
||||
Title = "Mr",
|
||||
FirstName = "John",
|
||||
MiddleName = "Robert",
|
||||
LastName = "Doe",
|
||||
Email = "john.doe@example.com",
|
||||
Phone = "+1-555-123-4567",
|
||||
SSN = "123-45-6789",
|
||||
Address1 = "123 Main Street",
|
||||
City = "Anytown",
|
||||
State = "CA",
|
||||
PostalCode = "90210",
|
||||
Country = "US"
|
||||
}
|
||||
};
|
||||
|
||||
var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions);
|
||||
var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key);
|
||||
|
||||
Assert.DoesNotContain("\"error\"", encryptedJson);
|
||||
Assert.DoesNotContain("123-45-6789", encryptedJson);
|
||||
Assert.DoesNotContain("john.doe@example.com", encryptedJson);
|
||||
|
||||
var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key);
|
||||
var decrypted = JsonSerializer.Deserialize<CipherViewDto>(decryptedJson, SdkJsonOptions);
|
||||
|
||||
Assert.NotNull(decrypted?.Identity);
|
||||
Assert.Equal("John", decrypted.Identity.FirstName);
|
||||
Assert.Equal("123-45-6789", decrypted.Identity.SSN);
|
||||
Assert.Equal("john.doe@example.com", decrypted.Identity.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipherSeeder_IdentityCipher_ProducesServerCompatibleFormat()
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
var orgId = Guid.NewGuid();
|
||||
|
||||
var identity = new IdentityViewDto
|
||||
{
|
||||
Title = "Dr",
|
||||
FirstName = "Alice",
|
||||
LastName = "Johnson",
|
||||
Email = "alice@company.com",
|
||||
SSN = "987-65-4321",
|
||||
PassportNumber = "X12345678"
|
||||
};
|
||||
|
||||
var cipher = IdentityCipherSeeder.Create(orgKeys.Key, name: "Dr. Alice Johnson", identity: identity, organizationId: orgId);
|
||||
|
||||
Assert.Equal(orgId, cipher.OrganizationId);
|
||||
Assert.Equal(Core.Vault.Enums.CipherType.Identity, cipher.Type);
|
||||
|
||||
var identityData = JsonSerializer.Deserialize<CipherIdentityData>(cipher.Data);
|
||||
Assert.NotNull(identityData);
|
||||
|
||||
var encStringPrefix = "2.";
|
||||
Assert.StartsWith(encStringPrefix, identityData.Name);
|
||||
Assert.StartsWith(encStringPrefix, identityData.FirstName);
|
||||
Assert.StartsWith(encStringPrefix, identityData.SSN);
|
||||
|
||||
Assert.DoesNotContain("987-65-4321", cipher.Data);
|
||||
Assert.DoesNotContain("Alice", cipher.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncryptDecrypt_SecureNoteCipher_RoundtripPreservesPlaintext()
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
|
||||
var originalCipher = new CipherViewDto
|
||||
{
|
||||
Name = "API Secrets",
|
||||
Notes = "sk_test_FAKE_abc123xyz789key",
|
||||
Type = CipherTypes.SecureNote,
|
||||
SecureNote = new SecureNoteViewDto { Type = 0 }
|
||||
};
|
||||
|
||||
var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions);
|
||||
var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key);
|
||||
|
||||
Assert.DoesNotContain("\"error\"", encryptedJson);
|
||||
Assert.DoesNotContain("sk_test_FAKE_abc123xyz789key", encryptedJson);
|
||||
|
||||
var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key);
|
||||
var decrypted = JsonSerializer.Deserialize<CipherViewDto>(decryptedJson, SdkJsonOptions);
|
||||
|
||||
Assert.NotNull(decrypted);
|
||||
Assert.Equal("API Secrets", decrypted.Name);
|
||||
Assert.Equal("sk_test_FAKE_abc123xyz789key", decrypted.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipherSeeder_SecureNoteCipher_ProducesServerCompatibleFormat()
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
var orgId = Guid.NewGuid();
|
||||
|
||||
var cipher = SecureNoteCipherSeeder.Create(
|
||||
orgKeys.Key,
|
||||
name: "Production Secrets",
|
||||
organizationId: orgId,
|
||||
notes: "DATABASE_URL=postgres://user:FAKE_secret@db.example.com/prod");
|
||||
|
||||
Assert.Equal(orgId, cipher.OrganizationId);
|
||||
Assert.Equal(Core.Vault.Enums.CipherType.SecureNote, cipher.Type);
|
||||
|
||||
var noteData = JsonSerializer.Deserialize<CipherSecureNoteData>(cipher.Data);
|
||||
Assert.NotNull(noteData);
|
||||
Assert.Equal(Core.Vault.Enums.SecureNoteType.Generic, noteData.Type);
|
||||
|
||||
var encStringPrefix = "2.";
|
||||
Assert.StartsWith(encStringPrefix, noteData.Name);
|
||||
Assert.StartsWith(encStringPrefix, noteData.Notes);
|
||||
|
||||
Assert.DoesNotContain("postgres://", cipher.Data);
|
||||
Assert.DoesNotContain("secret", cipher.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncryptDecrypt_SshKeyCipher_RoundtripPreservesPlaintext()
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
|
||||
var originalCipher = new CipherViewDto
|
||||
{
|
||||
Name = "Dev Server Key",
|
||||
Type = CipherTypes.SshKey,
|
||||
SshKey = new SshKeyViewDto
|
||||
{
|
||||
PrivateKey = "-----BEGIN FAKE RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END FAKE RSA PRIVATE KEY-----",
|
||||
PublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user@host",
|
||||
Fingerprint = "SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8"
|
||||
}
|
||||
};
|
||||
|
||||
var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions);
|
||||
var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key);
|
||||
|
||||
Assert.DoesNotContain("\"error\"", encryptedJson);
|
||||
Assert.DoesNotContain("BEGIN FAKE RSA PRIVATE KEY", encryptedJson);
|
||||
Assert.DoesNotContain("ssh-rsa AAAAB3", encryptedJson);
|
||||
|
||||
var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key);
|
||||
var decrypted = JsonSerializer.Deserialize<CipherViewDto>(decryptedJson, SdkJsonOptions);
|
||||
|
||||
Assert.NotNull(decrypted?.SshKey);
|
||||
Assert.Contains("BEGIN FAKE RSA PRIVATE KEY", decrypted.SshKey.PrivateKey);
|
||||
Assert.StartsWith("ssh-rsa", decrypted.SshKey.PublicKey);
|
||||
Assert.StartsWith("SHA256:", decrypted.SshKey.Fingerprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipherSeeder_SshKeyCipher_ProducesServerCompatibleFormat()
|
||||
{
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
var orgId = Guid.NewGuid();
|
||||
|
||||
var sshKey = new SshKeyViewDto
|
||||
{
|
||||
PrivateKey = "-----BEGIN FAKE OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA...\n-----END FAKE OPENSSH PRIVATE KEY-----",
|
||||
PublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample test@machine",
|
||||
Fingerprint = "SHA256:examplefingerprint123"
|
||||
};
|
||||
|
||||
var cipher = SshKeyCipherSeeder.Create(orgKeys.Key, name: "Production Deploy Key", sshKey: sshKey, organizationId: orgId);
|
||||
|
||||
Assert.Equal(orgId, cipher.OrganizationId);
|
||||
Assert.Equal(Core.Vault.Enums.CipherType.SSHKey, cipher.Type);
|
||||
|
||||
var sshData = JsonSerializer.Deserialize<CipherSSHKeyData>(cipher.Data);
|
||||
Assert.NotNull(sshData);
|
||||
|
||||
var encStringPrefix = "2.";
|
||||
Assert.StartsWith(encStringPrefix, sshData.Name);
|
||||
Assert.StartsWith(encStringPrefix, sshData.PrivateKey);
|
||||
Assert.StartsWith(encStringPrefix, sshData.PublicKey);
|
||||
Assert.StartsWith(encStringPrefix, sshData.KeyFingerprint);
|
||||
|
||||
Assert.DoesNotContain("BEGIN FAKE OPENSSH PRIVATE KEY", cipher.Data);
|
||||
Assert.DoesNotContain("ssh-ed25519", cipher.Data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Bit.Seeder.Services;
|
||||
using CommandDotNet;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -38,7 +39,8 @@ public class Program
|
||||
|
||||
var mapper = scopedServices.GetRequiredService<IMapper>();
|
||||
var passwordHasher = scopedServices.GetRequiredService<IPasswordHasher<User>>();
|
||||
var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
|
||||
var manglerService = scopedServices.GetRequiredService<IManglerService>();
|
||||
var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
|
||||
recipe.Seed(name: name, domain: domain, users: users);
|
||||
}
|
||||
|
||||
@@ -48,17 +50,31 @@ public class Program
|
||||
args.Validate();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
ServiceCollectionExtension.ConfigureServices(services);
|
||||
ServiceCollectionExtension.ConfigureServices(services, enableMangling: args.Mangle);
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var scopedServices = scope.ServiceProvider;
|
||||
|
||||
var manglerService = scopedServices.GetRequiredService<IManglerService>();
|
||||
var recipe = new OrganizationWithVaultRecipe(
|
||||
scopedServices.GetRequiredService<DatabaseContext>(),
|
||||
scopedServices.GetRequiredService<IMapper>(),
|
||||
scopedServices.GetRequiredService<IPasswordHasher<User>>());
|
||||
scopedServices.GetRequiredService<IPasswordHasher<User>>(),
|
||||
manglerService);
|
||||
|
||||
recipe.Seed(args.ToOptions());
|
||||
|
||||
if (!manglerService.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var map = manglerService.GetMangleMap();
|
||||
Console.WriteLine("--- Mangled Data Map ---");
|
||||
foreach (var (original, mangled) in map)
|
||||
{
|
||||
Console.WriteLine($"{original} -> {mangled}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Seeder.Services;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.DbSeederUtility;
|
||||
|
||||
public static class ServiceCollectionExtension
|
||||
{
|
||||
public static void ConfigureServices(ServiceCollection services)
|
||||
public static void ConfigureServices(ServiceCollection services, bool enableMangling = false)
|
||||
{
|
||||
// Load configuration using the GlobalSettingsFactory
|
||||
var globalSettings = GlobalSettingsFactory.GlobalSettings;
|
||||
@@ -29,5 +31,14 @@ public static class ServiceCollectionExtension
|
||||
.SetApplicationName("Bitwarden");
|
||||
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
|
||||
if (enableMangling)
|
||||
{
|
||||
services.TryAddScoped<IManglerService, ManglerService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<IManglerService, NoOpManglerService>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
# Seeder - Claude Code Context
|
||||
# Bitwarden Seeder Library - Claude Code Configuration
|
||||
|
||||
## Ubiquitous Language
|
||||
## Quick Reference
|
||||
|
||||
The Seeder follows six core patterns:
|
||||
**For detailed pattern descriptions (Factories, Recipes, Models, Scenes, Queries, Data), read `README.md`.**
|
||||
|
||||
1. **Factories** - Create ONE entity with encryption. Named `{Entity}Seeder` with `Create{Type}{Entity}()` methods. Do not interact with database.
|
||||
**For detailed usages of the Seeder library, read `util/DbSeederUtility/README.md` and `util/SeederApi/README.md`**
|
||||
|
||||
2. **Recipes** - Orchestrate MANY entities. Named `{DomainConcept}Recipe`. **MUST have `Seed()` method** as primary interface, not `AddToOrganization()` or similar. Use parameters for variations, not separate methods. Compose Factories internally.
|
||||
## Commands
|
||||
|
||||
3. **Models** - DTOs bridging SDK ↔ Server format. Named `{Entity}ViewDto` (plaintext), `Encrypted{Entity}Dto` (SDK format). Pure data, no logic.
|
||||
```bash
|
||||
# Build
|
||||
dotnet build util/Seeder/Seeder.csproj
|
||||
|
||||
4. **Scenes** - Complete test scenarios with ID mangling. Implement `IScene<TReques, TResult>`. Async, returns `SceneResult<TResult>` with MangleMap and result property populated with `TResult`. Named `{Scenario}Scene`.
|
||||
# Run tests
|
||||
dotnet test test/SeederApi.IntegrationTest/
|
||||
|
||||
5. **Queries** - Read-only data retrieval. Implement `IQuery<TRequest, TResult>`. Synchronous, no DB modifications. Named `{DataToRetrieve}Query`.
|
||||
|
||||
6. **Data** - Static, filterable test data collections (Companies, Passwords, Names, OrgStructures). Deterministic, composable. Enums provide public API.
|
||||
|
||||
## The Recipe Contract
|
||||
|
||||
Recipes follow strict rules (like a cooking recipe that you follow completely):
|
||||
|
||||
1. A Recipe SHALL have exactly one public method named `Seed()`
|
||||
2. A Recipe MUST produce one cohesive result (like baking one complete cake)
|
||||
3. A Recipe MAY have overloaded `Seed()` methods with different parameters
|
||||
4. A Recipe SHALL use private helper methods for internal steps
|
||||
5. A Recipe SHALL use BulkCopy for performance when creating multiple entities
|
||||
6. A Recipe SHALL compose Factories for individual entity creation
|
||||
7. A Recipe SHALL NOT expose implementation details as public methods
|
||||
|
||||
**Current violations** (to be refactored):
|
||||
|
||||
- `CiphersRecipe` - Uses `AddLoginCiphersToOrganization()` instead of `Seed()`
|
||||
- `CollectionsRecipe` - Uses `AddFromStructure()` and `AddToOrganization()` instead of `Seed()`
|
||||
- `GroupsRecipe` - Uses `AddToOrganization()` instead of `Seed()`
|
||||
- `OrganizationDomainRecipe` - Uses `AddVerifiedDomainToOrganization()` instead of `Seed()`
|
||||
# Run single test
|
||||
dotnet test test/SeederApi.IntegrationTest/ --filter "FullyQualifiedName~TestMethodName"
|
||||
```
|
||||
|
||||
## Pattern Decision Tree
|
||||
|
||||
@@ -41,175 +25,60 @@ Recipes follow strict rules (like a cooking recipe that you follow completely):
|
||||
Need to create test data?
|
||||
├─ ONE entity with encryption? → Factory
|
||||
├─ MANY entities as cohesive operation? → Recipe
|
||||
├─ Complete test scenario with ID mangling to be used by the Seeder API? → Scene
|
||||
├─ Complete test scenario with ID mangling for SeederApi? → Scene
|
||||
├─ READ existing seeded data? → Query
|
||||
└─ Data transformation SDK ↔ Server? → Model
|
||||
```
|
||||
|
||||
## When to Use the Seeder
|
||||
## The Recipe Contract
|
||||
|
||||
✅ Use for:
|
||||
Recipes follow strict rules:
|
||||
|
||||
- Local development database setup
|
||||
- Integration test data creation
|
||||
- Performance testing with realistic encrypted data
|
||||
|
||||
❌ Do NOT use for:
|
||||
|
||||
- Production data
|
||||
- Copying real user vaults (use backup/restore instead)
|
||||
1. A Recipe SHALL have exactly one public method named `Seed()`
|
||||
2. A Recipe MUST produce one cohesive result
|
||||
3. A Recipe MAY have overloaded `Seed()` methods with different parameters
|
||||
4. A Recipe SHALL use private helper methods for internal steps
|
||||
5. A Recipe SHALL use BulkCopy for performance when creating multiple entities
|
||||
6. A Recipe SHALL compose Factories for individual entity creation
|
||||
7. A Recipe SHALL NOT expose implementation details as public methods
|
||||
|
||||
## Zero-Knowledge Architecture
|
||||
|
||||
**Critical Principle:** Unencrypted vault data never leaves the client. The server never sees plaintext.
|
||||
**Critical:** Unencrypted vault data never leaves the client. The server never sees plaintext.
|
||||
|
||||
### Why Seeder Uses the Rust SDK
|
||||
The Seeder uses the Rust SDK via FFI because it must behave like a real Bitwarden client:
|
||||
|
||||
The Seeder must behave exactly like any other Bitwarden client. Since the server:
|
||||
1. Generate encryption keys (like client account setup)
|
||||
2. Encrypt vault data client-side (same SDK as real clients)
|
||||
3. Store only encrypted result
|
||||
|
||||
- Never receives plaintext
|
||||
- Cannot perform encryption (doesn't have keys)
|
||||
- Only stores/retrieves encrypted blobs
|
||||
|
||||
...the Seeder cannot simply write plaintext to the database. It must:
|
||||
|
||||
1. Generate encryption keys (like a client does during account setup)
|
||||
2. Encrypt vault data client-side (using the same SDK the real clients use)
|
||||
3. Store only the encrypted result
|
||||
|
||||
This is why we use the Rust SDK via FFI - it's the same cryptographic implementation used by the official clients.
|
||||
|
||||
## Cipher Encryption Architecture
|
||||
|
||||
### The Two-State Pattern
|
||||
|
||||
Bitwarden uses a clean separation between encrypted and decrypted data:
|
||||
|
||||
| State | SDK Type | Description | Stored in DB? |
|
||||
| --------- | ------------ | ------------------------- | ------------- |
|
||||
| Plaintext | `CipherView` | Decrypted, human-readable | Never |
|
||||
| Encrypted | `Cipher` | EncString values | Yes |
|
||||
|
||||
**Encryption flow:**
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
CipherView (plaintext) → encrypt_composite() → Cipher (encrypted)
|
||||
CipherViewDto → Rust SDK encrypt_cipher → EncryptedCipherDto → TransformToServer → Server Cipher Entity
|
||||
```
|
||||
|
||||
**Decryption flow:**
|
||||
Shared logic: `CipherEncryption.cs`, `EncryptedCipherDtoExtensions.cs`
|
||||
|
||||
```
|
||||
Cipher (encrypted) → decrypt() → CipherView (plaintext)
|
||||
```
|
||||
## Rust SDK Version Alignment
|
||||
|
||||
### SDK vs Server Format Difference
|
||||
| Component | Version Source |
|
||||
| ----------- | ----------------------------------------- |
|
||||
| Server Shim | `util/RustSdk/rust/Cargo.toml` git rev |
|
||||
| Clients | `@bitwarden/sdk-internal` in clients repo |
|
||||
|
||||
**Critical:** The SDK and server use different JSON structures.
|
||||
Before modifying SDK integration, run `RustSdkCipherTests` to validate roundtrip encryption.
|
||||
|
||||
**SDK Cipher (nested):**
|
||||
## Deterministic Data Generation
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "2.abc...",
|
||||
"login": {
|
||||
"username": "2.def...",
|
||||
"password": "2.ghi..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server Cipher.Data (flat CipherLoginData):**
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "2.abc...",
|
||||
"Username": "2.def...",
|
||||
"Password": "2.ghi..."
|
||||
}
|
||||
```
|
||||
|
||||
### Data Flow in Seeder
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ CipherViewDto │────▶│ Rust SDK │────▶│ EncryptedCipherDto │
|
||||
│ (plaintext) │ │ encrypt_cipher │ │ (SDK Cipher) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ TransformToServer │
|
||||
│ (flatten nested → │
|
||||
│ flat structure) │
|
||||
└───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ Server Cipher │◀────│ CipherLoginData │◀────│ Flattened JSON │
|
||||
│ Entity │ │ (serialized) │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
### Key Hierarchy
|
||||
|
||||
Bitwarden uses a two-level encryption hierarchy:
|
||||
|
||||
1. **User/Organization Key** - Encrypts the cipher's individual key
|
||||
2. **Cipher Key** (optional) - Encrypts the actual cipher data
|
||||
|
||||
For seeding, we use the organization's symmetric key directly (no per-cipher key).
|
||||
|
||||
## Rust SDK FFI
|
||||
|
||||
### Error Handling
|
||||
|
||||
SDK functions return JSON with an `"error"` field on failure:
|
||||
|
||||
```json
|
||||
{ "error": "Failed to parse CipherView JSON" }
|
||||
```
|
||||
|
||||
Always check for `"error"` in the response before parsing.
|
||||
|
||||
## Testing
|
||||
|
||||
Integration tests in `test/SeederApi.IntegrationTest` verify:
|
||||
|
||||
1. **Roundtrip encryption** - Encrypt then decrypt preserves plaintext
|
||||
2. **Server format compatibility** - Output matches CipherLoginData structure
|
||||
3. **Field encryption** - Custom fields are properly encrypted
|
||||
4. **Security** - Plaintext never appears in encrypted output
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Cipher
|
||||
Same domain = same seed = reproducible data:
|
||||
|
||||
```csharp
|
||||
var sdk = new RustSdkService();
|
||||
var seeder = new CipherSeeder(sdk);
|
||||
|
||||
var cipher = seeder.CreateOrganizationLoginCipher(
|
||||
organizationId,
|
||||
orgKey, // Base64-encoded symmetric key
|
||||
name: "My Login",
|
||||
username: "user@example.com",
|
||||
password: "secret123");
|
||||
```
|
||||
|
||||
### Bulk Cipher Creation
|
||||
|
||||
```csharp
|
||||
var recipe = new CiphersRecipe(dbContext, sdkService);
|
||||
|
||||
var cipherIds = recipe.AddLoginCiphersToOrganization(
|
||||
organizationId,
|
||||
orgKey,
|
||||
collectionIds,
|
||||
count: 100);
|
||||
_seed = options.Seed ?? StableHash.ToInt32(options.Domain);
|
||||
```
|
||||
|
||||
## Security Reminders
|
||||
|
||||
- Generated test passwords are intentionally weak (`asdfasdfasdf`)
|
||||
- Never commit database dumps containing seeded data to version control
|
||||
- Seeded keys are for testing only - regenerate for each test run
|
||||
- Test password: `asdfasdfasdf`
|
||||
- Never commit database dumps with seeded data
|
||||
- Seeded keys are for testing only
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bogus;
|
||||
using Bogus.DataSets;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Provides locale-aware name generation using the Bogus library.
|
||||
/// Maps GeographicRegion to appropriate Bogus locales for culturally-appropriate names.
|
||||
/// </summary>
|
||||
internal sealed class BogusNameProvider
|
||||
{
|
||||
private readonly Faker _faker;
|
||||
|
||||
public BogusNameProvider(GeographicRegion region, int? seed = null)
|
||||
{
|
||||
var locale = MapRegionToLocale(region, seed);
|
||||
_faker = seed.HasValue
|
||||
? new Faker(locale) { Random = new Randomizer(seed.Value) }
|
||||
: new Faker(locale);
|
||||
}
|
||||
|
||||
public string FirstName() => _faker.Name.FirstName();
|
||||
|
||||
public string FirstName(Name.Gender gender) => _faker.Name.FirstName(gender);
|
||||
|
||||
public string LastName() => _faker.Name.LastName();
|
||||
|
||||
private static string MapRegionToLocale(GeographicRegion region, int? seed) => region switch
|
||||
{
|
||||
GeographicRegion.NorthAmerica => "en_US",
|
||||
GeographicRegion.Europe => GetRandomEuropeanLocale(seed),
|
||||
GeographicRegion.AsiaPacific => GetRandomAsianLocale(seed),
|
||||
GeographicRegion.LatinAmerica => GetRandomLatinAmericanLocale(seed),
|
||||
GeographicRegion.MiddleEast => GetRandomMiddleEastLocale(seed),
|
||||
GeographicRegion.Africa => GetRandomAfricanLocale(seed),
|
||||
GeographicRegion.Global => "en",
|
||||
_ => "en"
|
||||
};
|
||||
|
||||
private static string GetRandomEuropeanLocale(int? seed)
|
||||
{
|
||||
var locales = new[] { "en_GB", "de", "fr", "es", "it", "nl", "pl", "pt_PT", "sv" };
|
||||
return PickLocale(locales, seed);
|
||||
}
|
||||
|
||||
private static string GetRandomAsianLocale(int? seed)
|
||||
{
|
||||
var locales = new[] { "ja", "ko", "zh_CN", "zh_TW", "vi" };
|
||||
return PickLocale(locales, seed);
|
||||
}
|
||||
|
||||
private static string GetRandomLatinAmericanLocale(int? seed)
|
||||
{
|
||||
var locales = new[] { "es_MX", "pt_BR", "es" };
|
||||
return PickLocale(locales, seed);
|
||||
}
|
||||
|
||||
private static string GetRandomMiddleEastLocale(int? seed)
|
||||
{
|
||||
// Bogus has limited Middle East support; use available Arabic/Turkish locales
|
||||
var locales = new[] { "ar", "tr", "fa" };
|
||||
return PickLocale(locales, seed);
|
||||
}
|
||||
|
||||
private static string GetRandomAfricanLocale(int? seed)
|
||||
{
|
||||
// Bogus has limited African support; use South African English and French (West Africa)
|
||||
var locales = new[] { "en_ZA", "fr" };
|
||||
return PickLocale(locales, seed);
|
||||
}
|
||||
|
||||
private static string PickLocale(string[] locales, int? seed)
|
||||
{
|
||||
var random = seed.HasValue ? new Random(seed.Value) : Random.Shared;
|
||||
return locales[random.Next(locales.Length)];
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Generates deterministic usernames for companies using configurable patterns.
|
||||
/// Uses Bogus library for locale-aware name generation while maintaining determinism
|
||||
/// through pre-generated arrays indexed by a seed.
|
||||
/// </summary>
|
||||
internal sealed class CipherUsernameGenerator
|
||||
{
|
||||
private const int _namePoolSize = 1500;
|
||||
|
||||
private readonly Random _random;
|
||||
|
||||
private readonly UsernamePattern _pattern;
|
||||
|
||||
private readonly string[] _firstNames;
|
||||
|
||||
private readonly string[] _lastNames;
|
||||
|
||||
public CipherUsernameGenerator(
|
||||
int seed,
|
||||
UsernamePatternType patternType = UsernamePatternType.FirstDotLast,
|
||||
GeographicRegion? region = null)
|
||||
{
|
||||
_random = new Random(seed);
|
||||
_pattern = UsernamePatterns.GetPattern(patternType);
|
||||
|
||||
// Pre-generate arrays from Bogus for deterministic index-based access
|
||||
var provider = new BogusNameProvider(region ?? GeographicRegion.Global, seed);
|
||||
_firstNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.FirstName()).ToArray();
|
||||
_lastNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.LastName()).ToArray();
|
||||
}
|
||||
|
||||
public string Generate(Company company)
|
||||
{
|
||||
var firstName = _firstNames[_random.Next(_firstNames.Length)];
|
||||
var lastName = _lastNames[_random.Next(_lastNames.Length)];
|
||||
return _pattern.Generate(firstName, lastName, company.Domain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates username using index for deterministic selection across cipher iterations.
|
||||
/// </summary>
|
||||
public string GenerateByIndex(Company company, int index)
|
||||
{
|
||||
var firstName = _firstNames[index % _firstNames.Length];
|
||||
var lastName = _lastNames[(index * 7) % _lastNames.Length]; // Prime multiplier for variety
|
||||
return _pattern.Generate(firstName, lastName, company.Domain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines deterministic index with random offset for controlled variety.
|
||||
/// </summary>
|
||||
public string GenerateVaried(Company company, int index)
|
||||
{
|
||||
var offset = _random.Next(10);
|
||||
var firstName = _firstNames[(index + offset) % _firstNames.Length];
|
||||
var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length];
|
||||
return _pattern.Generate(firstName, lastName, company.Domain);
|
||||
}
|
||||
|
||||
public string GetFirstName(int index) => _firstNames[index % _firstNames.Length];
|
||||
|
||||
public string GetLastName(int index) => _lastNames[(index * 7) % _lastNames.Length];
|
||||
}
|
||||
50
util/Seeder/Data/Distributions/CipherTypeDistributions.cs
Normal file
50
util/Seeder/Data/Distributions/CipherTypeDistributions.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Bit.Core.Vault.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data.Distributions;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-configured cipher type distributions for seeding scenarios.
|
||||
/// </summary>
|
||||
public static class CipherTypeDistributions
|
||||
{
|
||||
/// <summary>
|
||||
/// Realistic enterprise mix based on typical usage patterns.
|
||||
/// 60% Login, 15% SecureNote, 12% Card, 10% Identity, 3% SshKey
|
||||
/// </summary>
|
||||
public static Distribution<CipherType> Realistic { get; } = new(
|
||||
(CipherType.Login, 0.60),
|
||||
(CipherType.SecureNote, 0.15),
|
||||
(CipherType.Card, 0.12),
|
||||
(CipherType.Identity, 0.10),
|
||||
(CipherType.SSHKey, 0.03)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Login-only distribution for backward compatibility or login-focused testing.
|
||||
/// </summary>
|
||||
public static Distribution<CipherType> LoginOnly { get; } = new(
|
||||
(CipherType.Login, 1.0)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Heavy on secure notes for documentation-focused organizations.
|
||||
/// </summary>
|
||||
public static Distribution<CipherType> DocumentationHeavy { get; } = new(
|
||||
(CipherType.Login, 0.40),
|
||||
(CipherType.SecureNote, 0.40),
|
||||
(CipherType.Card, 0.10),
|
||||
(CipherType.Identity, 0.07),
|
||||
(CipherType.SSHKey, 0.03)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Developer-focused with more SSH keys.
|
||||
/// </summary>
|
||||
public static Distribution<CipherType> DeveloperFocused { get; } = new(
|
||||
(CipherType.Login, 0.50),
|
||||
(CipherType.SecureNote, 0.20),
|
||||
(CipherType.Card, 0.05),
|
||||
(CipherType.Identity, 0.05),
|
||||
(CipherType.SSHKey, 0.20)
|
||||
);
|
||||
}
|
||||
65
util/Seeder/Data/Distributions/Distribution.cs
Normal file
65
util/Seeder/Data/Distributions/Distribution.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace Bit.Seeder.Data.Distributions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic, percentage-based item selection for test data generation.
|
||||
/// Replaces duplicated distribution logic in GetRealisticStatus, GetFolderCountForUser, etc.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of values in the distribution.</typeparam>
|
||||
public sealed class Distribution<T>
|
||||
{
|
||||
private readonly (T Value, double Percentage)[] _buckets;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a distribution from percentage buckets.
|
||||
/// </summary>
|
||||
/// <param name="buckets">Value-percentage pairs that must sum to 1.0 (within 0.001 tolerance).</param>
|
||||
/// <exception cref="ArgumentException">Thrown when percentages don't sum to 1.0.</exception>
|
||||
public Distribution(params (T Value, double Percentage)[] buckets)
|
||||
{
|
||||
var total = buckets.Sum(b => b.Percentage);
|
||||
if (Math.Abs(total - 1.0) > 0.001)
|
||||
{
|
||||
throw new ArgumentException($"Percentages must sum to 1.0, got {total}");
|
||||
}
|
||||
_buckets = buckets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a value deterministically based on index position within a total count.
|
||||
/// Items 0 to (total * percentage1 - 1) get value1, and so on.
|
||||
/// </summary>
|
||||
/// <param name="index">Zero-based index of the item.</param>
|
||||
/// <param name="total">Total number of items being distributed. For best accuracy, use totals >= 100.</param>
|
||||
/// <returns>The value assigned to this index position.</returns>
|
||||
public T Select(int index, int total)
|
||||
{
|
||||
var cumulative = 0;
|
||||
foreach (var (value, percentage) in _buckets)
|
||||
{
|
||||
cumulative += (int)(total * percentage);
|
||||
if (index < cumulative)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return _buckets[^1].Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all values with their calculated counts for a given total.
|
||||
/// The last bucket receives any remainder from rounding.
|
||||
/// </summary>
|
||||
/// <param name="total">Total number of items to distribute.</param>
|
||||
/// <returns>Sequence of value-count pairs.</returns>
|
||||
public IEnumerable<(T Value, int Count)> GetCounts(int total)
|
||||
{
|
||||
var remaining = total;
|
||||
for (var i = 0; i < _buckets.Length - 1; i++)
|
||||
{
|
||||
var count = (int)(total * _buckets[i].Percentage);
|
||||
yield return (_buckets[i].Value, count);
|
||||
remaining -= count;
|
||||
}
|
||||
yield return (_buckets[^1].Value, remaining);
|
||||
}
|
||||
}
|
||||
19
util/Seeder/Data/Distributions/FolderCountDistributions.cs
Normal file
19
util/Seeder/Data/Distributions/FolderCountDistributions.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Bit.Seeder.Data.Distributions;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-configured folder count distributions for user vault seeding.
|
||||
/// </summary>
|
||||
public static class FolderCountDistributions
|
||||
{
|
||||
/// <summary>
|
||||
/// Realistic distribution of folders per user.
|
||||
/// 35% have zero, 35% have 1-3, 20% have 4-7, 10% have 10-15.
|
||||
/// Values are (Min, Max) ranges for deterministic selection.
|
||||
/// </summary>
|
||||
public static Distribution<(int Min, int Max)> Realistic { get; } = new(
|
||||
((0, 1), 0.35),
|
||||
((1, 4), 0.35),
|
||||
((4, 8), 0.20),
|
||||
((10, 16), 0.10)
|
||||
);
|
||||
}
|
||||
21
util/Seeder/Data/Distributions/PasswordDistributions.cs
Normal file
21
util/Seeder/Data/Distributions/PasswordDistributions.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data.Distributions;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-configured password strength distributions for seeding scenarios.
|
||||
/// </summary>
|
||||
public static class PasswordDistributions
|
||||
{
|
||||
/// <summary>
|
||||
/// Realistic distribution based on breach data and security research.
|
||||
/// 25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong
|
||||
/// </summary>
|
||||
public static Distribution<PasswordStrength> Realistic { get; } = new(
|
||||
(PasswordStrength.VeryWeak, 0.25),
|
||||
(PasswordStrength.Weak, 0.30),
|
||||
(PasswordStrength.Fair, 0.25),
|
||||
(PasswordStrength.Strong, 0.15),
|
||||
(PasswordStrength.VeryStrong, 0.05)
|
||||
);
|
||||
}
|
||||
36
util/Seeder/Data/Distributions/UserStatusDistributions.cs
Normal file
36
util/Seeder/Data/Distributions/UserStatusDistributions.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data.Distributions;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-configured user status distributions for seeding scenarios.
|
||||
/// </summary>
|
||||
public static class UserStatusDistributions
|
||||
{
|
||||
/// <summary>
|
||||
/// Realistic organization membership distribution.
|
||||
/// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked
|
||||
/// </summary>
|
||||
public static Distribution<OrganizationUserStatusType> Realistic { get; } = new(
|
||||
(OrganizationUserStatusType.Confirmed, 0.85),
|
||||
(OrganizationUserStatusType.Invited, 0.05),
|
||||
(OrganizationUserStatusType.Accepted, 0.05),
|
||||
(OrganizationUserStatusType.Revoked, 0.05)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// All users confirmed - for simpler testing scenarios.
|
||||
/// </summary>
|
||||
public static Distribution<OrganizationUserStatusType> AllConfirmed { get; } = new(
|
||||
(OrganizationUserStatusType.Confirmed, 1.0)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// New organization with many pending invites.
|
||||
/// </summary>
|
||||
public static Distribution<OrganizationUserStatusType> NewOrganization { get; } = new(
|
||||
(OrganizationUserStatusType.Confirmed, 0.30),
|
||||
(OrganizationUserStatusType.Invited, 0.50),
|
||||
(OrganizationUserStatusType.Accepted, 0.20)
|
||||
);
|
||||
}
|
||||
65
util/Seeder/Data/Distributions/UsernameDistributions.cs
Normal file
65
util/Seeder/Data/Distributions/UsernameDistributions.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data.Distributions;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-configured username category distributions for seeding scenarios.
|
||||
/// Pass to CipherUsernameGenerator for different username mixes.
|
||||
/// </summary>
|
||||
public static class UsernameDistributions
|
||||
{
|
||||
/// <summary>
|
||||
/// Realistic enterprise mix with variety.
|
||||
/// 45% Corporate email, varied personal/legacy/social.
|
||||
/// </summary>
|
||||
public static Distribution<UsernameCategory> Realistic { get; } = new(
|
||||
(UsernameCategory.CorporateEmail, 0.45),
|
||||
(UsernameCategory.PersonalEmail, 0.15),
|
||||
(UsernameCategory.SocialHandle, 0.10),
|
||||
(UsernameCategory.UsernameOnly, 0.10),
|
||||
(UsernameCategory.EmployeeId, 0.08),
|
||||
(UsernameCategory.PhoneNumber, 0.05),
|
||||
(UsernameCategory.LegacySystem, 0.04),
|
||||
(UsernameCategory.RandomAlphanumeric, 0.03)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Corporate-only: 100% corporate email format.
|
||||
/// Use for strict enterprise environments.
|
||||
/// </summary>
|
||||
public static Distribution<UsernameCategory> CorporateOnly { get; } = new(
|
||||
(UsernameCategory.CorporateEmail, 1.0)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Consumer-focused: personal emails and social handles.
|
||||
/// Use for B2C application testing.
|
||||
/// </summary>
|
||||
public static Distribution<UsernameCategory> Consumer { get; } = new(
|
||||
(UsernameCategory.PersonalEmail, 0.40),
|
||||
(UsernameCategory.SocialHandle, 0.25),
|
||||
(UsernameCategory.UsernameOnly, 0.20),
|
||||
(UsernameCategory.PhoneNumber, 0.15)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Legacy enterprise: older systems with employee IDs.
|
||||
/// Use for testing migrations from legacy systems.
|
||||
/// </summary>
|
||||
public static Distribution<UsernameCategory> LegacyEnterprise { get; } = new(
|
||||
(UsernameCategory.CorporateEmail, 0.30),
|
||||
(UsernameCategory.EmployeeId, 0.30),
|
||||
(UsernameCategory.LegacySystem, 0.25),
|
||||
(UsernameCategory.RandomAlphanumeric, 0.15)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Developer-focused: mix of corporate and technical identifiers.
|
||||
/// </summary>
|
||||
public static Distribution<UsernameCategory> Developer { get; } = new(
|
||||
(UsernameCategory.CorporateEmail, 0.35),
|
||||
(UsernameCategory.UsernameOnly, 0.25),
|
||||
(UsernameCategory.SocialHandle, 0.20),
|
||||
(UsernameCategory.RandomAlphanumeric, 0.20)
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,28 @@
|
||||
/// </summary>
|
||||
public enum PasswordStrength
|
||||
{
|
||||
/// <summary>Score 0: Too guessable (< 10³ guesses)</summary>
|
||||
/// <summary>
|
||||
/// Score 0: Too guessable (< 10³ guesses)
|
||||
/// </summary>
|
||||
VeryWeak = 0,
|
||||
|
||||
/// <summary>Score 1: Very guessable (< 10⁶ guesses)</summary>
|
||||
/// <summary>
|
||||
/// Score 1: Very guessable (< 10⁶ guesses)
|
||||
/// </summary>
|
||||
Weak = 1,
|
||||
|
||||
/// <summary>Score 2: Somewhat guessable (< 10⁸ guesses)</summary>
|
||||
/// <summary>
|
||||
/// Score 2: Somewhat guessable (< 10⁸ guesses)
|
||||
/// </summary>
|
||||
Fair = 2,
|
||||
|
||||
/// <summary>Score 3: Safely unguessable (< 10¹⁰ guesses)</summary>
|
||||
/// <summary>
|
||||
/// Score 3: Safely unguessable (< 10¹⁰ guesses)
|
||||
/// </summary>
|
||||
Strong = 3,
|
||||
|
||||
/// <summary>Score 4: Very unguessable (≥ 10¹⁰ guesses)</summary>
|
||||
VeryStrong = 4,
|
||||
|
||||
/// <summary>Realistic distribution based on breach data statistics.</summary>
|
||||
Realistic = 99
|
||||
/// <summary>
|
||||
/// Score 4: Very unguessable (≥ 10¹⁰ guesses)
|
||||
/// </summary>
|
||||
VeryStrong = 4
|
||||
}
|
||||
|
||||
48
util/Seeder/Data/Enums/UsernameCategory.cs
Normal file
48
util/Seeder/Data/Enums/UsernameCategory.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace Bit.Seeder.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Categories of username formats found in real-world credential vaults.
|
||||
/// Used with Distribution<UsernameCategory> for realistic username generation.
|
||||
/// </summary>
|
||||
public enum UsernameCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// Corporate email format: john.smith@acme.com
|
||||
/// </summary>
|
||||
CorporateEmail,
|
||||
|
||||
/// <summary>
|
||||
/// Personal email: jsmith99@fake-gmail.com
|
||||
/// </summary>
|
||||
PersonalEmail,
|
||||
|
||||
/// <summary>
|
||||
/// Social media handle: @john_smith_42
|
||||
/// </summary>
|
||||
SocialHandle,
|
||||
|
||||
/// <summary>
|
||||
/// Plain username: johnsmith, jdoe1985
|
||||
/// </summary>
|
||||
UsernameOnly,
|
||||
|
||||
/// <summary>
|
||||
/// Employee identifier: EMP001234, E-12345
|
||||
/// </summary>
|
||||
EmployeeId,
|
||||
|
||||
/// <summary>
|
||||
/// Phone number as username: 15551234567
|
||||
/// </summary>
|
||||
PhoneNumber,
|
||||
|
||||
/// <summary>
|
||||
/// Legacy system format: JSMITH01, DOEJ
|
||||
/// </summary>
|
||||
LegacySystem,
|
||||
|
||||
/// <summary>
|
||||
/// Random alphanumeric: xK7mP9qR2n
|
||||
/// </summary>
|
||||
RandomAlphanumeric
|
||||
}
|
||||
@@ -5,16 +5,33 @@
|
||||
/// </summary>
|
||||
public enum UsernamePatternType
|
||||
{
|
||||
/// <summary>first.last@domain.com</summary>
|
||||
/// <summary>
|
||||
/// first.last@domain.com
|
||||
/// </summary>
|
||||
FirstDotLast,
|
||||
/// <summary>f.last@domain.com</summary>
|
||||
|
||||
/// <summary>
|
||||
/// f.last@domain.com
|
||||
/// </summary>
|
||||
FDotLast,
|
||||
/// <summary>flast@domain.com</summary>
|
||||
|
||||
/// <summary>
|
||||
/// flast@domain.com
|
||||
/// </summary>
|
||||
FLast,
|
||||
/// <summary>last.first@domain.com</summary>
|
||||
|
||||
/// <summary>
|
||||
/// last.first@domain.com
|
||||
/// </summary>
|
||||
LastDotFirst,
|
||||
/// <summary>first_last@domain.com</summary>
|
||||
|
||||
/// <summary>
|
||||
/// first_last@domain.com
|
||||
/// </summary>
|
||||
First_Last,
|
||||
/// <summary>lastf@domain.com</summary>
|
||||
|
||||
/// <summary>
|
||||
/// lastf@domain.com
|
||||
/// </summary>
|
||||
LastFirst
|
||||
}
|
||||
|
||||
87
util/Seeder/Data/GeneratorContext.cs
Normal file
87
util/Seeder/Data/GeneratorContext.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Data.Generators;
|
||||
using Bit.Seeder.Options;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized context for all data generators in a seeding operation.
|
||||
/// Lazy-initializes generators on first access to avoid creating unused instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Adding a new generator:
|
||||
/// 1. Add private nullable field
|
||||
/// 2. Add public property with lazy initialization
|
||||
/// 3. Use in Recipe via _ctx.NewGenerator.Method()
|
||||
/// </remarks>
|
||||
internal sealed class GeneratorContext
|
||||
{
|
||||
private readonly int _seed;
|
||||
|
||||
private readonly GeographicRegion _region;
|
||||
|
||||
private readonly OrganizationVaultOptions _options;
|
||||
|
||||
private GeneratorContext(int seed, GeographicRegion region, OrganizationVaultOptions options)
|
||||
{
|
||||
_seed = seed;
|
||||
_region = region;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a GeneratorContext from vault options, deriving seed from domain if not specified.
|
||||
/// </summary>
|
||||
public static GeneratorContext FromOptions(OrganizationVaultOptions options)
|
||||
{
|
||||
var seed = options.Seed ?? DeriveStableSeed(options.Domain);
|
||||
var region = options.Region ?? GeographicRegion.Global;
|
||||
return new GeneratorContext(seed, region, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a stable 32-bit seed from a domain string using SHA256.
|
||||
/// Same input always produces same output for deterministic generation.
|
||||
/// </summary>
|
||||
private static int DeriveStableSeed(string domain)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(domain));
|
||||
return BitConverter.ToInt32(bytes, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The seed used for deterministic generation. Exposed for distribution calculations.
|
||||
/// </summary>
|
||||
public int Seed => _seed;
|
||||
|
||||
/// <summary>
|
||||
/// Total cipher count from options. Used for distribution calculations.
|
||||
/// </summary>
|
||||
public int CipherCount => _options.Ciphers;
|
||||
|
||||
private CipherUsernameGenerator? _username;
|
||||
|
||||
public CipherUsernameGenerator Username => _username ??= new(
|
||||
_seed,
|
||||
_options.UsernameDistribution,
|
||||
_region,
|
||||
_options.UsernamePattern);
|
||||
|
||||
private FolderNameGenerator? _folder;
|
||||
|
||||
public FolderNameGenerator Folder => _folder ??= new(_seed);
|
||||
|
||||
private CardDataGenerator? _card;
|
||||
|
||||
public CardDataGenerator Card => _card ??= new(_seed, _region);
|
||||
|
||||
private IdentityDataGenerator? _identity;
|
||||
|
||||
public IdentityDataGenerator Identity => _identity ??= new(_seed, _region);
|
||||
|
||||
private SecureNoteDataGenerator? _secureNote;
|
||||
|
||||
public SecureNoteDataGenerator SecureNote => _secureNote ??= new(_seed);
|
||||
}
|
||||
75
util/Seeder/Data/Generators/CardDataGenerator.cs
Normal file
75
util/Seeder/Data/Generators/CardDataGenerator.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Globalization;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Models;
|
||||
using Bogus;
|
||||
|
||||
namespace Bit.Seeder.Data.Generators;
|
||||
|
||||
internal sealed class CardDataGenerator
|
||||
{
|
||||
private readonly int _seed;
|
||||
private readonly GeographicRegion _region;
|
||||
|
||||
private static readonly Dictionary<GeographicRegion, string[]> _regionalBrands = new()
|
||||
{
|
||||
[GeographicRegion.NorthAmerica] = ["Visa", "Mastercard", "Amex", "Discover"],
|
||||
[GeographicRegion.Europe] = ["Visa", "Mastercard", "Maestro", "Amex"],
|
||||
[GeographicRegion.AsiaPacific] = ["Visa", "Mastercard", "JCB", "UnionPay"],
|
||||
[GeographicRegion.LatinAmerica] = ["Visa", "Mastercard", "Elo", "Amex"],
|
||||
[GeographicRegion.MiddleEast] = ["Visa", "Mastercard", "Amex"],
|
||||
[GeographicRegion.Africa] = ["Visa", "Mastercard"],
|
||||
[GeographicRegion.Global] = ["Visa", "Mastercard", "Amex", "Discover", "JCB", "UnionPay", "Maestro", "Elo"]
|
||||
};
|
||||
|
||||
internal CardDataGenerator(int seed, GeographicRegion region = GeographicRegion.Global)
|
||||
{
|
||||
_seed = seed;
|
||||
_region = region;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic card based on index.
|
||||
/// </summary>
|
||||
internal CardViewDto GenerateByIndex(int index)
|
||||
{
|
||||
var seededFaker = new Faker { Random = new Randomizer(_seed + index) };
|
||||
var brands = _regionalBrands[_region];
|
||||
var brand = brands[index % brands.Length];
|
||||
|
||||
return new CardViewDto
|
||||
{
|
||||
CardholderName = seededFaker.Name.FullName(),
|
||||
Brand = brand,
|
||||
Number = GenerateNumber(brand, seededFaker),
|
||||
ExpMonth = ((index % 12) + 1).ToString("D2", CultureInfo.InvariantCulture),
|
||||
ExpYear = (DateTime.Now.Year + (index % 5) + 1).ToString(CultureInfo.InvariantCulture),
|
||||
Code = GenerateCode(brand, seededFaker)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateNumber(string brand, Faker faker) => brand switch
|
||||
{
|
||||
// North American / Global
|
||||
"Visa" => "4" + faker.Random.ReplaceNumbers("###############"),
|
||||
"Mastercard" => faker.PickRandom("51", "52", "53", "54", "55") + faker.Random.ReplaceNumbers("##############"),
|
||||
"Amex" => faker.PickRandom("34", "37") + faker.Random.ReplaceNumbers("#############"),
|
||||
"Discover" => "6011" + faker.Random.ReplaceNumbers("############"),
|
||||
|
||||
// Europe
|
||||
"Maestro" => faker.PickRandom("5018", "5020", "5038", "5893", "6304") + faker.Random.ReplaceNumbers("############"),
|
||||
|
||||
// Asia Pacific
|
||||
"JCB" => "35" + faker.Random.ReplaceNumbers("##############"),
|
||||
"UnionPay" => "62" + faker.Random.ReplaceNumbers("##############"),
|
||||
|
||||
// Latin America
|
||||
"Elo" => faker.PickRandom("4011", "4312", "4389", "5041", "5066", "5067", "6277", "6362", "6363") + faker.Random.ReplaceNumbers("############"),
|
||||
|
||||
_ => faker.Finance.CreditCardNumber()
|
||||
};
|
||||
|
||||
private static string GenerateCode(string brand, Faker faker) =>
|
||||
brand == "Amex"
|
||||
? faker.Random.Int(1000, 9999).ToString(CultureInfo.InvariantCulture)
|
||||
: faker.Random.Int(100, 999).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
235
util/Seeder/Data/Generators/CipherUsernameGenerator.cs
Normal file
235
util/Seeder/Data/Generators/CipherUsernameGenerator.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bogus;
|
||||
|
||||
namespace Bit.Seeder.Data.Generators;
|
||||
|
||||
/// <summary>
|
||||
/// Generates diverse usernames based on configurable category distributions.
|
||||
/// Supports corporate emails, personal emails, social handles, employee IDs, and more.
|
||||
/// Includes locale-aware name generation for culturally-appropriate usernames.
|
||||
/// </summary>
|
||||
internal sealed class CipherUsernameGenerator
|
||||
{
|
||||
private const int NamePoolSize = 1500;
|
||||
|
||||
private static readonly string[] PersonalEmailDomains =
|
||||
["fake-gmail.com", "fake-yahoo.com", "fake-outlook.com", "fake-hotmail.com", "fake-icloud.com"];
|
||||
|
||||
private static readonly string[] SocialPlatformPrefixes =
|
||||
["@", "@", "@", ""]; // 75% chance of @ prefix
|
||||
|
||||
private static readonly string[] EuropeanLocales =
|
||||
["en_GB", "de", "fr", "es", "it", "nl", "pl", "pt_PT", "sv"];
|
||||
|
||||
private static readonly string[] AsianLocales =
|
||||
["ja", "ko", "zh_CN", "zh_TW", "vi"];
|
||||
|
||||
private static readonly string[] LatinAmericanLocales =
|
||||
["es_MX", "pt_BR", "es"];
|
||||
|
||||
private static readonly string[] MiddleEastLocales =
|
||||
["ar", "tr", "fa"];
|
||||
|
||||
private static readonly string[] AfricanLocales =
|
||||
["en_ZA", "fr"];
|
||||
|
||||
private readonly int _seed;
|
||||
|
||||
private readonly Distribution<UsernameCategory> _distribution;
|
||||
|
||||
private readonly UsernamePatternType _corporateEmailPattern;
|
||||
|
||||
private readonly string[] _firstNames;
|
||||
|
||||
private readonly string[] _lastNames;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a username generator with the specified distribution and settings.
|
||||
/// </summary>
|
||||
/// <param name="seed">Seed for deterministic generation.</param>
|
||||
/// <param name="distribution">Distribution of username categories. Use UsernameDistributions.Realistic for defaults.</param>
|
||||
/// <param name="region">Geographic region for culturally-appropriate name generation.</param>
|
||||
/// <param name="corporateEmailPattern">Pattern for corporate emails (default: first.last@domain).</param>
|
||||
internal CipherUsernameGenerator(
|
||||
int seed,
|
||||
Distribution<UsernameCategory> distribution,
|
||||
GeographicRegion region = GeographicRegion.Global,
|
||||
UsernamePatternType corporateEmailPattern = UsernamePatternType.FirstDotLast)
|
||||
{
|
||||
_seed = seed;
|
||||
_distribution = distribution;
|
||||
_corporateEmailPattern = corporateEmailPattern;
|
||||
|
||||
// Build locale-aware name pools
|
||||
var locale = MapRegionToLocale(region, seed);
|
||||
var faker = new Faker(locale) { Random = new Randomizer(seed) };
|
||||
_firstNames = Enumerable.Range(0, NamePoolSize).Select(_ => faker.Name.FirstName()).ToArray();
|
||||
_lastNames = Enumerable.Range(0, NamePoolSize).Select(_ => faker.Name.LastName()).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic username based on index and optional domain.
|
||||
/// Category is selected based on the configured distribution.
|
||||
/// </summary>
|
||||
/// <param name="index">Index for deterministic selection.</param>
|
||||
/// <param name="totalHint">Total number of items (for distribution calculation). Default: 1000.</param>
|
||||
/// <param name="domain">Corporate domain (used for CorporateEmail category).</param>
|
||||
internal string GenerateByIndex(int index, int totalHint = 1000, string? domain = null)
|
||||
{
|
||||
var category = _distribution.Select(index, totalHint);
|
||||
var seededFaker = new Faker { Random = new Randomizer(_seed + index) };
|
||||
|
||||
var offset = GetDeterministicOffset(index);
|
||||
var firstName = _firstNames[(index + offset) % _firstNames.Length];
|
||||
var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length];
|
||||
|
||||
return category switch
|
||||
{
|
||||
UsernameCategory.CorporateEmail => GenerateCorporateEmail(firstName, lastName, domain ?? "example.com"),
|
||||
UsernameCategory.PersonalEmail => GeneratePersonalEmail(seededFaker, firstName, lastName, index),
|
||||
UsernameCategory.SocialHandle => GenerateSocialHandle(seededFaker, firstName, lastName, index),
|
||||
UsernameCategory.UsernameOnly => GenerateUsernameOnly(seededFaker, firstName, lastName, index),
|
||||
UsernameCategory.EmployeeId => GenerateEmployeeId(seededFaker, index),
|
||||
UsernameCategory.PhoneNumber => GeneratePhoneNumber(seededFaker, index),
|
||||
UsernameCategory.LegacySystem => GenerateLegacySystem(firstName, lastName, index),
|
||||
UsernameCategory.RandomAlphanumeric => GenerateRandomAlphanumeric(seededFaker),
|
||||
_ => GenerateCorporateEmail(firstName, lastName, domain ?? "example.com")
|
||||
};
|
||||
}
|
||||
|
||||
private string GenerateCorporateEmail(string firstName, string lastName, string domain)
|
||||
{
|
||||
var first = firstName.ToLowerInvariant();
|
||||
var last = lastName.ToLowerInvariant();
|
||||
var f = char.ToLowerInvariant(firstName[0]);
|
||||
|
||||
return _corporateEmailPattern switch
|
||||
{
|
||||
UsernamePatternType.FirstDotLast => $"{first}.{last}@{domain}",
|
||||
UsernamePatternType.FDotLast => $"{f}.{last}@{domain}",
|
||||
UsernamePatternType.FLast => $"{f}{last}@{domain}",
|
||||
UsernamePatternType.LastDotFirst => $"{last}.{first}@{domain}",
|
||||
UsernamePatternType.First_Last => $"{first}_{last}@{domain}",
|
||||
UsernamePatternType.LastFirst => $"{last}{f}@{domain}",
|
||||
_ => $"{first}.{last}@{domain}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GeneratePersonalEmail(Faker faker, string firstName, string lastName, int index)
|
||||
{
|
||||
var domain = PersonalEmailDomains[index % PersonalEmailDomains.Length];
|
||||
var style = index % 5;
|
||||
|
||||
return style switch
|
||||
{
|
||||
0 => $"{firstName.ToLowerInvariant()}.{lastName.ToLowerInvariant()}{faker.Random.Int(1, 99)}@{domain}",
|
||||
1 => $"{firstName.ToLowerInvariant()}{faker.Random.Int(1970, 2005)}@{domain}",
|
||||
2 => $"{char.ToLowerInvariant(firstName[0])}{lastName.ToLowerInvariant()}{faker.Random.Int(1, 999)}@{domain}",
|
||||
3 => $"{lastName.ToLowerInvariant()}.{firstName.ToLowerInvariant()}@{domain}",
|
||||
_ => $"{firstName.ToLowerInvariant()}_{faker.Random.Int(100, 9999)}@{domain}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSocialHandle(Faker faker, string firstName, string lastName, int index)
|
||||
{
|
||||
var prefix = SocialPlatformPrefixes[index % SocialPlatformPrefixes.Length];
|
||||
var style = index % 6;
|
||||
|
||||
var handle = style switch
|
||||
{
|
||||
0 => $"{firstName.ToLowerInvariant()}_{lastName.ToLowerInvariant()}",
|
||||
1 => $"{firstName.ToLowerInvariant()}{faker.Random.Int(1, 999)}",
|
||||
2 => $"{char.ToLowerInvariant(firstName[0])}{lastName.ToLowerInvariant()}",
|
||||
3 => $"{firstName.ToLowerInvariant()}_{faker.Random.Int(10, 99)}",
|
||||
4 => $"the_{firstName.ToLowerInvariant()}",
|
||||
_ => $"{lastName.ToLowerInvariant()}{char.ToLowerInvariant(firstName[0])}{faker.Random.Int(1, 99)}"
|
||||
};
|
||||
|
||||
return $"{prefix}{handle}";
|
||||
}
|
||||
|
||||
private static string GenerateUsernameOnly(Faker faker, string firstName, string lastName, int index)
|
||||
{
|
||||
var style = index % 5;
|
||||
|
||||
return style switch
|
||||
{
|
||||
0 => $"{firstName.ToLowerInvariant()}{lastName.ToLowerInvariant()}",
|
||||
1 => $"{firstName.ToLowerInvariant()}.{lastName.ToLowerInvariant()}",
|
||||
2 => $"{char.ToLowerInvariant(firstName[0])}{lastName.ToLowerInvariant()}{faker.Random.Int(1, 99)}",
|
||||
3 => $"{firstName.ToLowerInvariant()}{faker.Random.Int(1980, 2010)}",
|
||||
_ => $"{lastName.ToLowerInvariant()}_{firstName.ToLowerInvariant()}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateEmployeeId(Faker faker, int index)
|
||||
{
|
||||
var style = index % 4;
|
||||
|
||||
return style switch
|
||||
{
|
||||
0 => $"EMP{100000 + index:D6}",
|
||||
1 => $"E-{faker.Random.Int(10000, 99999)}",
|
||||
2 => $"USR{faker.Random.Int(10000, 99999):D5}",
|
||||
_ => $"{faker.Random.AlphaNumeric(2).ToUpperInvariant()}{faker.Random.Int(1000, 9999)}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GeneratePhoneNumber(Faker faker, int index)
|
||||
{
|
||||
// No + prefix per requirements
|
||||
var areaCode = 200 + (index % 800); // Valid US area codes start at 200
|
||||
var exchange = faker.Random.Int(200, 999);
|
||||
var subscriber = faker.Random.Int(1000, 9999);
|
||||
|
||||
return $"1{areaCode}{exchange}{subscriber}";
|
||||
}
|
||||
|
||||
private static string GenerateLegacySystem(string firstName, string lastName, int index)
|
||||
{
|
||||
var style = index % 4;
|
||||
|
||||
return style switch
|
||||
{
|
||||
0 => $"{lastName.ToUpperInvariant()[..Math.Min(6, lastName.Length)]}{char.ToUpperInvariant(firstName[0])}{(index % 100):D2}",
|
||||
1 => $"{char.ToUpperInvariant(firstName[0])}{lastName.ToUpperInvariant()[..Math.Min(7, lastName.Length)]}",
|
||||
2 => $"{lastName.ToUpperInvariant()[..Math.Min(4, lastName.Length)]}{firstName.ToUpperInvariant()[..Math.Min(2, firstName.Length)]}{index % 10}",
|
||||
_ => $"U{(10000 + index):D5}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateRandomAlphanumeric(Faker faker)
|
||||
{
|
||||
return faker.Random.AlphaNumeric(10);
|
||||
}
|
||||
|
||||
private int GetDeterministicOffset(int index)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = _seed;
|
||||
hash = hash * 397 ^ index;
|
||||
return ((hash % 10) + 10) % 10;
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapRegionToLocale(GeographicRegion region, int seed) => region switch
|
||||
{
|
||||
GeographicRegion.NorthAmerica => "en_US",
|
||||
GeographicRegion.Europe => PickLocale(EuropeanLocales, seed),
|
||||
GeographicRegion.AsiaPacific => PickLocale(AsianLocales, seed),
|
||||
GeographicRegion.LatinAmerica => PickLocale(LatinAmericanLocales, seed),
|
||||
GeographicRegion.MiddleEast => PickLocale(MiddleEastLocales, seed),
|
||||
GeographicRegion.Africa => PickLocale(AfricanLocales, seed),
|
||||
GeographicRegion.Global => "en",
|
||||
_ => "en"
|
||||
};
|
||||
|
||||
private static string PickLocale(string[] locales, int seed)
|
||||
{
|
||||
var length = locales.Length;
|
||||
var index = ((seed % length) + length) % length;
|
||||
return locales[index];
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
using Bogus;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
namespace Bit.Seeder.Data.Generators;
|
||||
|
||||
/// <summary>
|
||||
/// Generates deterministic folder names using Bogus Commerce.Department().
|
||||
/// Pre-generates a pool of business-themed names for consistent index-based access.
|
||||
/// </summary>
|
||||
internal sealed class FolderNameGenerator
|
||||
{
|
||||
private const int _namePoolSize = 50;
|
||||
|
||||
private readonly string[] _folderNames;
|
||||
|
||||
public FolderNameGenerator(int seed)
|
||||
internal FolderNameGenerator(int seed)
|
||||
{
|
||||
var faker = new Faker { Random = new Randomizer(seed) };
|
||||
|
||||
@@ -27,5 +23,5 @@ internal sealed class FolderNameGenerator
|
||||
/// <summary>
|
||||
/// Gets a folder name by index, wrapping around if index exceeds pool size.
|
||||
/// </summary>
|
||||
public string GetFolderName(int index) => _folderNames[index % _folderNames.Length];
|
||||
internal string GetFolderName(int index) => _folderNames[index % _folderNames.Length];
|
||||
}
|
||||
92
util/Seeder/Data/Generators/IdentityDataGenerator.cs
Normal file
92
util/Seeder/Data/Generators/IdentityDataGenerator.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Models;
|
||||
using Bogus;
|
||||
|
||||
namespace Bit.Seeder.Data.Generators;
|
||||
|
||||
internal sealed class IdentityDataGenerator(int seed, GeographicRegion region = GeographicRegion.Global)
|
||||
{
|
||||
private readonly int _seed = seed;
|
||||
|
||||
private readonly GeographicRegion _region = region;
|
||||
|
||||
private static readonly Dictionary<GeographicRegion, string[]> _regionalTitles = new()
|
||||
{
|
||||
[GeographicRegion.NorthAmerica] = ["Mr", "Mrs", "Ms", "Dr", "Prof"],
|
||||
[GeographicRegion.Europe] = ["Mr", "Mrs", "Ms", "Dr", "Prof", "Sir", "Dame"],
|
||||
[GeographicRegion.AsiaPacific] = ["Mr", "Mrs", "Ms", "Dr"],
|
||||
[GeographicRegion.LatinAmerica] = ["Sr", "Sra", "Srta", "Dr", "Prof"],
|
||||
[GeographicRegion.MiddleEast] = ["Mr", "Mrs", "Ms", "Dr", "Sheikh", "Sheikha"],
|
||||
[GeographicRegion.Africa] = ["Mr", "Mrs", "Ms", "Dr", "Chief"],
|
||||
[GeographicRegion.Global] = ["Mr", "Mrs", "Ms", "Dr", "Prof"]
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic identity based on index.
|
||||
/// </summary>
|
||||
internal IdentityViewDto GenerateByIndex(int index)
|
||||
{
|
||||
var seededFaker = new Faker(MapRegionToLocale(_region)) { Random = new Randomizer(_seed + index) };
|
||||
var person = seededFaker.Person;
|
||||
var titles = _regionalTitles[_region];
|
||||
|
||||
return new IdentityViewDto
|
||||
{
|
||||
Title = titles[index % titles.Length],
|
||||
FirstName = person.FirstName,
|
||||
MiddleName = index % 3 == 0 ? seededFaker.Name.FirstName() : null,
|
||||
LastName = person.LastName,
|
||||
Address1 = seededFaker.Address.StreetAddress(),
|
||||
Address2 = index % 5 == 0 ? seededFaker.Address.SecondaryAddress() : null,
|
||||
Address3 = null,
|
||||
City = seededFaker.Address.City(),
|
||||
State = seededFaker.Address.StateAbbr(),
|
||||
PostalCode = seededFaker.Address.ZipCode(),
|
||||
Country = GetCountryCode(seededFaker),
|
||||
Company = index % 2 == 0 ? seededFaker.Company.CompanyName() : null,
|
||||
Email = person.Email,
|
||||
Phone = seededFaker.Phone.PhoneNumber(),
|
||||
SSN = GenerateNationalIdByIndex(index),
|
||||
Username = person.UserName,
|
||||
PassportNumber = index % 3 == 0 ? GeneratePassportNumberByIndex(index) : null,
|
||||
LicenseNumber = index % 2 == 0 ? GenerateLicenseNumberByIndex(index) : null
|
||||
};
|
||||
}
|
||||
|
||||
private string GenerateNationalIdByIndex(int index) => _region switch
|
||||
{
|
||||
GeographicRegion.NorthAmerica => $"{100 + (index % 899):D3}-{10 + (index % 90):D2}-{1000 + (index % 9000):D4}",
|
||||
GeographicRegion.Europe => $"AB {10 + (index % 90):D2} {10 + ((index + 1) % 90):D2} {10 + ((index + 2) % 90):D2} C",
|
||||
GeographicRegion.AsiaPacific => $"{1000 + (index % 9000):D4}-{1000 + ((index + 1) % 9000):D4}-{1000 + ((index + 2) % 9000):D4}",
|
||||
GeographicRegion.LatinAmerica => $"{100 + (index % 900):D3}.{100 + ((index + 1) % 900):D3}.{100 + ((index + 2) % 900):D3}-{10 + (index % 90):D2}",
|
||||
_ => $"{100 + (index % 899):D3}-{10 + (index % 90):D2}-{1000 + (index % 9000):D4}"
|
||||
};
|
||||
|
||||
private static string GeneratePassportNumberByIndex(int index) =>
|
||||
$"{(char)('A' + index % 26)}{10000000 + index}";
|
||||
|
||||
private static string GenerateLicenseNumberByIndex(int index) =>
|
||||
$"DL{1000000 + index}";
|
||||
|
||||
private string GetCountryCode(Faker faker) => _region switch
|
||||
{
|
||||
GeographicRegion.NorthAmerica => faker.PickRandom("US", "CA"),
|
||||
GeographicRegion.Europe => faker.PickRandom("GB", "DE", "FR", "ES", "IT", "NL"),
|
||||
GeographicRegion.AsiaPacific => faker.PickRandom("JP", "CN", "IN", "AU", "KR", "SG"),
|
||||
GeographicRegion.LatinAmerica => faker.PickRandom("BR", "MX", "AR", "CO", "CL"),
|
||||
GeographicRegion.MiddleEast => faker.PickRandom("AE", "SA", "IL", "TR"),
|
||||
GeographicRegion.Africa => faker.PickRandom("ZA", "NG", "EG", "KE"),
|
||||
_ => faker.Address.CountryCode()
|
||||
};
|
||||
|
||||
private static string MapRegionToLocale(GeographicRegion region) => region switch
|
||||
{
|
||||
GeographicRegion.NorthAmerica => "en_US",
|
||||
GeographicRegion.Europe => "en_GB",
|
||||
GeographicRegion.AsiaPacific => "en",
|
||||
GeographicRegion.LatinAmerica => "es",
|
||||
GeographicRegion.MiddleEast => "en",
|
||||
GeographicRegion.Africa => "en",
|
||||
_ => "en"
|
||||
};
|
||||
}
|
||||
167
util/Seeder/Data/Generators/SecureNoteDataGenerator.cs
Normal file
167
util/Seeder/Data/Generators/SecureNoteDataGenerator.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System.Globalization;
|
||||
using Bogus;
|
||||
|
||||
namespace Bit.Seeder.Data.Generators;
|
||||
|
||||
internal sealed class SecureNoteDataGenerator(int seed)
|
||||
{
|
||||
private readonly int _seed = seed;
|
||||
|
||||
private static readonly string[] _noteCategories =
|
||||
[
|
||||
"API Keys & Secrets",
|
||||
"License Keys",
|
||||
"Recovery Codes",
|
||||
"Network Credentials",
|
||||
"Server Information",
|
||||
"Documentation",
|
||||
"WiFi Passwords",
|
||||
"Database Credentials",
|
||||
"Cloud Console Access",
|
||||
"Meeting Room Codes",
|
||||
"Vendor Portal",
|
||||
"Building Access",
|
||||
"Expense System",
|
||||
"Coffee Machine",
|
||||
"Parking Garage"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic secure note based on index for reproducible test data.
|
||||
/// </summary>
|
||||
/// <returns>Tuple of (name, notes) for the secure note cipher.</returns>
|
||||
internal (string name, string notes) GenerateByIndex(int index)
|
||||
{
|
||||
var category = _noteCategories[index % _noteCategories.Length];
|
||||
var seededFaker = new Faker { Random = new Randomizer(_seed + index) };
|
||||
return (GenerateNoteName(category, seededFaker), GenerateNoteContent(category, seededFaker));
|
||||
}
|
||||
|
||||
private static string GenerateNoteName(string category, Faker faker) => category switch
|
||||
{
|
||||
"API Keys & Secrets" => $"{faker.Company.CompanyName()} API Key",
|
||||
"License Keys" => $"{faker.Commerce.ProductName()} License",
|
||||
"Recovery Codes" => $"{faker.Internet.DomainName()} Recovery Codes",
|
||||
"Network Credentials" => $"{faker.Company.CompanyName()} VPN",
|
||||
"Server Information" => $"{faker.Hacker.Noun()}-{faker.Random.Int(1, 99)} Server Info",
|
||||
"Documentation" => $"{faker.Commerce.Department()} Docs",
|
||||
"WiFi Passwords" => $"{faker.PickRandom("Office", "Guest", "Executive", "Lab", "Warehouse")} WiFi - Floor {faker.Random.Int(1, 12)}",
|
||||
"Database Credentials" => $"{faker.PickRandom("Production", "Staging", "Analytics", "Reporting")} {faker.PickRandom("MySQL", "PostgreSQL", "MongoDB", "Redis")}",
|
||||
"Cloud Console Access" => $"{faker.PickRandom("AWS", "Azure", "GCP", "DigitalOcean")} - {faker.Company.CompanyName()}",
|
||||
"Meeting Room Codes" => $"{faker.Address.City()} Conference Room",
|
||||
"Vendor Portal" => $"{faker.Company.CompanyName()} Vendor Portal",
|
||||
"Building Access" => $"{faker.Address.StreetName()} Office Access",
|
||||
"Expense System" => $"{faker.PickRandom("Concur", "Expensify", "SAP", "Corporate Card")} Access",
|
||||
"Coffee Machine" => $"{faker.PickRandom("Break Room", "Executive Lounge", "Cafeteria", "Kitchen")} Coffee Machine",
|
||||
"Parking Garage" => $"{faker.Address.StreetName()} Parking",
|
||||
_ => faker.Lorem.Sentence(3)
|
||||
};
|
||||
|
||||
private static string GenerateNoteContent(string category, Faker faker) => category switch
|
||||
{
|
||||
"API Keys & Secrets" => $"""
|
||||
API Key: sk_test_FAKE_{faker.Random.AlphaNumeric(32)}
|
||||
Created: {faker.Date.Past():yyyy-MM-dd}
|
||||
Environment: {faker.PickRandom("production", "staging", "development")}
|
||||
""",
|
||||
|
||||
"License Keys" => $"""
|
||||
License: {faker.Random.AlphaNumeric(5).ToUpper(CultureInfo.InvariantCulture)}-{faker.Random.AlphaNumeric(5).ToUpper(CultureInfo.InvariantCulture)}-{faker.Random.AlphaNumeric(5).ToUpper(CultureInfo.InvariantCulture)}
|
||||
Expires: {faker.Date.Future():yyyy-MM-dd}
|
||||
Seats: {faker.Random.Int(1, 100)}
|
||||
""",
|
||||
|
||||
"Recovery Codes" => string.Join("\n",
|
||||
Enumerable.Range(1, 10).Select(i => $"{i}. {faker.Random.AlphaNumeric(8).ToLower(CultureInfo.InvariantCulture)}")),
|
||||
|
||||
"Network Credentials" => $"""
|
||||
Host: vpn.{faker.Internet.DomainName()}
|
||||
Port: {faker.PickRandom(443, 1194, 500)}
|
||||
Protocol: {faker.PickRandom("OpenVPN", "IKEv2", "WireGuard")}
|
||||
""",
|
||||
|
||||
"Server Information" => $"""
|
||||
Host: {faker.Internet.Ip()}
|
||||
SSH Port: 22
|
||||
OS: {faker.PickRandom("Ubuntu 22.04", "Debian 12", "CentOS 9")}
|
||||
""",
|
||||
|
||||
"Documentation" => faker.Lorem.Paragraphs(2),
|
||||
|
||||
"WiFi Passwords" => $"""
|
||||
Network: {faker.Company.CompanyName()}-{faker.PickRandom("Corp", "Guest", "IoT", "Secure")}
|
||||
Password: {faker.Internet.Password(12)}
|
||||
Security: {faker.PickRandom("WPA2-Enterprise", "WPA3", "WPA2-PSK")}
|
||||
Note: {faker.PickRandom("Rotates quarterly", "Ask IT for guest access", "Do not share externally")}
|
||||
""",
|
||||
|
||||
"Database Credentials" => $"""
|
||||
Host: {faker.Hacker.Noun()}-db-{faker.Random.Int(1, 9)}.{faker.Internet.DomainName()}
|
||||
Port: {faker.PickRandom(3306, 5432, 27017, 6379)}
|
||||
Database: {faker.Hacker.Noun()}_{faker.PickRandom("prod", "staging", "analytics")}
|
||||
Username: svc_{faker.Hacker.Noun()}_{faker.Random.Int(100, 999)}
|
||||
Password: {faker.Internet.Password(24)}
|
||||
""",
|
||||
|
||||
"Cloud Console Access" => $"""
|
||||
Console: {faker.PickRandom("https://console.aws.amazon.com", "https://portal.azure.com", "https://console.cloud.google.com")}
|
||||
Account ID: {faker.Random.Int(100000000, 999999999)}
|
||||
IAM User: {faker.Internet.UserName()}
|
||||
MFA Device: {faker.PickRandom("Yubikey", "Google Authenticator", "Authy", "1Password")}
|
||||
Role: {faker.PickRandom("AdministratorAccess", "PowerUserAccess", "ReadOnlyAccess", "BillingAccess")}
|
||||
""",
|
||||
|
||||
"Meeting Room Codes" => $"""
|
||||
Room: {faker.Address.City()} {faker.PickRandom("A", "B", "C", "")}{faker.Random.Int(100, 450)}
|
||||
Capacity: {faker.PickRandom(4, 6, 8, 12, 20)} people
|
||||
PIN: {faker.Random.Int(1000, 9999)}
|
||||
Zoom Room ID: {faker.Random.Int(100, 999)}-{faker.Random.Int(100, 999)}-{faker.Random.Int(1000, 9999)}
|
||||
AV Contact: x{faker.Random.Int(1000, 9999)}
|
||||
""",
|
||||
|
||||
"Vendor Portal" => $"""
|
||||
URL: https://vendor.{faker.Internet.DomainName()}/portal
|
||||
Company ID: {faker.Random.AlphaNumeric(8).ToUpper(CultureInfo.InvariantCulture)}
|
||||
Username: {faker.Internet.Email()}
|
||||
Password: {faker.Internet.Password(16)}
|
||||
Support: {faker.Phone.PhoneNumber("1-800-###-####")}
|
||||
Account Rep: {faker.Name.FullName()}
|
||||
""",
|
||||
|
||||
"Building Access" => $"""
|
||||
Address: {faker.Address.StreetAddress()}, {faker.Address.City()}
|
||||
Alarm Code: {faker.Random.Int(1000, 9999)}#
|
||||
Disarm Window: {faker.Random.Int(30, 90)} seconds
|
||||
Emergency Contact: {faker.Phone.PhoneNumber()}
|
||||
After Hours: {faker.PickRandom("Call security at x5555", "Use side entrance", "Badge required 24/7")}
|
||||
""",
|
||||
|
||||
"Expense System" => $"""
|
||||
System: {faker.PickRandom("Concur", "Expensify", "SAP Concur", "Certify")}
|
||||
Employee ID: {faker.Random.AlphaNumeric(6).ToUpper(CultureInfo.InvariantCulture)}
|
||||
Approval Limit: ${faker.Random.Int(500, 5000):N0}
|
||||
Corporate Card: **** **** **** {faker.Random.Int(1000, 9999)}
|
||||
PIN: {faker.Random.Int(1000, 9999)}
|
||||
Billing Code: {faker.Random.Int(10000, 99999)}-{faker.Random.Int(100, 999)}
|
||||
""",
|
||||
|
||||
"Coffee Machine" => $"""
|
||||
Machine: {faker.PickRandom("Jura", "Breville", "De'Longhi", "Nespresso")} {faker.Commerce.ProductAdjective()}
|
||||
Premium Code: {faker.Random.Int(1000, 9999)}
|
||||
Maintenance: {faker.PickRandom("Facilities", "Office Manager", "Self-service")}
|
||||
Bean Refill: {faker.PickRandom("Tuesdays", "Wednesdays", "Weekly", "As needed")}
|
||||
Secret Menu: Double-tap for extra shot
|
||||
""",
|
||||
|
||||
"Parking Garage" => $"""
|
||||
Location: {faker.Address.StreetAddress()}
|
||||
Gate Code: #{faker.Random.Int(1000, 9999)}
|
||||
Assigned Spot: {faker.PickRandom("A", "B", "C", "P")}{faker.Random.Int(1, 4)}-{faker.Random.Int(100, 450)}
|
||||
Monthly Pass: {faker.Random.AlphaNumeric(10).ToUpper(CultureInfo.InvariantCulture)}
|
||||
Validation: {faker.PickRandom("Get ticket stamped at reception", "Use company app", "Auto-validated by badge")}
|
||||
Emergency Exit: {faker.PickRandom("Stairwell B", "North ramp", "Elevator to lobby")}
|
||||
""",
|
||||
|
||||
_ => faker.Lorem.Paragraph()
|
||||
};
|
||||
}
|
||||
142
util/Seeder/Data/Generators/SshKeyDataGenerator.cs
Normal file
142
util/Seeder/Data/Generators/SshKeyDataGenerator.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using System.Security.Cryptography;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Data.Generators;
|
||||
|
||||
/// <summary>
|
||||
/// Generates structurally-valid but intentionally unusable SSH keys for test vault data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Security by Design:</strong> These keys are deliberately marked with "FAKE" in the
|
||||
/// PEM headers (e.g., "-----BEGIN FAKE RSA PRIVATE KEY-----") to ensure they cannot be
|
||||
/// mistaken for or used as real credentials. The keys are cryptographically valid in structure
|
||||
/// but are explicitly labeled to prevent any accidental production use.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Why realistic structure?</strong> Clients validate SSH key format for
|
||||
/// display purposes (fingerprint rendering, key type detection, copy-to-clipboard formatting).
|
||||
/// Using placeholder strings like "FAKE_KEY_HERE" would fail client-side validation and not
|
||||
/// exercise the full code path during integration testing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Context:</strong> This generator is part of the Seeder, which creates test data for
|
||||
/// local development and integration testing only. All generated keys are encrypted with
|
||||
/// organization keys before database storage, maintaining zero-knowledge architecture even
|
||||
/// for test data.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Note:</strong> Keys are NOT deterministically seeded - RSA.Create() uses system RNG.
|
||||
/// The pool provides variety but not cross-run reproducibility.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SshKeyDataGenerator
|
||||
{
|
||||
private const int _poolSize = 500;
|
||||
|
||||
private static readonly Lazy<(string Private, string Public, string Fingerprint)[]> _keyPool =
|
||||
new(() => GenerateKeyPool(_poolSize));
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic SSH key based on index from the pre-generated pool.
|
||||
/// </summary>
|
||||
internal static SshKeyViewDto GenerateByIndex(int index)
|
||||
{
|
||||
var poolLength = _keyPool.Value.Length;
|
||||
var poolIndex = ((index % poolLength) + poolLength) % poolLength;
|
||||
var (Private, Public, Fingerprint) = _keyPool.Value[poolIndex];
|
||||
return new SshKeyViewDto
|
||||
{
|
||||
PrivateKey = Private,
|
||||
PublicKey = Public,
|
||||
Fingerprint = Fingerprint
|
||||
};
|
||||
}
|
||||
|
||||
private static (string, string, string)[] GenerateKeyPool(int count)
|
||||
{
|
||||
var keys = new (string, string, string)[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
keys[i] = (ExportPrivateKey(rsa), ExportPublicKey(rsa), ComputeFingerprint(rsa));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static string ExportPrivateKey(RSA rsa)
|
||||
{
|
||||
var privateKeyBytes = rsa.ExportRSAPrivateKey();
|
||||
var base64 = Convert.ToBase64String(privateKeyBytes);
|
||||
var lines = new List<string> { "-----BEGIN FAKE RSA PRIVATE KEY-----" };
|
||||
for (var i = 0; i < base64.Length; i += 64)
|
||||
{
|
||||
lines.Add(base64.Substring(i, Math.Min(64, base64.Length - i)));
|
||||
}
|
||||
lines.Add("-----END FAKE RSA PRIVATE KEY-----");
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private static string ExportPublicKey(RSA rsa)
|
||||
{
|
||||
var parameters = rsa.ExportParameters(false);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
WriteString(writer, "ssh-rsa");
|
||||
WriteBigInteger(writer, parameters.Exponent!);
|
||||
WriteBigInteger(writer, parameters.Modulus!);
|
||||
|
||||
var keyBlob = Convert.ToBase64String(ms.ToArray());
|
||||
return $"ssh-rsa {keyBlob} test@seeder";
|
||||
}
|
||||
|
||||
private static string ComputeFingerprint(RSA rsa)
|
||||
{
|
||||
var parameters = rsa.ExportParameters(false);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
WriteString(writer, "ssh-rsa");
|
||||
WriteBigInteger(writer, parameters.Exponent!);
|
||||
WriteBigInteger(writer, parameters.Modulus!);
|
||||
|
||||
var hash = SHA256.HashData(ms.ToArray());
|
||||
return $"SHA256:{Convert.ToBase64String(hash).TrimEnd('=')}";
|
||||
}
|
||||
|
||||
private static void WriteString(BinaryWriter writer, string value)
|
||||
{
|
||||
var bytes = System.Text.Encoding.ASCII.GetBytes(value);
|
||||
WriteBytes(writer, bytes);
|
||||
}
|
||||
|
||||
private static void WriteBigInteger(BinaryWriter writer, byte[] value)
|
||||
{
|
||||
if (value.Length > 0 && (value[0] & 0x80) != 0)
|
||||
{
|
||||
var padded = new byte[value.Length + 1];
|
||||
padded[0] = 0;
|
||||
Array.Copy(value, 0, padded, 1, value.Length);
|
||||
WriteBytes(writer, padded);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteBytes(writer, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteBytes(BinaryWriter writer, byte[] bytes)
|
||||
{
|
||||
var length = BitConverter.GetBytes(bytes.Length);
|
||||
if (BitConverter.IsLittleEndian)
|
||||
{
|
||||
Array.Reverse(length);
|
||||
}
|
||||
writer.Write(length);
|
||||
writer.Write(bytes);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,37 @@ Foundation layer for all cipher generation—data and patterns that future ciphe
|
||||
|
||||
---
|
||||
|
||||
## Generators
|
||||
|
||||
Seeded, deterministic data generation for cipher content. Orchestrated by `GeneratorContext` which lazy-initializes on first access.
|
||||
|
||||
| Generator | Output | Method |
|
||||
|-----------|--------|--------|
|
||||
| `CipherUsernameGenerator` | Emails, handles | `GenerateByIndex(index, totalHint, domain)` |
|
||||
| `CardDataGenerator` | Card numbers, names | `GenerateByIndex(index)` |
|
||||
| `IdentityDataGenerator` | Full identity profiles | `GenerateByIndex(index)` |
|
||||
| `FolderNameGenerator` | Folder names | `GetFolderName(index)` |
|
||||
| `SecureNoteDataGenerator` | Note title + content | `GenerateByIndex(index)` |
|
||||
| `SshKeyDataGenerator` | RSA key pairs | `GenerateByIndex(index)` |
|
||||
|
||||
**Adding a generator:** See `GeneratorContext.cs` remarks for the 3-step pattern.
|
||||
|
||||
---
|
||||
|
||||
## Distributions
|
||||
|
||||
Percentage-based deterministic selection via `Distribution<T>.Select(index, total)`.
|
||||
|
||||
| Distribution | Values | Usage |
|
||||
|--------------|--------|-------|
|
||||
| `PasswordDistributions.Realistic` | 25% VeryWeak → 5% VeryStrong | Password strength mix |
|
||||
| `UsernameDistributions.Realistic` | 45% corporate, 30% personal, etc. | Username category mix |
|
||||
| `CipherTypeDistributions.Realistic` | 70% Login, 15% Card, etc. | Cipher type mix |
|
||||
| `UserStatusDistributions.Realistic` | 85% Confirmed, 5% each other | Org user status mix |
|
||||
| `FolderCountDistributions.Realistic` | 35% zero, 35% 1-3, etc. | Folders per user |
|
||||
|
||||
---
|
||||
|
||||
## Current Capabilities
|
||||
|
||||
### Login Ciphers
|
||||
@@ -37,9 +68,10 @@ Foundation layer for all cipher generation—data and patterns that future ciphe
|
||||
| Cipher Type | Data Needed | Status |
|
||||
| ----------- | ---------------------------------------------------- | ----------- |
|
||||
| Login | Companies, Names, Passwords, Patterns | ✅ Complete |
|
||||
| Card | Card networks, bank names, realistic numbers | ⬜ Planned |
|
||||
| Identity | Full identity profiles (name, address, SSN patterns) | ⬜ Planned |
|
||||
| SecureNote | Note templates, categories, content generators | ⬜ Planned |
|
||||
| Card | Card networks, bank names, realistic numbers | ✅ Complete |
|
||||
| Identity | Full identity profiles (name, address, SSN patterns) | ✅ Complete |
|
||||
| SecureNote | Note templates, categories, content generators | ✅ Complete |
|
||||
| SSH Key | RSA key pairs, fingerprints | ✅ Complete |
|
||||
|
||||
### Phase 2: Spec-Driven Generation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
namespace Bit.Seeder.Data.Static;
|
||||
|
||||
internal sealed record Company(
|
||||
string Domain,
|
||||
@@ -14,7 +14,7 @@ internal sealed record Company(
|
||||
/// </summary>
|
||||
internal static class Companies
|
||||
{
|
||||
public static readonly Company[] NorthAmerica =
|
||||
internal static readonly Company[] NorthAmerica =
|
||||
[
|
||||
// CRM & Sales
|
||||
new("salesforce.com", "Salesforce", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica),
|
||||
@@ -47,7 +47,7 @@ internal static class Companies
|
||||
new("spotify.com", "Spotify", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica)
|
||||
];
|
||||
|
||||
public static readonly Company[] Europe =
|
||||
internal static readonly Company[] Europe =
|
||||
[
|
||||
// Enterprise Software
|
||||
new("sap.com", "SAP", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe),
|
||||
@@ -72,7 +72,7 @@ internal static class Companies
|
||||
new("adyen.com", "Adyen", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.Europe)
|
||||
];
|
||||
|
||||
public static readonly Company[] AsiaPacific =
|
||||
internal static readonly Company[] AsiaPacific =
|
||||
[
|
||||
// Chinese Tech Giants
|
||||
new("alibaba.com", "Alibaba", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.AsiaPacific),
|
||||
@@ -96,9 +96,9 @@ internal static class Companies
|
||||
new("flipkart.com", "Flipkart", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific)
|
||||
];
|
||||
|
||||
public static readonly Company[] All = [.. NorthAmerica, .. Europe, .. AsiaPacific];
|
||||
internal static readonly Company[] All = [.. NorthAmerica, .. Europe, .. AsiaPacific];
|
||||
|
||||
public static Company[] Filter(
|
||||
internal static Company[] Filter(
|
||||
CompanyType? type = null,
|
||||
GeographicRegion? region = null,
|
||||
CompanyCategory? category = null)
|
||||
@@ -1,6 +1,6 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
namespace Bit.Seeder.Data.Static;
|
||||
|
||||
internal sealed record OrgUnit(string Name, string[]? SubUnits = null);
|
||||
|
||||
@@ -11,7 +11,7 @@ internal sealed record OrgStructure(OrgStructureModel Model, OrgUnit[] Units);
|
||||
/// </summary>
|
||||
internal static class OrgStructures
|
||||
{
|
||||
public static readonly OrgStructure Traditional = new(OrgStructureModel.Traditional,
|
||||
internal static readonly OrgStructure Traditional = new(OrgStructureModel.Traditional,
|
||||
[
|
||||
new("Executive", ["CEO Office", "Strategy", "Board Relations"]),
|
||||
new("Finance", ["Accounting", "FP&A", "Treasury", "Tax", "Audit"]),
|
||||
@@ -27,7 +27,7 @@ internal static class OrgStructures
|
||||
new("Product", ["Product Management", "UX Design", "User Research", "Product Analytics"])
|
||||
]);
|
||||
|
||||
public static readonly OrgStructure Spotify = new(OrgStructureModel.Spotify,
|
||||
internal static readonly OrgStructure Spotify = new(OrgStructureModel.Spotify,
|
||||
[
|
||||
// Tribes
|
||||
new("Payments Tribe", ["Checkout Squad", "Fraud Prevention Squad", "Billing Squad", "Payment Methods Squad"]),
|
||||
@@ -48,7 +48,7 @@ internal static class OrgStructures
|
||||
new("Developer Experience Guild")
|
||||
]);
|
||||
|
||||
public static readonly OrgStructure Modern = new(OrgStructureModel.Modern,
|
||||
internal static readonly OrgStructure Modern = new(OrgStructureModel.Modern,
|
||||
[
|
||||
// Feature Teams
|
||||
new("Auth Team", ["Identity", "SSO", "MFA", "Passwordless"]),
|
||||
@@ -72,9 +72,9 @@ internal static class OrgStructures
|
||||
new("Quality", ["Testing Strategy", "Release Quality", "Production Health"])
|
||||
]);
|
||||
|
||||
public static readonly OrgStructure[] All = [Traditional, Spotify, Modern];
|
||||
internal static readonly OrgStructure[] All = [Traditional, Spotify, Modern];
|
||||
|
||||
public static OrgStructure GetStructure(OrgStructureModel model) => model switch
|
||||
internal static OrgStructure GetStructure(OrgStructureModel model) => model switch
|
||||
{
|
||||
OrgStructureModel.Traditional => Traditional,
|
||||
OrgStructureModel.Spotify => Spotify,
|
||||
@@ -1,6 +1,7 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
namespace Bit.Seeder.Data.Static;
|
||||
|
||||
/// <summary>
|
||||
/// Password collections by zxcvbn strength level (0-4) for realistic test data.
|
||||
@@ -10,7 +11,7 @@ internal static class Passwords
|
||||
/// <summary>
|
||||
/// Score 0 - Too guessable: keyboard walks, simple sequences, single words.
|
||||
/// </summary>
|
||||
public static readonly string[] VeryWeak =
|
||||
internal static readonly string[] VeryWeak =
|
||||
[
|
||||
"password", "123456", "qwerty", "abc123", "letmein",
|
||||
"admin", "welcome", "monkey", "dragon", "master",
|
||||
@@ -23,7 +24,7 @@ internal static class Passwords
|
||||
/// <summary>
|
||||
/// Score 1 - Very guessable: common patterns with minor complexity.
|
||||
/// </summary>
|
||||
public static readonly string[] Weak =
|
||||
internal static readonly string[] Weak =
|
||||
[
|
||||
"Password1", "Qwerty123", "Welcome1", "Admin123", "Letmein1",
|
||||
"Dragon123", "Master123", "Shadow123", "Michael1", "Jennifer1",
|
||||
@@ -36,7 +37,7 @@ internal static class Passwords
|
||||
/// <summary>
|
||||
/// Score 2 - Somewhat guessable: meets basic complexity but predictable patterns.
|
||||
/// </summary>
|
||||
public static readonly string[] Fair =
|
||||
internal static readonly string[] Fair =
|
||||
[
|
||||
"Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!",
|
||||
"Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login2024!",
|
||||
@@ -49,7 +50,7 @@ internal static class Passwords
|
||||
/// <summary>
|
||||
/// Score 3 - Safely unguessable: good entropy, mixed character types.
|
||||
/// </summary>
|
||||
public static readonly string[] Strong =
|
||||
internal static readonly string[] Strong =
|
||||
[
|
||||
"k#9Lm$vQ2@xR7nP!", "Yx8&mK3$pL5#wQ9@", "Nv4%jH7!bT2@sF6#",
|
||||
"Rm9#cX5$gW1@zK8!", "Qp3@hY6#nL9$tB2!", "Wz7!mF4@kS8#xC1$",
|
||||
@@ -64,7 +65,7 @@ internal static class Passwords
|
||||
/// <summary>
|
||||
/// Score 4 - Very unguessable: high entropy, long passphrases, random strings.
|
||||
/// </summary>
|
||||
public static readonly string[] VeryStrong =
|
||||
internal static readonly string[] VeryStrong =
|
||||
[
|
||||
"Kx9#mL4$pQ7@wR2!vN5hT8", "Yz3@hT8#bF1$cS6!nM9wK4", "Wv5!rK2@jG9#tX4$mL7nB3",
|
||||
"Qn7$sB3@yH6#pC1!zF8kW2", "Tm2@xD5#kW9$vL4!rJ7gN1", "Pf4!nC8@bR3#yL6$hS9mV2",
|
||||
@@ -77,71 +78,24 @@ internal static class Passwords
|
||||
];
|
||||
|
||||
/// <summary>All passwords combined for mixed/random selection.</summary>
|
||||
public static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong];
|
||||
internal static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong];
|
||||
|
||||
/// <summary>
|
||||
/// Realistic distribution based on breach data and security research.
|
||||
/// Sources: NordPass annual reports, Have I Been Pwned analysis, academic studies.
|
||||
/// Distribution: 25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong
|
||||
/// </summary>
|
||||
private static readonly (PasswordStrength Strength, int CumulativePercent)[] RealisticDistribution =
|
||||
[
|
||||
(PasswordStrength.VeryWeak, 25), // 25% - most common breached passwords
|
||||
(PasswordStrength.Weak, 55), // 30% - simple patterns with numbers
|
||||
(PasswordStrength.Fair, 80), // 25% - meets basic requirements
|
||||
(PasswordStrength.Strong, 95), // 15% - good passwords
|
||||
(PasswordStrength.VeryStrong, 100) // 5% - password manager users
|
||||
];
|
||||
|
||||
public static string[] GetByStrength(PasswordStrength strength) => strength switch
|
||||
internal static string[] GetByStrength(PasswordStrength strength) => strength switch
|
||||
{
|
||||
PasswordStrength.VeryWeak => VeryWeak,
|
||||
PasswordStrength.Weak => Weak,
|
||||
PasswordStrength.Fair => Fair,
|
||||
PasswordStrength.Strong => Strong,
|
||||
PasswordStrength.VeryStrong => VeryStrong,
|
||||
PasswordStrength.Realistic => All, // For direct array access, use All
|
||||
_ => Strong
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets a password with realistic strength distribution.
|
||||
/// Uses deterministic selection based on index for reproducible test data.
|
||||
/// Gets a password using the provided distribution to select strength.
|
||||
/// </summary>
|
||||
public static string GetRealisticPassword(int index)
|
||||
internal static string GetPassword(int index, int total, Distribution<PasswordStrength> distribution)
|
||||
{
|
||||
var strength = GetRealisticStrength(index);
|
||||
var passwords = GetByStrength(strength);
|
||||
return passwords[index % passwords.Length];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a password strength following realistic distribution.
|
||||
/// Deterministic based on index for reproducible results.
|
||||
/// </summary>
|
||||
public static PasswordStrength GetRealisticStrength(int index)
|
||||
{
|
||||
// Use modulo 100 for percentage-based bucket selection
|
||||
var bucket = index % 100;
|
||||
|
||||
foreach (var (strength, cumulativePercent) in RealisticDistribution)
|
||||
{
|
||||
if (bucket < cumulativePercent)
|
||||
{
|
||||
return strength;
|
||||
}
|
||||
}
|
||||
|
||||
return PasswordStrength.Strong; // Fallback
|
||||
}
|
||||
|
||||
public static string GetPassword(PasswordStrength strength, int index)
|
||||
{
|
||||
if (strength == PasswordStrength.Realistic)
|
||||
{
|
||||
return GetRealisticPassword(index);
|
||||
}
|
||||
|
||||
var strength = distribution.Select(index, total);
|
||||
var passwords = GetByStrength(strength);
|
||||
return passwords[index % passwords.Length];
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
|
||||
internal sealed record UsernamePattern(
|
||||
UsernamePatternType Type,
|
||||
string FormatDescription,
|
||||
Func<string, string, string, string> Generate);
|
||||
|
||||
/// <summary>
|
||||
/// Username pattern implementations for different email conventions.
|
||||
/// </summary>
|
||||
internal static class UsernamePatterns
|
||||
{
|
||||
public static readonly UsernamePattern FirstDotLast = new(
|
||||
UsernamePatternType.FirstDotLast,
|
||||
"first.last@domain",
|
||||
(first, last, domain) => $"{first.ToLowerInvariant()}.{last.ToLowerInvariant()}@{domain}");
|
||||
|
||||
public static readonly UsernamePattern FDotLast = new(
|
||||
UsernamePatternType.FDotLast,
|
||||
"f.last@domain",
|
||||
(first, last, domain) => $"{char.ToLowerInvariant(first[0])}.{last.ToLowerInvariant()}@{domain}");
|
||||
|
||||
public static readonly UsernamePattern FLast = new(
|
||||
UsernamePatternType.FLast,
|
||||
"flast@domain",
|
||||
(first, last, domain) => $"{char.ToLowerInvariant(first[0])}{last.ToLowerInvariant()}@{domain}");
|
||||
|
||||
public static readonly UsernamePattern LastDotFirst = new(
|
||||
UsernamePatternType.LastDotFirst,
|
||||
"last.first@domain",
|
||||
(first, last, domain) => $"{last.ToLowerInvariant()}.{first.ToLowerInvariant()}@{domain}");
|
||||
|
||||
public static readonly UsernamePattern First_Last = new(
|
||||
UsernamePatternType.First_Last,
|
||||
"first_last@domain",
|
||||
(first, last, domain) => $"{first.ToLowerInvariant()}_{last.ToLowerInvariant()}@{domain}");
|
||||
|
||||
public static readonly UsernamePattern LastFirst = new(
|
||||
UsernamePatternType.LastFirst,
|
||||
"lastf@domain",
|
||||
(first, last, domain) => $"{last.ToLowerInvariant()}{char.ToLowerInvariant(first[0])}@{domain}");
|
||||
|
||||
public static readonly UsernamePattern[] All = [FirstDotLast, FDotLast, FLast, LastDotFirst, First_Last, LastFirst];
|
||||
|
||||
public static UsernamePattern GetPattern(UsernamePatternType type) => type switch
|
||||
{
|
||||
UsernamePatternType.FirstDotLast => FirstDotLast,
|
||||
UsernamePatternType.FDotLast => FDotLast,
|
||||
UsernamePatternType.FLast => FLast,
|
||||
UsernamePatternType.LastDotFirst => LastDotFirst,
|
||||
UsernamePatternType.First_Last => First_Last,
|
||||
UsernamePatternType.LastFirst => LastFirst,
|
||||
_ => FirstDotLast
|
||||
};
|
||||
}
|
||||
29
util/Seeder/Factories/CardCipherSeeder.cs
Normal file
29
util/Seeder/Factories/CardCipherSeeder.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
internal static class CardCipherSeeder
|
||||
{
|
||||
internal static Cipher Create(
|
||||
string encryptionKey,
|
||||
string name,
|
||||
CardViewDto card,
|
||||
Guid? organizationId = null,
|
||||
Guid? userId = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
Type = CipherTypes.Card,
|
||||
Card = card
|
||||
};
|
||||
|
||||
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
|
||||
return CipherEncryption.CreateEntity(encrypted, encrypted.ToCardData(), CipherType.Card, organizationId, userId);
|
||||
}
|
||||
}
|
||||
55
util/Seeder/Factories/CipherEncryption.cs
Normal file
55
util/Seeder/Factories/CipherEncryption.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
internal static class CipherEncryption
|
||||
{
|
||||
private static readonly JsonSerializerOptions SdkJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions ServerJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
internal static EncryptedCipherDto Encrypt(CipherViewDto cipherView, string keyBase64)
|
||||
{
|
||||
var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions);
|
||||
var encryptedJson = RustSdkService.EncryptCipher(viewJson, keyBase64);
|
||||
return JsonSerializer.Deserialize<EncryptedCipherDto>(encryptedJson, SdkJsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse encrypted cipher");
|
||||
}
|
||||
|
||||
internal static Cipher CreateEntity(
|
||||
EncryptedCipherDto encrypted,
|
||||
object data,
|
||||
CipherType cipherType,
|
||||
Guid? organizationId,
|
||||
Guid? userId)
|
||||
{
|
||||
var dataJson = JsonSerializer.Serialize(data, ServerJsonOptions);
|
||||
|
||||
return new Cipher
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organizationId,
|
||||
UserId = userId,
|
||||
Type = cipherType,
|
||||
Data = dataJson,
|
||||
Key = encrypted.Key,
|
||||
Reprompt = (CipherRepromptType?)encrypted.Reprompt,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Creates encrypted ciphers for seeding vaults via the Rust SDK.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Supported cipher types:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Login - <see cref="CreateOrganizationLoginCipher"/></description></item>
|
||||
/// </list>
|
||||
/// Future: Card, Identity, SecureNote will follow the same pattern—public Create method + private Transform method.
|
||||
/// </remarks>
|
||||
public class CipherSeeder
|
||||
{
|
||||
private static readonly JsonSerializerOptions SdkJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions ServerJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public static Cipher CreateOrganizationLoginCipher(
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
string name,
|
||||
string? username = null,
|
||||
string? password = null,
|
||||
string? uri = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
Type = CipherTypes.Login,
|
||||
Login = new LoginViewDto
|
||||
{
|
||||
Username = username,
|
||||
Password = password,
|
||||
Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }]
|
||||
}
|
||||
};
|
||||
|
||||
return EncryptAndTransform(cipherView, orgKeyBase64, organizationId);
|
||||
}
|
||||
|
||||
public static Cipher CreateOrganizationLoginCipherWithFields(
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
string name,
|
||||
string? username,
|
||||
string? password,
|
||||
string? uri,
|
||||
IEnumerable<(string name, string value, int type)> fields)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Type = CipherTypes.Login,
|
||||
Login = new LoginViewDto
|
||||
{
|
||||
Username = username,
|
||||
Password = password,
|
||||
Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }]
|
||||
},
|
||||
Fields = fields.Select(f => new FieldViewDto
|
||||
{
|
||||
Name = f.name,
|
||||
Value = f.value,
|
||||
Type = f.type
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return EncryptAndTransform(cipherView, orgKeyBase64, organizationId);
|
||||
}
|
||||
|
||||
private static Cipher EncryptAndTransform(CipherViewDto cipherView, string keyBase64, Guid organizationId)
|
||||
{
|
||||
var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions);
|
||||
var encryptedJson = RustSdkService.EncryptCipher(viewJson, keyBase64);
|
||||
|
||||
var encryptedDto = JsonSerializer.Deserialize<EncryptedCipherDto>(encryptedJson, SdkJsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse encrypted cipher");
|
||||
|
||||
return TransformLoginToServerCipher(encryptedDto, organizationId);
|
||||
}
|
||||
|
||||
private static Cipher TransformLoginToServerCipher(EncryptedCipherDto encrypted, Guid organizationId)
|
||||
{
|
||||
var loginData = new CipherLoginData
|
||||
{
|
||||
Name = encrypted.Name,
|
||||
Notes = encrypted.Notes,
|
||||
Username = encrypted.Login?.Username,
|
||||
Password = encrypted.Login?.Password,
|
||||
Totp = encrypted.Login?.Totp,
|
||||
PasswordRevisionDate = encrypted.Login?.PasswordRevisionDate,
|
||||
Uris = encrypted.Login?.Uris?.Select(u => new CipherLoginData.CipherLoginUriData
|
||||
{
|
||||
Uri = u.Uri,
|
||||
UriChecksum = u.UriChecksum,
|
||||
Match = u.Match.HasValue ? (UriMatchType?)u.Match : null
|
||||
}),
|
||||
Fields = encrypted.Fields?.Select(f => new CipherFieldData
|
||||
{
|
||||
Name = f.Name,
|
||||
Value = f.Value,
|
||||
Type = (FieldType)f.Type,
|
||||
LinkedId = f.LinkedId
|
||||
})
|
||||
};
|
||||
|
||||
var dataJson = JsonSerializer.Serialize(loginData, ServerJsonOptions);
|
||||
|
||||
return new Cipher
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organizationId,
|
||||
UserId = null,
|
||||
Type = CipherType.Login,
|
||||
Data = dataJson,
|
||||
Key = encrypted.Key,
|
||||
Reprompt = (CipherRepromptType?)encrypted.Reprompt,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
23
util/Seeder/Factories/CollectionUserSeeder.cs
Normal file
23
util/Seeder/Factories/CollectionUserSeeder.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
internal static class CollectionUserSeeder
|
||||
{
|
||||
internal static CollectionUser Create(
|
||||
Guid collectionId,
|
||||
Guid organizationUserId,
|
||||
bool readOnly = false,
|
||||
bool hidePasswords = false,
|
||||
bool manage = false)
|
||||
{
|
||||
return new CollectionUser
|
||||
{
|
||||
CollectionId = collectionId,
|
||||
OrganizationUserId = organizationUserId,
|
||||
ReadOnly = readOnly,
|
||||
HidePasswords = hidePasswords,
|
||||
Manage = manage
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,9 @@ using Bit.RustSDK;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating Folder entities with encrypted names.
|
||||
/// Folders are per-user constructs encrypted with the user's symmetric key.
|
||||
/// </summary>
|
||||
internal sealed class FolderSeeder
|
||||
internal static class FolderSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a folder with an encrypted name.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user who owns this folder.</param>
|
||||
/// <param name="userKeyBase64">The user's symmetric key (not org key).</param>
|
||||
/// <param name="name">The plaintext folder name to encrypt.</param>
|
||||
public static Folder CreateFolder(Guid userId, string userKeyBase64, string name)
|
||||
internal static Folder Create(Guid userId, string userKeyBase64, string name)
|
||||
{
|
||||
return new Folder
|
||||
{
|
||||
|
||||
@@ -3,18 +3,9 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Creates groups and group-user relationships for seeding.
|
||||
/// </summary>
|
||||
public static class GroupSeeder
|
||||
internal static class GroupSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a group entity for an organization.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The organization ID.</param>
|
||||
/// <param name="name">The group name.</param>
|
||||
/// <returns>A new Group entity (not persisted).</returns>
|
||||
public static Group CreateGroup(Guid organizationId, string name)
|
||||
internal static Group Create(Guid organizationId, string name)
|
||||
{
|
||||
return new Group
|
||||
{
|
||||
@@ -23,19 +14,4 @@ public static class GroupSeeder
|
||||
Name = name
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a group-user relationship entity.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The group ID.</param>
|
||||
/// <param name="organizationUserId">The organization user ID.</param>
|
||||
/// <returns>A new GroupUser entity (not persisted).</returns>
|
||||
public static GroupUser CreateGroupUser(Guid groupId, Guid organizationUserId)
|
||||
{
|
||||
return new GroupUser
|
||||
{
|
||||
GroupId = groupId,
|
||||
OrganizationUserId = organizationUserId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
15
util/Seeder/Factories/GroupUserSeeder.cs
Normal file
15
util/Seeder/Factories/GroupUserSeeder.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
internal static class GroupUserSeeder
|
||||
{
|
||||
internal static GroupUser Create(Guid groupId, Guid organizationUserId)
|
||||
{
|
||||
return new GroupUser
|
||||
{
|
||||
GroupId = groupId,
|
||||
OrganizationUserId = organizationUserId
|
||||
};
|
||||
}
|
||||
}
|
||||
29
util/Seeder/Factories/IdentityCipherSeeder.cs
Normal file
29
util/Seeder/Factories/IdentityCipherSeeder.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
internal static class IdentityCipherSeeder
|
||||
{
|
||||
internal static Cipher Create(
|
||||
string encryptionKey,
|
||||
string name,
|
||||
IdentityViewDto identity,
|
||||
Guid? organizationId = null,
|
||||
Guid? userId = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
Type = CipherTypes.Identity,
|
||||
Identity = identity
|
||||
};
|
||||
|
||||
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
|
||||
return CipherEncryption.CreateEntity(encrypted, encrypted.ToIdentityData(), CipherType.Identity, organizationId, userId);
|
||||
}
|
||||
}
|
||||
43
util/Seeder/Factories/LoginCipherSeeder.cs
Normal file
43
util/Seeder/Factories/LoginCipherSeeder.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
internal static class LoginCipherSeeder
|
||||
{
|
||||
internal static Cipher Create(
|
||||
string encryptionKey,
|
||||
string name,
|
||||
Guid? organizationId = null,
|
||||
Guid? userId = null,
|
||||
string? username = null,
|
||||
string? password = null,
|
||||
string? uri = null,
|
||||
string? notes = null,
|
||||
IEnumerable<(string name, string value, int type)>? fields = null)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
Type = CipherTypes.Login,
|
||||
Login = new LoginViewDto
|
||||
{
|
||||
Username = username,
|
||||
Password = password,
|
||||
Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }]
|
||||
},
|
||||
Fields = fields?.Select(f => new FieldViewDto
|
||||
{
|
||||
Name = f.name,
|
||||
Value = f.value,
|
||||
Type = f.type
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
|
||||
return CipherEncryption.CreateEntity(encrypted, encrypted.ToLoginData(), CipherType.Login, organizationId, userId);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,15 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Creates organization domain entities for seeding.
|
||||
/// </summary>
|
||||
public static class OrganizationDomainSeeder
|
||||
internal static class OrganizationDomainSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a verified organization domain entity.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The organization ID.</param>
|
||||
/// <param name="domainName">The domain name (e.g., "example.com").</param>
|
||||
/// <returns>A new verified OrganizationDomain entity (not persisted).</returns>
|
||||
public static OrganizationDomain CreateVerifiedDomain(Guid organizationId, string domainName)
|
||||
internal static OrganizationDomain Create(Guid organizationId, string domainName)
|
||||
{
|
||||
var domain = new OrganizationDomain
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organizationId,
|
||||
DomainName = domainName,
|
||||
Txt = Guid.NewGuid().ToString("N"),
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
public class OrganizationSeeder
|
||||
internal static class OrganizationSeeder
|
||||
{
|
||||
public static Organization CreateEnterprise(string name, string domain, int seats, string? publicKey = null, string? privateKey = null)
|
||||
internal static Organization Create(string name, string domain, int seats, string? publicKey = null, string? privateKey = null)
|
||||
{
|
||||
return new Organization
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
Name = name,
|
||||
BillingEmail = $"billing@{domain}",
|
||||
Plan = "Enterprise (Annually)",
|
||||
@@ -46,13 +47,13 @@ public class OrganizationSeeder
|
||||
}
|
||||
}
|
||||
|
||||
public static class OrganizationExtensions
|
||||
internal static class OrganizationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an OrganizationUser with a dynamically provided encrypted org key.
|
||||
/// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey().
|
||||
/// </summary>
|
||||
public static OrganizationUser CreateOrganizationUserWithKey(
|
||||
internal static OrganizationUser CreateOrganizationUserWithKey(
|
||||
this Organization organization,
|
||||
User user,
|
||||
OrganizationUserType type,
|
||||
@@ -64,7 +65,7 @@ public static class OrganizationExtensions
|
||||
|
||||
return new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organization.Id,
|
||||
UserId = shouldLinkUserId ? user.Id : null,
|
||||
Email = shouldLinkUserId ? null : user.Email,
|
||||
|
||||
28
util/Seeder/Factories/SecureNoteCipherSeeder.cs
Normal file
28
util/Seeder/Factories/SecureNoteCipherSeeder.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
internal static class SecureNoteCipherSeeder
|
||||
{
|
||||
internal static Cipher Create(
|
||||
string encryptionKey,
|
||||
string name,
|
||||
Guid? organizationId = null,
|
||||
Guid? userId = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
Type = CipherTypes.SecureNote,
|
||||
SecureNote = new SecureNoteViewDto { Type = 0 }
|
||||
};
|
||||
|
||||
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
|
||||
return CipherEncryption.CreateEntity(encrypted, encrypted.ToSecureNoteData(), CipherType.SecureNote, organizationId, userId);
|
||||
}
|
||||
}
|
||||
29
util/Seeder/Factories/SshKeyCipherSeeder.cs
Normal file
29
util/Seeder/Factories/SshKeyCipherSeeder.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
internal static class SshKeyCipherSeeder
|
||||
{
|
||||
internal static Cipher Create(
|
||||
string encryptionKey,
|
||||
string name,
|
||||
SshKeyViewDto sshKey,
|
||||
Guid? organizationId = null,
|
||||
Guid? userId = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
Type = CipherTypes.SshKey,
|
||||
SshKey = sshKey
|
||||
};
|
||||
|
||||
var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey);
|
||||
return CipherEncryption.CreateEntity(encrypted, encrypted.ToSshKeyData(), CipherType.SSHKey, organizationId, userId);
|
||||
}
|
||||
}
|
||||
@@ -2,124 +2,46 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
public struct UserData
|
||||
internal static class UserSeeder
|
||||
{
|
||||
public string Email;
|
||||
}
|
||||
internal const string DefaultPassword = "asdfasdfasdf";
|
||||
|
||||
public class UserSeeder(IPasswordHasher<Bit.Core.Entities.User> passwordHasher, MangleId mangleId)
|
||||
{
|
||||
private string MangleEmail(string email)
|
||||
internal static User Create(
|
||||
string email,
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
IManglerService manglerService,
|
||||
bool emailVerified = true,
|
||||
bool premium = false,
|
||||
UserKeys? keys = null)
|
||||
{
|
||||
return $"{mangleId}+{email}";
|
||||
}
|
||||
// When keys are provided, caller owns email/key consistency - don't mangle
|
||||
var mangledEmail = keys == null ? manglerService.Mangle(email) : email;
|
||||
|
||||
public User CreateUser(string email, bool emailVerified = false, bool premium = false)
|
||||
{
|
||||
email = MangleEmail(email);
|
||||
var keys = RustSdkService.GenerateUserKeys(email, DefaultPassword);
|
||||
keys ??= RustSdkService.GenerateUserKeys(mangledEmail, DefaultPassword);
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
Email = email,
|
||||
Email = mangledEmail,
|
||||
EmailVerified = emailVerified,
|
||||
MasterPassword = null,
|
||||
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
|
||||
Key = keys.EncryptedUserKey,
|
||||
PublicKey = keys.PublicKey,
|
||||
PrivateKey = keys.PrivateKey,
|
||||
Premium = premium,
|
||||
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 5_000,
|
||||
};
|
||||
|
||||
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default test password used for all seeded users.
|
||||
/// </summary>
|
||||
public const string DefaultPassword = "asdfasdfasdf";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user with hardcoded keys (no email mangling, no SDK calls).
|
||||
/// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs.
|
||||
/// </summary>
|
||||
public static User CreateUserNoMangle(string email)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = email,
|
||||
MasterPassword = "AQAAAAIAAYagAAAAEBATmF66OHMpHuHKc1CsGZQ1ltHUHyhYK+7e4re3bVFi16SOpLpDfzdFswnvFQs2Rg==",
|
||||
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
|
||||
Key = "2.z/eLKFhd62qy9RzXu3UHgA==|fF6yNupiCIguFKSDTB3DoqcGR0Xu4j+9VlnMyT5F3PaWIcGhzQKIzxdB95nhslaCQv3c63M7LBnvzVo1J9SUN85RMbP/57bP1HvhhU1nvL8=|IQPtf8v7k83MFZEhazSYXSdu98BBU5rqtvC4keVWyHM=",
|
||||
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Ww2chogqCpaAR7Uw448am4b7vDFXiM5kXjFlGfXBlrAdAqTTggEvTDlMNYqPlCo+mBM6iFmTTUY9rpZBvFskMnKvsvpJ47/fehAH2o2e3Ulv/5NFevaVCMCmpkBDtbMbO1A4a3btdRtCP8DsKWMefHauEpaoLxNTLWnOIZVfCMjsSgx2EvULHAZPTtbFwm4+UVKniM4ds4jvOsD85h4jn2aLs/jWJXFfxN8iVSqEqpC2TBvsPdyHb49xQoWWfF0Z6BiNqeNGKEU9Uos1pjL+kzhEzzSpH31PZT/ufJ/oo4+93wrUt57hb6f0jxiXhwd5yQ+9F6wVwpbfkq0IwhjOwIDAQAB",
|
||||
PrivateKey = "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=",
|
||||
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 600_000,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user with SDK-generated cryptographic keys (no email mangling).
|
||||
/// The user can log in with email and password = "asdfasdfasdf".
|
||||
/// </summary>
|
||||
public static User CreateUserWithSdkKeys(
|
||||
string email,
|
||||
IPasswordHasher<User> passwordHasher)
|
||||
{
|
||||
var keys = RustSdkService.GenerateUserKeys(email, DefaultPassword);
|
||||
return CreateUserFromKeys(email, keys, passwordHasher);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user from pre-generated keys (no email mangling).
|
||||
/// Use this when you need to retain the user's symmetric key for subsequent operations
|
||||
/// (e.g., encrypting folders with the user's key).
|
||||
/// </summary>
|
||||
public static User CreateUserFromKeys(
|
||||
string email,
|
||||
UserKeys keys,
|
||||
IPasswordHasher<User> passwordHasher)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
Email = email,
|
||||
EmailVerified = true,
|
||||
MasterPassword = null,
|
||||
SecurityStamp = Guid.NewGuid().ToString(),
|
||||
Key = keys.EncryptedUserKey,
|
||||
PublicKey = keys.PublicKey,
|
||||
PrivateKey = keys.PrivateKey,
|
||||
Premium = false,
|
||||
Premium = premium,
|
||||
ApiKey = Guid.NewGuid().ToString("N")[..30],
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 5_000,
|
||||
KdfIterations = 5_000
|
||||
};
|
||||
|
||||
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public Dictionary<string, string?> GetMangleMap(User user, UserData expectedUserData)
|
||||
{
|
||||
var mangleMap = new Dictionary<string, string?>
|
||||
{
|
||||
{ expectedUserData.Email, user.Email },
|
||||
};
|
||||
|
||||
return mangleMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Bit.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for generating unique identifier suffixes to prevent collisions in test data.
|
||||
/// "Mangling" adds a random suffix to test data identifiers (usernames, emails, org names, etc.)
|
||||
/// to ensure uniqueness across multiple test runs and parallel test executions.
|
||||
/// </summary>
|
||||
public class MangleId
|
||||
{
|
||||
public readonly string Value;
|
||||
|
||||
public MangleId()
|
||||
{
|
||||
// Generate a short random string (6 char) to use as the mangle ID
|
||||
Value = Random.Shared.NextInt64().ToString("x", CultureInfo.InvariantCulture).Substring(0, 8);
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
@@ -32,16 +32,16 @@ public class CipherViewDto
|
||||
public LoginViewDto? Login { get; set; }
|
||||
|
||||
[JsonPropertyName("identity")]
|
||||
public object? Identity { get; set; }
|
||||
public IdentityViewDto? Identity { get; set; }
|
||||
|
||||
[JsonPropertyName("card")]
|
||||
public object? Card { get; set; }
|
||||
public CardViewDto? Card { get; set; }
|
||||
|
||||
[JsonPropertyName("secureNote")]
|
||||
public object? SecureNote { get; set; }
|
||||
public SecureNoteViewDto? SecureNote { get; set; }
|
||||
|
||||
[JsonPropertyName("sshKey")]
|
||||
public object? SshKey { get; set; }
|
||||
public SshKeyViewDto? SshKey { get; set; }
|
||||
|
||||
[JsonPropertyName("favorite")]
|
||||
public bool Favorite { get; set; }
|
||||
@@ -151,3 +151,112 @@ public static class RepromptTypes
|
||||
public const int None = 0;
|
||||
public const int Password = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Card cipher data for SDK encryption. Uses record for composition via `with` expressions.
|
||||
/// </summary>
|
||||
public record CardViewDto
|
||||
{
|
||||
[JsonPropertyName("cardholderName")]
|
||||
public string? CardholderName { get; init; }
|
||||
|
||||
[JsonPropertyName("brand")]
|
||||
public string? Brand { get; init; }
|
||||
|
||||
[JsonPropertyName("number")]
|
||||
public string? Number { get; init; }
|
||||
|
||||
[JsonPropertyName("expMonth")]
|
||||
public string? ExpMonth { get; init; }
|
||||
|
||||
[JsonPropertyName("expYear")]
|
||||
public string? ExpYear { get; init; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity cipher data for SDK encryption. Uses record for composition via `with` expressions.
|
||||
/// </summary>
|
||||
public record IdentityViewDto
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
[JsonPropertyName("firstName")]
|
||||
public string? FirstName { get; init; }
|
||||
|
||||
[JsonPropertyName("middleName")]
|
||||
public string? MiddleName { get; init; }
|
||||
|
||||
[JsonPropertyName("lastName")]
|
||||
public string? LastName { get; init; }
|
||||
|
||||
[JsonPropertyName("address1")]
|
||||
public string? Address1 { get; init; }
|
||||
|
||||
[JsonPropertyName("address2")]
|
||||
public string? Address2 { get; init; }
|
||||
|
||||
[JsonPropertyName("address3")]
|
||||
public string? Address3 { get; init; }
|
||||
|
||||
[JsonPropertyName("city")]
|
||||
public string? City { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string? State { get; init; }
|
||||
|
||||
[JsonPropertyName("postalCode")]
|
||||
public string? PostalCode { get; init; }
|
||||
|
||||
[JsonPropertyName("country")]
|
||||
public string? Country { get; init; }
|
||||
|
||||
[JsonPropertyName("company")]
|
||||
public string? Company { get; init; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; init; }
|
||||
|
||||
[JsonPropertyName("phone")]
|
||||
public string? Phone { get; init; }
|
||||
|
||||
[JsonPropertyName("ssn")]
|
||||
public string? SSN { get; init; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string? Username { get; init; }
|
||||
|
||||
[JsonPropertyName("passportNumber")]
|
||||
public string? PassportNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("licenseNumber")]
|
||||
public string? LicenseNumber { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SecureNote cipher data for SDK encryption. Minimal structure - content is in cipher.Notes.
|
||||
/// </summary>
|
||||
public record SecureNoteViewDto
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public int Type { get; init; } = 0; // Generic = 0
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SSH Key cipher data for SDK encryption. Uses record for composition via `with` expressions.
|
||||
/// </summary>
|
||||
public record SshKeyViewDto
|
||||
{
|
||||
[JsonPropertyName("privateKey")]
|
||||
public string? PrivateKey { get; init; }
|
||||
|
||||
[JsonPropertyName("publicKey")]
|
||||
public string? PublicKey { get; init; }
|
||||
|
||||
/// <summary>SDK expects "fingerprint" field name.</summary>
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ public class EncryptedCipherDto
|
||||
[JsonPropertyName("login")]
|
||||
public EncryptedLoginDto? Login { get; set; }
|
||||
|
||||
[JsonPropertyName("card")]
|
||||
public EncryptedCardDto? Card { get; set; }
|
||||
|
||||
[JsonPropertyName("identity")]
|
||||
public EncryptedIdentityDto? Identity { get; set; }
|
||||
|
||||
[JsonPropertyName("secureNote")]
|
||||
public EncryptedSecureNoteDto? SecureNote { get; set; }
|
||||
|
||||
[JsonPropertyName("sshKey")]
|
||||
public EncryptedSshKeyDto? SshKey { get; set; }
|
||||
|
||||
[JsonPropertyName("fields")]
|
||||
public List<EncryptedFieldDto>? Fields { get; set; }
|
||||
|
||||
@@ -94,3 +106,99 @@ public class EncryptedFieldDto
|
||||
[JsonPropertyName("linkedId")]
|
||||
public int? LinkedId { get; set; }
|
||||
}
|
||||
|
||||
public class EncryptedCardDto
|
||||
{
|
||||
[JsonPropertyName("cardholderName")]
|
||||
public string? CardholderName { get; set; }
|
||||
|
||||
[JsonPropertyName("brand")]
|
||||
public string? Brand { get; set; }
|
||||
|
||||
[JsonPropertyName("number")]
|
||||
public string? Number { get; set; }
|
||||
|
||||
[JsonPropertyName("expMonth")]
|
||||
public string? ExpMonth { get; set; }
|
||||
|
||||
[JsonPropertyName("expYear")]
|
||||
public string? ExpYear { get; set; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; set; }
|
||||
}
|
||||
|
||||
public class EncryptedIdentityDto
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("firstName")]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[JsonPropertyName("middleName")]
|
||||
public string? MiddleName { get; set; }
|
||||
|
||||
[JsonPropertyName("lastName")]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[JsonPropertyName("address1")]
|
||||
public string? Address1 { get; set; }
|
||||
|
||||
[JsonPropertyName("address2")]
|
||||
public string? Address2 { get; set; }
|
||||
|
||||
[JsonPropertyName("address3")]
|
||||
public string? Address3 { get; set; }
|
||||
|
||||
[JsonPropertyName("city")]
|
||||
public string? City { get; set; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string? State { get; set; }
|
||||
|
||||
[JsonPropertyName("postalCode")]
|
||||
public string? PostalCode { get; set; }
|
||||
|
||||
[JsonPropertyName("country")]
|
||||
public string? Country { get; set; }
|
||||
|
||||
[JsonPropertyName("company")]
|
||||
public string? Company { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[JsonPropertyName("phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[JsonPropertyName("ssn")]
|
||||
public string? SSN { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string? Username { get; set; }
|
||||
|
||||
[JsonPropertyName("passportNumber")]
|
||||
public string? PassportNumber { get; set; }
|
||||
|
||||
[JsonPropertyName("licenseNumber")]
|
||||
public string? LicenseNumber { get; set; }
|
||||
}
|
||||
|
||||
public class EncryptedSecureNoteDto
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public int Type { get; set; }
|
||||
}
|
||||
|
||||
public class EncryptedSshKeyDto
|
||||
{
|
||||
[JsonPropertyName("privateKey")]
|
||||
public string? PrivateKey { get; set; }
|
||||
|
||||
[JsonPropertyName("publicKey")]
|
||||
public string? PublicKey { get; set; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; set; }
|
||||
}
|
||||
|
||||
90
util/Seeder/Models/EncryptedCipherDtoExtensions.cs
Normal file
90
util/Seeder/Models/EncryptedCipherDtoExtensions.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
|
||||
namespace Bit.Seeder.Models;
|
||||
|
||||
internal static class EncryptedCipherDtoExtensions
|
||||
{
|
||||
internal static CipherLoginData ToLoginData(this EncryptedCipherDto e) => new()
|
||||
{
|
||||
Name = e.Name,
|
||||
Notes = e.Notes,
|
||||
Username = e.Login?.Username,
|
||||
Password = e.Login?.Password,
|
||||
Totp = e.Login?.Totp,
|
||||
PasswordRevisionDate = e.Login?.PasswordRevisionDate,
|
||||
Uris = e.Login?.Uris?.Select(u => new CipherLoginData.CipherLoginUriData
|
||||
{
|
||||
Uri = u.Uri,
|
||||
UriChecksum = u.UriChecksum,
|
||||
Match = u.Match.HasValue ? (UriMatchType?)u.Match : null
|
||||
}),
|
||||
Fields = e.ToFields()
|
||||
};
|
||||
|
||||
internal static CipherCardData ToCardData(this EncryptedCipherDto e) => new()
|
||||
{
|
||||
Name = e.Name,
|
||||
Notes = e.Notes,
|
||||
CardholderName = e.Card?.CardholderName,
|
||||
Brand = e.Card?.Brand,
|
||||
Number = e.Card?.Number,
|
||||
ExpMonth = e.Card?.ExpMonth,
|
||||
ExpYear = e.Card?.ExpYear,
|
||||
Code = e.Card?.Code,
|
||||
Fields = e.ToFields()
|
||||
};
|
||||
|
||||
internal static CipherIdentityData ToIdentityData(this EncryptedCipherDto e) => new()
|
||||
{
|
||||
Name = e.Name,
|
||||
Notes = e.Notes,
|
||||
Title = e.Identity?.Title,
|
||||
FirstName = e.Identity?.FirstName,
|
||||
MiddleName = e.Identity?.MiddleName,
|
||||
LastName = e.Identity?.LastName,
|
||||
Address1 = e.Identity?.Address1,
|
||||
Address2 = e.Identity?.Address2,
|
||||
Address3 = e.Identity?.Address3,
|
||||
City = e.Identity?.City,
|
||||
State = e.Identity?.State,
|
||||
PostalCode = e.Identity?.PostalCode,
|
||||
Country = e.Identity?.Country,
|
||||
Company = e.Identity?.Company,
|
||||
Email = e.Identity?.Email,
|
||||
Phone = e.Identity?.Phone,
|
||||
SSN = e.Identity?.SSN,
|
||||
Username = e.Identity?.Username,
|
||||
PassportNumber = e.Identity?.PassportNumber,
|
||||
LicenseNumber = e.Identity?.LicenseNumber,
|
||||
Fields = e.ToFields()
|
||||
};
|
||||
|
||||
internal static CipherSecureNoteData ToSecureNoteData(this EncryptedCipherDto e) => new()
|
||||
{
|
||||
Name = e.Name,
|
||||
Notes = e.Notes,
|
||||
Type = (SecureNoteType)(e.SecureNote?.Type ?? 0),
|
||||
Fields = e.ToFields()
|
||||
};
|
||||
|
||||
internal static CipherSSHKeyData ToSshKeyData(this EncryptedCipherDto e) => new()
|
||||
{
|
||||
Name = e.Name,
|
||||
Notes = e.Notes,
|
||||
PrivateKey = e.SshKey?.PrivateKey,
|
||||
PublicKey = e.SshKey?.PublicKey,
|
||||
KeyFingerprint = e.SshKey?.Fingerprint,
|
||||
Fields = e.ToFields()
|
||||
};
|
||||
|
||||
private static IEnumerable<CipherFieldData>? ToFields(this EncryptedCipherDto e) =>
|
||||
e.Fields?.Select(f => new CipherFieldData
|
||||
{
|
||||
Name = f.Name,
|
||||
Value = f.Value,
|
||||
Type = (FieldType)f.Type,
|
||||
LinkedId = f.LinkedId
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Options;
|
||||
|
||||
@@ -45,19 +47,40 @@ public class OrganizationVaultOptions
|
||||
public OrgStructureModel? StructureModel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Username pattern for cipher logins.
|
||||
/// Username pattern for corporate email format (e.g., first.last@domain).
|
||||
/// Only applies to CorporateEmail category usernames.
|
||||
/// </summary>
|
||||
public UsernamePatternType UsernamePattern { get; init; } = UsernamePatternType.FirstDotLast;
|
||||
|
||||
/// <summary>
|
||||
/// Password strength for cipher logins. Defaults to Realistic distribution
|
||||
/// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong).
|
||||
/// Distribution of username categories (corporate email, personal email, social handles, etc.).
|
||||
/// Use <see cref="UsernameDistributions.Realistic"/> for a typical enterprise mix (45% corporate).
|
||||
/// Defaults to Realistic if not specified.
|
||||
/// </summary>
|
||||
public PasswordStrength PasswordStrength { get; init; } = PasswordStrength.Realistic;
|
||||
public Distribution<UsernameCategory> UsernameDistribution { get; init; } = UsernameDistributions.Realistic;
|
||||
|
||||
/// <summary>
|
||||
/// Distribution of password strengths for cipher logins.
|
||||
/// Use <see cref="PasswordDistributions.Realistic"/> for breach-data distribution
|
||||
/// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong).
|
||||
/// Defaults to Realistic if not specified.
|
||||
/// </summary>
|
||||
public Distribution<PasswordStrength> PasswordDistribution { get; init; } = PasswordDistributions.Realistic;
|
||||
|
||||
/// <summary>
|
||||
/// Geographic region for culturally-appropriate name generation in cipher usernames.
|
||||
/// Defaults to Global (mixed locales from all regions).
|
||||
/// </summary>
|
||||
public GeographicRegion? Region { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When specified, ciphers are distributed according to the percentages.
|
||||
/// Use <see cref="CipherTypeDistributions.Realistic"/> for a typical enterprise mix.
|
||||
/// </summary>
|
||||
public Distribution<CipherType> CipherTypeDistribution { get; init; } = CipherTypeDistributions.Realistic;
|
||||
|
||||
/// <summary>
|
||||
/// Seed for deterministic data generation. When null, derived from Domain hash.
|
||||
/// </summary>
|
||||
public int? Seed { get; init; }
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ The Seeder is organized around six core patterns, each with a specific responsib
|
||||
|
||||
- Implement `IScene<TRequest>` or `IScene<TRequest, TResult>`
|
||||
- Create complete, realistic test scenarios
|
||||
- Handle uniqueness constraint mangling for test isolation
|
||||
- Return `SceneResult` with mangle map and optional additional operation result data for test assertions
|
||||
- Receive `IManglerService` via DI for test isolation—service handles mangling automatically
|
||||
- Return `SceneResult` with MangleMap (original→mangled) for test assertions
|
||||
- Async operations
|
||||
- CAN modify database state
|
||||
|
||||
@@ -117,7 +117,7 @@ The Seeder is organized around six core patterns, each with a specific responsib
|
||||
|
||||
**When to use:** Need to READ existing seeded data for verification or follow-up operations.
|
||||
|
||||
** Example:** Inviting a user to an organization produces a magic link to accept the invite, a query should be used to retrieve that link because it is easier than interfacing with an external smtp catcher.
|
||||
**Example:** Inviting a user to an organization produces a magic link to accept the invite, a query should be used to retrieve that link because it is easier than interfacing with an external smtp catcher.
|
||||
|
||||
**Key characteristics:**
|
||||
|
||||
@@ -144,6 +144,35 @@ The Seeder is organized around six core patterns, each with a specific responsib
|
||||
- Composable across regions
|
||||
- Enums provide the public API (CompanyType, PasswordStrength, etc.)
|
||||
|
||||
**Folder structure:** See `Data/README.md` for Generators and Distributions details.
|
||||
|
||||
- `Static/` - Read-only data arrays (Companies, Passwords, Names, OrgStructures)
|
||||
- `Generators/` - Seeded data generators via `GeneratorContext`
|
||||
- `Distributions/` - Percentage-based selection via `Distribution<T>`
|
||||
- `Enums/` - Public API enums
|
||||
|
||||
#### Services
|
||||
|
||||
**Purpose:** Injectable services that provide cross-cutting functionality via dependency injection.
|
||||
|
||||
**`IManglerService`** - Context-aware string mangling for test isolation:
|
||||
|
||||
- `Mangle(string)` - Transforms strings with unique prefixes for collision-free test data
|
||||
- `GetMangleMap()` - Returns dictionary of original → mangled mappings for assertions
|
||||
- `IsEnabled` - Indicates whether mangling is active
|
||||
|
||||
**Implementations:**
|
||||
|
||||
- `ManglerService` - Scoped stateful service that adds unique prefixes (`{prefix}+user@domain` for emails, `{prefix}-value` for strings)
|
||||
- `NoOpManglerService` - Singleton no-op service that returns values unchanged
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- SeederApi: Enabled when `GlobalSettings.TestPlayIdTrackingEnabled` is true
|
||||
- DbSeederUtility: Enabled with `--mangle` CLI flag
|
||||
|
||||
---
|
||||
|
||||
## Rust SDK Integration
|
||||
|
||||
The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption:
|
||||
|
||||
@@ -4,6 +4,7 @@ using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.Seeder.Services;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization;
|
||||
@@ -12,7 +13,11 @@ using EfUser = Bit.Infrastructure.EntityFramework.Models.User;
|
||||
|
||||
namespace Bit.Seeder.Recipes;
|
||||
|
||||
public class OrganizationWithUsersRecipe(DatabaseContext db, IMapper mapper, IPasswordHasher<User> passwordHasher)
|
||||
public class OrganizationWithUsersRecipe(
|
||||
DatabaseContext db,
|
||||
IMapper mapper,
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
IManglerService manglerService)
|
||||
{
|
||||
public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed)
|
||||
{
|
||||
@@ -20,11 +25,11 @@ public class OrganizationWithUsersRecipe(DatabaseContext db, IMapper mapper, IPa
|
||||
|
||||
// Generate organization keys
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
var organization = OrganizationSeeder.CreateEnterprise(
|
||||
var organization = OrganizationSeeder.Create(
|
||||
name, domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey);
|
||||
|
||||
// Create owner with SDK-generated keys
|
||||
var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{domain}", passwordHasher);
|
||||
var ownerUser = UserSeeder.Create($"owner@{domain}", passwordHasher, manglerService);
|
||||
var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
|
||||
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
|
||||
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
|
||||
@@ -33,7 +38,7 @@ public class OrganizationWithUsersRecipe(DatabaseContext db, IMapper mapper, IPa
|
||||
var additionalOrgUsers = new List<OrganizationUser>();
|
||||
for (var i = 0; i < users; i++)
|
||||
{
|
||||
var additionalUser = UserSeeder.CreateUserWithSdkKeys($"user{i}@{domain}", passwordHasher);
|
||||
var additionalUser = UserSeeder.Create($"user{i}@{domain}", passwordHasher, manglerService);
|
||||
additionalUsers.Add(additionalUser);
|
||||
|
||||
// Generate org key for confirmed/revoked users
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Data;
|
||||
using Bit.Seeder.Data.Distributions;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Data.Generators;
|
||||
using Bit.Seeder.Data.Static;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.Seeder.Options;
|
||||
using Bit.Seeder.Services;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using EfFolder = Bit.Infrastructure.EntityFramework.Vault.Models.Folder;
|
||||
@@ -27,8 +33,12 @@ namespace Bit.Seeder.Recipes;
|
||||
public class OrganizationWithVaultRecipe(
|
||||
DatabaseContext db,
|
||||
IMapper mapper,
|
||||
IPasswordHasher<User> passwordHasher)
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
IManglerService manglerService)
|
||||
{
|
||||
private const int _minimumOrgSeats = 1000;
|
||||
|
||||
private GeneratorContext _ctx = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a user with their symmetric key for folder encryption.
|
||||
@@ -42,15 +52,17 @@ public class OrganizationWithVaultRecipe(
|
||||
/// <returns>The organization ID.</returns>
|
||||
public Guid Seed(OrganizationVaultOptions options)
|
||||
{
|
||||
var seats = Math.Max(options.Users + 1, 1000);
|
||||
_ctx = GeneratorContext.FromOptions(options);
|
||||
|
||||
var seats = Math.Max(options.Users + 1, _minimumOrgSeats);
|
||||
var orgKeys = RustSdkService.GenerateOrganizationKeys();
|
||||
|
||||
// Create organization via factory
|
||||
var organization = OrganizationSeeder.CreateEnterprise(
|
||||
var organization = OrganizationSeeder.Create(
|
||||
options.Name, options.Domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey);
|
||||
|
||||
// Create owner user via factory
|
||||
var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{options.Domain}", passwordHasher);
|
||||
var ownerUser = UserSeeder.Create($"owner@{options.Domain}", passwordHasher, manglerService);
|
||||
var ownerOrgKey = RustSdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
|
||||
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
|
||||
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
|
||||
@@ -63,12 +75,13 @@ public class OrganizationWithVaultRecipe(
|
||||
for (var i = 0; i < options.Users; i++)
|
||||
{
|
||||
var email = $"user{i}@{options.Domain}";
|
||||
var userKeys = RustSdkService.GenerateUserKeys(email, UserSeeder.DefaultPassword);
|
||||
var memberUser = UserSeeder.CreateUserFromKeys(email, userKeys, passwordHasher);
|
||||
var mangledEmail = manglerService.Mangle(email);
|
||||
var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, UserSeeder.DefaultPassword);
|
||||
var memberUser = UserSeeder.Create(mangledEmail, passwordHasher, manglerService, keys: userKeys);
|
||||
memberUsersWithKeys.Add(new UserWithKey(memberUser, userKeys.Key));
|
||||
|
||||
var status = useRealisticMix
|
||||
? GetRealisticStatus(i, options.Users)
|
||||
? UserStatusDistributions.Realistic.Select(i, options.Users)
|
||||
: OrganizationUserStatusType.Confirmed;
|
||||
|
||||
var memberOrgKey = (status == OrganizationUserStatusType.Confirmed ||
|
||||
@@ -102,7 +115,7 @@ public class OrganizationWithVaultRecipe(
|
||||
|
||||
var collectionIds = CreateCollections(organization.Id, orgKeys.Key, options.StructureModel, confirmedOrgUserIds);
|
||||
CreateGroups(organization.Id, options.Groups, confirmedOrgUserIds);
|
||||
CreateCiphers(organization.Id, orgKeys.Key, collectionIds, options.Ciphers, options.UsernamePattern, options.PasswordStrength, options.Region);
|
||||
CreateCiphers(organization.Id, orgKeys.Key, collectionIds, options.Ciphers, options.PasswordDistribution, options.CipherTypeDistribution);
|
||||
CreateFolders(memberUsersWithKeys);
|
||||
|
||||
return organization.Id;
|
||||
@@ -120,12 +133,12 @@ public class OrganizationWithVaultRecipe(
|
||||
{
|
||||
var structure = OrgStructures.GetStructure(structureModel.Value);
|
||||
collections = structure.Units
|
||||
.Select(unit => CollectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name))
|
||||
.Select(unit => CollectionSeeder.Create(organizationId, orgKeyBase64, unit.Name))
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
collections = [CollectionSeeder.CreateCollection(organizationId, orgKeyBase64, "Default Collection")];
|
||||
collections = [CollectionSeeder.Create(organizationId, orgKeyBase64, "Default Collection")];
|
||||
}
|
||||
|
||||
db.BulkCopy(collections);
|
||||
@@ -138,7 +151,7 @@ public class OrganizationWithVaultRecipe(
|
||||
{
|
||||
var maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count);
|
||||
return Enumerable.Range(0, maxAssignments)
|
||||
.Select(j => CollectionSeeder.CreateCollectionUser(
|
||||
.Select(j => CollectionUserSeeder.Create(
|
||||
collections[(userIndex + j) % collections.Count].Id,
|
||||
orgUserId,
|
||||
readOnly: j > 0,
|
||||
@@ -154,7 +167,7 @@ public class OrganizationWithVaultRecipe(
|
||||
private void CreateGroups(Guid organizationId, int groupCount, List<Guid> orgUserIds)
|
||||
{
|
||||
var groupList = Enumerable.Range(0, groupCount)
|
||||
.Select(i => GroupSeeder.CreateGroup(organizationId, $"Group {i + 1}"))
|
||||
.Select(i => GroupSeeder.Create(organizationId, $"Group {i + 1}"))
|
||||
.ToList();
|
||||
|
||||
db.BulkCopy(groupList);
|
||||
@@ -163,7 +176,7 @@ public class OrganizationWithVaultRecipe(
|
||||
if (groupList.Count > 0 && orgUserIds.Count > 0)
|
||||
{
|
||||
var groupUsers = orgUserIds
|
||||
.Select((orgUserId, i) => GroupSeeder.CreateGroupUser(
|
||||
.Select((orgUserId, i) => GroupUserSeeder.Create(
|
||||
groupList[i % groupList.Count].Id,
|
||||
orgUserId))
|
||||
.ToList();
|
||||
@@ -176,24 +189,29 @@ public class OrganizationWithVaultRecipe(
|
||||
string orgKeyBase64,
|
||||
List<Guid> collectionIds,
|
||||
int cipherCount,
|
||||
UsernamePatternType usernamePattern,
|
||||
PasswordStrength passwordStrength,
|
||||
GeographicRegion? region)
|
||||
Distribution<PasswordStrength> passwordDistribution,
|
||||
Distribution<CipherType> typeDistribution)
|
||||
{
|
||||
if (cipherCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var companies = Companies.All;
|
||||
var usernameGenerator = new CipherUsernameGenerator(organizationId.GetHashCode(), usernamePattern, region);
|
||||
|
||||
var cipherList = Enumerable.Range(0, cipherCount)
|
||||
.Select(i =>
|
||||
{
|
||||
var company = companies[i % companies.Length];
|
||||
return CipherSeeder.CreateOrganizationLoginCipher(
|
||||
organizationId,
|
||||
orgKeyBase64,
|
||||
name: $"{company.Name} ({company.Category})",
|
||||
username: usernameGenerator.GenerateVaried(company, i),
|
||||
password: Passwords.GetPassword(passwordStrength, i),
|
||||
uri: $"https://{company.Domain}");
|
||||
var cipherType = typeDistribution.Select(i, cipherCount);
|
||||
return cipherType switch
|
||||
{
|
||||
CipherType.Login => CreateLoginCipher(i, organizationId, orgKeyBase64, companies, cipherCount, passwordDistribution),
|
||||
CipherType.Card => CreateCardCipher(i, organizationId, orgKeyBase64),
|
||||
CipherType.Identity => CreateIdentityCipher(i, organizationId, orgKeyBase64),
|
||||
CipherType.SecureNote => CreateSecureNoteCipher(i, organizationId, orgKeyBase64),
|
||||
CipherType.SSHKey => CreateSshKeyCipher(i, organizationId, orgKeyBase64),
|
||||
_ => throw new ArgumentException($"Unsupported cipher type: {cipherType}")
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -224,46 +242,77 @@ public class OrganizationWithVaultRecipe(
|
||||
};
|
||||
}
|
||||
|
||||
return new[] { primary };
|
||||
return [primary];
|
||||
}).ToList();
|
||||
|
||||
db.BulkCopy(collectionCiphers);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a realistic user status based on index position.
|
||||
/// Distribution: 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked.
|
||||
/// </summary>
|
||||
private static OrganizationUserStatusType GetRealisticStatus(int index, int totalUsers)
|
||||
private Cipher CreateLoginCipher(
|
||||
int index,
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
Company[] companies,
|
||||
int cipherCount,
|
||||
Distribution<PasswordStrength> passwordDistribution)
|
||||
{
|
||||
// Calculate bucket boundaries
|
||||
var confirmedCount = (int)(totalUsers * 0.85);
|
||||
var invitedCount = (int)(totalUsers * 0.05);
|
||||
var acceptedCount = (int)(totalUsers * 0.05);
|
||||
// Revoked gets the remainder
|
||||
var company = companies[index % companies.Length];
|
||||
return LoginCipherSeeder.Create(
|
||||
orgKeyBase64,
|
||||
name: $"{company.Name} ({company.Category})",
|
||||
organizationId: organizationId,
|
||||
username: _ctx.Username.GenerateByIndex(index, totalHint: _ctx.CipherCount, domain: company.Domain),
|
||||
password: Passwords.GetPassword(index, cipherCount, passwordDistribution),
|
||||
uri: $"https://{company.Domain}");
|
||||
}
|
||||
|
||||
if (index < confirmedCount)
|
||||
private Cipher CreateCardCipher(int index, Guid organizationId, string orgKeyBase64)
|
||||
{
|
||||
var card = _ctx.Card.GenerateByIndex(index);
|
||||
return CardCipherSeeder.Create(
|
||||
orgKeyBase64,
|
||||
name: $"{card.CardholderName}'s {card.Brand}",
|
||||
card: card,
|
||||
organizationId: organizationId);
|
||||
}
|
||||
|
||||
private Cipher CreateIdentityCipher(int index, Guid organizationId, string orgKeyBase64)
|
||||
{
|
||||
var identity = _ctx.Identity.GenerateByIndex(index);
|
||||
var name = $"{identity.FirstName} {identity.LastName}";
|
||||
if (!string.IsNullOrEmpty(identity.Company))
|
||||
{
|
||||
return OrganizationUserStatusType.Confirmed;
|
||||
name += $" ({identity.Company})";
|
||||
}
|
||||
return IdentityCipherSeeder.Create(
|
||||
orgKeyBase64,
|
||||
name: name,
|
||||
identity: identity,
|
||||
organizationId: organizationId);
|
||||
}
|
||||
|
||||
if (index < confirmedCount + invitedCount)
|
||||
{
|
||||
return OrganizationUserStatusType.Invited;
|
||||
}
|
||||
private Cipher CreateSecureNoteCipher(int index, Guid organizationId, string orgKeyBase64)
|
||||
{
|
||||
var (name, notes) = _ctx.SecureNote.GenerateByIndex(index);
|
||||
return SecureNoteCipherSeeder.Create(
|
||||
orgKeyBase64,
|
||||
name: name,
|
||||
organizationId: organizationId,
|
||||
notes: notes);
|
||||
}
|
||||
|
||||
if (index < confirmedCount + invitedCount + acceptedCount)
|
||||
{
|
||||
return OrganizationUserStatusType.Accepted;
|
||||
}
|
||||
|
||||
return OrganizationUserStatusType.Revoked;
|
||||
private Cipher CreateSshKeyCipher(int index, Guid organizationId, string orgKeyBase64)
|
||||
{
|
||||
var sshKey = SshKeyDataGenerator.GenerateByIndex(index);
|
||||
return SshKeyCipherSeeder.Create(
|
||||
orgKeyBase64,
|
||||
name: $"SSH Key {index + 1}",
|
||||
sshKey: sshKey,
|
||||
organizationId: organizationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates personal vault folders for users with realistic distribution.
|
||||
/// Folders are encrypted with each user's individual symmetric key.
|
||||
/// </summary>
|
||||
private void CreateFolders(List<UserWithKey> usersWithKeys)
|
||||
{
|
||||
@@ -272,19 +321,15 @@ public class OrganizationWithVaultRecipe(
|
||||
return;
|
||||
}
|
||||
|
||||
var seed = usersWithKeys[0].User.Id.GetHashCode();
|
||||
var random = new Random(seed);
|
||||
var folderNameGenerator = new FolderNameGenerator(seed);
|
||||
|
||||
var allFolders = usersWithKeys
|
||||
.SelectMany((uwk, userIndex) =>
|
||||
{
|
||||
var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, random);
|
||||
var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, _ctx.Seed);
|
||||
return Enumerable.Range(0, folderCount)
|
||||
.Select(folderIndex => FolderSeeder.CreateFolder(
|
||||
.Select(folderIndex => FolderSeeder.Create(
|
||||
uwk.User.Id,
|
||||
uwk.SymmetricKey,
|
||||
folderNameGenerator.GetFolderName(userIndex * 15 + folderIndex)));
|
||||
_ctx.Folder.GetFolderName(userIndex * 15 + folderIndex)));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -295,32 +340,24 @@ public class OrganizationWithVaultRecipe(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns folder count based on user index position in the distribution.
|
||||
/// Distribution: 35% Zero, 35% Few (1-3), 20% Some (4-7), 10% TooMany (10-15)
|
||||
/// </summary>
|
||||
private static int GetFolderCountForUser(int userIndex, int totalUsers, Random random)
|
||||
private static int GetFolderCountForUser(int userIndex, int totalUsers, int seed)
|
||||
{
|
||||
var zeroCount = (int)(totalUsers * 0.35);
|
||||
var fewCount = (int)(totalUsers * 0.35);
|
||||
var someCount = (int)(totalUsers * 0.20);
|
||||
// TooMany gets the remainder
|
||||
var (min, max) = FolderCountDistributions.Realistic.Select(userIndex, totalUsers);
|
||||
return GetDeterministicValueInRange(userIndex, seed, min, max);
|
||||
}
|
||||
|
||||
if (userIndex < zeroCount)
|
||||
/// <summary>
|
||||
/// Returns a deterministic value in [min, max) based on index and seed.
|
||||
/// </summary>
|
||||
private static int GetDeterministicValueInRange(int index, int seed, int min, int max)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
return 0; // Zero folders
|
||||
var hash = seed;
|
||||
hash = hash * 397 ^ index;
|
||||
hash = hash * 397 ^ min;
|
||||
var range = max - min;
|
||||
return min + ((hash % range) + range) % range;
|
||||
}
|
||||
|
||||
if (userIndex < zeroCount + fewCount)
|
||||
{
|
||||
return random.Next(1, 4); // Few: 1-3 folders
|
||||
}
|
||||
|
||||
if (userIndex < zeroCount + fewCount + someCount)
|
||||
{
|
||||
return random.Next(4, 8); // Some: 4-7 folders
|
||||
}
|
||||
|
||||
return random.Next(10, 16); // TooMany: 10-15 folders
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.Seeder.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Bit.Seeder.Scenes;
|
||||
|
||||
@@ -13,13 +16,15 @@ public struct SingleUserSceneResult
|
||||
public string PublicKey { get; init; }
|
||||
public string PrivateKey { get; init; }
|
||||
public string ApiKey { get; init; }
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a single user using the provided account details.
|
||||
/// </summary>
|
||||
public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene<SingleUserScene.Request, SingleUserSceneResult>
|
||||
public class SingleUserScene(
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
IUserRepository userRepository,
|
||||
IManglerService manglerService) : IScene<SingleUserScene.Request, SingleUserSceneResult>
|
||||
{
|
||||
public class Request
|
||||
{
|
||||
@@ -31,22 +36,27 @@ public class SingleUserScene(UserSeeder userSeeder, IUserRepository userReposito
|
||||
|
||||
public async Task<SceneResult<SingleUserSceneResult>> SeedAsync(Request request)
|
||||
{
|
||||
var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
|
||||
// Pass service to factory - factory will call Mangle()
|
||||
var user = UserSeeder.Create(
|
||||
request.Email,
|
||||
passwordHasher,
|
||||
manglerService,
|
||||
request.EmailVerified,
|
||||
request.Premium);
|
||||
|
||||
await userRepository.CreateAsync(user);
|
||||
|
||||
return new SceneResult<SingleUserSceneResult>(result: new SingleUserSceneResult
|
||||
{
|
||||
UserId = user.Id,
|
||||
Kdf = user.Kdf.ToString(),
|
||||
KdfIterations = user.KdfIterations,
|
||||
Key = user.Key!,
|
||||
PublicKey = user.PublicKey!,
|
||||
PrivateKey = user.PrivateKey!,
|
||||
ApiKey = user.ApiKey!,
|
||||
}, mangleMap: userSeeder.GetMangleMap(user, new UserData
|
||||
{
|
||||
Email = request.Email,
|
||||
}));
|
||||
return new SceneResult<SingleUserSceneResult>(
|
||||
result: new SingleUserSceneResult
|
||||
{
|
||||
UserId = user.Id,
|
||||
Kdf = user.Kdf.ToString(),
|
||||
KdfIterations = user.KdfIterations,
|
||||
Key = user.Key!,
|
||||
PublicKey = user.PublicKey!,
|
||||
PrivateKey = user.PrivateKey!,
|
||||
ApiKey = user.ApiKey!,
|
||||
},
|
||||
mangleMap: manglerService.GetMangleMap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,4 +27,8 @@
|
||||
<Compile Remove="..\..\Program.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Allow integration tests to access internal seeders for validation -->
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SeederApi.IntegrationTest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
24
util/Seeder/Services/IManglerService.cs
Normal file
24
util/Seeder/Services/IManglerService.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Bit.Seeder.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for mangling strings to ensure test isolation and collision-free data.
|
||||
/// </summary>
|
||||
public interface IManglerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Mangles a string value for test isolation.
|
||||
/// Automatically tracks the original → mangled mapping.
|
||||
/// </summary>
|
||||
string Mangle(string value);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of tracked mangle mappings (original → mangled).
|
||||
/// Used by Scenes to populate SceneResult.MangleMap.
|
||||
/// </summary>
|
||||
Dictionary<string, string?> GetMangleMap();
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether mangling is enabled.
|
||||
/// </summary>
|
||||
bool IsEnabled { get; }
|
||||
}
|
||||
43
util/Seeder/Services/ManglerService.cs
Normal file
43
util/Seeder/Services/ManglerService.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Bit.Seeder.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped stateful implementation that mangles strings with a unique prefix.
|
||||
/// Each instance generates its own MangleId and tracks all manglings in an internal map.
|
||||
/// </summary>
|
||||
public class ManglerService : IManglerService
|
||||
{
|
||||
private readonly MangleId _mangleId = new();
|
||||
private readonly Dictionary<string, string> _mangleMap = new();
|
||||
|
||||
public string Mangle(string value)
|
||||
{
|
||||
var atIndex = value.IndexOf('@');
|
||||
var mangled = atIndex >= 0
|
||||
? $"{_mangleId}+{value[..atIndex]}{value[atIndex..]}"
|
||||
: $"{_mangleId}-{value}";
|
||||
|
||||
_mangleMap[value] = mangled;
|
||||
return mangled;
|
||||
}
|
||||
|
||||
public Dictionary<string, string?> GetMangleMap()
|
||||
{
|
||||
return _mangleMap.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value);
|
||||
}
|
||||
|
||||
public bool IsEnabled => true;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for generating unique identifier suffixes to prevent collisions in test data.
|
||||
/// "Mangling" adds a random suffix to test data identifiers (usernames, emails, org names, etc.)
|
||||
/// to ensure uniqueness across multiple test runs and parallel test executions.
|
||||
/// </summary>
|
||||
private class MangleId
|
||||
{
|
||||
private readonly string _value = Random.Shared.NextInt64().ToString("x", CultureInfo.InvariantCulture)[..8];
|
||||
|
||||
public override string ToString() => _value;
|
||||
}
|
||||
}
|
||||
14
util/Seeder/Services/NoOpManglerService.cs
Normal file
14
util/Seeder/Services/NoOpManglerService.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Bit.Seeder.Services;
|
||||
|
||||
/// <summary>
|
||||
/// No-op implementation for dev/human-readable mode.
|
||||
/// Returns original values unchanged. Registered as singleton since it has no state.
|
||||
/// </summary>
|
||||
public class NoOpManglerService : IManglerService
|
||||
{
|
||||
public string Mangle(string value) => value;
|
||||
|
||||
public Dictionary<string, string?> GetMangleMap() => new();
|
||||
|
||||
public bool IsEnabled => false;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Reflection;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Seeder;
|
||||
using Bit.Seeder.Services;
|
||||
using Bit.SeederApi.Commands;
|
||||
using Bit.SeederApi.Commands.Interfaces;
|
||||
using Bit.SeederApi.Execution;
|
||||
@@ -73,4 +75,23 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the appropriate mangler service based on execution context.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddManglerService(
|
||||
this IServiceCollection services,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
if (globalSettings.TestPlayIdTrackingEnabled)
|
||||
{
|
||||
services.TryAddScoped<IManglerService, ManglerService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<IManglerService, NoOpManglerService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Seeder;
|
||||
using Bit.Seeder.Factories;
|
||||
using Bit.SeederApi.Extensions;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -32,16 +30,13 @@ public class Startup
|
||||
services.AddTokenizers();
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
services.AddManglerService(globalSettings);
|
||||
|
||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
|
||||
services.AddScoped<IPasswordHasher<Core.Entities.User>, PasswordHasher<Core.Entities.User>>();
|
||||
|
||||
services.AddScoped<UserSeeder>();
|
||||
|
||||
services.AddSeederApiServices();
|
||||
|
||||
services.AddScoped<MangleId>(_ => new MangleId());
|
||||
services.AddScenes();
|
||||
services.AddQueries();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"globalSettings": {
|
||||
"projectName": "SeederApi"
|
||||
"projectName": "SeederApi",
|
||||
"testPlayIdTrackingEnabled": true
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
||||
Reference in New Issue
Block a user