Add cipher seeding with Rust SDK encryption to enable cryptographically correct test data generation (#6896)

This commit is contained in:
Mick Letofsky
2026-01-30 13:53:24 +01:00
committed by GitHub
parent 93a28eed40
commit bfc645e1c1
40 changed files with 3245 additions and 48 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,6 +1,10 @@
using Bit.Infrastructure.EntityFramework.Repositories;
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Recipes;
using CommandDotNet;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.DbSeederUtility;
@@ -36,4 +40,25 @@ public class Program
var recipe = new OrganizationWithUsersRecipe(db);
recipe.Seed(name: name, domain: domain, users: users);
}
[Command("vault-organization", Description = "Seed an organization with users and encrypted vault data (ciphers, collections, groups)")]
public void VaultOrganization(VaultOrganizationArgs args)
{
args.Validate();
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var recipe = new OrganizationWithVaultRecipe(
scopedServices.GetRequiredService<DatabaseContext>(),
scopedServices.GetRequiredService<IMapper>(),
scopedServices.GetRequiredService<RustSdkService>(),
scopedServices.GetRequiredService<IPasswordHasher<User>>());
recipe.Seed(args.ToOptions());
}
}

View File

@@ -28,13 +28,23 @@ 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 vault-organization -n TestOrg -u 5 -d test.com -c 100
# Generate with Spotify-style collections (tribes, chapters, guilds)
DbSeeder.exe vault-organization -n TestOrg -u 10 -d test.com -c 50 -o Spotify
# Generate a small test organization with ciphers for manual testing
DbSeeder.exe vault-organization -n DevOrg -u 2 -d dev.local -c 10
```
## Dependencies
This utility depends on:
- The Seeder class library
- CommandDotNet for command-line parsing
- .NET 8.0 runtime

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;
@@ -13,8 +16,15 @@ public static class ServiceCollectionExtension
var globalSettings = GlobalSettingsFactory.GlobalSettings;
// Register services
services.AddLogging(builder => builder.AddConsole());
services.AddLogging(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Warning);
builder.AddFilter("Microsoft.EntityFrameworkCore.Model.Validation", LogLevel.Error);
});
services.AddSingleton(globalSettings);
services.AddSingleton<RustSdkService>();
services.AddSingleton<IPasswordHasher<User>, PasswordHasher<User>>();
// Add Data Protection services
services.AddDataProtection()

View File

@@ -0,0 +1,112 @@
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Options;
using CommandDotNet;
namespace Bit.DbSeederUtility;
/// <summary>
/// CLI argument model for the vault-organization command.
/// Maps to <see cref="OrganizationVaultOptions"/> for the Seeder library.
/// </summary>
public class VaultOrganizationArgs : IArgumentModel
{
[Option('n', "name", Description = "Name of organization")]
public string Name { get; set; } = null!;
[Option('u', "users", Description = "Number of users to generate (minimum 1)")]
public int Users { get; set; }
[Option('d', "domain", Description = "Email domain for users")]
public string Domain { get; set; } = null!;
[Option('c', "ciphers", Description = "Number of login ciphers to create (minimum 1)")]
public int Ciphers { get; set; }
[Option('g', "groups", Description = "Number of groups to create (minimum 1)")]
public int Groups { get; set; }
[Option('m', "mix-user-statuses", Description = "Use realistic status mix (85% confirmed, 5% each invited/accepted/revoked). Requires >= 10 users.")]
public bool MixStatuses { get; set; } = true;
[Option('o', "org-structure", Description = "Org structure for collections: Traditional, Spotify, or Modern")]
public string? Structure { get; set; }
[Option('r', "region", Description = "Geographic region for names: NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, or Global")]
public string? Region { get; set; }
public void Validate()
{
if (Users < 1)
{
throw new ArgumentException("Users must be at least 1. Use another command for orgs without users.");
}
if (Ciphers < 1)
{
throw new ArgumentException("Ciphers must be at least 1. Use another command for orgs without vault data.");
}
if (Groups < 1)
{
throw new ArgumentException("Groups must be at least 1. Use another command for orgs without groups.");
}
if (!string.IsNullOrEmpty(Structure))
{
ParseOrgStructure(Structure);
}
if (!string.IsNullOrEmpty(Region))
{
ParseGeographicRegion(Region);
}
}
public OrganizationVaultOptions ToOptions() => new()
{
Name = Name,
Domain = Domain,
Users = Users,
Ciphers = Ciphers,
Groups = Groups,
RealisticStatusMix = MixStatuses,
StructureModel = ParseOrgStructure(Structure),
Region = ParseGeographicRegion(Region)
};
private static OrgStructureModel? ParseOrgStructure(string? structure)
{
if (string.IsNullOrEmpty(structure))
{
return null;
}
return structure.ToLowerInvariant() switch
{
"traditional" => OrgStructureModel.Traditional,
"spotify" => OrgStructureModel.Spotify,
"modern" => OrgStructureModel.Modern,
_ => throw new ArgumentException($"Unknown structure '{structure}'. Use: Traditional, Spotify, or Modern")
};
}
private static GeographicRegion? ParseGeographicRegion(string? region)
{
if (string.IsNullOrEmpty(region))
{
return null;
}
return region.ToLowerInvariant() switch
{
"northamerica" => GeographicRegion.NorthAmerica,
"europe" => GeographicRegion.Europe,
"asiapacific" => GeographicRegion.AsiaPacific,
"latinamerica" => GeographicRegion.LatinAmerica,
"middleeast" => GeographicRegion.MiddleEast,
"africa" => GeographicRegion.Africa,
"global" => GeographicRegion.Global,
_ => throw new ArgumentException($"Unknown region '{region}'. Use: NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, or Global")
};
}
}

View File

@@ -47,7 +47,7 @@ public class RustSdkService
{
var resultPtr = NativeMethods.generate_user_keys(emailPtr, passwordPtr);
var result = TakeAndDestroyRustString(resultPtr);
var result = ParseResponse(resultPtr);
return JsonSerializer.Deserialize<UserKeys>(result, CaseInsensitiveOptions)!;
}
@@ -57,7 +57,7 @@ public class RustSdkService
{
var resultPtr = NativeMethods.generate_organization_keys();
var result = TakeAndDestroyRustString(resultPtr);
var result = ParseResponse(resultPtr);
return JsonSerializer.Deserialize<OrganizationKeys>(result, CaseInsensitiveOptions)!;
}
@@ -72,19 +72,70 @@ public class RustSdkService
{
var resultPtr = NativeMethods.generate_user_organization_key(userKeyPtr, orgKeyPtr);
var result = TakeAndDestroyRustString(resultPtr);
var result = ParseResponse(resultPtr);
return result;
}
}
public unsafe string EncryptCipher(string cipherViewJson, string symmetricKeyBase64)
{
var cipherViewBytes = StringToRustString(cipherViewJson);
var keyBytes = StringToRustString(symmetricKeyBase64);
fixed (byte* cipherViewPtr = cipherViewBytes)
fixed (byte* keyPtr = keyBytes)
{
var resultPtr = NativeMethods.encrypt_cipher(cipherViewPtr, keyPtr);
return ParseResponse(resultPtr);
}
}
public unsafe string DecryptCipher(string cipherJson, string symmetricKeyBase64)
{
var cipherBytes = StringToRustString(cipherJson);
var keyBytes = StringToRustString(symmetricKeyBase64);
fixed (byte* cipherPtr = cipherBytes)
fixed (byte* keyPtr = keyBytes)
{
var resultPtr = NativeMethods.decrypt_cipher(cipherPtr, keyPtr);
return ParseResponse(resultPtr);
}
}
/// <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 ParseResponse(resultPtr);
}
}
private static byte[] StringToRustString(string str)
{
return Encoding.UTF8.GetBytes(str + '\0');
}
private static unsafe string TakeAndDestroyRustString(byte* ptr)
/// <summary>
/// Parses a response from Rust FFI, checks for errors, and frees the native string.
/// </summary>
/// <param name="ptr">Pointer to the C string returned from Rust</param>
/// <returns>The parsed response string</returns>
/// <exception cref="RustSdkException">Thrown if the pointer is null, conversion fails, or the response contains an error</exception>
private static unsafe string ParseResponse(byte* ptr)
{
if (ptr == null)
{
@@ -99,6 +150,28 @@ public class RustSdkService
throw new RustSdkException("Failed to convert native result to string");
}
// Check if response is an error from Rust
// Rust error responses follow the format: {"error": "message"}
if (result.TrimStart().StartsWith('{') && result.Contains("\"error\"", StringComparison.Ordinal))
{
try
{
using var doc = JsonDocument.Parse(result);
if (doc.RootElement.TryGetProperty("error", out var errorElement))
{
var errorMessage = errorElement.GetString();
if (!string.IsNullOrEmpty(errorMessage))
{
throw new RustSdkException($"Rust SDK error: {errorMessage}");
}
}
}
catch (JsonException)
{
// If we can't parse it as an error, it's likely valid data that happens to contain "error"
}
}
return result;
}
}

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

@@ -1,6 +1,7 @@
fn main() {
csbindgen::Builder::default()
.input_extern_file("src/lib.rs")
.input_extern_file("src/cipher.rs")
.csharp_dll_name("libsdk")
.csharp_namespace("Bit.RustSDK")
.csharp_class_accessibility("public")

View File

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

@@ -1,4 +1,7 @@
#![allow(clippy::missing_safety_doc)]
mod cipher;
use std::{
ffi::{c_char, CStr, CString},
num::NonZeroU32,
@@ -20,9 +23,6 @@ pub unsafe extern "C" fn generate_user_keys(
let email = CStr::from_ptr(email).to_str().unwrap();
let password = CStr::from_ptr(password).to_str().unwrap();
println!("Generating keys for {email}");
println!("Password: {password}");
let kdf = Kdf::PBKDF2 {
iterations: NonZeroU32::new(5_000).unwrap(),
};
@@ -32,8 +32,6 @@ pub unsafe extern "C" fn generate_user_keys(
let master_password_hash =
master_key.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization);
println!("Master password hash: {}", master_password_hash);
let (user_key, encrypted_user_key) = master_key.make_user_key().unwrap();
let keypair = keypair(&user_key.0);

215
util/Seeder/CLAUDE.md Normal file
View File

@@ -0,0 +1,215 @@
# Seeder - Claude Code Context
## Ubiquitous Language
The Seeder follows six core patterns:
1. **Factories** - Create ONE entity with encryption. Named `{Entity}Seeder` with `Create{Type}{Entity}()` methods. Do not interact with database.
2. **Recipes** - Orchestrate MANY entities. Named `{DomainConcept}Recipe`. **MUST have `Seed()` method** as primary interface, not `AddToOrganization()` or similar. Use parameters for variations, not separate methods. Compose Factories internally.
3. **Models** - DTOs bridging SDK ↔ Server format. Named `{Entity}ViewDto` (plaintext), `Encrypted{Entity}Dto` (SDK format). Pure data, no logic.
4. **Scenes** - Complete test scenarios with ID mangling. Implement `IScene<TReques, TResult>`. Async, returns `SceneResult<TResult>` with MangleMap and result property populated with `TResult`. Named `{Scenario}Scene`.
5. **Queries** - Read-only data retrieval. Implement `IQuery<TRequest, TResult>`. Synchronous, no DB modifications. Named `{DataToRetrieve}Query`.
6. **Data** - Static, filterable test data collections (Companies, Passwords, Names, OrgStructures). Deterministic, composable. Enums provide public API.
## The Recipe Contract
Recipes follow strict rules (like a cooking recipe that you follow completely):
1. A Recipe SHALL have exactly one public method named `Seed()`
2. A Recipe MUST produce one cohesive result (like baking one complete cake)
3. A Recipe MAY have overloaded `Seed()` methods with different parameters
4. A Recipe SHALL use private helper methods for internal steps
5. A Recipe SHALL use BulkCopy for performance when creating multiple entities
6. A Recipe SHALL compose Factories for individual entity creation
7. A Recipe SHALL NOT expose implementation details as public methods
**Current violations** (to be refactored):
- `CiphersRecipe` - Uses `AddLoginCiphersToOrganization()` instead of `Seed()`
- `CollectionsRecipe` - Uses `AddFromStructure()` and `AddToOrganization()` instead of `Seed()`
- `GroupsRecipe` - Uses `AddToOrganization()` instead of `Seed()`
- `OrganizationDomainRecipe` - Uses `AddVerifiedDomainToOrganization()` instead of `Seed()`
## Pattern Decision Tree
```
Need to create test data?
├─ ONE entity with encryption? → Factory
├─ MANY entities as cohesive operation? → Recipe
├─ Complete test scenario with ID mangling to be used by the Seeder API? → Scene
├─ READ existing seeded data? → Query
└─ Data transformation SDK ↔ Server? → Model
```
## When to Use the Seeder
✅ Use for:
- Local development database setup
- Integration test data creation
- Performance testing with realistic encrypted data
❌ Do NOT use for:
- Production data
- Copying real user vaults (use backup/restore instead)
## Zero-Knowledge Architecture
**Critical Principle:** Unencrypted vault data never leaves the client. The server never sees plaintext.
### Why Seeder Uses the Rust SDK
The Seeder must behave exactly like any other Bitwarden client. Since the server:
- Never receives plaintext
- Cannot perform encryption (doesn't have keys)
- Only stores/retrieves encrypted blobs
...the Seeder cannot simply write plaintext to the database. It must:
1. Generate encryption keys (like a client does during account setup)
2. Encrypt vault data client-side (using the same SDK the real clients use)
3. Store only the encrypted result
This is why we use the Rust SDK via FFI - it's the same cryptographic implementation used by the official clients.
## Cipher Encryption Architecture
### The Two-State Pattern
Bitwarden uses a clean separation between encrypted and decrypted data:
| State | SDK Type | Description | Stored in DB? |
| --------- | ------------ | ------------------------- | ------------- |
| Plaintext | `CipherView` | Decrypted, human-readable | Never |
| Encrypted | `Cipher` | EncString values | Yes |
**Encryption flow:**
```
CipherView (plaintext) → encrypt_composite() → Cipher (encrypted)
```
**Decryption flow:**
```
Cipher (encrypted) → decrypt() → CipherView (plaintext)
```
### SDK vs Server Format Difference
**Critical:** The SDK and server use different JSON structures.
**SDK Cipher (nested):**
```json
{
"name": "2.abc...",
"login": {
"username": "2.def...",
"password": "2.ghi..."
}
}
```
**Server Cipher.Data (flat CipherLoginData):**
```json
{
"Name": "2.abc...",
"Username": "2.def...",
"Password": "2.ghi..."
}
```
### Data Flow in Seeder
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ CipherViewDto │────▶│ Rust SDK │────▶│ EncryptedCipherDto │
│ (plaintext) │ │ encrypt_cipher │ │ (SDK Cipher) │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
┌───────────────────────┐
│ TransformToServer │
│ (flatten nested → │
│ flat structure) │
└───────────────────────┘
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Server Cipher │◀────│ CipherLoginData │◀────│ Flattened JSON │
│ Entity │ │ (serialized) │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
```
### Key Hierarchy
Bitwarden uses a two-level encryption hierarchy:
1. **User/Organization Key** - Encrypts the cipher's individual key
2. **Cipher Key** (optional) - Encrypts the actual cipher data
For seeding, we use the organization's symmetric key directly (no per-cipher key).
## Rust SDK FFI
### Error Handling
SDK functions return JSON with an `"error"` field on failure:
```json
{ "error": "Failed to parse CipherView JSON" }
```
Always check for `"error"` in the response before parsing.
## Testing
Integration tests in `test/SeederApi.IntegrationTest` verify:
1. **Roundtrip encryption** - Encrypt then decrypt preserves plaintext
2. **Server format compatibility** - Output matches CipherLoginData structure
3. **Field encryption** - Custom fields are properly encrypted
4. **Security** - Plaintext never appears in encrypted output
## Common Patterns
### Creating a Cipher
```csharp
var sdk = new RustSdkService();
var seeder = new CipherSeeder(sdk);
var cipher = seeder.CreateOrganizationLoginCipher(
organizationId,
orgKey, // Base64-encoded symmetric key
name: "My Login",
username: "user@example.com",
password: "secret123");
```
### Bulk Cipher Creation
```csharp
var recipe = new CiphersRecipe(dbContext, sdkService);
var cipherIds = recipe.AddLoginCiphersToOrganization(
organizationId,
orgKey,
collectionIds,
count: 100);
```
## Security Reminders
- Generated test passwords are intentionally weak (`asdfasdfasdf`)
- Never commit database dumps containing seeded data to version control
- Seeded keys are for testing only - regenerate for each test run

