1
0
mirror of https://github.com/bitwarden/server synced 2026-01-29 07:43:22 +00:00

Add cipher seeding with Rust SDK encryption to enable cryptographically correct test data generation

This commit is contained in:
Mick Letofsky
2026-01-26 15:46:24 +01:00
parent 7fb2822e05
commit c997f87333
34 changed files with 2731 additions and 248 deletions

View File

@@ -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) {

View File

@@ -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<CipherViewDto>(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<CipherViewDto>(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<CipherViewDto>(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<CipherLoginData>(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<CipherLoginData>(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" }]
}
};
}
}

View File

@@ -1,4 +1,4 @@
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Recipes;
using CommandDotNet;
using Microsoft.Extensions.DependencyInjection;
@@ -13,27 +13,44 @@ public class Program
.Run(args);
}
[Command("organization", Description = "Seed an organization and organization users")]
[Command("organization", Description = "Seed an organization with users and optional ciphers")]
public void Organization(
[Option('n', "Name", Description = "Name of organization")]
[Option('n', "name", Description = "Name of organization")]
string name,
[Option('u', "users", Description = "Number of users to generate")]
int users,
[Option('d', "domain", Description = "Email domain for users")]
string domain
string domain,
[Option('c', "ciphers", Description = "Number of login ciphers to create")]
int ciphers = 0,
[Option('s', "structure", Description = "Org structure for collections: Traditional, Spotify, or Modern")]
string? structure = null
)
{
// Create service provider with necessary services
var structureModel = ParseStructureModel(structure);
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
// Get a scoped DB context
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<DatabaseContext>();
OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, name, domain, users, ciphers,
structureModel: structureModel);
}
var recipe = new OrganizationWithUsersRecipe(db);
recipe.Seed(name: name, domain: domain, users: users);
private static OrgStructureModel? ParseStructureModel(string? structure)
{
if (string.IsNullOrEmpty(structure))
{
return null;
}
return structure.ToLowerInvariant() switch
{
"traditional" => OrgStructureModel.Traditional,
"spotify" => OrgStructureModel.Spotify,
"modern" => OrgStructureModel.Modern,
_ => throw new ArgumentException($"Unknown structure '{structure}'. Use: Traditional, Spotify, or Modern")
};
}
}

View File

@@ -28,10 +28,34 @@ DbSeeder.exe <command> [options]
```bash
# Generate an organization called "seeded" with 10000 users using the @large.test email domain.
# Login using "admin@large.test" with password "asdfasdfasdf"
# Login using "owner@large.test" with password "asdfasdfasdf"
DbSeeder.exe organization -n seeded -u 10000 -d large.test
# Generate an organization with 5 users and 100 encrypted ciphers
DbSeeder.exe organization -n TestOrg -u 5 -d test.com -c 100
# Generate a small test organization with ciphers for manual testing
DbSeeder.exe organization -n DevOrg -u 2 -d dev.local -c 10
```
### Options
| Option | Description |
|--------|-------------|
| `-n, --name` | Organization name |
| `-u, --users` | Number of member users to create |
| `-d, --domain` | Email domain (e.g., test.com creates owner@test.com) |
| `-c, --ciphers` | Number of encrypted ciphers to create (optional) |
| `-s, --status` | User status: Confirmed (default), Invited, Accepted, Revoked |
### Notes
- All users are created with the password `asdfasdfasdf`
- The owner account is always `owner@{domain}` with Confirmed status
- Member accounts are `user0@{domain}`, `user1@{domain}`, etc.
- When ciphers are created, a "Default Collection" is automatically created and all users are granted access
- Ciphers are encrypted using dynamically generated organization keys
## Dependencies
This utility depends on:

View File

@@ -1,5 +1,8 @@
using Bit.SharedWeb.Utilities;
using Bit.Core.Entities;
using Bit.RustSDK;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -15,6 +18,8 @@ public static class ServiceCollectionExtension
// Register services
services.AddLogging(builder => builder.AddConsole());
services.AddSingleton(globalSettings);
services.AddSingleton<RustSdkService>();
services.AddSingleton<IPasswordHasher<User>, PasswordHasher<User>>();
// Add Data Protection services
services.AddDataProtection()

View File

@@ -78,6 +78,51 @@ public class RustSdkService
}
}
public unsafe string EncryptCipher(string cipherViewJson, string symmetricKeyBase64)
{
var cipherViewBytes = StringToRustString(cipherViewJson);
var keyBytes = StringToRustString(symmetricKeyBase64);
fixed (byte* cipherViewPtr = cipherViewBytes)
fixed (byte* keyPtr = keyBytes)
{
var resultPtr = NativeMethods.encrypt_cipher(cipherViewPtr, keyPtr);
return TakeAndDestroyRustString(resultPtr);
}
}
public unsafe string DecryptCipher(string cipherJson, string symmetricKeyBase64)
{
var cipherBytes = StringToRustString(cipherJson);
var keyBytes = StringToRustString(symmetricKeyBase64);
fixed (byte* cipherPtr = cipherBytes)
fixed (byte* keyPtr = keyBytes)
{
var resultPtr = NativeMethods.decrypt_cipher(cipherPtr, keyPtr);
return TakeAndDestroyRustString(resultPtr);
}
}
/// <summary>
/// Encrypts a plaintext string using the provided symmetric key.
/// Returns an EncString in format "2.{iv}|{data}|{mac}".
/// </summary>
public unsafe string EncryptString(string plaintext, string symmetricKeyBase64)
{
var plaintextBytes = StringToRustString(plaintext);
var keyBytes = StringToRustString(symmetricKeyBase64);
fixed (byte* plaintextPtr = plaintextBytes)
fixed (byte* keyPtr = keyBytes)
{
var resultPtr = NativeMethods.encrypt_string(plaintextPtr, keyPtr);
return TakeAndDestroyRustString(resultPtr);
}
}
private static byte[] StringToRustString(string str)
{

View File

@@ -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",
]

View File

@@ -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"

View File

