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..1336268de1 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -1,6 +1,10 @@ -using Bit.Infrastructure.EntityFramework.Repositories; +using AutoMapper; +using Bit.Core.Entities; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; using Bit.Seeder.Recipes; using CommandDotNet; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; namespace Bit.DbSeederUtility; @@ -36,4 +40,25 @@ public class Program var recipe = new OrganizationWithUsersRecipe(db); recipe.Seed(name: name, domain: domain, users: users); } + + [Command("vault-organization", Description = "Seed an organization with users and encrypted vault data (ciphers, collections, groups)")] + public void VaultOrganization(VaultOrganizationArgs args) + { + args.Validate(); + + var services = new ServiceCollection(); + ServiceCollectionExtension.ConfigureServices(services); + var serviceProvider = services.BuildServiceProvider(); + + using var scope = serviceProvider.CreateScope(); + var scopedServices = scope.ServiceProvider; + + var recipe = new OrganizationWithVaultRecipe( + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService(), + scopedServices.GetRequiredService>()); + + recipe.Seed(args.ToOptions()); + } } diff --git a/util/DbSeederUtility/README.md b/util/DbSeederUtility/README.md index 0eb21ae6c5..4bd3c389d6 100644 --- a/util/DbSeederUtility/README.md +++ b/util/DbSeederUtility/README.md @@ -28,13 +28,23 @@ 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 vault-organization -n TestOrg -u 5 -d test.com -c 100 + +# Generate with Spotify-style collections (tribes, chapters, guilds) +DbSeeder.exe vault-organization -n TestOrg -u 10 -d test.com -c 50 -o Spotify + +# Generate a small test organization with ciphers for manual testing +DbSeeder.exe vault-organization -n DevOrg -u 2 -d dev.local -c 10 ``` ## Dependencies This utility depends on: + - The Seeder class library - CommandDotNet for command-line parsing - .NET 8.0 runtime diff --git a/util/DbSeederUtility/ServiceCollectionExtension.cs b/util/DbSeederUtility/ServiceCollectionExtension.cs index 0653bb1801..f21c0b89cf 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; @@ -13,8 +16,15 @@ public static class ServiceCollectionExtension var globalSettings = GlobalSettingsFactory.GlobalSettings; // Register services - services.AddLogging(builder => builder.AddConsole()); + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Warning); + builder.AddFilter("Microsoft.EntityFrameworkCore.Model.Validation", LogLevel.Error); + }); services.AddSingleton(globalSettings); + services.AddSingleton(); + services.AddSingleton, PasswordHasher>(); // Add Data Protection services services.AddDataProtection() diff --git a/util/DbSeederUtility/VaultOrganizationArgs.cs b/util/DbSeederUtility/VaultOrganizationArgs.cs new file mode 100644 index 0000000000..8ec7762073 --- /dev/null +++ b/util/DbSeederUtility/VaultOrganizationArgs.cs @@ -0,0 +1,112 @@ +using Bit.Seeder.Data.Enums; +using Bit.Seeder.Options; +using CommandDotNet; + +namespace Bit.DbSeederUtility; + +/// +/// CLI argument model for the vault-organization command. +/// Maps to for the Seeder library. +/// +public class VaultOrganizationArgs : IArgumentModel +{ + [Option('n', "name", Description = "Name of organization")] + public string Name { get; set; } = null!; + + [Option('u', "users", Description = "Number of users to generate (minimum 1)")] + public int Users { get; set; } + + [Option('d', "domain", Description = "Email domain for users")] + public string Domain { get; set; } = null!; + + [Option('c', "ciphers", Description = "Number of login ciphers to create (minimum 1)")] + public int Ciphers { get; set; } + + [Option('g', "groups", Description = "Number of groups to create (minimum 1)")] + public int Groups { get; set; } + + [Option('m', "mix-user-statuses", Description = "Use realistic status mix (85% confirmed, 5% each invited/accepted/revoked). Requires >= 10 users.")] + public bool MixStatuses { get; set; } = true; + + [Option('o', "org-structure", Description = "Org structure for collections: Traditional, Spotify, or Modern")] + public string? Structure { get; set; } + + [Option('r', "region", Description = "Geographic region for names: NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, or Global")] + public string? Region { get; set; } + + public void Validate() + { + if (Users < 1) + { + throw new ArgumentException("Users must be at least 1. Use another command for orgs without users."); + } + + if (Ciphers < 1) + { + throw new ArgumentException("Ciphers must be at least 1. Use another command for orgs without vault data."); + } + + if (Groups < 1) + { + throw new ArgumentException("Groups must be at least 1. Use another command for orgs without groups."); + } + + if (!string.IsNullOrEmpty(Structure)) + { + ParseOrgStructure(Structure); + } + + if (!string.IsNullOrEmpty(Region)) + { + ParseGeographicRegion(Region); + } + } + + public OrganizationVaultOptions ToOptions() => new() + { + Name = Name, + Domain = Domain, + Users = Users, + Ciphers = Ciphers, + Groups = Groups, + RealisticStatusMix = MixStatuses, + StructureModel = ParseOrgStructure(Structure), + Region = ParseGeographicRegion(Region) + }; + + private static OrgStructureModel? ParseOrgStructure(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") + }; + } + + private static GeographicRegion? ParseGeographicRegion(string? region) + { + if (string.IsNullOrEmpty(region)) + { + return null; + } + + return region.ToLowerInvariant() switch + { + "northamerica" => GeographicRegion.NorthAmerica, + "europe" => GeographicRegion.Europe, + "asiapacific" => GeographicRegion.AsiaPacific, + "latinamerica" => GeographicRegion.LatinAmerica, + "middleeast" => GeographicRegion.MiddleEast, + "africa" => GeographicRegion.Africa, + "global" => GeographicRegion.Global, + _ => throw new ArgumentException($"Unknown region '{region}'. Use: NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, or Global") + }; + } +} diff --git a/util/RustSdk/RustSdkService.cs b/util/RustSdk/RustSdkService.cs index ee01d56fee..ec3712274f 100644 --- a/util/RustSdk/RustSdkService.cs +++ b/util/RustSdk/RustSdkService.cs @@ -47,7 +47,7 @@ public class RustSdkService { var resultPtr = NativeMethods.generate_user_keys(emailPtr, passwordPtr); - var result = TakeAndDestroyRustString(resultPtr); + var result = ParseResponse(resultPtr); return JsonSerializer.Deserialize(result, CaseInsensitiveOptions)!; } @@ -57,7 +57,7 @@ public class RustSdkService { var resultPtr = NativeMethods.generate_organization_keys(); - var result = TakeAndDestroyRustString(resultPtr); + var result = ParseResponse(resultPtr); return JsonSerializer.Deserialize(result, CaseInsensitiveOptions)!; } @@ -72,19 +72,70 @@ public class RustSdkService { var resultPtr = NativeMethods.generate_user_organization_key(userKeyPtr, orgKeyPtr); - var result = TakeAndDestroyRustString(resultPtr); + var result = ParseResponse(resultPtr); return result; } } + 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 ParseResponse(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 ParseResponse(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 ParseResponse(resultPtr); + } + } private static byte[] StringToRustString(string str) { return Encoding.UTF8.GetBytes(str + '\0'); } - private static unsafe string TakeAndDestroyRustString(byte* ptr) + /// + /// Parses a response from Rust FFI, checks for errors, and frees the native string. + /// + /// Pointer to the C string returned from Rust + /// The parsed response string + /// Thrown if the pointer is null, conversion fails, or the response contains an error + private static unsafe string ParseResponse(byte* ptr) { if (ptr == null) { @@ -99,6 +150,28 @@ public class RustSdkService throw new RustSdkException("Failed to convert native result to string"); } + // Check if response is an error from Rust + // Rust error responses follow the format: {"error": "message"} + if (result.TrimStart().StartsWith('{') && result.Contains("\"error\"", StringComparison.Ordinal)) + { + try + { + using var doc = JsonDocument.Parse(result); + if (doc.RootElement.TryGetProperty("error", out var errorElement)) + { + var errorMessage = errorElement.GetString(); + if (!string.IsNullOrEmpty(errorMessage)) + { + throw new RustSdkException($"Rust SDK error: {errorMessage}"); + } + } + } + catch (JsonException) + { + // If we can't parse it as an error, it's likely valid data that happens to contain "error" + } + } + return result; } } 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/build.rs b/util/RustSdk/rust/build.rs index 0905afc22d..2eeedbbebd 100644 --- a/util/RustSdk/rust/build.rs +++ b/util/RustSdk/rust/build.rs @@ -1,6 +1,7 @@ fn main() { csbindgen::Builder::default() .input_extern_file("src/lib.rs") + .input_extern_file("src/cipher.rs") .csharp_dll_name("libsdk") .csharp_namespace("Bit.RustSDK") .csharp_class_accessibility("public") diff --git a/util/RustSdk/rust/src/cipher.rs b/util/RustSdk/rust/src/cipher.rs new file mode 100644 index 0000000000..208aa65193 --- /dev/null +++ b/util/RustSdk/rust/src/cipher.rs @@ -0,0 +1,403 @@ +//! Cipher encryption and decryption functions for the Seeder. +//! +//! This module provides FFI functions for encrypting and decrypting Bitwarden ciphers +//! using the Rust SDK's cryptographic primitives. + +use std::ffi::{c_char, CStr, CString}; + +use base64::{engine::general_purpose::STANDARD, Engine}; + +use bitwarden_core::key_management::KeyIds; +use bitwarden_crypto::{ + BitwardenLegacyKeyBytes, CompositeEncryptable, Decryptable, KeyEncryptable, KeyStore, + SymmetricCryptoKey, +}; +use bitwarden_vault::{Cipher, CipherView}; + +/// 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 Ok(cipher_view_json) = CStr::from_ptr(cipher_view_json).to_str() else { + return error_response("Invalid UTF-8 in cipher_view_json"); + }; + + let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { + return error_response("Invalid UTF-8 in symmetric_key_b64"); + }; + + let Ok(cipher_view): Result = serde_json::from_str(cipher_view_json) else { + return error_response("Failed to parse CipherView JSON"); + }; + + let Ok(key_bytes) = STANDARD.decode(key_b64) else { + return error_response("Failed to decode base64 key"); + }; + + let Ok(key) = SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) else { + 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 Ok(cipher) = cipher_view.encrypt_composite(&mut ctx, key_id) else { + 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 Ok(cipher_json) = CStr::from_ptr(cipher_json).to_str() else { + return error_response("Invalid UTF-8 in cipher_json"); + }; + + let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { + return error_response("Invalid UTF-8 in symmetric_key_b64"); + }; + + let Ok(cipher): Result = serde_json::from_str(cipher_json) else { + return error_response("Failed to parse Cipher JSON"); + }; + + let Ok(key_bytes) = STANDARD.decode(key_b64) else { + return error_response("Failed to decode base64 key"); + }; + + let Ok(key) = SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) else { + 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 Ok(cipher_view): Result = cipher.decrypt(&mut ctx, key_id) else { + 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 Ok(plaintext) = CStr::from_ptr(plaintext).to_str() else { + return error_response("Invalid UTF-8 in plaintext"); + }; + + let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { + return error_response("Invalid UTF-8 in symmetric_key_b64"); + }; + + let Ok(key_bytes) = STANDARD.decode(key_b64) else { + return error_response("Failed to decode base64 key"); + }; + + let Ok(key) = SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) else { + return error_response("Failed to create symmetric key: invalid key format or length"); + }; + + let Ok(encrypted) = plaintext.to_string().encrypt_with_key(&key) else { + return error_response("Failed to encrypt string"); + }; + + CString::new(encrypted.to_string()).unwrap().into_raw() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{free_c_string, generate_organization_keys}; + use bitwarden_vault::{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/RustSdk/rust/src/lib.rs b/util/RustSdk/rust/src/lib.rs index 10f8d8dca4..65b9d4f116 100644 --- a/util/RustSdk/rust/src/lib.rs +++ b/util/RustSdk/rust/src/lib.rs @@ -1,4 +1,7 @@ #![allow(clippy::missing_safety_doc)] + +mod cipher; + use std::{ ffi::{c_char, CStr, CString}, num::NonZeroU32, @@ -20,9 +23,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 +32,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); diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md new file mode 100644 index 0000000000..a5a4105f03 --- /dev/null +++ b/util/Seeder/CLAUDE.md @@ -0,0 +1,215 @@ +# Seeder - Claude Code Context + +## Ubiquitous Language + +The Seeder follows six core patterns: + +1. **Factories** - Create ONE entity with encryption. Named `{Entity}Seeder` with `Create{Type}{Entity}()` methods. Do not interact with database. + +2. **Recipes** - Orchestrate MANY entities. Named `{DomainConcept}Recipe`. **MUST have `Seed()` method** as primary interface, not `AddToOrganization()` or similar. Use parameters for variations, not separate methods. Compose Factories internally. + +3. **Models** - DTOs bridging SDK ↔ Server format. Named `{Entity}ViewDto` (plaintext), `Encrypted{Entity}Dto` (SDK format). Pure data, no logic. + +4. **Scenes** - Complete test scenarios with ID mangling. Implement `IScene`. Async, returns `SceneResult` with MangleMap and result property populated with `TResult`. Named `{Scenario}Scene`. + +5. **Queries** - Read-only data retrieval. Implement `IQuery`. Synchronous, no DB modifications. Named `{DataToRetrieve}Query`. + +6. **Data** - Static, filterable test data collections (Companies, Passwords, Names, OrgStructures). Deterministic, composable. Enums provide public API. + +## The Recipe Contract + +Recipes follow strict rules (like a cooking recipe that you follow completely): + +1. A Recipe SHALL have exactly one public method named `Seed()` +2. A Recipe MUST produce one cohesive result (like baking one complete cake) +3. A Recipe MAY have overloaded `Seed()` methods with different parameters +4. A Recipe SHALL use private helper methods for internal steps +5. A Recipe SHALL use BulkCopy for performance when creating multiple entities +6. A Recipe SHALL compose Factories for individual entity creation +7. A Recipe SHALL NOT expose implementation details as public methods + +**Current violations** (to be refactored): + +- `CiphersRecipe` - Uses `AddLoginCiphersToOrganization()` instead of `Seed()` +- `CollectionsRecipe` - Uses `AddFromStructure()` and `AddToOrganization()` instead of `Seed()` +- `GroupsRecipe` - Uses `AddToOrganization()` instead of `Seed()` +- `OrganizationDomainRecipe` - Uses `AddVerifiedDomainToOrganization()` instead of `Seed()` + +## Pattern Decision Tree + +``` +Need to create test data? +├─ ONE entity with encryption? → Factory +├─ MANY entities as cohesive operation? → Recipe +├─ Complete test scenario with ID mangling to be used by the Seeder API? → Scene +├─ READ existing seeded data? → Query +└─ Data transformation SDK ↔ Server? → Model +``` + +## 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) +``` + +### 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..." +} +``` + +### Data Flow in Seeder + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ CipherViewDto │────▶│ Rust SDK │────▶│ EncryptedCipherDto │ +│ (plaintext) │ │ encrypt_cipher │ │ (SDK Cipher) │ +└─────────────────┘ └──────────────────┘ └─────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ TransformToServer │ + │ (flatten nested → │ + │ flat structure) │ + └───────────────────────┘ + │ + ▼ +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ Server Cipher │◀────│ CipherLoginData │◀────│ Flattened JSON │ +│ Entity │ │ (serialized) │ │ │ +└─────────────────┘ └──────────────────┘ └─────────────────────┘ +``` + +### Key Hierarchy + +Bitwarden uses a two-level encryption hierarchy: + +1. **User/Organization Key** - Encrypts the cipher's individual key +2. **Cipher Key** (optional) - Encrypts the actual cipher data + +For seeding, we use the organization's symmetric key directly (no per-cipher key). + +## Rust SDK FFI + +### Error Handling + +SDK functions return JSON with an `"error"` field on failure: + +```json +{ "error": "Failed to parse CipherView JSON" } +``` + +Always check for `"error"` in the response before parsing. + +## Testing + +Integration tests in `test/SeederApi.IntegrationTest` verify: + +1. **Roundtrip encryption** - Encrypt then decrypt preserves plaintext +2. **Server format compatibility** - Output matches CipherLoginData structure +3. **Field encryption** - Custom fields are properly encrypted +4. **Security** - Plaintext never appears in encrypted output + +## Common Patterns + +### Creating a Cipher + +```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/BogusNameProvider.cs b/util/Seeder/Data/BogusNameProvider.cs new file mode 100644 index 0000000000..4a41b6b120 --- /dev/null +++ b/util/Seeder/Data/BogusNameProvider.cs @@ -0,0 +1,78 @@ +using Bit.Seeder.Data.Enums; +using Bogus; +using Bogus.DataSets; + +namespace Bit.Seeder.Data; + +/// +/// Provides locale-aware name generation using the Bogus library. +/// Maps GeographicRegion to appropriate Bogus locales for culturally-appropriate names. +/// +internal sealed class BogusNameProvider +{ + private readonly Faker _faker; + + public BogusNameProvider(GeographicRegion region, int? seed = null) + { + var locale = MapRegionToLocale(region, seed); + _faker = seed.HasValue + ? new Faker(locale) { Random = new Randomizer(seed.Value) } + : new Faker(locale); + } + + public string FirstName() => _faker.Name.FirstName(); + + public string FirstName(Name.Gender gender) => _faker.Name.FirstName(gender); + + public string LastName() => _faker.Name.LastName(); + + private static string MapRegionToLocale(GeographicRegion region, int? seed) => region switch + { + GeographicRegion.NorthAmerica => "en_US", + GeographicRegion.Europe => GetRandomEuropeanLocale(seed), + GeographicRegion.AsiaPacific => GetRandomAsianLocale(seed), + GeographicRegion.LatinAmerica => GetRandomLatinAmericanLocale(seed), + GeographicRegion.MiddleEast => GetRandomMiddleEastLocale(seed), + GeographicRegion.Africa => GetRandomAfricanLocale(seed), + GeographicRegion.Global => "en", + _ => "en" + }; + + private static string GetRandomEuropeanLocale(int? seed) + { + var locales = new[] { "en_GB", "de", "fr", "es", "it", "nl", "pl", "pt_PT", "sv" }; + return PickLocale(locales, seed); + } + + private static string GetRandomAsianLocale(int? seed) + { + var locales = new[] { "ja", "ko", "zh_CN", "zh_TW", "vi" }; + return PickLocale(locales, seed); + } + + private static string GetRandomLatinAmericanLocale(int? seed) + { + var locales = new[] { "es_MX", "pt_BR", "es" }; + return PickLocale(locales, seed); + } + + private static string GetRandomMiddleEastLocale(int? seed) + { + // Bogus has limited Middle East support; use available Arabic/Turkish locales + var locales = new[] { "ar", "tr", "fa" }; + return PickLocale(locales, seed); + } + + private static string GetRandomAfricanLocale(int? seed) + { + // Bogus has limited African support; use South African English and French (West Africa) + var locales = new[] { "en_ZA", "fr" }; + return PickLocale(locales, seed); + } + + private static string PickLocale(string[] locales, int? seed) + { + var random = seed.HasValue ? new Random(seed.Value) : Random.Shared; + return locales[random.Next(locales.Length)]; + } +} diff --git a/util/Seeder/Data/CipherUsernameGenerator.cs b/util/Seeder/Data/CipherUsernameGenerator.cs new file mode 100644 index 0000000000..21a726a8ff --- /dev/null +++ b/util/Seeder/Data/CipherUsernameGenerator.cs @@ -0,0 +1,67 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +/// +/// Generates deterministic usernames for companies using configurable patterns. +/// Uses Bogus library for locale-aware name generation while maintaining determinism +/// through pre-generated arrays indexed by a seed. +/// +internal sealed class CipherUsernameGenerator +{ + private const int _namePoolSize = 1500; + + private readonly Random _random; + + private readonly UsernamePattern _pattern; + + private readonly string[] _firstNames; + + private readonly string[] _lastNames; + + public CipherUsernameGenerator( + int seed, + UsernamePatternType patternType = UsernamePatternType.FirstDotLast, + GeographicRegion? region = null) + { + _random = new Random(seed); + _pattern = UsernamePatterns.GetPattern(patternType); + + // Pre-generate arrays from Bogus for deterministic index-based access + var provider = new BogusNameProvider(region ?? GeographicRegion.Global, seed); + _firstNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.FirstName()).ToArray(); + _lastNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.LastName()).ToArray(); + } + + public string Generate(Company company) + { + var firstName = _firstNames[_random.Next(_firstNames.Length)]; + var lastName = _lastNames[_random.Next(_lastNames.Length)]; + return _pattern.Generate(firstName, lastName, company.Domain); + } + + /// + /// Generates username using index for deterministic selection across cipher iterations. + /// + public string GenerateByIndex(Company company, int index) + { + var firstName = _firstNames[index % _firstNames.Length]; + var lastName = _lastNames[(index * 7) % _lastNames.Length]; // Prime multiplier for variety + return _pattern.Generate(firstName, lastName, company.Domain); + } + + /// + /// Combines deterministic index with random offset for controlled variety. + /// + public string GenerateVaried(Company company, int index) + { + var offset = _random.Next(10); + var firstName = _firstNames[(index + offset) % _firstNames.Length]; + var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length]; + return _pattern.Generate(firstName, lastName, company.Domain); + } + + public string GetFirstName(int index) => _firstNames[index % _firstNames.Length]; + + public string GetLastName(int index) => _lastNames[(index * 7) % _lastNames.Length]; +} diff --git a/util/Seeder/Data/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..bd7f72e2b6 --- /dev/null +++ b/util/Seeder/Data/Enums/PasswordStrength.cs @@ -0,0 +1,25 @@ +namespace Bit.Seeder.Data.Enums; + +/// +/// Password strength levels aligned with zxcvbn scoring (0-4). +/// +public enum PasswordStrength +{ + /// Score 0: Too guessable (< 10³ guesses) + VeryWeak = 0, + + /// Score 1: Very guessable (< 10⁶ guesses) + Weak = 1, + + /// Score 2: Somewhat guessable (< 10⁸ guesses) + Fair = 2, + + /// Score 3: Safely unguessable (< 10¹⁰ guesses) + Strong = 3, + + /// Score 4: Very unguessable (≥ 10¹⁰ guesses) + VeryStrong = 4, + + /// Realistic distribution based on breach data statistics. + Realistic = 99 +} 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/FolderNameGenerator.cs b/util/Seeder/Data/FolderNameGenerator.cs new file mode 100644 index 0000000000..173fae3116 --- /dev/null +++ b/util/Seeder/Data/FolderNameGenerator.cs @@ -0,0 +1,31 @@ +using Bogus; + +namespace Bit.Seeder.Data; + +/// +/// Generates deterministic folder names using Bogus Commerce.Department(). +/// Pre-generates a pool of business-themed names for consistent index-based access. +/// +internal sealed class FolderNameGenerator +{ + private const int _namePoolSize = 50; + + private readonly string[] _folderNames; + + public FolderNameGenerator(int seed) + { + var faker = new Faker { Random = new Randomizer(seed) }; + + // Pre-generate business department names for determinism + // Examples: "Automotive", "Home & Garden", "Sports", "Electronics", "Beauty" + _folderNames = Enumerable.Range(0, _namePoolSize) + .Select(_ => faker.Commerce.Department()) + .Distinct() + .ToArray(); + } + + /// + /// Gets a folder name by index, wrapping around if index exceeds pool size. + /// + public string GetFolderName(int index) => _folderNames[index % _folderNames.Length]; +} 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..1717c2b408 --- /dev/null +++ b/util/Seeder/Data/Passwords.cs @@ -0,0 +1,148 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Data; + +/// +/// Password collections by zxcvbn strength level (0-4) for realistic test data. +/// +internal static class Passwords +{ + /// + /// Score 0 - Too guessable: keyboard walks, simple sequences, single words. + /// + public static readonly string[] VeryWeak = + [ + "password", "123456", "qwerty", "abc123", "letmein", + "admin", "welcome", "monkey", "dragon", "master", + "111111", "baseball", "iloveyou", "trustno1", "sunshine", + "princess", "football", "shadow", "superman", "michael", + "password1", "123456789", "12345678", "1234567", "12345", + "qwerty123", "1q2w3e4r", "123123", "000000", "654321" + ]; + + /// + /// Score 1 - Very guessable: common patterns with minor complexity. + /// + public static readonly string[] Weak = + [ + "Password1", "Qwerty123", "Welcome1", "Admin123", "Letmein1", + "Dragon123", "Master123", "Shadow123", "Michael1", "Jennifer1", + "abc123!", "pass123!", "test1234", "hello123", "love1234", + "money123", "secret1", "access1", "login123", "super123", + "changeme", "temp1234", "guest123", "user1234", "pass1234", + "default1", "sample12", "demo1234", "trial123", "secure1" + ]; + + /// + /// Score 2 - Somewhat guessable: meets basic complexity but predictable patterns. + /// + public static readonly string[] Fair = + [ + "Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!", + "Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login2024!", + "Michael123!", "Jennifer2024@", "Robert456#", "Sarah789!", "David2024!", + "Password123!", "Security2024@", "Admin2024!", "User2024#", "Guest123!", + "Football123!", "Baseball2024@", "Soccer456#", "Hockey789!", "Tennis2024!", + "NewYork2024!", "Chicago123@", "Boston2024#", "Seattle789!", "Denver2024$" + ]; + + /// + /// Score 3 - Safely unguessable: good entropy, mixed character types. + /// + 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$", "Xk5#tW8@jR2$mN9!", "Cv3@yB6#pF1$qL4!", + "correct-horse-battery", "purple-monkey-dishwasher", "quantum-bicycle-elephant", + "velvet-thunder-crystal", "neon-wizard-cosmic", "amber-phoenix-digital", + "Brave.Tiger.Runs.42", "Blue.Ocean.Deep.17", "Swift.Eagle.Soars.93", + "maple#stream#winter", "ember@cloud@silent", "frost$dawn$valley" + ]; + + /// + /// Score 4 - Very unguessable: high entropy, long passphrases, random strings. + /// + public static readonly string[] VeryStrong = + [ + "Kx9#mL4$pQ7@wR2!vN5hT8", "Yz3@hT8#bF1$cS6!nM9wK4", "Wv5!rK2@jG9#tX4$mL7nB3", + "Qn7$sB3@yH6#pC1!zF8kW2", "Tm2@xD5#kW9$vL4!rJ7gN1", "Pf4!nC8@bR3#yL6$hS9mV2", + "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", + "Brave.Tiger.Runs.Fast.42!", "Blue.Ocean.Deep.Wave.17@", "Swift.Eagle.Soars.High.93#", + "maple#stream#winter#glow#dawn", "ember@cloud@silent@peak@mist", "frost$dawn$valley$mist$glow", + "7hK$mN2@pL9#xR4!wQ8vB5&jF", "3yT@nC7#bS1$kW6!mH9rL2%xD", "9pF!vK4@jR8#tN3$yB7mL1&wS" + ]; + + /// All passwords combined for mixed/random selection. + public static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong]; + + /// + /// Realistic distribution based on breach data and security research. + /// Sources: NordPass annual reports, Have I Been Pwned analysis, academic studies. + /// Distribution: 25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong + /// + private static readonly (PasswordStrength Strength, int CumulativePercent)[] RealisticDistribution = + [ + (PasswordStrength.VeryWeak, 25), // 25% - most common breached passwords + (PasswordStrength.Weak, 55), // 30% - simple patterns with numbers + (PasswordStrength.Fair, 80), // 25% - meets basic requirements + (PasswordStrength.Strong, 95), // 15% - good passwords + (PasswordStrength.VeryStrong, 100) // 5% - password manager users + ]; + + public static string[] GetByStrength(PasswordStrength strength) => strength switch + { + PasswordStrength.VeryWeak => VeryWeak, + PasswordStrength.Weak => Weak, + PasswordStrength.Fair => Fair, + PasswordStrength.Strong => Strong, + PasswordStrength.VeryStrong => VeryStrong, + PasswordStrength.Realistic => All, // For direct array access, use All + _ => Strong + }; + + /// + /// Gets a password with realistic strength distribution. + /// Uses deterministic selection based on index for reproducible test data. + /// + public static string GetRealisticPassword(int index) + { + var strength = GetRealisticStrength(index); + var passwords = GetByStrength(strength); + return passwords[index % passwords.Length]; + } + + /// + /// Gets a password strength following realistic distribution. + /// Deterministic based on index for reproducible results. + /// + public static PasswordStrength GetRealisticStrength(int index) + { + // Use modulo 100 for percentage-based bucket selection + var bucket = index % 100; + + foreach (var (strength, cumulativePercent) in RealisticDistribution) + { + if (bucket < cumulativePercent) + { + return strength; + } + } + + return PasswordStrength.Strong; // Fallback + } + + public static string GetPassword(PasswordStrength strength, int index) + { + if (strength == PasswordStrength.Realistic) + { + return GetRealisticPassword(index); + } + + var 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/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..c751d83399 --- /dev/null +++ b/util/Seeder/Factories/CipherSeeder.cs @@ -0,0 +1,153 @@ +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); + + 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/FolderSeeder.cs b/util/Seeder/Factories/FolderSeeder.cs new file mode 100644 index 0000000000..d8674552bd --- /dev/null +++ b/util/Seeder/Factories/FolderSeeder.cs @@ -0,0 +1,28 @@ +using Bit.Core.Utilities; +using Bit.Core.Vault.Entities; +using Bit.RustSDK; + +namespace Bit.Seeder.Factories; + +/// +/// Factory for creating Folder entities with encrypted names. +/// Folders are per-user constructs encrypted with the user's symmetric key. +/// +internal sealed class FolderSeeder(RustSdkService sdkService) +{ + /// + /// Creates a folder with an encrypted name. + /// + /// The user who owns this folder. + /// The user's symmetric key (not org key). + /// The plaintext folder name to encrypt. + public Folder CreateFolder(Guid userId, string userKeyBase64, string name) + { + return new Folder + { + Id = CoreHelpers.GenerateComb(), + UserId = userId, + Name = sdkService.EncryptString(name, userKeyBase64) + }; + } +} diff --git a/util/Seeder/Factories/GroupSeeder.cs b/util/Seeder/Factories/GroupSeeder.cs new file mode 100644 index 0000000000..7ee7df9484 --- /dev/null +++ b/util/Seeder/Factories/GroupSeeder.cs @@ -0,0 +1,41 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Utilities; + +namespace Bit.Seeder.Factories; + +/// +/// Creates groups and group-user relationships for seeding. +/// +public static class GroupSeeder +{ + /// + /// Creates a group entity for an organization. + /// + /// The organization ID. + /// The group name. + /// A new Group entity (not persisted). + public static Group CreateGroup(Guid organizationId, string name) + { + return new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + Name = name + }; + } + + /// + /// Creates a group-user relationship entity. + /// + /// The group ID. + /// The organization user ID. + /// A new GroupUser entity (not persisted). + public static GroupUser CreateGroupUser(Guid groupId, Guid organizationUserId) + { + return new GroupUser + { + GroupId = groupId, + OrganizationUserId = organizationUserId + }; + } +} diff --git a/util/Seeder/Factories/OrganizationDomainSeeder.cs b/util/Seeder/Factories/OrganizationDomainSeeder.cs new file mode 100644 index 0000000000..2bc41f8514 --- /dev/null +++ b/util/Seeder/Factories/OrganizationDomainSeeder.cs @@ -0,0 +1,32 @@ +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Seeder.Factories; + +/// +/// Creates organization domain entities for seeding. +/// +public static class OrganizationDomainSeeder +{ + /// + /// Creates a verified organization domain entity. + /// + /// The organization ID. + /// The domain name (e.g., "example.com"). + /// A new verified OrganizationDomain entity (not persisted). + public static OrganizationDomain CreateVerifiedDomain(Guid organizationId, string domainName) + { + var domain = new OrganizationDomain + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + DomainName = domainName, + Txt = Guid.NewGuid().ToString("N"), + CreationDate = DateTime.UtcNow, + }; + + domain.SetVerifiedDate(); + domain.SetLastCheckedDate(); + + return domain; + } +} diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 3aac87d400..0646fdd9ee 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -1,13 +1,16 @@ -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 { - public static Organization CreateEnterprise(string name, string domain, int seats) + private static readonly string _defaultPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB"; + private static readonly string _defaultPrivateKey = "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="; + + public static Organization CreateEnterprise(string name, string domain, int seats, string? publicKey = null, string? privateKey = null) { return new Organization { @@ -39,18 +42,14 @@ 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 = publicKey ?? _defaultPublicKey, + PrivateKey = privateKey ?? _defaultPrivateKey, }; } } -public static class OrgnaizationExtensions +public static class OrganizationExtensions { /// /// Creates an OrganizationUser with fields populated based on status. @@ -74,17 +73,29 @@ public static class OrgnaizationExtensions }; } - public static OrganizationUser CreateSdkOrganizationUser(this Organization organization, User user) + /// + /// Creates an OrganizationUser with a dynamically provided encrypted org key. + /// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey(). + /// + public static OrganizationUser CreateOrganizationUserWithKey( + this Organization organization, + User user, + OrganizationUserType type, + OrganizationUserStatusType status, + string? encryptedOrgKey) { + var shouldLinkUserId = status != OrganizationUserStatusType.Invited; + var shouldIncludeKey = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; + 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 + UserId = shouldLinkUserId ? user.Id : null, + Email = shouldLinkUserId ? null : user.Email, + Key = shouldIncludeKey ? encryptedOrgKey : null, + Type = type, + Status = status }; } } diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index 9b80dbef3c..f355bde705 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -21,7 +21,7 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher + /// Default test password used for all seeded users. + /// + public const string DefaultPassword = "asdfasdfasdf"; + + /// + /// Creates a user with hardcoded keys (no email mangling, no SDK calls). + /// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs. + /// public static User CreateUserNoMangle(string email) { return new User @@ -57,12 +65,55 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher + /// 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); + return CreateUserFromKeys(email, keys, passwordHasher); + } + + /// + /// Creates a user from pre-generated keys (no email mangling). + /// Use this when you need to retain the user's symmetric key for subsequent operations + /// (e.g., encrypting folders with the user's key). + /// + public static User CreateUserFromKeys( + string email, + UserKeys keys, + IPasswordHasher passwordHasher) + { + var user = new User + { + Id = CoreHelpers.GenerateComb(), + Email = email, + EmailVerified = true, + MasterPassword = null, + SecurityStamp = Guid.NewGuid().ToString(), + Key = keys.EncryptedUserKey, + PublicKey = keys.PublicKey, + PrivateKey = keys.PrivateKey, + Premium = false, + ApiKey = Guid.NewGuid().ToString("N")[..30], + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 5_000, + }; + + user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + + return user; + } + public Dictionary GetMangleMap(User user, UserData expectedUserData) { var mangleMap = new Dictionary 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/Options/OrganizationVaultOptions.cs b/util/Seeder/Options/OrganizationVaultOptions.cs new file mode 100644 index 0000000000..ff1be02f7c --- /dev/null +++ b/util/Seeder/Options/OrganizationVaultOptions.cs @@ -0,0 +1,63 @@ +using Bit.Seeder.Data.Enums; + +namespace Bit.Seeder.Options; + +/// +/// Options for seeding an organization with vault data. +/// +public class OrganizationVaultOptions +{ + /// + /// Organization name. + /// + public required string Name { get; init; } + + /// + /// Domain for user emails (e.g., "example.com"). + /// + public required string Domain { get; init; } + + /// + /// Number of member users to create. + /// + public required int Users { get; init; } + + /// + /// Number of login ciphers to create. + /// + public int Ciphers { get; init; } = 0; + + /// + /// Number of groups to create. + /// + public int Groups { get; init; } = 0; + + /// + /// When true and Users >= 10, creates a realistic mix of user statuses: + /// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked. + /// When false or Users < 10, all users are Confirmed. + /// + public bool RealisticStatusMix { get; init; } = false; + + /// + /// Org structure for realistic collection names. + /// + public OrgStructureModel? StructureModel { get; init; } + + /// + /// Username pattern for cipher logins. + /// + public UsernamePatternType UsernamePattern { get; init; } = UsernamePatternType.FirstDotLast; + + /// + /// Password strength for cipher logins. Defaults to Realistic distribution + /// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong). + /// + public PasswordStrength PasswordStrength { get; init; } = PasswordStrength.Realistic; + + /// + /// Geographic region for culturally-appropriate name generation in cipher usernames. + /// Defaults to Global (mixed locales from all regions). + /// + public GeographicRegion? Region { get; init; } +} diff --git a/util/Seeder/README.md b/util/Seeder/README.md index 8597ad6e39..3b38c3d731 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -1,18 +1,155 @@ # 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. -## Project Structure +## Domain Taxonomy -The project is organized into these main components: +### Cipher Encryption States -### Factories +| Term | Description | Stored in DB? | +| -------------- | ---------------------------------------------------- | ------------- | +| **CipherView** | Plaintext/decrypted form. Human-readable data. | Never | +| **Cipher** | Encrypted form. All sensitive fields are EncStrings. | Yes | -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. +The "View" suffix always denotes plaintext. No suffix means encrypted. -### Recipes +### Data Structure Differences -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. +**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. + +### Project Structure + +The Seeder is organized around six core patterns, each with a specific responsibility: + +#### Factories + +**Purpose:** Create individual domain entities with cryptographically correct encrypted data. + +**Metaphor:** Skilled craftspeople who create one perfect item per call. + +**When to use:** Need to create ONE entity (user, cipher, collection) with proper encryption. + +**Key characteristics:** + +- Create ONE entity per method call +- Handle encryption/transformation internally +- Stateless (except for SDK service dependency) +- Do NOT interact with database directly + +**Naming:** `{Entity}Seeder` class with `Create{Type}{Entity}()` methods + +--- + +#### Recipes + +**Purpose:** Orchestrate cohesive bulk operations using BulkCopy for performance. + +**Metaphor:** Cooking recipes that produce one complete result through coordinated steps. Like baking a three-layer cake - you don't grab three separate recipes and stack them; you follow one comprehensive recipe that orchestrates all the steps. + +**When to use:** Need to create MANY related entities as one cohesive operation (e.g., organization + users + collections + ciphers). + +**Key characteristics:** + +- Orchestrate multiple entity creations as a cohesive operation +- Use BulkCopy for performance optimization +- Interact with database directly +- Compose Factories for individual entity creation +- **SHALL have a `Seed()` method** that executes the complete recipe +- Use method parameters (with defaults) for variations, not separate methods + +**Naming:** `{DomainConcept}Recipe` class with primary `Seed()` method + +**Note:** Some existing recipes violate the `Seed()` method convention and will be refactored in the future. + +--- + +#### Models + +**Purpose:** DTOs that bridge the gap between SDK encryption format and server storage format. + +**Metaphor:** Translators between two different languages (SDK format vs. Server format). + +**When to use:** Need data transformation during the encryption pipeline (SDK → Server format). + +**Key characteristics:** + +- Pure data structures (DTOs) +- No business logic +- Handle serialization/deserialization +- Bridge SDK ↔ Server format differences + +#### Scenes + +**Purpose:** Create complete, isolated test scenarios for integration tests. + +**Metaphor:** Theater scenes with multiple actors and props arranged to tell a complete story. + +**When to use:** Need a complete test scenario with proper ID mangling for test isolation. + +**Key characteristics:** + +- Implement `IScene` or `IScene` +- Create complete, realistic test scenarios +- Handle uniqueness constraint mangling for test isolation +- Return `SceneResult` with mangle map and optional additional operation result data for test assertions +- Async operations +- CAN modify database state + +**Naming:** `{Scenario}Scene` class with `SeedAsync(Request)` method (defined by interface) + +#### Queries + +**Purpose:** Read-only data retrieval for test assertions and verification. + +**Metaphor:** Information desks that answer questions without changing anything. + +**When to use:** Need to READ existing seeded data for verification or follow-up operations. + +** Example:** Inviting a user to an organization produces a magic link to accept the invite, a query should be used to retrieve that link because it is easier than interfacing with an external smtp catcher. + +**Key characteristics:** + +- Implement `IQuery` +- Read-only (no database modifications) +- Return typed data for test assertions +- Can be used to retrieve side effects due to tested flows + +**Naming:** `{DataToRetrieve}Query` class with `Execute(Request)` method (defined by interface) + +#### Data + +**Purpose:** Reusable, realistic test data collections that provide the foundation for cipher generation. + +**Metaphor:** A well-stocked ingredient pantry that all recipes draw from. + +**When to use:** Need realistic, filterable data for cipher content (company names, passwords, usernames). + +**Key characteristics:** + +- Static readonly arrays and classes +- Filterable by region, type, category +- Deterministic (seeded randomness for reproducibility) +- Composable across regions +- Enums provide the public API (CompanyType, PasswordStrength, etc.) + +## 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/OrganizationWithVaultRecipe.cs b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs new file mode 100644 index 0000000000..44c86f49f0 --- /dev/null +++ b/util/Seeder/Recipes/OrganizationWithVaultRecipe.cs @@ -0,0 +1,330 @@ +using AutoMapper; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.RustSDK; +using Bit.Seeder.Data; +using Bit.Seeder.Data.Enums; +using Bit.Seeder.Factories; +using Bit.Seeder.Options; +using LinqToDB.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity; +using EfFolder = Bit.Infrastructure.EntityFramework.Vault.Models.Folder; +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; + +/// +/// Seeds an organization with users, collections, groups, and encrypted ciphers. +/// +/// +/// This recipe creates a complete organization with vault data in a single operation. +/// All entity creation is delegated to factories. Users can log in with their email +/// and password "asdfasdfasdf". Organization and user keys are generated dynamically. +/// +public class OrganizationWithVaultRecipe( + DatabaseContext db, + IMapper mapper, + RustSdkService sdkService, + IPasswordHasher passwordHasher) +{ + private readonly CollectionSeeder _collectionSeeder = new(sdkService); + private readonly CipherSeeder _cipherSeeder = new(sdkService); + private readonly FolderSeeder _folderSeeder = new(sdkService); + + /// + /// Tracks a user with their symmetric key for folder encryption. + /// + private record UserWithKey(User User, string SymmetricKey); + + /// + /// Seeds an organization with users, collections, groups, and encrypted ciphers. + /// + /// Options specifying what to seed. + /// The organization ID. + public Guid Seed(OrganizationVaultOptions options) + { + var seats = Math.Max(options.Users + 1, 1000); + var orgKeys = sdkService.GenerateOrganizationKeys(); + + // Create organization via factory + var organization = OrganizationSeeder.CreateEnterprise( + options.Name, options.Domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey); + + // Create owner user via factory + var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{options.Domain}", sdkService, passwordHasher); + var ownerOrgKey = sdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key); + var ownerOrgUser = organization.CreateOrganizationUserWithKey( + ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey); + + // Create member users via factory, retaining keys for folder encryption + var memberUsersWithKeys = new List(); + var memberOrgUsers = new List(); + var useRealisticMix = options.RealisticStatusMix && options.Users >= 10; + + for (var i = 0; i < options.Users; i++) + { + var email = $"user{i}@{options.Domain}"; + var userKeys = sdkService.GenerateUserKeys(email, UserSeeder.DefaultPassword); + var memberUser = UserSeeder.CreateUserFromKeys(email, userKeys, passwordHasher); + memberUsersWithKeys.Add(new UserWithKey(memberUser, userKeys.Key)); + + var status = useRealisticMix + ? GetRealisticStatus(i, options.Users) + : OrganizationUserStatusType.Confirmed; + + var memberOrgKey = (status == OrganizationUserStatusType.Confirmed || + status == OrganizationUserStatusType.Revoked) + ? sdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key) + : null; + + memberOrgUsers.Add(organization.CreateOrganizationUserWithKey( + memberUser, OrganizationUserType.User, status, memberOrgKey)); + } + + var memberUsers = memberUsersWithKeys.Select(uwk => uwk.User).ToList(); + + // Persist organization and users + db.Add(mapper.Map(organization)); + db.Add(mapper.Map(ownerUser)); + db.Add(mapper.Map(ownerOrgUser)); + + 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(); + + // Get confirmed org user IDs for collection/group relationships + var confirmedOrgUserIds = memberOrgUsers + .Where(ou => ou.Status == OrganizationUserStatusType.Confirmed) + .Select(ou => ou.Id) + .Prepend(ownerOrgUser.Id) + .ToList(); + + var collectionIds = CreateCollections(organization.Id, orgKeys.Key, options.StructureModel, confirmedOrgUserIds); + CreateGroups(organization.Id, options.Groups, confirmedOrgUserIds); + CreateCiphers(organization.Id, orgKeys.Key, collectionIds, options.Ciphers, options.UsernamePattern, options.PasswordStrength, options.Region); + CreateFolders(memberUsersWithKeys); + + return organization.Id; + } + + private List CreateCollections( + Guid organizationId, + string orgKeyBase64, + OrgStructureModel? structureModel, + List orgUserIds) + { + List collections; + + if (structureModel.HasValue) + { + var structure = OrgStructures.GetStructure(structureModel.Value); + collections = structure.Units + .Select(unit => _collectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name)) + .ToList(); + } + else + { + collections = [_collectionSeeder.CreateCollection(organizationId, orgKeyBase64, "Default Collection")]; + } + + db.BulkCopy(collections); + + // Create collection-user relationships + if (collections.Count > 0 && orgUserIds.Count > 0) + { + var collectionUsers = orgUserIds + .SelectMany((orgUserId, userIndex) => + { + var maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count); + return Enumerable.Range(0, maxAssignments) + .Select(j => CollectionSeeder.CreateCollectionUser( + collections[(userIndex + j) % collections.Count].Id, + orgUserId, + readOnly: j > 0, + manage: j == 0)); + }) + .ToList(); + db.BulkCopy(collectionUsers); + } + + return collections.Select(c => c.Id).ToList(); + } + + private void CreateGroups(Guid organizationId, int groupCount, List orgUserIds) + { + var groupList = Enumerable.Range(0, groupCount) + .Select(i => GroupSeeder.CreateGroup(organizationId, $"Group {i + 1}")) + .ToList(); + + db.BulkCopy(groupList); + + // Create group-user relationships (round-robin assignment) + if (groupList.Count > 0 && orgUserIds.Count > 0) + { + var groupUsers = orgUserIds + .Select((orgUserId, i) => GroupSeeder.CreateGroupUser( + groupList[i % groupList.Count].Id, + orgUserId)) + .ToList(); + db.BulkCopy(groupUsers); + } + } + + private void CreateCiphers( + Guid organizationId, + string orgKeyBase64, + List collectionIds, + int cipherCount, + UsernamePatternType usernamePattern, + PasswordStrength passwordStrength, + GeographicRegion? region) + { + var companies = Companies.All; + var usernameGenerator = new CipherUsernameGenerator(organizationId.GetHashCode(), usernamePattern, region); + + var cipherList = Enumerable.Range(0, cipherCount) + .Select(i => + { + var company = companies[i % companies.Length]; + return _cipherSeeder.CreateOrganizationLoginCipher( + organizationId, + orgKeyBase64, + name: $"{company.Name} ({company.Category})", + username: usernameGenerator.GenerateVaried(company, i), + password: Passwords.GetPassword(passwordStrength, i), + uri: $"https://{company.Domain}"); + }) + .ToList(); + + db.BulkCopy(cipherList); + + // Create cipher-collection relationships + if (cipherList.Count > 0 && collectionIds.Count > 0) + { + var collectionCiphers = cipherList.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 new[] { primary }; + }).ToList(); + + db.BulkCopy(collectionCiphers); + } + } + + /// + /// Returns a realistic user status based on index position. + /// Distribution: 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked. + /// + private static OrganizationUserStatusType GetRealisticStatus(int index, int totalUsers) + { + // Calculate bucket boundaries + var confirmedCount = (int)(totalUsers * 0.85); + var invitedCount = (int)(totalUsers * 0.05); + var acceptedCount = (int)(totalUsers * 0.05); + // Revoked gets the remainder + + if (index < confirmedCount) + { + return OrganizationUserStatusType.Confirmed; + } + + if (index < confirmedCount + invitedCount) + { + return OrganizationUserStatusType.Invited; + } + + if (index < confirmedCount + invitedCount + acceptedCount) + { + return OrganizationUserStatusType.Accepted; + } + + return OrganizationUserStatusType.Revoked; + } + + /// + /// Creates personal vault folders for users with realistic distribution. + /// Folders are encrypted with each user's individual symmetric key. + /// + private void CreateFolders(List usersWithKeys) + { + if (usersWithKeys.Count == 0) + { + return; + } + + var seed = usersWithKeys[0].User.Id.GetHashCode(); + var random = new Random(seed); + var folderNameGenerator = new FolderNameGenerator(seed); + + var allFolders = usersWithKeys + .SelectMany((uwk, userIndex) => + { + var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, random); + return Enumerable.Range(0, folderCount) + .Select(folderIndex => _folderSeeder.CreateFolder( + uwk.User.Id, + uwk.SymmetricKey, + folderNameGenerator.GetFolderName(userIndex * 15 + folderIndex))); + }) + .ToList(); + + if (allFolders.Count > 0) + { + var efFolders = allFolders.Select(f => mapper.Map(f)).ToList(); + db.BulkCopy(efFolders); + } + } + + /// + /// Returns folder count based on user index position in the distribution. + /// Distribution: 35% Zero, 35% Few (1-3), 20% Some (4-7), 10% TooMany (10-15) + /// + private static int GetFolderCountForUser(int userIndex, int totalUsers, Random random) + { + var zeroCount = (int)(totalUsers * 0.35); + var fewCount = (int)(totalUsers * 0.35); + var someCount = (int)(totalUsers * 0.20); + // TooMany gets the remainder + + if (userIndex < zeroCount) + { + return 0; // Zero folders + } + + if (userIndex < zeroCount + fewCount) + { + return random.Next(1, 4); // Few: 1-3 folders + } + + if (userIndex < zeroCount + fewCount + someCount) + { + return random.Next(4, 8); // Some: 4-7 folders + } + + return random.Next(10, 16); // TooMany: 10-15 folders + } +} diff --git a/util/Seeder/Seeder.csproj b/util/Seeder/Seeder.csproj index fd6e26c1ee..b38c2cf1e1 100644 --- a/util/Seeder/Seeder.csproj +++ b/util/Seeder/Seeder.csproj @@ -19,6 +19,10 @@ + + + +