View File

@@ -0,0 +1,78 @@
using Bit.Seeder.Data.Enums;
using Bogus;
using Bogus.DataSets;
namespace Bit.Seeder.Data;
/// <summary>
/// Provides locale-aware name generation using the Bogus library.
/// Maps GeographicRegion to appropriate Bogus locales for culturally-appropriate names.
/// </summary>
internal sealed class BogusNameProvider
{
private readonly Faker _faker;
public BogusNameProvider(GeographicRegion region, int? seed = null)
{
var locale = MapRegionToLocale(region, seed);
_faker = seed.HasValue
? new Faker(locale) { Random = new Randomizer(seed.Value) }
: new Faker(locale);
}
public string FirstName() => _faker.Name.FirstName();
public string FirstName(Name.Gender gender) => _faker.Name.FirstName(gender);
public string LastName() => _faker.Name.LastName();
private static string MapRegionToLocale(GeographicRegion region, int? seed) => region switch
{
GeographicRegion.NorthAmerica => "en_US",
GeographicRegion.Europe => GetRandomEuropeanLocale(seed),
GeographicRegion.AsiaPacific => GetRandomAsianLocale(seed),
GeographicRegion.LatinAmerica => GetRandomLatinAmericanLocale(seed),
GeographicRegion.MiddleEast => GetRandomMiddleEastLocale(seed),
GeographicRegion.Africa => GetRandomAfricanLocale(seed),
GeographicRegion.Global => "en",
_ => "en"
};
private static string GetRandomEuropeanLocale(int? seed)
{
var locales = new[] { "en_GB", "de", "fr", "es", "it", "nl", "pl", "pt_PT", "sv" };
return PickLocale(locales, seed);
}
private static string GetRandomAsianLocale(int? seed)
{
var locales = new[] { "ja", "ko", "zh_CN", "zh_TW", "vi" };
return PickLocale(locales, seed);
}
private static string GetRandomLatinAmericanLocale(int? seed)
{
var locales = new[] { "es_MX", "pt_BR", "es" };
return PickLocale(locales, seed);
}
private static string GetRandomMiddleEastLocale(int? seed)
{
// Bogus has limited Middle East support; use available Arabic/Turkish locales
var locales = new[] { "ar", "tr", "fa" };
return PickLocale(locales, seed);
}
private static string GetRandomAfricanLocale(int? seed)
{
// Bogus has limited African support; use South African English and French (West Africa)
var locales = new[] { "en_ZA", "fr" };
return PickLocale(locales, seed);
}
private static string PickLocale(string[] locales, int? seed)
{
var random = seed.HasValue ? new Random(seed.Value) : Random.Shared;
return locales[random.Next(locales.Length)];
}
}