@@ -6,11 +6,13 @@ use std::{
use base64::{engine::general_purpose::STANDARD, Engine};
use bitwarden_core::key_management::KeyIds;
use bitwarden_crypto::{
AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, HashPurpose, Kdf,
KeyEncryptable, MasterKey, RsaKeyPair, SpkiPublicKeyBytes, SymmetricCryptoKey,
UnsignedSharedKey, UserKey,
AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, CompositeEncryptable,
Decryptable, HashPurpose, Kdf, KeyEncryptable, KeyStore, MasterKey, RsaKeyPair,
SpkiPublicKeyBytes, SymmetricCryptoKey, UnsignedSharedKey, UserKey,
};
use bitwarden_vault::{Cipher, CipherView};
#[no_mangle]
pub unsafe extern "C" fn generate_user_keys(
@@ -20,9 +22,6 @@ pub unsafe extern "C" fn generate_user_keys(
let email = CStr::from_ptr(email).to_str().unwrap();
let password = CStr::from_ptr(password).to_str().unwrap();
println!("Generating keys for {email}");
println!("Password: {password}");
let kdf = Kdf::PBKDF2 {
iterations: NonZeroU32::new(5_000).unwrap(),
};
@@ -32,8 +31,6 @@ pub unsafe extern "C" fn generate_user_keys(
let master_password_hash =
master_key.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization);
println!("Master password hash: {}", master_password_hash);
let (user_key, encrypted_user_key) = master_key.make_user_key().unwrap();
let keypair = keypair(&user_key.0);
@@ -140,6 +137,183 @@ pub unsafe extern "C" fn generate_user_organization_key(
result.into_raw()
}
/// Create an error JSON response and return it as a C string pointer.
fn error_response(message: &str) -> *const c_char {
let error_json = serde_json::json!({ "error": message }).to_string();
CString::new(error_json).unwrap().into_raw()
}
/// Encrypt a CipherView with a symmetric key, returning an encrypted Cipher as JSON.
///
/// # Arguments
/// * `cipher_view_json` - JSON string representing a CipherView (camelCase format)
/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256)
///
/// # Returns
/// JSON string representing the encrypted Cipher
///
/// # Safety
/// Both pointers must be valid null-terminated strings.
#[no_mangle]
pub unsafe extern "C" fn encrypt_cipher(
cipher_view_json: *const c_char,
symmetric_key_b64: *const c_char,
) -> *const c_char {
let cipher_view_json = match CStr::from_ptr(cipher_view_json).to_str() {
Ok(s) => s,
Err(_) => return error_response("Invalid UTF-8 in cipher_view_json"),
};
let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() {
Ok(s) => s,
Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"),
};
let cipher_view: CipherView = match serde_json::from_str(cipher_view_json) {
Ok(v) => v,
Err(_) => return error_response("Failed to parse CipherView JSON"),
};
let key_bytes = match STANDARD.decode(key_b64) {
Ok(b) => b,
Err(_) => return error_response("Failed to decode base64 key"),
};
let key =
match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) {
Ok(k) => k,
Err(_) => {
return error_response(
"Failed to create symmetric key: invalid key format or length",
)
}
};
let store: KeyStore<KeyIds> = KeyStore::default();
let mut ctx = store.context_mut();
let key_id = ctx.add_local_symmetric_key(key);
let cipher = match cipher_view.encrypt_composite(&mut ctx, key_id) {
Ok(c) => c,
Err(_) => return error_response("Failed to encrypt cipher: encryption operation failed"),
};
match serde_json::to_string(&cipher) {
Ok(json) => CString::new(json).unwrap().into_raw(),
Err(_) => error_response("Failed to serialize encrypted cipher"),
}
}
/// Decrypt an encrypted Cipher with a symmetric key, returning a CipherView as JSON.
///
/// # Arguments
/// * `cipher_json` - JSON string representing an encrypted Cipher
/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256)
///
/// # Returns
/// JSON string representing the decrypted CipherView
///
/// # Safety
/// Both pointers must be valid null-terminated strings.
#[no_mangle]
pub unsafe extern "C" fn decrypt_cipher(
cipher_json: *const c_char,
symmetric_key_b64: *const c_char,
) -> *const c_char {
let cipher_json = match CStr::from_ptr(cipher_json).to_str() {
Ok(s) => s,
Err(_) => return error_response("Invalid UTF-8 in cipher_json"),
};
let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() {
Ok(s) => s,
Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"),
};
let cipher: Cipher = match serde_json::from_str(cipher_json) {
Ok(c) => c,
Err(_) => return error_response("Failed to parse Cipher JSON"),
};
let key_bytes = match STANDARD.decode(key_b64) {
Ok(b) => b,
Err(_) => return error_response("Failed to decode base64 key"),
};
let key =
match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) {
Ok(k) => k,
Err(_) => {
return error_response(
"Failed to create symmetric key: invalid key format or length",
)
}
};
let store: KeyStore<KeyIds> = KeyStore::default();
let mut ctx = store.context_mut();
let key_id = ctx.add_local_symmetric_key(key);
let cipher_view: CipherView = match cipher.decrypt(&mut ctx, key_id) {
Ok(v) => v,
Err(_) => return error_response("Failed to decrypt cipher: decryption operation failed"),
};
match serde_json::to_string(&cipher_view) {
Ok(json) => CString::new(json).unwrap().into_raw(),
Err(_) => error_response("Failed to serialize decrypted cipher"),
}
}
/// Encrypt a plaintext string with a symmetric key, returning an EncString.
///
/// # Arguments
/// * `plaintext` - The plaintext string to encrypt
/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256)
///
/// # Returns
/// EncString in format "2.{iv}|{data}|{mac}"
///
/// # Safety
/// Both pointers must be valid null-terminated strings.
#[no_mangle]
pub unsafe extern "C" fn encrypt_string(
plaintext: *const c_char,
symmetric_key_b64: *const c_char,
) -> *const c_char {
let plaintext = match CStr::from_ptr(plaintext).to_str() {
Ok(s) => s,
Err(_) => return error_response("Invalid UTF-8 in plaintext"),
};
let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() {
Ok(s) => s,
Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"),
};
let key_bytes = match STANDARD.decode(key_b64) {
Ok(b) => b,
Err(_) => return error_response("Failed to decode base64 key"),
};
let key =
match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) {
Ok(k) => k,
Err(_) => {
return error_response(
"Failed to create symmetric key: invalid key format or length",
)
}
};
let encrypted = match plaintext.to_string().encrypt_with_key(&key) {
Ok(e) => e,
Err(_) => return error_response("Failed to encrypt string"),
};
CString::new(encrypted.to_string()).unwrap().into_raw()
}
/// # Safety
///
/// The `str` pointer must be a valid pointer previously returned by `CString::into_raw`
@@ -150,3 +324,245 @@ pub unsafe extern "C" fn free_c_string(str: *mut c_char) {
drop(CString::from_raw(str));
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitwarden_vault::{Cipher, CipherType, LoginView};
fn create_test_cipher_view() -> CipherView {
CipherView {
id: None,
organization_id: None,
folder_id: None,
collection_ids: vec![],
key: None,
name: "Test Login".to_string(),
notes: Some("Secret notes".to_string()),
r#type: CipherType::Login,
login: Some(LoginView {
username: Some("testuser@example.com".to_string()),
password: Some("SuperSecretP@ssw0rd!".to_string()),
password_revision_date: None,
uris: None,
totp: None,
autofill_on_page_load: None,
fido2_credentials: None,
}),
identity: None,
card: None,
secure_note: None,
ssh_key: None,
favorite: false,
reprompt: bitwarden_vault::CipherRepromptType::None,
organization_use_totp: false,
edit: true,
permissions: None,
view_password: true,
local_data: None,
attachments: None,
fields: None,
password_history: None,
creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
deleted_date: None,
revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
archived_date: None,
}
}
fn call_encrypt_cipher(cipher_json: &str, key_b64: &str) -> String {
let cipher_cstr = CString::new(cipher_json).unwrap();
let key_cstr = CString::new(key_b64).unwrap();
let result_ptr = unsafe { encrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) };
let result_cstr = unsafe { CStr::from_ptr(result_ptr) };
let result = result_cstr.to_str().unwrap().to_owned();
unsafe { free_c_string(result_ptr as *mut c_char) };
result
}
fn make_test_key_b64() -> String {
SymmetricCryptoKey::make_aes256_cbc_hmac_key()
.to_base64()
.into()
}
#[test]
fn encrypt_cipher_produces_encrypted_fields() {
let key_b64 = make_test_key_b64();
let cipher_view = create_test_cipher_view();
let cipher_json = serde_json::to_string(&cipher_view).unwrap();
let encrypted_json = call_encrypt_cipher(&cipher_json, &key_b64);
assert!(
!encrypted_json.contains("\"error\""),
"Got error: {}",
encrypted_json
);
let encrypted_cipher: Cipher =
serde_json::from_str(&encrypted_json).expect("Failed to parse encrypted cipher JSON");
let encrypted_name = encrypted_cipher.name.to_string();
assert!(
encrypted_name.starts_with("2."),
"Name should be encrypted: {}",
encrypted_name
);
let login = encrypted_cipher.login.expect("Login should be present");
if let Some(username) = &login.username {
assert!(
username.to_string().starts_with("2."),
"Username should be encrypted"
);
}
if let Some(password) = &login.password {
assert!(
password.to_string().starts_with("2."),
"Password should be encrypted"
);
}
}
#[test]
fn encrypt_cipher_works_with_generated_org_key() {
let org_keys_ptr = unsafe { generate_organization_keys() };
let org_keys_cstr = unsafe { CStr::from_ptr(org_keys_ptr) };
let org_keys_json = org_keys_cstr.to_str().unwrap().to_owned();
unsafe { free_c_string(org_keys_ptr as *mut c_char) };
let org_keys: serde_json::Value = serde_json::from_str(&org_keys_json).unwrap();
let org_key_b64 = org_keys["key"].as_str().unwrap();
let cipher_view = create_test_cipher_view();
let cipher_json = serde_json::to_string(&cipher_view).unwrap();
let encrypted_json = call_encrypt_cipher(&cipher_json, org_key_b64);
assert!(
!encrypted_json.contains("\"error\""),
"Got error: {}",
encrypted_json
);
let encrypted_cipher: Cipher = serde_json::from_str(&encrypted_json).unwrap();
assert!(encrypted_cipher.name.to_string().starts_with("2."));
}
#[test]
fn encrypt_cipher_rejects_invalid_json() {
let key_b64 = make_test_key_b64();
let error_json = call_encrypt_cipher("{ this is not valid json }", &key_b64);
assert!(
error_json.contains("\"error\""),
"Should return error for invalid JSON"
);
assert!(error_json.contains("Failed to parse CipherView JSON"));
}
#[test]
fn encrypt_cipher_rejects_invalid_base64_key() {
let cipher_view = create_test_cipher_view();
let cipher_json = serde_json::to_string(&cipher_view).unwrap();
let error_json = call_encrypt_cipher(&cipher_json, "not-valid-base64!!!");
assert!(
error_json.contains("\"error\""),
"Should return error for invalid base64"
);
assert!(error_json.contains("Failed to decode base64 key"));
}
#[test]
fn encrypt_cipher_rejects_wrong_key_length() {
let cipher_view = create_test_cipher_view();
let cipher_json = serde_json::to_string(&cipher_view).unwrap();
let short_key_b64 = STANDARD.encode(b"too short");
let error_json = call_encrypt_cipher(&cipher_json, &short_key_b64);
assert!(
error_json.contains("\"error\""),
"Should return error for wrong key length"
);
assert!(error_json.contains("invalid key format or length"));
}
fn call_decrypt_cipher(cipher_json: &str, key_b64: &str) -> String {
let cipher_cstr = CString::new(cipher_json).unwrap();
let key_cstr = CString::new(key_b64).unwrap();
let result_ptr = unsafe { decrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) };
let result_cstr = unsafe { CStr::from_ptr(result_ptr) };
let result = result_cstr.to_str().unwrap().to_owned();
unsafe { free_c_string(result_ptr as *mut c_char) };
result
}
#[test]
fn encrypt_decrypt_roundtrip_preserves_plaintext() {
let key_b64 = make_test_key_b64();
let original_view = create_test_cipher_view();
let original_json = serde_json::to_string(&original_view).unwrap();
let encrypted_json = call_encrypt_cipher(&original_json, &key_b64);
assert!(
!encrypted_json.contains("\"error\""),
"Encryption failed: {}",
encrypted_json
);
let decrypted_json = call_decrypt_cipher(&encrypted_json, &key_b64);
assert!(
!decrypted_json.contains("\"error\""),
"Decryption failed: {}",
decrypted_json
);
let decrypted_view: CipherView = serde_json::from_str(&decrypted_json)
.expect("Failed to parse decrypted CipherView");
assert_eq!(decrypted_view.name, original_view.name);
assert_eq!(decrypted_view.notes, original_view.notes);
let original_login = original_view.login.expect("Original should have login");
let decrypted_login = decrypted_view.login.expect("Decrypted should have login");
assert_eq!(decrypted_login.username, original_login.username);
assert_eq!(decrypted_login.password, original_login.password);
}
#[test]
fn decrypt_cipher_rejects_wrong_key() {
let encrypt_key = make_test_key_b64();
let wrong_key = make_test_key_b64();
let original_view = create_test_cipher_view();
let original_json = serde_json::to_string(&original_view).unwrap();
let encrypted_json = call_encrypt_cipher(&original_json, &encrypt_key);
assert!(!encrypted_json.contains("\"error\""));
let decrypted_json = call_decrypt_cipher(&encrypted_json, &wrong_key);
// Decryption with wrong key should fail or produce garbage
// The SDK may return an error or the MAC validation will fail
let result: Result<CipherView, _> = 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"
);
}
}
}
}

