diff --git a/src/AliasVault.Api/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index c341a72a1..45570a76c 100644 --- a/src/AliasVault.Api/Controllers/VaultController.cs +++ b/src/AliasVault.Api/Controllers/VaultController.cs @@ -65,10 +65,10 @@ public class VaultController(IDbContextFactory dbContextFa // as starting point. if (vault == null) { - return Ok(new Shared.Models.WebApi.Vault(string.Empty, string.Empty, DateTime.MinValue, DateTime.MinValue)); + return Ok(new Shared.Models.WebApi.Vault(string.Empty, string.Empty, new List(), DateTime.MinValue, DateTime.MinValue)); } - return Ok(new Shared.Models.WebApi.Vault(vault.VaultBlob, vault.Version, vault.CreatedAt, vault.UpdatedAt)); + return Ok(new Shared.Models.WebApi.Vault(vault.VaultBlob, vault.Version, new List(), vault.CreatedAt, vault.UpdatedAt)); } /// @@ -116,6 +116,51 @@ public class VaultController(IDbContextFactory dbContextFa await context.Vaults.AddAsync(newVault); await context.SaveChangesAsync(); + // Update user email claims if email addresses have been supplied. + if (model.EmailAddressList.Count > 0) + { + await UpdateUserEmailClaims(context, user.Id, model.EmailAddressList); + } + return Ok(); } + + /// + /// Updates the user's email claims based on the provided email address list. + /// + /// The database context. + /// The ID of the user. + /// The list of new email addresses to claim. + /// A task representing the asynchronous operation. + private async Task UpdateUserEmailClaims(AliasServerDbContext context, string userId, List newEmailAddresses) + { + // Get all existing user email claims. + var existingEmailClaims = await context.UserEmailClaims + .Where(x => x.UserId == userId) + .Select(x => x.Address) + .ToListAsync(); + + // Register new email addresses. + foreach (var email in newEmailAddresses) + { + if (!existingEmailClaims.Contains(email)) + { + await context.UserEmailClaims.AddAsync(new UserEmailClaim + { + UserId = userId, + Address = email, + AddressLocal = email.Split('@')[0], + AddressDomain = email.Split('@')[1], + CreatedAt = timeProvider.UtcNow, + UpdatedAt = timeProvider.UtcNow, + }); + } + } + + // Do not delete email claims that are not in the new list + // as they may be re-used by the user in the future. We don't want + // to allow other users to re-use emails used by other users. + // Email claims are considered permanent. + await context.SaveChangesAsync(); + } } diff --git a/src/AliasVault.Client/Config.cs b/src/AliasVault.Client/Config.cs new file mode 100644 index 000000000..230af8f75 --- /dev/null +++ b/src/AliasVault.Client/Config.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client; + +/// +/// Configuration class for the Client project with values loaded from appsettings.json. +/// +public class Config +{ + /// + /// Gets or sets the admin password hash which is generated by install.sh and will be set + /// as the default password for the admin user. + /// + public string ApiUrl { get; set; } = "false"; + + /// + /// Gets or sets the domains that the AliasVault server is listening for. + /// Email addresses that client vault users use will be registered at the server + /// to get exclusive access to the email address. + /// + public List SmtpAllowedDomains { get; set; } = []; +} diff --git a/src/AliasVault.Client/Program.cs b/src/AliasVault.Client/Program.cs index 903c614ec..c983f7f54 100644 --- a/src/AliasVault.Client/Program.cs +++ b/src/AliasVault.Client/Program.cs @@ -16,6 +16,20 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); builder.Configuration.AddJsonFile($"appsettings.{builder.HostEnvironment.Environment}.json", optional: true, reloadOnChange: true); +var config = new Config(); +builder.Configuration.Bind(config); +if (string.IsNullOrEmpty(config.ApiUrl)) +{ + throw new KeyNotFoundException("ApiUrl is not set in the configuration."); +} + +if (config.SmtpAllowedDomains == null || config.SmtpAllowedDomains.Count == 0) +{ + throw new KeyNotFoundException("SmtpAllowedDomains is not set in the configuration."); +} + +builder.Services.AddSingleton(config); + builder.Services.AddLogging(logging => { if (builder.HostEnvironment.IsDevelopment()) diff --git a/src/AliasVault.Client/Services/Database/DbService.cs b/src/AliasVault.Client/Services/Database/DbService.cs index 489c58748..9efdfa5ff 100644 --- a/src/AliasVault.Client/Services/Database/DbService.cs +++ b/src/AliasVault.Client/Services/Database/DbService.cs @@ -27,6 +27,7 @@ public class DbService : IDisposable private readonly IJSRuntime _jsRuntime; private readonly HttpClient _httpClient; private readonly DbServiceState _state = new(); + private readonly Config _config; private SqliteConnection _sqlConnection; private AliasClientDbContext _dbContext; private bool _isSuccessfullyInitialized; @@ -39,11 +40,13 @@ public class DbService : IDisposable /// AuthService. /// IJSRuntime. /// HttpClient. - public DbService(AuthService authService, IJSRuntime jsRuntime, HttpClient httpClient) + /// Config instance. + public DbService(AuthService authService, IJSRuntime jsRuntime, HttpClient httpClient, Config config) { _authService = authService; _jsRuntime = jsRuntime; _httpClient = httpClient; + _config = config; // Set the initial state of the database service. _state.UpdateState(DbServiceState.DatabaseStatus.Uninitialized); @@ -441,8 +444,18 @@ public class DbService : IDisposable /// True if save action succeeded. private async Task SaveToServerAsync(string encryptedDatabase) { + // Send list of email addresses that are used in aliases by this vault so they can be + // claimed on the server. + var emailAddresses = await _dbContext.Aliases + .Where(a => a.Email != null) + .Select(a => a.Email) + .Where(email => _config.SmtpAllowedDomains.Any(domain => EF.Functions.Like(email, $"%@{domain}"))) + .Distinct() + .Select(email => email!) + .ToListAsync(); + var databaseVersion = await GetCurrentDatabaseVersionAsync(); - var vaultObject = new Vault(encryptedDatabase, databaseVersion, DateTime.Now, DateTime.Now); + var vaultObject = new Vault(encryptedDatabase, databaseVersion, emailAddresses, DateTime.Now, DateTime.Now); try { diff --git a/src/AliasVault.Client/entrypoint.sh b/src/AliasVault.Client/entrypoint.sh index e4560236c..56b1db8fc 100644 --- a/src/AliasVault.Client/entrypoint.sh +++ b/src/AliasVault.Client/entrypoint.sh @@ -1,12 +1,18 @@ #!/bin/sh # Set the default API URL for localhost debugging DEFAULT_API_URL="http://localhost:81" +DEFAULT_SMTP_ALLOWED_DOMAINS="localmail.tld" # Use the provided API_URL environment variable if it exists, otherwise use the default API_URL=${API_URL:-$DEFAULT_API_URL} +SMTP_ALLOWED_DOMAINS=${SMTP_ALLOWED_DOMAINS:-$DEFAULT_SMTP_ALLOWED_DOMAINS} # Replace the default URL with the actual API URL sed -i "s|http://localhost:5092|${API_URL}|g" /usr/share/nginx/html/appsettings.json +# Replace the default SMTP allowed domains with the actual allowed SMTP domains +# Note: this is used so the client knows which email addresses should be registered with the AliasVault server +# in order to be able to receive emails. +sed -i "s|localmail.tld|${SMTP_ALLOWED_DOMAINS}|g" /usr/share/nginx/html/appsettings.json # Start the application nginx -g "daemon off;" diff --git a/src/AliasVault.Client/wwwroot/appsettings.json b/src/AliasVault.Client/wwwroot/appsettings.json index 779c58b6b..6439e6917 100644 --- a/src/AliasVault.Client/wwwroot/appsettings.json +++ b/src/AliasVault.Client/wwwroot/appsettings.json @@ -1,3 +1,4 @@ { - "ApiUrl": "http://localhost:5092" + "ApiUrl": "http://localhost:5092", + "SmtpAllowedDomains": "localmail.tld" } diff --git a/src/AliasVault.Shared/Models/WebApi/Vault.cs b/src/AliasVault.Shared/Models/WebApi/Vault.cs index 339af2cb1..8b0a3edad 100644 --- a/src/AliasVault.Shared/Models/WebApi/Vault.cs +++ b/src/AliasVault.Shared/Models/WebApi/Vault.cs @@ -17,12 +17,14 @@ public class Vault /// /// Blob. /// Version of the vault data model (migration). + /// List of email addresses that are used in the vault and should be registered. /// CreatedAt. /// UpdatedAt. - public Vault(string blob, string version, DateTime createdAt, DateTime updatedAt) + public Vault(string blob, string version, List emailAddressList, DateTime createdAt, DateTime updatedAt) { Blob = blob; Version = version; + EmailAddressList = emailAddressList; CreatedAt = createdAt; UpdatedAt = updatedAt; } @@ -37,6 +39,11 @@ public class Vault /// public string Version { get; set; } + /// + /// Gets or sets the list of email addresses that are used in the vault and should be registered on the server. + /// + public List EmailAddressList { get; set; } + /// /// Gets or sets the date and time of creation. /// diff --git a/src/Databases/AliasServerDb/AliasServerDbContext.cs b/src/Databases/AliasServerDb/AliasServerDbContext.cs index 9719b6ced..e2b6f793f 100644 --- a/src/Databases/AliasServerDb/AliasServerDbContext.cs +++ b/src/Databases/AliasServerDb/AliasServerDbContext.cs @@ -100,6 +100,16 @@ public class AliasServerDbContext : WorkerStatusDbContext /// public DbSet EmailAttachments { get; set; } + /// + /// Gets or sets the UserEmailClaims DbSet. + /// + public DbSet UserEmailClaims { get; set; } + + /// + /// Gets or sets the UserEncryptionKeys DbSet. + /// + public DbSet UserEncryptionKeys { get; set; } + /// /// Gets or sets the Logs DbSet. /// @@ -182,6 +192,27 @@ public class AliasServerDbContext : WorkerStatusDbContext .WithMany(c => c.Vaults) .HasForeignKey(l => l.UserId) .OnDelete(DeleteBehavior.Cascade); + + // Configure UserEmailClaim - AliasVaultUser relationship + modelBuilder.Entity() + .HasOne(l => l.User) + .WithMany(c => c.EmailClaims) + .HasForeignKey(l => l.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // Configure Email - UserEncryptionKey relationship + modelBuilder.Entity() + .HasOne(l => l.EncryptionKey) + .WithMany(c => c.Emails) + .HasForeignKey(l => l.UserEncryptionKeyId) + .OnDelete(DeleteBehavior.NoAction); + + // Configure UserEncryptionKey - AliasVaultUser relationship + modelBuilder.Entity() + .HasOne(l => l.User) + .WithMany(c => c.EncryptionKeys) + .HasForeignKey(l => l.UserId) + .OnDelete(DeleteBehavior.Cascade); } /// diff --git a/src/Databases/AliasServerDb/AliasVaultUser.cs b/src/Databases/AliasServerDb/AliasVaultUser.cs index daa5830e9..d5553a368 100644 --- a/src/Databases/AliasServerDb/AliasVaultUser.cs +++ b/src/Databases/AliasServerDb/AliasVaultUser.cs @@ -27,6 +27,13 @@ public class AliasVaultUser : IdentityUser [StringLength(1000)] public string Verifier { get; set; } = null!; + /// + /// Gets or sets the user public key to be used by server to encrypt information that server + /// receives for user such as emails. + /// + [StringLength(2000)] + public string PublicKey { get; set; } = null!; + /// /// Gets or sets created timestamp. /// @@ -41,4 +48,14 @@ public class AliasVaultUser : IdentityUser /// Gets or sets the collection of vaults. /// public virtual ICollection Vaults { get; set; } = []; + + /// + /// Gets or sets the collection of EmailClaims. + /// + public virtual ICollection EmailClaims { get; set; } = []; + + /// + /// Gets or sets the collection of EncryptionKeys. + /// + public virtual ICollection EncryptionKeys { get; set; } = []; } diff --git a/src/Databases/AliasServerDb/Email.cs b/src/Databases/AliasServerDb/Email.cs index e5fd7f565..89f3866c8 100644 --- a/src/Databases/AliasServerDb/Email.cs +++ b/src/Databases/AliasServerDb/Email.cs @@ -7,6 +7,8 @@ namespace AliasServerDb; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; /// @@ -24,6 +26,18 @@ public class Email /// public int Id { get; set; } + /// + /// Gets or sets encryption key foreign key. + /// + [StringLength(255)] + public Guid UserEncryptionKeyId { get; set; } + + /// + /// Gets or sets foreign key to the UserEncryptionKey object. + /// + [ForeignKey("UserEncryptionKeyId")] + public virtual UserEncryptionKey EncryptionKey { get; set; } = null!; + /// /// Gets or sets the subject of the email. /// diff --git a/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.Designer.cs new file mode 100644 index 000000000..6f5952b34 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.Designer.cs @@ -0,0 +1,702 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20240729090556_AddEncryptionKeyTables")] + partial class AddEncryptionKeyTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordChanged") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Salt") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserEncryptionKeyId") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("UserEncryptionKeyId"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Application") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Exception") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LogEvent") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("LogEvent"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageTemplate") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeStamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Application"); + + b.HasIndex("TimeStamp"); + + b.ToTable("Logs", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AddressDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AddressLocal") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEncryptionKeys"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DesiredStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Heartbeat") + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar"); + + b.HasKey("Id"); + + b.ToTable("WorkerServiceStatuses"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey") + .WithMany("Emails") + .HasForeignKey("UserEncryptionKeyId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("EncryptionKey"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EmailClaims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EncryptionKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("Vaults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Navigation("EmailClaims"); + + b.Navigation("EncryptionKeys"); + + b.Navigation("Vaults"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Navigation("Emails"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.cs b/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.cs new file mode 100644 index 000000000..6ece51f37 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.cs @@ -0,0 +1,126 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddEncryptionKeyTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Delete all records from the Email table as adding PKI will break the existing data. + migrationBuilder.Sql("DELETE FROM Emails"); + + migrationBuilder.AddColumn( + name: "UserEncryptionKeyId", + table: "Emails", + type: "TEXT", + maxLength: 255, + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "PublicKey", + table: "AliasVaultUsers", + type: "TEXT", + maxLength: 2000, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "UserEmailClaims", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Address = table.Column(type: "TEXT", maxLength: 255, nullable: false), + AddressLocal = table.Column(type: "TEXT", maxLength: 255, nullable: false), + AddressDomain = table.Column(type: "TEXT", maxLength: 255, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserEmailClaims", x => x.Id); + table.ForeignKey( + name: "FK_UserEmailClaims_AliasVaultUsers_UserId", + column: x => x.UserId, + principalTable: "AliasVaultUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserEncryptionKeys", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 255, nullable: false), + PublicKey = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserEncryptionKeys", x => x.Id); + table.ForeignKey( + name: "FK_UserEncryptionKeys_AliasVaultUsers_UserId", + column: x => x.UserId, + principalTable: "AliasVaultUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Emails_UserEncryptionKeyId", + table: "Emails", + column: "UserEncryptionKeyId"); + + migrationBuilder.CreateIndex( + name: "IX_UserEmailClaims_UserId", + table: "UserEmailClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserEncryptionKeys_UserId", + table: "UserEncryptionKeys", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Emails_UserEncryptionKeys_UserEncryptionKeyId", + table: "Emails", + column: "UserEncryptionKeyId", + principalTable: "UserEncryptionKeys", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Emails_UserEncryptionKeys_UserEncryptionKeyId", + table: "Emails"); + + migrationBuilder.DropTable( + name: "UserEmailClaims"); + + migrationBuilder.DropTable( + name: "UserEncryptionKeys"); + + migrationBuilder.DropIndex( + name: "IX_Emails_UserEncryptionKeyId", + table: "Emails"); + + migrationBuilder.DropColumn( + name: "UserEncryptionKeyId", + table: "Emails"); + + migrationBuilder.DropColumn( + name: "PublicKey", + table: "AliasVaultUsers"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index c9faa69f7..f91d18887 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -155,6 +155,11 @@ namespace AliasServerDb.Migrations b.Property("PhoneNumberConfirmed") .HasColumnType("INTEGER"); + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + b.Property("Salt") .IsRequired() .HasMaxLength(100) @@ -273,6 +278,10 @@ namespace AliasServerDb.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("UserEncryptionKeyId") + .HasMaxLength(255) + .HasColumnType("TEXT"); + b.Property("Visible") .HasColumnType("INTEGER"); @@ -286,6 +295,8 @@ namespace AliasServerDb.Migrations b.HasIndex("ToLocal"); + b.HasIndex("UserEncryptionKeyId"); + b.HasIndex("Visible"); b.ToTable("Emails"); @@ -374,6 +385,74 @@ namespace AliasServerDb.Migrations b.ToTable("Logs", (string)null); }); + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AddressDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AddressLocal") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEncryptionKeys"); + }); + modelBuilder.Entity("AliasServerDb.Vault", b => { b.Property("Id") @@ -541,6 +620,17 @@ namespace AliasServerDb.Migrations b.Navigation("User"); }); + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey") + .WithMany("Emails") + .HasForeignKey("UserEncryptionKeyId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("EncryptionKey"); + }); + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => { b.HasOne("AliasServerDb.Email", "Email") @@ -552,10 +642,10 @@ namespace AliasServerDb.Migrations b.Navigation("Email"); }); - modelBuilder.Entity("AliasServerDb.Vault", b => + modelBuilder.Entity("AliasServerDb.UserEmailClaim", b => { b.HasOne("AliasServerDb.AliasVaultUser", "User") - .WithMany() + .WithMany("EmailClaims") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -563,10 +653,46 @@ namespace AliasServerDb.Migrations b.Navigation("User"); }); + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("EncryptionKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany("Vaults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Navigation("EmailClaims"); + + b.Navigation("EncryptionKeys"); + + b.Navigation("Vaults"); + }); + modelBuilder.Entity("AliasServerDb.Email", b => { b.Navigation("Attachments"); }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Navigation("Emails"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Databases/AliasServerDb/UserEmailClaim.cs b/src/Databases/AliasServerDb/UserEmailClaim.cs new file mode 100644 index 000000000..4392bfbc4 --- /dev/null +++ b/src/Databases/AliasServerDb/UserEmailClaim.cs @@ -0,0 +1,62 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- +namespace AliasServerDb; + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +/// +/// UserEmailClaim object. This object is used to reserve an email address for a user. +/// +public class UserEmailClaim +{ + /// + /// Gets or sets the ID. + /// + [Key] + public Guid Id { get; set; } + + /// + /// Gets or sets user ID foreign key. + /// + [StringLength(255)] + public string UserId { get; set; } = null!; + + /// + /// Gets or sets foreign key to the AliasVaultUser object. + /// + [ForeignKey("UserId")] + public virtual AliasVaultUser User { get; set; } = null!; + + /// + /// Gets or sets the full email address. + /// + [StringLength(255)] + public string Address { get; set; } = null!; + + /// + /// Gets or sets the email adress local part. + /// + [StringLength(255)] + public string AddressLocal { get; set; } = null!; + + /// + /// Gets or sets the email adress domain part. + /// + [StringLength(255)] + public string AddressDomain { get; set; } = null!; + + /// + /// Gets or sets created timestamp. + /// + public DateTime CreatedAt { get; set; } + + /// + /// Gets or sets updated timestamp. + /// + public DateTime UpdatedAt { get; set; } +} diff --git a/src/Databases/AliasServerDb/UserEncryptionKey.cs b/src/Databases/AliasServerDb/UserEncryptionKey.cs new file mode 100644 index 000000000..4ed432c5c --- /dev/null +++ b/src/Databases/AliasServerDb/UserEncryptionKey.cs @@ -0,0 +1,55 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- +namespace AliasServerDb; + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +/// +/// UserEncryptionKey object. This object is used for storing user public keys for encryption. +/// +public class UserEncryptionKey +{ + /// + /// Gets or sets the ID. + /// + [Key] + public Guid Id { get; set; } + + /// + /// Gets or sets user ID foreign key. + /// + [StringLength(255)] + public string UserId { get; set; } = null!; + + /// + /// Gets or sets foreign key to the AliasVaultUser object. + /// + [ForeignKey("UserId")] + public virtual AliasVaultUser User { get; set; } = null!; + + /// + /// Gets or sets the public key. + /// + [StringLength(2000)] + public string PublicKey { get; set; } = null!; + + /// + /// Gets or sets created timestamp. + /// + public DateTime CreatedAt { get; set; } + + /// + /// Gets or sets updated timestamp. + /// + public DateTime UpdatedAt { get; set; } + + /// + /// Gets or sets the collection of Emails that are using this encryption key. + /// + public virtual ICollection Emails { get; set; } = []; +} diff --git a/src/Services/AliasVault.SmtpService/Config.cs b/src/Services/AliasVault.SmtpService/Config.cs index 7a6938947..7fe7fdf1d 100644 --- a/src/Services/AliasVault.SmtpService/Config.cs +++ b/src/Services/AliasVault.SmtpService/Config.cs @@ -21,5 +21,5 @@ public class Config /// Gets or sets the domains that the SMTP service is listening for. /// Domains not in this list will be rejected. /// - public List AllowedToDomains { get; set; } = []; + public List AllowedToDomains { get; set; } = []; }