diff --git a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs index efd1452eb7e3..c77efa52ff19 100644 --- a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs +++ b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; using Bit.RustSDK; using Bit.Seeder.Attributes; @@ -158,14 +159,20 @@ public void CipherSeeder_ProducesServerCompatibleFormat() var orgKeys = RustSdkService.GenerateOrganizationKeys(); var orgId = Guid.NewGuid(); - var cipher = LoginCipherSeeder.Create( - orgKeys.Key, - name: "GitHub Account", - organizationId: orgId, - username: "developer@example.com", - password: "SecureP@ss123!", - uri: "https://github.com", - notes: "My development account"); + var cipher = LoginCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Login, + Name = "GitHub Account", + EncryptionKey = orgKeys.Key, + OrganizationId = orgId, + Notes = "My development account", + Login = new LoginViewDto + { + Username = "developer@example.com", + Password = "SecureP@ss123!", + Uris = [new LoginUriViewDto { Uri = "https://github.com" }] + } + }); Assert.Equal(orgId, cipher.OrganizationId); Assert.Null(cipher.UserId); @@ -195,17 +202,24 @@ public void CipherSeeder_WithFields_ProducesCorrectServerFormat() { var orgKeys = RustSdkService.GenerateOrganizationKeys(); - var cipher = LoginCipherSeeder.Create( - orgKeys.Key, - name: "API Service", - organizationId: Guid.NewGuid(), - username: "service@example.com", - password: "SvcP@ss!", - uri: "https://api.example.com", - fields: [ - ("API Key", "sk_test_FAKE_abc123", 1), - ("Environment", "production", 0) - ]); + var cipher = LoginCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Login, + Name = "API Service", + EncryptionKey = orgKeys.Key, + OrganizationId = Guid.NewGuid(), + Login = new LoginViewDto + { + Username = "service@example.com", + Password = "SvcP@ss!", + Uris = [new LoginUriViewDto { Uri = "https://api.example.com" }] + }, + Fields = + [ + new FieldViewDto { Name = "API Key", Value = "sk_test_FAKE_abc123", Type = 1 }, + new FieldViewDto { Name = "Environment", Value = "production", Type = 0 } + ] + }); var loginData = JsonSerializer.Deserialize(cipher.Data); Assert.NotNull(loginData); @@ -345,7 +359,15 @@ public void CipherSeeder_CardCipher_ProducesServerCompatibleFormat() Code = "456" }; - var cipher = CardCipherSeeder.Create(orgKeys.Key, name: "Business Card", card: card, organizationId: orgId, notes: "Company expenses"); + var cipher = CardCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Card, + Name = "Business Card", + Notes = "Company expenses", + EncryptionKey = orgKeys.Key, + OrganizationId = orgId, + Card = card + }); Assert.Equal(orgId, cipher.OrganizationId); Assert.Equal(Core.Vault.Enums.CipherType.Card, cipher.Type); @@ -379,7 +401,14 @@ public void CipherSeeder_IdentityCipher_ProducesServerCompatibleFormat() PassportNumber = "X12345678" }; - var cipher = IdentityCipherSeeder.Create(orgKeys.Key, name: "Dr. Alice Johnson", identity: identity, organizationId: orgId); + var cipher = IdentityCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Identity, + Name = "Dr. Alice Johnson", + EncryptionKey = orgKeys.Key, + OrganizationId = orgId, + Identity = identity + }); Assert.Equal(orgId, cipher.OrganizationId); Assert.Equal(Core.Vault.Enums.CipherType.Identity, cipher.Type); @@ -402,11 +431,14 @@ public void CipherSeeder_SecureNoteCipher_ProducesServerCompatibleFormat() var orgKeys = RustSdkService.GenerateOrganizationKeys(); var orgId = Guid.NewGuid(); - var cipher = SecureNoteCipherSeeder.Create( - orgKeys.Key, - name: "Production Secrets", - organizationId: orgId, - notes: "DATABASE_URL=postgres://user:FAKE_secret@db.example.com/prod"); + var cipher = SecureNoteCipherSeeder.Create(new CipherSeed + { + Type = CipherType.SecureNote, + Name = "Production Secrets", + Notes = "DATABASE_URL=postgres://user:FAKE_secret@db.example.com/prod", + EncryptionKey = orgKeys.Key, + OrganizationId = orgId + }); Assert.Equal(orgId, cipher.OrganizationId); Assert.Equal(Core.Vault.Enums.CipherType.SecureNote, cipher.Type); @@ -435,7 +467,14 @@ public void CipherSeeder_SshKeyCipher_ProducesServerCompatibleFormat() Fingerprint = "SHA256:examplefingerprint123" }; - var cipher = SshKeyCipherSeeder.Create(orgKeys.Key, name: "Production Deploy Key", sshKey: sshKey, organizationId: orgId); + var cipher = SshKeyCipherSeeder.Create(new CipherSeed + { + Type = CipherType.SSHKey, + Name = "Production Deploy Key", + EncryptionKey = orgKeys.Key, + OrganizationId = orgId, + SshKey = sshKey + }); Assert.Equal(orgId, cipher.OrganizationId); Assert.Equal(Core.Vault.Enums.CipherType.SSHKey, cipher.Type); diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index 24bfb7bb3910..2488ecb50705 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -24,6 +24,7 @@ dotnet test test/SeederApi.IntegrationTest/ --filter "FullyQualifiedName~TestMet ``` Need to create test data? ├─ ONE entity with encryption? → Factory +├─ ONE cipher from a SeedVaultItem? → CipherSeed.FromSeedItem() + {Type}CipherSeeder.Create() ├─ MANY entities as cohesive operation? → Recipe or Pipeline ├─ Flexible preset-based seeding? → Pipeline (RecipeBuilder + Steps) ├─ Complete test scenario with ID mangling? → Scene @@ -122,11 +123,17 @@ The Seeder uses the Rust SDK via FFI because it must behave like a real Bitwarde ## Data Flow +### Pipeline path (fixture → entity) +``` +SeedVaultItem → CipherSeed.FromSeedItem() → CipherSeed → {Type}CipherSeeder.Create(options) → CipherViewDto → encrypt_fields (Rust FFI) → EncryptedCipherDto → EncryptedCipherDtoExtensions → Server Cipher Entity +``` + +### Core encryption (shared by all paths) ``` CipherViewDto → JSON + [EncryptProperty] field paths → encrypt_fields (Rust FFI, bitwarden_crypto) → EncryptedCipherDto → EncryptedCipherDtoExtensions → Server Cipher Entity ``` -Shared logic: `CipherEncryption.cs`, `EncryptedCipherDtoExtensions.cs` +Shared logic: `Factories/CipherEncryption.cs`, `Models/EncryptedCipherDtoExtensions.cs` ## Rust Crypto Dependency diff --git a/util/Seeder/Factories/CardCipherSeeder.cs b/util/Seeder/Factories/CardCipherSeeder.cs index e6f4518f11b8..b10adc3b825e 100644 --- a/util/Seeder/Factories/CardCipherSeeder.cs +++ b/util/Seeder/Factories/CardCipherSeeder.cs @@ -6,52 +6,21 @@ namespace Bit.Seeder.Factories; internal static class CardCipherSeeder { - internal static Cipher Create( - string encryptionKey, - string name, - CardViewDto card, - Guid? organizationId = null, - Guid? userId = null, - string? notes = null) + internal static Cipher Create(CipherSeed options) { - var cipherView = new CipherViewDto - { - OrganizationId = organizationId, - Name = name, - Notes = notes, - Type = CipherTypes.Card, - Card = card - }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToCardData(), CipherType.Card, organizationId, userId); - } - - internal static Cipher CreateFromSeed( - string encryptionKey, - SeedVaultItem item, - Guid? organizationId = null, - Guid? userId = null) - { var cipherView = new CipherViewDto { - OrganizationId = organizationId, - Name = item.Name, - Notes = item.Notes, + OrganizationId = options.OrganizationId, + Name = options.Name, + Notes = options.Notes, Type = CipherTypes.Card, - Card = item.Card == null ? null : new CardViewDto - { - CardholderName = item.Card.CardholderName, - Brand = item.Card.Brand, - Number = item.Card.Number, - ExpMonth = item.Card.ExpMonth, - ExpYear = item.Card.ExpYear, - Code = item.Card.Code - }, - Fields = SeedItemMapping.MapFields(item.Fields) + Card = options.Card, + Fields = options.Fields }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToCardData(), CipherType.Card, organizationId, userId); + var encrypted = CipherEncryption.Encrypt(cipherView, options.EncryptionKey!); + return CipherEncryption.CreateEntity(encrypted, encrypted.ToCardData(), CipherType.Card, options.OrganizationId, options.UserId); } + } diff --git a/util/Seeder/Factories/CipherComposer.cs b/util/Seeder/Factories/CipherComposer.cs index eb8d62c36c29..18413c8ca909 100644 --- a/util/Seeder/Factories/CipherComposer.cs +++ b/util/Seeder/Factories/CipherComposer.cs @@ -5,6 +5,7 @@ using Bit.Seeder.Data.Enums; using Bit.Seeder.Data.Generators; using Bit.Seeder.Data.Static; +using Bit.Seeder.Models; namespace Bit.Seeder.Factories; @@ -45,14 +46,21 @@ private static Cipher ComposeLogin( Guid? userId = null) { var company = companies[index % companies.Length]; - return LoginCipherSeeder.Create( - encryptionKey, - name: $"{company.Name} ({company.Category})", - organizationId: organizationId, - userId: userId, - username: generator.Username.GenerateByIndex(index, totalHint: generator.CipherCount, domain: company.Domain), - password: Passwords.GetPassword(index, generator.CipherCount, passwordDistribution), - uri: $"https://{company.Domain}"); + var uri = $"https://{company.Domain}"; + return LoginCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Login, + Name = $"{company.Name} ({company.Category})", + EncryptionKey = encryptionKey, + OrganizationId = organizationId, + UserId = userId, + Login = new LoginViewDto + { + Username = generator.Username.GenerateByIndex(index, totalHint: generator.CipherCount, domain: company.Domain), + Password = Passwords.GetPassword(index, generator.CipherCount, passwordDistribution), + Uris = [new LoginUriViewDto { Uri = uri }] + } + }); } private static Cipher ComposeCard( @@ -63,12 +71,15 @@ private static Cipher ComposeCard( Guid? userId = null) { var card = generator.Card.GenerateByIndex(index); - return CardCipherSeeder.Create( - encryptionKey, - name: $"{card.CardholderName}'s {card.Brand}", - card: card, - organizationId: organizationId, - userId: userId); + return CardCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Card, + Name = $"{card.CardholderName}'s {card.Brand}", + EncryptionKey = encryptionKey, + OrganizationId = organizationId, + UserId = userId, + Card = card + }); } private static Cipher ComposeIdentity( @@ -84,12 +95,15 @@ private static Cipher ComposeIdentity( { name += $" ({identity.Company})"; } - return IdentityCipherSeeder.Create( - encryptionKey, - name: name, - identity: identity, - organizationId: organizationId, - userId: userId); + return IdentityCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Identity, + Name = name, + EncryptionKey = encryptionKey, + OrganizationId = organizationId, + UserId = userId, + Identity = identity + }); } private static Cipher ComposeSecureNote( @@ -100,12 +114,15 @@ private static Cipher ComposeSecureNote( Guid? userId = null) { var (name, notes) = generator.SecureNote.GenerateByIndex(index); - return SecureNoteCipherSeeder.Create( - encryptionKey, - name: name, - organizationId: organizationId, - userId: userId, - notes: notes); + return SecureNoteCipherSeeder.Create(new CipherSeed + { + Type = CipherType.SecureNote, + Name = name, + Notes = notes, + EncryptionKey = encryptionKey, + OrganizationId = organizationId, + UserId = userId + }); } private static Cipher ComposeSshKey( @@ -115,12 +132,15 @@ private static Cipher ComposeSshKey( Guid? userId = null) { var sshKey = SshKeyDataGenerator.GenerateByIndex(index); - return SshKeyCipherSeeder.Create( - encryptionKey, - name: $"SSH Key {index + 1}", - sshKey: sshKey, - organizationId: organizationId, - userId: userId); + return SshKeyCipherSeeder.Create(new CipherSeed + { + Type = CipherType.SSHKey, + Name = $"SSH Key {index + 1}", + EncryptionKey = encryptionKey, + OrganizationId = organizationId, + UserId = userId, + SshKey = sshKey + }); } /// diff --git a/util/Seeder/Factories/IdentityCipherSeeder.cs b/util/Seeder/Factories/IdentityCipherSeeder.cs index c6d340d400cb..6b5c2039f49d 100644 --- a/util/Seeder/Factories/IdentityCipherSeeder.cs +++ b/util/Seeder/Factories/IdentityCipherSeeder.cs @@ -6,63 +6,21 @@ namespace Bit.Seeder.Factories; internal static class IdentityCipherSeeder { - internal static Cipher Create( - string encryptionKey, - string name, - IdentityViewDto identity, - Guid? organizationId = null, - Guid? userId = null, - string? notes = null) + internal static Cipher Create(CipherSeed options) { - var cipherView = new CipherViewDto - { - OrganizationId = organizationId, - Name = name, - Notes = notes, - Type = CipherTypes.Identity, - Identity = identity - }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToIdentityData(), CipherType.Identity, organizationId, userId); - } - - internal static Cipher CreateFromSeed( - string encryptionKey, - SeedVaultItem item, - Guid? organizationId = null, - Guid? userId = null) - { var cipherView = new CipherViewDto { - OrganizationId = organizationId, - Name = item.Name, - Notes = item.Notes, + OrganizationId = options.OrganizationId, + Name = options.Name, + Notes = options.Notes, Type = CipherTypes.Identity, - Identity = item.Identity == null ? null : new IdentityViewDto - { - FirstName = item.Identity.FirstName, - MiddleName = item.Identity.MiddleName, - LastName = item.Identity.LastName, - Address1 = item.Identity.Address1, - Address2 = item.Identity.Address2, - Address3 = item.Identity.Address3, - City = item.Identity.City, - State = item.Identity.State, - PostalCode = item.Identity.PostalCode, - Country = item.Identity.Country, - Company = item.Identity.Company, - Email = item.Identity.Email, - Phone = item.Identity.Phone, - SSN = item.Identity.Ssn, - Username = item.Identity.Username, - PassportNumber = item.Identity.PassportNumber, - LicenseNumber = item.Identity.LicenseNumber - }, - Fields = SeedItemMapping.MapFields(item.Fields) + Identity = options.Identity, + Fields = options.Fields }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToIdentityData(), CipherType.Identity, organizationId, userId); + var encrypted = CipherEncryption.Encrypt(cipherView, options.EncryptionKey!); + return CipherEncryption.CreateEntity(encrypted, encrypted.ToIdentityData(), CipherType.Identity, options.OrganizationId, options.UserId); } + } diff --git a/util/Seeder/Factories/LoginCipherSeeder.cs b/util/Seeder/Factories/LoginCipherSeeder.cs index f7d9982937fd..faf15a7b2116 100644 --- a/util/Seeder/Factories/LoginCipherSeeder.cs +++ b/util/Seeder/Factories/LoginCipherSeeder.cs @@ -9,81 +9,24 @@ namespace Bit.Seeder.Factories; internal static class LoginCipherSeeder { - internal static Cipher Create( - string encryptionKey, - string name, - Guid? organizationId = null, - Guid? userId = null, - string? username = null, - string? password = null, - string? totp = null, - string? uri = null, - string? notes = null, - bool reprompt = false, - bool deleted = false, - IEnumerable<(string name, string value, int type)>? fields = null, - IEnumerable<(string rpName, string userName)>? passkeys = null - ) + internal static Cipher Create(CipherSeed options) { - var cipherView = new CipherViewDto - { - OrganizationId = organizationId, - Name = name, - Notes = notes, - Type = CipherTypes.Login, - Login = new LoginViewDto - { - Username = username, - Password = password, - Totp = totp, - Uris = string.IsNullOrEmpty(uri) ? null : [new LoginUriViewDto { Uri = uri }], - Fido2Credentials = passkeys == null ? null : passkeys.Select(r => CreateFido2Credential(r.rpName, r.userName)).ToList() - }, - Reprompt = reprompt ? RepromptTypes.Password : RepromptTypes.None, - DeletedDate = deleted ? DateTime.UtcNow.AddDays(-1) : null, - Fields = fields?.Select(f => new FieldViewDto - { - Name = f.name, - Value = f.value, - Type = f.type - }).ToList() - }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToLoginData(), CipherType.Login, organizationId, userId, deletedDate: cipherView.DeletedDate); - } - - internal static Cipher CreateFromSeed( - string encryptionKey, - SeedVaultItem item, - Guid? organizationId = null, - Guid? userId = null) - { var cipherView = new CipherViewDto { - OrganizationId = organizationId, - Name = item.Name, - Notes = item.Notes, + OrganizationId = options.OrganizationId, + Name = options.Name, + Notes = options.Notes, Type = CipherTypes.Login, - Login = item.Login == null ? null : new LoginViewDto - { - Username = item.Login.Username, - Password = item.Login.Password, - Totp = item.Login.Totp, - Uris = item.Login.Uris?.Select(u => new LoginUriViewDto - { - Uri = u.Uri, - Match = SeedItemMapping.MapUriMatchType(u.Match) - }).ToList() - }, - Fields = SeedItemMapping.MapFields(item.Fields) + Login = options.Login, + Fields = options.Fields }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToLoginData(), CipherType.Login, organizationId, userId); + var encrypted = CipherEncryption.Encrypt(cipherView, options.EncryptionKey!); + return CipherEncryption.CreateEntity(encrypted, encrypted.ToLoginData(), CipherType.Login, options.OrganizationId, options.UserId); } - public static Fido2CredentialViewDto CreateFido2Credential(string rpName, string userName) + internal static Fido2CredentialViewDto CreateFido2Credential(string rpName, string userName) { // Generate ECDSA P-256 private key in PKCS#8 format using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); @@ -91,7 +34,7 @@ public static Fido2CredentialViewDto CreateFido2Credential(string rpName, string // Generate 16-byte random user handle and encode as unpadded base64url var userHandleBytes = new byte[16]; - new Random().NextBytes(userHandleBytes); + RandomNumberGenerator.Fill(userHandleBytes); var userHandle = CoreHelpers.Base64UrlEncode(userHandleBytes); return new Fido2CredentialViewDto diff --git a/util/Seeder/Factories/SecureNoteCipherSeeder.cs b/util/Seeder/Factories/SecureNoteCipherSeeder.cs index 9756d1f67a42..8e48cbe2c1cf 100644 --- a/util/Seeder/Factories/SecureNoteCipherSeeder.cs +++ b/util/Seeder/Factories/SecureNoteCipherSeeder.cs @@ -6,43 +6,21 @@ namespace Bit.Seeder.Factories; internal static class SecureNoteCipherSeeder { - internal static Cipher Create( - string encryptionKey, - string name, - Guid? organizationId = null, - Guid? userId = null, - string? notes = null) + internal static Cipher Create(CipherSeed options) { - var cipherView = new CipherViewDto - { - OrganizationId = organizationId, - Name = name, - Notes = notes, - Type = CipherTypes.SecureNote, - SecureNote = new SecureNoteViewDto { Type = 0 } - }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToSecureNoteData(), CipherType.SecureNote, organizationId, userId); - } - - internal static Cipher CreateFromSeed( - string encryptionKey, - SeedVaultItem item, - Guid? organizationId = null, - Guid? userId = null) - { var cipherView = new CipherViewDto { - OrganizationId = organizationId, - Name = item.Name, - Notes = item.Notes, + OrganizationId = options.OrganizationId, + Name = options.Name, + Notes = options.Notes, Type = CipherTypes.SecureNote, - SecureNote = new SecureNoteViewDto { Type = 0 }, - Fields = SeedItemMapping.MapFields(item.Fields) + SecureNote = options.SecureNote ?? new SecureNoteViewDto { Type = 0 }, + Fields = options.Fields }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToSecureNoteData(), CipherType.SecureNote, organizationId, userId); + var encrypted = CipherEncryption.Encrypt(cipherView, options.EncryptionKey!); + return CipherEncryption.CreateEntity(encrypted, encrypted.ToSecureNoteData(), CipherType.SecureNote, options.OrganizationId, options.UserId); } + } diff --git a/util/Seeder/Factories/SeedItemMapping.cs b/util/Seeder/Factories/SeedItemMapping.cs deleted file mode 100644 index 5176cff6b223..000000000000 --- a/util/Seeder/Factories/SeedItemMapping.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Bit.Seeder.Models; - -namespace Bit.Seeder.Factories; - -/// -/// Shared mapping helpers for converting SeedItem fields to CipherViewDto fields. -/// -internal static class SeedItemMapping -{ - internal static int MapFieldType(string type) => type switch - { - "hidden" => 1, - "boolean" => 2, - "linked" => 3, - "text" => 0, - _ => throw new ArgumentException($"Unknown field type: '{type}'", nameof(type)) - }; - - internal static List? MapFields(List? fields) => - fields?.Select(f => new FieldViewDto - { - Name = f.Name, - Value = f.Value, - Type = MapFieldType(f.Type) - }).ToList(); - - internal static int MapUriMatchType(string match) => match switch - { - "host" => 1, - "startsWith" => 2, - "exact" => 3, - "regex" => 4, - "never" => 5, - "domain" => 0, - _ => throw new ArgumentException($"Unknown URI match type: '{match}'", nameof(match)) - }; -} diff --git a/util/Seeder/Factories/SshKeyCipherSeeder.cs b/util/Seeder/Factories/SshKeyCipherSeeder.cs index fa039ccfbd9c..e881857c5fd9 100644 --- a/util/Seeder/Factories/SshKeyCipherSeeder.cs +++ b/util/Seeder/Factories/SshKeyCipherSeeder.cs @@ -6,51 +6,21 @@ namespace Bit.Seeder.Factories; internal static class SshKeyCipherSeeder { - internal static Cipher Create( - string encryptionKey, - string name, - SshKeyViewDto sshKey, - Guid? organizationId = null, - Guid? userId = null, - string? notes = null, - bool reprompt = false) + internal static Cipher Create(CipherSeed options) { - var cipherView = new CipherViewDto - { - OrganizationId = organizationId, - Name = name, - Notes = notes, - Type = CipherTypes.SshKey, - SshKey = sshKey, - Reprompt = reprompt ? RepromptTypes.Password : RepromptTypes.None, - }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToSshKeyData(), CipherType.SSHKey, organizationId, userId); - } - - internal static Cipher CreateFromSeed( - string encryptionKey, - SeedVaultItem item, - Guid? organizationId = null, - Guid? userId = null) - { var cipherView = new CipherViewDto { - OrganizationId = organizationId, - Name = item.Name, - Notes = item.Notes, + OrganizationId = options.OrganizationId, + Name = options.Name, + Notes = options.Notes, Type = CipherTypes.SshKey, - SshKey = item.SshKey == null ? null : new SshKeyViewDto - { - PrivateKey = item.SshKey.PrivateKey, - PublicKey = item.SshKey.PublicKey, - Fingerprint = item.SshKey.KeyFingerprint - }, - Fields = SeedItemMapping.MapFields(item.Fields) + SshKey = options.SshKey, + Fields = options.Fields }; - var encrypted = CipherEncryption.Encrypt(cipherView, encryptionKey); - return CipherEncryption.CreateEntity(encrypted, encrypted.ToSshKeyData(), CipherType.SSHKey, organizationId, userId); + var encrypted = CipherEncryption.Encrypt(cipherView, options.EncryptionKey!); + return CipherEncryption.CreateEntity(encrypted, encrypted.ToSshKeyData(), CipherType.SSHKey, options.OrganizationId, options.UserId); } + } diff --git a/util/Seeder/Models/CipherSeed.cs b/util/Seeder/Models/CipherSeed.cs new file mode 100644 index 000000000000..ed3a4088bdab --- /dev/null +++ b/util/Seeder/Models/CipherSeed.cs @@ -0,0 +1,197 @@ +using Bit.Core.Vault.Enums; + +namespace Bit.Seeder.Models; + +/// +/// Normalized, strongly-typed plaintext for a single cipher before encryption. +/// Constructed from fixture JSON via FromSeedItem() or programmatically via CipherComposer. +/// +internal record CipherSeed +{ + /// + /// Determines which type-specific DTO (Login, Card, Identity, SecureNote, SshKey) + /// the factory reads. Exactly one matching DTO must be non-null. + /// + public required CipherType Type { get; init; } + + /// + /// Plaintext cipher name (will be encrypted by the factory). + /// + public required string Name { get; init; } + + /// + /// Symmetric key (org key or user key) used for Rust FFI encryption. + /// + public string? EncryptionKey { get; init; } + + /// + /// Optional plaintext notes (will be encrypted by the factory). + /// + public string? Notes { get; init; } + + /// + /// Optional custom fields (will be encrypted by the factory). + /// + public List? Fields { get; init; } + + /// + /// Master-password re-prompt (0 = None, 1 = Password). + /// + public CipherRepromptType Reprompt { get; init; } + + /// + /// Owning organization. Null for personal vault ciphers. + /// + public Guid? OrganizationId { get; init; } + + /// + /// Owning user for personal vault ciphers. Null for organization ciphers. + /// + public Guid? UserId { get; init; } + + /// + /// Plaintext login data (username, password, URIs). Non-null when Type is Login. + /// + public LoginViewDto? Login { get; init; } + + /// + /// Plaintext card data (cardholder, number, expiry). Non-null when Type is Card. + /// + public CardViewDto? Card { get; init; } + + /// + /// Plaintext identity data (name, address, documents). Non-null when Type is Identity. + /// + public IdentityViewDto? Identity { get; init; } + + /// + /// Secure note type marker. Non-null when Type is SecureNote. + /// The actual note content is carried by the Notes property, not this DTO. + /// + public SecureNoteViewDto? SecureNote { get; init; } + + /// + /// Plaintext SSH key data (private key, public key, fingerprint). Non-null when Type is SSHKey. + /// + public SshKeyViewDto? SshKey { get; init; } + + /// + /// Validates that required fields are set before factory consumption. + /// Call after populating EncryptionKey via with. + /// + internal void Validate() + { + ArgumentException.ThrowIfNullOrEmpty(EncryptionKey); + } + + /// + /// Maps a deserialized into a , + /// converting Seed* models to their ViewDto counterparts. + /// EncryptionKey, OrganizationId, and UserId are left null — callers set them via with. + /// + internal static CipherSeed FromSeedItem(SeedVaultItem item) => new() + { + Type = MapCipherType(item.Type), + Name = item.Name, + Notes = item.Notes, + Reprompt = item.Reprompt == 1 ? CipherRepromptType.Password : CipherRepromptType.None, + Fields = MapFields(item.Fields), + Login = MapLogin(item.Login), + Card = MapCard(item.Card), + Identity = MapIdentity(item.Identity), + SecureNote = item.Type == "secureNote" ? new SecureNoteViewDto { Type = 0 } : null, + SshKey = MapSshKey(item.SshKey) + }; + + private static CipherType MapCipherType(string type) => type switch + { + "login" => CipherType.Login, + "card" => CipherType.Card, + "identity" => CipherType.Identity, + "secureNote" => CipherType.SecureNote, + "sshKey" => CipherType.SSHKey, + _ => throw new ArgumentException($"Unknown cipher type: '{type}'", nameof(type)) + }; + + private static List? MapFields(List? fields) => + fields?.Select(f => new FieldViewDto + { + Name = f.Name, + Value = f.Value, + Type = MapFieldType(f.Type) + }).ToList(); + + private static int MapFieldType(string type) => type switch + { + "hidden" => 1, + "boolean" => 2, + "linked" => 3, + "text" => 0, + _ => throw new ArgumentException($"Unknown field type: '{type}'", nameof(type)) + }; + + private static LoginViewDto? MapLogin(SeedLogin? login) => + login == null ? null : new LoginViewDto + { + Username = login.Username, + Password = login.Password, + Totp = login.Totp, + Uris = login.Uris?.Select(u => new LoginUriViewDto + { + Uri = u.Uri, + Match = MapUriMatchType(u.Match) + }).ToList() + }; + + private static int MapUriMatchType(string match) => match switch + { + "host" => 1, + "startsWith" => 2, + "exact" => 3, + "regex" => 4, + "never" => 5, + "domain" => 0, + _ => throw new ArgumentException($"Unknown URI match type: '{match}'", nameof(match)) + }; + + private static CardViewDto? MapCard(SeedCard? card) => + card == null ? null : new CardViewDto + { + CardholderName = card.CardholderName, + Brand = card.Brand, + Number = card.Number, + ExpMonth = card.ExpMonth, + ExpYear = card.ExpYear, + Code = card.Code + }; + + private static IdentityViewDto? MapIdentity(SeedIdentity? identity) => + identity == null ? null : new IdentityViewDto + { + FirstName = identity.FirstName, + MiddleName = identity.MiddleName, + LastName = identity.LastName, + Address1 = identity.Address1, + Address2 = identity.Address2, + Address3 = identity.Address3, + City = identity.City, + State = identity.State, + PostalCode = identity.PostalCode, + Country = identity.Country, + Company = identity.Company, + Email = identity.Email, + Phone = identity.Phone, + SSN = identity.Ssn, + Username = identity.Username, + PassportNumber = identity.PassportNumber, + LicenseNumber = identity.LicenseNumber + }; + + private static SshKeyViewDto? MapSshKey(SeedSshKey? sshKey) => + sshKey == null ? null : new SshKeyViewDto + { + PrivateKey = sshKey.PrivateKey, + PublicKey = sshKey.PublicKey, + Fingerprint = sshKey.KeyFingerprint + }; +} diff --git a/util/Seeder/README.md b/util/Seeder/README.md index 434cc574394d..23da90e0bce0 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -65,7 +65,9 @@ The Seeder is organized around six core patterns, each with a specific responsib - Stateless (except for SDK service dependency) - Do NOT interact with database directly -**Naming:** `{Entity}Seeder` with `Create{Type}{Entity}()` methods +**Naming:** `{Entity}Seeder` with `Create()` methods + +**Pipeline cipher path:** Each cipher factory accepts a single `CipherSeed` parameter. `CipherSeed.FromSeedItem()` converts a deserialized `SeedVaultItem` into a `CipherSeed` for the pipeline path. #### Recipes diff --git a/util/Seeder/Scenes/UserCardCipherScene.cs b/util/Seeder/Scenes/UserCardCipherScene.cs index 99ff302ad3e4..cb528ed1b244 100644 --- a/util/Seeder/Scenes/UserCardCipherScene.cs +++ b/util/Seeder/Scenes/UserCardCipherScene.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Repositories; using Bit.Seeder.Factories; using Bit.Seeder.Models; @@ -46,7 +47,15 @@ public async Task> SeedAsync(Request request) ExpYear = request.ExpYear, Code = request.Code }; - var cipher = CardCipherSeeder.Create(request.UserKeyB64, request.Name, card: card, userId: request.UserId, notes: request.Notes); + var cipher = CardCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Card, + Name = request.Name, + Notes = request.Notes, + EncryptionKey = request.UserKeyB64, + UserId = request.UserId, + Card = card + }); await cipherRepository.CreateAsync(cipher); diff --git a/util/Seeder/Scenes/UserIdentityCipherScene.cs b/util/Seeder/Scenes/UserIdentityCipherScene.cs index 4c5be33cebf6..ede0b3075b99 100644 --- a/util/Seeder/Scenes/UserIdentityCipherScene.cs +++ b/util/Seeder/Scenes/UserIdentityCipherScene.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Repositories; using Bit.Seeder.Factories; using Bit.Seeder.Models; @@ -44,7 +45,15 @@ public async Task> SeedAsync(Request request) MiddleName = request.MiddleName, LastName = request.LastName }; - var cipher = IdentityCipherSeeder.Create(request.UserKeyB64, request.Name, userId: request.UserId, identity: identity, notes: request.Notes); + var cipher = IdentityCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Identity, + Name = request.Name, + Notes = request.Notes, + EncryptionKey = request.UserKeyB64, + UserId = request.UserId, + Identity = identity + }); await cipherRepository.CreateAsync(cipher); diff --git a/util/Seeder/Scenes/UserLoginCipherScene.cs b/util/Seeder/Scenes/UserLoginCipherScene.cs index 4e9a51dabde3..3c9a71cdaaeb 100644 --- a/util/Seeder/Scenes/UserLoginCipherScene.cs +++ b/util/Seeder/Scenes/UserLoginCipherScene.cs @@ -1,8 +1,10 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core.Repositories; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Repositories; using Bit.Seeder.Factories; +using Bit.Seeder.Models; using Bit.Seeder.Services; namespace Bit.Seeder.Scenes; @@ -55,7 +57,36 @@ public async Task> SeedAsync(Request request) throw new Exception($"User with ID {request.UserId} not found."); } - var cipher = LoginCipherSeeder.Create(request.UserKeyB64, request.Name, userId: request.UserId, username: request.Username, password: request.Password, totp: request.Totp, uri: request.Uri, notes: request.Notes, fields: request.Fields?.Select(f => (f.Name, f.Value, f.Type)), reprompt: request.Reprompt, deleted: request.Deleted, passkeys: request.Passkeys?.Select(p => (p.RpName, p.UserName))); + var cipher = LoginCipherSeeder.Create(new CipherSeed + { + Type = CipherType.Login, + Name = request.Name, + Notes = request.Notes, + EncryptionKey = request.UserKeyB64, + UserId = request.UserId, + Login = new LoginViewDto + { + Username = request.Username, + Password = request.Password, + Totp = request.Totp, + Uris = string.IsNullOrEmpty(request.Uri) ? null : [new LoginUriViewDto { Uri = request.Uri }], + Fido2Credentials = request.Passkeys?.Select(p => LoginCipherSeeder.CreateFido2Credential(p.RpName, p.UserName)).ToList() + }, + Fields = request.Fields?.Select(f => new FieldViewDto + { + Name = f.Name, + Value = f.Value, + Type = f.Type + }).ToList() + }); + if (request.Reprompt) + { + cipher.Reprompt = CipherRepromptType.Password; + } + if (request.Deleted) + { + cipher.DeletedDate = DateTime.UtcNow.AddDays(-1); + } if (request.Favorite) { cipher.Favorites = JsonSerializer.Serialize(new Dictionary diff --git a/util/Seeder/Scenes/UserSecureNoteCipherScene.cs b/util/Seeder/Scenes/UserSecureNoteCipherScene.cs index 25113bf1509d..4305df209ea6 100644 --- a/util/Seeder/Scenes/UserSecureNoteCipherScene.cs +++ b/util/Seeder/Scenes/UserSecureNoteCipherScene.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Repositories; using Bit.Seeder.Factories; +using Bit.Seeder.Models; using Bit.Seeder.Services; namespace Bit.Seeder.Scenes; @@ -32,7 +34,14 @@ public async Task> SeedAsync(Request request) throw new Exception($"User with ID {request.UserId} not found."); } - var cipher = SecureNoteCipherSeeder.Create(request.UserKeyB64, request.Name, userId: request.UserId, notes: request.Notes); + var cipher = SecureNoteCipherSeeder.Create(new CipherSeed + { + Type = CipherType.SecureNote, + Name = request.Name, + Notes = request.Notes, + EncryptionKey = request.UserKeyB64, + UserId = request.UserId + }); await cipherRepository.CreateAsync(cipher); diff --git a/util/Seeder/Scenes/UserSshKeyCipherScene.cs b/util/Seeder/Scenes/UserSshKeyCipherScene.cs index a43fd551eba4..a03949fb77c4 100644 --- a/util/Seeder/Scenes/UserSshKeyCipherScene.cs +++ b/util/Seeder/Scenes/UserSshKeyCipherScene.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Repositories; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Repositories; using Bit.Seeder.Factories; using Bit.Seeder.Models; @@ -43,7 +44,19 @@ public async Task> SeedAsync(Request request) PublicKey = request.PublicKey, Fingerprint = request.Fingerprint }; - var cipher = SshKeyCipherSeeder.Create(request.UserKeyB64, request.Name, userId: request.UserId, sshKey: sshKey, notes: request.Notes, reprompt: request.Reprompt); + var cipher = SshKeyCipherSeeder.Create(new CipherSeed + { + Type = CipherType.SSHKey, + Name = request.Name, + Notes = request.Notes, + EncryptionKey = request.UserKeyB64, + UserId = request.UserId, + SshKey = sshKey + }); + if (request.Reprompt) + { + cipher.Reprompt = CipherRepromptType.Password; + } await cipherRepository.CreateAsync(cipher); diff --git a/util/Seeder/Steps/CreateCiphersStep.cs b/util/Seeder/Steps/CreateCiphersStep.cs index 9681fd5835ed..59fc68509735 100644 --- a/util/Seeder/Steps/CreateCiphersStep.cs +++ b/util/Seeder/Steps/CreateCiphersStep.cs @@ -1,5 +1,6 @@ using Bit.Core.Entities; using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; using Bit.Seeder.Factories; using Bit.Seeder.Models; using Bit.Seeder.Pipeline; @@ -57,14 +58,21 @@ public void Execute(SeederContext context) for (var i = 0; i < seedFile.Items.Count; i++) { var item = seedFile.Items[i]; - var cipher = item.Type switch + var options = CipherSeed.FromSeedItem(item) with { - "login" => LoginCipherSeeder.CreateFromSeed(encryptionKey, item, organizationId: organizationId, userId: userId), - "card" => CardCipherSeeder.CreateFromSeed(encryptionKey, item, organizationId: organizationId, userId: userId), - "identity" => IdentityCipherSeeder.CreateFromSeed(encryptionKey, item, organizationId: organizationId, userId: userId), - "secureNote" => SecureNoteCipherSeeder.CreateFromSeed(encryptionKey, item, organizationId: organizationId, userId: userId), - "sshKey" => SshKeyCipherSeeder.CreateFromSeed(encryptionKey, item, organizationId: organizationId, userId: userId), - _ => throw new InvalidOperationException($"Unknown cipher type: {item.Type}") + EncryptionKey = encryptionKey, + OrganizationId = organizationId, + UserId = userId + }; + options.Validate(); + var cipher = options.Type switch + { + CipherType.Login => LoginCipherSeeder.Create(options), + CipherType.Card => CardCipherSeeder.Create(options), + CipherType.Identity => IdentityCipherSeeder.Create(options), + CipherType.SecureNote => SecureNoteCipherSeeder.Create(options), + CipherType.SSHKey => SshKeyCipherSeeder.Create(options), + _ => throw new ArgumentOutOfRangeException(nameof(options), $"Unsupported cipher type: {options.Type}") }; if (item.Favorite == true && userId.HasValue) @@ -72,10 +80,7 @@ public void Execute(SeederContext context) cipher.Favorites = CipherComposer.BuildFavoritesJson([userId.Value]); } - if (item.Reprompt == 1) - { - cipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password; - } + cipher.Reprompt = options.Reprompt; ciphers.Add(cipher);