View File

@@ -0,0 +1,199 @@
# Seeder - Claude Code Context
## When to Use the Seeder
✅ Use for:
- Local development database setup
- Integration test data creation
- Performance testing with realistic encrypted data
❌ Do NOT use for:
- Production data
- Copying real user vaults (use backup/restore instead)
## Zero-Knowledge Architecture
**Critical Principle:** Unencrypted vault data never leaves the client. The server never sees plaintext.
### Why Seeder Uses the Rust SDK
The Seeder must behave exactly like any other Bitwarden client. Since the server:
- Never receives plaintext
- Cannot perform encryption (doesn't have keys)
- Only stores/retrieves encrypted blobs
...the Seeder cannot simply write plaintext to the database. It must:
1. Generate encryption keys (like a client does during account setup)
2. Encrypt vault data client-side (using the same SDK the real clients use)
3. Store only the encrypted result
This is why we use the Rust SDK via FFI - it's the same cryptographic implementation used by the official clients.
## Cipher Encryption Architecture
### The Two-State Pattern
Bitwarden uses a clean separation between encrypted and decrypted data:
| State | SDK Type | Description | Stored in DB? |
| --------- | ------------ | ------------------------- | ------------- |
| Plaintext | `CipherView` | Decrypted, human-readable | Never |
| Encrypted | `Cipher` | EncString values | Yes |
**Encryption flow:**
```
CipherView (plaintext) → encrypt_composite() → Cipher (encrypted)
```
**Decryption flow:**
```
Cipher (encrypted) → decrypt() → CipherView (plaintext)
```
### EncString Format
All encrypted fields use the EncString format:
```
2.{base64_iv}|{base64_data}|{base64_mac}
│ └──────────┘ └──────────┘ └──────────┘
│ IV Ciphertext HMAC
└─ Type 2 = AES-256-CBC-HMAC-SHA256
```
### SDK vs Server Format Difference
**Critical:** The SDK and server use different JSON structures.
**SDK Cipher (nested):**
```json
{
"name": "2.abc...",
"login": {
"username": "2.def...",
"password": "2.ghi..."
}
}
```
**Server Cipher.Data (flat CipherLoginData):**
```json
{
"Name": "2.abc...",
"Username": "2.def...",
"Password": "2.ghi..."
}
```
The `CipherSeeder.TransformToServerCipher()` method performs this flattening.
### Data Flow in Seeder
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ CipherViewDto │────▶│ Rust SDK │────▶│ EncryptedCipherDto │
│ (plaintext) │ │ encrypt_cipher │ │ (SDK Cipher) │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
┌───────────────────────┐
│ TransformToServer │
│ (flatten nested → │
│ flat structure) │
└───────────────────────┘
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Server Cipher │◀────│ CipherLoginData │◀────│ Flattened JSON │
│ Entity │ │ (serialized) │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
```
### Key Files
| File | Purpose |
| ------------------------------ | ------------------------------------------------------ |
| `Models/CipherViewDto.cs` | Plaintext input matching SDK's CipherView |
| `Models/EncryptedCipherDto.cs` | Parses SDK's encrypted Cipher output |
| `Factories/CipherSeeder.cs` | Creates encrypted ciphers, transforms to server format |
| `Recipes/CiphersRecipe.cs` | Bulk cipher creation with collection assignment |
### Key Hierarchy
Bitwarden uses a two-level encryption hierarchy:
1. **User/Organization Key** - Encrypts the cipher's individual key
2. **Cipher Key** (optional) - Encrypts the actual cipher data
For seeding, we use the organization's symmetric key directly (no per-cipher key).
## Rust SDK FFI
### Available Functions
| Function | Input | Output |
| ---------------------------- | --------------------- | --------------------------- |
| `encrypt_cipher` | CipherView JSON + key | Cipher JSON |
| `decrypt_cipher` | Cipher JSON + key | CipherView JSON |
| `generate_organization_keys` | (none) | Org symmetric key + keypair |
### Error Handling
SDK functions return JSON with an `"error"` field on failure:
```json
{ "error": "Failed to parse CipherView JSON" }
```
Always check for `"error"` in the response before parsing.
## Testing
Integration tests in `test/SeederApi.IntegrationTest/RustSdkCipherTests.cs` verify:
1. **Roundtrip encryption** - Encrypt then decrypt preserves plaintext
2. **Server format compatibility** - Output matches CipherLoginData structure
3. **Field encryption** - Custom fields are properly encrypted
4. **Security** - Plaintext never appears in encrypted output
## Common Patterns
### Creating a Cipher
```csharp
var sdk = new RustSdkService();
var seeder = new CipherSeeder(sdk);
var cipher = seeder.CreateOrganizationLoginCipher(
organizationId,
orgKey, // Base64-encoded symmetric key
name: "My Login",
username: "user@example.com",
password: "secret123");
```
### Bulk Cipher Creation
```csharp
var recipe = new CiphersRecipe(dbContext, sdkService);
var cipherIds = recipe.AddLoginCiphersToOrganization(
organizationId,
orgKey,
collectionIds,
count: 100);
```
## Security Reminders
- Generated test passwords are intentionally weak (`asdfasdfasdf`)
- Never commit database dumps containing seeded data to version control
- Seeded keys are for testing only - regenerate for each test run

View File

@@ -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);
/// <summary>
/// Sample company data organized by region. Add new regions by creating arrays and including them in All.
/// </summary>
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<Company> 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];
}
}

View File

@@ -0,0 +1,11 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Business category for company classification.
/// </summary>
public enum CompanyCategory
{
SocialMedia, Streaming, ECommerce, CRM, Security, CloudInfrastructure,
DevOps, Collaboration, HRTalent, FinanceERP, Analytics, ProjectManagement,
Marketing, ITServiceManagement, Productivity, Developer, Financial
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Target market type for companies.
/// </summary>
public enum CompanyType { Consumer, Enterprise, Hybrid }

View File

@@ -0,0 +1,9 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Geographic region for company headquarters.
/// </summary>
public enum GeographicRegion
{
NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, Global
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Organizational structure model types.
/// </summary>
public enum OrgStructureModel { Traditional, Spotify, Modern }

View File

@@ -0,0 +1,6 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Password strength levels for test data generation.
/// </summary>
public enum PasswordStrength { Weak, Medium, Strong, Mixed }

View File

@@ -0,0 +1,20 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Username/email format patterns used by organizations.
/// </summary>
public enum UsernamePatternType
{
/// <summary>first.last@domain.com</summary>
FirstDotLast,
/// <summary>f.last@domain.com</summary>
FDotLast,
/// <summary>flast@domain.com</summary>
FLast,
/// <summary>last.first@domain.com</summary>
LastDotFirst,
/// <summary>first_last@domain.com</summary>
First_Last,
/// <summary>lastf@domain.com</summary>
LastFirst
}

80
util/Seeder/Data/Names.cs Normal file
View File

@@ -0,0 +1,80 @@
namespace Bit.Seeder.Data;
/// <summary>
/// First and last names organized by region for username generation.
/// Add new regions by creating arrays and including them in the All* properties.
/// </summary>
internal static class Names
{
public static readonly string[] UsFirstNames =
[
// Male
"James", "Robert", "John", "Michael", "David", "William", "Richard", "Joseph", "Thomas", "Christopher",
"Charles", "Daniel", "Matthew", "Anthony", "Mark", "Donald", "Steven", "Paul", "Andrew", "Joshua",
"Kenneth", "Kevin", "Brian", "George", "Timothy", "Ronald", "Edward", "Jason", "Jeffrey", "Ryan",
"Jacob", "Gary", "Nicholas", "Eric", "Jonathan", "Stephen", "Larry", "Justin", "Scott", "Brandon",
"Benjamin", "Samuel", "Raymond", "Gregory", "Frank", "Alexander", "Patrick", "Jack", "Dennis", "Jerry",
// Female
"Mary", "Patricia", "Jennifer", "Linda", "Barbara", "Elizabeth", "Susan", "Jessica", "Sarah", "Karen",
"Lisa", "Nancy", "Betty", "Margaret", "Sandra", "Ashley", "Kimberly", "Emily", "Donna", "Michelle",
"Dorothy", "Carol", "Amanda", "Melissa", "Deborah", "Stephanie", "Rebecca", "Sharon", "Laura", "Cynthia",
"Kathleen", "Amy", "Angela", "Shirley", "Anna", "Brenda", "Pamela", "Emma", "Nicole", "Helen",
"Samantha", "Katherine", "Christine", "Debra", "Rachel", "Carolyn", "Janet", "Catherine", "Maria", "Heather"
];
public static readonly string[] UsLastNames =
[
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez",
"Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin",
"Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson",
"Walker", "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores",
"Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", "Carter", "Roberts",
"Gomez", "Phillips", "Evans", "Turner", "Diaz", "Parker", "Cruz", "Edwards", "Collins", "Reyes",
"Stewart", "Morris", "Morales", "Murphy", "Cook", "Rogers", "Gutierrez", "Ortiz", "Morgan", "Cooper",
"Peterson", "Bailey", "Reed", "Kelly", "Howard", "Ramos", "Kim", "Cox", "Ward", "Richardson",
"Watson", "Brooks", "Chavez", "Wood", "James", "Bennett", "Gray", "Mendoza", "Ruiz", "Hughes",
"Price", "Alvarez", "Castillo", "Sanders", "Patel", "Myers", "Long", "Ross", "Foster", "Jimenez"
];
public static readonly string[] EuropeanFirstNames =
[
// British
"Oliver", "George", "Harry", "Jack", "Charlie", "Thomas", "Oscar", "William", "James", "Henry",
"Olivia", "Amelia", "Isla", "Ava", "Emily", "Sophie", "Grace", "Mia", "Poppy", "Ella",
// German
"Maximilian", "Alexander", "Paul", "Leon", "Lukas", "Felix", "Noah", "Elias", "Ben", "Finn",
"Emma", "Hannah", "Mia", "Sofia", "Anna", "Emilia", "Lena", "Marie", "Lea", "Clara",
// French
"Gabriel", "Raphael", "Leo", "Louis", "Lucas", "Adam", "Hugo", "Jules", "Arthur", "Nathan",
"Louise", "Alice", "Chloe", "Ines", "Lea", "Manon", "Rose", "Anna", "Lina", "Mila",
// Spanish
"Hugo", "Martin", "Lucas", "Daniel", "Pablo", "Alejandro", "Adrian", "Alvaro", "David", "Mario",
"Lucia", "Sofia", "Maria", "Martina", "Paula", "Julia", "Daniela", "Valeria", "Alba", "Emma",
// Italian
"Leonardo", "Francesco", "Alessandro", "Lorenzo", "Mattia", "Andrea", "Gabriele", "Riccardo", "Tommaso", "Edoardo",
"Sofia", "Giulia", "Aurora", "Alice", "Ginevra", "Emma", "Giorgia", "Greta", "Beatrice", "Anna"
];
public static readonly string[] EuropeanLastNames =
[
// British
"Smith", "Jones", "Williams", "Brown", "Taylor", "Davies", "Wilson", "Evans", "Thomas", "Johnson",
"Roberts", "Walker", "Wright", "Robinson", "Thompson", "White", "Hughes", "Edwards", "Green", "Hall",
// German
"Mueller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner", "Becker", "Schulz", "Hoffmann",
"Schaefer", "Koch", "Bauer", "Richter", "Klein", "Wolf", "Schroeder", "Neumann", "Schwarz", "Zimmermann",
// French
"Martin", "Bernard", "Dubois", "Thomas", "Robert", "Richard", "Petit", "Durand", "Leroy", "Moreau",
"Simon", "Laurent", "Lefebvre", "Michel", "Garcia", "David", "Bertrand", "Roux", "Vincent", "Fournier",
// Spanish
"Garcia", "Rodriguez", "Martinez", "Lopez", "Sanchez", "Gonzalez", "Perez", "Martin", "Gomez", "Ruiz",
"Hernandez", "Jimenez", "Diaz", "Moreno", "Alvarez", "Munoz", "Romero", "Alonso", "Gutierrez", "Navarro",
// Italian
"Rossi", "Russo", "Ferrari", "Esposito", "Bianchi", "Romano", "Colombo", "Ricci", "Marino", "Greco",
"Bruno", "Gallo", "Conti", "DeLuca", "Costa", "Giordano", "Mancini", "Rizzo", "Lombardi", "Moretti"
];
public static readonly string[] AllFirstNames = [.. UsFirstNames, .. EuropeanFirstNames];
public static readonly string[] AllLastNames = [.. UsLastNames, .. EuropeanLastNames];
}

View File

@@ -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);
/// <summary>
/// Pre-defined organizational structures for different company models.
/// </summary>
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
};
}

View File

@@ -0,0 +1,67 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
/// <summary>
/// Password collections by strength level for realistic test data.
/// </summary>
internal static class Passwords
{
/// <summary>
/// Top breached passwords - use for security testing scenarios.
/// </summary>
public static readonly string[] Weak =
[
"password", "123456", "qwerty", "abc123", "letmein", "welcome", "admin", "dragon", "sunshine", "princess",
"football", "master", "shadow", "superman", "trustno1", "iloveyou", "passw0rd", "p@ssw0rd", "welcome1", "Password1",
"qwerty123", "123qwe", "1q2w3e", "password123", "12345678", "111111", "1234567890", "monkey", "baseball", "access"
];
/// <summary>
/// Meets basic complexity requirements but follows predictable patterns (season+year, name+numbers).
/// </summary>
public static readonly string[] Medium =
[
"Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!", "December2023#",
"Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login123!", "Portal2024#",
"Michael123!", "Jennifer2024@", "Robert456#", "Sarah789!",
"Qwerty123!", "Asdfgh456@", "Zxcvbn789#",
"Password123!", "Security2024@", "Admin123!", "User2024#", "Guest123!", "Test2024@",
"Football123!", "Baseball2024@", "Soccer456#", "Hockey789!"
];
/// <summary>
/// High-entropy passwords: random strings (password manager style) and diceware passphrases.
/// </summary>
public static readonly string[] Strong =
[
"k#9Lm$vQ2@xR7nP!", "Yx8&mK3$pL5#wQ9@", "Nv4%jH7!bT2@sF6#", "Rm9#cX5$gW1@zK8!", "Qp3@hY6#nL9$tB2!",
"Wz7!mF4@kS8#xC1$", "Jd2#pR9!vN5@bG7$", "Ht6@wL3#yK8!mQ4$", "Bf8$cM2@zT5#rX9!", "Lg1!nV7@sH4#pY6$",
"Kx9#mL4$pQ7@wR2!vN5", "Yz3@hT8#bF1$cS6!nM9", "Wv5!rK2@jG9#tX4$mL7", "Qn7$sB3@yH6#pC1!zF8", "Tm2@xD5#kW9$vL4!rJ7",
"correct-horse-battery-staple", "purple-monkey-dishwasher-lamp", "quantum-bicycle-elephant-storm",
"velvet-thunder-crystal-forge", "neon-wizard-cosmic-river", "amber-phoenix-digital-maze",
"silver-falcon-ancient-code", "lunar-garden-frozen-spark", "echo-prism-wandering-light", "rust-vapor-hidden-gate",
"Brave.Tiger.Runs.Fast.42", "Blue.Ocean.Deep.Wave.17", "Swift.Eagle.Soars.High.93",
"Calm.Forest.Green.Path.28", "Warm.Summer.Golden.Sun.61",
"maple#stream#winter#glow", "ember@cloud@silent@peak", "frost$dawn$valley$mist", "coral!reef!azure!tide", "stone&moss&ancient&oak",
"Kx9mL4pQ7wR2vN5hT8bF", "Yz3hT8bF1cS6nM9wK4pL", "Wv5rK2jG9tX4mL7nB3sH", "Qn7sB3yH6pC1zF8kW2xD", "Tm2xD5kW9vL4rJ7gN1cY"
];
/// <remarks>Must be declared after strength arrays (S3263).</remarks>
public static readonly string[] All = [.. Weak, .. Medium, .. Strong];
public static string[] GetByStrength(PasswordStrength strength) => strength switch
{
PasswordStrength.Weak => Weak,
PasswordStrength.Medium => Medium,
PasswordStrength.Strong => Strong,
PasswordStrength.Mixed => All,
_ => Strong
};
public static string GetPassword(PasswordStrength strength, int index)
{
var passwords = GetByStrength(strength);
return passwords[index % passwords.Length];
}
}

144
util/Seeder/Data/README.md Normal file
View File

@@ -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)
```

View File

@@ -0,0 +1,65 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
/// <summary>
/// Generates deterministic usernames for companies using configurable patterns.
/// </summary>
internal sealed class UsernameGenerator
{
private readonly Random _random;
private readonly UsernamePattern _pattern;
private readonly string[] _firstNames;
private readonly string[] _lastNames;
public UsernameGenerator(
int seed,
UsernamePatternType patternType = UsernamePatternType.FirstDotLast,
GeographicRegion? region = null)
{
_random = new Random(seed);
_pattern = UsernamePatterns.GetPattern(patternType);
(_firstNames, _lastNames) = region switch
{
GeographicRegion.NorthAmerica => (Names.UsFirstNames, Names.UsLastNames),
GeographicRegion.Europe => (Names.EuropeanFirstNames, Names.EuropeanLastNames),
_ => (Names.AllFirstNames, Names.AllLastNames)
};
}
public string Generate(Company company)
{
var firstName = _firstNames[_random.Next(_firstNames.Length)];
var lastName = _lastNames[_random.Next(_lastNames.Length)];
return _pattern.Generate(firstName, lastName, company.Domain);
}
/// <summary>
/// Generates username using index for deterministic selection across cipher iterations.
/// </summary>
public string GenerateByIndex(Company company, int index)
{
var firstName = _firstNames[index % _firstNames.Length];
var lastName = _lastNames[(index * 7) % _lastNames.Length]; // Prime multiplier for variety
return _pattern.Generate(firstName, lastName, company.Domain);
}
/// <summary>
/// Combines deterministic index with random offset for controlled variety.
/// </summary>
public string GenerateVaried(Company company, int index)
{
var offset = _random.Next(10);
var firstName = _firstNames[(index + offset) % _firstNames.Length];
var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length];
return _pattern.Generate(firstName, lastName, company.Domain);
}
public string GetFirstName(int index) => _firstNames[index % _firstNames.Length];
public string GetLastName(int index) => _lastNames[(index * 7) % _lastNames.Length];
}

