diff --git a/dev/setup_secrets.ps1 b/dev/setup_secrets.ps1 index 5013ca8bac..a41890bc46 100755 --- a/dev/setup_secrets.ps1 +++ b/dev/setup_secrets.ps1 @@ -28,6 +28,7 @@ $projects = @{ Scim = "../bitwarden_license/src/Scim" IntegrationTests = "../test/Infrastructure.IntegrationTest" SeederApi = "../util/SeederApi" + SeederUtility = "../util/DbSeederUtility" } foreach ($key in $projects.keys) { diff --git a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs new file mode 100644 index 0000000000..3c831c4893 --- /dev/null +++ b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs @@ -0,0 +1,234 @@ +using System.Text.Json; +using Bit.Core.Vault.Models.Data; +using Bit.RustSDK; +using Bit.Seeder.Factories; +using Bit.Seeder.Models; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class RustSdkCipherTests +{ + private static readonly JsonSerializerOptions SdkJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + [Fact] + public void EncryptDecrypt_LoginCipher_RoundtripPreservesPlaintext() + { + var sdk = new RustSdkService(); + var orgKeys = sdk.GenerateOrganizationKeys(); + + var originalCipher = CreateTestLoginCipher(); + var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); + + var encryptedJson = sdk.EncryptCipher(originalJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", encryptedJson); + Assert.Contains("\"name\":\"2.", encryptedJson); + + var decryptedJson = sdk.DecryptCipher(encryptedJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", decryptedJson); + + var decryptedCipher = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + + Assert.NotNull(decryptedCipher); + Assert.Equal(originalCipher.Name, decryptedCipher.Name); + Assert.Equal(originalCipher.Notes, decryptedCipher.Notes); + Assert.Equal(originalCipher.Login?.Username, decryptedCipher.Login?.Username); + Assert.Equal(originalCipher.Login?.Password, decryptedCipher.Login?.Password); + } + + [Fact] + public void EncryptCipher_WithUri_EncryptsAllFields() + { + var sdk = new RustSdkService(); + var orgKeys = sdk.GenerateOrganizationKeys(); + + var cipher = new CipherViewDto + { + Name = "Amazon Shopping", + Notes = "Prime member since 2020", + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = "shopper@example.com", + Password = "MySecretPassword123!", + Uris = + [ + new LoginUriViewDto { Uri = "https://amazon.com/login" }, + new LoginUriViewDto { Uri = "https://www.amazon.com" } + ] + } + }; + + var cipherJson = JsonSerializer.Serialize(cipher, SdkJsonOptions); + var encryptedJson = sdk.EncryptCipher(cipherJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", encryptedJson); + Assert.DoesNotContain("Amazon Shopping", encryptedJson); + Assert.DoesNotContain("shopper@example.com", encryptedJson); + Assert.DoesNotContain("MySecretPassword123!", encryptedJson); + } + + [Fact] + public void DecryptCipher_WithWrongKey_FailsOrProducesGarbage() + { + var sdk = new RustSdkService(); + var encryptionKey = sdk.GenerateOrganizationKeys(); + var differentKey = sdk.GenerateOrganizationKeys(); + + var originalCipher = CreateTestLoginCipher(); + var cipherJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); + + var encryptedJson = sdk.EncryptCipher(cipherJson, encryptionKey.Key); + Assert.DoesNotContain("\"error\"", encryptedJson); + + var decryptedJson = sdk.DecryptCipher(encryptedJson, differentKey.Key); + + var decryptionFailedWithError = decryptedJson.Contains("\"error\""); + if (!decryptionFailedWithError) + { + var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + Assert.NotEqual(originalCipher.Name, decrypted?.Name); + } + } + + [Fact] + public void EncryptCipher_WithFields_EncryptsCustomFields() + { + var sdk = new RustSdkService(); + var orgKeys = sdk.GenerateOrganizationKeys(); + + var cipher = new CipherViewDto + { + Name = "Service Account", + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = "service-account", + Password = "svc-password" + }, + Fields = + [ + new FieldViewDto { Name = "API Key", Value = "sk-secret-api-key-12345", Type = 1 }, + new FieldViewDto { Name = "Client ID", Value = "client-id-xyz", Type = 0 } + ] + }; + + var cipherJson = JsonSerializer.Serialize(cipher, SdkJsonOptions); + var encryptedJson = sdk.EncryptCipher(cipherJson, orgKeys.Key); + + Assert.DoesNotContain("\"error\"", encryptedJson); + Assert.DoesNotContain("sk-secret-api-key-12345", encryptedJson); + Assert.DoesNotContain("client-id-xyz", encryptedJson); + + var decryptedJson = sdk.DecryptCipher(encryptedJson, orgKeys.Key); + var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + + 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); + } + + [Fact] + public void CipherSeeder_ProducesServerCompatibleFormat() + { + var sdk = new RustSdkService(); + var orgKeys = sdk.GenerateOrganizationKeys(); + var seeder = new CipherSeeder(sdk); + var orgId = Guid.NewGuid(); + + // Create cipher using the seeder + var cipher = seeder.CreateOrganizationLoginCipher( + orgId, + orgKeys.Key, + name: "GitHub Account", + username: "developer@example.com", + password: "SecureP@ss123!", + uri: "https://github.com", + notes: "My development account"); + + Assert.Equal(orgId, cipher.OrganizationId); + Assert.Null(cipher.UserId); + Assert.Equal(Core.Vault.Enums.CipherType.Login, cipher.Type); + Assert.NotNull(cipher.Data); + + var loginData = JsonSerializer.Deserialize(cipher.Data); + Assert.NotNull(loginData); + + var encStringPrefix = "2."; + Assert.StartsWith(encStringPrefix, loginData.Name); + Assert.StartsWith(encStringPrefix, loginData.Username); + Assert.StartsWith(encStringPrefix, loginData.Password); + Assert.StartsWith(encStringPrefix, loginData.Notes); + + Assert.NotNull(loginData.Uris); + var uriData = loginData.Uris.First(); + Assert.StartsWith(encStringPrefix, uriData.Uri); + + Assert.DoesNotContain("GitHub Account", cipher.Data); + Assert.DoesNotContain("developer@example.com", cipher.Data); + Assert.DoesNotContain("SecureP@ss123!", cipher.Data); + } + + [Fact] + public void CipherSeeder_WithFields_ProducesCorrectServerFormat() + { + var sdk = new RustSdkService(); + var orgKeys = sdk.GenerateOrganizationKeys(); + var seeder = new CipherSeeder(sdk); + + var cipher = seeder.CreateOrganizationLoginCipherWithFields( + Guid.NewGuid(), + orgKeys.Key, + name: "API Service", + 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 + ]); + + var loginData = JsonSerializer.Deserialize(cipher.Data); + Assert.NotNull(loginData); + Assert.NotNull(loginData.Fields); + + var fields = loginData.Fields.ToList(); + Assert.Equal(2, fields.Count); + + var encStringPrefix = "2."; + Assert.StartsWith(encStringPrefix, fields[0].Name); + Assert.StartsWith(encStringPrefix, fields[0].Value); + Assert.StartsWith(encStringPrefix, fields[1].Name); + Assert.StartsWith(encStringPrefix, fields[1].Value); + + Assert.Equal(Core.Vault.Enums.FieldType.Hidden, fields[0].Type); + Assert.Equal(Core.Vault.Enums.FieldType.Text, fields[1].Type); + + Assert.DoesNotContain("API Key", cipher.Data); + Assert.DoesNotContain("sk-live-abc123", cipher.Data); + } + + private static CipherViewDto CreateTestLoginCipher() + { + return new CipherViewDto + { + Name = "Test Login", + Notes = "Secret notes about this login", + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = "testuser@example.com", + Password = "SuperSecretP@ssw0rd!", + Uris = [new LoginUriViewDto { Uri = "https://example.com" }] + } + }; + } + +} diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 0b41c1a692..789bd9e9ef 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -1,4 +1,4 @@ -using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Seeder.Data.Enums; using Bit.Seeder.Recipes; using CommandDotNet; using Microsoft.Extensions.DependencyInjection; @@ -13,27 +13,44 @@ public class Program .Run(args); } - [Command("organization", Description = "Seed an organization and organization users")] + [Command("organization", Description = "Seed an organization with users and optional ciphers")] public void Organization( - [Option('n', "Name", Description = "Name of organization")] + [Option('n', "name", Description = "Name of organization")] string name, [Option('u', "users", Description = "Number of users to generate")] int users, [Option('d', "domain", Description = "Email domain for users")] - string domain + string domain, + [Option('c', "ciphers", Description = "Number of login ciphers to create")] + int ciphers = 0, + [Option('s', "structure", Description = "Org structure for collections: Traditional, Spotify, or Modern")] + string? structure = null ) { - // Create service provider with necessary services + var structureModel = ParseStructureModel(structure); + var services = new ServiceCollection(); ServiceCollectionExtension.ConfigureServices(services); var serviceProvider = services.BuildServiceProvider(); - // Get a scoped DB context using var scope = serviceProvider.CreateScope(); - var scopedServices = scope.ServiceProvider; - var db = scopedServices.GetRequiredService(); + OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, name, domain, users, ciphers, + structureModel: structureModel); + } - var recipe = new OrganizationWithUsersRecipe(db); - recipe.Seed(name: name, domain: domain, users: users); + private static OrgStructureModel? ParseStructureModel(string? structure) + { + if (string.IsNullOrEmpty(structure)) + { + return null; + } + + return structure.ToLowerInvariant() switch + { + "traditional" => OrgStructureModel.Traditional, + "spotify" => OrgStructureModel.Spotify, + "modern" => OrgStructureModel.Modern, + _ => throw new ArgumentException($"Unknown structure '{structure}'. Use: Traditional, Spotify, or Modern") + }; } } diff --git a/util/DbSeederUtility/README.md b/util/DbSeederUtility/README.md index 0eb21ae6c5..f900cd45e2 100644 --- a/util/DbSeederUtility/README.md +++ b/util/DbSeederUtility/README.md @@ -28,10 +28,34 @@ DbSeeder.exe [options] ```bash # Generate an organization called "seeded" with 10000 users using the @large.test email domain. -# Login using "admin@large.test" with password "asdfasdfasdf" +# Login using "owner@large.test" with password "asdfasdfasdf" DbSeeder.exe organization -n seeded -u 10000 -d large.test + +# Generate an organization with 5 users and 100 encrypted ciphers +DbSeeder.exe organization -n TestOrg -u 5 -d test.com -c 100 + +# Generate a small test organization with ciphers for manual testing +DbSeeder.exe organization -n DevOrg -u 2 -d dev.local -c 10 ``` +### Options + +| Option | Description | +|--------|-------------| +| `-n, --name` | Organization name | +| `-u, --users` | Number of member users to create | +| `-d, --domain` | Email domain (e.g., test.com creates owner@test.com) | +| `-c, --ciphers` | Number of encrypted ciphers to create (optional) | +| `-s, --status` | User status: Confirmed (default), Invited, Accepted, Revoked | + +### Notes + +- All users are created with the password `asdfasdfasdf` +- The owner account is always `owner@{domain}` with Confirmed status +- Member accounts are `user0@{domain}`, `user1@{domain}`, etc. +- When ciphers are created, a "Default Collection" is automatically created and all users are granted access +- Ciphers are encrypted using dynamically generated organization keys + ## Dependencies This utility depends on: diff --git a/util/DbSeederUtility/ServiceCollectionExtension.cs b/util/DbSeederUtility/ServiceCollectionExtension.cs index 0653bb1801..c3526e899e 100644 --- a/util/DbSeederUtility/ServiceCollectionExtension.cs +++ b/util/DbSeederUtility/ServiceCollectionExtension.cs @@ -1,5 +1,8 @@ -using Bit.SharedWeb.Utilities; +using Bit.Core.Entities; +using Bit.RustSDK; +using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -15,6 +18,8 @@ public static class ServiceCollectionExtension // Register services services.AddLogging(builder => builder.AddConsole()); services.AddSingleton(globalSettings); + services.AddSingleton(); + services.AddSingleton, PasswordHasher>(); // Add Data Protection services services.AddDataProtection() diff --git a/util/RustSdk/RustSdkService.cs b/util/RustSdk/RustSdkService.cs index ee01d56fee..8ec29a3bf3 100644 --- a/util/RustSdk/RustSdkService.cs +++ b/util/RustSdk/RustSdkService.cs @@ -78,6 +78,51 @@ public class RustSdkService } } + public unsafe string EncryptCipher(string cipherViewJson, string symmetricKeyBase64) + { + var cipherViewBytes = StringToRustString(cipherViewJson); + var keyBytes = StringToRustString(symmetricKeyBase64); + + fixed (byte* cipherViewPtr = cipherViewBytes) + fixed (byte* keyPtr = keyBytes) + { + var resultPtr = NativeMethods.encrypt_cipher(cipherViewPtr, keyPtr); + + return TakeAndDestroyRustString(resultPtr); + } + } + + public unsafe string DecryptCipher(string cipherJson, string symmetricKeyBase64) + { + var cipherBytes = StringToRustString(cipherJson); + var keyBytes = StringToRustString(symmetricKeyBase64); + + fixed (byte* cipherPtr = cipherBytes) + fixed (byte* keyPtr = keyBytes) + { + var resultPtr = NativeMethods.decrypt_cipher(cipherPtr, keyPtr); + + return TakeAndDestroyRustString(resultPtr); + } + } + + /// + /// Encrypts a plaintext string using the provided symmetric key. + /// Returns an EncString in format "2.{iv}|{data}|{mac}". + /// + public unsafe string EncryptString(string plaintext, string symmetricKeyBase64) + { + var plaintextBytes = StringToRustString(plaintext); + var keyBytes = StringToRustString(symmetricKeyBase64); + + fixed (byte* plaintextPtr = plaintextBytes) + fixed (byte* keyPtr = keyBytes) + { + var resultPtr = NativeMethods.encrypt_string(plaintextPtr, keyPtr); + + return TakeAndDestroyRustString(resultPtr); + } + } private static byte[] StringToRustString(string str) { diff --git a/util/RustSdk/rust/Cargo.lock b/util/RustSdk/rust/Cargo.lock index aff61935e4..1170795133 100644 --- a/util/RustSdk/rust/Cargo.lock +++ b/util/RustSdk/rust/Cargo.lock @@ -126,6 +126,21 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "2.9.1" @@ -162,6 +177,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "bitwarden-collections" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=7080159154a42b59028ccb9f5af62bf087e565f9#7080159154a42b59028ccb9f5af62bf087e565f9" +dependencies = [ + "bitwarden-api-api", + "bitwarden-core", + "bitwarden-crypto", + "bitwarden-error", + "bitwarden-uuid", + "serde", + "serde_repr", + "thiserror 2.0.12", + "uuid", +] + [[package]] name = "bitwarden-core" version = "1.0.0" @@ -188,9 +219,10 @@ dependencies = [ "serde_json", "serde_qs", "serde_repr", - "thiserror 1.0.69", + "thiserror 2.0.12", "uuid", "zeroize", + "zxcvbn", ] [[package]] @@ -224,7 +256,7 @@ dependencies = [ "sha1", "sha2", "subtle", - "thiserror 1.0.69", + "thiserror 2.0.12", "typenum", "uuid", "zeroize", @@ -239,7 +271,7 @@ dependencies = [ "data-encoding", "data-encoding-macro", "serde", - "thiserror 1.0.69", + "thiserror 2.0.12", ] [[package]] @@ -274,7 +306,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tsify", ] @@ -287,7 +319,7 @@ dependencies = [ "bitwarden-error", "log", "serde", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tokio-util", ] @@ -309,6 +341,36 @@ dependencies = [ "syn", ] +[[package]] +name = "bitwarden-vault" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=7080159154a42b59028ccb9f5af62bf087e565f9#7080159154a42b59028ccb9f5af62bf087e565f9" +dependencies = [ + "bitwarden-api-api", + "bitwarden-collections", + "bitwarden-core", + "bitwarden-crypto", + "bitwarden-encoding", + "bitwarden-error", + "bitwarden-state", + "bitwarden-uuid", + "chrono", + "data-encoding", + "futures", + "hmac", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.12", + "uuid", + "zxcvbn", +] + [[package]] name = "blake2" version = "0.11.0-rc.3" @@ -431,8 +493,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -695,6 +759,37 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -784,6 +879,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -811,6 +917,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -818,6 +939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -826,6 +948,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -855,9 +994,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1292,6 +1435,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -2099,6 +2251,7 @@ dependencies = [ "base64", "bitwarden-core", "bitwarden-crypto", + "bitwarden-vault", "csbindgen", "serde", "serde_json", @@ -3189,3 +3342,20 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zxcvbn" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad76e35b00ad53688d6b90c431cabe3cbf51f7a4a154739e04b63004ab1c736c" +dependencies = [ + "chrono", + "derive_builder", + "fancy-regex", + "itertools", + "lazy_static", + "regex", + "time", + "wasm-bindgen", + "web-sys", +] diff --git a/util/RustSdk/rust/Cargo.toml b/util/RustSdk/rust/Cargo.toml index 65b0d42e5f..767cbf47e6 100644 --- a/util/RustSdk/rust/Cargo.toml +++ b/util/RustSdk/rust/Cargo.toml @@ -13,8 +13,9 @@ crate-type = ["cdylib"] [dependencies] base64 = "0.22.1" -bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "7080159154a42b59028ccb9f5af62bf087e565f9" } +bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "7080159154a42b59028ccb9f5af62bf087e565f9", features = ["internal"] } bitwarden-crypto = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "7080159154a42b59028ccb9f5af62bf087e565f9" } +bitwarden-vault = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "7080159154a42b59028ccb9f5af62bf087e565f9" } serde = "=1.0.219" serde_json = "=1.0.141" diff --git a/util/RustSdk/rust/src/lib.rs b/util/RustSdk/rust/src/lib.rs index 10f8d8dca4..c18ae4308f 100644 --- a/util/RustSdk/rust/src/lib.rs +++ b/util/RustSdk/rust/src/lib.rs @@ -6,11 +6,13 @@ use std::{ use base64::{engine::general_purpose::STANDARD, Engine}; +use bitwarden_core::key_management::KeyIds; use bitwarden_crypto::{ - AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, HashPurpose, Kdf, - KeyEncryptable, MasterKey, RsaKeyPair, SpkiPublicKeyBytes, SymmetricCryptoKey, - UnsignedSharedKey, UserKey, + AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, CompositeEncryptable, + Decryptable, HashPurpose, Kdf, KeyEncryptable, KeyStore, MasterKey, RsaKeyPair, + SpkiPublicKeyBytes, SymmetricCryptoKey, UnsignedSharedKey, UserKey, }; +use bitwarden_vault::{Cipher, CipherView}; #[no_mangle] pub unsafe extern "C" fn generate_user_keys( @@ -20,9 +22,6 @@ pub unsafe extern "C" fn generate_user_keys( let email = CStr::from_ptr(email).to_str().unwrap(); let password = CStr::from_ptr(password).to_str().unwrap(); - println!("Generating keys for {email}"); - println!("Password: {password}"); - let kdf = Kdf::PBKDF2 { iterations: NonZeroU32::new(5_000).unwrap(), }; @@ -32,8 +31,6 @@ pub unsafe extern "C" fn generate_user_keys( let master_password_hash = master_key.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization); - println!("Master password hash: {}", master_password_hash); - let (user_key, encrypted_user_key) = master_key.make_user_key().unwrap(); let keypair = keypair(&user_key.0); @@ -140,6 +137,183 @@ pub unsafe extern "C" fn generate_user_organization_key( result.into_raw() } +/// Create an error JSON response and return it as a C string pointer. +fn error_response(message: &str) -> *const c_char { + let error_json = serde_json::json!({ "error": message }).to_string(); + CString::new(error_json).unwrap().into_raw() +} + +/// Encrypt a CipherView with a symmetric key, returning an encrypted Cipher as JSON. +/// +/// # Arguments +/// * `cipher_view_json` - JSON string representing a CipherView (camelCase format) +/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) +/// +/// # Returns +/// JSON string representing the encrypted Cipher +/// +/// # Safety +/// Both pointers must be valid null-terminated strings. +#[no_mangle] +pub unsafe extern "C" fn encrypt_cipher( + cipher_view_json: *const c_char, + symmetric_key_b64: *const c_char, +) -> *const c_char { + let cipher_view_json = match CStr::from_ptr(cipher_view_json).to_str() { + Ok(s) => s, + Err(_) => return error_response("Invalid UTF-8 in cipher_view_json"), + }; + + let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() { + Ok(s) => s, + Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"), + }; + + let cipher_view: CipherView = match serde_json::from_str(cipher_view_json) { + Ok(v) => v, + Err(_) => return error_response("Failed to parse CipherView JSON"), + }; + + let key_bytes = match STANDARD.decode(key_b64) { + Ok(b) => b, + Err(_) => return error_response("Failed to decode base64 key"), + }; + + let key = + match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) { + Ok(k) => k, + Err(_) => { + return error_response( + "Failed to create symmetric key: invalid key format or length", + ) + } + }; + + let store: KeyStore = KeyStore::default(); + let mut ctx = store.context_mut(); + let key_id = ctx.add_local_symmetric_key(key); + + let cipher = match cipher_view.encrypt_composite(&mut ctx, key_id) { + Ok(c) => c, + Err(_) => return error_response("Failed to encrypt cipher: encryption operation failed"), + }; + + match serde_json::to_string(&cipher) { + Ok(json) => CString::new(json).unwrap().into_raw(), + Err(_) => error_response("Failed to serialize encrypted cipher"), + } +} + +/// Decrypt an encrypted Cipher with a symmetric key, returning a CipherView as JSON. +/// +/// # Arguments +/// * `cipher_json` - JSON string representing an encrypted Cipher +/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) +/// +/// # Returns +/// JSON string representing the decrypted CipherView +/// +/// # Safety +/// Both pointers must be valid null-terminated strings. +#[no_mangle] +pub unsafe extern "C" fn decrypt_cipher( + cipher_json: *const c_char, + symmetric_key_b64: *const c_char, +) -> *const c_char { + let cipher_json = match CStr::from_ptr(cipher_json).to_str() { + Ok(s) => s, + Err(_) => return error_response("Invalid UTF-8 in cipher_json"), + }; + + let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() { + Ok(s) => s, + Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"), + }; + + let cipher: Cipher = match serde_json::from_str(cipher_json) { + Ok(c) => c, + Err(_) => return error_response("Failed to parse Cipher JSON"), + }; + + let key_bytes = match STANDARD.decode(key_b64) { + Ok(b) => b, + Err(_) => return error_response("Failed to decode base64 key"), + }; + + let key = + match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) { + Ok(k) => k, + Err(_) => { + return error_response( + "Failed to create symmetric key: invalid key format or length", + ) + } + }; + + let store: KeyStore = KeyStore::default(); + let mut ctx = store.context_mut(); + let key_id = ctx.add_local_symmetric_key(key); + + let cipher_view: CipherView = match cipher.decrypt(&mut ctx, key_id) { + Ok(v) => v, + Err(_) => return error_response("Failed to decrypt cipher: decryption operation failed"), + }; + + match serde_json::to_string(&cipher_view) { + Ok(json) => CString::new(json).unwrap().into_raw(), + Err(_) => error_response("Failed to serialize decrypted cipher"), + } +} + +/// Encrypt a plaintext string with a symmetric key, returning an EncString. +/// +/// # Arguments +/// * `plaintext` - The plaintext string to encrypt +/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) +/// +/// # Returns +/// EncString in format "2.{iv}|{data}|{mac}" +/// +/// # Safety +/// Both pointers must be valid null-terminated strings. +#[no_mangle] +pub unsafe extern "C" fn encrypt_string( + plaintext: *const c_char, + symmetric_key_b64: *const c_char, +) -> *const c_char { + let plaintext = match CStr::from_ptr(plaintext).to_str() { + Ok(s) => s, + Err(_) => return error_response("Invalid UTF-8 in plaintext"), + }; + + let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() { + Ok(s) => s, + Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"), + }; + + let key_bytes = match STANDARD.decode(key_b64) { + Ok(b) => b, + Err(_) => return error_response("Failed to decode base64 key"), + }; + + let key = + match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) { + Ok(k) => k, + Err(_) => { + return error_response( + "Failed to create symmetric key: invalid key format or length", + ) + } + }; + + let encrypted = match plaintext.to_string().encrypt_with_key(&key) { + Ok(e) => e, + Err(_) => return error_response("Failed to encrypt string"), + }; + + CString::new(encrypted.to_string()).unwrap().into_raw() +} + /// # Safety /// /// The `str` pointer must be a valid pointer previously returned by `CString::into_raw` @@ -150,3 +324,245 @@ pub unsafe extern "C" fn free_c_string(str: *mut c_char) { drop(CString::from_raw(str)); } } + +#[cfg(test)] +mod tests { + use super::*; + use bitwarden_vault::{Cipher, CipherType, LoginView}; + + fn create_test_cipher_view() -> CipherView { + CipherView { + id: None, + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: "Test Login".to_string(), + notes: Some("Secret notes".to_string()), + r#type: CipherType::Login, + login: Some(LoginView { + username: Some("testuser@example.com".to_string()), + password: Some("SuperSecretP@ssw0rd!".to_string()), + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: bitwarden_vault::CipherRepromptType::None, + organization_use_totp: false, + edit: true, + permissions: None, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2025-01-01T00:00:00Z".parse().unwrap(), + deleted_date: None, + revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), + archived_date: None, + } + } + + fn call_encrypt_cipher(cipher_json: &str, key_b64: &str) -> String { + let cipher_cstr = CString::new(cipher_json).unwrap(); + let key_cstr = CString::new(key_b64).unwrap(); + + let result_ptr = unsafe { encrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) }; + let result_cstr = unsafe { CStr::from_ptr(result_ptr) }; + let result = result_cstr.to_str().unwrap().to_owned(); + unsafe { free_c_string(result_ptr as *mut c_char) }; + + result + } + + fn make_test_key_b64() -> String { + SymmetricCryptoKey::make_aes256_cbc_hmac_key() + .to_base64() + .into() + } + + #[test] + fn encrypt_cipher_produces_encrypted_fields() { + let key_b64 = make_test_key_b64(); + let cipher_view = create_test_cipher_view(); + let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + + let encrypted_json = call_encrypt_cipher(&cipher_json, &key_b64); + + assert!( + !encrypted_json.contains("\"error\""), + "Got error: {}", + encrypted_json + ); + + let encrypted_cipher: Cipher = + serde_json::from_str(&encrypted_json).expect("Failed to parse encrypted cipher JSON"); + + let encrypted_name = encrypted_cipher.name.to_string(); + assert!( + encrypted_name.starts_with("2."), + "Name should be encrypted: {}", + encrypted_name + ); + + let login = encrypted_cipher.login.expect("Login should be present"); + if let Some(username) = &login.username { + assert!( + username.to_string().starts_with("2."), + "Username should be encrypted" + ); + } + if let Some(password) = &login.password { + assert!( + password.to_string().starts_with("2."), + "Password should be encrypted" + ); + } + } + + #[test] + fn encrypt_cipher_works_with_generated_org_key() { + let org_keys_ptr = unsafe { generate_organization_keys() }; + let org_keys_cstr = unsafe { CStr::from_ptr(org_keys_ptr) }; + let org_keys_json = org_keys_cstr.to_str().unwrap().to_owned(); + unsafe { free_c_string(org_keys_ptr as *mut c_char) }; + + let org_keys: serde_json::Value = serde_json::from_str(&org_keys_json).unwrap(); + let org_key_b64 = org_keys["key"].as_str().unwrap(); + + let cipher_view = create_test_cipher_view(); + let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + + let encrypted_json = call_encrypt_cipher(&cipher_json, org_key_b64); + + assert!( + !encrypted_json.contains("\"error\""), + "Got error: {}", + encrypted_json + ); + + let encrypted_cipher: Cipher = serde_json::from_str(&encrypted_json).unwrap(); + assert!(encrypted_cipher.name.to_string().starts_with("2.")); + } + + #[test] + fn encrypt_cipher_rejects_invalid_json() { + let key_b64 = make_test_key_b64(); + + let error_json = call_encrypt_cipher("{ this is not valid json }", &key_b64); + + assert!( + error_json.contains("\"error\""), + "Should return error for invalid JSON" + ); + assert!(error_json.contains("Failed to parse CipherView JSON")); + } + + #[test] + fn encrypt_cipher_rejects_invalid_base64_key() { + let cipher_view = create_test_cipher_view(); + let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + + let error_json = call_encrypt_cipher(&cipher_json, "not-valid-base64!!!"); + + assert!( + error_json.contains("\"error\""), + "Should return error for invalid base64" + ); + assert!(error_json.contains("Failed to decode base64 key")); + } + + #[test] + fn encrypt_cipher_rejects_wrong_key_length() { + let cipher_view = create_test_cipher_view(); + let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + let short_key_b64 = STANDARD.encode(b"too short"); + + let error_json = call_encrypt_cipher(&cipher_json, &short_key_b64); + + assert!( + error_json.contains("\"error\""), + "Should return error for wrong key length" + ); + assert!(error_json.contains("invalid key format or length")); + } + + fn call_decrypt_cipher(cipher_json: &str, key_b64: &str) -> String { + let cipher_cstr = CString::new(cipher_json).unwrap(); + let key_cstr = CString::new(key_b64).unwrap(); + + let result_ptr = unsafe { decrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) }; + let result_cstr = unsafe { CStr::from_ptr(result_ptr) }; + let result = result_cstr.to_str().unwrap().to_owned(); + unsafe { free_c_string(result_ptr as *mut c_char) }; + + result + } + + #[test] + fn encrypt_decrypt_roundtrip_preserves_plaintext() { + let key_b64 = make_test_key_b64(); + let original_view = create_test_cipher_view(); + let original_json = serde_json::to_string(&original_view).unwrap(); + + let encrypted_json = call_encrypt_cipher(&original_json, &key_b64); + assert!( + !encrypted_json.contains("\"error\""), + "Encryption failed: {}", + encrypted_json + ); + + let decrypted_json = call_decrypt_cipher(&encrypted_json, &key_b64); + assert!( + !decrypted_json.contains("\"error\""), + "Decryption failed: {}", + decrypted_json + ); + + let decrypted_view: CipherView = serde_json::from_str(&decrypted_json) + .expect("Failed to parse decrypted CipherView"); + + assert_eq!(decrypted_view.name, original_view.name); + assert_eq!(decrypted_view.notes, original_view.notes); + + let original_login = original_view.login.expect("Original should have login"); + let decrypted_login = decrypted_view.login.expect("Decrypted should have login"); + + assert_eq!(decrypted_login.username, original_login.username); + assert_eq!(decrypted_login.password, original_login.password); + } + + #[test] + fn decrypt_cipher_rejects_wrong_key() { + let encrypt_key = make_test_key_b64(); + let wrong_key = make_test_key_b64(); + + let original_view = create_test_cipher_view(); + let original_json = serde_json::to_string(&original_view).unwrap(); + + let encrypted_json = call_encrypt_cipher(&original_json, &encrypt_key); + assert!(!encrypted_json.contains("\"error\"")); + + let decrypted_json = call_decrypt_cipher(&encrypted_json, &wrong_key); + + // Decryption with wrong key should fail or produce garbage + // The SDK may return an error or the MAC validation will fail + let result: Result = serde_json::from_str(&decrypted_json); + if !decrypted_json.contains("\"error\"") { + // If no error, the decrypted data should not match original + if let Ok(view) = result { + assert_ne!( + view.name, original_view.name, + "Decryption with wrong key should not produce original plaintext" + ); + } + } + } +} diff --git a/util/Seeder/.claude/CLAUDE.md b/util/Seeder/.claude/CLAUDE.md new file mode 100644 index 0000000000..2e75494662 --- /dev/null +++ b/util/Seeder/.claude/CLAUDE.md @@ -0,0 +1,199 @@ +# Seeder - Claude Code Context + +## When to Use the Seeder + +✅ Use for: + +- 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) + +## Zero-Knowledge Architecture + +**Critical Principle:** Unencrypted vault data never leaves the client. The server never sees plaintext. + +### Why Seeder Uses the Rust SDK + +The Seeder must behave exactly like any other Bitwarden client. Since the server: + +- 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:** + +``` +CipherView (plaintext) → encrypt_composite() → Cipher (encrypted) +``` + +**Decryption flow:** + +``` +Cipher (encrypted) → decrypt() → CipherView (plaintext) +``` + +### EncString Format + +All encrypted fields use the EncString format: + +``` +2.{base64_iv}|{base64_data}|{base64_mac} +│ └──────────┘ └──────────┘ └──────────┘ +│ IV Ciphertext HMAC +└─ Type 2 = AES-256-CBC-HMAC-SHA256 +``` + +### SDK vs Server Format Difference + +**Critical:** The SDK and server use different JSON structures. + +**SDK Cipher (nested):** + +```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..." +} +``` + +The `CipherSeeder.TransformToServerCipher()` method performs this flattening. + +### 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 Files + +| File | Purpose | +| ------------------------------ | ------------------------------------------------------ | +| `Models/CipherViewDto.cs` | Plaintext input matching SDK's CipherView | +| `Models/EncryptedCipherDto.cs` | Parses SDK's encrypted Cipher output | +| `Factories/CipherSeeder.cs` | Creates encrypted ciphers, transforms to server format | +| `Recipes/CiphersRecipe.cs` | Bulk cipher creation with collection assignment | + +### 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 + +### Available Functions + +| Function | Input | Output | +| ---------------------------- | --------------------- | --------------------------- | +| `encrypt_cipher` | CipherView JSON + key | Cipher JSON | +| `decrypt_cipher` | Cipher JSON + key | CipherView JSON | +| `generate_organization_keys` | (none) | Org symmetric key + keypair | + +### 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/RustSdkCipherTests.cs` 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 + +```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); +``` + +## 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 diff --git a/util/Seeder/Data/Companies.cs b/util/Seeder/Data/Companies.cs new file mode 100644 index 0000000000..d37c2f810a --- /dev/null +++ b/util/Seeder/Data/Companies.cs @@ -0,0 +1,123 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +internal sealed record Company( + string Domain, + string Name, + CompanyCategory Category, + CompanyType Type, + GeographicRegion Region); + +/// +/// Sample company data organized by region. Add new regions by creating arrays and including them in All. +/// +internal static class Companies +{ + public static readonly Company[] NorthAmerica = + [ + // CRM & Sales + new("salesforce.com", "Salesforce", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("hubspot.com", "HubSpot", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Security + new("crowdstrike.com", "CrowdStrike", CompanyCategory.Security, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("okta.com", "Okta", CompanyCategory.Security, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Observability & DevOps + new("datadog.com", "Datadog", CompanyCategory.DevOps, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("splunk.com", "Splunk", CompanyCategory.Analytics, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("pagerduty.com", "PagerDuty", CompanyCategory.ITServiceManagement, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Cloud & Infrastructure + new("snowflake.com", "Snowflake", CompanyCategory.CloudInfrastructure, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // HR & Workforce + new("workday.com", "Workday", CompanyCategory.HRTalent, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("servicenow.com", "ServiceNow", CompanyCategory.ITServiceManagement, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Consumer Tech Giants + new("google.com", "Google", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("meta.com", "Meta", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("amazon.com", "Amazon", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("netflix.com", "Netflix", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + // Developer Tools + new("github.com", "GitHub", CompanyCategory.Developer, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("stripe.com", "Stripe", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + // Collaboration + new("slack.com", "Slack", CompanyCategory.Collaboration, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("zoom.us", "Zoom", CompanyCategory.Collaboration, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("dropbox.com", "Dropbox", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + // Streaming + new("spotify.com", "Spotify", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica) + ]; + + public static readonly Company[] Europe = + [ + // Enterprise Software + new("sap.com", "SAP", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe), + new("elastic.co", "Elastic", CompanyCategory.Analytics, CompanyType.Enterprise, GeographicRegion.Europe), + new("atlassian.com", "Atlassian", CompanyCategory.ProjectManagement, CompanyType.Enterprise, GeographicRegion.Europe), + // Fintech + new("wise.com", "Wise", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("revolut.com", "Revolut", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("klarna.com", "Klarna", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("n26.com", "N26", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + // Developer Tools + new("gitlab.com", "GitLab", CompanyCategory.DevOps, CompanyType.Enterprise, GeographicRegion.Europe), + new("contentful.com", "Contentful", CompanyCategory.Developer, CompanyType.Enterprise, GeographicRegion.Europe), + // Consumer Services + new("deliveroo.com", "Deliveroo", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Europe), + new("booking.com", "Booking.com", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Europe), + // Collaboration + new("miro.com", "Miro", CompanyCategory.Collaboration, CompanyType.Enterprise, GeographicRegion.Europe), + new("intercom.io", "Intercom", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.Europe), + // Business Software + new("sage.com", "Sage", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe), + new("adyen.com", "Adyen", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.Europe) + ]; + + public static readonly Company[] AsiaPacific = + [ + // Chinese Tech Giants + new("alibaba.com", "Alibaba", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.AsiaPacific), + new("tencent.com", "Tencent", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("bytedance.com", "ByteDance", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("wechat.com", "WeChat", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Japanese Companies + new("rakuten.com", "Rakuten", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("line.me", "Line", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sony.com", "Sony", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("paypay.ne.jp", "PayPay", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Korean Companies + new("samsung.com", "Samsung", CompanyCategory.Productivity, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Southeast Asian Companies + new("grab.com", "Grab", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sea.com", "Sea Limited", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("shopee.com", "Shopee", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("lazada.com", "Lazada", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("gojek.com", "Gojek", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Indian Companies + new("flipkart.com", "Flipkart", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific) + ]; + + public static readonly Company[] All = [.. NorthAmerica, .. Europe, .. AsiaPacific]; + + public static Company[] Filter( + CompanyType? type = null, + GeographicRegion? region = null, + CompanyCategory? category = null) + { + IEnumerable result = All; + + if (type.HasValue) + { + result = result.Where(c => c.Type == type.Value); + } + if (region.HasValue) + { + result = result.Where(c => c.Region == region.Value); + } + if (category.HasValue) + { + result = result.Where(c => c.Category == category.Value); + } + + return [.. result]; + } +} diff --git a/util/Seeder/Data/Enums/CompanyCategory.cs b/util/Seeder/Data/Enums/CompanyCategory.cs new file mode 100644 index 0000000000..cee7e0c583 --- /dev/null +++ b/util/Seeder/Data/Enums/CompanyCategory.cs @@ -0,0 +1,11 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Business category for company classification. +/// +public enum CompanyCategory +{ + SocialMedia, Streaming, ECommerce, CRM, Security, CloudInfrastructure, + DevOps, Collaboration, HRTalent, FinanceERP, Analytics, ProjectManagement, + Marketing, ITServiceManagement, Productivity, Developer, Financial +} diff --git a/util/Seeder/Data/Enums/CompanyType.cs b/util/Seeder/Data/Enums/CompanyType.cs new file mode 100644 index 0000000000..a09e060589 --- /dev/null +++ b/util/Seeder/Data/Enums/CompanyType.cs @@ -0,0 +1,6 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Target market type for companies. +/// +public enum CompanyType { Consumer, Enterprise, Hybrid } diff --git a/util/Seeder/Data/Enums/GeographicRegion.cs b/util/Seeder/Data/Enums/GeographicRegion.cs new file mode 100644 index 0000000000..55180e7f04 --- /dev/null +++ b/util/Seeder/Data/Enums/GeographicRegion.cs @@ -0,0 +1,9 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Geographic region for company headquarters. +/// +public enum GeographicRegion +{ + NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, Global +} diff --git a/util/Seeder/Data/Enums/OrgStructureModel.cs b/util/Seeder/Data/Enums/OrgStructureModel.cs new file mode 100644 index 0000000000..675d0e758f --- /dev/null +++ b/util/Seeder/Data/Enums/OrgStructureModel.cs @@ -0,0 +1,6 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Organizational structure model types. +/// +public enum OrgStructureModel { Traditional, Spotify, Modern } diff --git a/util/Seeder/Data/Enums/PasswordStrength.cs b/util/Seeder/Data/Enums/PasswordStrength.cs new file mode 100644 index 0000000000..0c39fe8645 --- /dev/null +++ b/util/Seeder/Data/Enums/PasswordStrength.cs @@ -0,0 +1,6 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Password strength levels for test data generation. +/// +public enum PasswordStrength { Weak, Medium, Strong, Mixed } diff --git a/util/Seeder/Data/Enums/UsernamePatternType.cs b/util/Seeder/Data/Enums/UsernamePatternType.cs new file mode 100644 index 0000000000..2c8083ca9d --- /dev/null +++ b/util/Seeder/Data/Enums/UsernamePatternType.cs @@ -0,0 +1,20 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Username/email format patterns used by organizations. +/// +public enum UsernamePatternType +{ + /// first.last@domain.com + FirstDotLast, + /// f.last@domain.com + FDotLast, + /// flast@domain.com + FLast, + /// last.first@domain.com + LastDotFirst, + /// first_last@domain.com + First_Last, + /// lastf@domain.com + LastFirst +} diff --git a/util/Seeder/Data/Names.cs b/util/Seeder/Data/Names.cs new file mode 100644 index 0000000000..dc493cd0e9 --- /dev/null +++ b/util/Seeder/Data/Names.cs @@ -0,0 +1,80 @@ +namespace Bit.Seeder.Data; + +/// +/// First and last names organized by region for username generation. +/// Add new regions by creating arrays and including them in the All* properties. +/// +internal static class Names +{ + public static readonly string[] UsFirstNames = + [ + // Male + "James", "Robert", "John", "Michael", "David", "William", "Richard", "Joseph", "Thomas", "Christopher", + "Charles", "Daniel", "Matthew", "Anthony", "Mark", "Donald", "Steven", "Paul", "Andrew", "Joshua", + "Kenneth", "Kevin", "Brian", "George", "Timothy", "Ronald", "Edward", "Jason", "Jeffrey", "Ryan", + "Jacob", "Gary", "Nicholas", "Eric", "Jonathan", "Stephen", "Larry", "Justin", "Scott", "Brandon", + "Benjamin", "Samuel", "Raymond", "Gregory", "Frank", "Alexander", "Patrick", "Jack", "Dennis", "Jerry", + // Female + "Mary", "Patricia", "Jennifer", "Linda", "Barbara", "Elizabeth", "Susan", "Jessica", "Sarah", "Karen", + "Lisa", "Nancy", "Betty", "Margaret", "Sandra", "Ashley", "Kimberly", "Emily", "Donna", "Michelle", + "Dorothy", "Carol", "Amanda", "Melissa", "Deborah", "Stephanie", "Rebecca", "Sharon", "Laura", "Cynthia", + "Kathleen", "Amy", "Angela", "Shirley", "Anna", "Brenda", "Pamela", "Emma", "Nicole", "Helen", + "Samantha", "Katherine", "Christine", "Debra", "Rachel", "Carolyn", "Janet", "Catherine", "Maria", "Heather" + ]; + + public static readonly string[] UsLastNames = + [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", + "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", + "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", + "Walker", "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores", + "Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", "Carter", "Roberts", + "Gomez", "Phillips", "Evans", "Turner", "Diaz", "Parker", "Cruz", "Edwards", "Collins", "Reyes", + "Stewart", "Morris", "Morales", "Murphy", "Cook", "Rogers", "Gutierrez", "Ortiz", "Morgan", "Cooper", + "Peterson", "Bailey", "Reed", "Kelly", "Howard", "Ramos", "Kim", "Cox", "Ward", "Richardson", + "Watson", "Brooks", "Chavez", "Wood", "James", "Bennett", "Gray", "Mendoza", "Ruiz", "Hughes", + "Price", "Alvarez", "Castillo", "Sanders", "Patel", "Myers", "Long", "Ross", "Foster", "Jimenez" + ]; + + public static readonly string[] EuropeanFirstNames = + [ + // British + "Oliver", "George", "Harry", "Jack", "Charlie", "Thomas", "Oscar", "William", "James", "Henry", + "Olivia", "Amelia", "Isla", "Ava", "Emily", "Sophie", "Grace", "Mia", "Poppy", "Ella", + // German + "Maximilian", "Alexander", "Paul", "Leon", "Lukas", "Felix", "Noah", "Elias", "Ben", "Finn", + "Emma", "Hannah", "Mia", "Sofia", "Anna", "Emilia", "Lena", "Marie", "Lea", "Clara", + // French + "Gabriel", "Raphael", "Leo", "Louis", "Lucas", "Adam", "Hugo", "Jules", "Arthur", "Nathan", + "Louise", "Alice", "Chloe", "Ines", "Lea", "Manon", "Rose", "Anna", "Lina", "Mila", + // Spanish + "Hugo", "Martin", "Lucas", "Daniel", "Pablo", "Alejandro", "Adrian", "Alvaro", "David", "Mario", + "Lucia", "Sofia", "Maria", "Martina", "Paula", "Julia", "Daniela", "Valeria", "Alba", "Emma", + // Italian + "Leonardo", "Francesco", "Alessandro", "Lorenzo", "Mattia", "Andrea", "Gabriele", "Riccardo", "Tommaso", "Edoardo", + "Sofia", "Giulia", "Aurora", "Alice", "Ginevra", "Emma", "Giorgia", "Greta", "Beatrice", "Anna" + ]; + + public static readonly string[] EuropeanLastNames = + [ + // British + "Smith", "Jones", "Williams", "Brown", "Taylor", "Davies", "Wilson", "Evans", "Thomas", "Johnson", + "Roberts", "Walker", "Wright", "Robinson", "Thompson", "White", "Hughes", "Edwards", "Green", "Hall", + // German + "Mueller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner", "Becker", "Schulz", "Hoffmann", + "Schaefer", "Koch", "Bauer", "Richter", "Klein", "Wolf", "Schroeder", "Neumann", "Schwarz", "Zimmermann", + // French + "Martin", "Bernard", "Dubois", "Thomas", "Robert", "Richard", "Petit", "Durand", "Leroy", "Moreau", + "Simon", "Laurent", "Lefebvre", "Michel", "Garcia", "David", "Bertrand", "Roux", "Vincent", "Fournier", + // Spanish + "Garcia", "Rodriguez", "Martinez", "Lopez", "Sanchez", "Gonzalez", "Perez", "Martin", "Gomez", "Ruiz", + "Hernandez", "Jimenez", "Diaz", "Moreno", "Alvarez", "Munoz", "Romero", "Alonso", "Gutierrez", "Navarro", + // Italian + "Rossi", "Russo", "Ferrari", "Esposito", "Bianchi", "Romano", "Colombo", "Ricci", "Marino", "Greco", + "Bruno", "Gallo", "Conti", "DeLuca", "Costa", "Giordano", "Mancini", "Rizzo", "Lombardi", "Moretti" + ]; + + public static readonly string[] AllFirstNames = [.. UsFirstNames, .. EuropeanFirstNames]; + + public static readonly string[] AllLastNames = [.. UsLastNames, .. EuropeanLastNames]; +} diff --git a/util/Seeder/Data/OrgStructures.cs b/util/Seeder/Data/OrgStructures.cs new file mode 100644 index 0000000000..668653cd37 --- /dev/null +++ b/util/Seeder/Data/OrgStructures.cs @@ -0,0 +1,84 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +internal sealed record OrgUnit(string Name, string[]? SubUnits = null); + +internal sealed record OrgStructure(OrgStructureModel Model, OrgUnit[] Units); + +/// +/// Pre-defined organizational structures for different company models. +/// +internal static class OrgStructures +{ + public static readonly OrgStructure Traditional = new(OrgStructureModel.Traditional, + [ + new("Executive", ["CEO Office", "Strategy", "Board Relations"]), + new("Finance", ["Accounting", "FP&A", "Treasury", "Tax", "Audit"]), + new("Human Resources", ["Recruiting", "Benefits", "Training", "Employee Relations", "Compensation"]), + new("Information Technology", ["Infrastructure", "Security", "Support", "Enterprise Apps", "Network"]), + new("Marketing", ["Brand", "Digital Marketing", "Content", "Events", "PR"]), + new("Sales", ["Enterprise Sales", "SMB Sales", "Sales Operations", "Account Management", "Inside Sales"]), + new("Operations", ["Facilities", "Procurement", "Supply Chain", "Quality", "Business Operations"]), + new("Research & Development", ["Product Development", "Innovation", "Research", "Prototyping"]), + new("Legal", ["Corporate Legal", "Compliance", "Contracts", "IP", "Privacy"]), + new("Customer Success", ["Onboarding", "Support", "Customer Education", "Renewals"]), + new("Engineering", ["Backend", "Frontend", "Mobile", "QA", "DevOps", "Platform"]), + new("Product", ["Product Management", "UX Design", "User Research", "Product Analytics"]) + ]); + + public static readonly OrgStructure Spotify = new(OrgStructureModel.Spotify, + [ + // Tribes + new("Payments Tribe", ["Checkout Squad", "Fraud Prevention Squad", "Billing Squad", "Payment Methods Squad"]), + new("Growth Tribe", ["Acquisition Squad", "Activation Squad", "Retention Squad", "Monetization Squad"]), + new("Platform Tribe", ["API Squad", "Infrastructure Squad", "Data Platform Squad", "Developer Tools Squad"]), + new("Experience Tribe", ["Web App Squad", "Mobile Squad", "Desktop Squad", "Accessibility Squad"]), + // Chapters + new("Backend Chapter", ["Java Developers", "Go Developers", "Python Developers", "Database Specialists"]), + new("Frontend Chapter", ["React Developers", "TypeScript Specialists", "Performance Engineers", "UI Engineers"]), + new("QA Chapter", ["Test Automation", "Manual Testing", "Performance Testing", "Security Testing"]), + new("Design Chapter", ["Product Designers", "UX Researchers", "Visual Designers", "Design Systems"]), + new("Data Science Chapter", ["ML Engineers", "Data Analysts", "Data Engineers", "AI Researchers"]), + // Guilds + new("Security Guild"), + new("Innovation Guild"), + new("Architecture Guild"), + new("Accessibility Guild"), + new("Developer Experience Guild") + ]); + + public static readonly OrgStructure Modern = new(OrgStructureModel.Modern, + [ + // Feature Teams + new("Auth Team", ["Identity", "SSO", "MFA", "Passwordless"]), + new("Search Team", ["Indexing", "Ranking", "Query Processing", "Search UX"]), + new("Notifications Team", ["Email", "Push", "In-App", "Preferences"]), + new("Analytics Team", ["Tracking", "Dashboards", "Reporting", "Data Pipeline"]), + new("Integrations Team", ["API Gateway", "Webhooks", "Third-Party Apps", "Marketplace"]), + // Platform Teams + new("Developer Experience", ["SDK", "Documentation", "Developer Portal", "API Design"]), + new("Data Platform", ["Data Lake", "ETL", "Data Governance", "Real-Time Processing"]), + new("ML Platform", ["Model Training", "Model Serving", "Feature Store", "MLOps"]), + new("Security Platform", ["AppSec", "Infrastructure Security", "Security Tooling", "Compliance"]), + new("Infrastructure Platform", ["Cloud", "Kubernetes", "Observability", "CI/CD"]), + // Pods + new("AI Assistant Pod", ["LLM Integration", "Prompt Engineering", "AI UX", "AI Safety"]), + new("Performance Pod", ["Frontend Performance", "Backend Performance", "Database Optimization"]), + new("Compliance Pod", ["SOC 2", "GDPR", "HIPAA", "Audit"]), + new("Migration Pod", ["Legacy Systems", "Data Migration", "Cutover Planning"]), + // Enablers + new("Architecture", ["Technical Strategy", "System Design", "Tech Debt"]), + new("Quality", ["Testing Strategy", "Release Quality", "Production Health"]) + ]); + + public static readonly OrgStructure[] All = [Traditional, Spotify, Modern]; + + public static OrgStructure GetStructure(OrgStructureModel model) => model switch + { + OrgStructureModel.Traditional => Traditional, + OrgStructureModel.Spotify => Spotify, + OrgStructureModel.Modern => Modern, + _ => Traditional + }; +} diff --git a/util/Seeder/Data/Passwords.cs b/util/Seeder/Data/Passwords.cs new file mode 100644 index 0000000000..b39f0ae48a --- /dev/null +++ b/util/Seeder/Data/Passwords.cs @@ -0,0 +1,67 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +/// +/// Password collections by strength level for realistic test data. +/// +internal static class Passwords +{ + /// + /// Top breached passwords - use for security testing scenarios. + /// + public static readonly string[] Weak = + [ + "password", "123456", "qwerty", "abc123", "letmein", "welcome", "admin", "dragon", "sunshine", "princess", + "football", "master", "shadow", "superman", "trustno1", "iloveyou", "passw0rd", "p@ssw0rd", "welcome1", "Password1", + "qwerty123", "123qwe", "1q2w3e", "password123", "12345678", "111111", "1234567890", "monkey", "baseball", "access" + ]; + + /// + /// Meets basic complexity requirements but follows predictable patterns (season+year, name+numbers). + /// + public static readonly string[] Medium = + [ + "Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!", "December2023#", + "Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login123!", "Portal2024#", + "Michael123!", "Jennifer2024@", "Robert456#", "Sarah789!", + "Qwerty123!", "Asdfgh456@", "Zxcvbn789#", + "Password123!", "Security2024@", "Admin123!", "User2024#", "Guest123!", "Test2024@", + "Football123!", "Baseball2024@", "Soccer456#", "Hockey789!" + ]; + + /// + /// High-entropy passwords: random strings (password manager style) and diceware passphrases. + /// + public 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$", "Jd2#pR9!vN5@bG7$", "Ht6@wL3#yK8!mQ4$", "Bf8$cM2@zT5#rX9!", "Lg1!nV7@sH4#pY6$", + "Kx9#mL4$pQ7@wR2!vN5", "Yz3@hT8#bF1$cS6!nM9", "Wv5!rK2@jG9#tX4$mL7", "Qn7$sB3@yH6#pC1!zF8", "Tm2@xD5#kW9$vL4!rJ7", + "correct-horse-battery-staple", "purple-monkey-dishwasher-lamp", "quantum-bicycle-elephant-storm", + "velvet-thunder-crystal-forge", "neon-wizard-cosmic-river", "amber-phoenix-digital-maze", + "silver-falcon-ancient-code", "lunar-garden-frozen-spark", "echo-prism-wandering-light", "rust-vapor-hidden-gate", + "Brave.Tiger.Runs.Fast.42", "Blue.Ocean.Deep.Wave.17", "Swift.Eagle.Soars.High.93", + "Calm.Forest.Green.Path.28", "Warm.Summer.Golden.Sun.61", + "maple#stream#winter#glow", "ember@cloud@silent@peak", "frost$dawn$valley$mist", "coral!reef!azure!tide", "stone&moss&ancient&oak", + "Kx9mL4pQ7wR2vN5hT8bF", "Yz3hT8bF1cS6nM9wK4pL", "Wv5rK2jG9tX4mL7nB3sH", "Qn7sB3yH6pC1zF8kW2xD", "Tm2xD5kW9vL4rJ7gN1cY" + ]; + + /// Must be declared after strength arrays (S3263). + public static readonly string[] All = [.. Weak, .. Medium, .. Strong]; + + public static string[] GetByStrength(PasswordStrength strength) => strength switch + { + PasswordStrength.Weak => Weak, + PasswordStrength.Medium => Medium, + PasswordStrength.Strong => Strong, + PasswordStrength.Mixed => All, + _ => Strong + }; + + public static string GetPassword(PasswordStrength strength, int index) + { + var passwords = GetByStrength(strength); + return passwords[index % passwords.Length]; + } +} diff --git a/util/Seeder/Data/README.md b/util/Seeder/Data/README.md new file mode 100644 index 0000000000..7c16242a0c --- /dev/null +++ b/util/Seeder/Data/README.md @@ -0,0 +1,144 @@ +# Seeder Data System + +Structured data generation for realistic vault seeding. Designed for extensibility and spec-driven generation. + +## Architecture + +Foundation layer for all cipher generation—data and patterns that future cipher types build upon. + +- **Enums are the API.** Configure via `CompanyType`, `PasswordStrength`, etc. Everything else is internal. +- **Composable by region.** Arrays aggregate with `[.. UsNames, .. EuropeanNames]`. New region = new array + one line change. +- **Deterministic.** Seeded randomness means same org ID → same test data → reproducible debugging. +- **Filterable.** `Companies.Filter(type, region, category)` for targeted data selection. + +--- + +## Current Capabilities + +### Login Ciphers + +- 50 real companies across 3 regions with metadata (category, type, domain) +- 200 first names + 200 last names (US, European) +- 6 username patterns (corporate email conventions) +- 3 password strength levels (95 total passwords) + +### Organizational Structures + +- Traditional (departments + sub-units) +- Spotify Model (tribes, squads, chapters, guilds) +- Modern/AI-First (feature teams, platform teams, pods) + +--- + +## Roadmap + +### Phase 1: Additional Cipher Types + +| 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 | + +### Phase 2: Spec-Driven Generation + +Import a specification file and generate a complete vault to match: + +```yaml +# Example: organization-spec.yaml +organization: + name: "Acme Corp" + users: 500 + +collections: + structure: spotify # Use Spotify org model + +ciphers: + logins: + count: 2000 + companies: + type: enterprise + region: north_america + passwords: mixed # Realistic distribution + username_pattern: first_dot_last + + cards: + count: 100 + networks: [visa, mastercard, amex] + + identities: + count: 200 + regions: [us, europe] + + secure_notes: + count: 300 + categories: [api_keys, licenses, documentation] +``` + +**Spec Engine Components (Future)** + +- `SpecParser` - YAML/JSON spec file parsing +- `SpecValidator` - Schema validation +- `SpecExecutor` - Orchestrates generation from spec +- `ProgressReporter` - Real-time generation progress + +### Phase 3: Data Enhancements + +| Enhancement | Description | +| ----------------------- | ---------------------------------------------------- | +| **Additional Regions** | LatinAmerica, MiddleEast, Africa companies and names | +| **Industry Verticals** | Healthcare, Finance, Government-specific companies | +| **Localized Passwords** | Region-specific common passwords | +| **Custom Fields** | Field templates per cipher type | +| **TOTP Seeds** | Realistic 2FA seed generation | +| **Attachments** | File attachment simulation | +| **Password History** | Historical password entries | + +### Phase 4: Advanced Features + +- **Relationship Graphs** - Ciphers that reference each other (SSO relationships) +- **Temporal Data** - Realistic created/modified timestamps over time +- **Access Patterns** - Simulate realistic collection/group membership distributions +- **Breach Simulation** - Mark specific passwords as "exposed" for security testing + +--- + +## Adding New Data + +### New Region (e.g., Swedish Names) + +```csharp +// In Names.cs - add array +public static readonly string[] SwedishFirstNames = ["Erik", "Lars", "Anna", ...]; +public static readonly string[] SwedishLastNames = ["Andersson", "Johansson", ...]; + +// Update aggregates +public static readonly string[] AllFirstNames = [.. UsFirstNames, .. EuropeanFirstNames, .. SwedishFirstNames]; +public static readonly string[] AllLastNames = [.. UsLastNames, .. EuropeanLastNames, .. SwedishLastNames]; +``` + +### New Company Category + +```csharp +// In Enums/CompanyCategory.cs +public enum CompanyCategory +{ + // ... existing ... + Healthcare, // Add new category + Government +} + +// In Companies.cs - add companies with new category +new("epic.com", "Epic Systems", CompanyCategory.Healthcare, CompanyType.Enterprise, GeographicRegion.NorthAmerica), +``` + +### New Password Pattern + +```csharp +// In Passwords.cs - add to appropriate strength array +// Strong array - add new passphrase style +"correct-horse-battery-staple", // Diceware +"Brave.Tiger.Runs.Fast.42", // Mixed case with numbers +"maple#stream#winter#glow", // Symbol-separated (new) +``` diff --git a/util/Seeder/Data/UsernameGenerator.cs b/util/Seeder/Data/UsernameGenerator.cs new file mode 100644 index 0000000000..42250bc7cd --- /dev/null +++ b/util/Seeder/Data/UsernameGenerator.cs @@ -0,0 +1,65 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +/// +/// Generates deterministic usernames for companies using configurable patterns. +/// +internal sealed class UsernameGenerator +{ + private readonly Random _random; + + private readonly UsernamePattern _pattern; + + private readonly string[] _firstNames; + + private readonly string[] _lastNames; + + public UsernameGenerator( + int seed, + UsernamePatternType patternType = UsernamePatternType.FirstDotLast, + GeographicRegion? region = null) + { + _random = new Random(seed); + _pattern = UsernamePatterns.GetPattern(patternType); + + (_firstNames, _lastNames) = region switch + { + GeographicRegion.NorthAmerica => (Names.UsFirstNames, Names.UsLastNames), + GeographicRegion.Europe => (Names.EuropeanFirstNames, Names.EuropeanLastNames), + _ => (Names.AllFirstNames, Names.AllLastNames) + }; + } + + public string Generate(Company company) + { + var firstName = _firstNames[_random.Next(_firstNames.Length)]; + var lastName = _lastNames[_random.Next(_lastNames.Length)]; + return _pattern.Generate(firstName, lastName, company.Domain); + } + + /// + /// Generates username using index for deterministic selection across cipher iterations. + /// + public string GenerateByIndex(Company company, int index) + { + var firstName = _firstNames[index % _firstNames.Length]; + var lastName = _lastNames[(index * 7) % _lastNames.Length]; // Prime multiplier for variety + return _pattern.Generate(firstName, lastName, company.Domain); + } + + /// + /// Combines deterministic index with random offset for controlled variety. + /// + public string GenerateVaried(Company company, int index) + { + var offset = _random.Next(10); + var firstName = _firstNames[(index + offset) % _firstNames.Length]; + var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length]; + return _pattern.Generate(firstName, lastName, company.Domain); + } + + public string GetFirstName(int index) => _firstNames[index % _firstNames.Length]; + + public string GetLastName(int index) => _lastNames[(index * 7) % _lastNames.Length]; +} diff --git a/util/Seeder/Data/UsernamePatterns.cs b/util/Seeder/Data/UsernamePatterns.cs new file mode 100644 index 0000000000..c435cacd93 --- /dev/null +++ b/util/Seeder/Data/UsernamePatterns.cs @@ -0,0 +1,57 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +internal sealed record UsernamePattern( + UsernamePatternType Type, + string FormatDescription, + Func Generate); + +/// +/// Username pattern implementations for different email conventions. +/// +internal static class UsernamePatterns +{ + public static readonly UsernamePattern FirstDotLast = new( + UsernamePatternType.FirstDotLast, + "first.last@domain", + (first, last, domain) => $"{first.ToLowerInvariant()}.{last.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern FDotLast = new( + UsernamePatternType.FDotLast, + "f.last@domain", + (first, last, domain) => $"{char.ToLowerInvariant(first[0])}.{last.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern FLast = new( + UsernamePatternType.FLast, + "flast@domain", + (first, last, domain) => $"{char.ToLowerInvariant(first[0])}{last.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern LastDotFirst = new( + UsernamePatternType.LastDotFirst, + "last.first@domain", + (first, last, domain) => $"{last.ToLowerInvariant()}.{first.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern First_Last = new( + UsernamePatternType.First_Last, + "first_last@domain", + (first, last, domain) => $"{first.ToLowerInvariant()}_{last.ToLowerInvariant()}@{domain}"); + + public static readonly UsernamePattern LastFirst = new( + UsernamePatternType.LastFirst, + "lastf@domain", + (first, last, domain) => $"{last.ToLowerInvariant()}{char.ToLowerInvariant(first[0])}@{domain}"); + + public static readonly UsernamePattern[] All = [FirstDotLast, FDotLast, FLast, LastDotFirst, First_Last, LastFirst]; + + public static UsernamePattern GetPattern(UsernamePatternType type) => type switch + { + UsernamePatternType.FirstDotLast => FirstDotLast, + UsernamePatternType.FDotLast => FDotLast, + UsernamePatternType.FLast => FLast, + UsernamePatternType.LastDotFirst => LastDotFirst, + UsernamePatternType.First_Last => First_Last, + UsernamePatternType.LastFirst => LastFirst, + _ => FirstDotLast + }; +} diff --git a/util/Seeder/Factories/CipherSeeder.cs b/util/Seeder/Factories/CipherSeeder.cs new file mode 100644 index 0000000000..0b31e98c0b --- /dev/null +++ b/util/Seeder/Factories/CipherSeeder.cs @@ -0,0 +1,158 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Models.Data; +using Bit.RustSDK; +using Bit.Seeder.Models; + +namespace Bit.Seeder.Factories; + +/// +/// Creates encrypted ciphers for seeding vaults via the Rust SDK. +/// +/// +/// Supported cipher types: +/// +/// Login - +/// +/// Future: Card, Identity, SecureNote will follow the same pattern—public Create method + private Transform method. +/// +public class CipherSeeder +{ + private readonly RustSdkService _sdkService; + + private static readonly JsonSerializerOptions SdkJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly JsonSerializerOptions ServerJsonOptions = new() + { + PropertyNamingPolicy = null, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public CipherSeeder(RustSdkService sdkService) + { + _sdkService = sdkService; + } + + public 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 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 Cipher EncryptAndTransform(CipherViewDto cipherView, string keyBase64, Guid organizationId) + { + var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions); + var encryptedJson = _sdkService.EncryptCipher(viewJson, keyBase64); + + if (encryptedJson.Contains("\"error\"")) + { + throw new InvalidOperationException($"Cipher encryption failed: {encryptedJson}"); + } + + var encryptedDto = JsonSerializer.Deserialize(encryptedJson, SdkJsonOptions) + ?? throw new InvalidOperationException("Failed to parse encrypted cipher"); + + return TransformLoginToServerCipher(encryptedDto, organizationId); + } + + private static Cipher TransformLoginToServerCipher(EncryptedCipherDto encrypted, Guid organizationId) + { + var loginData = new CipherLoginData + { + Name = encrypted.Name, + Notes = encrypted.Notes, + Username = encrypted.Login?.Username, + Password = encrypted.Login?.Password, + Totp = encrypted.Login?.Totp, + PasswordRevisionDate = encrypted.Login?.PasswordRevisionDate, + Uris = encrypted.Login?.Uris?.Select(u => new CipherLoginData.CipherLoginUriData + { + Uri = u.Uri, + UriChecksum = u.UriChecksum, + Match = u.Match.HasValue ? (UriMatchType?)u.Match : null + }), + Fields = encrypted.Fields?.Select(f => new CipherFieldData + { + Name = f.Name, + Value = f.Value, + Type = (FieldType)f.Type, + LinkedId = f.LinkedId + }) + }; + + var dataJson = JsonSerializer.Serialize(loginData, ServerJsonOptions); + + return new Cipher + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + UserId = null, + Type = CipherType.Login, + Data = dataJson, + Key = encrypted.Key, + Reprompt = (CipherRepromptType?)encrypted.Reprompt, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + } +} + diff --git a/util/Seeder/Factories/CollectionSeeder.cs b/util/Seeder/Factories/CollectionSeeder.cs new file mode 100644 index 0000000000..8d86335911 --- /dev/null +++ b/util/Seeder/Factories/CollectionSeeder.cs @@ -0,0 +1,36 @@ +using Bit.Core.Entities; +using Bit.RustSDK; + +namespace Bit.Seeder.Factories; + +public class CollectionSeeder(RustSdkService sdkService) +{ + public Collection CreateCollection(Guid organizationId, string orgKey, string name) + { + return new Collection + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + Name = sdkService.EncryptString(name, orgKey), + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + } + + public static CollectionUser CreateCollectionUser( + Guid collectionId, + Guid organizationUserId, + bool readOnly = false, + bool hidePasswords = false, + bool manage = false) + { + return new CollectionUser + { + CollectionId = collectionId, + OrganizationUserId = organizationUserId, + ReadOnly = readOnly, + HidePasswords = hidePasswords, + Manage = manage + }; + } +} diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 3aac87d400..fe09e6ddab 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -1,12 +1,17 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Seeder.Factories; public class OrganizationSeeder { + /// + /// Creates an enterprise organization without encryption keys. + /// Keys should be generated dynamically using RustSdkService.GenerateOrganizationKeys() + /// and assigned to PublicKey/PrivateKey after creation. + /// public static Organization CreateEnterprise(string name, string domain, int seats) { return new Organization @@ -39,52 +44,38 @@ public class OrganizationSeeder UseAdminSponsoredFamilies = true, SyncSeats = true, Status = OrganizationStatusType.Created, - //GatewayCustomerId = "example-customer-id", - //GatewaySubscriptionId = "example-subscription-id", MaxStorageGb = 10, - // Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs. - // TODO: These should be dynamically generated by the SDK. - PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB", - PrivateKey = "2.6FggyKVyaKQsfohi5yqgbg==|UU2JeafOB41L5UscGmf4kq15JGDf3Bkf67KECiehTODzbWctVLTgyDk0Qco8/6CMN6nZGXjxR2A4r5ExhmwRNsNxd77G+MprkmiJz+7w33ROZ1ouQO5XjD3wbQ3ssqNiTKId6yAUPBvuAZRixVApauTuADc8QWGixqCQcqZzmU7YSBBIPf652/AEYr4Tk64YihoE39pHiK8MRbTLdRt3EF4LSMugPAPM24vCgUv3w1TD3Fj6sDg/6oi3flOV9SJZX4vCiUXbDNEuD/p2aQrEXVbaxweFOHjTe7F4iawjXw3nG3SO8rUBHcxbhDDVx5rjYactbW5QvHWiyla6uLb6o8WHBneg2EjTEwAHOZE/rBjcqmAJb2sVp1E0Kwq8ycGmL69vmqJPC1GqVTohAQvmEkaxIPpfq24Yb9ZPrADA7iEXBKuAQ1FphFUVgJBJGJbd60sOV1Rz1T+gUwS4wCNQ4l3LG1S22+wzUVlEku5DXFnT932tatqTyWEthqPqLCt6dL1+qa94XLpeHagXAx2VGe8n8IlcADtxqS+l8xQ4heT12WO9kC316vqvg1mnsI56faup9hb3eT9ZpKyxSBGYOphlTWfV1Y/v64f5PYvTo4aL0IYHyLY/9Qi72vFmOpPeHBYgD5t3j+H2CsiU1PkYsBggOmD7xW8FDuT6HWVvwhEJqeibVPK0Lhyj6tgvlSIAvFUaSMFPlmwFNmwfj/AHUhr9KuTfsBFTZ10yy9TZVgf+EofwnrxHBaWUgdD40aHoY1VjfG33iEuajb6buxG3pYFyPNhJNzeLZisUKIDRMQpUHrsE22EyrFFran3tZGdtcyIEK4Q1F0ULYzJ6T9iY25/ZgPy3pEAAMZCtqo3s+GjX295fWIHfMcnjMgNUHPjExjWBHa+ggK9iQXkFpBVyYB1ga/+0eiIhiek3PlgtvpDrqF7TsLK+ROiBw2GJ7uaO3EEXOj2GpNBuEJ5CdodhZkwzhwMcSatgDHkUuNVu0iVbF6/MxVdOxWXKO+jCYM6PZk/vAhLYqpPzu2T2Uyz4nkDs2Tiq61ez6FoCrzdHIiyIxVTzUQH8G9FgSmtaZ7GCbqlhnurYgcMciwPzxg0hpAQT+NZw1tVEii9vFSpJJbGJqNhORKfKh/Mu1P/9LOQq7Y0P2FIR3x/eUVEQ7CGv2jVtO5ryGSmKeq/P9Fr54wTPaNiqN2K+leACUznCdUWw8kZo/AsBcrOe4OkRX6k8LC3oeJXy06DEToatxEvPYemUauhxiXRw8nfNMqc4LyJq2bbT0zCgJHoqpozPdNg6AYWcoIobgAGu7ZQGq+oE1MT3GZxotMPe/NUJiAc5YE9Thb5Yf3gyno71pyqPTVl/6IQuh4SUz7rkgwF/aVHEnr4aUYNoc0PEzd2Me0jElsA3GAneq1I/wngutOWgTViTK4Nptr5uIzMVQs9H1rOMJNorP8b02t1NDu010rSsib9GaaJJq4r4iy46laQOxWoU0ex26arYnk+jw4833WSCTVBIprTgizZ+fKjoY0xwXvI2oOvGNEUCtGFvKFORTaQrlaXZIg1toa2BBVNicyONbwnI3KIu3MgGJ2SlCVXJn8oHFppVHFCdwgN1uDzGiKAhjvr0sZTUtXin2f2CszPTbbo=|fUhbVKrr8CSKE7TZJneXpDGraj5YhRrq9ESo206S+BY=", + // PublicKey and PrivateKey intentionally not set - caller must generate and assign }; } } -public static class OrgnaizationExtensions +public static class OrganizationExtensions { /// - /// Creates an OrganizationUser with fields populated based on status. - /// For Invited status, only user.Email is used. For other statuses, user.Id is used. + /// Creates an OrganizationUser with a dynamically provided encrypted org key. + /// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey(). /// - public static OrganizationUser CreateOrganizationUser( - this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status) + public static OrganizationUser CreateOrganizationUserWithKey( + this Organization organization, + User user, + OrganizationUserType type, + OrganizationUserStatusType status, + string? encryptedOrgKey) { - var isInvited = status == OrganizationUserStatusType.Invited; - var isConfirmed = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; + var shouldLinkUserId = status != OrganizationUserStatusType.Invited; + var shouldIncludeKey = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; return new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = organization.Id, - UserId = isInvited ? null : user.Id, - Email = isInvited ? user.Email : null, - Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null, + UserId = shouldLinkUserId ? user.Id : null, + Email = shouldLinkUserId ? null : user.Email, + Key = shouldIncludeKey ? encryptedOrgKey : null, Type = type, Status = status }; } - public static OrganizationUser CreateSdkOrganizationUser(this Organization organization, User user) - { - return new OrganizationUser - { - Id = Guid.NewGuid(), - OrganizationId = organization.Id, - UserId = user.Id, - - Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==", - Type = OrganizationUserType.Admin, - Status = OrganizationUserStatusType.Confirmed - }; - } } diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index 4fc456981c..670dc2e96f 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -29,7 +29,7 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher + /// Default test password used for all seeded users. + /// + public const string DefaultPassword = "asdfasdfasdf"; + /// + /// Creates a user with SDK-generated cryptographic keys (no email mangling). + /// The user can log in with email and password = "asdfasdfasdf". + /// + public static User CreateUserWithSdkKeys( + string email, + RustSdkService sdkService, + IPasswordHasher passwordHasher) + { + var keys = sdkService.GenerateUserKeys(email, DefaultPassword); + + 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, + ApiKey = Guid.NewGuid().ToString("N")[..30], Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 600_000, + KdfIterations = 5_000, }; + + user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + + return user; } public Dictionary GetMangleMap(User user, UserData expectedUserData) diff --git a/util/Seeder/Models/CipherViewDto.cs b/util/Seeder/Models/CipherViewDto.cs new file mode 100644 index 0000000000..bd6ccfd6bf --- /dev/null +++ b/util/Seeder/Models/CipherViewDto.cs @@ -0,0 +1,153 @@ +using System.Text.Json.Serialization; + +namespace Bit.Seeder.Models; + +public class CipherViewDto +{ + [JsonPropertyName("id")] + public Guid? Id { get; set; } + + [JsonPropertyName("organizationId")] + public Guid? OrganizationId { get; set; } + + [JsonPropertyName("folderId")] + public Guid? FolderId { get; set; } + + [JsonPropertyName("collectionIds")] + public List CollectionIds { get; set; } = []; + + [JsonPropertyName("key")] + public string? Key { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("login")] + public LoginViewDto? Login { get; set; } + + [JsonPropertyName("identity")] + public object? Identity { get; set; } + + [JsonPropertyName("card")] + public object? Card { get; set; } + + [JsonPropertyName("secureNote")] + public object? SecureNote { get; set; } + + [JsonPropertyName("sshKey")] + public object? SshKey { get; set; } + + [JsonPropertyName("favorite")] + public bool Favorite { get; set; } + + [JsonPropertyName("reprompt")] + public int Reprompt { get; set; } + + [JsonPropertyName("organizationUseTotp")] + public bool OrganizationUseTotp { get; set; } + + [JsonPropertyName("edit")] + public bool Edit { get; set; } = true; + + [JsonPropertyName("permissions")] + public object? Permissions { get; set; } + + [JsonPropertyName("viewPassword")] + public bool ViewPassword { get; set; } = true; + + [JsonPropertyName("localData")] + public object? LocalData { get; set; } + + [JsonPropertyName("attachments")] + public object? Attachments { get; set; } + + [JsonPropertyName("fields")] + public List? Fields { get; set; } + + [JsonPropertyName("passwordHistory")] + public object? PasswordHistory { get; set; } + + [JsonPropertyName("creationDate")] + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + [JsonPropertyName("deletedDate")] + public DateTime? DeletedDate { get; set; } + + [JsonPropertyName("revisionDate")] + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + + [JsonPropertyName("archivedDate")] + public DateTime? ArchivedDate { get; set; } +} + +public class LoginViewDto +{ + [JsonPropertyName("username")] + public string? Username { get; set; } + + [JsonPropertyName("password")] + public string? Password { get; set; } + + [JsonPropertyName("passwordRevisionDate")] + public DateTime? PasswordRevisionDate { get; set; } + + [JsonPropertyName("uris")] + public List? Uris { get; set; } + + [JsonPropertyName("totp")] + public string? Totp { get; set; } + + [JsonPropertyName("autofillOnPageLoad")] + public bool? AutofillOnPageLoad { get; set; } + + [JsonPropertyName("fido2Credentials")] + public object? Fido2Credentials { get; set; } +} + +public class LoginUriViewDto +{ + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + [JsonPropertyName("match")] + public int? Match { get; set; } + + [JsonPropertyName("uriChecksum")] + public string? UriChecksum { get; set; } +} + +public class FieldViewDto +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("linkedId")] + public int? LinkedId { get; set; } +} + +public static class CipherTypes +{ + public const int Login = 1; + public const int SecureNote = 2; + public const int Card = 3; + public const int Identity = 4; + public const int SshKey = 5; +} + +public static class RepromptTypes +{ + public const int None = 0; + public const int Password = 1; +} diff --git a/util/Seeder/Models/EncryptedCipherDto.cs b/util/Seeder/Models/EncryptedCipherDto.cs new file mode 100644 index 0000000000..5b5b6aa56c --- /dev/null +++ b/util/Seeder/Models/EncryptedCipherDto.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Serialization; + +namespace Bit.Seeder.Models; + +public class EncryptedCipherDto +{ + [JsonPropertyName("id")] + public Guid? Id { get; set; } + + [JsonPropertyName("organizationId")] + public Guid? OrganizationId { get; set; } + + [JsonPropertyName("folderId")] + public Guid? FolderId { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("login")] + public EncryptedLoginDto? Login { get; set; } + + [JsonPropertyName("fields")] + public List? Fields { get; set; } + + [JsonPropertyName("favorite")] + public bool Favorite { get; set; } + + [JsonPropertyName("reprompt")] + public int Reprompt { get; set; } + + [JsonPropertyName("key")] + public string? Key { get; set; } + + [JsonPropertyName("creationDate")] + public DateTime CreationDate { get; set; } + + [JsonPropertyName("revisionDate")] + public DateTime RevisionDate { get; set; } + + [JsonPropertyName("deletedDate")] + public DateTime? DeletedDate { get; set; } +} + +public class EncryptedLoginDto +{ + [JsonPropertyName("username")] + public string? Username { get; set; } + + [JsonPropertyName("password")] + public string? Password { get; set; } + + [JsonPropertyName("totp")] + public string? Totp { get; set; } + + [JsonPropertyName("uris")] + public List? Uris { get; set; } + + [JsonPropertyName("passwordRevisionDate")] + public DateTime? PasswordRevisionDate { get; set; } + + [JsonPropertyName("fido2Credentials")] + public object? Fido2Credentials { get; set; } +} + +public class EncryptedLoginUriDto +{ + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + [JsonPropertyName("match")] + public int? Match { get; set; } + + [JsonPropertyName("uriChecksum")] + public string? UriChecksum { get; set; } +} + +public class EncryptedFieldDto +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("linkedId")] + public int? LinkedId { get; set; } +} diff --git a/util/Seeder/README.md b/util/Seeder/README.md index 8597ad6e39..fe758e3a8a 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -1,18 +1,106 @@ # Bitwarden Database Seeder -A class library for generating and inserting test data. +A class library for generating and inserting properly encrypted test data into Bitwarden databases. + +## Domain Taxonomy + +### Cipher Encryption States + +| Term | Description | Stored in DB? | +|------|-------------|---------------| +| **CipherView** | Plaintext/decrypted form. Human-readable data. | Never | +| **Cipher** | Encrypted form. All sensitive fields are EncStrings. | Yes | + +The "View" suffix always denotes plaintext. No suffix means encrypted. + +### EncString Format + +Encrypted strings follow this format: +``` +2.{iv}|{ciphertext}|{mac} +``` +- **2** = Algorithm type (AES-256-CBC-HMAC-SHA256) +- **iv** = Initialization vector (base64) +- **ciphertext** = Encrypted data (base64) +- **mac** = Message authentication code (base64) + +### Data Structure Differences + +**SDK Structure (nested):** +```json +{ "name": "2.x...", "login": { "username": "2.y...", "password": "2.z..." } } +``` + +**Server Structure (flat, stored in Cipher.Data):** +```json +{ "Name": "2.x...", "Username": "2.y...", "Password": "2.z..." } +``` + +The seeder transforms SDK output to server format before database insertion. + +### Key Hierarchy + +``` +Organization Key (or User Key) + │ + ├──▶ Encrypts Cipher.Key (optional per-cipher key) + │ + └──▶ Encrypts cipher fields directly (if no per-cipher key) +``` + +For seeding, we encrypt directly with the organization key. + +### Entity Relationships + +``` +Organization + │ + ├── Collections ──┬── CollectionCipher ──┐ + │ │ │ + └── Ciphers ──────┴──────────────────────┘ +``` + +Ciphers belong to organizations and are assigned to collections via the `CollectionCipher` join table. ## Project Structure -The project is organized into these main components: - ### Factories -Factories are helper classes for creating domain entities and populating them with realistic data. This assist in -decreasing the amount of boilerplate code needed to create test data in recipes. +Create individual domain entities with realistic encrypted data. + +| Factory | Purpose | +|---------|---------| +| `CipherSeeder` | Creates encrypted Cipher entities via Rust SDK | +| `CollectionSeeder` | Creates collections with encrypted names | +| `OrganizationSeeder` | Creates organizations with keys | +| `UserSeeder` | Creates users with encrypted credentials | ### Recipes -Recipes are pre-defined data sets which can be run to generate and load data into the database. They often allow a allow -for a few arguments to customize the data slightly. Recipes should be kept simple and focused on a single task. Default -to creating more recipes rather than adding complexity to existing ones. +Bulk data operations using BulkCopy for performance. + +| Recipe | Purpose | +|--------|---------| +| `CiphersRecipe` | Bulk create ciphers and assign to collections | +| `CollectionsRecipe` | Create collections with user permissions | +| `GroupsRecipe` | Create groups with collection access | +| `OrganizationWithUsersRecipe` | Full org setup with users | + +### Models + +DTOs for SDK interop and data transformation. + +| Model | Purpose | +|-------|---------| +| `CipherViewDto` | Plaintext input to SDK encryption | +| `EncryptedCipherDto` | Parses SDK's encrypted output | + +## Rust SDK Integration + +The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption: + +``` +CipherViewDto → RustSdkService.EncryptCipher() → EncryptedCipherDto → Server Format +``` + +This ensures seeded data can be decrypted and displayed in the actual Bitwarden clients. diff --git a/util/Seeder/Recipes/CiphersRecipe.cs b/util/Seeder/Recipes/CiphersRecipe.cs new file mode 100644 index 0000000000..810a67089d --- /dev/null +++ b/util/Seeder/Recipes/CiphersRecipe.cs @@ -0,0 +1,124 @@ +using Bit.Core.Entities; +using Bit.Core.Vault.Entities; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Data; +using Bit.Seeder.Data.Enums; +using Bit.Seeder.Factories; +using LinqToDB.EntityFrameworkCore; + +namespace Bit.Seeder.Recipes; + +/// +/// Creates encrypted ciphers for seeding organization vaults. +/// +/// +/// Currently supports: +/// +/// Login ciphers +/// +/// TODO: Add support for Card, Identity, and SecureNote cipher types. +/// +public class CiphersRecipe(DatabaseContext db, RustSdkService sdkService) +{ + private readonly CipherSeeder _cipherSeeder = new(sdkService); + + public List AddLoginCiphersToOrganization( + Guid organizationId, + string orgKeyBase64, + List collectionIds, + int? count = null, + bool useEnterpriseUrls = false) + { + // Delegate to the new system - Enterprise filter for enterprise URLs, Consumer for popular + var companyType = useEnterpriseUrls ? CompanyType.Enterprise : CompanyType.Consumer; + return AddLoginCiphersToOrganization( + organizationId, + orgKeyBase64, + collectionIds, + count, + companyType, + region: null, + UsernamePatternType.FLast, + PasswordStrength.Weak); + } + + public List AddLoginCiphersToOrganization( + Guid organizationId, + string orgKeyBase64, + List collectionIds, + int? count, + CompanyType? companyType, + GeographicRegion? region, + UsernamePatternType usernamePattern = UsernamePatternType.FirstDotLast, + PasswordStrength passwordStrength = PasswordStrength.Strong) + { + var companies = Companies.Filter(companyType, region); + if (companies.Length == 0) + { + companies = Companies.All; + } + + var passwords = Passwords.GetByStrength(passwordStrength); + var cipherCount = count ?? companies.Length; + var usernameGenerator = new UsernameGenerator(organizationId.GetHashCode(), usernamePattern, region); + + var ciphers = 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[i % passwords.Length], + uri: $"https://{company.Domain}"); + }) + .ToList(); + + return SaveCiphersWithCollections(ciphers, collectionIds); + } + + private List SaveCiphersWithCollections(List ciphers, List collectionIds) + { + if (ciphers.Count == 0) + { + return []; + } + + db.BulkCopy(ciphers); + + if (collectionIds.Count > 0) + { + var collectionCiphers = ciphers.SelectMany((cipher, i) => + { + var primary = new CollectionCipher + { + CipherId = cipher.Id, + CollectionId = collectionIds[i % collectionIds.Count] + }; + + // Every 3rd cipher gets assigned to an additional collection + if (i % 3 == 0 && collectionIds.Count > 1) + { + return new[] + { + primary, + new CollectionCipher + { + CipherId = cipher.Id, + CollectionId = collectionIds[(i + 1) % collectionIds.Count] + } + }; + } + + return [primary]; + }).ToList(); + + db.BulkCopy(collectionCiphers); + } + + return ciphers.Select(c => c.Id).ToList(); + } +} diff --git a/util/Seeder/Recipes/CollectionsRecipe.cs b/util/Seeder/Recipes/CollectionsRecipe.cs index e0f9057418..a5d3cb1efb 100644 --- a/util/Seeder/Recipes/CollectionsRecipe.cs +++ b/util/Seeder/Recipes/CollectionsRecipe.cs @@ -1,38 +1,56 @@ using Bit.Core.Enums; using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Data; +using Bit.Seeder.Data.Enums; +using Bit.Seeder.Factories; using LinqToDB.EntityFrameworkCore; namespace Bit.Seeder.Recipes; -public class CollectionsRecipe(DatabaseContext db) +/// +/// Creates collections for seeding organization vaults. +/// +public class CollectionsRecipe(DatabaseContext db, RustSdkService sdkService) { - /// - /// Adds collections to an organization and creates relationships between users and collections. - /// - /// The ID of the organization to add collections to. - /// The number of collections to add. - /// The IDs of the users to create relationships with. - /// The maximum number of users to create relationships with. - public List AddToOrganization(Guid organizationId, int collections, List organizationUserIds, int maxUsersWithRelationships = 1000) - { - var collectionList = CreateAndSaveCollections(organizationId, collections); + private readonly CollectionSeeder _collectionSeeder = new(sdkService); - if (collectionList.Any()) + /// + /// Creates collections from an organizational structure (e.g., Traditional departments, Spotify tribes). + /// Collection names are properly encrypted. + /// + public List AddFromStructure( + Guid organizationId, + string orgKeyBase64, + OrgStructureModel model, + List organizationUserIds, + int maxUsersWithRelationships = 1000) + { + var structure = OrgStructures.GetStructure(model); + + var collections = structure.Units + .Select(unit => _collectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name)) + .ToList(); + + db.BulkCopy(collections); + + if (collections.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0) { - CreateAndSaveCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships); + var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships); + db.BulkCopy(collectionUsers); } - return collectionList.Select(c => c.Id).ToList(); + return collections.Select(c => c.Id).ToList(); } - private List CreateAndSaveCollections(Guid organizationId, int count) + /// + /// Adds generic numbered collections (unencrypted names - use AddFromStructure for realistic data). + /// + public List AddToOrganization(Guid organizationId, int collections, List organizationUserIds, int maxUsersWithRelationships = 1000) { - var collectionList = new List(); - - for (var i = 0; i < count; i++) - { - collectionList.Add(new Core.Entities.Collection + var collectionList = Enumerable.Range(0, collections) + .Select(i => new Core.Entities.Collection { Id = CoreHelpers.GenerateComb(), OrganizationId = organizationId, @@ -40,83 +58,44 @@ public class CollectionsRecipe(DatabaseContext db) Type = CollectionType.SharedCollection, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow - }); - } + }) + .ToList(); - if (collectionList.Any()) - { - db.BulkCopy(collectionList); - } - - return collectionList; - } - - private void CreateAndSaveCollectionUserRelationships( - List collections, - List organizationUserIds, - int maxUsersWithRelationships) - { - if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) - { - return; - } - - var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships); - - if (collectionUsers.Any()) + db.BulkCopy(collectionList); + + if (collectionList.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0) { + var collectionUsers = BuildCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships); db.BulkCopy(collectionUsers); } + + return collectionList.Select(c => c.Id).ToList(); } /// - /// Creates user-to-collection relationships with varied assignment patterns for realistic test data. - /// Each user gets 1-3 collections based on a rotating pattern. + /// Creates user-to-collection relationships with varied assignment patterns. + /// Each user gets 1-3 collections (cycling). First collection has Manage rights. /// - private List BuildCollectionUserRelationships( + private static List BuildCollectionUserRelationships( List collections, List organizationUserIds, int maxUsersWithRelationships) { - var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); - var collectionUsers = new List(); - - for (var i = 0; i < maxRelationships; i++) - { - var orgUserId = organizationUserIds[i]; - var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i); - collectionUsers.AddRange(userCollectionAssignments); - } - - return collectionUsers; - } - - /// - /// Assigns collections to a user with varying permissions. - /// Pattern: 1-3 collections per user (cycles: 1, 2, 3, 1, 2, 3...). - /// First collection has Manage rights, subsequent ones are ReadOnly. - /// - private List CreateCollectionAssignmentsForUser( - List collections, - Guid organizationUserId, - int userIndex) - { - var assignments = new List(); - var userCollectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 collections - - for (var j = 0; j < userCollectionCount; j++) - { - var collectionIndex = (userIndex + j) % collections.Count; // Distribute across available collections - assignments.Add(new Core.Entities.CollectionUser + return organizationUserIds + .Take(maxUsersWithRelationships) + .SelectMany((orgUserId, userIndex) => { - CollectionId = collections[collectionIndex].Id, - OrganizationUserId = organizationUserId, - ReadOnly = j > 0, // First assignment gets write access - HidePasswords = false, - Manage = j == 0 // First assignment gets manage permissions - }); - } - - return assignments; + var collectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 + return Enumerable.Range(0, collectionCount) + .Select(j => new Core.Entities.CollectionUser + { + CollectionId = collections[(userIndex + j) % collections.Count].Id, + OrganizationUserId = orgUserId, + ReadOnly = j > 0, + HidePasswords = false, + Manage = j == 0 + }); + }) + .ToList(); } } diff --git a/util/Seeder/Recipes/GroupsRecipe.cs b/util/Seeder/Recipes/GroupsRecipe.cs index 3c8156d921..e4945837c0 100644 --- a/util/Seeder/Recipes/GroupsRecipe.cs +++ b/util/Seeder/Recipes/GroupsRecipe.cs @@ -15,80 +15,30 @@ public class GroupsRecipe(DatabaseContext db) /// The maximum number of users to create relationships with. public List AddToOrganization(Guid organizationId, int groups, List organizationUserIds, int maxUsersWithRelationships = 1000) { - var groupList = CreateAndSaveGroups(organizationId, groups); - - if (groupList.Any()) - { - CreateAndSaveGroupUserRelationships(groupList, organizationUserIds, maxUsersWithRelationships); - } - - return groupList.Select(g => g.Id).ToList(); - } - - private List CreateAndSaveGroups(Guid organizationId, int count) - { - var groupList = new List(); - - for (var i = 0; i < count; i++) - { - groupList.Add(new Core.AdminConsole.Entities.Group + var groupList = Enumerable.Range(0, groups) + .Select(i => new Core.AdminConsole.Entities.Group { Id = CoreHelpers.GenerateComb(), OrganizationId = organizationId, Name = $"Group {i + 1}" - }); - } + }) + .ToList(); - if (groupList.Any()) - { - db.BulkCopy(groupList); - } - - return groupList; - } - - private void CreateAndSaveGroupUserRelationships( - List groups, - List organizationUserIds, - int maxUsersWithRelationships) - { - if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) - { - return; - } - - var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships); - - if (groupUsers.Any()) + db.BulkCopy(groupList); + + if (groupList.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0) { + var groupUsers = organizationUserIds + .Take(maxUsersWithRelationships) + .Select((orgUserId, i) => new Core.AdminConsole.Entities.GroupUser + { + GroupId = groupList[i % groupList.Count].Id, + OrganizationUserId = orgUserId + }) + .ToList(); db.BulkCopy(groupUsers); } - } - /// - /// Creates user-to-group relationships with distributed assignment patterns for realistic test data. - /// Each user is assigned to one group, distributed evenly across available groups. - /// - private List BuildGroupUserRelationships( - List groups, - List organizationUserIds, - int maxUsersWithRelationships) - { - var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); - var groupUsers = new List(); - - for (var i = 0; i < maxRelationships; i++) - { - var orgUserId = organizationUserIds[i]; - var groupIndex = i % groups.Count; // Round-robin distribution across groups - - groupUsers.Add(new Core.AdminConsole.Entities.GroupUser - { - GroupId = groups[groupIndex].Id, - OrganizationUserId = orgUserId - }); - } - - return groupUsers; + return groupList.Select(g => g.Id).ToList(); } } diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index 87fcc1967b..8f7a2a0bec 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -1,38 +1,138 @@ -using Bit.Core.Entities; +using AutoMapper; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Data.Enums; using Bit.Seeder.Factories; using LinqToDB.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization; +using EfOrganizationUser = Bit.Infrastructure.EntityFramework.Models.OrganizationUser; +using EfUser = Bit.Infrastructure.EntityFramework.Models.User; namespace Bit.Seeder.Recipes; -public class OrganizationWithUsersRecipe(DatabaseContext db) +public class OrganizationWithUsersRecipe( + DatabaseContext db, + IMapper mapper, + RustSdkService sdkService, + IPasswordHasher passwordHasher) { - public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed) + public static Guid SeedFromServices( + IServiceProvider services, + string name, + string domain, + int users, + int ciphers = 0, + OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed, + OrgStructureModel? structureModel = null) + { + var db = services.GetRequiredService(); + var mapper = services.GetRequiredService(); + var sdkService = services.GetRequiredService(); + var passwordHasher = services.GetRequiredService>(); + + var recipe = new OrganizationWithUsersRecipe(db, mapper, sdkService, passwordHasher); + return recipe.Seed(name, domain, users, ciphers, usersStatus, structureModel); + } + + /// + /// Seeds an organization with users and optionally encrypted ciphers. + /// Users can log in with their email and password "asdfasdfasdf". + /// Organization and user keys are generated dynamically for each run. + /// + /// Optional org structure for realistic collection names (e.g., Traditional departments, Spotify tribes). + public Guid Seed( + string name, + string domain, + int users, + int ciphers = 0, + OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed, + OrgStructureModel? structureModel = null) { var seats = Math.Max(users + 1, 1000); - var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats); - var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}"); - var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed); + var orgKeys = sdkService.GenerateOrganizationKeys(); - var additionalUsers = new List(); - var additionalOrgUsers = new List(); + var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats); + organization.PublicKey = orgKeys.PublicKey; + organization.PrivateKey = orgKeys.PrivateKey; + + var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{domain}", sdkService, passwordHasher); + + var ownerOrgKey = sdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key); + var ownerOrgUser = organization.CreateOrganizationUserWithKey( + ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey); + + var memberUsers = new List(); + var memberOrgUsers = new List(); for (var i = 0; i < users; i++) { - var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}"); - additionalUsers.Add(additionalUser); - additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus)); + var memberUser = UserSeeder.CreateUserWithSdkKeys($"user{i}@{domain}", sdkService, passwordHasher); + memberUsers.Add(memberUser); + + var memberOrgKey = (usersStatus == OrganizationUserStatusType.Confirmed || + usersStatus == OrganizationUserStatusType.Revoked) + ? sdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key) + : null; + + memberOrgUsers.Add(organization.CreateOrganizationUserWithKey( + memberUser, OrganizationUserType.User, usersStatus, memberOrgKey)); } - db.Add(organization); - db.Add(ownerUser); - db.Add(ownerOrgUser); + db.Add(mapper.Map(organization)); + db.Add(mapper.Map(ownerUser)); + db.Add(mapper.Map(ownerOrgUser)); + + // BulkCopy for performance with large user counts + var efMemberUsers = memberUsers.Select(u => mapper.Map(u)).ToList(); + var efMemberOrgUsers = memberOrgUsers.Select(ou => mapper.Map(ou)).ToList(); + db.BulkCopy(efMemberUsers); + db.BulkCopy(efMemberOrgUsers); db.SaveChanges(); - // Use LinqToDB's BulkCopy for significant better performance - db.BulkCopy(additionalUsers); - db.BulkCopy(additionalOrgUsers); + // Create collections - either from org structure or single default + var allOrgUserIds = memberOrgUsers + .Where(ou => ou.Status == OrganizationUserStatusType.Confirmed) + .Select(ou => ou.Id) + .Prepend(ownerOrgUser.Id) + .ToList(); + + List collectionIds; + if (structureModel.HasValue) + { + var collectionsRecipe = new CollectionsRecipe(db, sdkService); + collectionIds = collectionsRecipe.AddFromStructure( + organization.Id, + orgKeys.Key, + structureModel.Value, + allOrgUserIds); + } + else + { + var defaultCollection = new CollectionSeeder(sdkService) + .CreateCollection(organization.Id, orgKeys.Key, "Default Collection"); + db.BulkCopy(new[] { defaultCollection }); + + var collectionUsers = allOrgUserIds + .Select((id, i) => CollectionSeeder.CreateCollectionUser(defaultCollection.Id, id, manage: i == 0)) + .ToList(); + db.BulkCopy(collectionUsers); + + collectionIds = [defaultCollection.Id]; + } + + if (ciphers > 0) + { + var cipherRecipe = new CiphersRecipe(db, sdkService); + cipherRecipe.AddLoginCiphersToOrganization( + organization.Id, + orgKeys.Key, + collectionIds, + count: ciphers); + } return organization.Id; }