mirror of
https://github.com/bitwarden/server.git
synced 2026-01-31 06:03:12 +08:00
Add cipher seeding with Rust SDK encryption to enable cryptographically correct test data generation (#6896)
This commit is contained in:
@@ -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) {
|
||||
|
||||
234
test/SeederApi.IntegrationTest/RustSdkCipherTests.cs
Normal file
234
test/SeederApi.IntegrationTest/RustSdkCipherTests.cs
Normal 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" }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
112
util/DbSeederUtility/VaultOrganizationArgs.cs
Normal file
112
util/DbSeederUtility/VaultOrganizationArgs.cs
Normal 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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
180
util/RustSdk/rust/Cargo.lock
generated
180
util/RustSdk/rust/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
403
util/RustSdk/rust/src/cipher.rs
Normal file
403
util/RustSdk/rust/src/cipher.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
215
util/Seeder/CLAUDE.md
Normal 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
|
||||
78
util/Seeder/Data/BogusNameProvider.cs
Normal file
78
util/Seeder/Data/BogusNameProvider.cs
Normal 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)];
|
||||
}
|
||||
}
|
||||
67
util/Seeder/Data/CipherUsernameGenerator.cs
Normal file
67
util/Seeder/Data/CipherUsernameGenerator.cs
Normal 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];
|
||||
}
|
||||
123
util/Seeder/Data/Companies.cs
Normal file
123
util/Seeder/Data/Companies.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
11
util/Seeder/Data/Enums/CompanyCategory.cs
Normal file
11
util/Seeder/Data/Enums/CompanyCategory.cs
Normal 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
|
||||
}
|
||||
6
util/Seeder/Data/Enums/CompanyType.cs
Normal file
6
util/Seeder/Data/Enums/CompanyType.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Seeder.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Target market type for companies.
|
||||
/// </summary>
|
||||
public enum CompanyType { Consumer, Enterprise, Hybrid }
|
||||
9
util/Seeder/Data/Enums/GeographicRegion.cs
Normal file
9
util/Seeder/Data/Enums/GeographicRegion.cs
Normal 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
|
||||
}
|
||||
6
util/Seeder/Data/Enums/OrgStructureModel.cs
Normal file
6
util/Seeder/Data/Enums/OrgStructureModel.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Seeder.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Organizational structure model types.
|
||||
/// </summary>
|
||||
public enum OrgStructureModel { Traditional, Spotify, Modern }
|
||||
25
util/Seeder/Data/Enums/PasswordStrength.cs
Normal file
25
util/Seeder/Data/Enums/PasswordStrength.cs
Normal 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 (< 10³ guesses)</summary>
|
||||
VeryWeak = 0,
|
||||
|
||||
/// <summary>Score 1: Very guessable (< 10⁶ guesses)</summary>
|
||||
Weak = 1,
|
||||
|
||||
/// <summary>Score 2: Somewhat guessable (< 10⁸ guesses)</summary>
|
||||
Fair = 2,
|
||||
|
||||
/// <summary>Score 3: Safely unguessable (< 10¹⁰ guesses)</summary>
|
||||
Strong = 3,
|
||||
|
||||
/// <summary>Score 4: Very unguessable (≥ 10¹⁰ guesses)</summary>
|
||||
VeryStrong = 4,
|
||||
|
||||
/// <summary>Realistic distribution based on breach data statistics.</summary>
|
||||
Realistic = 99
|
||||
}
|
||||
20
util/Seeder/Data/Enums/UsernamePatternType.cs
Normal file
20
util/Seeder/Data/Enums/UsernamePatternType.cs
Normal 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
|
||||
}
|
||||
31
util/Seeder/Data/FolderNameGenerator.cs
Normal file
31
util/Seeder/Data/FolderNameGenerator.cs
Normal 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];
|
||||
}
|
||||
84
util/Seeder/Data/OrgStructures.cs
Normal file
84
util/Seeder/Data/OrgStructures.cs
Normal 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
|
||||
};
|
||||
}
|
||||
148
util/Seeder/Data/Passwords.cs
Normal file
148
util/Seeder/Data/Passwords.cs
Normal 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
144
util/Seeder/Data/README.md
Normal 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)
|
||||
```
|
||||
57
util/Seeder/Data/UsernamePatterns.cs
Normal file
57
util/Seeder/Data/UsernamePatterns.cs
Normal 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
|
||||
};
|
||||
}
|
||||
153
util/Seeder/Factories/CipherSeeder.cs
Normal file
153
util/Seeder/Factories/CipherSeeder.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
36
util/Seeder/Factories/CollectionSeeder.cs
Normal file
36
util/Seeder/Factories/CollectionSeeder.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
28
util/Seeder/Factories/FolderSeeder.cs
Normal file
28
util/Seeder/Factories/FolderSeeder.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
41
util/Seeder/Factories/GroupSeeder.cs
Normal file
41
util/Seeder/Factories/GroupSeeder.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
32
util/Seeder/Factories/OrganizationDomainSeeder.cs
Normal file
32
util/Seeder/Factories/OrganizationDomainSeeder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?>
|
||||
|
||||
153
util/Seeder/Models/CipherViewDto.cs
Normal file
153
util/Seeder/Models/CipherViewDto.cs
Normal 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;
|
||||
}
|
||||
96
util/Seeder/Models/EncryptedCipherDto.cs
Normal file
96
util/Seeder/Models/EncryptedCipherDto.cs
Normal 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; }
|
||||
}
|
||||
63
util/Seeder/Options/OrganizationVaultOptions.cs
Normal file
63
util/Seeder/Options/OrganizationVaultOptions.cs
Normal 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 < 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; }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
330
util/Seeder/Recipes/OrganizationWithVaultRecipe.cs
Normal file
330
util/Seeder/Recipes/OrganizationWithVaultRecipe.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
<ProjectReference Include="..\RustSdk\RustSdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bogus" Version="35.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="..\..\Program.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user