View File

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

View File

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

View File

@@ -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
};
}
}

View File

@@ -1,12 +1,17 @@
using Bit.Core.Billing.Enums;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
namespace Bit.Seeder.Factories;
public class OrganizationSeeder
{
/// <summary>
/// Creates an enterprise organization without encryption keys.
/// Keys should be generated dynamically using RustSdkService.GenerateOrganizationKeys()
/// and assigned to PublicKey/PrivateKey after creation.
/// </summary>
public static Organization CreateEnterprise(string name, string domain, int seats)
{
return new Organization
@@ -39,52 +44,38 @@ public class OrganizationSeeder
UseAdminSponsoredFamilies = true,
SyncSeats = true,
Status = OrganizationStatusType.Created,
//GatewayCustomerId = "example-customer-id",
//GatewaySubscriptionId = "example-subscription-id",
MaxStorageGb = 10,
// Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs.
// TODO: These should be dynamically generated by the SDK.
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB",
PrivateKey = "2.6FggyKVyaKQsfohi5yqgbg==|UU2JeafOB41L5UscGmf4kq15JGDf3Bkf67KECiehTODzbWctVLTgyDk0Qco8/6CMN6nZGXjxR2A4r5ExhmwRNsNxd77G+MprkmiJz+7w33ROZ1ouQO5XjD3wbQ3ssqNiTKId6yAUPBvuAZRixVApauTuADc8QWGixqCQcqZzmU7YSBBIPf652/AEYr4Tk64YihoE39pHiK8MRbTLdRt3EF4LSMugPAPM24vCgUv3w1TD3Fj6sDg/6oi3flOV9SJZX4vCiUXbDNEuD/p2aQrEXVbaxweFOHjTe7F4iawjXw3nG3SO8rUBHcxbhDDVx5rjYactbW5QvHWiyla6uLb6o8WHBneg2EjTEwAHOZE/rBjcqmAJb2sVp1E0Kwq8ycGmL69vmqJPC1GqVTohAQvmEkaxIPpfq24Yb9ZPrADA7iEXBKuAQ1FphFUVgJBJGJbd60sOV1Rz1T+gUwS4wCNQ4l3LG1S22+wzUVlEku5DXFnT932tatqTyWEthqPqLCt6dL1+qa94XLpeHagXAx2VGe8n8IlcADtxqS+l8xQ4heT12WO9kC316vqvg1mnsI56faup9hb3eT9ZpKyxSBGYOphlTWfV1Y/v64f5PYvTo4aL0IYHyLY/9Qi72vFmOpPeHBYgD5t3j+H2CsiU1PkYsBggOmD7xW8FDuT6HWVvwhEJqeibVPK0Lhyj6tgvlSIAvFUaSMFPlmwFNmwfj/AHUhr9KuTfsBFTZ10yy9TZVgf+EofwnrxHBaWUgdD40aHoY1VjfG33iEuajb6buxG3pYFyPNhJNzeLZisUKIDRMQpUHrsE22EyrFFran3tZGdtcyIEK4Q1F0ULYzJ6T9iY25/ZgPy3pEAAMZCtqo3s+GjX295fWIHfMcnjMgNUHPjExjWBHa+ggK9iQXkFpBVyYB1ga/+0eiIhiek3PlgtvpDrqF7TsLK+ROiBw2GJ7uaO3EEXOj2GpNBuEJ5CdodhZkwzhwMcSatgDHkUuNVu0iVbF6/MxVdOxWXKO+jCYM6PZk/vAhLYqpPzu2T2Uyz4nkDs2Tiq61ez6FoCrzdHIiyIxVTzUQH8G9FgSmtaZ7GCbqlhnurYgcMciwPzxg0hpAQT+NZw1tVEii9vFSpJJbGJqNhORKfKh/Mu1P/9LOQq7Y0P2FIR3x/eUVEQ7CGv2jVtO5ryGSmKeq/P9Fr54wTPaNiqN2K+leACUznCdUWw8kZo/AsBcrOe4OkRX6k8LC3oeJXy06DEToatxEvPYemUauhxiXRw8nfNMqc4LyJq2bbT0zCgJHoqpozPdNg6AYWcoIobgAGu7ZQGq+oE1MT3GZxotMPe/NUJiAc5YE9Thb5Yf3gyno71pyqPTVl/6IQuh4SUz7rkgwF/aVHEnr4aUYNoc0PEzd2Me0jElsA3GAneq1I/wngutOWgTViTK4Nptr5uIzMVQs9H1rOMJNorP8b02t1NDu010rSsib9GaaJJq4r4iy46laQOxWoU0ex26arYnk+jw4833WSCTVBIprTgizZ+fKjoY0xwXvI2oOvGNEUCtGFvKFORTaQrlaXZIg1toa2BBVNicyONbwnI3KIu3MgGJ2SlCVXJn8oHFppVHFCdwgN1uDzGiKAhjvr0sZTUtXin2f2CszPTbbo=|fUhbVKrr8CSKE7TZJneXpDGraj5YhRrq9ESo206S+BY=",
// PublicKey and PrivateKey intentionally not set - caller must generate and assign
};
}
}
public static class OrgnaizationExtensions
public static class OrganizationExtensions
{
/// <summary>
/// Creates an OrganizationUser with fields populated based on status.
/// For Invited status, only user.Email is used. For other statuses, user.Id is used.
/// Creates an OrganizationUser with a dynamically provided encrypted org key.
/// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey().
/// </summary>
public static OrganizationUser CreateOrganizationUser(
this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status)
public static OrganizationUser CreateOrganizationUserWithKey(
this Organization organization,
User user,
OrganizationUserType type,
OrganizationUserStatusType status,
string? encryptedOrgKey)
{
var isInvited = status == OrganizationUserStatusType.Invited;
var isConfirmed = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked;
var shouldLinkUserId = status != OrganizationUserStatusType.Invited;
var shouldIncludeKey = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked;
return new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
UserId = isInvited ? null : user.Id,
Email = isInvited ? user.Email : null,
Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null,
UserId = shouldLinkUserId ? user.Id : null,
Email = shouldLinkUserId ? null : user.Email,
Key = shouldIncludeKey ? encryptedOrgKey : null,
Type = type,
Status = status
};
}
public static OrganizationUser CreateSdkOrganizationUser(this Organization organization, User user)
{
return new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
UserId = user.Id,
Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==",
Type = OrganizationUserType.Admin,
Status = OrganizationUserStatusType.Confirmed
};
}
}

