mirror of
https://github.com/bitwarden/server
synced 2026-01-29 07:43:22 +00:00
Add cipher seeding with Rust SDK encryption to enable cryptographically correct test data generation
This commit is contained in:
@@ -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,4 +1,4 @@
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Recipes;
|
||||
using CommandDotNet;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -13,27 +13,44 @@ public class Program
|
||||
.Run(args);
|
||||
}
|
||||
|
||||
[Command("organization", Description = "Seed an organization and organization users")]
|
||||
[Command("organization", Description = "Seed an organization with users and optional ciphers")]
|
||||
public void Organization(
|
||||
[Option('n', "Name", Description = "Name of organization")]
|
||||
[Option('n', "name", Description = "Name of organization")]
|
||||
string name,
|
||||
[Option('u', "users", Description = "Number of users to generate")]
|
||||
int users,
|
||||
[Option('d', "domain", Description = "Email domain for users")]
|
||||
string domain
|
||||
string domain,
|
||||
[Option('c', "ciphers", Description = "Number of login ciphers to create")]
|
||||
int ciphers = 0,
|
||||
[Option('s', "structure", Description = "Org structure for collections: Traditional, Spotify, or Modern")]
|
||||
string? structure = null
|
||||
)
|
||||
{
|
||||
// Create service provider with necessary services
|
||||
var structureModel = ParseStructureModel(structure);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
ServiceCollectionExtension.ConfigureServices(services);
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// Get a scoped DB context
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var scopedServices = scope.ServiceProvider;
|
||||
var db = scopedServices.GetRequiredService<DatabaseContext>();
|
||||
OrganizationWithUsersRecipe.SeedFromServices(scope.ServiceProvider, name, domain, users, ciphers,
|
||||
structureModel: structureModel);
|
||||
}
|
||||
|
||||
var recipe = new OrganizationWithUsersRecipe(db);
|
||||
recipe.Seed(name: name, domain: domain, users: users);
|
||||
private static OrgStructureModel? ParseStructureModel(string? structure)
|
||||
{
|
||||
if (string.IsNullOrEmpty(structure))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return structure.ToLowerInvariant() switch
|
||||
{
|
||||
"traditional" => OrgStructureModel.Traditional,
|
||||
"spotify" => OrgStructureModel.Spotify,
|
||||
"modern" => OrgStructureModel.Modern,
|
||||
_ => throw new ArgumentException($"Unknown structure '{structure}'. Use: Traditional, Spotify, or Modern")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,34 @@ DbSeeder.exe <command> [options]
|
||||
|
||||
```bash
|
||||
# Generate an organization called "seeded" with 10000 users using the @large.test email domain.
|
||||
# Login using "admin@large.test" with password "asdfasdfasdf"
|
||||
# Login using "owner@large.test" with password "asdfasdfasdf"
|
||||
DbSeeder.exe organization -n seeded -u 10000 -d large.test
|
||||
|
||||
# Generate an organization with 5 users and 100 encrypted ciphers
|
||||
DbSeeder.exe organization -n TestOrg -u 5 -d test.com -c 100
|
||||
|
||||
# Generate a small test organization with ciphers for manual testing
|
||||
DbSeeder.exe organization -n DevOrg -u 2 -d dev.local -c 10
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-n, --name` | Organization name |
|
||||
| `-u, --users` | Number of member users to create |
|
||||
| `-d, --domain` | Email domain (e.g., test.com creates owner@test.com) |
|
||||
| `-c, --ciphers` | Number of encrypted ciphers to create (optional) |
|
||||
| `-s, --status` | User status: Confirmed (default), Invited, Accepted, Revoked |
|
||||
|
||||
### Notes
|
||||
|
||||
- All users are created with the password `asdfasdfasdf`
|
||||
- The owner account is always `owner@{domain}` with Confirmed status
|
||||
- Member accounts are `user0@{domain}`, `user1@{domain}`, etc.
|
||||
- When ciphers are created, a "Default Collection" is automatically created and all users are granted access
|
||||
- Ciphers are encrypted using dynamically generated organization keys
|
||||
|
||||
## Dependencies
|
||||
|
||||
This utility depends on:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.RustSDK;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -15,6 +18,8 @@ public static class ServiceCollectionExtension
|
||||
// Register services
|
||||
services.AddLogging(builder => builder.AddConsole());
|
||||
services.AddSingleton(globalSettings);
|
||||
services.AddSingleton<RustSdkService>();
|
||||
services.AddSingleton<IPasswordHasher<User>, PasswordHasher<User>>();
|
||||
|
||||
// Add Data Protection services
|
||||
services.AddDataProtection()
|
||||
|
||||
@@ -78,6 +78,51 @@ public class RustSdkService
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe string EncryptCipher(string cipherViewJson, string symmetricKeyBase64)
|
||||
{
|
||||
var cipherViewBytes = StringToRustString(cipherViewJson);
|
||||
var keyBytes = StringToRustString(symmetricKeyBase64);
|
||||
|
||||
fixed (byte* cipherViewPtr = cipherViewBytes)
|
||||
fixed (byte* keyPtr = keyBytes)
|
||||
{
|
||||
var resultPtr = NativeMethods.encrypt_cipher(cipherViewPtr, keyPtr);
|
||||
|
||||
return TakeAndDestroyRustString(resultPtr);
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe string DecryptCipher(string cipherJson, string symmetricKeyBase64)
|
||||
{
|
||||
var cipherBytes = StringToRustString(cipherJson);
|
||||
var keyBytes = StringToRustString(symmetricKeyBase64);
|
||||
|
||||
fixed (byte* cipherPtr = cipherBytes)
|
||||
fixed (byte* keyPtr = keyBytes)
|
||||
{
|
||||
var resultPtr = NativeMethods.decrypt_cipher(cipherPtr, keyPtr);
|
||||
|
||||
return TakeAndDestroyRustString(resultPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a plaintext string using the provided symmetric key.
|
||||
/// Returns an EncString in format "2.{iv}|{data}|{mac}".
|
||||
/// </summary>
|
||||
public unsafe string EncryptString(string plaintext, string symmetricKeyBase64)
|
||||
{
|
||||
var plaintextBytes = StringToRustString(plaintext);
|
||||
var keyBytes = StringToRustString(symmetricKeyBase64);
|
||||
|
||||
fixed (byte* plaintextPtr = plaintextBytes)
|
||||
fixed (byte* keyPtr = keyBytes)
|
||||
{
|
||||
var resultPtr = NativeMethods.encrypt_string(plaintextPtr, keyPtr);
|
||||
|
||||
return TakeAndDestroyRustString(resultPtr);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] StringToRustString(string str)
|
||||
{
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ use std::{
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
|
||||
use bitwarden_core::key_management::KeyIds;
|
||||
use bitwarden_crypto::{
|
||||
AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, HashPurpose, Kdf,
|
||||
KeyEncryptable, MasterKey, RsaKeyPair, SpkiPublicKeyBytes, SymmetricCryptoKey,
|
||||
UnsignedSharedKey, UserKey,
|
||||
AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, CompositeEncryptable,
|
||||
Decryptable, HashPurpose, Kdf, KeyEncryptable, KeyStore, MasterKey, RsaKeyPair,
|
||||
SpkiPublicKeyBytes, SymmetricCryptoKey, UnsignedSharedKey, UserKey,
|
||||
};
|
||||
use bitwarden_vault::{Cipher, CipherView};
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn generate_user_keys(
|
||||
@@ -20,9 +22,6 @@ pub unsafe extern "C" fn generate_user_keys(
|
||||
let email = CStr::from_ptr(email).to_str().unwrap();
|
||||
let password = CStr::from_ptr(password).to_str().unwrap();
|
||||
|
||||
println!("Generating keys for {email}");
|
||||
println!("Password: {password}");
|
||||
|
||||
let kdf = Kdf::PBKDF2 {
|
||||
iterations: NonZeroU32::new(5_000).unwrap(),
|
||||
};
|
||||
@@ -32,8 +31,6 @@ pub unsafe extern "C" fn generate_user_keys(
|
||||
let master_password_hash =
|
||||
master_key.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization);
|
||||
|
||||
println!("Master password hash: {}", master_password_hash);
|
||||
|
||||
let (user_key, encrypted_user_key) = master_key.make_user_key().unwrap();
|
||||
|
||||
let keypair = keypair(&user_key.0);
|
||||
@@ -140,6 +137,183 @@ pub unsafe extern "C" fn generate_user_organization_key(
|
||||
result.into_raw()
|
||||
}
|
||||
|
||||
/// Create an error JSON response and return it as a C string pointer.
|
||||
fn error_response(message: &str) -> *const c_char {
|
||||
let error_json = serde_json::json!({ "error": message }).to_string();
|
||||
CString::new(error_json).unwrap().into_raw()
|
||||
}
|
||||
|
||||
/// Encrypt a CipherView with a symmetric key, returning an encrypted Cipher as JSON.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cipher_view_json` - JSON string representing a CipherView (camelCase format)
|
||||
/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256)
|
||||
///
|
||||
/// # Returns
|
||||
/// JSON string representing the encrypted Cipher
|
||||
///
|
||||
/// # Safety
|
||||
/// Both pointers must be valid null-terminated strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn encrypt_cipher(
|
||||
cipher_view_json: *const c_char,
|
||||
symmetric_key_b64: *const c_char,
|
||||
) -> *const c_char {
|
||||
let cipher_view_json = match CStr::from_ptr(cipher_view_json).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return error_response("Invalid UTF-8 in cipher_view_json"),
|
||||
};
|
||||
|
||||
let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"),
|
||||
};
|
||||
|
||||
let cipher_view: CipherView = match serde_json::from_str(cipher_view_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return error_response("Failed to parse CipherView JSON"),
|
||||
};
|
||||
|
||||
let key_bytes = match STANDARD.decode(key_b64) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return error_response("Failed to decode base64 key"),
|
||||
};
|
||||
|
||||
let key =
|
||||
match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return error_response(
|
||||
"Failed to create symmetric key: invalid key format or length",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let store: KeyStore<KeyIds> = KeyStore::default();
|
||||
let mut ctx = store.context_mut();
|
||||
let key_id = ctx.add_local_symmetric_key(key);
|
||||
|
||||
let cipher = match cipher_view.encrypt_composite(&mut ctx, key_id) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return error_response("Failed to encrypt cipher: encryption operation failed"),
|
||||
};
|
||||
|
||||
match serde_json::to_string(&cipher) {
|
||||
Ok(json) => CString::new(json).unwrap().into_raw(),
|
||||
Err(_) => error_response("Failed to serialize encrypted cipher"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt an encrypted Cipher with a symmetric key, returning a CipherView as JSON.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cipher_json` - JSON string representing an encrypted Cipher
|
||||
/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256)
|
||||
///
|
||||
/// # Returns
|
||||
/// JSON string representing the decrypted CipherView
|
||||
///
|
||||
/// # Safety
|
||||
/// Both pointers must be valid null-terminated strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn decrypt_cipher(
|
||||
cipher_json: *const c_char,
|
||||
symmetric_key_b64: *const c_char,
|
||||
) -> *const c_char {
|
||||
let cipher_json = match CStr::from_ptr(cipher_json).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return error_response("Invalid UTF-8 in cipher_json"),
|
||||
};
|
||||
|
||||
let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"),
|
||||
};
|
||||
|
||||
let cipher: Cipher = match serde_json::from_str(cipher_json) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return error_response("Failed to parse Cipher JSON"),
|
||||
};
|
||||
|
||||
let key_bytes = match STANDARD.decode(key_b64) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return error_response("Failed to decode base64 key"),
|
||||
};
|
||||
|
||||
let key =
|
||||
match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return error_response(
|
||||
"Failed to create symmetric key: invalid key format or length",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let store: KeyStore<KeyIds> = KeyStore::default();
|
||||
let mut ctx = store.context_mut();
|
||||
let key_id = ctx.add_local_symmetric_key(key);
|
||||
|
||||
let cipher_view: CipherView = match cipher.decrypt(&mut ctx, key_id) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return error_response("Failed to decrypt cipher: decryption operation failed"),
|
||||
};
|
||||
|
||||
match serde_json::to_string(&cipher_view) {
|
||||
Ok(json) => CString::new(json).unwrap().into_raw(),
|
||||
Err(_) => error_response("Failed to serialize decrypted cipher"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext string with a symmetric key, returning an EncString.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `plaintext` - The plaintext string to encrypt
|
||||
/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256)
|
||||
///
|
||||
/// # Returns
|
||||
/// EncString in format "2.{iv}|{data}|{mac}"
|
||||
///
|
||||
/// # Safety
|
||||
/// Both pointers must be valid null-terminated strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn encrypt_string(
|
||||
plaintext: *const c_char,
|
||||
symmetric_key_b64: *const c_char,
|
||||
) -> *const c_char {
|
||||
let plaintext = match CStr::from_ptr(plaintext).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return error_response("Invalid UTF-8 in plaintext"),
|
||||
};
|
||||
|
||||
let key_b64 = match CStr::from_ptr(symmetric_key_b64).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return error_response("Invalid UTF-8 in symmetric_key_b64"),
|
||||
};
|
||||
|
||||
let key_bytes = match STANDARD.decode(key_b64) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return error_response("Failed to decode base64 key"),
|
||||
};
|
||||
|
||||
let key =
|
||||
match SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(key_bytes.as_slice())) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return error_response(
|
||||
"Failed to create symmetric key: invalid key format or length",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let encrypted = match plaintext.to_string().encrypt_with_key(&key) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return error_response("Failed to encrypt string"),
|
||||
};
|
||||
|
||||
CString::new(encrypted.to_string()).unwrap().into_raw()
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// The `str` pointer must be a valid pointer previously returned by `CString::into_raw`
|
||||
@@ -150,3 +324,245 @@ pub unsafe extern "C" fn free_c_string(str: *mut c_char) {
|
||||
drop(CString::from_raw(str));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bitwarden_vault::{Cipher, CipherType, LoginView};
|
||||
|
||||
fn create_test_cipher_view() -> CipherView {
|
||||
CipherView {
|
||||
id: None,
|
||||
organization_id: None,
|
||||
folder_id: None,
|
||||
collection_ids: vec![],
|
||||
key: None,
|
||||
name: "Test Login".to_string(),
|
||||
notes: Some("Secret notes".to_string()),
|
||||
r#type: CipherType::Login,
|
||||
login: Some(LoginView {
|
||||
username: Some("testuser@example.com".to_string()),
|
||||
password: Some("SuperSecretP@ssw0rd!".to_string()),
|
||||
password_revision_date: None,
|
||||
uris: None,
|
||||
totp: None,
|
||||
autofill_on_page_load: None,
|
||||
fido2_credentials: None,
|
||||
}),
|
||||
identity: None,
|
||||
card: None,
|
||||
secure_note: None,
|
||||
ssh_key: None,
|
||||
favorite: false,
|
||||
reprompt: bitwarden_vault::CipherRepromptType::None,
|
||||
organization_use_totp: false,
|
||||
edit: true,
|
||||
permissions: None,
|
||||
view_password: true,
|
||||
local_data: None,
|
||||
attachments: None,
|
||||
fields: None,
|
||||
password_history: None,
|
||||
creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
|
||||
deleted_date: None,
|
||||
revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
|
||||
archived_date: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn call_encrypt_cipher(cipher_json: &str, key_b64: &str) -> String {
|
||||
let cipher_cstr = CString::new(cipher_json).unwrap();
|
||||
let key_cstr = CString::new(key_b64).unwrap();
|
||||
|
||||
let result_ptr = unsafe { encrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) };
|
||||
let result_cstr = unsafe { CStr::from_ptr(result_ptr) };
|
||||
let result = result_cstr.to_str().unwrap().to_owned();
|
||||
unsafe { free_c_string(result_ptr as *mut c_char) };
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn make_test_key_b64() -> String {
|
||||
SymmetricCryptoKey::make_aes256_cbc_hmac_key()
|
||||
.to_base64()
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_cipher_produces_encrypted_fields() {
|
||||
let key_b64 = make_test_key_b64();
|
||||
let cipher_view = create_test_cipher_view();
|
||||
let cipher_json = serde_json::to_string(&cipher_view).unwrap();
|
||||
|
||||
let encrypted_json = call_encrypt_cipher(&cipher_json, &key_b64);
|
||||
|
||||
assert!(
|
||||
!encrypted_json.contains("\"error\""),
|
||||
"Got error: {}",
|
||||
encrypted_json
|
||||
);
|
||||
|
||||
let encrypted_cipher: Cipher =
|
||||
serde_json::from_str(&encrypted_json).expect("Failed to parse encrypted cipher JSON");
|
||||
|
||||
let encrypted_name = encrypted_cipher.name.to_string();
|
||||
assert!(
|
||||
encrypted_name.starts_with("2."),
|
||||
"Name should be encrypted: {}",
|
||||
encrypted_name
|
||||
);
|
||||
|
||||
let login = encrypted_cipher.login.expect("Login should be present");
|
||||
if let Some(username) = &login.username {
|
||||
assert!(
|
||||
username.to_string().starts_with("2."),
|
||||
"Username should be encrypted"
|
||||
);
|
||||
}
|
||||
if let Some(password) = &login.password {
|
||||
assert!(
|
||||
password.to_string().starts_with("2."),
|
||||
"Password should be encrypted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_cipher_works_with_generated_org_key() {
|
||||
let org_keys_ptr = unsafe { generate_organization_keys() };
|
||||
let org_keys_cstr = unsafe { CStr::from_ptr(org_keys_ptr) };
|
||||
let org_keys_json = org_keys_cstr.to_str().unwrap().to_owned();
|
||||
unsafe { free_c_string(org_keys_ptr as *mut c_char) };
|
||||
|
||||
let org_keys: serde_json::Value = serde_json::from_str(&org_keys_json).unwrap();
|
||||
let org_key_b64 = org_keys["key"].as_str().unwrap();
|
||||
|
||||
let cipher_view = create_test_cipher_view();
|
||||
let cipher_json = serde_json::to_string(&cipher_view).unwrap();
|
||||
|
||||
let encrypted_json = call_encrypt_cipher(&cipher_json, org_key_b64);
|
||||
|
||||
assert!(
|
||||
!encrypted_json.contains("\"error\""),
|
||||
"Got error: {}",
|
||||
encrypted_json
|
||||
);
|
||||
|
||||
let encrypted_cipher: Cipher = serde_json::from_str(&encrypted_json).unwrap();
|
||||
assert!(encrypted_cipher.name.to_string().starts_with("2."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_cipher_rejects_invalid_json() {
|
||||
let key_b64 = make_test_key_b64();
|
||||
|
||||
let error_json = call_encrypt_cipher("{ this is not valid json }", &key_b64);
|
||||
|
||||
assert!(
|
||||
error_json.contains("\"error\""),
|
||||
"Should return error for invalid JSON"
|
||||
);
|
||||
assert!(error_json.contains("Failed to parse CipherView JSON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_cipher_rejects_invalid_base64_key() {
|
||||
let cipher_view = create_test_cipher_view();
|
||||
let cipher_json = serde_json::to_string(&cipher_view).unwrap();
|
||||
|
||||
let error_json = call_encrypt_cipher(&cipher_json, "not-valid-base64!!!");
|
||||
|
||||
assert!(
|
||||
error_json.contains("\"error\""),
|
||||
"Should return error for invalid base64"
|
||||
);
|
||||
assert!(error_json.contains("Failed to decode base64 key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_cipher_rejects_wrong_key_length() {
|
||||
let cipher_view = create_test_cipher_view();
|
||||
let cipher_json = serde_json::to_string(&cipher_view).unwrap();
|
||||
let short_key_b64 = STANDARD.encode(b"too short");
|
||||
|
||||
let error_json = call_encrypt_cipher(&cipher_json, &short_key_b64);
|
||||
|
||||
assert!(
|
||||
error_json.contains("\"error\""),
|
||||
"Should return error for wrong key length"
|
||||
);
|
||||
assert!(error_json.contains("invalid key format or length"));
|
||||
}
|
||||
|
||||
fn call_decrypt_cipher(cipher_json: &str, key_b64: &str) -> String {
|
||||
let cipher_cstr = CString::new(cipher_json).unwrap();
|
||||
let key_cstr = CString::new(key_b64).unwrap();
|
||||
|
||||
let result_ptr = unsafe { decrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) };
|
||||
let result_cstr = unsafe { CStr::from_ptr(result_ptr) };
|
||||
let result = result_cstr.to_str().unwrap().to_owned();
|
||||
unsafe { free_c_string(result_ptr as *mut c_char) };
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip_preserves_plaintext() {
|
||||
let key_b64 = make_test_key_b64();
|
||||
let original_view = create_test_cipher_view();
|
||||
let original_json = serde_json::to_string(&original_view).unwrap();
|
||||
|
||||
let encrypted_json = call_encrypt_cipher(&original_json, &key_b64);
|
||||
assert!(
|
||||
!encrypted_json.contains("\"error\""),
|
||||
"Encryption failed: {}",
|
||||
encrypted_json
|
||||
);
|
||||
|
||||
let decrypted_json = call_decrypt_cipher(&encrypted_json, &key_b64);
|
||||
assert!(
|
||||
!decrypted_json.contains("\"error\""),
|
||||
"Decryption failed: {}",
|
||||
decrypted_json
|
||||
);
|
||||
|
||||
let decrypted_view: CipherView = serde_json::from_str(&decrypted_json)
|
||||
.expect("Failed to parse decrypted CipherView");
|
||||
|
||||
assert_eq!(decrypted_view.name, original_view.name);
|
||||
assert_eq!(decrypted_view.notes, original_view.notes);
|
||||
|
||||
let original_login = original_view.login.expect("Original should have login");
|
||||
let decrypted_login = decrypted_view.login.expect("Decrypted should have login");
|
||||
|
||||
assert_eq!(decrypted_login.username, original_login.username);
|
||||
assert_eq!(decrypted_login.password, original_login.password);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_cipher_rejects_wrong_key() {
|
||||
let encrypt_key = make_test_key_b64();
|
||||
let wrong_key = make_test_key_b64();
|
||||
|
||||
let original_view = create_test_cipher_view();
|
||||
let original_json = serde_json::to_string(&original_view).unwrap();
|
||||
|
||||
let encrypted_json = call_encrypt_cipher(&original_json, &encrypt_key);
|
||||
assert!(!encrypted_json.contains("\"error\""));
|
||||
|
||||
let decrypted_json = call_decrypt_cipher(&encrypted_json, &wrong_key);
|
||||
|
||||
// Decryption with wrong key should fail or produce garbage
|
||||
// The SDK may return an error or the MAC validation will fail
|
||||
let result: Result<CipherView, _> = serde_json::from_str(&decrypted_json);
|
||||
if !decrypted_json.contains("\"error\"") {
|
||||
// If no error, the decrypted data should not match original
|
||||
if let Ok(view) = result {
|
||||
assert_ne!(
|
||||
view.name, original_view.name,
|
||||
"Decryption with wrong key should not produce original plaintext"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
199
util/Seeder/.claude/CLAUDE.md
Normal file
199
util/Seeder/.claude/CLAUDE.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Seeder - Claude Code Context
|
||||
|
||||
## When to Use the Seeder
|
||||
|
||||
✅ Use for:
|
||||
|
||||
- Local development database setup
|
||||
- Integration test data creation
|
||||
- Performance testing with realistic encrypted data
|
||||
|
||||
❌ Do NOT use for:
|
||||
|
||||
- Production data
|
||||
- Copying real user vaults (use backup/restore instead)
|
||||
|
||||
## Zero-Knowledge Architecture
|
||||
|
||||
**Critical Principle:** Unencrypted vault data never leaves the client. The server never sees plaintext.
|
||||
|
||||
### Why Seeder Uses the Rust SDK
|
||||
|
||||
The Seeder must behave exactly like any other Bitwarden client. Since the server:
|
||||
|
||||
- Never receives plaintext
|
||||
- Cannot perform encryption (doesn't have keys)
|
||||
- Only stores/retrieves encrypted blobs
|
||||
|
||||
...the Seeder cannot simply write plaintext to the database. It must:
|
||||
|
||||
1. Generate encryption keys (like a client does during account setup)
|
||||
2. Encrypt vault data client-side (using the same SDK the real clients use)
|
||||
3. Store only the encrypted result
|
||||
|
||||
This is why we use the Rust SDK via FFI - it's the same cryptographic implementation used by the official clients.
|
||||
|
||||
## Cipher Encryption Architecture
|
||||
|
||||
### The Two-State Pattern
|
||||
|
||||
Bitwarden uses a clean separation between encrypted and decrypted data:
|
||||
|
||||
| State | SDK Type | Description | Stored in DB? |
|
||||
| --------- | ------------ | ------------------------- | ------------- |
|
||||
| Plaintext | `CipherView` | Decrypted, human-readable | Never |
|
||||
| Encrypted | `Cipher` | EncString values | Yes |
|
||||
|
||||
**Encryption flow:**
|
||||
|
||||
```
|
||||
CipherView (plaintext) → encrypt_composite() → Cipher (encrypted)
|
||||
```
|
||||
|
||||
**Decryption flow:**
|
||||
|
||||
```
|
||||
Cipher (encrypted) → decrypt() → CipherView (plaintext)
|
||||
```
|
||||
|
||||
### EncString Format
|
||||
|
||||
All encrypted fields use the EncString format:
|
||||
|
||||
```
|
||||
2.{base64_iv}|{base64_data}|{base64_mac}
|
||||
│ └──────────┘ └──────────┘ └──────────┘
|
||||
│ IV Ciphertext HMAC
|
||||
└─ Type 2 = AES-256-CBC-HMAC-SHA256
|
||||
```
|
||||
|
||||
### SDK vs Server Format Difference
|
||||
|
||||
**Critical:** The SDK and server use different JSON structures.
|
||||
|
||||
**SDK Cipher (nested):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "2.abc...",
|
||||
"login": {
|
||||
"username": "2.def...",
|
||||
"password": "2.ghi..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server Cipher.Data (flat CipherLoginData):**
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "2.abc...",
|
||||
"Username": "2.def...",
|
||||
"Password": "2.ghi..."
|
||||
}
|
||||
```
|
||||
|
||||
The `CipherSeeder.TransformToServerCipher()` method performs this flattening.
|
||||
|
||||
### Data Flow in Seeder
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ CipherViewDto │────▶│ Rust SDK │────▶│ EncryptedCipherDto │
|
||||
│ (plaintext) │ │ encrypt_cipher │ │ (SDK Cipher) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ TransformToServer │
|
||||
│ (flatten nested → │
|
||||
│ flat structure) │
|
||||
└───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ Server Cipher │◀────│ CipherLoginData │◀────│ Flattened JSON │
|
||||
│ Entity │ │ (serialized) │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------ | ------------------------------------------------------ |
|
||||
| `Models/CipherViewDto.cs` | Plaintext input matching SDK's CipherView |
|
||||
| `Models/EncryptedCipherDto.cs` | Parses SDK's encrypted Cipher output |
|
||||
| `Factories/CipherSeeder.cs` | Creates encrypted ciphers, transforms to server format |
|
||||
| `Recipes/CiphersRecipe.cs` | Bulk cipher creation with collection assignment |
|
||||
|
||||
### Key Hierarchy
|
||||
|
||||
Bitwarden uses a two-level encryption hierarchy:
|
||||
|
||||
1. **User/Organization Key** - Encrypts the cipher's individual key
|
||||
2. **Cipher Key** (optional) - Encrypts the actual cipher data
|
||||
|
||||
For seeding, we use the organization's symmetric key directly (no per-cipher key).
|
||||
|
||||
## Rust SDK FFI
|
||||
|
||||
### Available Functions
|
||||
|
||||
| Function | Input | Output |
|
||||
| ---------------------------- | --------------------- | --------------------------- |
|
||||
| `encrypt_cipher` | CipherView JSON + key | Cipher JSON |
|
||||
| `decrypt_cipher` | Cipher JSON + key | CipherView JSON |
|
||||
| `generate_organization_keys` | (none) | Org symmetric key + keypair |
|
||||
|
||||
### Error Handling
|
||||
|
||||
SDK functions return JSON with an `"error"` field on failure:
|
||||
|
||||
```json
|
||||
{ "error": "Failed to parse CipherView JSON" }
|
||||
```
|
||||
|
||||
Always check for `"error"` in the response before parsing.
|
||||
|
||||
## Testing
|
||||
|
||||
Integration tests in `test/SeederApi.IntegrationTest/RustSdkCipherTests.cs` verify:
|
||||
|
||||
1. **Roundtrip encryption** - Encrypt then decrypt preserves plaintext
|
||||
2. **Server format compatibility** - Output matches CipherLoginData structure
|
||||
3. **Field encryption** - Custom fields are properly encrypted
|
||||
4. **Security** - Plaintext never appears in encrypted output
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Cipher
|
||||
|
||||
```csharp
|
||||
var sdk = new RustSdkService();
|
||||
var seeder = new CipherSeeder(sdk);
|
||||
|
||||
var cipher = seeder.CreateOrganizationLoginCipher(
|
||||
organizationId,
|
||||
orgKey, // Base64-encoded symmetric key
|
||||
name: "My Login",
|
||||
username: "user@example.com",
|
||||
password: "secret123");
|
||||
```
|
||||
|
||||
### Bulk Cipher Creation
|
||||
|
||||
```csharp
|
||||
var recipe = new CiphersRecipe(dbContext, sdkService);
|
||||
|
||||
var cipherIds = recipe.AddLoginCiphersToOrganization(
|
||||
organizationId,
|
||||
orgKey,
|
||||
collectionIds,
|
||||
count: 100);
|
||||
```
|
||||
|
||||
## Security Reminders
|
||||
|
||||
- Generated test passwords are intentionally weak (`asdfasdfasdf`)
|
||||
- Never commit database dumps containing seeded data to version control
|
||||
- Seeded keys are for testing only - regenerate for each test run
|
||||
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 }
|
||||
6
util/Seeder/Data/Enums/PasswordStrength.cs
Normal file
6
util/Seeder/Data/Enums/PasswordStrength.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Seeder.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Password strength levels for test data generation.
|
||||
/// </summary>
|
||||
public enum PasswordStrength { Weak, Medium, Strong, Mixed }
|
||||
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
|
||||
}
|
||||
80
util/Seeder/Data/Names.cs
Normal file
80
util/Seeder/Data/Names.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
namespace Bit.Seeder.Data;
|
||||
|
||||
/// <summary>
|
||||
/// First and last names organized by region for username generation.
|
||||
/// Add new regions by creating arrays and including them in the All* properties.
|
||||
/// </summary>
|
||||
internal static class Names
|
||||
{
|
||||
public static readonly string[] UsFirstNames =
|
||||
[
|
||||
// Male
|
||||
"James", "Robert", "John", "Michael", "David", "William", "Richard", "Joseph", "Thomas", "Christopher",
|
||||
"Charles", "Daniel", "Matthew", "Anthony", "Mark", "Donald", "Steven", "Paul", "Andrew", "Joshua",
|
||||
"Kenneth", "Kevin", "Brian", "George", "Timothy", "Ronald", "Edward", "Jason", "Jeffrey", "Ryan",
|
||||
"Jacob", "Gary", "Nicholas", "Eric", "Jonathan", "Stephen", "Larry", "Justin", "Scott", "Brandon",
|
||||
"Benjamin", "Samuel", "Raymond", "Gregory", "Frank", "Alexander", "Patrick", "Jack", "Dennis", "Jerry",
|
||||
// Female
|
||||
"Mary", "Patricia", "Jennifer", "Linda", "Barbara", "Elizabeth", "Susan", "Jessica", "Sarah", "Karen",
|
||||
"Lisa", "Nancy", "Betty", "Margaret", "Sandra", "Ashley", "Kimberly", "Emily", "Donna", "Michelle",
|
||||
"Dorothy", "Carol", "Amanda", "Melissa", "Deborah", "Stephanie", "Rebecca", "Sharon", "Laura", "Cynthia",
|
||||
"Kathleen", "Amy", "Angela", "Shirley", "Anna", "Brenda", "Pamela", "Emma", "Nicole", "Helen",
|
||||
"Samantha", "Katherine", "Christine", "Debra", "Rachel", "Carolyn", "Janet", "Catherine", "Maria", "Heather"
|
||||
];
|
||||
|
||||
public static readonly string[] UsLastNames =
|
||||
[
|
||||
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez",
|
||||
"Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin",
|
||||
"Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson",
|
||||
"Walker", "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores",
|
||||
"Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", "Carter", "Roberts",
|
||||
"Gomez", "Phillips", "Evans", "Turner", "Diaz", "Parker", "Cruz", "Edwards", "Collins", "Reyes",
|
||||
"Stewart", "Morris", "Morales", "Murphy", "Cook", "Rogers", "Gutierrez", "Ortiz", "Morgan", "Cooper",
|
||||
"Peterson", "Bailey", "Reed", "Kelly", "Howard", "Ramos", "Kim", "Cox", "Ward", "Richardson",
|
||||
"Watson", "Brooks", "Chavez", "Wood", "James", "Bennett", "Gray", "Mendoza", "Ruiz", "Hughes",
|
||||
"Price", "Alvarez", "Castillo", "Sanders", "Patel", "Myers", "Long", "Ross", "Foster", "Jimenez"
|
||||
];
|
||||
|
||||
public static readonly string[] EuropeanFirstNames =
|
||||
[
|
||||
// British
|
||||
"Oliver", "George", "Harry", "Jack", "Charlie", "Thomas", "Oscar", "William", "James", "Henry",
|
||||
"Olivia", "Amelia", "Isla", "Ava", "Emily", "Sophie", "Grace", "Mia", "Poppy", "Ella",
|
||||
// German
|
||||
"Maximilian", "Alexander", "Paul", "Leon", "Lukas", "Felix", "Noah", "Elias", "Ben", "Finn",
|
||||
"Emma", "Hannah", "Mia", "Sofia", "Anna", "Emilia", "Lena", "Marie", "Lea", "Clara",
|
||||
// French
|
||||
"Gabriel", "Raphael", "Leo", "Louis", "Lucas", "Adam", "Hugo", "Jules", "Arthur", "Nathan",
|
||||
"Louise", "Alice", "Chloe", "Ines", "Lea", "Manon", "Rose", "Anna", "Lina", "Mila",
|
||||
// Spanish
|
||||
"Hugo", "Martin", "Lucas", "Daniel", "Pablo", "Alejandro", "Adrian", "Alvaro", "David", "Mario",
|
||||
"Lucia", "Sofia", "Maria", "Martina", "Paula", "Julia", "Daniela", "Valeria", "Alba", "Emma",
|
||||
// Italian
|
||||
"Leonardo", "Francesco", "Alessandro", "Lorenzo", "Mattia", "Andrea", "Gabriele", "Riccardo", "Tommaso", "Edoardo",
|
||||
"Sofia", "Giulia", "Aurora", "Alice", "Ginevra", "Emma", "Giorgia", "Greta", "Beatrice", "Anna"
|
||||
];
|
||||
|
||||
public static readonly string[] EuropeanLastNames =
|
||||
[
|
||||
// British
|
||||
"Smith", "Jones", "Williams", "Brown", "Taylor", "Davies", "Wilson", "Evans", "Thomas", "Johnson",
|
||||
"Roberts", "Walker", "Wright", "Robinson", "Thompson", "White", "Hughes", "Edwards", "Green", "Hall",
|
||||
// German
|
||||
"Mueller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner", "Becker", "Schulz", "Hoffmann",
|
||||
"Schaefer", "Koch", "Bauer", "Richter", "Klein", "Wolf", "Schroeder", "Neumann", "Schwarz", "Zimmermann",
|
||||
// French
|
||||
"Martin", "Bernard", "Dubois", "Thomas", "Robert", "Richard", "Petit", "Durand", "Leroy", "Moreau",
|
||||
"Simon", "Laurent", "Lefebvre", "Michel", "Garcia", "David", "Bertrand", "Roux", "Vincent", "Fournier",
|
||||
// Spanish
|
||||
"Garcia", "Rodriguez", "Martinez", "Lopez", "Sanchez", "Gonzalez", "Perez", "Martin", "Gomez", "Ruiz",
|
||||
"Hernandez", "Jimenez", "Diaz", "Moreno", "Alvarez", "Munoz", "Romero", "Alonso", "Gutierrez", "Navarro",
|
||||
// Italian
|
||||
"Rossi", "Russo", "Ferrari", "Esposito", "Bianchi", "Romano", "Colombo", "Ricci", "Marino", "Greco",
|
||||
"Bruno", "Gallo", "Conti", "DeLuca", "Costa", "Giordano", "Mancini", "Rizzo", "Lombardi", "Moretti"
|
||||
];
|
||||
|
||||
public static readonly string[] AllFirstNames = [.. UsFirstNames, .. EuropeanFirstNames];
|
||||
|
||||
public static readonly string[] AllLastNames = [.. UsLastNames, .. EuropeanLastNames];
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
67
util/Seeder/Data/Passwords.cs
Normal file
67
util/Seeder/Data/Passwords.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Password collections by strength level for realistic test data.
|
||||
/// </summary>
|
||||
internal static class Passwords
|
||||
{
|
||||
/// <summary>
|
||||
/// Top breached passwords - use for security testing scenarios.
|
||||
/// </summary>
|
||||
public static readonly string[] Weak =
|
||||
[
|
||||
"password", "123456", "qwerty", "abc123", "letmein", "welcome", "admin", "dragon", "sunshine", "princess",
|
||||
"football", "master", "shadow", "superman", "trustno1", "iloveyou", "passw0rd", "p@ssw0rd", "welcome1", "Password1",
|
||||
"qwerty123", "123qwe", "1q2w3e", "password123", "12345678", "111111", "1234567890", "monkey", "baseball", "access"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Meets basic complexity requirements but follows predictable patterns (season+year, name+numbers).
|
||||
/// </summary>
|
||||
public static readonly string[] Medium =
|
||||
[
|
||||
"Summer2024!", "Winter2023#", "Spring2024@", "Autumn2023$", "January2024!", "December2023#",
|
||||
"Welcome123!", "Company2024#", "Secure123!", "Access2024@", "Login123!", "Portal2024#",
|
||||
"Michael123!", "Jennifer2024@", "Robert456#", "Sarah789!",
|
||||
"Qwerty123!", "Asdfgh456@", "Zxcvbn789#",
|
||||
"Password123!", "Security2024@", "Admin123!", "User2024#", "Guest123!", "Test2024@",
|
||||
"Football123!", "Baseball2024@", "Soccer456#", "Hockey789!"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// High-entropy passwords: random strings (password manager style) and diceware passphrases.
|
||||
/// </summary>
|
||||
public static readonly string[] Strong =
|
||||
[
|
||||
"k#9Lm$vQ2@xR7nP!", "Yx8&mK3$pL5#wQ9@", "Nv4%jH7!bT2@sF6#", "Rm9#cX5$gW1@zK8!", "Qp3@hY6#nL9$tB2!",
|
||||
"Wz7!mF4@kS8#xC1$", "Jd2#pR9!vN5@bG7$", "Ht6@wL3#yK8!mQ4$", "Bf8$cM2@zT5#rX9!", "Lg1!nV7@sH4#pY6$",
|
||||
"Kx9#mL4$pQ7@wR2!vN5", "Yz3@hT8#bF1$cS6!nM9", "Wv5!rK2@jG9#tX4$mL7", "Qn7$sB3@yH6#pC1!zF8", "Tm2@xD5#kW9$vL4!rJ7",
|
||||
"correct-horse-battery-staple", "purple-monkey-dishwasher-lamp", "quantum-bicycle-elephant-storm",
|
||||
"velvet-thunder-crystal-forge", "neon-wizard-cosmic-river", "amber-phoenix-digital-maze",
|
||||
"silver-falcon-ancient-code", "lunar-garden-frozen-spark", "echo-prism-wandering-light", "rust-vapor-hidden-gate",
|
||||
"Brave.Tiger.Runs.Fast.42", "Blue.Ocean.Deep.Wave.17", "Swift.Eagle.Soars.High.93",
|
||||
"Calm.Forest.Green.Path.28", "Warm.Summer.Golden.Sun.61",
|
||||
"maple#stream#winter#glow", "ember@cloud@silent@peak", "frost$dawn$valley$mist", "coral!reef!azure!tide", "stone&moss&ancient&oak",
|
||||
"Kx9mL4pQ7wR2vN5hT8bF", "Yz3hT8bF1cS6nM9wK4pL", "Wv5rK2jG9tX4mL7nB3sH", "Qn7sB3yH6pC1zF8kW2xD", "Tm2xD5kW9vL4rJ7gN1cY"
|
||||
];
|
||||
|
||||
/// <remarks>Must be declared after strength arrays (S3263).</remarks>
|
||||
public static readonly string[] All = [.. Weak, .. Medium, .. Strong];
|
||||
|
||||
public static string[] GetByStrength(PasswordStrength strength) => strength switch
|
||||
{
|
||||
PasswordStrength.Weak => Weak,
|
||||
PasswordStrength.Medium => Medium,
|
||||
PasswordStrength.Strong => Strong,
|
||||
PasswordStrength.Mixed => All,
|
||||
_ => Strong
|
||||
};
|
||||
|
||||
public static string GetPassword(PasswordStrength strength, int index)
|
||||
{
|
||||
var passwords = GetByStrength(strength);
|
||||
return passwords[index % passwords.Length];
|
||||
}
|
||||
}
|
||||
144
util/Seeder/Data/README.md
Normal file
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)
|
||||
```
|
||||
65
util/Seeder/Data/UsernameGenerator.cs
Normal file
65
util/Seeder/Data/UsernameGenerator.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Bit.Seeder.Data.Enums;
|
||||
|
||||
namespace Bit.Seeder.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Generates deterministic usernames for companies using configurable patterns.
|
||||
/// </summary>
|
||||
internal sealed class UsernameGenerator
|
||||
{
|
||||
private readonly Random _random;
|
||||
|
||||
private readonly UsernamePattern _pattern;
|
||||
|
||||
private readonly string[] _firstNames;
|
||||
|
||||
private readonly string[] _lastNames;
|
||||
|
||||
public UsernameGenerator(
|
||||
int seed,
|
||||
UsernamePatternType patternType = UsernamePatternType.FirstDotLast,
|
||||
GeographicRegion? region = null)
|
||||
{
|
||||
_random = new Random(seed);
|
||||
_pattern = UsernamePatterns.GetPattern(patternType);
|
||||
|
||||
(_firstNames, _lastNames) = region switch
|
||||
{
|
||||
GeographicRegion.NorthAmerica => (Names.UsFirstNames, Names.UsLastNames),
|
||||
GeographicRegion.Europe => (Names.EuropeanFirstNames, Names.EuropeanLastNames),
|
||||
_ => (Names.AllFirstNames, Names.AllLastNames)
|
||||
};
|
||||
}
|
||||
|
||||
public string Generate(Company company)
|
||||
{
|
||||
var firstName = _firstNames[_random.Next(_firstNames.Length)];
|
||||
var lastName = _lastNames[_random.Next(_lastNames.Length)];
|
||||
return _pattern.Generate(firstName, lastName, company.Domain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates username using index for deterministic selection across cipher iterations.
|
||||
/// </summary>
|
||||
public string GenerateByIndex(Company company, int index)
|
||||
{
|
||||
var firstName = _firstNames[index % _firstNames.Length];
|
||||
var lastName = _lastNames[(index * 7) % _lastNames.Length]; // Prime multiplier for variety
|
||||
return _pattern.Generate(firstName, lastName, company.Domain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines deterministic index with random offset for controlled variety.
|
||||
/// </summary>
|
||||
public string GenerateVaried(Company company, int index)
|
||||
{
|
||||
var offset = _random.Next(10);
|
||||
var firstName = _firstNames[(index + offset) % _firstNames.Length];
|
||||
var lastName = _lastNames[(index * 7 + offset) % _lastNames.Length];
|
||||
return _pattern.Generate(firstName, lastName, company.Domain);
|
||||
}
|
||||
|
||||
public string GetFirstName(int index) => _firstNames[index % _firstNames.Length];
|
||||
|
||||
public string GetLastName(int index) => _lastNames[(index * 7) % _lastNames.Length];
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
158
util/Seeder/Factories/CipherSeeder.cs
Normal file
158
util/Seeder/Factories/CipherSeeder.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Creates encrypted ciphers for seeding vaults via the Rust SDK.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Supported cipher types:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Login - <see cref="CreateOrganizationLoginCipher"/></description></item>
|
||||
/// </list>
|
||||
/// Future: Card, Identity, SecureNote will follow the same pattern—public Create method + private Transform method.
|
||||
/// </remarks>
|
||||
public class CipherSeeder
|
||||
{
|
||||
private readonly RustSdkService _sdkService;
|
||||
|
||||
private static readonly JsonSerializerOptions SdkJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions ServerJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public CipherSeeder(RustSdkService sdkService)
|
||||
{
|
||||
_sdkService = sdkService;
|
||||
}
|
||||
|
||||
public Cipher CreateOrganizationLoginCipher(
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
string name,
|
||||
string? username = null,
|
||||
string? password = null,
|
||||
string? uri = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
Type = CipherTypes.Login,
|
||||
Login = new LoginViewDto
|
||||
{
|
||||
Username = username,
|
||||
Password = password,
|
||||
Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }]
|
||||
}
|
||||
};
|
||||
|
||||
return EncryptAndTransform(cipherView, orgKeyBase64, organizationId);
|
||||
}
|
||||
|
||||
public Cipher CreateOrganizationLoginCipherWithFields(
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
string name,
|
||||
string? username,
|
||||
string? password,
|
||||
string? uri,
|
||||
IEnumerable<(string name, string value, int type)> fields)
|
||||
{
|
||||
var cipherView = new CipherViewDto
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = name,
|
||||
Type = CipherTypes.Login,
|
||||
Login = new LoginViewDto
|
||||
{
|
||||
Username = username,
|
||||
Password = password,
|
||||
Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }]
|
||||
},
|
||||
Fields = fields.Select(f => new FieldViewDto
|
||||
{
|
||||
Name = f.name,
|
||||
Value = f.value,
|
||||
Type = f.type
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return EncryptAndTransform(cipherView, orgKeyBase64, organizationId);
|
||||
}
|
||||
|
||||
private Cipher EncryptAndTransform(CipherViewDto cipherView, string keyBase64, Guid organizationId)
|
||||
{
|
||||
var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions);
|
||||
var encryptedJson = _sdkService.EncryptCipher(viewJson, keyBase64);
|
||||
|
||||
if (encryptedJson.Contains("\"error\""))
|
||||
{
|
||||
throw new InvalidOperationException($"Cipher encryption failed: {encryptedJson}");
|
||||
}
|
||||
|
||||
var encryptedDto = JsonSerializer.Deserialize<EncryptedCipherDto>(encryptedJson, SdkJsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse encrypted cipher");
|
||||
|
||||
return TransformLoginToServerCipher(encryptedDto, organizationId);
|
||||
}
|
||||
|
||||
private static Cipher TransformLoginToServerCipher(EncryptedCipherDto encrypted, Guid organizationId)
|
||||
{
|
||||
var loginData = new CipherLoginData
|
||||
{
|
||||
Name = encrypted.Name,
|
||||
Notes = encrypted.Notes,
|
||||
Username = encrypted.Login?.Username,
|
||||
Password = encrypted.Login?.Password,
|
||||
Totp = encrypted.Login?.Totp,
|
||||
PasswordRevisionDate = encrypted.Login?.PasswordRevisionDate,
|
||||
Uris = encrypted.Login?.Uris?.Select(u => new CipherLoginData.CipherLoginUriData
|
||||
{
|
||||
Uri = u.Uri,
|
||||
UriChecksum = u.UriChecksum,
|
||||
Match = u.Match.HasValue ? (UriMatchType?)u.Match : null
|
||||
}),
|
||||
Fields = encrypted.Fields?.Select(f => new CipherFieldData
|
||||
{
|
||||
Name = f.Name,
|
||||
Value = f.Value,
|
||||
Type = (FieldType)f.Type,
|
||||
LinkedId = f.LinkedId
|
||||
})
|
||||
};
|
||||
|
||||
var dataJson = JsonSerializer.Serialize(loginData, ServerJsonOptions);
|
||||
|
||||
return new Cipher
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organizationId,
|
||||
UserId = null,
|
||||
Type = CipherType.Login,
|
||||
Data = dataJson,
|
||||
Key = encrypted.Key,
|
||||
Reprompt = (CipherRepromptType?)encrypted.Reprompt,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
|
||||
|
||||
namespace Bit.Seeder.Factories;
|
||||
|
||||
public class OrganizationSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an enterprise organization without encryption keys.
|
||||
/// Keys should be generated dynamically using RustSdkService.GenerateOrganizationKeys()
|
||||
/// and assigned to PublicKey/PrivateKey after creation.
|
||||
/// </summary>
|
||||
public static Organization CreateEnterprise(string name, string domain, int seats)
|
||||
{
|
||||
return new Organization
|
||||
@@ -39,52 +44,38 @@ public class OrganizationSeeder
|
||||
UseAdminSponsoredFamilies = true,
|
||||
SyncSeats = true,
|
||||
Status = OrganizationStatusType.Created,
|
||||
//GatewayCustomerId = "example-customer-id",
|
||||
//GatewaySubscriptionId = "example-subscription-id",
|
||||
MaxStorageGb = 10,
|
||||
// Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs.
|
||||
// TODO: These should be dynamically generated by the SDK.
|
||||
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB",
|
||||
PrivateKey = "2.6FggyKVyaKQsfohi5yqgbg==|UU2JeafOB41L5UscGmf4kq15JGDf3Bkf67KECiehTODzbWctVLTgyDk0Qco8/6CMN6nZGXjxR2A4r5ExhmwRNsNxd77G+MprkmiJz+7w33ROZ1ouQO5XjD3wbQ3ssqNiTKId6yAUPBvuAZRixVApauTuADc8QWGixqCQcqZzmU7YSBBIPf652/AEYr4Tk64YihoE39pHiK8MRbTLdRt3EF4LSMugPAPM24vCgUv3w1TD3Fj6sDg/6oi3flOV9SJZX4vCiUXbDNEuD/p2aQrEXVbaxweFOHjTe7F4iawjXw3nG3SO8rUBHcxbhDDVx5rjYactbW5QvHWiyla6uLb6o8WHBneg2EjTEwAHOZE/rBjcqmAJb2sVp1E0Kwq8ycGmL69vmqJPC1GqVTohAQvmEkaxIPpfq24Yb9ZPrADA7iEXBKuAQ1FphFUVgJBJGJbd60sOV1Rz1T+gUwS4wCNQ4l3LG1S22+wzUVlEku5DXFnT932tatqTyWEthqPqLCt6dL1+qa94XLpeHagXAx2VGe8n8IlcADtxqS+l8xQ4heT12WO9kC316vqvg1mnsI56faup9hb3eT9ZpKyxSBGYOphlTWfV1Y/v64f5PYvTo4aL0IYHyLY/9Qi72vFmOpPeHBYgD5t3j+H2CsiU1PkYsBggOmD7xW8FDuT6HWVvwhEJqeibVPK0Lhyj6tgvlSIAvFUaSMFPlmwFNmwfj/AHUhr9KuTfsBFTZ10yy9TZVgf+EofwnrxHBaWUgdD40aHoY1VjfG33iEuajb6buxG3pYFyPNhJNzeLZisUKIDRMQpUHrsE22EyrFFran3tZGdtcyIEK4Q1F0ULYzJ6T9iY25/ZgPy3pEAAMZCtqo3s+GjX295fWIHfMcnjMgNUHPjExjWBHa+ggK9iQXkFpBVyYB1ga/+0eiIhiek3PlgtvpDrqF7TsLK+ROiBw2GJ7uaO3EEXOj2GpNBuEJ5CdodhZkwzhwMcSatgDHkUuNVu0iVbF6/MxVdOxWXKO+jCYM6PZk/vAhLYqpPzu2T2Uyz4nkDs2Tiq61ez6FoCrzdHIiyIxVTzUQH8G9FgSmtaZ7GCbqlhnurYgcMciwPzxg0hpAQT+NZw1tVEii9vFSpJJbGJqNhORKfKh/Mu1P/9LOQq7Y0P2FIR3x/eUVEQ7CGv2jVtO5ryGSmKeq/P9Fr54wTPaNiqN2K+leACUznCdUWw8kZo/AsBcrOe4OkRX6k8LC3oeJXy06DEToatxEvPYemUauhxiXRw8nfNMqc4LyJq2bbT0zCgJHoqpozPdNg6AYWcoIobgAGu7ZQGq+oE1MT3GZxotMPe/NUJiAc5YE9Thb5Yf3gyno71pyqPTVl/6IQuh4SUz7rkgwF/aVHEnr4aUYNoc0PEzd2Me0jElsA3GAneq1I/wngutOWgTViTK4Nptr5uIzMVQs9H1rOMJNorP8b02t1NDu010rSsib9GaaJJq4r4iy46laQOxWoU0ex26arYnk+jw4833WSCTVBIprTgizZ+fKjoY0xwXvI2oOvGNEUCtGFvKFORTaQrlaXZIg1toa2BBVNicyONbwnI3KIu3MgGJ2SlCVXJn8oHFppVHFCdwgN1uDzGiKAhjvr0sZTUtXin2f2CszPTbbo=|fUhbVKrr8CSKE7TZJneXpDGraj5YhRrq9ESo206S+BY=",
|
||||
// PublicKey and PrivateKey intentionally not set - caller must generate and assign
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class OrgnaizationExtensions
|
||||
public static class OrganizationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an OrganizationUser with fields populated based on status.
|
||||
/// For Invited status, only user.Email is used. For other statuses, user.Id is used.
|
||||
/// Creates an OrganizationUser with a dynamically provided encrypted org key.
|
||||
/// The encryptedOrgKey should be generated using sdkService.GenerateUserOrganizationKey().
|
||||
/// </summary>
|
||||
public static OrganizationUser CreateOrganizationUser(
|
||||
this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status)
|
||||
public static OrganizationUser CreateOrganizationUserWithKey(
|
||||
this Organization organization,
|
||||
User user,
|
||||
OrganizationUserType type,
|
||||
OrganizationUserStatusType status,
|
||||
string? encryptedOrgKey)
|
||||
{
|
||||
var isInvited = status == OrganizationUserStatusType.Invited;
|
||||
var isConfirmed = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked;
|
||||
var shouldLinkUserId = status != OrganizationUserStatusType.Invited;
|
||||
var shouldIncludeKey = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked;
|
||||
|
||||
return new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
UserId = isInvited ? null : user.Id,
|
||||
Email = isInvited ? user.Email : null,
|
||||
Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null,
|
||||
UserId = shouldLinkUserId ? user.Id : null,
|
||||
Email = shouldLinkUserId ? null : user.Email,
|
||||
Key = shouldIncludeKey ? encryptedOrgKey : null,
|
||||
Type = type,
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
public static OrganizationUser CreateSdkOrganizationUser(this Organization organization, User user)
|
||||
{
|
||||
return new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
|
||||
Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==",
|
||||
Type = OrganizationUserType.Admin,
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
|
||||
public User CreateUser(string email, bool emailVerified = false, bool premium = false)
|
||||
{
|
||||
email = MangleEmail(email);
|
||||
var keys = sdkService.GenerateUserKeys(email, "asdfasdfasdf");
|
||||
var keys = sdkService.GenerateUserKeys(email, DefaultPassword);
|
||||
|
||||
var user = new User
|
||||
{
|
||||
@@ -43,7 +43,6 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
|
||||
PrivateKey = keys.PrivateKey,
|
||||
Premium = premium,
|
||||
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
|
||||
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 5_000,
|
||||
};
|
||||
@@ -53,22 +52,41 @@ public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Enti
|
||||
return user;
|
||||
}
|
||||
|
||||
public static User CreateUserNoMangle(string email)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = email,
|
||||
MasterPassword = "AQAAAAIAAYagAAAAEBATmF66OHMpHuHKc1CsGZQ1ltHUHyhYK+7e4re3bVFi16SOpLpDfzdFswnvFQs2Rg==",
|
||||
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
|
||||
Key = "2.z/eLKFhd62qy9RzXu3UHgA==|fF6yNupiCIguFKSDTB3DoqcGR0Xu4j+9VlnMyT5F3PaWIcGhzQKIzxdB95nhslaCQv3c63M7LBnvzVo1J9SUN85RMbP/57bP1HvhhU1nvL8=|IQPtf8v7k83MFZEhazSYXSdu98BBU5rqtvC4keVWyHM=",
|
||||
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Ww2chogqCpaAR7Uw448am4b7vDFXiM5kXjFlGfXBlrAdAqTTggEvTDlMNYqPlCo+mBM6iFmTTUY9rpZBvFskMnKvsvpJ47/fehAH2o2e3Ulv/5NFevaVCMCmpkBDtbMbO1A4a3btdRtCP8DsKWMefHauEpaoLxNTLWnOIZVfCMjsSgx2EvULHAZPTtbFwm4+UVKniM4ds4jvOsD85h4jn2aLs/jWJXFfxN8iVSqEqpC2TBvsPdyHb49xQoWWfF0Z6BiNqeNGKEU9Uos1pjL+kzhEzzSpH31PZT/ufJ/oo4+93wrUt57hb6f0jxiXhwd5yQ+9F6wVwpbfkq0IwhjOwIDAQAB",
|
||||
PrivateKey = "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=",
|
||||
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
|
||||
/// <summary>
|
||||
/// Default test password used for all seeded users.
|
||||
/// </summary>
|
||||
public const string DefaultPassword = "asdfasdfasdf";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user with SDK-generated cryptographic keys (no email mangling).
|
||||
/// The user can log in with email and password = "asdfasdfasdf".
|
||||
/// </summary>
|
||||
public static User CreateUserWithSdkKeys(
|
||||
string email,
|
||||
RustSdkService sdkService,
|
||||
IPasswordHasher<User> passwordHasher)
|
||||
{
|
||||
var keys = sdkService.GenerateUserKeys(email, DefaultPassword);
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
Email = email,
|
||||
EmailVerified = true,
|
||||
MasterPassword = null,
|
||||
SecurityStamp = Guid.NewGuid().ToString(),
|
||||
Key = keys.EncryptedUserKey,
|
||||
PublicKey = keys.PublicKey,
|
||||
PrivateKey = keys.PrivateKey,
|
||||
Premium = false,
|
||||
ApiKey = Guid.NewGuid().ToString("N")[..30],
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 600_000,
|
||||
KdfIterations = 5_000,
|
||||
};
|
||||
|
||||
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public Dictionary<string, string?> GetMangleMap(User user, UserData expectedUserData)
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -1,18 +1,106 @@
|
||||
# Bitwarden Database Seeder
|
||||
|
||||
A class library for generating and inserting test data.
|
||||
A class library for generating and inserting properly encrypted test data into Bitwarden databases.
|
||||
|
||||
## Domain Taxonomy
|
||||
|
||||
### Cipher Encryption States
|
||||
|
||||
| Term | Description | Stored in DB? |
|
||||
|------|-------------|---------------|
|
||||
| **CipherView** | Plaintext/decrypted form. Human-readable data. | Never |
|
||||
| **Cipher** | Encrypted form. All sensitive fields are EncStrings. | Yes |
|
||||
|
||||
The "View" suffix always denotes plaintext. No suffix means encrypted.
|
||||
|
||||
### EncString Format
|
||||
|
||||
Encrypted strings follow this format:
|
||||
```
|
||||
2.{iv}|{ciphertext}|{mac}
|
||||
```
|
||||
- **2** = Algorithm type (AES-256-CBC-HMAC-SHA256)
|
||||
- **iv** = Initialization vector (base64)
|
||||
- **ciphertext** = Encrypted data (base64)
|
||||
- **mac** = Message authentication code (base64)
|
||||
|
||||
### Data Structure Differences
|
||||
|
||||
**SDK Structure (nested):**
|
||||
```json
|
||||
{ "name": "2.x...", "login": { "username": "2.y...", "password": "2.z..." } }
|
||||
```
|
||||
|
||||
**Server Structure (flat, stored in Cipher.Data):**
|
||||
```json
|
||||
{ "Name": "2.x...", "Username": "2.y...", "Password": "2.z..." }
|
||||
```
|
||||
|
||||
The seeder transforms SDK output to server format before database insertion.
|
||||
|
||||
### Key Hierarchy
|
||||
|
||||
```
|
||||
Organization Key (or User Key)
|
||||
│
|
||||
├──▶ Encrypts Cipher.Key (optional per-cipher key)
|
||||
│
|
||||
└──▶ Encrypts cipher fields directly (if no per-cipher key)
|
||||
```
|
||||
|
||||
For seeding, we encrypt directly with the organization key.
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
```
|
||||
Organization
|
||||
│
|
||||
├── Collections ──┬── CollectionCipher ──┐
|
||||
│ │ │
|
||||
└── Ciphers ──────┴──────────────────────┘
|
||||
```
|
||||
|
||||
Ciphers belong to organizations and are assigned to collections via the `CollectionCipher` join table.
|
||||
|
||||
## Project Structure
|
||||
|
||||
The project is organized into these main components:
|
||||
|
||||
### Factories
|
||||
|
||||
Factories are helper classes for creating domain entities and populating them with realistic data. This assist in
|
||||
decreasing the amount of boilerplate code needed to create test data in recipes.
|
||||
Create individual domain entities with realistic encrypted data.
|
||||
|
||||
| Factory | Purpose |
|
||||
|---------|---------|
|
||||
| `CipherSeeder` | Creates encrypted Cipher entities via Rust SDK |
|
||||
| `CollectionSeeder` | Creates collections with encrypted names |
|
||||
| `OrganizationSeeder` | Creates organizations with keys |
|
||||
| `UserSeeder` | Creates users with encrypted credentials |
|
||||
|
||||
### Recipes
|
||||
|
||||
Recipes are pre-defined data sets which can be run to generate and load data into the database. They often allow a allow
|
||||
for a few arguments to customize the data slightly. Recipes should be kept simple and focused on a single task. Default
|
||||
to creating more recipes rather than adding complexity to existing ones.
|
||||
Bulk data operations using BulkCopy for performance.
|
||||
|
||||
| Recipe | Purpose |
|
||||
|--------|---------|
|
||||
| `CiphersRecipe` | Bulk create ciphers and assign to collections |
|
||||
| `CollectionsRecipe` | Create collections with user permissions |
|
||||
| `GroupsRecipe` | Create groups with collection access |
|
||||
| `OrganizationWithUsersRecipe` | Full org setup with users |
|
||||
|
||||
### Models
|
||||
|
||||
DTOs for SDK interop and data transformation.
|
||||
|
||||
| Model | Purpose |
|
||||
|-------|---------|
|
||||
| `CipherViewDto` | Plaintext input to SDK encryption |
|
||||
| `EncryptedCipherDto` | Parses SDK's encrypted output |
|
||||
|
||||
## Rust SDK Integration
|
||||
|
||||
The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption:
|
||||
|
||||
```
|
||||
CipherViewDto → RustSdkService.EncryptCipher() → EncryptedCipherDto → Server Format
|
||||
```
|
||||
|
||||
This ensures seeded data can be decrypted and displayed in the actual Bitwarden clients.
|
||||
|
||||
124
util/Seeder/Recipes/CiphersRecipe.cs
Normal file
124
util/Seeder/Recipes/CiphersRecipe.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Data;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Factories;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
|
||||
namespace Bit.Seeder.Recipes;
|
||||
|
||||
/// <summary>
|
||||
/// Creates encrypted ciphers for seeding organization vaults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Currently supports:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Login ciphers</description></item>
|
||||
/// </list>
|
||||
/// TODO: Add support for Card, Identity, and SecureNote cipher types.
|
||||
/// </remarks>
|
||||
public class CiphersRecipe(DatabaseContext db, RustSdkService sdkService)
|
||||
{
|
||||
private readonly CipherSeeder _cipherSeeder = new(sdkService);
|
||||
|
||||
public List<Guid> AddLoginCiphersToOrganization(
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
List<Guid> collectionIds,
|
||||
int? count = null,
|
||||
bool useEnterpriseUrls = false)
|
||||
{
|
||||
// Delegate to the new system - Enterprise filter for enterprise URLs, Consumer for popular
|
||||
var companyType = useEnterpriseUrls ? CompanyType.Enterprise : CompanyType.Consumer;
|
||||
return AddLoginCiphersToOrganization(
|
||||
organizationId,
|
||||
orgKeyBase64,
|
||||
collectionIds,
|
||||
count,
|
||||
companyType,
|
||||
region: null,
|
||||
UsernamePatternType.FLast,
|
||||
PasswordStrength.Weak);
|
||||
}
|
||||
|
||||
public List<Guid> AddLoginCiphersToOrganization(
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
List<Guid> collectionIds,
|
||||
int? count,
|
||||
CompanyType? companyType,
|
||||
GeographicRegion? region,
|
||||
UsernamePatternType usernamePattern = UsernamePatternType.FirstDotLast,
|
||||
PasswordStrength passwordStrength = PasswordStrength.Strong)
|
||||
{
|
||||
var companies = Companies.Filter(companyType, region);
|
||||
if (companies.Length == 0)
|
||||
{
|
||||
companies = Companies.All;
|
||||
}
|
||||
|
||||
var passwords = Passwords.GetByStrength(passwordStrength);
|
||||
var cipherCount = count ?? companies.Length;
|
||||
var usernameGenerator = new UsernameGenerator(organizationId.GetHashCode(), usernamePattern, region);
|
||||
|
||||
var ciphers = Enumerable.Range(0, cipherCount)
|
||||
.Select(i =>
|
||||
{
|
||||
var company = companies[i % companies.Length];
|
||||
return _cipherSeeder.CreateOrganizationLoginCipher(
|
||||
organizationId,
|
||||
orgKeyBase64,
|
||||
name: $"{company.Name} ({company.Category})",
|
||||
username: usernameGenerator.GenerateVaried(company, i),
|
||||
password: passwords[i % passwords.Length],
|
||||
uri: $"https://{company.Domain}");
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return SaveCiphersWithCollections(ciphers, collectionIds);
|
||||
}
|
||||
|
||||
private List<Guid> SaveCiphersWithCollections(List<Cipher> ciphers, List<Guid> collectionIds)
|
||||
{
|
||||
if (ciphers.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
db.BulkCopy(ciphers);
|
||||
|
||||
if (collectionIds.Count > 0)
|
||||
{
|
||||
var collectionCiphers = ciphers.SelectMany((cipher, i) =>
|
||||
{
|
||||
var primary = new CollectionCipher
|
||||
{
|
||||
CipherId = cipher.Id,
|
||||
CollectionId = collectionIds[i % collectionIds.Count]
|
||||
};
|
||||
|
||||
// Every 3rd cipher gets assigned to an additional collection
|
||||
if (i % 3 == 0 && collectionIds.Count > 1)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
primary,
|
||||
new CollectionCipher
|
||||
{
|
||||
CipherId = cipher.Id,
|
||||
CollectionId = collectionIds[(i + 1) % collectionIds.Count]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return [primary];
|
||||
}).ToList();
|
||||
|
||||
db.BulkCopy(collectionCiphers);
|
||||
}
|
||||
|
||||
return ciphers.Select(c => c.Id).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,56 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Data;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Factories;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
|
||||
namespace Bit.Seeder.Recipes;
|
||||
|
||||
public class CollectionsRecipe(DatabaseContext db)
|
||||
/// <summary>
|
||||
/// Creates collections for seeding organization vaults.
|
||||
/// </summary>
|
||||
public class CollectionsRecipe(DatabaseContext db, RustSdkService sdkService)
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds collections to an organization and creates relationships between users and collections.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The ID of the organization to add collections to.</param>
|
||||
/// <param name="collections">The number of collections to add.</param>
|
||||
/// <param name="organizationUserIds">The IDs of the users to create relationships with.</param>
|
||||
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
|
||||
public List<Guid> AddToOrganization(Guid organizationId, int collections, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
|
||||
{
|
||||
var collectionList = CreateAndSaveCollections(organizationId, collections);
|
||||
private readonly CollectionSeeder _collectionSeeder = new(sdkService);
|
||||
|
||||
if (collectionList.Any())
|
||||
/// <summary>
|
||||
/// Creates collections from an organizational structure (e.g., Traditional departments, Spotify tribes).
|
||||
/// Collection names are properly encrypted.
|
||||
/// </summary>
|
||||
public List<Guid> AddFromStructure(
|
||||
Guid organizationId,
|
||||
string orgKeyBase64,
|
||||
OrgStructureModel model,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships = 1000)
|
||||
{
|
||||
var structure = OrgStructures.GetStructure(model);
|
||||
|
||||
var collections = structure.Units
|
||||
.Select(unit => _collectionSeeder.CreateCollection(organizationId, orgKeyBase64, unit.Name))
|
||||
.ToList();
|
||||
|
||||
db.BulkCopy(collections);
|
||||
|
||||
if (collections.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
|
||||
{
|
||||
CreateAndSaveCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships);
|
||||
var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships);
|
||||
db.BulkCopy(collectionUsers);
|
||||
}
|
||||
|
||||
return collectionList.Select(c => c.Id).ToList();
|
||||
return collections.Select(c => c.Id).ToList();
|
||||
}
|
||||
|
||||
private List<Core.Entities.Collection> CreateAndSaveCollections(Guid organizationId, int count)
|
||||
/// <summary>
|
||||
/// Adds generic numbered collections (unencrypted names - use AddFromStructure for realistic data).
|
||||
/// </summary>
|
||||
public List<Guid> AddToOrganization(Guid organizationId, int collections, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
|
||||
{
|
||||
var collectionList = new List<Core.Entities.Collection>();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
collectionList.Add(new Core.Entities.Collection
|
||||
var collectionList = Enumerable.Range(0, collections)
|
||||
.Select(i => new Core.Entities.Collection
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organizationId,
|
||||
@@ -40,83 +58,44 @@ public class CollectionsRecipe(DatabaseContext db)
|
||||
Type = CollectionType.SharedCollection,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (collectionList.Any())
|
||||
{
|
||||
db.BulkCopy(collectionList);
|
||||
}
|
||||
|
||||
return collectionList;
|
||||
}
|
||||
|
||||
private void CreateAndSaveCollectionUserRelationships(
|
||||
List<Core.Entities.Collection> collections,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships)
|
||||
{
|
||||
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships);
|
||||
|
||||
if (collectionUsers.Any())
|
||||
db.BulkCopy(collectionList);
|
||||
|
||||
if (collectionList.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
|
||||
{
|
||||
var collectionUsers = BuildCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships);
|
||||
db.BulkCopy(collectionUsers);
|
||||
}
|
||||
|
||||
return collectionList.Select(c => c.Id).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates user-to-collection relationships with varied assignment patterns for realistic test data.
|
||||
/// Each user gets 1-3 collections based on a rotating pattern.
|
||||
/// Creates user-to-collection relationships with varied assignment patterns.
|
||||
/// Each user gets 1-3 collections (cycling). First collection has Manage rights.
|
||||
/// </summary>
|
||||
private List<Core.Entities.CollectionUser> BuildCollectionUserRelationships(
|
||||
private static List<Core.Entities.CollectionUser> BuildCollectionUserRelationships(
|
||||
List<Core.Entities.Collection> collections,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships)
|
||||
{
|
||||
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
|
||||
var collectionUsers = new List<Core.Entities.CollectionUser>();
|
||||
|
||||
for (var i = 0; i < maxRelationships; i++)
|
||||
{
|
||||
var orgUserId = organizationUserIds[i];
|
||||
var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i);
|
||||
collectionUsers.AddRange(userCollectionAssignments);
|
||||
}
|
||||
|
||||
return collectionUsers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns collections to a user with varying permissions.
|
||||
/// Pattern: 1-3 collections per user (cycles: 1, 2, 3, 1, 2, 3...).
|
||||
/// First collection has Manage rights, subsequent ones are ReadOnly.
|
||||
/// </summary>
|
||||
private List<Core.Entities.CollectionUser> CreateCollectionAssignmentsForUser(
|
||||
List<Core.Entities.Collection> collections,
|
||||
Guid organizationUserId,
|
||||
int userIndex)
|
||||
{
|
||||
var assignments = new List<Core.Entities.CollectionUser>();
|
||||
var userCollectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 collections
|
||||
|
||||
for (var j = 0; j < userCollectionCount; j++)
|
||||
{
|
||||
var collectionIndex = (userIndex + j) % collections.Count; // Distribute across available collections
|
||||
assignments.Add(new Core.Entities.CollectionUser
|
||||
return organizationUserIds
|
||||
.Take(maxUsersWithRelationships)
|
||||
.SelectMany((orgUserId, userIndex) =>
|
||||
{
|
||||
CollectionId = collections[collectionIndex].Id,
|
||||
OrganizationUserId = organizationUserId,
|
||||
ReadOnly = j > 0, // First assignment gets write access
|
||||
HidePasswords = false,
|
||||
Manage = j == 0 // First assignment gets manage permissions
|
||||
});
|
||||
}
|
||||
|
||||
return assignments;
|
||||
var collectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3
|
||||
return Enumerable.Range(0, collectionCount)
|
||||
.Select(j => new Core.Entities.CollectionUser
|
||||
{
|
||||
CollectionId = collections[(userIndex + j) % collections.Count].Id,
|
||||
OrganizationUserId = orgUserId,
|
||||
ReadOnly = j > 0,
|
||||
HidePasswords = false,
|
||||
Manage = j == 0
|
||||
});
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,80 +15,30 @@ public class GroupsRecipe(DatabaseContext db)
|
||||
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
|
||||
public List<Guid> AddToOrganization(Guid organizationId, int groups, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
|
||||
{
|
||||
var groupList = CreateAndSaveGroups(organizationId, groups);
|
||||
|
||||
if (groupList.Any())
|
||||
{
|
||||
CreateAndSaveGroupUserRelationships(groupList, organizationUserIds, maxUsersWithRelationships);
|
||||
}
|
||||
|
||||
return groupList.Select(g => g.Id).ToList();
|
||||
}
|
||||
|
||||
private List<Core.AdminConsole.Entities.Group> CreateAndSaveGroups(Guid organizationId, int count)
|
||||
{
|
||||
var groupList = new List<Core.AdminConsole.Entities.Group>();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
groupList.Add(new Core.AdminConsole.Entities.Group
|
||||
var groupList = Enumerable.Range(0, groups)
|
||||
.Select(i => new Core.AdminConsole.Entities.Group
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organizationId,
|
||||
Name = $"Group {i + 1}"
|
||||
});
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (groupList.Any())
|
||||
{
|
||||
db.BulkCopy(groupList);
|
||||
}
|
||||
|
||||
return groupList;
|
||||
}
|
||||
|
||||
private void CreateAndSaveGroupUserRelationships(
|
||||
List<Core.AdminConsole.Entities.Group> groups,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships)
|
||||
{
|
||||
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships);
|
||||
|
||||
if (groupUsers.Any())
|
||||
db.BulkCopy(groupList);
|
||||
|
||||
if (groupList.Count > 0 && organizationUserIds.Count > 0 && maxUsersWithRelationships > 0)
|
||||
{
|
||||
var groupUsers = organizationUserIds
|
||||
.Take(maxUsersWithRelationships)
|
||||
.Select((orgUserId, i) => new Core.AdminConsole.Entities.GroupUser
|
||||
{
|
||||
GroupId = groupList[i % groupList.Count].Id,
|
||||
OrganizationUserId = orgUserId
|
||||
})
|
||||
.ToList();
|
||||
db.BulkCopy(groupUsers);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates user-to-group relationships with distributed assignment patterns for realistic test data.
|
||||
/// Each user is assigned to one group, distributed evenly across available groups.
|
||||
/// </summary>
|
||||
private List<Core.AdminConsole.Entities.GroupUser> BuildGroupUserRelationships(
|
||||
List<Core.AdminConsole.Entities.Group> groups,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships)
|
||||
{
|
||||
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
|
||||
var groupUsers = new List<Core.AdminConsole.Entities.GroupUser>();
|
||||
|
||||
for (var i = 0; i < maxRelationships; i++)
|
||||
{
|
||||
var orgUserId = organizationUserIds[i];
|
||||
var groupIndex = i % groups.Count; // Round-robin distribution across groups
|
||||
|
||||
groupUsers.Add(new Core.AdminConsole.Entities.GroupUser
|
||||
{
|
||||
GroupId = groups[groupIndex].Id,
|
||||
OrganizationUserId = orgUserId
|
||||
});
|
||||
}
|
||||
|
||||
return groupUsers;
|
||||
return groupList.Select(g => g.Id).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,138 @@
|
||||
using Bit.Core.Entities;
|
||||
using AutoMapper;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.RustSDK;
|
||||
using Bit.Seeder.Data.Enums;
|
||||
using Bit.Seeder.Factories;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EfOrganization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization;
|
||||
using EfOrganizationUser = Bit.Infrastructure.EntityFramework.Models.OrganizationUser;
|
||||
using EfUser = Bit.Infrastructure.EntityFramework.Models.User;
|
||||
|
||||
namespace Bit.Seeder.Recipes;
|
||||
|
||||
public class OrganizationWithUsersRecipe(DatabaseContext db)
|
||||
public class OrganizationWithUsersRecipe(
|
||||
DatabaseContext db,
|
||||
IMapper mapper,
|
||||
RustSdkService sdkService,
|
||||
IPasswordHasher<User> passwordHasher)
|
||||
{
|
||||
public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed)
|
||||
public static Guid SeedFromServices(
|
||||
IServiceProvider services,
|
||||
string name,
|
||||
string domain,
|
||||
int users,
|
||||
int ciphers = 0,
|
||||
OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed,
|
||||
OrgStructureModel? structureModel = null)
|
||||
{
|
||||
var db = services.GetRequiredService<DatabaseContext>();
|
||||
var mapper = services.GetRequiredService<IMapper>();
|
||||
var sdkService = services.GetRequiredService<RustSdkService>();
|
||||
var passwordHasher = services.GetRequiredService<IPasswordHasher<User>>();
|
||||
|
||||
var recipe = new OrganizationWithUsersRecipe(db, mapper, sdkService, passwordHasher);
|
||||
return recipe.Seed(name, domain, users, ciphers, usersStatus, structureModel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an organization with users and optionally encrypted ciphers.
|
||||
/// Users can log in with their email and password "asdfasdfasdf".
|
||||
/// Organization and user keys are generated dynamically for each run.
|
||||
/// </summary>
|
||||
/// <param name="structureModel">Optional org structure for realistic collection names (e.g., Traditional departments, Spotify tribes).</param>
|
||||
public Guid Seed(
|
||||
string name,
|
||||
string domain,
|
||||
int users,
|
||||
int ciphers = 0,
|
||||
OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed,
|
||||
OrgStructureModel? structureModel = null)
|
||||
{
|
||||
var seats = Math.Max(users + 1, 1000);
|
||||
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
|
||||
var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}");
|
||||
var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed);
|
||||
var orgKeys = sdkService.GenerateOrganizationKeys();
|
||||
|
||||
var additionalUsers = new List<User>();
|
||||
var additionalOrgUsers = new List<OrganizationUser>();
|
||||
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
|
||||
organization.PublicKey = orgKeys.PublicKey;
|
||||
organization.PrivateKey = orgKeys.PrivateKey;
|
||||
|
||||
var ownerUser = UserSeeder.CreateUserWithSdkKeys($"owner@{domain}", sdkService, passwordHasher);
|
||||
|
||||
var ownerOrgKey = sdkService.GenerateUserOrganizationKey(ownerUser.PublicKey!, orgKeys.Key);
|
||||
var ownerOrgUser = organization.CreateOrganizationUserWithKey(
|
||||
ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed, ownerOrgKey);
|
||||
|
||||
var memberUsers = new List<User>();
|
||||
var memberOrgUsers = new List<OrganizationUser>();
|
||||
for (var i = 0; i < users; i++)
|
||||
{
|
||||
var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}");
|
||||
additionalUsers.Add(additionalUser);
|
||||
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus));
|
||||
var memberUser = UserSeeder.CreateUserWithSdkKeys($"user{i}@{domain}", sdkService, passwordHasher);
|
||||
memberUsers.Add(memberUser);
|
||||
|
||||
var memberOrgKey = (usersStatus == OrganizationUserStatusType.Confirmed ||
|
||||
usersStatus == OrganizationUserStatusType.Revoked)
|
||||
? sdkService.GenerateUserOrganizationKey(memberUser.PublicKey!, orgKeys.Key)
|
||||
: null;
|
||||
|
||||
memberOrgUsers.Add(organization.CreateOrganizationUserWithKey(
|
||||
memberUser, OrganizationUserType.User, usersStatus, memberOrgKey));
|
||||
}
|
||||
|
||||
db.Add(organization);
|
||||
db.Add(ownerUser);
|
||||
db.Add(ownerOrgUser);
|
||||
db.Add(mapper.Map<EfOrganization>(organization));
|
||||
db.Add(mapper.Map<EfUser>(ownerUser));
|
||||
db.Add(mapper.Map<EfOrganizationUser>(ownerOrgUser));
|
||||
|
||||
// BulkCopy for performance with large user counts
|
||||
var efMemberUsers = memberUsers.Select(u => mapper.Map<EfUser>(u)).ToList();
|
||||
var efMemberOrgUsers = memberOrgUsers.Select(ou => mapper.Map<EfOrganizationUser>(ou)).ToList();
|
||||
db.BulkCopy(efMemberUsers);
|
||||
db.BulkCopy(efMemberOrgUsers);
|
||||
|
||||
db.SaveChanges();
|
||||
|
||||
// Use LinqToDB's BulkCopy for significant better performance
|
||||
db.BulkCopy(additionalUsers);
|
||||
db.BulkCopy(additionalOrgUsers);
|
||||
// Create collections - either from org structure or single default
|
||||
var allOrgUserIds = memberOrgUsers
|
||||
.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed)
|
||||
.Select(ou => ou.Id)
|
||||
.Prepend(ownerOrgUser.Id)
|
||||
.ToList();
|
||||
|
||||
List<Guid> collectionIds;
|
||||
if (structureModel.HasValue)
|
||||
{
|
||||
var collectionsRecipe = new CollectionsRecipe(db, sdkService);
|
||||
collectionIds = collectionsRecipe.AddFromStructure(
|
||||
organization.Id,
|
||||
orgKeys.Key,
|
||||
structureModel.Value,
|
||||
allOrgUserIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
var defaultCollection = new CollectionSeeder(sdkService)
|
||||
.CreateCollection(organization.Id, orgKeys.Key, "Default Collection");
|
||||
db.BulkCopy(new[] { defaultCollection });
|
||||
|
||||
var collectionUsers = allOrgUserIds
|
||||
.Select((id, i) => CollectionSeeder.CreateCollectionUser(defaultCollection.Id, id, manage: i == 0))
|
||||
.ToList();
|
||||
db.BulkCopy(collectionUsers);
|
||||
|
||||
collectionIds = [defaultCollection.Id];
|
||||
}
|
||||
|
||||
if (ciphers > 0)
|
||||
{
|
||||
var cipherRecipe = new CiphersRecipe(db, sdkService);
|
||||
cipherRecipe.AddLoginCiphersToOrganization(
|
||||
organization.Id,
|
||||
orgKeys.Key,
|
||||
collectionIds,
|
||||
count: ciphers);
|
||||
}
|
||||
|
||||
return organization.Id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user