View File

@@ -0,0 +1,67 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
/// <summary>
/// Generates deterministic usernames for companies using configurable patterns.
/// Uses Bogus library for locale-aware name generation while maintaining determinism
/// through pre-generated arrays indexed by a seed.
/// </summary>
internal sealed class CipherUsernameGenerator
{
private const int _namePoolSize = 1500;
private readonly Random _random;
private readonly UsernamePattern _pattern;
private readonly string[] _firstNames;
private readonly string[] _lastNames;
public CipherUsernameGenerator(
int seed,
UsernamePatternType patternType = UsernamePatternType.FirstDotLast,
GeographicRegion? region = null)
{
_random = new Random(seed);
_pattern = UsernamePatterns.GetPattern(patternType);
// Pre-generate arrays from Bogus for deterministic index-based access
var provider = new BogusNameProvider(region ?? GeographicRegion.Global, seed);
_firstNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.FirstName()).ToArray();
_lastNames = Enumerable.Range(0, _namePoolSize).Select(_ => provider.LastName()).ToArray();
}
public string Generate(Company company)
{
var firstName = _firstNames[_random.Next(_firstNames.Length)];
var lastName = _lastNames[_random.Next(_lastNames.Length)];
return _pattern.Generate(firstName, lastName, company.Domain);
}
/// <summary>
/// Generates username using index for deterministic selection across cipher iterations.
/// </summary>
public string GenerateByIndex(Company company, int index)
{
var firstName = _firstNames[index % _firstNames.Length];
var lastName = _lastNames[(index * 7) % _lastNames.Length]; // Prime multiplier for variety
return _pattern.Generate(firstName, lastName, company.Domain);
}
/// <summary>
/// Combines deterministic index with random offset for controlled variety.
/// </summary>
public string GenerateVaried(Company company, int index)
{
var offset = _random.Next(10);
var firstName = _firstNames[(index + offset) % _firstNames.Length];
var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length];
return _pattern.Generate(firstName, lastName, company.Domain);
}
public string GetFirstName(int index) => _firstNames[index % _firstNames.Length];
public string GetLastName(int index) => _lastNames[(index * 7) % _lastNames.Length];
}

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,25 @@
namespace Bit.Seeder.Data.Enums;
/// <summary>
/// Password strength levels aligned with zxcvbn scoring (0-4).
/// </summary>
public enum PasswordStrength
{
/// <summary>Score 0: Too guessable (&lt; 10³ guesses)</summary>
VeryWeak = 0,
/// <summary>Score 1: Very guessable (&lt; 10⁶ guesses)</summary>
Weak = 1,
/// <summary>Score 2: Somewhat guessable (&lt; 10⁸ guesses)</summary>
Fair = 2,
/// <summary>Score 3: Safely unguessable (&lt; 10¹⁰ guesses)</summary>
Strong = 3,
/// <summary>Score 4: Very unguessable (≥ 10¹⁰ guesses)</summary>
VeryStrong = 4,
/// <summary>Realistic distribution based on breach data statistics.</summary>
Realistic = 99
}

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
}