View File

@@ -29,7 +29,7 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
public User CreateUser(string email, bool emailVerified = false, bool premium = false)
{
email = MangleEmail(email);
var keys = sdkService.GenerateUserKeys(email, "asdfasdfasdf");
var keys = sdkService.GenerateUserKeys(email, DefaultPassword);
var user = new User
{
@@ -43,7 +43,6 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
PrivateKey = keys.PrivateKey,
Premium = premium,
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 5_000,
};
@@ -53,22 +52,41 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
return user;
}
public static User CreateUserNoMangle(string email)
{
return new User
{
Id = Guid.NewGuid(),
Email = email,
MasterPassword = "AQAAAAIAAYagAAAAEBATmF66OHMpHuHKc1CsGZQ1ltHUHyhYK+7e4re3bVFi16SOpLpDfzdFswnvFQs2Rg==",
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
Key = "2.z/eLKFhd62qy9RzXu3UHgA==|fF6yNupiCIguFKSDTB3DoqcGR0Xu4j+9VlnMyT5F3PaWIcGhzQKIzxdB95nhslaCQv3c63M7LBnvzVo1J9SUN85RMbP/57bP1HvhhU1nvL8=|IQPtf8v7k83MFZEhazSYXSdu98BBU5rqtvC4keVWyHM=",
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Ww2chogqCpaAR7Uw448am4b7vDFXiM5kXjFlGfXBlrAdAqTTggEvTDlMNYqPlCo+mBM6iFmTTUY9rpZBvFskMnKvsvpJ47/fehAH2o2e3Ulv/5NFevaVCMCmpkBDtbMbO1A4a3btdRtCP8DsKWMefHauEpaoLxNTLWnOIZVfCMjsSgx2EvULHAZPTtbFwm4+UVKniM4ds4jvOsD85h4jn2aLs/jWJXFfxN8iVSqEqpC2TBvsPdyHb49xQoWWfF0Z6BiNqeNGKEU9Uos1pjL+kzhEzzSpH31PZT/ufJ/oo4+93wrUt57hb6f0jxiXhwd5yQ+9F6wVwpbfkq0IwhjOwIDAQAB",
PrivateKey = "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=",
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
/// <summary>
/// Default test password used for all seeded users.
/// </summary>
public const string DefaultPassword = "asdfasdfasdf";
/// <summary>
/// Creates a user with SDK-generated cryptographic keys (no email mangling).
/// The user can log in with email and password = "asdfasdfasdf".
/// </summary>
public static User CreateUserWithSdkKeys(
string email,
RustSdkService sdkService,
IPasswordHasher<User> passwordHasher)
{
var keys = sdkService.GenerateUserKeys(email, DefaultPassword);
var user = new User
{
Id = CoreHelpers.GenerateComb(),
Email = email,
EmailVerified = true,
MasterPassword = null,
SecurityStamp = Guid.NewGuid().ToString(),
Key = keys.EncryptedUserKey,
PublicKey = keys.PublicKey,
PrivateKey = keys.PrivateKey,
Premium = false,
ApiKey = Guid.NewGuid().ToString("N")[..30],
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 600_000,
KdfIterations = 5_000,
};
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
return user;
}
public Dictionary<string, string?> GetMangleMap(User user, UserData expectedUserData)

View File

@@ -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<Guid> 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<FieldViewDto>? 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<LoginUriViewDto>? 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;
}

View File

@@ -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<EncryptedFieldDto>? 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<EncryptedLoginUriDto>? 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; }
}

View File

@@ -1,18 +1,106 @@
# Bitwarden Database Seeder
A class library for generating and inserting test data.
A class library for generating and inserting properly encrypted test data into Bitwarden databases.
## Domain Taxonomy
### Cipher Encryption States
| Term | Description | Stored in DB? |
|------|-------------|---------------|
| **CipherView** | Plaintext/decrypted form. Human-readable data. | Never |
| **Cipher** | Encrypted form. All sensitive fields are EncStrings. | Yes |
The "View" suffix always denotes plaintext. No suffix means encrypted.
### EncString Format
Encrypted strings follow this format:
```
2.{iv}|{ciphertext}|{mac}
```
- **2** = Algorithm type (AES-256-CBC-HMAC-SHA256)
- **iv** = Initialization vector (base64)
- **ciphertext** = Encrypted data (base64)
- **mac** = Message authentication code (base64)
### Data Structure Differences
**SDK Structure (nested):**
```json
{ "name": "2.x...", "login": { "username": "2.y...", "password": "2.z..." } }
```
**Server Structure (flat, stored in Cipher.Data):**
```json
{ "Name": "2.x...", "Username": "2.y...", "Password": "2.z..." }
```
The seeder transforms SDK output to server format before database insertion.
### Key Hierarchy
```
Organization Key (or User Key)
├──▶ Encrypts Cipher.Key (optional per-cipher key)
└──▶ Encrypts cipher fields directly (if no per-cipher key)
```
For seeding, we encrypt directly with the organization key.
### Entity Relationships
```
Organization
├── Collections ──┬── CollectionCipher ──┐
│ │ │
└── Ciphers ──────┴──────────────────────┘
```
Ciphers belong to organizations and are assigned to collections via the `CollectionCipher` join table.
## Project Structure
The project is organized into these main components:
### Factories
Factories are helper classes for creating domain entities and populating them with realistic data. This assist in
decreasing the amount of boilerplate code needed to create test data in recipes.
Create individual domain entities with realistic encrypted data.
| Factory | Purpose |
|---------|---------|
| `CipherSeeder` | Creates encrypted Cipher entities via Rust SDK |
| `CollectionSeeder` | Creates collections with encrypted names |
| `OrganizationSeeder` | Creates organizations with keys |
| `UserSeeder` | Creates users with encrypted credentials |
### Recipes
Recipes are pre-defined data sets which can be run to generate and load data into the database. They often allow a allow
for a few arguments to customize the data slightly. Recipes should be kept simple and focused on a single task. Default
to creating more recipes rather than adding complexity to existing ones.
Bulk data operations using BulkCopy for performance.
| Recipe | Purpose |
|--------|---------|
| `CiphersRecipe` | Bulk create ciphers and assign to collections |
| `CollectionsRecipe` | Create collections with user permissions |
| `GroupsRecipe` | Create groups with collection access |
| `OrganizationWithUsersRecipe` | Full org setup with users |
### Models
DTOs for SDK interop and data transformation.
| Model | Purpose |
|-------|---------|
| `CipherViewDto` | Plaintext input to SDK encryption |
| `EncryptedCipherDto` | Parses SDK's encrypted output |
## Rust SDK Integration
The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption:
```
CipherViewDto → RustSdkService.EncryptCipher() → EncryptedCipherDto → Server Format
```
This ensures seeded data can be decrypted and displayed in the actual Bitwarden clients.