View File

@@ -0,0 +1,31 @@
using Bogus;
namespace Bit.Seeder.Data;
/// <summary>
/// Generates deterministic folder names using Bogus Commerce.Department().
/// Pre-generates a pool of business-themed names for consistent index-based access.
/// </summary>
internal sealed class FolderNameGenerator
{
private const int _namePoolSize = 50;
private readonly string[] _folderNames;
public FolderNameGenerator(int seed)
{
var faker = new Faker { Random = new Randomizer(seed) };
// Pre-generate business department names for determinism
// Examples: "Automotive", "Home & Garden", "Sports", "Electronics", "Beauty"
_folderNames = Enumerable.Range(0, _namePoolSize)
.Select(_ => faker.Commerce.Department())
.Distinct()
.ToArray();
}
/// <summary>
/// Gets a folder name by index, wrapping around if index exceeds pool size.
/// </summary>
public string GetFolderName(int index) => _folderNames[index % _folderNames.Length];
}

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,148 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Data;
/// <summary>
/// Password collections by zxcvbn strength level (0-4) for realistic test data.
/// </summary>
internal static class Passwords
{
/// <summary>
/// Score 0 - Too guessable: keyboard walks, simple sequences, single words.
/// </summary>
public static readonly string[] VeryWeak =
[
"password", "123456", "qwerty", "abc123", "letmein",
"admin", "welcome", "monkey", "dragon", "master",
"111111", "baseball", "iloveyou", "trustno1", "sunshine",
"princess", "football", "shadow", "superman", "michael",
"password1", "123456789", "12345678", "1234567", "12345",
"qwerty123", "1q2w3e4r", "123123", "000000", "654321"
];
/// <summary>
/// Score 1 - Very guessable: common patterns with minor complexity.
/// </summary>
public static readonly string[] Weak =
[
"Password1", "Qwerty123", "Welcome1", "Admin123", "Letmein1",
"Dragon123", "Master123", "Shadow123", "Michael1", "Jennifer1",
"abc123!", "pass123!", "test1234", "hello123", "love1234",
"money123", "secret1", "access1", "login123", "super123",
"changeme", "temp1234", "guest123", "user1234", "pass1234",
"default1", "sample12", "demo1234", "trial123", "secure1"
];
/// <summary>
/// Score 2 - Somewhat guessable: meets basic complexity but predictable patterns.
/// </summary>
public static readonly string[] Fair =
[
"Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!",
"Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login2024!",
"Michael123!", "Jennifer2024@", "Robert456#", "Sarah789!", "David2024!",
"Password123!", "Security2024@", "Admin2024!", "User2024#", "Guest123!",
"Football123!", "Baseball2024@", "Soccer456#", "Hockey789!", "Tennis2024!",
"NewYork2024!", "Chicago123@", "Boston2024#", "Seattle789!", "Denver2024$"
];
/// <summary>
/// Score 3 - Safely unguessable: good entropy, mixed character types.
/// </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$", "Xk5#tW8@jR2$mN9!", "Cv3@yB6#pF1$qL4!",
"correct-horse-battery", "purple-monkey-dishwasher", "quantum-bicycle-elephant",
"velvet-thunder-crystal", "neon-wizard-cosmic", "amber-phoenix-digital",
"Brave.Tiger.Runs.42", "Blue.Ocean.Deep.17", "Swift.Eagle.Soars.93",
"maple#stream#winter", "ember@cloud@silent", "frost$dawn$valley"
];
/// <summary>
/// Score 4 - Very unguessable: high entropy, long passphrases, random strings.
/// </summary>
public static readonly string[] VeryStrong =
[
"Kx9#mL4$pQ7@wR2!vN5hT8", "Yz3@hT8#bF1$cS6!nM9wK4", "Wv5!rK2@jG9#tX4$mL7nB3",
"Qn7$sB3@yH6#pC1!zF8kW2", "Tm2@xD5#kW9$vL4!rJ7gN1", "Pf4!nC8@bR3#yL6$hS9mV2",
"correct-horse-battery-staple", "purple-monkey-dishwasher-lamp", "quantum-bicycle-elephant-storm",
"velvet-thunder-crystal-forge", "neon-wizard-cosmic-river", "amber-phoenix-digital-maze",
"silver-falcon-ancient-code", "lunar-garden-frozen-spark", "echo-prism-wandering-light",
"Brave.Tiger.Runs.Fast.42!", "Blue.Ocean.Deep.Wave.17@", "Swift.Eagle.Soars.High.93#",
"maple#stream#winter#glow#dawn", "ember@cloud@silent@peak@mist", "frost$dawn$valley$mist$glow",
"7hK$mN2@pL9#xR4!wQ8vB5&jF", "3yT@nC7#bS1$kW6!mH9rL2%xD", "9pF!vK4@jR8#tN3$yB7mL1&wS"
];
/// <summary>All passwords combined for mixed/random selection.</summary>
public static readonly string[] All = [.. VeryWeak, .. Weak, .. Fair, .. Strong, .. VeryStrong];
/// <summary>
/// Realistic distribution based on breach data and security research.
/// Sources: NordPass annual reports, Have I Been Pwned analysis, academic studies.
/// Distribution: 25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong
/// </summary>
private static readonly (PasswordStrength Strength, int CumulativePercent)[] RealisticDistribution =
[
(PasswordStrength.VeryWeak, 25), // 25% - most common breached passwords
(PasswordStrength.Weak, 55), // 30% - simple patterns with numbers
(PasswordStrength.Fair, 80), // 25% - meets basic requirements
(PasswordStrength.Strong, 95), // 15% - good passwords
(PasswordStrength.VeryStrong, 100) // 5% - password manager users
];
public static string[] GetByStrength(PasswordStrength strength) => strength switch
{
PasswordStrength.VeryWeak => VeryWeak,
PasswordStrength.Weak => Weak,
PasswordStrength.Fair => Fair,
PasswordStrength.Strong => Strong,
PasswordStrength.VeryStrong => VeryStrong,
PasswordStrength.Realistic => All, // For direct array access, use All
_ => Strong
};
/// <summary>
/// Gets a password with realistic strength distribution.
/// Uses deterministic selection based on index for reproducible test data.
/// </summary>
public static string GetRealisticPassword(int index)
{
var strength = GetRealisticStrength(index);
var passwords = GetByStrength(strength);
return passwords[index % passwords.Length];
}
/// <summary>
/// Gets a password strength following realistic distribution.
/// Deterministic based on index for reproducible results.
/// </summary>
public static PasswordStrength GetRealisticStrength(int index)
{
// Use modulo 100 for percentage-based bucket selection
var bucket = index % 100;
foreach (var (strength, cumulativePercent) in RealisticDistribution)
{
if (bucket < cumulativePercent)
{
return strength;
}
}
return PasswordStrength.Strong; // Fallback
}
public static string GetPassword(PasswordStrength strength, int index)
{
if (strength == PasswordStrength.Realistic)
{
return GetRealisticPassword(index);
}
var 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,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,153 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Models.Data;
using Bit.RustSDK;
using Bit.Seeder.Models;
namespace Bit.Seeder.Factories;
/// <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);
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

@@ -0,0 +1,28 @@
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Bit.RustSDK;
namespace Bit.Seeder.Factories;
/// <summary>
/// Factory for creating Folder entities with encrypted names.
/// Folders are per-user constructs encrypted with the user's symmetric key.
/// </summary>
internal sealed class FolderSeeder(RustSdkService sdkService)
{
/// <summary>
/// Creates a folder with an encrypted name.
/// </summary>
/// <param name="userId">The user who owns this folder.</param>
/// <param name="userKeyBase64">The user's symmetric key (not org key).</param>
/// <param name="name">The plaintext folder name to encrypt.</param>
public Folder CreateFolder(Guid userId, string userKeyBase64, string name)
{
return new Folder
{
Id = CoreHelpers.GenerateComb(),
UserId = userId,
Name = sdkService.EncryptString(name, userKeyBase64)
};
}
}

View File

@@ -0,0 +1,41 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Utilities;
namespace Bit.Seeder.Factories;
/// <summary>
/// Creates groups and group-user relationships for seeding.
/// </summary>
public static class GroupSeeder
{
/// <summary>
/// Creates a group entity for an organization.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="name">The group name.</param>
/// <returns>A new Group entity (not persisted).</returns>
public static Group CreateGroup(Guid organizationId, string name)
{
return new Group
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organizationId,
Name = name
};
}
/// <summary>
/// Creates a group-user relationship entity.
/// </summary>
/// <param name="groupId">The group ID.</param>
/// <param name="organizationUserId">The organization user ID.</param>
/// <returns>A new GroupUser entity (not persisted).</returns>
public static GroupUser CreateGroupUser(Guid groupId, Guid organizationUserId)
{
return new GroupUser
{
GroupId = groupId,
OrganizationUserId = organizationUserId
};
}
}