View File

@@ -0,0 +1,124 @@
using Bit.Core.Entities;
using Bit.Core.Vault.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Factories;
using LinqToDB.EntityFrameworkCore;
namespace Bit.Seeder.Recipes;
/// <summary>
/// Creates encrypted ciphers for seeding organization vaults.
/// </summary>
/// <remarks>
/// Currently supports:
/// <list type="bullet">
/// <item><description>Login ciphers</description></item>
/// </list>
/// TODO: Add support for Card, Identity, and SecureNote cipher types.
/// </remarks>
public class CiphersRecipe(DatabaseContext db, RustSdkService sdkService)
{
private readonly CipherSeeder _cipherSeeder = new(sdkService);
public List<Guid> AddLoginCiphersToOrganization(
Guid organizationId,
string orgKeyBase64,
List<Guid> collectionIds,
int? count = null,
bool useEnterpriseUrls = false)
{
// Delegate to the new system - Enterprise filter for enterprise URLs, Consumer for popular
var companyType = useEnterpriseUrls ? CompanyType.Enterprise : CompanyType.Consumer;
return AddLoginCiphersToOrganization(
organizationId,
orgKeyBase64,
collectionIds,
count,
companyType,
region: null,
UsernamePatternType.FLast,
PasswordStrength.Weak);
}
public List<Guid> AddLoginCiphersToOrganization(
Guid organizationId,
string orgKeyBase64,
List<Guid> collectionIds,
int? count,
CompanyType? companyType,
GeographicRegion? region,
UsernamePatternType usernamePattern = UsernamePatternType.FirstDotLast,
PasswordStrength passwordStrength = PasswordStrength.Strong)
{
var companies = Companies.Filter(companyType, region);
if (companies.Length == 0)
{
companies = Companies.All;
}
var passwords = Passwords.GetByStrength(passwordStrength);
var cipherCount = count ?? companies.Length;
var usernameGenerator = new UsernameGenerator(organizationId.GetHashCode(), usernamePattern, region);
var ciphers = Enumerable.Range(0, cipherCount)
.Select(i =>
{
var company = companies[i % companies.Length];
return _cipherSeeder.CreateOrganizationLoginCipher(
organizationId,
orgKeyBase64,
name: $"{company.Name} ({company.Category})",
username: usernameGenerator.GenerateVaried(company, i),
password: passwords[i % passwords.Length],
uri: $"https://{company.Domain}");
})
.ToList();
return SaveCiphersWithCollections(ciphers, collectionIds);
}
private List<Guid> SaveCiphersWithCollections(List<Cipher> ciphers, List<Guid> collectionIds)
{
if (ciphers.Count == 0)
{
return [];
}
db.BulkCopy(ciphers);
if (collectionIds.Count > 0)
{
var collectionCiphers = ciphers.SelectMany((cipher, i) =>
{
var primary = new CollectionCipher
{
CipherId = cipher.Id,
CollectionId = collectionIds[i % collectionIds.Count]
};
// Every 3rd cipher gets assigned to an additional collection
if (i % 3 == 0 && collectionIds.Count > 1)
{
return new[]
{
primary,
new CollectionCipher
{
CipherId = cipher.Id,
CollectionId = collectionIds[(i + 1) % collectionIds.Count]
}
};
}
return [primary];
}).ToList();
db.BulkCopy(collectionCiphers);
}
return ciphers.Select(c => c.Id).ToList();
}
}

View File

@@ -1,38 +1,56 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Factories;
using LinqToDB.EntityFrameworkCore;
namespace Bit.Seeder.Recipes;
public class CollectionsRecipe(DatabaseContext db)
/// <summary>
/// Creates collections for seeding organization vaults.
/// </summary>
public class CollectionsRecipe(DatabaseContext db, RustSdkService sdkService)
{
/// <summary>
/// Adds collections to an organization and creates relationships between users and collections.
/// </summary>
/// <param name="organizationId">The ID of the organization to add collections to.</param>
/// <param name="collections">The number of collections to add.</param>
/// <param name="organizationUserIds">The IDs of the users to create relationships with.</param>
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
public List<Guid> AddToOrganization(Guid organizationId, int collections, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
{
var collectionList = CreateAndSaveCollections(organizationId, collections);
private readonly CollectionSeeder _collectionSeeder = new(sdkService);
if (collectionList.Any())
/// <summary>
/// Creates collections from an organizational structure (e.g., Traditional departments, Spotify tribes).
/// Collection names are properly encrypted.
/// </summary>
public List<Guid> AddFromStructure(
Guid organizationId,
string orgKeyBase64,
OrgStructureModel model,
List<Guid> organizationUserIds,
int maxUsersWithRelationships = 1000)
{
var structure = OrgStructures.GetStructure(model);
var collections = structure.Units
.Select(unit => _collectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name))
.ToList();
db.BulkCopy(collections);
if (collections.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
{
CreateAndSaveCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships);
var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships);
db.BulkCopy(collectionUsers);
}
return collectionList.Select(c => c.Id).ToList();
return collections.Select(c => c.Id).ToList();
}
private List<Core.Entities.Collection> CreateAndSaveCollections(Guid organizationId, int count)
/// <summary>
/// Adds generic numbered collections (unencrypted names - use AddFromStructure for realistic data).
/// </summary>
public List<Guid> AddToOrganization(Guid organizationId, int collections, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
{
var collectionList = new List<Core.Entities.Collection>();
for (var i = 0; i < count; i++)
{
collectionList.Add(new Core.Entities.Collection
var collectionList = Enumerable.Range(0, collections)
.Select(i => new Core.Entities.Collection
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
@@ -40,83 +58,44 @@ public class CollectionsRecipe(DatabaseContext db)
Type = CollectionType.SharedCollection,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow
});
}
})
.ToList();
if (collectionList.Any())
{
db.BulkCopy(collectionList);
}
return collectionList;
}
private void CreateAndSaveCollectionUserRelationships(
List<Core.Entities.Collection> collections,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
{
return;
}
var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships);
if (collectionUsers.Any())
db.BulkCopy(collectionList);
if (collectionList.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
{
var collectionUsers = BuildCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships);
db.BulkCopy(collectionUsers);
}
return collectionList.Select(c => c.Id).ToList();
}
/// <summary>
/// Creates user-to-collection relationships with varied assignment patterns for realistic test data.
/// Each user gets 1-3 collections based on a rotating pattern.
/// Creates user-to-collection relationships with varied assignment patterns.
/// Each user gets 1-3 collections (cycling). First collection has Manage rights.
/// </summary>
private List<Core.Entities.CollectionUser> BuildCollectionUserRelationships(
private static List<Core.Entities.CollectionUser> BuildCollectionUserRelationships(
List<Core.Entities.Collection> collections,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
var collectionUsers = new List<Core.Entities.CollectionUser>();
for (var i = 0; i < maxRelationships; i++)
{
var orgUserId = organizationUserIds[i];
var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i);
collectionUsers.AddRange(userCollectionAssignments);
}
return collectionUsers;
}
/// <summary>
/// Assigns collections to a user with varying permissions.
/// Pattern: 1-3 collections per user (cycles: 1, 2, 3, 1, 2, 3...).
/// First collection has Manage rights, subsequent ones are ReadOnly.
/// </summary>
private List<Core.Entities.CollectionUser> CreateCollectionAssignmentsForUser(
List<Core.Entities.Collection> collections,
Guid organizationUserId,
int userIndex)
{
var assignments = new List<Core.Entities.CollectionUser>();
var userCollectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 collections
for (var j = 0; j < userCollectionCount; j++)
{
var collectionIndex = (userIndex + j) % collections.Count; // Distribute across available collections
assignments.Add(new Core.Entities.CollectionUser
return organizationUserIds
.Take(maxUsersWithRelationships)
.SelectMany((orgUserId, userIndex) =>
{
CollectionId = collections[collectionIndex].Id,
OrganizationUserId = organizationUserId,
ReadOnly = j > 0, // First assignment gets write access
HidePasswords = false,
Manage = j == 0 // First assignment gets manage permissions
});
}
return assignments;
var collectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3
return Enumerable.Range(0, collectionCount)
.Select(j => new Core.Entities.CollectionUser
{
CollectionId = collections[(userIndex + j) % collections.Count].Id,
OrganizationUserId = orgUserId,
ReadOnly = j > 0,
HidePasswords = false,
Manage = j == 0
});
})
.ToList();
}
}

View File

@@ -15,80 +15,30 @@ public class GroupsRecipe(DatabaseContext db)
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
public List<Guid> AddToOrganization(Guid organizationId, int groups, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
{
var groupList = CreateAndSaveGroups(organizationId, groups);
if (groupList.Any())
{
CreateAndSaveGroupUserRelationships(groupList, organizationUserIds, maxUsersWithRelationships);
}
return groupList.Select(g => g.Id).ToList();
}
private List<Core.AdminConsole.Entities.Group> CreateAndSaveGroups(Guid organizationId, int count)
{
var groupList = new List<Core.AdminConsole.Entities.Group>();
for (var i = 0; i < count; i++)
{
groupList.Add(new Core.AdminConsole.Entities.Group
var groupList = Enumerable.Range(0, groups)
.Select(i => new Core.AdminConsole.Entities.Group
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
Name = $"Group {i + 1}"
});
}
})
.ToList();
if (groupList.Any())
{
db.BulkCopy(groupList);
}
return groupList;
}
private void CreateAndSaveGroupUserRelationships(
List<Core.AdminConsole.Entities.Group> groups,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
{
return;
}
var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships);
if (groupUsers.Any())
db.BulkCopy(groupList);
if (groupList.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
{
var groupUsers = organizationUserIds
.Take(maxUsersWithRelationships)
.Select((orgUserId, i) => new Core.AdminConsole.Entities.GroupUser
{
GroupId = groupList[i % groupList.Count].Id,
OrganizationUserId = orgUserId
})
.ToList();
db.BulkCopy(groupUsers);
}
}
/// <summary>
/// Creates user-to-group relationships with distributed assignment patterns for realistic test data.
/// Each user is assigned to one group, distributed evenly across available groups.
/// </summary>
private List<Core.AdminConsole.Entities.GroupUser> BuildGroupUserRelationships(
List<Core.AdminConsole.Entities.Group> groups,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
var groupUsers = new List<Core.AdminConsole.Entities.GroupUser>();
for (var i = 0; i < maxRelationships; i++)
{
var orgUserId = organizationUserIds[i];
var groupIndex = i % groups.Count; // Round-robin distribution across groups
groupUsers.Add(new Core.AdminConsole.Entities.GroupUser
{
GroupId = groups[groupIndex].Id,
OrganizationUserId = orgUserId
});
}
return groupUsers;
return groupList.Select(g => g.Id).ToList();
}
}

View File

@@ -1,38 +1,138 @@
using Bit.Core.Entities;
using AutoMapper;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Factories;
using LinqToDB.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization;
using EfOrganizationUser = Bit.Infrastructure.EntityFramework.Models.OrganizationUser;
using EfUser = Bit.Infrastructure.EntityFramework.Models.User;
namespace Bit.Seeder.Recipes;
public class OrganizationWithUsersRecipe(DatabaseContext db)
public class OrganizationWithUsersRecipe(
DatabaseContext db,
IMapper mapper,
RustSdkService sdkService,
IPasswordHasher<User> passwordHasher)
{
public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed)
public static Guid SeedFromServices(
IServiceProvider services,
string name,
string domain,
int users,
int ciphers = 0,
OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed,
OrgStructureModel? structureModel = null)
{
var db = services.GetRequiredService<DatabaseContext>();
var mapper = services.GetRequiredService<IMapper>();
var sdkService = services.GetRequiredService<RustSdkService>();
var passwordHasher = services.GetRequiredService<IPasswordHasher<User>>();
var recipe = new OrganizationWithUsersRecipe(db, mapper, sdkService, passwordHasher);
return recipe.Seed(name, domain, users, ciphers, usersStatus, structureModel);
}
/// <summary>
/// Seeds an organization with users and optionally encrypted ciphers.
/// Users can log in with their email and password "asdfasdfasdf".
/// Organization and user keys are generated dynamically for each run.
/// </summary>
/// <param name="structureModel">Optional org structure for realistic collection names (e.g., Traditional departments, Spotify tribes).</param>
public Guid Seed(
string name,
string domain,
int users,
int ciphers = 0,
OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed,
OrgStructureModel? structureModel = null)
{
var seats = Math.Max(users + 1, 1000);
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}");
var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed);
var orgKeys = sdkService.GenerateOrganizationKeys();
var additionalUsers = new List<User>();
var additionalOrgUsers = new List<OrganizationUser>();
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
organization.PublicKey = orgKeys.PublicKey;
organization.PrivateKey = orgKeys.PrivateKey;
var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{domain}", sdkService, passwordHasher);
var ownerOrgKey = sdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
var memberUsers = new List<User>();
var memberOrgUsers = new List<OrganizationUser>();
for (var i = 0; i < users; i++)
{
var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}");
additionalUsers.Add(additionalUser);
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus));
var memberUser = UserSeeder.CreateUserWithSdkKeys($"user{i}@{domain}", sdkService, passwordHasher);
memberUsers.Add(memberUser);
var memberOrgKey = (usersStatus == OrganizationUserStatusType.Confirmed ||
usersStatus == OrganizationUserStatusType.Revoked)
? sdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key)
: null;
memberOrgUsers.Add(organization.CreateOrganizationUserWithKey(
memberUser, OrganizationUserType.User, usersStatus, memberOrgKey));
}
db.Add(organization);
db.Add(ownerUser);
db.Add(ownerOrgUser);
db.Add(mapper.Map<EfOrganization>(organization));
db.Add(mapper.Map<EfUser>(ownerUser));
db.Add(mapper.Map<EfOrganizationUser>(ownerOrgUser));
// BulkCopy for performance with large user counts
var efMemberUsers = memberUsers.Select(u => mapper.Map<EfUser>(u)).ToList();
var efMemberOrgUsers = memberOrgUsers.Select(ou => mapper.Map<EfOrganizationUser>(ou)).ToList();
db.BulkCopy(efMemberUsers);
db.BulkCopy(efMemberOrgUsers);
db.SaveChanges();
// Use LinqToDB's BulkCopy for significant better performance
db.BulkCopy(additionalUsers);
db.BulkCopy(additionalOrgUsers);
// Create collections - either from org structure or single default
var allOrgUserIds = memberOrgUsers
.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed)
.Select(ou => ou.Id)
.Prepend(ownerOrgUser.Id)
.ToList();
List<Guid> collectionIds;
if (structureModel.HasValue)
{
var collectionsRecipe = new CollectionsRecipe(db, sdkService);
collectionIds = collectionsRecipe.AddFromStructure(
organization.Id,
orgKeys.Key,
structureModel.Value,
allOrgUserIds);
}
else
{
var defaultCollection = new CollectionSeeder(sdkService)
.CreateCollection(organization.Id, orgKeys.Key, "Default Collection");
db.BulkCopy(new[] { defaultCollection });
var collectionUsers = allOrgUserIds
.Select((id, i) => CollectionSeeder.CreateCollectionUser(defaultCollection.Id, id, manage: i == 0))
.ToList();
db.BulkCopy(collectionUsers);
collectionIds = [defaultCollection.Id];
}
if (ciphers > 0)
{
var cipherRecipe = new CiphersRecipe(db, sdkService);
cipherRecipe.AddLoginCiphersToOrganization(
organization.Id,
orgKeys.Key,
collectionIds,
count: ciphers);
}
return organization.Id;
}