View File

@@ -0,0 +1,32 @@
using Bit.Infrastructure.EntityFramework.Models;
namespace Bit.Seeder.Factories;
/// <summary>
/// Creates organization domain entities for seeding.
/// </summary>
public static class OrganizationDomainSeeder
{
/// <summary>
/// Creates a verified organization domain entity.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="domainName">The domain name (e.g., "example.com").</param>
/// <returns>A new verified OrganizationDomain entity (not persisted).</returns>
public static OrganizationDomain CreateVerifiedDomain(Guid organizationId, string domainName)
{
var domain = new OrganizationDomain
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
DomainName = domainName,
Txt = Guid.NewGuid().ToString("N"),
CreationDate = DateTime.UtcNow,
};
domain.SetVerifiedDate();
domain.SetLastCheckedDate();
return domain;
}
}

View File

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

View File

@@ -21,7 +21,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
{
@@ -35,7 +35,6 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
PrivateKey = keys.PrivateKey,
Premium = premium,
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 5_000,
};
@@ -45,6 +44,15 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
return user;
}
/// <summary>
/// Default test password used for all seeded users.
/// </summary>
public const string DefaultPassword = "asdfasdfasdf";
/// <summary>
/// Creates a user with hardcoded keys (no email mangling, no SDK calls).
/// Used by OrganizationWithUsersRecipe for fast user creation without encryption needs.
/// </summary>
public static User CreateUserNoMangle(string email)
{
return new User
@@ -57,12 +65,55 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Ww2chogqCpaAR7Uw448am4b7vDFXiM5kXjFlGfXBlrAdAqTTggEvTDlMNYqPlCo+mBM6iFmTTUY9rpZBvFskMnKvsvpJ47/fehAH2o2e3Ulv/5NFevaVCMCmpkBDtbMbO1A4a3btdRtCP8DsKWMefHauEpaoLxNTLWnOIZVfCMjsSgx2EvULHAZPTtbFwm4+UVKniM4ds4jvOsD85h4jn2aLs/jWJXFfxN8iVSqEqpC2TBvsPdyHb49xQoWWfF0Z6BiNqeNGKEU9Uos1pjL+kzhEzzSpH31PZT/ufJ/oo4+93wrUt57hb6f0jxiXhwd5yQ+9F6wVwpbfkq0IwhjOwIDAQAB",
PrivateKey = "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=",
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 600_000,
};
}
/// <summary>
/// Creates a user with SDK-generated cryptographic keys (no email mangling).
/// The user can log in with email and password = "asdfasdfasdf".
/// </summary>
public static User CreateUserWithSdkKeys(
string email,
RustSdkService sdkService,
IPasswordHasher<User> passwordHasher)
{
var keys = sdkService.GenerateUserKeys(email, DefaultPassword);
return CreateUserFromKeys(email, keys, passwordHasher);
}
/// <summary>
/// Creates a user from pre-generated keys (no email mangling).
/// Use this when you need to retain the user's symmetric key for subsequent operations
/// (e.g., encrypting folders with the user's key).
/// </summary>
public static User CreateUserFromKeys(
string email,
UserKeys keys,
IPasswordHasher<User> passwordHasher)
{
var user = new User
{
Id = CoreHelpers.GenerateComb(),
Email = email,
EmailVerified = true,
MasterPassword = null,
SecurityStamp = Guid.NewGuid().ToString(),
Key = keys.EncryptedUserKey,
PublicKey = keys.PublicKey,
PrivateKey = keys.PrivateKey,
Premium = false,
ApiKey = Guid.NewGuid().ToString("N")[..30],
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 5_000,
};
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
return user;
}
public Dictionary<string, string?> GetMangleMap(User user, UserData expectedUserData)
{
var mangleMap = new Dictionary<string, string?>

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

@@ -0,0 +1,63 @@
using Bit.Seeder.Data.Enums;
namespace Bit.Seeder.Options;
/// <summary>
/// Options for seeding an organization with vault data.
/// </summary>
public class OrganizationVaultOptions
{
/// <summary>
/// Organization name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Domain for user emails (e.g., "example.com").
/// </summary>
public required string Domain { get; init; }
/// <summary>
/// Number of member users to create.
/// </summary>
public required int Users { get; init; }
/// <summary>
/// Number of login ciphers to create.
/// </summary>
public int Ciphers { get; init; } = 0;
/// <summary>
/// Number of groups to create.
/// </summary>
public int Groups { get; init; } = 0;
/// <summary>
/// When true and Users >= 10, creates a realistic mix of user statuses:
/// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked.
/// When false or Users &lt; 10, all users are Confirmed.
/// </summary>
public bool RealisticStatusMix { get; init; } = false;
/// <summary>
/// Org structure for realistic collection names.
/// </summary>
public OrgStructureModel? StructureModel { get; init; }
/// <summary>
/// Username pattern for cipher logins.
/// </summary>
public UsernamePatternType UsernamePattern { get; init; } = UsernamePatternType.FirstDotLast;
/// <summary>
/// Password strength for cipher logins. Defaults to Realistic distribution
/// (25% VeryWeak, 30% Weak, 25% Fair, 15% Strong, 5% VeryStrong).
/// </summary>
public PasswordStrength PasswordStrength { get; init; } = PasswordStrength.Realistic;
/// <summary>
/// Geographic region for culturally-appropriate name generation in cipher usernames.
/// Defaults to Global (mixed locales from all regions).
/// </summary>
public GeographicRegion? Region { get; init; }
}

View File

@@ -1,18 +1,155 @@
# Bitwarden Database Seeder
A class library for generating and inserting test data.
A class library for generating and inserting properly encrypted test data into Bitwarden databases.
## Project Structure
## Domain Taxonomy
The project is organized into these main components:
### Cipher Encryption States
### Factories
| Term | Description | Stored in DB? |
| -------------- | ---------------------------------------------------- | ------------- |
| **CipherView** | Plaintext/decrypted form. Human-readable data. | Never |
| **Cipher** | Encrypted form. All sensitive fields are EncStrings. | Yes |
Factories are helper classes for creating domain entities and populating them with realistic data. This assist in
decreasing the amount of boilerplate code needed to create test data in recipes.
The "View" suffix always denotes plaintext. No suffix means encrypted.
### Recipes
### Data Structure Differences
Recipes are pre-defined data sets which can be run to generate and load data into the database. They often allow a allow
for a few arguments to customize the data slightly. Recipes should be kept simple and focused on a single task. Default
to creating more recipes rather than adding complexity to existing ones.
**SDK Structure (nested):**
```json
{ "name": "2.x...", "login": { "username": "2.y...", "password": "2.z..." } }
```
**Server Structure (flat, stored in Cipher.Data):**
```json
{ "Name": "2.x...", "Username": "2.y...", "Password": "2.z..." }
```
The seeder transforms SDK output to server format before database insertion.
### Project Structure
The Seeder is organized around six core patterns, each with a specific responsibility:
#### Factories
**Purpose:** Create individual domain entities with cryptographically correct encrypted data.
**Metaphor:** Skilled craftspeople who create one perfect item per call.
**When to use:** Need to create ONE entity (user, cipher, collection) with proper encryption.
**Key characteristics:**
- Create ONE entity per method call
- Handle encryption/transformation internally
- Stateless (except for SDK service dependency)
- Do NOT interact with database directly
**Naming:** `{Entity}Seeder` class with `Create{Type}{Entity}()` methods
---
#### Recipes
**Purpose:** Orchestrate cohesive bulk operations using BulkCopy for performance.
**Metaphor:** Cooking recipes that produce one complete result through coordinated steps. Like baking a three-layer cake - you don't grab three separate recipes and stack them; you follow one comprehensive recipe that orchestrates all the steps.
**When to use:** Need to create MANY related entities as one cohesive operation (e.g., organization + users + collections + ciphers).
**Key characteristics:**
- Orchestrate multiple entity creations as a cohesive operation
- Use BulkCopy for performance optimization
- Interact with database directly
- Compose Factories for individual entity creation
- **SHALL have a `Seed()` method** that executes the complete recipe
- Use method parameters (with defaults) for variations, not separate methods
**Naming:** `{DomainConcept}Recipe` class with primary `Seed()` method
**Note:** Some existing recipes violate the `Seed()` method convention and will be refactored in the future.
---
#### Models
**Purpose:** DTOs that bridge the gap between SDK encryption format and server storage format.
**Metaphor:** Translators between two different languages (SDK format vs. Server format).
**When to use:** Need data transformation during the encryption pipeline (SDK → Server format).
**Key characteristics:**
- Pure data structures (DTOs)
- No business logic
- Handle serialization/deserialization
- Bridge SDK ↔ Server format differences
#### Scenes
**Purpose:** Create complete, isolated test scenarios for integration tests.
**Metaphor:** Theater scenes with multiple actors and props arranged to tell a complete story.
**When to use:** Need a complete test scenario with proper ID mangling for test isolation.
**Key characteristics:**
- Implement `IScene<TRequest>` or `IScene<TRequest, TResult>`
- Create complete, realistic test scenarios
- Handle uniqueness constraint mangling for test isolation
- Return `SceneResult` with mangle map and optional additional operation result data for test assertions
- Async operations
- CAN modify database state
**Naming:** `{Scenario}Scene` class with `SeedAsync(Request)` method (defined by interface)
#### Queries
**Purpose:** Read-only data retrieval for test assertions and verification.
**Metaphor:** Information desks that answer questions without changing anything.
**When to use:** Need to READ existing seeded data for verification or follow-up operations.
** Example:** Inviting a user to an organization produces a magic link to accept the invite, a query should be used to retrieve that link because it is easier than interfacing with an external smtp catcher.
**Key characteristics:**
- Implement `IQuery<TRequest, TResult>`
- Read-only (no database modifications)
- Return typed data for test assertions
- Can be used to retrieve side effects due to tested flows
**Naming:** `{DataToRetrieve}Query` class with `Execute(Request)` method (defined by interface)
#### Data
**Purpose:** Reusable, realistic test data collections that provide the foundation for cipher generation.
**Metaphor:** A well-stocked ingredient pantry that all recipes draw from.
**When to use:** Need realistic, filterable data for cipher content (company names, passwords, usernames).
**Key characteristics:**
- Static readonly arrays and classes
- Filterable by region, type, category
- Deterministic (seeded randomness for reproducibility)
- Composable across regions
- Enums provide the public API (CompanyType, PasswordStrength, etc.)
## Rust SDK Integration
The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption:
```
CipherViewDto → RustSdkService.EncryptCipher() → EncryptedCipherDto → Server Format
```
This ensures seeded data can be decrypted and displayed in the actual Bitwarden clients.

View File

@@ -0,0 +1,330 @@
using AutoMapper;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.RustSDK;
using Bit.Seeder.Data;
using Bit.Seeder.Data.Enums;
using Bit.Seeder.Factories;
using Bit.Seeder.Options;
using LinqToDB.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using EfFolder = Bit.Infrastructure.EntityFramework.Vault.Models.Folder;
using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization;
using EfOrganizationUser = Bit.Infrastructure.EntityFramework.Models.OrganizationUser;
using EfUser = Bit.Infrastructure.EntityFramework.Models.User;
namespace Bit.Seeder.Recipes;
/// <summary>
/// Seeds an organization with users, collections, groups, and encrypted ciphers.
/// </summary>
/// <remarks>
/// This recipe creates a complete organization with vault data in a single operation.
/// All entity creation is delegated to factories. Users can log in with their email
/// and password "asdfasdfasdf". Organization and user keys are generated dynamically.
/// </remarks>
public class OrganizationWithVaultRecipe(
DatabaseContext db,
IMapper mapper,
RustSdkService sdkService,
IPasswordHasher<User> passwordHasher)
{
private readonly CollectionSeeder _collectionSeeder = new(sdkService);
private readonly CipherSeeder _cipherSeeder = new(sdkService);
private readonly FolderSeeder _folderSeeder = new(sdkService);
/// <summary>
/// Tracks a user with their symmetric key for folder encryption.
/// </summary>
private record UserWithKey(User User, string SymmetricKey);
/// <summary>
/// Seeds an organization with users, collections, groups, and encrypted ciphers.
/// </summary>
/// <param name="options">Options specifying what to seed.</param>
/// <returns>The organization ID.</returns>
public Guid Seed(OrganizationVaultOptions options)
{
var seats = Math.Max(options.Users + 1, 1000);
var orgKeys = sdkService.GenerateOrganizationKeys();
// Create organization via factory
var organization = OrganizationSeeder.CreateEnterprise(
options.Name, options.Domain, seats, orgKeys.PublicKey, orgKeys.PrivateKey);
// Create owner user via factory
var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{options.Domain}", sdkService, passwordHasher);
var ownerOrgKey = sdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
// Create member users via factory, retaining keys for folder encryption
var memberUsersWithKeys = new List<UserWithKey>();
var memberOrgUsers = new List<OrganizationUser>();
var useRealisticMix = options.RealisticStatusMix && options.Users >= 10;
for (var i = 0; i < options.Users; i++)
{
var email = $"user{i}@{options.Domain}";
var userKeys = sdkService.GenerateUserKeys(email, UserSeeder.DefaultPassword);
var memberUser = UserSeeder.CreateUserFromKeys(email, userKeys, passwordHasher);
memberUsersWithKeys.Add(new UserWithKey(memberUser, userKeys.Key));
var status = useRealisticMix
? GetRealisticStatus(i, options.Users)
: OrganizationUserStatusType.Confirmed;
var memberOrgKey = (status == OrganizationUserStatusType.Confirmed ||
status == OrganizationUserStatusType.Revoked)
? sdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key)
: null;
memberOrgUsers.Add(organization.CreateOrganizationUserWithKey(
memberUser, OrganizationUserType.User, status, memberOrgKey));
}
var memberUsers = memberUsersWithKeys.Select(uwk => uwk.User).ToList();
// Persist organization and users
db.Add(mapper.Map<EfOrganization>(organization));
db.Add(mapper.Map<EfUser>(ownerUser));
db.Add(mapper.Map<EfOrganizationUser>(ownerOrgUser));
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();
// Get confirmed org user IDs for collection/group relationships
var confirmedOrgUserIds = memberOrgUsers
.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed)
.Select(ou => ou.Id)
.Prepend(ownerOrgUser.Id)
.ToList();
var collectionIds = CreateCollections(organization.Id, orgKeys.Key, options.StructureModel, confirmedOrgUserIds);
CreateGroups(organization.Id, options.Groups, confirmedOrgUserIds);
CreateCiphers(organization.Id, orgKeys.Key, collectionIds, options.Ciphers, options.UsernamePattern, options.PasswordStrength, options.Region);
CreateFolders(memberUsersWithKeys);
return organization.Id;
}
private List<Guid> CreateCollections(
Guid organizationId,
string orgKeyBase64,
OrgStructureModel? structureModel,
List<Guid> orgUserIds)
{
List<Collection> collections;
if (structureModel.HasValue)
{
var structure = OrgStructures.GetStructure(structureModel.Value);
collections = structure.Units
.Select(unit => _collectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name))
.ToList();
}
else
{
collections = [_collectionSeeder.CreateCollection(organizationId, orgKeyBase64, "Default Collection")];
}
db.BulkCopy(collections);
// Create collection-user relationships
if (collections.Count > 0 && orgUserIds.Count > 0)
{
var collectionUsers = orgUserIds
.SelectMany((orgUserId, userIndex) =>
{
var maxAssignments = Math.Min((userIndex % 3) + 1, collections.Count);
return Enumerable.Range(0, maxAssignments)
.Select(j => CollectionSeeder.CreateCollectionUser(
collections[(userIndex + j) % collections.Count].Id,
orgUserId,
readOnly: j > 0,
manage: j == 0));
})
.ToList();
db.BulkCopy(collectionUsers);
}
return collections.Select(c => c.Id).ToList();
}
private void CreateGroups(Guid organizationId, int groupCount, List<Guid> orgUserIds)
{
var groupList = Enumerable.Range(0, groupCount)
.Select(i => GroupSeeder.CreateGroup(organizationId, $"Group {i + 1}"))
.ToList();
db.BulkCopy(groupList);
// Create group-user relationships (round-robin assignment)
if (groupList.Count > 0 && orgUserIds.Count > 0)
{
var groupUsers = orgUserIds
.Select((orgUserId, i) => GroupSeeder.CreateGroupUser(
groupList[i % groupList.Count].Id,
orgUserId))
.ToList();
db.BulkCopy(groupUsers);
}
}
private void CreateCiphers(
Guid organizationId,
string orgKeyBase64,
List<Guid> collectionIds,
int cipherCount,
UsernamePatternType usernamePattern,
PasswordStrength passwordStrength,
GeographicRegion? region)
{
var companies = Companies.All;
var usernameGenerator = new CipherUsernameGenerator(organizationId.GetHashCode(), usernamePattern, region);
var cipherList = Enumerable.Range(0, cipherCount)
.Select(i =>
{
var company = companies[i % companies.Length];
return _cipherSeeder.CreateOrganizationLoginCipher(
organizationId,
orgKeyBase64,
name: $"{company.Name} ({company.Category})",
username: usernameGenerator.GenerateVaried(company, i),
password: Passwords.GetPassword(passwordStrength, i),
uri: $"https://{company.Domain}");
})
.ToList();
db.BulkCopy(cipherList);
// Create cipher-collection relationships
if (cipherList.Count > 0 && collectionIds.Count > 0)
{
var collectionCiphers = cipherList.SelectMany((cipher, i) =>
{
var primary = new CollectionCipher
{
CipherId = cipher.Id,
CollectionId = collectionIds[i % collectionIds.Count]
};
// Every 3rd cipher gets assigned to an additional collection
if (i % 3 == 0 && collectionIds.Count > 1)
{
return new[]
{
primary,
new CollectionCipher
{
CipherId = cipher.Id,
CollectionId = collectionIds[(i + 1) % collectionIds.Count]
}
};
}
return new[] { primary };
}).ToList();
db.BulkCopy(collectionCiphers);
}
}
/// <summary>
/// Returns a realistic user status based on index position.
/// Distribution: 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked.
/// </summary>
private static OrganizationUserStatusType GetRealisticStatus(int index, int totalUsers)
{
// Calculate bucket boundaries
var confirmedCount = (int)(totalUsers * 0.85);
var invitedCount = (int)(totalUsers * 0.05);
var acceptedCount = (int)(totalUsers * 0.05);
// Revoked gets the remainder
if (index < confirmedCount)
{
return OrganizationUserStatusType.Confirmed;
}
if (index < confirmedCount + invitedCount)
{
return OrganizationUserStatusType.Invited;
}
if (index < confirmedCount + invitedCount + acceptedCount)
{
return OrganizationUserStatusType.Accepted;
}
return OrganizationUserStatusType.Revoked;
}
/// <summary>
/// Creates personal vault folders for users with realistic distribution.
/// Folders are encrypted with each user's individual symmetric key.
/// </summary>
private void CreateFolders(List<UserWithKey> usersWithKeys)
{
if (usersWithKeys.Count == 0)
{
return;
}
var seed = usersWithKeys[0].User.Id.GetHashCode();
var random = new Random(seed);
var folderNameGenerator = new FolderNameGenerator(seed);
var allFolders = usersWithKeys
.SelectMany((uwk, userIndex) =>
{
var folderCount = GetFolderCountForUser(userIndex, usersWithKeys.Count, random);
return Enumerable.Range(0, folderCount)
.Select(folderIndex => _folderSeeder.CreateFolder(
uwk.User.Id,
uwk.SymmetricKey,
folderNameGenerator.GetFolderName(userIndex * 15 + folderIndex)));
})
.ToList();
if (allFolders.Count > 0)
{
var efFolders = allFolders.Select(f => mapper.Map<EfFolder>(f)).ToList();
db.BulkCopy(efFolders);
}
}
/// <summary>
/// Returns folder count based on user index position in the distribution.
/// Distribution: 35% Zero, 35% Few (1-3), 20% Some (4-7), 10% TooMany (10-15)
/// </summary>
private static int GetFolderCountForUser(int userIndex, int totalUsers, Random random)
{
var zeroCount = (int)(totalUsers * 0.35);
var fewCount = (int)(totalUsers * 0.35);
var someCount = (int)(totalUsers * 0.20);
// TooMany gets the remainder
if (userIndex < zeroCount)
{
return 0; // Zero folders
}
if (userIndex < zeroCount + fewCount)
{
return random.Next(1, 4); // Few: 1-3 folders
}
if (userIndex < zeroCount + fewCount + someCount)
{
return random.Next(4, 8); // Some: 4-7 folders
}
return random.Next(10, 16); // TooMany: 10-15 folders
}
}

View File

@@ -19,6 +19,10 @@
<ProjectReference Include="..\RustSdk\RustSdk.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Bogus" Version="35.*" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\Program.cs" />
</ItemGroup>