From 2016117d4719e5dda1361ec1179ebe19ff0f3ccf Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 29 Jul 2024 11:18:38 +0200 Subject: [PATCH 01/11] Add PKI tables (#117) --- .../Controllers/VaultController.cs | 49 +- src/AliasVault.Client/Config.cs | 27 + src/AliasVault.Client/Program.cs | 14 + .../Services/Database/DbService.cs | 17 +- src/AliasVault.Client/entrypoint.sh | 6 + .../wwwroot/appsettings.json | 3 +- src/AliasVault.Shared/Models/WebApi/Vault.cs | 9 +- .../AliasServerDb/AliasServerDbContext.cs | 31 + src/Databases/AliasServerDb/AliasVaultUser.cs | 17 + src/Databases/AliasServerDb/Email.cs | 14 + ...9090556_AddEncryptionKeyTables.Designer.cs | 702 ++++++++++++++++++ .../20240729090556_AddEncryptionKeyTables.cs | 126 ++++ .../AliasServerDbContextModelSnapshot.cs | 130 +++- src/Databases/AliasServerDb/UserEmailClaim.cs | 62 ++ .../AliasServerDb/UserEncryptionKey.cs | 55 ++ src/Services/AliasVault.SmtpService/Config.cs | 2 +- 16 files changed, 1255 insertions(+), 9 deletions(-) create mode 100644 src/AliasVault.Client/Config.cs create mode 100644 src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.Designer.cs create mode 100644 src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.cs create mode 100644 src/Databases/AliasServerDb/UserEmailClaim.cs create mode 100644 src/Databases/AliasServerDb/UserEncryptionKey.cs 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; } = []; } From e64893c26c4b381531d97db575da947589b04eba Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 29 Jul 2024 14:06:11 +0200 Subject: [PATCH 02/11] Add JSInterop RSA methods, refactor JSInterop on client (#117) --- .../Controllers/VaultController.cs | 67 +++- .../Attachments/AttachmentUploader.razor | 5 +- .../Attachments/AttachmentViewer.razor | 4 +- .../Components/Forms/CopyPasteFormRow.razor | 4 +- .../Main/Components/Forms/EditFormRow.razor | 1 - .../Main/Layout/TopMenu.razor | 4 +- src/AliasVault.Client/Main/Pages/MainBase.cs | 4 +- .../Main/Pages/Settings/Vault.razor | 7 +- .../StatusMessages/PendingMigrations.razor | 5 +- src/AliasVault.Client/Program.cs | 1 + .../Services/Auth/AuthService.cs | 8 +- .../Services/Database/DbService.cs | 54 ++- .../Services/JsInteropService.cs | 101 ++++++ src/AliasVault.Client/entrypoint.sh | 7 +- .../wwwroot/appsettings.json | 2 +- .../wwwroot/js/cryptoInterop.js | 93 ++++++ src/AliasVault.Shared/Models/WebApi/Vault.cs | 9 +- .../AliasClientDb/AliasClientDbContext.cs | 5 + src/Databases/AliasClientDb/EncryptionKey.cs | 48 +++ ...40729105618_1.1.0-AddPkiTables.Designer.cs | 308 ++++++++++++++++++ .../20240729105618_1.1.0-AddPkiTables.cs | 39 +++ .../AliasClientDbContextModelSnapshot.cs | 32 +- ...104544_AddEncryptionKeyTables.Designer.cs} | 5 +- ... 20240729104544_AddEncryptionKeyTables.cs} | 4 +- .../AliasServerDbContextModelSnapshot.cs | 3 + .../AliasServerDb/UserEncryptionKey.cs | 5 + .../Utilities/RsaEncryptionTests.cs | 153 +++++++++ ...ionTests.cs => SrpArgonEncryptionTests.cs} | 44 +-- src/Utilities/Cryptography/Encryption.cs | 120 ++++++- 29 files changed, 1074 insertions(+), 68 deletions(-) create mode 100644 src/AliasVault.Client/Services/JsInteropService.cs mode change 100644 => 100755 src/AliasVault.Client/entrypoint.sh create mode 100644 src/Databases/AliasClientDb/EncryptionKey.cs create mode 100644 src/Databases/AliasClientDb/Migrations/20240729105618_1.1.0-AddPkiTables.Designer.cs create mode 100644 src/Databases/AliasClientDb/Migrations/20240729105618_1.1.0-AddPkiTables.cs rename src/Databases/AliasServerDb/Migrations/{20240729090556_AddEncryptionKeyTables.Designer.cs => 20240729104544_AddEncryptionKeyTables.Designer.cs} (99%) rename src/Databases/AliasServerDb/Migrations/{20240729090556_AddEncryptionKeyTables.cs => 20240729104544_AddEncryptionKeyTables.cs} (97%) create mode 100644 src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs rename src/Tests/AliasVault.UnitTests/Utilities/{EncryptionTests.cs => SrpArgonEncryptionTests.cs} (96%) diff --git a/src/AliasVault.Api/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index 45570a76c..825ce2dcb 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, new List(), DateTime.MinValue, DateTime.MinValue)); + return Ok(new Shared.Models.WebApi.Vault(string.Empty, string.Empty, string.Empty, new List(), DateTime.MinValue, DateTime.MinValue)); } - return Ok(new Shared.Models.WebApi.Vault(vault.VaultBlob, vault.Version, new List(), vault.CreatedAt, vault.UpdatedAt)); + return Ok(new Shared.Models.WebApi.Vault(vault.VaultBlob, vault.Version, string.Empty, new List(), vault.CreatedAt, vault.UpdatedAt)); } /// @@ -122,6 +122,12 @@ public class VaultController(IDbContextFactory dbContextFa await UpdateUserEmailClaims(context, user.Id, model.EmailAddressList); } + // Sync user public key if supplied. + if (!string.IsNullOrEmpty(model.EncryptionPublicKey)) + { + await UpdateUserPublicKey(context, user.Id, model.EncryptionPublicKey); + } + return Ok(); } @@ -163,4 +169,61 @@ public class VaultController(IDbContextFactory dbContextFa // Email claims are considered permanent. await context.SaveChangesAsync(); } + + /// + /// Updates the user's public key based on the provided public key. If it already exists, do nothing. + /// + /// The database context. + /// The ID of the user. + /// The new public key to sync and set as default. + /// A task representing the asynchronous operation. + private async Task UpdateUserPublicKey(AliasServerDbContext context, string userId, string newPublicKey) + { + // Get all existing user public keys. + var publicKeyExists = await context.UserEncryptionKeys + .AnyAsync(x => x.UserId == userId && x.IsPrimary && x.PublicKey == newPublicKey); + + // If the public key already exists and is marked as primary (default), do nothing. + if (publicKeyExists) + { + return; + } + + // Update all existing keys to not be primary. + var otherKeys = await context.UserEncryptionKeys + .Where(x => x.UserId == userId) + .ToListAsync(); + + foreach (var key in otherKeys) + { + key.IsPrimary = false; + key.UpdatedAt = timeProvider.UtcNow; + } + + // Check if the new public key already exists but is not marked as primary. + var existingPublicKey = await context.UserEncryptionKeys + .FirstOrDefaultAsync(x => x.UserId == userId && x.PublicKey == newPublicKey); + + if (existingPublicKey is not null) + { + // Set the existing key to be primary. + existingPublicKey.IsPrimary = true; + existingPublicKey.UpdatedAt = timeProvider.UtcNow; + await context.SaveChangesAsync(); + return; + } + + // Public key is new, so create it. + var newPublicKeyEntry = new UserEncryptionKey + { + UserId = userId, + PublicKey = newPublicKey, + IsPrimary = true, + CreatedAt = timeProvider.UtcNow, + UpdatedAt = timeProvider.UtcNow, + }; + context.UserEncryptionKeys.Add(newPublicKeyEntry); + + await context.SaveChangesAsync(); + } } diff --git a/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor b/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor index 42d50f7f2..9ba71f8b1 100644 --- a/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor +++ b/src/AliasVault.Client/Main/Components/Attachments/AttachmentUploader.razor @@ -1,5 +1,4 @@ @using System.IO -@inject IJSRuntime JSRuntime
@@ -74,7 +73,7 @@ catch (Exception ex) { statusMessage = $"Error uploading file: {ex.Message}"; - await JSRuntime.InvokeVoidAsync("console.error", ex.Message); + Console.Error.WriteLine("Error uploading file: {0}", ex.Message); } } @@ -93,7 +92,7 @@ catch (Exception ex) { statusMessage = $"Error deleting attachment: {ex.Message}"; - await JSRuntime.InvokeVoidAsync("console.error", ex.Message); + Console.Error.WriteLine("Error deleting file: {0}", ex.Message); } StateHasChanged(); diff --git a/src/AliasVault.Client/Main/Components/Attachments/AttachmentViewer.razor b/src/AliasVault.Client/Main/Components/Attachments/AttachmentViewer.razor index 467c2cab5..af0f63698 100644 --- a/src/AliasVault.Client/Main/Components/Attachments/AttachmentViewer.razor +++ b/src/AliasVault.Client/Main/Components/Attachments/AttachmentViewer.razor @@ -1,4 +1,4 @@ -@inject IJSRuntime JSRuntime +@inject JsInteropService JsInteropService

Attachments

@@ -47,7 +47,7 @@ { if (attachment.Blob != null) { - await JSRuntime.InvokeVoidAsync("downloadFileFromStream", attachment.Filename, attachment.Blob); + await JsInteropService.DownloadFileFromStream(attachment.Filename, attachment.Blob); } else { diff --git a/src/AliasVault.Client/Main/Components/Forms/CopyPasteFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/CopyPasteFormRow.razor index d14cea7ea..df1e5b499 100644 --- a/src/AliasVault.Client/Main/Components/Forms/CopyPasteFormRow.razor +++ b/src/AliasVault.Client/Main/Components/Forms/CopyPasteFormRow.razor @@ -1,5 +1,5 @@ @inject ClipboardCopyService ClipboardCopyService -@inject IJSRuntime JsRuntime +@inject JsInteropService JsInteropService @implements IDisposable @@ -37,7 +37,7 @@ private async Task CopyToClipboard() { - await JsRuntime.InvokeVoidAsync("navigator.clipboard.writeText", Value); + await JsInteropService.CopyToClipboard(Value); ClipboardCopyService.SetCopied(_inputId); // After 2 seconds, reset the copied state if it's still the same element diff --git a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor index 42a5acb99..0b4748785 100644 --- a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor +++ b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor @@ -1,5 +1,4 @@ @inject ClipboardCopyService ClipboardCopyService -@inject IJSRuntime JsRuntime
diff --git a/src/AliasVault.Client/Main/Layout/TopMenu.razor b/src/AliasVault.Client/Main/Layout/TopMenu.razor index 303d6f374..961c11dd1 100644 --- a/src/AliasVault.Client/Main/Layout/TopMenu.razor +++ b/src/AliasVault.Client/Main/Layout/TopMenu.razor @@ -119,9 +119,9 @@ await base.OnAfterRenderAsync(firstRender); if (firstRender) { - await Js.InvokeVoidAsync("window.initTopMenu"); + await JsInteropService.InitTopMenu(); DotNetObjectReference objRef = DotNetObjectReference.Create(this); - await Js.InvokeVoidAsync("window.registerClickOutsideHandler", objRef); + await JsInteropService.RegisterClickOutsideHandler(objRef); } } diff --git a/src/AliasVault.Client/Main/Pages/MainBase.cs b/src/AliasVault.Client/Main/Pages/MainBase.cs index 3755fa5b5..bd999bb84 100644 --- a/src/AliasVault.Client/Main/Pages/MainBase.cs +++ b/src/AliasVault.Client/Main/Pages/MainBase.cs @@ -49,10 +49,10 @@ public class MainBase : OwningComponentBase public GlobalLoadingService GlobalLoadingSpinner { get; set; } = null!; /// - /// Gets or sets the IJSRuntime. + /// Gets or sets the JsInteropService. /// [Inject] - public IJSRuntime Js { get; set; } = null!; + public JsInteropService JsInteropService { get; set; } = null!; /// /// Gets or sets the DbService. diff --git a/src/AliasVault.Client/Main/Pages/Settings/Vault.razor b/src/AliasVault.Client/Main/Pages/Settings/Vault.razor index fc0145b90..586df31f1 100644 --- a/src/AliasVault.Client/Main/Pages/Settings/Vault.razor +++ b/src/AliasVault.Client/Main/Pages/Settings/Vault.razor @@ -40,8 +40,6 @@
- - @if (IsImporting) {

Loading...

@@ -78,7 +76,7 @@ else if (!string.IsNullOrEmpty(ImportSuccessMessage)) using (MemoryStream memoryStream = new MemoryStream(fileBytes)) { // Invoke JavaScript to initiate the download - await Js.InvokeVoidAsync("downloadFileFromStream", "aliasvault-client.sqlite", memoryStream.ToArray()); + await JsInteropService.DownloadFileFromStream("aliasvault-client.sqlite", memoryStream.ToArray()); } } catch (Exception ex) @@ -99,9 +97,8 @@ else if (!string.IsNullOrEmpty(ImportSuccessMessage)) using (MemoryStream memoryStream = new MemoryStream(csvBytes)) { // Invoke JavaScript to initiate the download - await Js.InvokeVoidAsync("downloadFileFromStream", "aliasvault-client.csv", memoryStream.ToArray()); + await JsInteropService.DownloadFileFromStream("aliasvault-client.csv", memoryStream.ToArray()); } - } catch (Exception ex) { diff --git a/src/AliasVault.Client/Main/Pages/Sync/StatusMessages/PendingMigrations.razor b/src/AliasVault.Client/Main/Pages/Sync/StatusMessages/PendingMigrations.razor index 52d6342e3..a278442cb 100644 --- a/src/AliasVault.Client/Main/Pages/Sync/StatusMessages/PendingMigrations.razor +++ b/src/AliasVault.Client/Main/Pages/Sync/StatusMessages/PendingMigrations.razor @@ -76,7 +76,10 @@ // Migrate the database if (await DbService.MigrateDatabaseAsync()) { - // Migration successful + // Save the database to the server. + await DbService.SaveDatabaseAsync(); + + // Migration successful. GlobalNotificationService.AddSuccessMessage("Vault upgrade successful.", true); } else diff --git a/src/AliasVault.Client/Program.cs b/src/AliasVault.Client/Program.cs index c983f7f54..176cb380b 100644 --- a/src/AliasVault.Client/Program.cs +++ b/src/AliasVault.Client/Program.cs @@ -67,6 +67,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddAuthorizationCore(); diff --git a/src/AliasVault.Client/Services/Auth/AuthService.cs b/src/AliasVault.Client/Services/Auth/AuthService.cs index 9ebaa63d5..72fb32275 100644 --- a/src/AliasVault.Client/Services/Auth/AuthService.cs +++ b/src/AliasVault.Client/Services/Auth/AuthService.cs @@ -98,7 +98,7 @@ public class AuthService(HttpClient httpClient, ILocalStorageService localStorag /// /// Get encryption key. /// - /// Encryption key as byte[]. + /// SrpArgonEncryption key as byte[]. public byte[] GetEncryptionKeyAsync() { return _encryptionKey; @@ -107,7 +107,7 @@ public class AuthService(HttpClient httpClient, ILocalStorageService localStorag /// /// Get encryption key as base64 string. /// - /// Encryption key as base64 string. + /// SrpArgonEncryption key as base64 string. public string GetEncryptionKeyAsBase64Async() { if (environment.IsDevelopment() && configuration["UseDebugEncryptionKey"] == "true") @@ -131,7 +131,7 @@ public class AuthService(HttpClient httpClient, ILocalStorageService localStorag var encryptionKey = GetEncryptionKeyAsBase64Async(); if (encryptionKey == string.Empty || encryptionKey == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") { - // Encryption key is empty or base64 encoded empty string. + // SrpArgonEncryption key is empty or base64 encoded empty string. return false; } @@ -141,7 +141,7 @@ public class AuthService(HttpClient httpClient, ILocalStorageService localStorag /// /// Stores the encryption key asynchronously in-memory. /// - /// Encryption key. + /// SrpArgonEncryption key. public void StoreEncryptionKey(byte[] newKey) { _encryptionKey = newKey; diff --git a/src/AliasVault.Client/Services/Database/DbService.cs b/src/AliasVault.Client/Services/Database/DbService.cs index 9efdfa5ff..d53f4cca9 100644 --- a/src/AliasVault.Client/Services/Database/DbService.cs +++ b/src/AliasVault.Client/Services/Database/DbService.cs @@ -12,6 +12,7 @@ using System.Net.Http.Json; using AliasClientDb; using AliasVault.Client.Services.Auth; using AliasVault.Shared.Models.WebApi; +using Cryptography; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.JSInterop; @@ -24,7 +25,7 @@ using Microsoft.JSInterop; public class DbService : IDisposable { private readonly AuthService _authService; - private readonly IJSRuntime _jsRuntime; + private readonly JsInteropService _jsInteropService; private readonly HttpClient _httpClient; private readonly DbServiceState _state = new(); private readonly Config _config; @@ -38,13 +39,13 @@ public class DbService : IDisposable /// Initializes a new instance of the class. ///
/// AuthService. - /// IJSRuntime. + /// JsInteropService. /// HttpClient. /// Config instance. - public DbService(AuthService authService, IJSRuntime jsRuntime, HttpClient httpClient, Config config) + public DbService(AuthService authService, JsInteropService jsInteropService, HttpClient httpClient, Config config) { _authService = authService; - _jsRuntime = jsRuntime; + _jsInteropService = jsInteropService; _httpClient = httpClient; _config = config; @@ -125,8 +126,8 @@ public class DbService : IDisposable string base64String = await ExportSqliteToBase64Async(); - // Encrypt base64 string using IJSInterop. - string encryptedBase64String = await _jsRuntime.InvokeAsync("cryptoInterop.encrypt", base64String, _authService.GetEncryptionKeyAsBase64Async()); + // SymmetricEncrypt base64 string using IJSInterop. + string encryptedBase64String = await _jsInteropService.SymmetricEncrypt(base64String, _authService.GetEncryptionKeyAsBase64Async()); // Save to webapi. var success = await SaveToServerAsync(encryptedBase64String); @@ -411,7 +412,7 @@ public class DbService : IDisposable } // Attempt to decrypt the database blob. - string decryptedBase64String = await _jsRuntime.InvokeAsync("cryptoInterop.decrypt", vault.Blob, _authService.GetEncryptionKeyAsBase64Async()); + string decryptedBase64String = await _jsInteropService.SymmetricDecrypt(vault.Blob, _authService.GetEncryptionKeyAsBase64Async()); await ImportDbContextFromBase64Async(decryptedBase64String); // Check if database is up to date with migrations. @@ -449,13 +450,19 @@ public class DbService : IDisposable 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(); + // Filter the list of email addresses to only include those that are in the allowed domains. + emailAddresses = emailAddresses + .Where(email => _config.SmtpAllowedDomains.Any(domain => email.EndsWith(domain))) + .ToList(); + + var encryptionKey = await GetOrCreateEncryptionKeyAsync(); + var databaseVersion = await GetCurrentDatabaseVersionAsync(); - var vaultObject = new Vault(encryptedDatabase, databaseVersion, emailAddresses, DateTime.Now, DateTime.Now); + var vaultObject = new Vault(encryptedDatabase, databaseVersion, encryptionKey.PublicKey, emailAddresses, DateTime.Now, DateTime.Now); try { @@ -467,4 +474,33 @@ public class DbService : IDisposable return false; } } + + /// + /// Get the default public/private encryption key, if it does not yet exist, create it. + /// + /// A representing the asynchronous operation. + private async Task GetOrCreateEncryptionKeyAsync() + { + var encryptionKey = await _dbContext.EncryptionKeys.FirstOrDefaultAsync(x => x.IsPrimary); + if (encryptionKey is not null) + { + return encryptionKey; + } + + // Create a new encryption key via JSInterop, .NET WASM does not support crypto operations natively (yet). + var keyPair = await _jsInteropService.GenerateRsaKeyPair(); + + encryptionKey = new EncryptionKey + { + PublicKey = keyPair.PublicKey, + PrivateKey = keyPair.PrivateKey, + IsPrimary = true, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now, + }; + await _dbContext.EncryptionKeys.AddAsync(encryptionKey); + await _dbContext.SaveChangesAsync(); + + return encryptionKey; + } } diff --git a/src/AliasVault.Client/Services/JsInteropService.cs b/src/AliasVault.Client/Services/JsInteropService.cs new file mode 100644 index 000000000..f04c6fbf9 --- /dev/null +++ b/src/AliasVault.Client/Services/JsInteropService.cs @@ -0,0 +1,101 @@ +//----------------------------------------------------------------------- +// +// 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.Services; + +using System.ComponentModel; +using System.Text.Json; +using Microsoft.JSInterop; + +/// +/// JavaScript interop service for calling JavaScript functions from C#. +/// +/// IJSRuntime. +public class JsInteropService(IJSRuntime jsRuntime) +{ + /// + /// Symmetrically encrypts a string using the provided encryption key. + /// + /// Plain text to encrypt. + /// Encryption key to use. + /// Encrypted ciphertext. + public async Task SymmetricEncrypt(string plaintext, string encryptionKey) => + await jsRuntime.InvokeAsync("cryptoInterop.encrypt", plaintext, encryptionKey); + + /// + /// Symmetrically decrypts a string using the provided encryption key. + /// + /// Cipher text to decrypt. + /// Encryption key to use. + /// Encrypted ciphertext. + public async Task SymmetricDecrypt(string ciphertext, string encryptionKey) => + await jsRuntime.InvokeAsync("cryptoInterop.decrypt", ciphertext, encryptionKey); + + /// + /// Downloads a file from a stream. + /// + /// Filename of the download. + /// Blob byte array to download. + /// Task. + public async Task DownloadFileFromStream(string filename, byte[] blob) => + await jsRuntime.InvokeVoidAsync("downloadFileFromStream", filename, blob); + + /// + /// Copy a string to the browsers clipboard. + /// + /// Value to copy to clipboard. + /// Task. + public async Task CopyToClipboard(string value) => + await jsRuntime.InvokeVoidAsync("navigator.clipboard.writeText", value); + + /// + /// Initializes the top menu. + /// + /// Task. + public async Task InitTopMenu() => + await jsRuntime.InvokeVoidAsync("window.initTopMenu"); + + /// + /// Registers a click outside handler. + /// + /// Component type. + /// DotNetObjectReference. + /// Task. + public async Task RegisterClickOutsideHandler(DotNetObjectReference objRef) + where TComponent : class + { + await jsRuntime.InvokeVoidAsync("window.registerClickOutsideHandler", objRef); + } + + /// + /// Generates a new RSA key pair. + /// + /// Tuple with public and private key. + public async Task<(string PublicKey, string PrivateKey)> GenerateRsaKeyPair() + { + var result = await jsRuntime.InvokeAsync("rsaInterop.generateRsaKeyPair"); + return (result.GetProperty("publicKey").GetString()!, result.GetProperty("privateKey").GetString()!); + } + + /// + /// Encrypts a plaintext with a public key. + /// + /// Plain text to encrypt. + /// Public key to use for encryption. + /// Encrypted ciphertext. + public async Task EncryptWithPublicKey(string plaintext, string publicKey) => + await jsRuntime.InvokeAsync("rsaInterop.encryptWithPublicKey", plaintext, publicKey); + + /// + /// Decrypts a ciphertext with a private key. + /// + /// Ciphertext to decrypt. + /// Private key to use for decryption. + /// Decrypted string. + public async Task DecryptWithPrivateKey(string ciphertext, string privateKey) => + await jsRuntime.InvokeAsync("rsaInterop.decryptWithPrivateKey", ciphertext, privateKey); +} diff --git a/src/AliasVault.Client/entrypoint.sh b/src/AliasVault.Client/entrypoint.sh old mode 100644 new mode 100755 index 56b1db8fc..8651a6c25 --- a/src/AliasVault.Client/entrypoint.sh +++ b/src/AliasVault.Client/entrypoint.sh @@ -12,7 +12,12 @@ sed -i "s|http://localhost:5092|${API_URL}|g" /usr/share/nginx/html/appsettings. # 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 + +# Convert comma-separated list to JSON array +json_array=$(echo $domains | awk '{split($0,a,","); printf "["; for(i=1;i<=length(a);i++) {printf "\"%s\"", a[i]; if(i), decrypt: (function(*, *): Promise)}} + */ window.cryptoInterop = { encrypt: async function (plaintext, base64Key) { const key = await window.crypto.subtle.importKey( @@ -51,3 +55,92 @@ window.cryptoInterop = { return decoder.decode(decrypted); } }; + +/** + * RSA (asymmetric) encryption and decryption functions. + * @type {{decryptWithPrivateKey: (function(string, string): Promise), encryptWithPublicKey: (function(string, string): Promise), generateRsaKeyPair: (function(): Promise<{privateKey: string, publicKey: string}>)}} + */ +window.rsaInterop = { + /** + * Generates a new RSA key pair. + * @returns {Promise<{publicKey: string, privateKey: string}>} A promise that resolves to an object containing the public and private keys as JWK strings. + */ + generateRsaKeyPair : async function() { + const keyPair = await window.crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["encrypt", "decrypt"] + ); + + const publicKey = await window.crypto.subtle.exportKey("jwk", keyPair.publicKey); + const privateKey = await window.crypto.subtle.exportKey("jwk", keyPair.privateKey); + + return { + publicKey: JSON.stringify(publicKey), + privateKey: JSON.stringify(privateKey) + }; + }, + /** + * Encrypts a plaintext string using an RSA public key. + * @param {string} plaintext - The plaintext to encrypt. + * @param {string} publicKey - The public key in JWK format. + * @returns {Promise} A promise that resolves to the encrypted data as a base64-encoded string. + */ + encryptWithPublicKey : async function(plaintext, publicKey) { + const publicKeyObj = await window.crypto.subtle.importKey( + "jwk", + JSON.parse(publicKey), + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + false, + ["encrypt"] + ); + + const encodedPlaintext = new TextEncoder().encode(plaintext); + const cipherBuffer = await window.crypto.subtle.encrypt( + { + name: "RSA-OAEP" + }, + publicKeyObj, + encodedPlaintext + ); + + return btoa(String.fromCharCode.apply(null, new Uint8Array(cipherBuffer))); + }, + /** + * Decrypts a ciphertext string using an RSA private key. + * @param {string} ciphertext - The base64-encoded ciphertext to decrypt. + * @param {string} privateKey - The private key in JWK format. + * @returns {Promise} A promise that resolves to the decrypted plaintext. + */ + decryptWithPrivateKey : async function(ciphertext, privateKey) { + const privateKeyObj = await window.crypto.subtle.importKey( + "jwk", + JSON.parse(privateKey), + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + false, + ["decrypt"] + ); + + const cipherBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0)); + const plaintextBuffer = await window.crypto.subtle.decrypt( + { + name: "RSA-OAEP" + }, + privateKeyObj, + cipherBuffer + ); + + return new TextDecoder().decode(plaintextBuffer); + } +}; diff --git a/src/AliasVault.Shared/Models/WebApi/Vault.cs b/src/AliasVault.Shared/Models/WebApi/Vault.cs index 8b0a3edad..d682f4c70 100644 --- a/src/AliasVault.Shared/Models/WebApi/Vault.cs +++ b/src/AliasVault.Shared/Models/WebApi/Vault.cs @@ -17,13 +17,15 @@ public class Vault /// /// Blob. /// Version of the vault data model (migration). + /// Public encryption key that server requires to encrypt user data such as received emails. /// List of email addresses that are used in the vault and should be registered. /// CreatedAt. /// UpdatedAt. - public Vault(string blob, string version, List emailAddressList, DateTime createdAt, DateTime updatedAt) + public Vault(string blob, string version, string encryptionPublicKey, List emailAddressList, DateTime createdAt, DateTime updatedAt) { Blob = blob; Version = version; + EncryptionPublicKey = encryptionPublicKey; EmailAddressList = emailAddressList; CreatedAt = createdAt; UpdatedAt = updatedAt; @@ -39,6 +41,11 @@ public class Vault /// public string Version { get; set; } + /// + /// Gets or sets the public encryption key that server requires to encrypt user data such as received emails. + /// + public string EncryptionPublicKey { get; set; } + /// /// Gets or sets the list of email addresses that are used in the vault and should be registered on the server. /// diff --git a/src/Databases/AliasClientDb/AliasClientDbContext.cs b/src/Databases/AliasClientDb/AliasClientDbContext.cs index 6a5b33d44..c208ae485 100644 --- a/src/Databases/AliasClientDb/AliasClientDbContext.cs +++ b/src/Databases/AliasClientDb/AliasClientDbContext.cs @@ -68,6 +68,11 @@ public class AliasClientDbContext : DbContext /// public DbSet Services { get; set; } = null!; + /// + /// Gets or sets the EncryptionKey DbSet. + /// + public DbSet EncryptionKeys { get; set; } = null!; + /// /// The OnModelCreating method. /// diff --git a/src/Databases/AliasClientDb/EncryptionKey.cs b/src/Databases/AliasClientDb/EncryptionKey.cs new file mode 100644 index 000000000..8afe07aa4 --- /dev/null +++ b/src/Databases/AliasClientDb/EncryptionKey.cs @@ -0,0 +1,48 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- +namespace AliasClientDb; + +using System.ComponentModel.DataAnnotations; + +/// +/// The EncryptionKey entity. +/// +public class EncryptionKey +{ + /// + /// Gets or sets the encryption key primary key. + /// + [Key] + public Guid Id { get; set; } + + /// + /// Gets or sets the public key. + /// + [StringLength(2000)] + public string PublicKey { get; set; } = null!; + + /// + /// Gets or sets the private key. + /// + [StringLength(2000)] + public string PrivateKey { get; set; } = null!; + + /// + /// Gets or sets a value indicating whether this public/private key is the primary key to use by default. + /// + public bool IsPrimary { get; set; } + + /// + /// Gets or sets the created timestamp. + /// + public DateTime CreatedAt { get; set; } + + /// + /// Gets or sets the updated timestamp. + /// + public DateTime UpdatedAt { get; set; } +} diff --git a/src/Databases/AliasClientDb/Migrations/20240729105618_1.1.0-AddPkiTables.Designer.cs b/src/Databases/AliasClientDb/Migrations/20240729105618_1.1.0-AddPkiTables.Designer.cs new file mode 100644 index 000000000..c5602f339 --- /dev/null +++ b/src/Databases/AliasClientDb/Migrations/20240729105618_1.1.0-AddPkiTables.Designer.cs @@ -0,0 +1,308 @@ +// +using System; +using AliasClientDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasClientDb.Migrations +{ + [DbContext(typeof(AliasClientDbContext))] + [Migration("20240729105618_1.1.0-AddPkiTables")] + partial class _110AddPkiTables + { + /// + 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("AliasClientDb.Alias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddressCity") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("AddressCountry") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("AddressState") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("AddressStreet") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("AddressZipCode") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("BankAccountIBAN") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("Gender") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("Hobbies") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("NickName") + .HasMaxLength(255) + .HasColumnType("VARCHAR"); + + b.Property("PhoneMobile") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Aliases"); + }); + + modelBuilder.Entity("AliasClientDb.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blob") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("Filename") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId"); + + b.ToTable("Attachment"); + }); + + modelBuilder.Entity("AliasClientDb.Credential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AliasId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("ServiceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AliasId"); + + b.HasIndex("ServiceId"); + + b.ToTable("Credentials"); + }); + + modelBuilder.Entity("AliasClientDb.EncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EncryptionKeys"); + }); + + modelBuilder.Entity("AliasClientDb.Password", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId"); + + b.ToTable("Passwords"); + }); + + modelBuilder.Entity("AliasClientDb.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Logo") + .HasColumnType("BLOB"); + + b.Property("Name") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("AliasClientDb.Attachment", b => + { + b.HasOne("AliasClientDb.Credential", "Credential") + .WithMany("Attachments") + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("AliasClientDb.Credential", b => + { + b.HasOne("AliasClientDb.Alias", "Alias") + .WithMany("Credentials") + .HasForeignKey("AliasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AliasClientDb.Service", "Service") + .WithMany("Credentials") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alias"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("AliasClientDb.Password", b => + { + b.HasOne("AliasClientDb.Credential", "Credential") + .WithMany("Passwords") + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("AliasClientDb.Alias", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("AliasClientDb.Credential", b => + { + b.Navigation("Attachments"); + + b.Navigation("Passwords"); + }); + + modelBuilder.Entity("AliasClientDb.Service", b => + { + b.Navigation("Credentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasClientDb/Migrations/20240729105618_1.1.0-AddPkiTables.cs b/src/Databases/AliasClientDb/Migrations/20240729105618_1.1.0-AddPkiTables.cs new file mode 100644 index 000000000..c0e7dcc03 --- /dev/null +++ b/src/Databases/AliasClientDb/Migrations/20240729105618_1.1.0-AddPkiTables.cs @@ -0,0 +1,39 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasClientDb.Migrations +{ + /// + public partial class _110AddPkiTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EncryptionKeys", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + PublicKey = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + PrivateKey = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + IsPrimary = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EncryptionKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EncryptionKeys"); + } + } +} diff --git a/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs b/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs index 94529522e..249a24427 100644 --- a/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs +++ b/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs @@ -16,7 +16,7 @@ namespace AliasClientDb.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("ProductVersion", "8.0.7") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true); @@ -158,6 +158,36 @@ namespace AliasClientDb.Migrations b.ToTable("Credentials"); }); + modelBuilder.Entity("AliasClientDb.EncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EncryptionKeys"); + }); + modelBuilder.Entity("AliasClientDb.Password", b => { b.Property("Id") diff --git a/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.Designer.cs similarity index 99% rename from src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.Designer.cs rename to src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.Designer.cs index 6f5952b34..cc27ee9e6 100644 --- a/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.Designer.cs +++ b/src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace AliasServerDb.Migrations { [DbContext(typeof(AliasServerDbContext))] - [Migration("20240729090556_AddEncryptionKeyTables")] + [Migration("20240729104544_AddEncryptionKeyTables")] partial class AddEncryptionKeyTables { /// @@ -436,6 +436,9 @@ namespace AliasServerDb.Migrations b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + b.Property("PublicKey") .IsRequired() .HasMaxLength(2000) diff --git a/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.cs b/src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.cs similarity index 97% rename from src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.cs rename to src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.cs index 6ece51f37..d38e78c59 100644 --- a/src/Databases/AliasServerDb/Migrations/20240729090556_AddEncryptionKeyTables.cs +++ b/src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.cs @@ -1,4 +1,5 @@ -using System; +// +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -60,6 +61,7 @@ namespace AliasServerDb.Migrations 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), + IsPrimary = table.Column(type: "INTEGER", nullable: false), CreatedAt = table.Column(type: "TEXT", nullable: false), UpdatedAt = table.Column(type: "TEXT", nullable: false) }, diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index f91d18887..d24be653d 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -433,6 +433,9 @@ namespace AliasServerDb.Migrations b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + b.Property("PublicKey") .IsRequired() .HasMaxLength(2000) diff --git a/src/Databases/AliasServerDb/UserEncryptionKey.cs b/src/Databases/AliasServerDb/UserEncryptionKey.cs index 4ed432c5c..fe1b56c55 100644 --- a/src/Databases/AliasServerDb/UserEncryptionKey.cs +++ b/src/Databases/AliasServerDb/UserEncryptionKey.cs @@ -38,6 +38,11 @@ public class UserEncryptionKey [StringLength(2000)] public string PublicKey { get; set; } = null!; + /// + /// Gets or sets a value indicating whether this public key is the primary key to use by default. + /// + public bool IsPrimary { get; set; } + /// /// Gets or sets created timestamp. /// diff --git a/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs new file mode 100644 index 000000000..545c25850 --- /dev/null +++ b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs @@ -0,0 +1,153 @@ +//----------------------------------------------------------------------- +// +// 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.Tests.Utilities; + +using System.Security.Cryptography; +using System.Text.Json; +using Cryptography; + +/// +/// Tests for the SrpArgonEncryption class. +/// +public class RsaEncryptionTests +{ + /// + /// Example public key for RSA encryption tests. This is a public key generated by the JSInterop on the client. + /// + public const string PublicKey = "{\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"encrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\"}"; + + /// + /// Example private key for RSA encryption tests. This is a private key generated by the JSInterop on the client. + /// + public const string PrivateKey = "{\"alg\":\"RSA-OAEP-256\",\"d\":\"KLByToUaseNym1oNkkrTRPQOHfREXywWWaTXhP8AwtXgEKomqv9G-c6aR-K-T6btY2P-oPj268I0rbnRhSEQdrsmUT5_cp8goYGJrx6MFwGlA32x6klXnus6GDsjkXJi7I5eJL17XV99CDOBtTagFxkNdaBpvClUcHTDvncQ5bGAIrNqS7KADoi-E19BxiW_GcSJiVT4H8kDHCkcgTjZx4rKJjTPqqJOLg_poDrvnTJbsjcXP80kQ1AAENRAvDGhSWzP0IYtP1DM_2FzM1s1b_SrUsS3KiO8drR2Kv-PSOvncpaNVnZGElGCraJ3B2Mm-dr3vFjkyWeWPceqyhtYoQ\",\"dp\":\"ttxRg6uB2YLWfkPKUkzAaBWniZDHM4silJX3IgexA5GJBd9GIhUiVEolc_MgmieQbZ10CC65wqcHVv82lgCeqxYHxHWLxxJCrOpvkFlYE8wr_WqOPQEzYKv3KsL6s6Fj7Pbv9WehWpXdlbJUm4Cy5cgUkdH6PXiwBSvfhCQGrYk\",\"dq\":\"YFqlDAVTfvTR2bMJulvWzd_at81CsEmR-lPo91h-3cLpxcLDOlrTP-d3Ass2I4r1PtBT1bKuuHeQ6fZmHH55a6m8XxPEs2BuIxlh9RiFfWbd66969UOnItuawf0rfGneKt1zl4st60T3KXd8-ECrLxdsvOYpOEuNzvIY_b3qitE\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"decrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\",\"p\":\"yUdbuDwmVwKhou5xXUxJfi1eOjN-5F88wtyR4LpgU2OvZ7m-er4hpXx5I2E-KTVX_iIp0Q9VDXhHH-WkN3qg20RXjRoxwgrggYbfdIYdrB-2kbMamq5cOf2XbXGEO8PoDXYoZprIB0EhrD4qVVykPUYg5El0hIKPdfs9LNoOEzs\",\"q\":\"vg93lGTurG0EY179tPr6Qe3ttKEN9zvQ97dZ9034DOWDoWLe-iMKG1-yKmkG4uwC8QqNnm1mPz7EqOuHPPGVTTib9NA4JdM27PUHSPKDUvp0cV4LhF6e-W7tMFk8WbJ2ACqkqhZHYgm-FDkZBCpnehNegTxipLluKa79G__ZHFE\",\"qi\":\"fnI3Wh5aYuxI0R18NTeFKjo1P7_Ck65Gc9O3CmeqiIe58EJaXQEcdwdSOG8aVmn03szXLHEnp7anNIH63f0ericbRYdCQVhcQpvsXzEM_sp4aYmwz45palrjlY4Jc6G6XQn3FwiqqRDvpnXdsunnQ62HHhxmslaEMYHQyLng2ss\"}"; + + /// + /// Tests that GenerateRsaKeyPair method returns a valid key pair. + /// + [Test] + public void GenerateRsaKeyPair_ShouldReturnValidKeyPair() + { + Assert.That(PublicKey, Is.Not.Null); + Assert.That(PrivateKey, Is.Not.Null); + Assert.That(PublicKey, Is.Not.EqualTo(PrivateKey)); + + // Verify that the keys are in valid JSON format + Assert.That(() => JsonSerializer.Deserialize>(PublicKey), Throws.Nothing); + Assert.That(() => JsonSerializer.Deserialize>(PrivateKey), Throws.Nothing); + } + + /// + /// Tests that encryption with public key followed by decryption with private key returns the original plaintext. + /// + [Test] + public void EncryptWithPublicKey_DecryptWithPrivateKey_ShouldReturnOriginalPlaintext() + { + // Example public and private keys as generated by the JSInterop on the client. + string originalPlaintext = "Hello, RSA encryption!"; + + string ciphertext = Encryption.EncryptWithPublicKey(originalPlaintext, PublicKey); + string decryptedText = Encryption.DecryptWithPrivateKey(ciphertext, PrivateKey); + + Assert.That(decryptedText, Is.EqualTo(originalPlaintext)); + } + + /// + /// Tests that encrypting the same plaintext twice produces different ciphertexts. + /// + [Test] + public void EncryptWithPublicKey_ShouldProduceDifferentCiphertextForSamePlaintext() + { + string plaintext = "Same plaintext"; + + string ciphertext1 = Encryption.EncryptWithPublicKey(plaintext, PublicKey); + string ciphertext2 = Encryption.EncryptWithPublicKey(plaintext, PublicKey); + + Assert.That(ciphertext2, Is.Not.EqualTo(ciphertext1)); + } + + /// + /// Tests that decrypting an invalid ciphertext throws an exception. + /// + [Test] + public void DecryptWithPrivateKey_ShouldThrowExceptionForInvalidCiphertext() + { + string invalidCiphertext = "ThisIsNotValidCiphertext"; + + Assert.That( + () => Encryption.DecryptWithPrivateKey(invalidCiphertext, PrivateKey), + Throws.TypeOf()); + } + + /// + /// Tests encryption and decryption with a long plaintext string. + /// + [Test] + public void EncryptDecrypt_ShouldWorkWithLongPlaintext() + { + string longPlaintext = new string('A', 192); // 192 character string + + string ciphertext = Encryption.EncryptWithPublicKey(longPlaintext, PublicKey); + string decryptedText = Encryption.DecryptWithPrivateKey(ciphertext, PrivateKey); + + Assert.That(decryptedText, Is.EqualTo(longPlaintext)); + } + + /// + /// Tests encryption and decryption with special characters. + /// + [Test] + public void EncryptDecrypt_ShouldWorkWithSpecialCharacters() + { + string specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`"; + + string ciphertext = Encryption.EncryptWithPublicKey(specialChars, PublicKey); + string decryptedText = Encryption.DecryptWithPrivateKey(ciphertext, PrivateKey); + + Assert.That(decryptedText, Is.EqualTo(specialChars)); + } + + /// + /// Tests encryption and decryption with Unicode characters from different languages. + /// + [Test] + public void EncryptDecrypt_ShouldWorkWithUnicodeCharacters() + { + string unicodeText = "こんにちは世界! - Здравствуй, мир! - مرحبا بالعالم! - 你好,世界!"; + + string ciphertext = Encryption.EncryptWithPublicKey(unicodeText, PublicKey); + string decryptedText = Encryption.DecryptWithPrivateKey(ciphertext, PrivateKey); + + Assert.That(decryptedText, Is.EqualTo(unicodeText)); + } + + /// + /// Tests that encrypting with an invalid public key throws an exception. + /// + [Test] + public void EncryptWithPublicKey_ShouldThrowExceptionForInvalidPublicKey() + { + string invalidPublicKey = "ThisIsNotAValidPublicKey"; + string plaintext = "Test plaintext"; + + Assert.Throws(() => Encryption.EncryptWithPublicKey(plaintext, invalidPublicKey)); + } + + /// + /// Tests that decrypting with an invalid private key throws an exception. + /// + [Test] + public void DecryptWithPrivateKey_ShouldThrowExceptionForInvalidPrivateKey() + { + string invalidPrivateKey = "ThisIsNotAValidPrivateKey"; + string plaintext = "Test plaintext"; + + string ciphertext = Encryption.EncryptWithPublicKey(plaintext, PublicKey); + + Assert.Throws(() => Encryption.DecryptWithPrivateKey(ciphertext, invalidPrivateKey)); + } +} diff --git a/src/Tests/AliasVault.UnitTests/Utilities/EncryptionTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs similarity index 96% rename from src/Tests/AliasVault.UnitTests/Utilities/EncryptionTests.cs rename to src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs index b30db3c80..c44e55e38 100644 --- a/src/Tests/AliasVault.UnitTests/Utilities/EncryptionTests.cs +++ b/src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) lanedirt. All rights reserved. // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // @@ -12,9 +12,9 @@ using Org.BouncyCastle.Crypto; using SecureRemotePassword; /// -/// Tests for the Encryption class. +/// Tests for the SrpArgonEncryption class. /// -public class EncryptionTests +public class SrpArgonEncryptionTests { /// /// Test basic encryption and decryption using default encryption logic (Argon2id and AES-256). @@ -28,19 +28,19 @@ public class EncryptionTests string plaintext = "Hello, World!"; // Derive a key from the password using Argon2id - byte[] key = Cryptography.Encryption.DeriveKeyFromPassword(password, salt); + byte[] key = Encryption.DeriveKeyFromPassword(password, salt); Console.WriteLine($"Derived key: {key.Length} bytes (hex: {BitConverter.ToString(key).Replace("-", string.Empty)})"); - // Encrypt the plaintext - string encrypted = Cryptography.Encryption.Encrypt(plaintext, key); + // SymmetricEncrypt the plaintext + string encrypted = Encryption.SymmetricEncrypt(plaintext, key); Console.WriteLine($"Encrypted: {encrypted}"); Assert.That(encrypted, Is.Not.Null); Assert.That(encrypted, Is.Not.Empty); Assert.That(encrypted, Is.Not.EqualTo(plaintext)); - // Decrypt the ciphertext - string decrypted = Cryptography.Encryption.Decrypt(encrypted, key); + // SymmetricDecrypt the ciphertext + string decrypted = Encryption.SymmetricDecrypt(encrypted, key); Console.WriteLine($"Decrypted: {decrypted}"); Assert.That(decrypted, Is.EqualTo(plaintext)); } @@ -49,14 +49,14 @@ public class EncryptionTests /// Test that the used JS encryption implementation is compatible with the C# encryption implementation. /// [Test] - public void TestJSEncryption() + public void TestJsEncryption() { string jsEncryptionKeyBase64 = "vtIsIn3D9oZcQ2ssfXLwM6EduYbW3b1tFSZPzmhhy+Y="; string jsEncryptedBase64Contents = "duGjA5Sq3hojf8FXQqyvA7INHcT/QCO4W6tQ7HD2B/tg9TkThv1rs1BnWfzY8s5n++fPxzepbpFD+CvAzOXC3rLEth2fiLIwngckfDY3buaUQlySImlmRnZ2xQzu3oF4QtiebwaLuQPXyGnyhX/qbVHvopw9LAwHC4/33kksN9uayMROnY8RZy4Qy2if0iIbtqHsslwK5ZEQTte0THjkUTUA1dIFrndY3iMig/HH5ofAdlN+nF8uLQ2u9yUsg/0xmnDk4P27KdlEu7Qq3A8hi4SW5CxBiZGx2ezGLJAw30o/YUcof5CejQCikhDj+YQKoeHIdXWqg5FX3lI4lhHl7/usXpYpHzGPjQWSUGBQ9Hrah5R2vvORqLrQ/pnnih0CwsOyKQf2ueamCoArcGyi7yfhy98G42F1v0q97M+EYLa6i8YO9hA2UbZJJSfi9zJ5aZxKoxWVFrPPU3BiCXQYsn+qjQNxTles9ltqWJEPL+HuLBmp/vIfW2RR0xua8FsWpA6dEzretYWkz1PzO7laZy7doWMostu9wCqtRzSwn6QyWKgdA7n4esXuoQJEJWsg99M1FeRubkhqSWAxv2x6XPg/xCxoz+SwTEjRPbrtoS8WEFIVZ8q6ysAoJ5cH/OPEpH3NnVVI0aTjKxoxcSIoewgg9uwyfOR5yKtKcEPRS4l6E1Sb2hHqkYKmrZ5qSiUQ2TJXTQn6HO2vS1YDY1PNdWwJS/+XZHNBn6fPCdtorDppIJBolzzx8SHQN1JLBj/RsKq8BMwTxQXuT56Dnnzhi9xr8pvYkXB6VrgWVSwtpXfzBiKgFygh8HNnX6Ssq8IByFNpA/jOcTlp5lHZh0yxDDCe0EsZT8KIY0dVHagFjKssUOjhC5N0VMtcvxSVclx+uN1/5LDh/2rGcqzSa2bXUSZuGCVjmGFmG49HTTcIV6r2hYy+W0GdtQbtixEyP+k0Y2JbT1Ko0pGIWulgrtmnhZJ9xuIrHD8jkz3qKC9BuaLFpm2rcdDhubWEtuCdLk/pAeHFomfNjGwr5MF8i8arK0YitzsycH8tSOAbMpWpXt0r75NVoG2ip3G7JR7ukpOxk2Ups2cLYkJQlVjkASB8OuRwpCSeC+ETFgpxJZg96M16tDbNgaKhWRM426XkssprR+SSNGHNTLNL9Caf7dulgFS43aVcpmqbxkajn+Grsr/o0uMJ9WfhRu7PXvQ24t9ux/2uByoPOuHguh+uzIzsbO684dCPKF9NsaNB3ARIpslaff2vxKDmefGvc957SNZQe7F868FYocLxR+0DZoGWKG1uKlCPNkObpOcJmk0DYXoqVXW5I4XvfcUdw9brGEBfLhSvCtJDlk0qBMeHQlOGwD3tanRp7gleRIXmOFUuiKfJigo0vyA3SbS5mbd09cmK+jMHhNTzLdaLwOb0bedqgOgbTSvT2V7f8dKg0HrTge3L2o2iW3tGj6MMHoLGMCqxWNdzAxvxhw4mvQlm4BKeYpxyieZJyXPxbcfnzsIGaEKkGIHOM6ZmK+d+HA4sePLfEbbDksi4Mm/QFjb/RLFezyxBGE/o5UQaRGlZr+K+x31dCyUpqOLT4RApnkR3EsdyFk3EMIYqMAcCE+XYMuFx/fpcUDoW23cJroe5OV22mwzum7LRWYX6TvIfJRcN1LvOKpK29d9Tmg0pOtlkGbZxJQX1ZWhkmrMcZjYRUffaU7k53KRPTrmgaaGc/vKGw+yRDbOaW93+5JMhulF9hMxhk5impFIVTVek6SkdsGfjDBMyobVAfE4GmiyaRaU9HJUM3Qfa/UD4mDXZaslwDkwG8UfrNl5bDTVglTuOCmZW506RzFuv91Wyc+LAG4DQ+uhfh0VgMldK3ZQ3rmUuYakqYaUdymD0CokFOG/k4a8veD1FX2e47/X8GfuxQHGk2kUyFHeJO/CnMHzd3WcznPyby3ByXuAnTu+sM+l9w/h7LAiNaUS0mq4s9QdgoWVFyd6Pl5wMla5gXY0QQNWZ/NkXoSS65cO0QBH4xG/mSkGmHhHU+MRidUeBso0ixWsUjl8wiiZhp39V5uZlM4BiDLALh+Wn8ovPSe4ar+/AeqTq9wNHdEgJc0tGvz73wUp0MCXMgmUUhYvsRELDOEoeZV1Kf37WnoLFEhH9NkvqubQFq8yPHSOG/8kteWk9SnNF1siTk/AZBPOYVk/DSuddduxwRugwXLpYnydiHIEFX6aUpgKpDYqax5j3Z7HOUBrGl1Yudr1eo/xbp6U+2cK2w9FrnNbiYGsNlZcpOvrdA2CeHLIEInMvQEnBh4kV+6ZRS9MuvBx1EEndB0qKBS6ZHQamTE7NszUJT+TjX6CYAZhDm1fEXUC3+GOkXZ6gNs1Oqw1Vodw54agDhnC10XfYL7qskZ5qXtMZvEHweh1erxKZUdAs0HkwT2fhvjNuKZH5BEqsRO/ZIoV3Wn12YvBytg4EimUc0Qrq+mp1px9gSdyTuzzuE9FKIBN9JTNmxmoYMJKQ4ah8bLIhW8brp9isRIlZzPY1URcf9K/+hICsdJ48q6gOwOoaXhqbI7ij1UT0b4yp3V2gxofAkBPjCluzmKIbnOFcmeHVXrQN8FknLrCtJhieAoCN/hURbUeUmB1GUTIjBRpWxicvX00jaJDSccnW1MzAOLYIPc3fIyg6zrgWXZGDWqJ0DDeGUF8mSP50uAoMIj401HUR+TwzYG6KzwqNbg+YF8L40IKLTgI1vJNW3HpWDF37IRKggkTIn1/63B/4U11vZWyUrpCiSCByFgqObZmwbUeP2eiHRHGVtezj7KQulZ3Uojtf/H5lWTS0gbZvjfE0sjU+gDwhpV/hURDRR7uVffCAzQt/CR8jITUYbgLx42G38cwGm0zebZx8d/EZmZiL9ly+LPLxRMxacJzGnoec2VN9DhtFKezUSBHhKw5qoB42WpQ8jBT9+vKUddEAL0dzypXHiAnrf0xf/d7rYf7gpE1S2TbMllQ0NRP1+JMCt2NlD5jmMZLrz5/AdIpf8qlBf1VTQX0tKQCLXmmIFAAfitakIrMiuSJfxeaTTQpMG/rw/F9KxqEVmwX/8pnnhgV6KlG5+cHkKQYzwsCBEwVMZee16TMHDw0MtKTxWHfVv+go1rlBKHcMdw5usJSOraXpdbhubbcwsIGIrE74Tplg+8a6oh3d0zsIWSUIsTnzZVHBbdk2abM9PX2KxluBzbTWdqPvPDZVLTLg5tL/X0oM8rqNiPIPXe5KXKhJB2Zuh7tS4CMwHqkzG51tofZ6fa4IkH8pmE+Fq2CYPgTj7aW7AXEg3sOxsZ8Nqeix1Uxlmw9sygL0Cqdz4dvwntOItwyz43UuEJ7oQ1zTMvk0ygrX65Mpn5TXNOzkZALDwuYb8zggxzs+WU1QVucgGbGdpUaCcgDDVplF7oYzOl4zhNfqSq30XxqkjCFIEV+pB6uyZeagZRGvBQprRQrqaVNV4aZObdNH7ttT1kKBALht5R191tdOuQXQEPCd3A9YCV6oFnGh7lwwh02SjdNs/KxOZMaFj7czPxUVUuUTG+wI6JZqRbZoARp2GfM+0hR37CXYSrWrjQSmPJ8DjJ37OyCb7z73MhdiUac2M99aMYfERN+frzc0cmQvVfjWVNXnPGCTamsKbyPJ3hK2p4MbyG1XU3qzDDpoN5G4MOg6OkVLO6yabq1jA9F17iv3jPHBl1eywUc4cpkvNFTB+0MY6E4+oyNEhe/Kj+tUnsHeDZbCNnyjJoRoecntDoPYVBROFh0GaGcMY890W8LBzkpzzHT5ybsiVR2SoiH09FyZUpdX9FcjFLjl4Pkc0KwvR4YadNgzsJCfsLg0dUPOOgLMazaU4lQgXcbC1x8CqEJHB+OIFcbIbSnks9K3Xdem8EE5ImHIn5BUXEn1YCRmNHmfjm3olfpJPg4Q930aKHejgUAOZHjImzQ+6KhMpTAFZImlXl4Z+akjKFd3OnBySozOzx+J+Xw3Fn4F45nRe4jpQmJOW73St1R5r8X6NK59PKDu6SpJnFu75yAOdfcybfs6DGJijH99N90xMkgBdozwkqgh+2hmoYmeGGnVVCie0UvDcXHLiHGeKXZSLO4uXfI5jDR96+MZfUpwutHJxFTmxFpPEcCra3xZAqYpDR1xoKi8tG0K4lrpZFRgtXcnjtD5jd/cT2W9AVx/FQd4JrTDA4+YKo6VJG5z2g2V3ODSVMjsPosX7AkztxMfTIRY7eGnus8c+/9C52F6apbDExG8NiYxfw8VWtga8e+zu7mEV84RCz5q1diqrOD89kV0AwifnVtWGQFAa1eIj74uoqJSgVSZ6EU0OgfivvS6vcKEo1tR2/xpCkKdXscOL8qFycELdfPdcZ82PIQHxqkSkmEIGls3yXyA1gulG0o2tRRionkJRgDJeux9p7bVzK6+E+fnUTQx1EpihTvPlUEWKtnJmuRkIelRsyc6+cfwSqRg2UCJPwmV9ShguGdMH/bjteJMULzIUe/lEXoP5fqpU6H0UWItMf1geE0/ghZsT/N9e6HtAwB1679u5ZrfIHdCd2zXV0IOBGfxXVZNy+9uGphrtidamlEPH3Qau8SS4y7KgtrQa9Tb6ZzNBJDQGQ06EYRFHuW7SMuxZI/qnPq00y2R1xmBorKxHEByeQj175DgXVsV+aJSfsiPK/FbZyBicJ/gRVKc9vwn8eksSI7vaAujeJgez0RzpwqSB9RCMseP0EpZLBR9Ld3+Qv4Ms7mVBzjtpcrLzhRjmSDZrff4UPi/1wHIRcw0eyd/nhpU9RI1YdZoazVG80b5HXCPdYCO0c0xN+GIiGmDH8ZjZYdzkPq4SL7j/pxGPBR6Sw2EtNOiBGFHFWkmnHRlnaI6MkqfCcbAsp4ezE+/kX9reA47JZE32IlP1PyKoVUu0NC7wCWGGr6nl64EBDcyo4sfCjlZZ7ycIq1D3UaJhsCx8DmwyGIBrEvRcyWPKekr6GiL6CuMEesnIE9BcTJgQE08Fc1ckjdztEUPHV5KKPvn3EDE1dPNtAaJxRHEI1w/1/Oo8Uc2pmaQHcxGoQ8/q4Y1d9Jb3DiZJGTGR6LqliTij/rtP71Rd/TbuqF00IDjHFq0TOD822kzEMz5t5YX0cxZDQ7pSBsENfU/K4GMgS/qffEcypXlIUgfcj9lW+YR1X7DYbw62tHiegn6bpJ8+FsN7CfFUolyO/qvDzsCkfExnpADnMBRiUsuAXLwglJ0Xcspw+UI4mr/9sVhcEsk6LCwsykqmPL0/gHC67ct8IfvG6wDosA++WgyipZ6JLf8waHDn1t6GK0Ocn12QLr070yFldolTGUnxDkFJRijtkFeu7xDUhIBmw8JGor0BPDEq3tGCN5rybT9NLSb5JydhGcqD3H5otFha6CeB2M1TODwT9MvCZp7abeVDhhe1eZxSyL2aQYW43Cku3Kx5x+3Veb5klUF5xXGLuEXA7I0gNwRKDN2qiqPWuqsbdHbWp5K2TTXFChJGTuLp0p4U+HlpdZGFc2tCemab7oXQ1HE+MxyN7gXr2+g9FoX7nTReknZ6qVx1ej1b9SGZ+zxsefdRfGD1U3Zhiu2qLf6T83BWnlPIaz7TUrg+IZCYFttyXB1dyGzJ+xhXDwtS7Uoqe2NqakmJGmUU56xZqetLKX2JLWPIGLUMass0GtjWoKR4p8dQ5E90mZuK/84LGsSkaLzoApHfuJm1O3Sonb2rF5srhqZBHtXYQeLzQ75D5f5gCREfRMMWUy3EYNTLgZGTxtS1A03zCNw+gG7fsYGUvgsZIc1BXRMwN76EHmdzCrYJz7EluYbMbBoDljKuRDAXzJn+9sscO2gt1kh6bCWVUA8v6yeS0l+GXyqiHNMoQfFrlFOZiUq8ysXusiQKHeW9WGFnBH9nKBNIuQbTDJUnOtWJHJyGa+K9YHAcWGXu9pkNEA06IRlX5GOGO33Ndth/iyBvXJkfQVQBara9oXRmmiD2BnwvgxjJnpQKzGN/tGNl/NVhvXl57PnNeHU0mhkjXKX8yhPKoSnQIG+f1ctMLY8NwUwcyA/R1VYPeobtU5nt+tN4wKqa957WuzNc6dSbCMkClT1T2yfTaeoks/xIOx6m3fXeNEFx7rYJ2WyN0YNQAy3eaH4Rtzc3L5k+FYfhRk5F1EGhon8rfDtxpqwQu2jMAve732g9CuTZCuOX3+ywjiaRhC0yfkKVqCcunmPDHXLVzImK1z5gLV/I4FnKyVelx7lblCs2QJsMTRnH4pDbLtu0ObzgGN2ssjaidwmSQ37PaIOoCAiivq4bdVazTSvu9agpCF+5Xl//kpKFieOAkTiXQTz0F9a1oCyCv9oh16wH6h9KJ0rvaAHlfC8K94SbY82d32yS3jW1pGnpRSiL/1MVK3VDFT0852temjIRI0tlG8avtHlxNAofgxiiOKY5ZEkNYYu7v4PU5awrGFkkrDGTw7a1Rpr+si3JgXJmO1jfycVjvJLAZjzLng294MRGJ8WmFqreGOQBr1xBtOKtZU7XCc8pNL98eNnfLVC0IErxy3Do+S7CixkYWigmK25fd5NRvogIxposTnTtAUtGrGC0/JdvnNz1QYG900OckjVvh7526Jr9MVtxK/NF9/VT4nZ1B9P+CHIctlpBuokfmV1vppc2yHIkvT9/rKLYD9GzcI1i0fRGDwTtD50Tq05vnA43Rqa11Nm7+jMKzXlLmReaM4VLz9EDnpZ+cooOOpCNEFXWi67VjWv1ugzWsGD9uwSK0XrOakEnJW6B82cYTG65TDeDEWMrPBW8NORFK1ENSeeCOVlpFqlls6dgxe6aykqmOFj+l2PmIpcinEkQkfn5bDzBpTeMr66rfqZsAUH3WrhbizloTZ/xvcGyNppRHLfUM6QmN0gzswpkh9PT9t1d9KWxD3BYs2TMwBeY/R7/VbdeKCFisYqd22zrBxm0nmaatZGU76Usk7WvEoPY4D3+NJ4ATw1NNp8CRfTLVB651OaphOLLXX25i/7Isv51n3EwFO+5oTsbqC8tsJmuXRxRbX8QPZVKtgz86lk4rGcl7l1UFiZaJf9RIJraWaeDsePuGi/R54wkHzokzuQn/Vl8xaRH936uWFp4nm375fulFHc73AnUYvy9aBG9mddf7gZUSclYqErAI62Tg6xR/AAe7UbOIW27SX9E0AGu+LtIsJWcPrDMSwXT6oGfM+wP+xQsniU1iYSmzuMPRgfPVxzUb7RdSiXpNNgdeUoN2jXNCVR8YXrYKU73BNJbMjgXOGFDcR1aQubFOmMuGlKQFaMIoLQrbdFQD91rqomHMae2B3R8wPeN2AZLNalrrVRVibfkPCZpU9yrpBLcTe7zMbMg/tgd85pUEwmLh3HI3RANLhuTVziT4GIcltgYk0cAO7SwAajXSYbA8Z2zGfiIdRK+Wea+RQ5tkAHCNe/ilqG9T+ujcewSzlEEyCawQELL/YyKbyxkVVZJtzjr6i3cKyPaXknUVSs2AE+ADnECwW6u7R8/NV9ek39FSdFLk4YmRjNQAL8Fre8GbsV8h22RD40LdG72iJTPAkW/lnJp6H/wCV0dsXGtkTNZq+TA/02jaaviKiwCxHhfTPqJXUvTrGgeaU/5XnhaAxwBHlv9ZYirI+cALJ+pq3iPaVW7Eu+ZXrDyJlMRvNPl9lHd/shEZ+CNUI8PDt21oOCFTB9aAVzMnzFta+DHKVM05zCq2Z8CoJifcJgJpsPa2mVUACCn9+drtYoykjSs3IocG/+/OoWYp4W+6wuKZE3xmmLm2x2PjJmrn8WEomUrg8Q5HmU6RFE3zzoe7GgkC/JL3pf7yQdva9cfzjh8goh2hBscWG93/H7M4eVaumr7ykcbpNLHLHbRDz0+2N0shLMNwOTXLyedcvK0HEYfUbK3Z//blS9bYVxhi9dK62IRgK6UAt+/eIxdyuVw0oO2bvpDD8rJPrk1nnBq0FP58kBjm7KPff1Wln0AIPewZCI08oiLqipjYcu9JvK/+uhSApblzkPo2IET+/bSUXqsuTJuW4l2vyFf7GZ0hQksQ8Lomd0M5sp25S0bMDzTKAbPOBf1h/3DTE0tOJiBI8Q12CTxizTR/iBc1XcSLmoBPW8lgDpx5Td/Wik1cDYCsBP6BcapHXXlJSAl3sIu7MvRSE9SBcfTfLvFE/clvKe8dcsVx6FqoQ4KUg9YjIpTIuva+GHX3DBv8GKvZ1yDA3Mia9ruBiIRXpA2npt8SePMsK44nRhitm3o1QSveJZX4g8ApIEHYJEzxj2lRmh8g/5hqn5SQCs7zfWDwXgmzegrWxHIWBJ36W7jxhyWgOQ0m8fIZC8TH+aqdRy+F6ryfGzxWfIjHSxAOYXgAvmRIcjo2xJsAExJa3/38NTZUDWoc0+U1+RX5wCoNrpqkWhp3HMU9ovUsWXfOVJT8b2ulgJVYUR02fasDJ25pTut5mEBObp3FNGRiva7fP8Y8gRGdg5XwJy9YM2KPu9cHebmsQpL9SzH9swDeiwhF7obMLC0pUcrMktGwYFU/tk4pMlVE+GqbPANin/ZB20545yp1ame4Rk9b2Ag9dKcr4l5Xnsfxjaur2dUkiWHWI5oPFLKXzyJOyI13VL2pXIKqABGS3rLMf6gkkMewTeqCd2IqhZ2ZWaZs89oSP/wxrTmLCDViHC+HvtP4PNP6v9T/8KCbzwjwt8Y8nQ9InEyTWXzp80wT7p7qFtlPZ+RLSgklSrNzuVY0CGAJaedNBjDgD/YEdORDuMpLKQO5Gy71UdX0MmXYSHH7r0LL235bIs+Xx+AVTo01Fs6eRqqOS+7OW7MZLbRohRpTZNz0A9SIe1PaAu1pf2vbDB2zVjyei9OaqJ3ZH8YvH7XMxsqUktV3Rn5kTXqquCq5nq0R+hpoU6dTnDG4b+YqbKc8AYTb9G7+TCuCaxqDUEHOs0bQ8EC69+1+89kgoxnApHnQnz0QuUKchBqSPjl8nfsUYOpT0CEWWGoN4vhdLT+Z00KWoERkIbjcT09nXqnRcTu10cQWsLRc7UgyWcKWZiuPnQvqZRnPxfXBlisyXEpGOxJIFgNa6Z7y2ZqlKxV2EApQtbXt6GReXLxUozSx7pcFFBoNiIUXzuVoCdotT5AzCwmcfFvOY2bDhr4OFEhQ75z8DIqg8AYY4RMYTaF5XUtDfgWD4bJHlw2Lj8Q3DiemqTqjFn4RV6dVlfbhNgJuDRMSozO4/TW4yH0IVEYQZcg9DeGqNmmHxMFJcnI4b3ZiBIyYWSDzH9itL5Zjr+ELgxav6MNPXWei7X2J9FRTwJkHpUxW0r22/gEIgcysaIWy/kJ5eWEPQ3IoAnOMbmE0IOm5jR7LynQOSJiEtdz3bw6ue+kP5HMos4wSC8nksSXqQ7bia04aklHB2fbT/z7ojhFgp9vWMmRVIKQi13vFuO86OXR62ajS4J3KaeSTE2oGmGyC+s+R177FXfh2a2ba/LIZPNqrXouwNDyGjx2HiSXEfworQzmP7kd3alWAIKnLSEOGFVd+3dhTJeSnNyAeVk2dEzXSJXmuTEwFMETQY8R/Qqy7wm31hoyBqe8z8pgsM3ZL6vAyoR6aqw+F6FXwqNduWPg0XcDCOAGwzRKxrLG53ivdY2w5coj9i+7mw8VUvXbrmZ+rUxJLVDMX23NExCQL4ZdVAIDaMMWTMjemKaExJ0ryI40tPIfak+kOOJPuk25IKVWP/5VoSQT6UyipVOqO/lx8eSQGMP9lvJXX9gPLNQtYDPSy4k4uzVERu7Ppo4xqbumtBiYdd7ue7AA2/joGe6mbu7ExlnCIMtOpBDUMSV5AmGKYY3D9w5hdWC5oPCvbx4ZXevvhNVvJSEr37cqeBvwecNcryqgEimm2JusxkI5zQ5cJn3pA+2Ibjpy/EAe6BedDBgMalCpYOJ9ZNiE0Pl2NnrO2etvhpHvFPiDqNqr2mU/UgJq0NaTjx3dSv2hEvUn8R7pUwJYJ16/O2hb6NpDUHs+Dy5J1/lqRo9u/g41NAjC1uw9aYCQNQotIsxqQ2LNg5AOSP9mup+PuiNdt9tdmB0MTysTquc/Cc1OLnZhA+PKy9oc2wrdhatqKP19HDd8t2sDfo3VUMthjSGvmsi+cdu0EDZpdtI0f/lMa8d9K73LudgV+HQbeNlmUhZ27Fn9LNwYLHlAGBYRdVqIMurE8MnCrXkJ00yXVBGJnbd3g5TwTzUT5B2AbWvIX1o6z0z6oy8o4pefqL1gWKPSbT0ghHr3ZFJWj3ZqIl8JizrcW4MZnRyChabNSl7wGgRf/xvVZ2SLGGbBOI91uyCjM4Y6gV+fJD0HEW0rNpG6L+LJyOb5IZhSidQ1+vLGIbF/jEX+i+TY7RvQmjHmpt47G5WFBRsbN65AYU95G2fPVtcJmCfkXOcE6F+15wa8D2f+ZE5LixhV695moxMqIB2P+0IZjOsgUUgzqNMHbBPI5CNKxOphfXpmnoWtoDNESjK9qkw2loImvijhfPn2YscgPQuCIhOxegRpFNv7hyDITx0ZrywdPnmTNPnyVCSAIReXMKrait7Y3a3scnJaCKzfw8+9fhaw4cxqokLj8u9k2N0RQN5owO/+fQwoOihInAkQPoyWhJA8Xdx3JTAK+5uB/WSL/iYnDyaJ+BG4kD6IPi4NABk/RmEu3HBQ6cGqDXkfWx4lxxCQjJh5wVf6iPs/UkzRKH6R3UOYNX/NXqTIMEXTAOzG8yg/DtQTnLScvohLHMjs5Ex51F/5FC3tDXLor3nAoNWOJRFgwsj9aMUZIov+lUbdtjVuO6trczq2ZM2COMd9cYNslZZE8Zon/Fx1e1T36QTEjwOZUbtaisTIZ/oI3bZa9IoEk86lF+0gdnVi+qYm6mG+oMq1lpq0lDtkGjkob5KNgWhWt3KHRcIrpqEw+1s1iABmoG6DbDutVAknfgpGEU6Z2VfRGtxv9YeAt52W98L2oeehBNCUKSznmLV0ytYSLs+/baqrgcNMetr5DsMU/HfF3r/s57kpntxdTQYShk3QbO7Br49TgdmBUd3cpqrNaYot++9GJl9DMYKBl6uQYMM6qwlILdAeFO3TWfc7VOMLDKtG3zMcY4/ZB9NEJ3UZHJlru5tmDyqZWdRjajrwqzBQO0jNoXrX91WM9gknF7TYpyvs40/0bPeaBmFIyIO+I9XRGTQRbQxHytWV/pv4BSstne73sUt7Vv1wPi4CZPrFTuT6H4LhL6/MxeRq1jBU7LCqQ8gQInGQEcyhYCb3zmvK6OpP9MKHBX07a0Pfdb+2ukSjBGA9NP4dfOeGtx9/ZTyMAb4gyP5vHnyX5kcu7zPh48E+oT7Ar4Uq/aB+1oaFj0YIahLKTKvYapSlsErppS2fYcK/86bYX+HvXn8Jkx61AC/Jhn8MjsL8/MTmIIsF8kDBRLEF9bco2roSBDZwIAJy976pMsc5zbsG3wo+8xPqFt1EjChj1fJw4Du8BpeYQvIQPs4kv+6AdZxLsGqrn5WA1L7a0WlYh+5uj9uKSj4RsEDNbr+ZS1rgCABepfecheevFAJHZaAs1UTcEF+dTI1tuR2rDJMTRGqqKjgnU3yXuk3XrwVk9Mw3It/pwT9jIDen2vW4FmGMBueOWhZ+R94WKNIbilgXLBoOUPUwOSY1mRKZmjW+7B7jmUaq2xdvIY8hdBdMcJy/cMIB/nI6S448vWesgoZ8ksFPz4EolhqLKWBSH1vbzHDCUpvEtrnw7DKdlNCCzTprdAqzdEgoqaVXkFvNil1r7R8M6R1HovFY3M5VJN5ys17dCgeE2uRYSIf09J+w1oLzwx63C/bcZu/bPMyo5iikgZELWliyMEISPxTrAaK1qOGHOmClIj36pTzgocY2/I9trNvtiP4GxkEa696op/N5z3Zbs9Y53Tx3TR9Wu6FkMvWMIqYXWGEilNi0SZ+Y34CQ14QrDD5bMNDQPvMFdFX2dT4qDrthh4szcS64z6KT2I9CvmM/mIKlrkPJOiSxuz7kV1DmXpqMW0WLdHn7mGnf+5ZciBPRxiPhMcaT1QqfIkNSLL2Rex3fOzJJYZS2FP0E7wNEiyvkIqqS1dBzljXDLKbhAUG/O59VRKITsR7yC0VVgUKc/0FXBEBzvTI/0gAFI202FNrQSZMYkq7Z1tSqgctPN371AZxGGWHSEf2BCNVhrax2lUBikZ0YZG6yj5P3eWuSoYAhNJdxfdJhsppbrsDHIYTZfQrNOyyntwfFb+MjaiUylZaIgn4lVSDF+m4jFf6ajzrdIbSstf2EVQJT1p289PvYNBEyoLdrUTbsZarGNb7r14DLnT/k+q7w8KlvTWrWcsukTaCMjwT5Q5l4ZmPqvjrcva8/xulU+pMlTea4AwHE1VcurciTH4DfJE6fzsBK84s9Y6NOCg4WQ5qtaVKxkmOsraccFcJ/w1fZUJBWAZpIygZSsz8xS+GsNuWCLr9Oh3H3COu3E1WFcvFshME+yxxHKyhoOFUpudTokBevt0UKxYTTmZQzeOaJ83d/y3MnXDolwMwLAmui/c9+Gway9IHYJaen2/sstETvQ1Kn14Flzmkva0qjedGr66G1CuR6HBGaLWsPNQ+Xm3h7NqY0R80BF+sLvP7p6eM/vL5qrXavHVerOUH6FDg9qWaOy3EIDeGorAXfnTYzHxvYxEhEbYq881ajDb9B3qoL7jEOj5qv8oTi2xz7+FYRMJUTYjmCPDZEMnMoiBSj0MgkFonp3wLfqwrryHpv8s3tbPMykYHFDabA75crpZav9kTcFsoSMl3sEz+xbho15jYOrmUlgAvW6piWi0Fwrl6hqt3AUl3tS8e+YzOZCxkIKS1RHLnyXuJ4RExXnqj+1JgFNZYEDc6rn1Lc4HwiGNBtm9A8XZbu5WWCXXHr74CXK88UAQ6GYMi0iJAPYI1vJ8WfLlwBwP2qbPfnOkNCKeC+wOLZCWB4PcKZyqaDEiRxgdJyzndPSeJGBOnS1fZsu/rOldjaID8XCGSoCWeF1nj3YnFAfMOn4eYHBvqIr6yqiDmGrOOKcGCZU2bULkMkstA9dLxbKOLcEmi3DRT08JfbxjCOZ3SoYelC3urHFy1Jv1GHmapwC757E6s3tyPRN3YCNewyJ/yqXT1gPWYk/z4eLAvCoWS7xEamdZzWLX7MZMeNByn9SP7DcwBc4aou9/g2aGaVaC6ksoHfa34HRApSudfPDwPUraPcAnXiwkkPgF9002P574XRyQVIA3flCGRm2cA4tXpRFoQkoMn87UVRP65i4bbzEejgEYKmKtpIvZSBe1NSt5/5cJhCzRmRkp74GR86IfsbkQYfKWKw+DG3fTarS8YfHTWV7y3vgVARbNLm89kFrlF0SYeMIpp56EJUQ3OG4eIexpEofRIVgtMEepFWCGzIf7LDMV4flscICckD1GTpOlyUeCiUtVouP6cOOYJFYaOjXSmzwZeVZRAAiz3TzU8uVsiP1kYkLvAyx8oQ16oWf2hjiiVtkBF1UnQybSg6eO0H6lWd9ZxVaJUk3WVreum9oyEOlritF9QQB+2U2Aw4g1d7OJ+SqNMsRdQGg4xAlsR7GleOuGLMWLdVzdCSHRJYqo+0KBxEXUREmOBPniaGTC8KUNN10xBXln1tk2k6QK/qV+WhyoJK2Oan89qp54DC2QWtu0gfF0IqY6FZKm0U/f3eM1GQrE9CHIrqVb934TTcXRkf05McXkKRXU1QhHYjKEv3XygD26sEWQ/9qQPsPz+0LLSoWUbn8ZGvQV/ah/YYMn+ZOnynUE/avG6236aq2786ARScXIpaghdc0W6J1MxREHn2R5NNTy5mqVV7lAyvoLpvLKUtzaTOY4y2RHkJbKcP9VdC1gyR58VCgydJoaT5rBpDG7yMWHxImwmrcUhblpEQOorHF8XMf+VjO3ZLvdcIQ0jZQkNegyer8dFJSOqoWh5eC9Vf0Yb7MvuWV80etTEuIiL+x1NCgYPVVaHkeFEai7enbVOID2+QHcj59JfZXzvKOOJAPMN9Z8JLBBEbrvSh2/LxDG+A51GBFQseckhOVZf5zFlXel8Tb2kOznTCJyLCc7s6vrowfVuDA8rLa6fHmsIkkAa9zX10xNUpwT+VeIjXaP4Z+JotU3pGbs4vkRpNfmU/YP8I9UpsivCTMnYYuaAQ0CrA6/d3HdwgWBEY8L/wmBL6l/EC4aRzjQlFu+X8BNn8rrT+twEGTAWY8xI30LW0A1TixUTOm2lu+6RXrDSQGKovNViL5uQ54QciYb2FBty5D6B88eJKjk22p31JvsYSbT+fYBzq91HhJPL4jbXPcZawkuT/PnNWGfwTzmSeXGIha2YrqO8yCzV8D2ET7eb9XR5dvgf1zhk6UOw0NOO4u9cZIYrciR+HBSoEkDxBnWmFdL6WmUtf9+sD51Dr4Q9ADOPK7uIqzGccosDNbrORwPtQLx/ZulruzID/wtuNYK6VktZi/pkefP9DAtyvZMu5SOIXw793Y2Fkt5crnotulZsw5VhLXTfZS4PO0Tglh+woTJiRJpCEMpAc0JOxYrVzQVHmCsNrNPFQ0BvN7taEw5D/TomIT8f9GQlP/8a3QSkiQjGO/YsU62SUXn0iA25M73wicHxVyEr61vZ2TItLrvVzdPM1JJOgjelWf+ntwbG5UxFX/12NiXKt/BJNeCgOTiGfAdu/Kq9m9cn36pjs+T0KTpGiyxahRl+T1paWB/FWbHRuhkR+gcSKDVCrrY8yGYxZ9iTXzyFBIrPsrRR+OrWxhwkBrPAWQexO8updxWvvtcmK1pMbhxNKPu18YDsrbZ/egVEQGx8xy5F7T7b+M/GEv5waLLF+IVqZdySZ1O/pD/1pPNcBOvJYNsqz/TcmCqQQupuNnMTJALnNvzqlSSxou7tBryvUNYssQB8C29B2CYWqRboDP/jzRN2vYTNUACGEaCmX33WawbL9Mhr3pLi+UjdOLCRNYeJBV2OTs9LUbot5XlC/9HmWTJaHzdryW/ypRLp7Lm4dgqUcezC51AGtxjWXwBVc8G8LX2MsasgQx8NMji1RwhaSyW5HjThkibn0Txk9pZouabUicig6QrgO1sJ8uMvO2ftOiAILfW8pXST5rKxhKJ5gekJxGyS/MuMMz4iIR5n4d2rev5EvIZok+j511qmuTBFwRJBTFjZ7U9B5H0EUGpmGPb67Y1qzV/hUCdULfjwIFYptuOTY/Z2aWIiJc0j4e0ma+DkxcqOvaBDy/6CdsLHkjX+R4QqywRnILwVxmxTLJ6r/TeJNJBYo0aOVENYXVhRj7C7yZMTbp22x7tl2q31C+bL5agSRe6f1enqZck42qS17AYI0zsaFTfT2KKBgqU4BSeo5DB4+K048bA/4pJaEk+tp6+5oLoRgRdNMbTTd2A92otE1t2WERQc0GW2BMuvXEDUspo2ZGyBD8eKcQHZgCYBs+eH2EyWcinxhy5g7v+8Mde5Yxz6/p32fLNaMErR96gMMN9HQZCWTKOpN2OJwk7qVH538aOGtP4J5AedRwkAVtvAAiMHBCS7ZE2Tn/u0J9XHqRTlhFEYrJuOyjFHkZKBYbpKIjhlcBkwYpGRkQ51i1KNp8CpRIby8MvVA8CToui4H4KuRIJdGb5pThMarT/3yuV5j+KDiVK2V6v/0zJf1tDA6N2j8tlT6SaF0exE2H2eYiDswp3SGog2tWL4SpeC69VKQ7S9tSAQz+3BtZZuefd9+o9OQGgj55xfgw4ouTfYiHsbld/r12OOhYZoarh7xtYavy+/tcoVajhnb25YC8HfR/kuo3JKx/9GDe9MRRGTPZb/l1xU+YEjZpgSzOBLORJ9Ue6FNRs3Y2Pqsc/9/Yvpd9gPDcYxi/r8bA+g1ca2eKQjwYMLBg0mCpa2i301QbJaFfeAHWGrXUKlg48sGm+TLwIfP37K9VrNz3pVWg/WJDIxBTxqARk20V4iY7mCPh98LP9F5wZPVTEANJ81bjKDIxeRkRZaamSWlBZNXciiywMeSqqoiVrVyySCK7pYVyN1bfECtk+6G/06RZA/sGTOdIHDF+5GecZhAk6sjeaZLoug4OT+JXM/ZTYul+3GsENOmwYbHIgIdcHhvSMaRRXYUVryTNlEBcqFmADzksvFXd8sNgoFyP9nm0lDRQUC6/GrxkxQrXv1m686C19IJkiwmuywELmQWciqBPLiMac/kMCI28ZjrkEb2Arja3X70R8yh97D0NtWpfn9k/DizkAvErQ4NOAtS1zsP6sRQLXLUQMRi9MU7LLuUJBlEZGWOxvd3baG00ynEyYtLY0cU2JNlerNW58NgZtC+a30WC0rQM9pCbznCpyaasFYW5Wtvi9paqKw2lMXlxIEf3IvTASpcroXPiRt0zOjPxmEKGHnaLeAvc0qsohWlBMjjYj7OlRWedeQg4VBKXj61whgoMAMgihb2XFreWnWdK56gCuU48GjHa2ykNfwMDHxojYc9Q9vzQD3+KQd52D5yRq3p+Tyi1InwaMGW7UEYFo+vycJ/lSkZp8oF9mp0R+JtuYG6cVkaKPHhPVEGfX+UhwkSgZfArnllj3tom7icqvhLQQYZzP5ZhCyEFm/fRgx8I5Cq1kJKWyb6f4N0BeVmxlcLOLLb1RxZP9TXd+85Su0Zx9ixNonyCgW75OA8cHN5Tzk+Sfr716aMubkn+lTHRzYAw5vyXf2o+DpYWShUPnx1yeEwYZ97K2xO4sggH3D4+GvhGddNxWRJU3tXXDwNGbWwtJL7hkjL06i6fQN+t3DkLckoxsBbd5HKPi5TyAMh+RkCsQDAmQAH5W3bzJBdalrqKLyM0func+9ut13SvuUXQqOit6vKUaCe+Qhlryb/mVbf+/WgfsD5n1ZeYETGMFWI5up/SRxJF1fp9hDjf5j4JV1vuwH5K9IRp15+SyY04qdl6aXPiXi34u4TS9WIotHlrFmYe3JUpyJ9JFzG2J/ve+t02TSAwyQAa4dG9HA4dw1goxrulB8Fjm7+bVrrc6ol0It6aVzweoUP+6KlLa+LKX15h7Mk6LVwpI+WYjVn444JxaSIOIQXo7zSpQVJm2hbyT7GKskHC0Ht4UYftrIvavDVEvXv1YlVF/Hu1+V+sfUgpnV/CrbGGxqeA9FhhmTCFV66lyHaAL20xaEsjEmHMgUu0DyPA4A3iBpHPLN359rtR6Z/GSe+bYmdJ5QvuKVEohdfUHKsKvhti3uSLWjHMK1Y2ZuXxGM3m5W357iqWZO+v9YnE/RCKtzfzlTyUi5mM1XTRUTzBrV2QWuDgkIrwgfkr1RVGW0tyPd+gPiGVuSXx77NSDMRpbaeZO8yRpJ+ajJmqJnPiiVte3s/KOsL2C/86Z7tBEp8lCQgDg/60oJP0hzyJV9pc6grzyR32lrqAgEnJX1sp8UAANY4EL2X5PcoX3p0y11kD0wwA9hG8dMf5UJXqvh6G1nApi1iM2IfdzKarVyGhvOPj5Q35Kb5+z322n4FqJkyNMTPVnyK/2xVBfOfdM9b64rV93i/NmAVOShSuFbob49+f4eh181/KdunCybUgrA8y9xEuMZ8y+XrXO5hoEyF/LGNyw/dYLeZH+bqHulHwaVnSQgH5UT4n5zaeobgX4DuiUD/stfDRrrYoMxstO89m9bpR+TSQOe9FclM9ZzA0sU2FHn6BT8vOUClKE35OmYCp54i1w0ut5A5vGH9VpYE+P2fA4uzNG2ZFU6Z5CSQfEjN4ZdBL3A5d4vIqFx2oN90qE6otgLGmY2Ka9SjWBfh3W7bQ+f0L0zaT7lz33oE7gmUM1FUtaCD34lRosVjmM+a+hirlQGmF1rhlsaS6LEIhBD9bA0C4L4aKkraWF94Fx1ra6AmcjAzOlRkNh3P7ZyH0gfU9jyei0chj2ugQZN+5aNi/VaQepdB6P4m/0bzKIO6eqFoAga6x5S/tK2vMWafB/zdK/RbxOMnVzw0EdAXB7e80D2AgsHyJb3uBL20a7CmctVxI5qzazYnEHeU89qNw8ABdb6tiTlelQE23z0yLUKCqznNe2fc09PpRVySXZqbmc28Ouhs8ghqRmnOt0AV4y2FHFkAD+zixH0/8F5KEf3sctdzeLjQusujviUs0JbBBwcgaMth05Bs+gaD6fzpRSOqgBwrnJLhAYIYYndtbCOSiPThy7mxn44JLMRT6g7JCBSvoNU86fJqPV0/jcvkNHgXfa4qQjPPAFOLp62bawBdZ+Xm5uO8+7i8dUyA5boRol3d/iu57lSMSxU6DjB3saQoCpAExPH2XDr+YLyymwk8F3HTVjh/J88n3aWdf1hdMKmLembY14dycYhba8SVSxdW2/Og/zTofeic8upuIYVe+lYbFwaaUkHYAbnYFOoz8dg1+egPSQE1loBtCaEETqYg1Z6ILgCS8SD7OAStKrTebYNgoBvniFM+AjFhWxm8dr+GhluuP2NIHja0/tHiwFopNvhmSP9zO5ythBoKlCYXmKXUz68qczn40w/ZmJcnw4PEiy4bAnQToMiV09EQodFCjjG2BTQZ95ML8Ao4Yx5SkCAzms/QbARiyeq8bQl19pOfclZo1Bxo8+2qnJYo93bJdaJ7dABjUUFuFiIz/bo4w/Z6sweGQRSAcou0zy47lpJJd9Ki+76H1qn2RZu5J0VbClPIo/vD/SICQOlG2K1ijg33tYhjix/ZsAykOnSpzqGawXeX5gpWeEOjU7ULu3xYRLtxKny6ZeFshGIfPQdniFwdRsZ7DBUKlRtGK9SyxseztY8oAWv0xMpU5zof/e0c8zQtqF4Zp+GtjpBwmvM1Suj+igKeLE0ygL7pCT+Td22e/SJthF7FVIwiNChVJlZJmVMc+B2cFmiADHpkyErz2wUDze/oMT+UPmwDtQEhav8jOu1TV6nc3icDcSd2usC0xN75keBoMSezoO+DK9mLb7i5vWAUDO7isaFrxFdqBSVAxE2lzVYInqPTHgg4EuMGfNwQI/WFAWAPERrytlL4xtrG8sUiTjqQZ7oLG4iuevNX4vHmMhXF2c7qh+BOdlE3PrNf+1tM+7b8T8nVeByJP+uHp6F2hAGlmhYLnv5f4vIGI7looVrHPyASDP+3LxgRHCk7hlR9KjqD+KsCEI8mLcSMZXViBu4qfNpAv3JUBvizPX94WOcrxDhkyvEaXKx7iAro1pXLRABwMAT8xNRCDqKe+3Ow5DU7O6oBAtnataWlCXSS/LEV3FDNzUbOJBsxQSI9blTh6KhY1jOWTVSv34JFlC4q2g/JVshe4kOOX0JErqWj4mXHXhDaxVrFefW11pWluKOxomQaOoweNKwY5hdgM4tFWXs0LefzN2IR1IL0wsVy2rxcaArx/LG7GsmCov20OKqTqhAkxLSfICeRO/e+B//bPlxtEXvp3ax4ekyNeUQ7E4TOP5W85wR7KxNbFv8BtLUGvhKYC9SKg7dHcd2y4wXSIfK8fg42t5J68B+jpNJCh9ZjOjTSL6pLHPNkJnqgDZka9tWh6t9Jgk0UYDGeTAD2Mn7CNsJYkgleFnC/dCo1t8DDb7/X6Xz8EWFqQL4J4Iab39ZXw4CDQLRsL8Gz4FxGbg9hAh6XsHNpLpyePE9kNl3TjSyb2pCSymqVKkEtRRxRAuG7UtEhG7EY8tKmYLQeFib1KUfLQ2tiVncWFKHZCUI3zkUPJYbZAPkwotZGcM7dQVZPFhp0k1Y2JNznYu0Y/+FAB3smTfGJzARU70ziUFaaUIv/Ig0mt+B8Y9eaPABBSLG6wVBq2ydMk6fv4Zo6EMANWfqemJ2/M2JJuIlOdPS/1A/daTJom6/pkTlHQZFNBYiO04EyPiALgO2LCnUWGqpB3OHKtouYOvhlPydPVIroD2i2ckp6ox4jg7HD6qs0OlbNr52iREi68W4TEzDDHuQH9g3/sK/CzdCBq84tpl6HhLhN25xaua8oLWRVFRdfrdgj9OiCsLYwwCemts2kgdPO3qiTTufXn5N0wwJbr8CdJXHf4TinUAGDFYrwDHDJiHnYYXWuEhJkglo2c6NHoAOPBBdiLBsxSD2dNkgFr07PtFdO7mxAObVc1jpg8/TjHxDQTg05bxxuilTgb4IATV9HozRq7jxM1HKfJVKQo6Vue8FX6huIsxp6C4ZmqSA1QW2ejCjAJBSRgu3z88G1BqvwMSzIUEAQ7ZHiTJz1VLJvk/WWc94w55KanRUmPHTk7sfO8VmeEDtRyFaSYFpSWGQtF9hY7oWOVsofp4xkzy9kTudwr85pwyt0GwKTtt+13d3Ok1IeVDeX08oh+vRHJ4xEStk8EYwon9OiLoi8B/ujtqrGMTrF5zkpxLhVJy0Ir5MmcuXwGQrckrtBhPQqP+Q8a4iKtGVyFgrHCuBSV/wkAL8tf/FZbbqAGsvlQgBzZCsDnEn5g75oGbRtPTApTZq4u0DhWmap+uFH8YZQuUj6WuiA6A2FqlsEHwNejJVa1/PIiJICr+sHV1L7uzPL6WnGTMjHzVaRJcwsF+h0ka96Bifm7wSlf+qiJ/uOI81G5lkmQHyIAGGKq7nFOfQ7apJl8D0pPn4u76GXAi56B0FOlvsoorrmc/aOU20Pbz2g7f0NzIHLpHBNKJNVmH/pbMRUviSMt+hniBM0vFlnDoGlxTbkfUZxlOEFvaxiUoi6HL+sGYm3h3vwnpr+exMpgyDiI5roc4tTPVrrH3i2oThUVeM7DYb0XCD9DmKMSU7XvzvbRgOrdrmisuLVLOcpIK4LW/vSghGuM4Y6bQy8QMOW8sndmteu/hp4e86mOtZFhVIncNgOyiafDAfZ78XhlhNRtPoQTovqDXI9cbcVgBU2kwdQLyn7nLXF+6j+kS6zjJgPQCjITfry37QqeOuY2pH1A9w+A6XqaZ2XcdXWGaElyPLowG6v58ceBDg/ePgw0NxwVCAnw48LaDEX3FytJeX5NyIyIW4mkMDLcIWE7WgIDgJ0oV2KDKQdj953VEacGCDVdxFrpycX1PiWdldaIvF9dD40D6JpqQVpGScElzsbLxX5NNQZeZ9ojIYCe7l6mIkjxbQOVW0Meb0V7UfMhx3RmjNMtRsG25NkEtxRiOi1eKzBpzlVO7FaBAWxJmDI7qpU7+TcYhaAJkwaW9CKIIGSaGZhYit4pPg57+ZQVp22bD/6qP1x0CAg/gnz/b8upY9RCD0v65tSFflxM1d/MNBYtO0S0IqDBJLGKIGiYSXqXaIS1zydzLiyy75UV+zMLxS0W3y6KUt/hGcDKrr+ZPFUUbUfgtZ0yID59rWBaWF+EYKENWC4GSbj7YtlonOP8B/JB5kX751QgqEQV8Pc4aGHaricHxHfG0QuSESOe7MIyq8WpV1cl7l4Paru9bLsjysJrQSclJzTrFUJU5ZYLSbU9G9ys1L6BpJ+Omm7dRq3ftKwzPcFdlWc3snYdyL8toqlA2Z++MMfieDDFTZVLQ+qY60lkOeUbl/LZSL3aSLKt/1wWw+FSjPme5eqTjkPH3XGOo7L/tDYANptzcVzoKAQ0U87kGBIx2MZWIuzZg2GKOZWPdo4d5AAxp3qLxSPLQp3QP/fMpJK2VU+l/e2TeaRFoNOu+i2Je/1FxrQHmwyyDy+vLFkAb9K6RXWc32hBxvEmdFlWuV1mfDgjyLzko4kE5sxgxHdwCz0Ka+F+s/Su6jVerGpSPWHEnN2Vz3hA3jm+sG1CaIs+9kIbmC5fnPSOxRzxPv02bLJdgd/8CMj2X5IH/dN6MlRfbYIY+563bzX+5sAy39QjYGq2IhburocfQ8Uo2z5+H7gti8+3uKJdWGHb/w+PTNW+dg7qEwIgyiAmvvkRL1tJcFDDARcgEqb+UkriPVvlwC+aMtYcYUNfD34IZEFIe8j8WmTSnqmv2fcj8krrIiPEcIu+ZFoE/TjX6F4jeRvCoZxJRe9NRPbYgIhLEBzIK9CV7sZete7rgJ738xyd8GoxldRld0LcPlXinU94NzGbyv8ImrkT23cT2TX3w="; var encryptionKey = Convert.FromBase64String(jsEncryptionKeyBase64); // Try to decrypt it from .NET. - var decrypted = Encryption.Decrypt(jsEncryptedBase64Contents, encryptionKey); + var decrypted = Encryption.SymmetricDecrypt(jsEncryptedBase64Contents, encryptionKey); // Assert that its equal as the original what we expect. var originalUnencrypted = "U1FMaXRlIGZvcm1hdCAzABAAAQEAQCAgAAAAAQAAAAMAAAAAAAAAAAAAAAIAAAAEAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAC52iQ0P+AACDvUADvUPxQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIFNAQcXHx8Bgml0YWJsZVBhc3N3b3Jkc1Bhc3N3b3JkcwJDUkVBVEUgVEFCTEUgIlBhc3N3b3JkcyIgKAogICAgIklkIiBURVhUIE5PVCBOVUxMIENPTlNUUkFJTlQgIlBLX1Bhc3N3b3JkcyIgUFJJTUFSWSBLRVksCiAgICAiVmFsdWUiIFRFWFQgTlVMTCwKICAgICJDcmVhdGVkQXQiIFRFWFQgTk9UIE5VTEwsCiAgICAiVXBkYXRlZEF0IiBURVhUIE5PVCBOVUxMCikxAgYXRR8BAGluZGV4c3FsaXRlX2F1dG9pbmRleF9QYXNzd29yZHNfMVBhc3N3b3JkcwMAAAAIAAAAAA0AAAADDrMAD5EPIg6zAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACgDVQFFOTc4MDJEQi05RUIzLTRDNkItQTdCOS03NDZFQ0YzNzQ4ODgCJwNVCTY5NjNCNUU5LURBNUUtNDgwMC05QzIwLTNEMDk3QkVCQjQyMigDVQE1NUMzM0VDNi1DQ0M4LTRBMjUtQjgyOC01MkJBNDlFQTVEMTcD"; @@ -75,15 +75,15 @@ public class EncryptionTests string plaintext = "Hello, World!"; // Derive a key from the password using Argon2id - byte[] key = Cryptography.Encryption.DeriveKeyFromPassword(password, salt); + byte[] key = Encryption.DeriveKeyFromPassword(password, salt); - // Encrypt the plaintext - string encrypted = Cryptography.Encryption.Encrypt(plaintext, key); + // SymmetricEncrypt the plaintext + string encrypted = Encryption.SymmetricEncrypt(plaintext, key); - // Decrypt the ciphertext using a different key - byte[] key2 = Cryptography.Encryption.DeriveKeyFromPassword("your-password2", salt); + // SymmetricDecrypt the ciphertext using a different key + byte[] key2 = Encryption.DeriveKeyFromPassword("your-password2", salt); - Assert.Throws(() => Cryptography.Encryption.Decrypt(encrypted, key2)); + Assert.Throws(() => Encryption.SymmetricDecrypt(encrypted, key2)); } /// @@ -103,35 +103,35 @@ public class EncryptionTests byte[] passwordHash = await Encryption.DeriveKeyFromPasswordAsync(password, salt); var passwordHashString = BitConverter.ToString(passwordHash).Replace("-", string.Empty); - var srpSignup = Cryptography.Srp.SignupPrepareAsync(client, salt, email, passwordHashString); + var srpSignup = Srp.SignupPrepareAsync(client, salt, email, passwordHashString); var privateKey = srpSignup.PrivateKey; var verifier = srpSignup.Verifier; // Login ----------------------------------- // 1. Client generates an ephemeral value. - var clientEphemeral = Cryptography.Srp.GenerateEphemeralClient(); + var clientEphemeral = Srp.GenerateEphemeralClient(); // --> Then client sends request to server. // 2. Server retrieves salt and verifier from database. // Then server generates an ephemeral value as well. - var serverEphemeral = Cryptography.Srp.GenerateEphemeralServer(verifier); + var serverEphemeral = Srp.GenerateEphemeralServer(verifier); // --> Send serverEphemeral.Public to client. // 3. Client derives shared session key. - var clientSession = Cryptography.Srp.DeriveSessionClient(privateKey, clientEphemeral.Secret, serverEphemeral.Public, salt, email); + var clientSession = Srp.DeriveSessionClient(privateKey, clientEphemeral.Secret, serverEphemeral.Public, salt, email); // --> send session.Proof to server. // 4. Server verifies the proof. - var serverSession = Cryptography.Srp.DeriveSessionServer(serverEphemeral.Secret, clientEphemeral.Public, salt, email, verifier, clientSession.Proof); + var serverSession = Srp.DeriveSessionServer(serverEphemeral.Secret, clientEphemeral.Public, salt, email, verifier, clientSession.Proof); // --> send serverSession.Proof to client. // 5. Client verifies the proof. - Cryptography.Srp.VerifySession(clientEphemeral.Public, clientSession, serverSession.Proof); + Srp.VerifySession(clientEphemeral.Public, clientSession, serverSession.Proof); // Ensure the keys match. Assert.That(clientSession.Key, Is.EqualTo(serverSession.Key)); diff --git a/src/Utilities/Cryptography/Encryption.cs b/src/Utilities/Cryptography/Encryption.cs index 5d1a4c256..25571d58a 100644 --- a/src/Utilities/Cryptography/Encryption.cs +++ b/src/Utilities/Cryptography/Encryption.cs @@ -7,7 +7,9 @@ namespace Cryptography; +using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Konscious.Security.Cryptography; using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Modes; @@ -15,7 +17,7 @@ using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Security; /// -/// Encryption class. +/// SrpArgonEncryption class. /// public static class Encryption { @@ -24,7 +26,7 @@ public static class Encryption /// /// User password. /// The salt to use for the Argon2id hash. - /// Encryption key as byte array. + /// SrpArgonEncryption key as byte array. public static byte[] DeriveKeyFromPassword(string password, string salt = "AliasVault") { byte[] passwordBytes = Encoding.UTF8.GetBytes(password); @@ -46,7 +48,7 @@ public static class Encryption /// /// User password. /// The salt to use for the Argon2id hash. - /// Encryption key as byte array. + /// SrpArgonEncryption key as byte array. public static async Task DeriveKeyFromPasswordAsync(string password, string salt) { byte[] passwordBytes = Encoding.UTF8.GetBytes(password); @@ -64,12 +66,12 @@ public static class Encryption } /// - /// Encrypt a plaintext string using AES-256 GCM. + /// SymmetricEncrypt a plaintext string using AES-256 GCM. /// /// The plaintext string. /// Key to use for encryption (must be 32 bytes for AES-256). /// The encrypted string (ciphertext). - public static string Encrypt(string plaintext, byte[] key) + public static string SymmetricEncrypt(string plaintext, byte[] key) { byte[] iv = new byte[12]; SecureRandom random = new(); @@ -92,12 +94,12 @@ public static class Encryption } /// - /// Decrypt a ciphertext string using AES-256 GCM. + /// SymmetricDecrypt a ciphertext string using AES-256 GCM. /// /// The encrypted string (ciphertext). /// The key used to originally encrypt the string. /// The original plaintext string. - public static string Decrypt(string ciphertext, byte[] key) + public static string SymmetricDecrypt(string ciphertext, byte[] key) { byte[] fullCipher = Convert.FromBase64String(ciphertext); @@ -117,4 +119,108 @@ public static class Encryption return Encoding.UTF8.GetString(plaintextBytes).TrimEnd('\0'); } + + /// + /// Encrypts a plaintext string using an RSA public key. + /// + /// The plaintext to encrypt. + /// The public key in XML format. + /// The encrypted data as a base64-encoded string. + public static string EncryptWithPublicKey(string plaintext, string publicKey) + { + using (var rsa = new RSACryptoServiceProvider()) + { + ImportPublicKey(rsa, publicKey); + byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + byte[] cipherBytes = rsa.Encrypt(plaintextBytes, true); + return Convert.ToBase64String(cipherBytes); + } + } + + /// + /// Decrypts a ciphertext string using an RSA private key. + /// + /// The base64-encoded ciphertext to decrypt. + /// The private key in XML format. + /// The decrypted plaintext. + public static string DecryptWithPrivateKey(string ciphertext, string privateKey) + { + using (var rsa = new RSACryptoServiceProvider()) + { + ImportPrivateKey(rsa, privateKey); + byte[] cipherBytes = Convert.FromBase64String(ciphertext); + byte[] plaintextBytes = rsa.Decrypt(cipherBytes, true); + return Encoding.UTF8.GetString(plaintextBytes); + } + } + + /// + /// Imports a public key from JWK format into an RSA provider. + /// + /// The RSA provider to import the key into. + /// The public key in JWK format. + private static void ImportPublicKey(RSACryptoServiceProvider rsa, string jwk) + { + var jwkObj = JsonSerializer.Deserialize(jwk); + var n = Base64UrlDecode(jwkObj.GetProperty("n").GetString()!); + var e = Base64UrlDecode(jwkObj.GetProperty("e").GetString()!); + + var rsaParameters = new RSAParameters + { + Modulus = n, + Exponent = e, + }; + + rsa.ImportParameters(rsaParameters); + } + + /// + /// Imports a private key from JWK format into an RSA provider. + /// + /// The RSA provider to import the key into. + /// The private key in JWK format. + private static void ImportPrivateKey(RSACryptoServiceProvider rsa, string jwk) + { + var jwkObj = JsonSerializer.Deserialize(jwk); + var n = Base64UrlDecode(jwkObj.GetProperty("n").GetString()!); + var e = Base64UrlDecode(jwkObj.GetProperty("e").GetString()!); + var d = Base64UrlDecode(jwkObj.GetProperty("d").GetString()!); + var p = Base64UrlDecode(jwkObj.GetProperty("p").GetString()!); + var q = Base64UrlDecode(jwkObj.GetProperty("q").GetString()!); + var dp = Base64UrlDecode(jwkObj.GetProperty("dp").GetString()!); + var dq = Base64UrlDecode(jwkObj.GetProperty("dq").GetString()!); + var qi = Base64UrlDecode(jwkObj.GetProperty("qi").GetString()!); + + var rsaParameters = new RSAParameters + { + Modulus = n, + Exponent = e, + D = d, + P = p, + Q = q, + DP = dp, + DQ = dq, + InverseQ = qi, + }; + + rsa.ImportParameters(rsaParameters); + } + + /// + /// Decodes a Base64Url-encoded string to a byte array. + /// + /// The Base64Url-encoded string. + /// The decoded byte array. + private static byte[] Base64UrlDecode(string base64Url) + { + string padded = base64Url; + switch (base64Url.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + string base64 = padded.Replace("-", "+").Replace("_", "/"); + return Convert.FromBase64String(base64); + } } From c266fedd8923bafe0d3991fc5117f748af1d89c7 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 29 Jul 2024 16:39:06 +0200 Subject: [PATCH 03/11] Add encryption logic to SmtpServer and integration tests (#117) --- .../Services/Database/DbService.cs | 14 +- .../wwwroot/appsettings.json | 2 +- src/Databases/AliasServerDb/AliasVaultUser.cs | 7 - src/Databases/AliasServerDb/Email.cs | 9 +- ...142520_AddEncryptionKeyTables.Designer.cs} | 11 +- ... 20240729142520_AddEncryptionKeyTables.cs} | 21 ++- .../AliasServerDbContextModelSnapshot.cs | 9 +- .../AliasVault.SmtpService.csproj | 1 + .../Handlers/DatabaseMessageStore.cs | 139 ++++++++++++------ .../SmtpServer/SmtpServerTests.cs | 111 ++++++++++++-- .../Utilities/RsaEncryptionTests.cs | 94 ++++++++++++ .../Utilities/SrpArgonEncryptionTests.cs | 12 +- .../Cryptography/Cryptography.csproj | 4 + src/Utilities/Cryptography/EmailEncryption.cs | 92 ++++++++++++ src/Utilities/Cryptography/Encryption.cs | 47 ++++-- 15 files changed, 460 insertions(+), 113 deletions(-) rename src/Databases/AliasServerDb/Migrations/{20240729104544_AddEncryptionKeyTables.Designer.cs => 20240729142520_AddEncryptionKeyTables.Designer.cs} (99%) rename src/Databases/AliasServerDb/Migrations/{20240729104544_AddEncryptionKeyTables.cs => 20240729142520_AddEncryptionKeyTables.cs} (96%) create mode 100644 src/Utilities/Cryptography/EmailEncryption.cs diff --git a/src/AliasVault.Client/Services/Database/DbService.cs b/src/AliasVault.Client/Services/Database/DbService.cs index d53f4cca9..30e332e84 100644 --- a/src/AliasVault.Client/Services/Database/DbService.cs +++ b/src/AliasVault.Client/Services/Database/DbService.cs @@ -121,6 +121,9 @@ public class DbService : IDisposable // Set the initial state of the database service. _state.UpdateState(DbServiceState.DatabaseStatus.SavingToServer); + // Get the public encryption key that server requires to encrypt data they receive for current user. + var encryptionKey = await GetOrCreateEncryptionKeyAsync(); + // Save the actual dbContext. await _dbContext.SaveChangesAsync(); @@ -130,7 +133,7 @@ public class DbService : IDisposable string encryptedBase64String = await _jsInteropService.SymmetricEncrypt(base64String, _authService.GetEncryptionKeyAsBase64Async()); // Save to webapi. - var success = await SaveToServerAsync(encryptedBase64String); + var success = await SaveToServerAsync(encryptionKey.PublicKey, encryptedBase64String); if (success) { Console.WriteLine("Database successfully saved to server."); @@ -441,9 +444,10 @@ public class DbService : IDisposable /// /// Save encrypted database blob to server. /// + /// RSA public key that server requires in order to encrypt data for user such as received emails. /// Encrypted database as string. /// True if save action succeeded. - private async Task SaveToServerAsync(string encryptedDatabase) + private async Task SaveToServerAsync(string publicEncryptionKey, string encryptedDatabase) { // Send list of email addresses that are used in aliases by this vault so they can be // claimed on the server. @@ -459,10 +463,8 @@ public class DbService : IDisposable .Where(email => _config.SmtpAllowedDomains.Any(domain => email.EndsWith(domain))) .ToList(); - var encryptionKey = await GetOrCreateEncryptionKeyAsync(); - var databaseVersion = await GetCurrentDatabaseVersionAsync(); - var vaultObject = new Vault(encryptedDatabase, databaseVersion, encryptionKey.PublicKey, emailAddresses, DateTime.Now, DateTime.Now); + var vaultObject = new Vault(encryptedDatabase, databaseVersion, publicEncryptionKey, emailAddresses, DateTime.Now, DateTime.Now); try { @@ -499,8 +501,6 @@ public class DbService : IDisposable UpdatedAt = DateTime.Now, }; await _dbContext.EncryptionKeys.AddAsync(encryptionKey); - await _dbContext.SaveChangesAsync(); - return encryptionKey; } } diff --git a/src/AliasVault.Client/wwwroot/appsettings.json b/src/AliasVault.Client/wwwroot/appsettings.json index 3c7ac3cc6..8625846b9 100644 --- a/src/AliasVault.Client/wwwroot/appsettings.json +++ b/src/AliasVault.Client/wwwroot/appsettings.json @@ -1,4 +1,4 @@ { "ApiUrl": "http://localhost:5092", - "SmtpAllowedDomains": ["localmail.tld","localmail2.tld"] + "SmtpAllowedDomains": ["example.tld"] } diff --git a/src/Databases/AliasServerDb/AliasVaultUser.cs b/src/Databases/AliasServerDb/AliasVaultUser.cs index d5553a368..dfb61a65a 100644 --- a/src/Databases/AliasServerDb/AliasVaultUser.cs +++ b/src/Databases/AliasServerDb/AliasVaultUser.cs @@ -27,13 +27,6 @@ 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. /// diff --git a/src/Databases/AliasServerDb/Email.cs b/src/Databases/AliasServerDb/Email.cs index 89f3866c8..4c7643525 100644 --- a/src/Databases/AliasServerDb/Email.cs +++ b/src/Databases/AliasServerDb/Email.cs @@ -33,11 +33,18 @@ public class Email public Guid UserEncryptionKeyId { get; set; } /// - /// Gets or sets foreign key to the UserEncryptionKey object. + /// Gets or sets foreign key to the UserEncryptionKey object which contains the public key used for encrypting + /// the symmetric encryption key. /// [ForeignKey("UserEncryptionKeyId")] public virtual UserEncryptionKey EncryptionKey { get; set; } = null!; + /// + /// Gets or sets the encrypted symmetric key which was used to encrypt the email message. + /// This key is encrypted with the public key of the user. + /// + public string EncryptedSymmetricKey { get; set; } = null!; + /// /// Gets or sets the subject of the email. /// diff --git a/src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.Designer.cs similarity index 99% rename from src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.Designer.cs rename to src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.Designer.cs index cc27ee9e6..c88bc6263 100644 --- a/src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.Designer.cs +++ b/src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace AliasServerDb.Migrations { [DbContext(typeof(AliasServerDbContext))] - [Migration("20240729104544_AddEncryptionKeyTables")] + [Migration("20240729142520_AddEncryptionKeyTables")] partial class AddEncryptionKeyTables { /// @@ -158,11 +158,6 @@ namespace AliasServerDb.Migrations b.Property("PhoneNumberConfirmed") .HasColumnType("INTEGER"); - b.Property("PublicKey") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("TEXT"); - b.Property("Salt") .IsRequired() .HasMaxLength(100) @@ -237,6 +232,10 @@ namespace AliasServerDb.Migrations b.Property("DateSystem") .HasColumnType("TEXT"); + b.Property("EncryptedSymmetricKey") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("From") .IsRequired() .HasColumnType("TEXT"); diff --git a/src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.cs b/src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.cs similarity index 96% rename from src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.cs rename to src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.cs index d38e78c59..371fe7ff6 100644 --- a/src/Databases/AliasServerDb/Migrations/20240729104544_AddEncryptionKeyTables.cs +++ b/src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.cs @@ -15,6 +15,13 @@ namespace AliasServerDb.Migrations // Delete all records from the Email table as adding PKI will break the existing data. migrationBuilder.Sql("DELETE FROM Emails"); + migrationBuilder.AddColumn( + name: "EncryptedSymmetricKey", + table: "Emails", + type: "TEXT", + nullable: false, + defaultValue: ""); + migrationBuilder.AddColumn( name: "UserEncryptionKeyId", table: "Emails", @@ -23,14 +30,6 @@ namespace AliasServerDb.Migrations 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 @@ -117,12 +116,12 @@ namespace AliasServerDb.Migrations table: "Emails"); migrationBuilder.DropColumn( - name: "UserEncryptionKeyId", + name: "EncryptedSymmetricKey", table: "Emails"); migrationBuilder.DropColumn( - name: "PublicKey", - table: "AliasVaultUsers"); + name: "UserEncryptionKeyId", + table: "Emails"); } } } diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index d24be653d..61a524f27 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -155,11 +155,6 @@ namespace AliasServerDb.Migrations b.Property("PhoneNumberConfirmed") .HasColumnType("INTEGER"); - b.Property("PublicKey") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("TEXT"); - b.Property("Salt") .IsRequired() .HasMaxLength(100) @@ -234,6 +229,10 @@ namespace AliasServerDb.Migrations b.Property("DateSystem") .HasColumnType("TEXT"); + b.Property("EncryptedSymmetricKey") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("From") .IsRequired() .HasColumnType("TEXT"); diff --git a/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj b/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj index a6017b757..4b503b865 100644 --- a/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj +++ b/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj @@ -24,5 +24,6 @@ + diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index e8f1b1f39..9f390f210 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -5,6 +5,8 @@ // //----------------------------------------------------------------------- +using Cryptography; + namespace AliasVault.SmtpService.Handlers; using System.Buffers; @@ -40,71 +42,122 @@ public class DatabaseMessageStore(ILogger logger, Config c /// SmtpResponse. public override async Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) { - await using var stream = new MemoryStream(); - - var position = buffer.GetPosition(0); - while (buffer.TryGet(ref position, out var memory)) + try { - stream.Write(memory.Span); - } + await using var stream = new MemoryStream(); - // Max email filesize limit: 10MB. If the mail is larger in size, reject it. - // Because of base64 encoding which has approx 33% increase in binary size - // we multiply the limit by 1.4 to be safe. - var maxEmailSizeInMegabytes = 10; - if (stream.Length > ((maxEmailSizeInMegabytes * 1024 * 1024) * 1.4)) - { - return SmtpResponse.SizeLimitExceeded; - } - - stream.Position = 0; - var message = await MimeMessage.LoadAsync(stream, cancellationToken); - // Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance. - var allAddresses = transaction.To - .Distinct() - .ToList(); - // Limit list to 15 addresses max. (to prevent mailbomb spam abuse) - var toAddresses = allAddresses.Take(15).ToList(); - // For every toAddress - foreach (var toAddress in toAddresses) - { - if (toAddress == null) + var position = buffer.GetPosition(0); + while (buffer.TryGet(ref position, out var memory)) { - // No toAddress, skip. - logger.LogWarning("Skip email, no toAddress available."); - return SmtpResponse.NoValidRecipientsGiven; + stream.Write(memory.Span); } - if (!config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) + + // Max email filesize limit: 10MB. If the mail is larger in size, reject it. + // Because of base64 encoding which has approx 33% increase in binary size + // we multiply the limit by 1.4 to be safe. + var maxEmailSizeInMegabytes = 10; + if (stream.Length > ((maxEmailSizeInMegabytes * 1024 * 1024) * 1.4)) { - // ToAddress domain is not allowed. - if (toAddresses.Count > 1) + return SmtpResponse.SizeLimitExceeded; + } + + stream.Position = 0; + var message = await MimeMessage.LoadAsync(stream, cancellationToken); + // Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance. + var allAddresses = transaction.To + .Distinct() + .ToList(); + // Limit list to 15 addresses max. (to prevent mailbomb spam abuse) + var toAddresses = allAddresses.Take(15).ToList(); + // For every toAddress + foreach (var toAddress in toAddresses) + { + // Check if toAddress domain is allowed. + if (toAddress is null || !config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) { - // If more recipients, silently skip this one. - continue; + // ToAddress domain is not allowed. + if (toAddresses.Count > 1) + { + // If more recipients, silently skip this one. + continue; + } + + // If only one recipient, return error. + logger.LogWarning( + "Rejected email: email for {ToAddress} is not allowed. Domain not in allowed domain list.", + toAddress?.User + "@" + toAddress?.Host); + return SmtpResponse.NoValidRecipientsGiven; } - // If only one recipient, return error. - logger.LogWarning("Rejected email: email for {ToAddress} is not allowed.", toAddress.User + "@" + toAddress.Host); - return SmtpResponse.NoValidRecipientsGiven; + // Check if the local part of the toAddress is a known alias (claimed by a user) + var dbContext = await dbContextFactory.CreateDbContextAsync(); + var userEmailClaim = await dbContext.UserEmailClaims.FirstOrDefaultAsync(x => + x.AddressLocal == toAddress.User.ToLowerInvariant() && + x.AddressDomain == toAddress.Host.ToLowerInvariant()); + + if (userEmailClaim is null) + { + // Email address has no user claim with corresponding encryption key so we cannot process it. + if (toAddresses.Count > 1) + { + // If more recipients, silently skip this one. + continue; + } + + // If only one recipient, return error. + logger.LogWarning( + "Rejected email: email for {ToAddress} is not allowed. No user claim on this ToAddress.", + toAddress.User + "@" + toAddress.Host); + return SmtpResponse.NoValidRecipientsGiven; + } + + // Retrieve user public encryption key from database + var userPublicKey = dbContext.UserEncryptionKeys.FirstOrDefault(x => + x.UserId == userEmailClaim.UserId && x.IsPrimary); + + if (userPublicKey is null) + { + // Email address has no user claim with corresponding encryption key so we cannot process it. + if (toAddresses.Count > 1) + { + // If more recipients, silently skip this one. + continue; + } + + // If only one recipient, return error. + logger.LogCritical( + "Rejected email: email for {ToAddress} cannot be processed. No primary encryption key found for this user.", + toAddress.User + "@" + toAddress.Host); + return SmtpResponse.NoValidRecipientsGiven; + } + + var insertedId = await InsertEmailIntoDatabase(message, userPublicKey); + logger.LogInformation("Email for {ToAddress} successfully saved into database with ID {insertedId}.", + toAddress.User + "@" + toAddress.Host, insertedId); } - var insertedId = await InsertEmailIntoDatabase(message); - logger.LogInformation("Email for {ToAddress} successfully saved into database with ID {insertedId}.", toAddress.User + "@" + toAddress.Host, insertedId); + return SmtpResponse.Ok; + } + catch (Exception ex) + { + logger.LogError(ex, "Error saving email into database."); + return SmtpResponse.MailboxUnavailable; } - - return SmtpResponse.Ok; } /// /// Insert email into database. /// /// MimeMessage to save into database. - private async Task InsertEmailIntoDatabase(MimeMessage message) + /// The public key of the user to encrypt the mail contents with. + private async Task InsertEmailIntoDatabase(MimeMessage message, UserEncryptionKey userEncryptionKey) { var dbContext = await dbContextFactory.CreateDbContextAsync(); var newEmail = ConvertMimeMessageToEmail(message); + newEmail = EmailEncryption.EncryptEmail(newEmail, userEncryptionKey); + // Insert the email into the database. await dbContext.Emails.AddAsync(newEmail); await dbContext.SaveChangesAsync(); diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index cb2469a2b..25084bf0f 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -8,17 +8,26 @@ namespace AliasVault.IntegrationTests.SmtpServer; using AliasServerDb; - +using Cryptography; using MailKit.Security; using MailKit.Net.Smtp; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using MimeKit; [TestFixture] public class SmtpServerTests { + /// + /// Example public key for RSA encryption tests. This is a public key generated by the JSInterop on the client. + /// + public const string PublicKey = "{\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"encrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\"}"; + + /// + /// Example private key for RSA encryption tests. This is a private key generated by the JSInterop on the client. + /// + public const string PrivateKey = "{\"alg\":\"RSA-OAEP-256\",\"d\":\"KLByToUaseNym1oNkkrTRPQOHfREXywWWaTXhP8AwtXgEKomqv9G-c6aR-K-T6btY2P-oPj268I0rbnRhSEQdrsmUT5_cp8goYGJrx6MFwGlA32x6klXnus6GDsjkXJi7I5eJL17XV99CDOBtTagFxkNdaBpvClUcHTDvncQ5bGAIrNqS7KADoi-E19BxiW_GcSJiVT4H8kDHCkcgTjZx4rKJjTPqqJOLg_poDrvnTJbsjcXP80kQ1AAENRAvDGhSWzP0IYtP1DM_2FzM1s1b_SrUsS3KiO8drR2Kv-PSOvncpaNVnZGElGCraJ3B2Mm-dr3vFjkyWeWPceqyhtYoQ\",\"dp\":\"ttxRg6uB2YLWfkPKUkzAaBWniZDHM4silJX3IgexA5GJBd9GIhUiVEolc_MgmieQbZ10CC65wqcHVv82lgCeqxYHxHWLxxJCrOpvkFlYE8wr_WqOPQEzYKv3KsL6s6Fj7Pbv9WehWpXdlbJUm4Cy5cgUkdH6PXiwBSvfhCQGrYk\",\"dq\":\"YFqlDAVTfvTR2bMJulvWzd_at81CsEmR-lPo91h-3cLpxcLDOlrTP-d3Ass2I4r1PtBT1bKuuHeQ6fZmHH55a6m8XxPEs2BuIxlh9RiFfWbd66969UOnItuawf0rfGneKt1zl4st60T3KXd8-ECrLxdsvOYpOEuNzvIY_b3qitE\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"decrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\",\"p\":\"yUdbuDwmVwKhou5xXUxJfi1eOjN-5F88wtyR4LpgU2OvZ7m-er4hpXx5I2E-KTVX_iIp0Q9VDXhHH-WkN3qg20RXjRoxwgrggYbfdIYdrB-2kbMamq5cOf2XbXGEO8PoDXYoZprIB0EhrD4qVVykPUYg5El0hIKPdfs9LNoOEzs\",\"q\":\"vg93lGTurG0EY179tPr6Qe3ttKEN9zvQ97dZ9034DOWDoWLe-iMKG1-yKmkG4uwC8QqNnm1mPz7EqOuHPPGVTTib9NA4JdM27PUHSPKDUvp0cV4LhF6e-W7tMFk8WbJ2ACqkqhZHYgm-FDkZBCpnehNegTxipLluKa79G__ZHFE\",\"qi\":\"fnI3Wh5aYuxI0R18NTeFKjo1P7_Ck65Gc9O3CmeqiIe58EJaXQEcdwdSOG8aVmn03szXLHEnp7anNIH63f0ericbRYdCQVhcQpvsXzEM_sp4aYmwz45palrjlY4Jc6G6XQn3FwiqqRDvpnXdsunnQ62HHhxmslaEMYHQyLng2ss\"}"; + /// /// The test host instance. /// @@ -39,6 +48,48 @@ public class SmtpServerTests _testHost = _testHostBuilder.Build(); await _testHost.StartAsync(); + + // Create an AliasVault user, public key and an email claim. + var dbContext = _testHostBuilder.GetDbContext(); + var user = new AliasVaultUser + { + UserName = "testuser", + Email = "testuser@example.tld", + Salt = "salt", + Verifier = "verifier", + }; + dbContext.AliasVaultUsers.Add(user); + await dbContext.SaveChangesAsync(); + + // Create email claims. + var emailClaim = new UserEmailClaim + { + UserId = user.Id, + Address = "claimed@example.tld", + AddressLocal = "claimed", + AddressDomain = "example.tld", + }; + dbContext.UserEmailClaims.Add(emailClaim); + var emailClaim2 = new UserEmailClaim + { + UserId = user.Id, + Address = "claimed.cc@example.tld", + AddressLocal = "claimed.cc", + AddressDomain = "example.tld", + }; + dbContext.UserEmailClaims.Add(emailClaim2); + + + // Create public key. + var encryptionKey = new UserEncryptionKey + { + UserId = user.Id, + PublicKey = PublicKey, + IsPrimary = true, + }; + dbContext.UserEncryptionKeys.Add(encryptionKey); + + await dbContext.SaveChangesAsync(); } /// @@ -55,7 +106,7 @@ public class SmtpServerTests } /// - /// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly. + /// Tests sending a single email in plain format to the SMTP server with valid claim to check if it is processed correctly. /// [Test] public async Task SingleEmailPlain() @@ -63,7 +114,7 @@ public class SmtpServerTests // Send an email to the SMTP server. var message = new MimeMessage(); message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); - message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld")); message.Subject = "Test Email"; const string textBody = "This is a test email plain."; message.Body = new BodyBuilder { TextBody = textBody}.ToMessageBody(); @@ -71,13 +122,18 @@ public class SmtpServerTests // Check if the email is in the database. var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + + // Test non-encrypted field. + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + + // Decrypt the email and then check all individual fields. + processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); Assert.Multiple(() => { Assert.That(processedEmail, Is.Not.Null); Assert.That(processedEmail.From, Is.EqualTo("\"Test Sender\" ")); Assert.That(processedEmail.FromLocal, Is.EqualTo("sender")); Assert.That(processedEmail.FromDomain, Is.EqualTo("example.com")); - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email plain.")); Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email plain.")); Assert.That(processedEmail.MessageHtml, Is.Null); @@ -93,7 +149,7 @@ public class SmtpServerTests // Arrange var message = new MimeMessage(); message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); - message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld")); message.Subject = "Test Email with HTML body."; const string htmlBody = "

This is a test email html.

"; message.Body = new BodyBuilder { HtmlBody = htmlBody }.ToMessageBody(); @@ -101,10 +157,15 @@ public class SmtpServerTests // Check if the email is in the database. var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + + // Test non-encrypted field. + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + + // Decrypt the email and then check all individual fields. + processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); Assert.Multiple(() => { Assert.That(processedEmail, Is.Not.Null); - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email html.")); Assert.That(processedEmail.MessagePlain, Is.Null); Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); @@ -120,7 +181,7 @@ public class SmtpServerTests // Arrange var message = new MimeMessage(); message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); - message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld")); message.Subject = "Test Email with multipart body."; const string textBody = "This is a test email multipart."; const string htmlBody = "

This is a test email multipart.

"; @@ -129,10 +190,15 @@ public class SmtpServerTests // Check if the email is in the database. var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + + // Test non-encrypted field. + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + + // Decrypt the email and then check all individual fields. + processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); Assert.Multiple(() => { Assert.That(processedEmail, Is.Not.Null); - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email multipart.")); Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email multipart.")); Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); @@ -148,8 +214,8 @@ public class SmtpServerTests // Send an email to the SMTP server. var message = new MimeMessage(); message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); - message.To.Add(new MailboxAddress("Test Recipient", "recipient.to@example.tld")); - message.Cc.Add(new MailboxAddress("Test Recipient 2", "recipient.cc@example.tld")); + message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld")); + message.Cc.Add(new MailboxAddress("Test Recipient 2", "claimed.cc@example.tld")); message.Cc.Add(new MailboxAddress("Test Recipient 3 unknown domain", "recipient@unknowndomain.tld")); message.Subject = "Test Email"; @@ -162,10 +228,10 @@ public class SmtpServerTests } /// - /// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly. + /// Tests sending an email to an unknown recipient domain, we expect to get an error from the SMTP server. /// [Test] - public void SingleEmailUnknownRecipient() + public void SingleEmailUnknownRecipientDomain() { // Send an email to the SMTP server. var message = new MimeMessage(); @@ -179,6 +245,25 @@ public class SmtpServerTests Assert.ThrowsAsync(async () => await SendMessageToSmtpServer(message)); } + /// + /// Tests sending a single email to a known recipient domain but with no valid user claim. We expect + /// to get an error from the SMTP server. + /// + [Test] + public void SingleEmailNoUserClaim() + { + // Send an email to the SMTP server. + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "not-claimed@example.tld")); + message.Subject = "Test Email"; + const string textBody = "This is a test email plain."; + message.Body = new BodyBuilder { TextBody = textBody}.ToMessageBody(); + + // Expect error from SmtpClient when sending email to unknown domain. + Assert.ThrowsAsync(async () => await SendMessageToSmtpServer(message)); + } + /// /// Sends a message to the SMTP server. /// diff --git a/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs index 545c25850..e52e7c1c6 100644 --- a/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs +++ b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs @@ -26,6 +26,37 @@ public class RsaEncryptionTests ///
public const string PrivateKey = "{\"alg\":\"RSA-OAEP-256\",\"d\":\"KLByToUaseNym1oNkkrTRPQOHfREXywWWaTXhP8AwtXgEKomqv9G-c6aR-K-T6btY2P-oPj268I0rbnRhSEQdrsmUT5_cp8goYGJrx6MFwGlA32x6klXnus6GDsjkXJi7I5eJL17XV99CDOBtTagFxkNdaBpvClUcHTDvncQ5bGAIrNqS7KADoi-E19BxiW_GcSJiVT4H8kDHCkcgTjZx4rKJjTPqqJOLg_poDrvnTJbsjcXP80kQ1AAENRAvDGhSWzP0IYtP1DM_2FzM1s1b_SrUsS3KiO8drR2Kv-PSOvncpaNVnZGElGCraJ3B2Mm-dr3vFjkyWeWPceqyhtYoQ\",\"dp\":\"ttxRg6uB2YLWfkPKUkzAaBWniZDHM4silJX3IgexA5GJBd9GIhUiVEolc_MgmieQbZ10CC65wqcHVv82lgCeqxYHxHWLxxJCrOpvkFlYE8wr_WqOPQEzYKv3KsL6s6Fj7Pbv9WehWpXdlbJUm4Cy5cgUkdH6PXiwBSvfhCQGrYk\",\"dq\":\"YFqlDAVTfvTR2bMJulvWzd_at81CsEmR-lPo91h-3cLpxcLDOlrTP-d3Ass2I4r1PtBT1bKuuHeQ6fZmHH55a6m8XxPEs2BuIxlh9RiFfWbd66969UOnItuawf0rfGneKt1zl4st60T3KXd8-ECrLxdsvOYpOEuNzvIY_b3qitE\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"decrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\",\"p\":\"yUdbuDwmVwKhou5xXUxJfi1eOjN-5F88wtyR4LpgU2OvZ7m-er4hpXx5I2E-KTVX_iIp0Q9VDXhHH-WkN3qg20RXjRoxwgrggYbfdIYdrB-2kbMamq5cOf2XbXGEO8PoDXYoZprIB0EhrD4qVVykPUYg5El0hIKPdfs9LNoOEzs\",\"q\":\"vg93lGTurG0EY179tPr6Qe3ttKEN9zvQ97dZ9034DOWDoWLe-iMKG1-yKmkG4uwC8QqNnm1mPz7EqOuHPPGVTTib9NA4JdM27PUHSPKDUvp0cV4LhF6e-W7tMFk8WbJ2ACqkqhZHYgm-FDkZBCpnehNegTxipLluKa79G__ZHFE\",\"qi\":\"fnI3Wh5aYuxI0R18NTeFKjo1P7_Ck65Gc9O3CmeqiIe58EJaXQEcdwdSOG8aVmn03szXLHEnp7anNIH63f0ericbRYdCQVhcQpvsXzEM_sp4aYmwz45palrjlY4Jc6G6XQn3FwiqqRDvpnXdsunnQ62HHhxmslaEMYHQyLng2ss\"}"; + /// + /// Full flow test for server-side email encryption and client-side decryption. + /// + [Test] + public void EmailEncryptionAndDecryptionTest() + { + // ----------------- + // Server-side part: + // ----------------- + // 1. Generate symmetric key. + var symmetricKey = Encryption.GenerateRandomSymmetricKey(); + + // 2. Encrypt email body with symmetric key. + var emailBody = "Hello, RSA encryption!"; + var encryptedEmailBody = Encryption.SymmetricEncrypt(emailBody, symmetricKey); + + // 3. Encrypt symmetric key with public key. + var encryptedSymmetricKey = Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, PublicKey); + + // ----------------- + // Client-side part: + // ----------------- + // 4. Decrypt symmetric key with private key. + var decryptedSymmetricKey = Encryption.DecryptSymmetricKeyWithRsa(encryptedSymmetricKey, PrivateKey); + + // 5. Decrypt email body with symmetric key. + var decryptedEmailBody = Encryption.SymmetricDecrypt(encryptedEmailBody, decryptedSymmetricKey); + + Assert.That(decryptedEmailBody, Is.EqualTo(emailBody)); + } + /// /// Tests that GenerateRsaKeyPair method returns a valid key pair. /// @@ -150,4 +181,67 @@ public class RsaEncryptionTests Assert.Throws(() => Encryption.DecryptWithPrivateKey(ciphertext, invalidPrivateKey)); } + + /// + /// Tests if GenerateRandomSymmetricKey method returns a key of correct length. + /// + [Test] + public void GenerateRandomSymmetricKey_ReturnsCorrectLength() + { + var key = Encryption.GenerateRandomSymmetricKey(); + Assert.That(key.Length, Is.EqualTo(32), "The generated key should be 32 bytes (256 bits) long."); + } + + /// + /// Tests if GenerateRandomSymmetricKey method generates different keys on consecutive calls. + /// + [Test] + public void GenerateRandomSymmetricKey_GeneratesDifferentKeys() + { + var key1 = Encryption.GenerateRandomSymmetricKey(); + var key2 = Encryption.GenerateRandomSymmetricKey(); + Assert.That(key1, Is.Not.EqualTo(key2), "Two generated keys should not be identical."); + } + + /// + /// Tests if EncryptSymmetricKey method correctly encrypts a symmetric key. + /// + [Test] + public void EncryptSymmetricKey_EncryptsCorrectly() + { + var symmetricKey = Encryption.GenerateRandomSymmetricKey(); + var encryptedKey = Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, PublicKey); + + Assert.That(encryptedKey, Is.Not.Null.And.Not.Empty, "Encrypted key should not be null or empty."); + Assert.That(encryptedKey, Is.Not.EqualTo(Convert.ToBase64String(symmetricKey)), "Encrypted key should be different from the original key."); + } + + /// + /// Tests if a symmetric key can be correctly encrypted and then decrypted. + /// + [Test] + public void EncryptAndDecryptSymmetricKey_ReturnsOriginalKey() + { + var symmetricKey = Encryption.GenerateRandomSymmetricKey(); + var encryptedKey = Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, PublicKey); + + // Assuming you have a method to decrypt with the private key + var decryptedKey = Encryption.DecryptSymmetricKeyWithRsa(encryptedKey, PrivateKey); + + Assert.That(decryptedKey, Is.EqualTo(symmetricKey), "Decrypted key should match the original symmetric key."); + } + + /// + /// Tests if EncryptSymmetricKey method throws an exception when given an invalid public key. + /// + [Test] + public void EncryptSymmetricKey_WithInvalidPublicKey_ThrowsException() + { + var symmetricKey = Encryption.GenerateRandomSymmetricKey(); + var invalidPublicKey = "invalid_key"; + + Assert.Throws( + () => Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, invalidPublicKey), + "Encrypting with an invalid public key should throw an ArgumentException."); + } } diff --git a/src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs index c44e55e38..51338a800 100644 --- a/src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs +++ b/src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs @@ -19,8 +19,9 @@ public class SrpArgonEncryptionTests /// /// Test basic encryption and decryption using default encryption logic (Argon2id and AES-256). /// + /// Task. [Test] - public void TestBasicEncrypt() + public async Task TestBasicEncrypt() { string password = "your-password"; string salt = "your-salt"; // Use a secure random salt in production @@ -28,7 +29,7 @@ public class SrpArgonEncryptionTests string plaintext = "Hello, World!"; // Derive a key from the password using Argon2id - byte[] key = Encryption.DeriveKeyFromPassword(password, salt); + byte[] key = await Encryption.DeriveKeyFromPasswordAsync(password, salt); Console.WriteLine($"Derived key: {key.Length} bytes (hex: {BitConverter.ToString(key).Replace("-", string.Empty)})"); // SymmetricEncrypt the plaintext @@ -66,8 +67,9 @@ public class SrpArgonEncryptionTests /// /// Test basic encryption and decryption using default encryption logic (Argon2id and AES-256). /// + /// Task. [Test] - public void TestNotEqualsPassword() + public async Task TestNotEqualsPassword() { string password = "your-password"; string salt = "your-salt"; // Use a secure random salt in production @@ -75,13 +77,13 @@ public class SrpArgonEncryptionTests string plaintext = "Hello, World!"; // Derive a key from the password using Argon2id - byte[] key = Encryption.DeriveKeyFromPassword(password, salt); + byte[] key = await Encryption.DeriveKeyFromPasswordAsync(password, salt); // SymmetricEncrypt the plaintext string encrypted = Encryption.SymmetricEncrypt(plaintext, key); // SymmetricDecrypt the ciphertext using a different key - byte[] key2 = Encryption.DeriveKeyFromPassword("your-password2", salt); + byte[] key2 = await Encryption.DeriveKeyFromPasswordAsync("your-password2", salt); Assert.Throws(() => Encryption.SymmetricDecrypt(encrypted, key2)); } diff --git a/src/Utilities/Cryptography/Cryptography.csproj b/src/Utilities/Cryptography/Cryptography.csproj index 4466c730b..ebab5f520 100644 --- a/src/Utilities/Cryptography/Cryptography.csproj +++ b/src/Utilities/Cryptography/Cryptography.csproj @@ -30,4 +30,8 @@ + + + + diff --git a/src/Utilities/Cryptography/EmailEncryption.cs b/src/Utilities/Cryptography/EmailEncryption.cs new file mode 100644 index 000000000..ce796b582 --- /dev/null +++ b/src/Utilities/Cryptography/EmailEncryption.cs @@ -0,0 +1,92 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace Cryptography; + +using AliasServerDb; + +/// +/// Helper class for encrypting and decrypting email contents. +/// +public class EmailEncryption +{ + /// + /// Encrypt the email contents with the user's public key. + /// + /// The plain text email object to encrypt. + /// The user public encryption key to use for the encryption. + /// Email object with all sensitive fields encrypted. + public static Email EncryptEmail(Email email, UserEncryptionKey userEncryptionKey) + { + // Generate symmetric key for email encryption. + var symmetricKey = Encryption.GenerateRandomSymmetricKey(); + + // Encrypt all email contents with the symmetric key. + if (email.MessageHtml is not null) + { + email.MessageHtml = Encryption.SymmetricEncrypt(email.MessageHtml, symmetricKey); + } + + if (email.MessagePlain is not null) + { + email.MessagePlain = Encryption.SymmetricEncrypt(email.MessagePlain, symmetricKey); + } + + if (email.MessagePreview is not null) + { + email.MessagePreview = Encryption.SymmetricEncrypt(email.MessagePreview, symmetricKey); + } + + email.MessageSource = Encryption.SymmetricEncrypt(email.MessageSource, symmetricKey); + email.Subject = Encryption.SymmetricEncrypt(email.Subject, symmetricKey); + email.From = Encryption.SymmetricEncrypt(email.From, symmetricKey); + email.FromLocal = Encryption.SymmetricEncrypt(email.FromLocal, symmetricKey); + email.FromDomain = Encryption.SymmetricEncrypt(email.FromDomain, symmetricKey); + + // Encrypt the symmetric key with the user's public key. + email.EncryptedSymmetricKey = Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, userEncryptionKey.PublicKey); + email.UserEncryptionKeyId = userEncryptionKey.Id; + + return email; + } + + /// + /// Decrypt the email contents with the user's private key. + /// + /// The plain text email object to decrypt. + /// The user private encryption key to use for the decryption. + /// Email object with all sensitive fields decrypted. + public static Email DecryptEmail(Email email, string userPrivateKey) + { + // Decrypt symmetric key using private key. + var symmetricKey = Encryption.DecryptSymmetricKeyWithRsa(email.EncryptedSymmetricKey, userPrivateKey); + + // Encrypt all email contents with the symmetric key. + if (email.MessageHtml is not null) + { + email.MessageHtml = Encryption.SymmetricDecrypt(email.MessageHtml, symmetricKey); + } + + if (email.MessagePlain is not null) + { + email.MessagePlain = Encryption.SymmetricDecrypt(email.MessagePlain, symmetricKey); + } + + if (email.MessagePreview is not null) + { + email.MessagePreview = Encryption.SymmetricDecrypt(email.MessagePreview, symmetricKey); + } + + email.MessageSource = Encryption.SymmetricDecrypt(email.MessageSource, symmetricKey); + email.Subject = Encryption.SymmetricDecrypt(email.Subject, symmetricKey); + email.From = Encryption.SymmetricDecrypt(email.From, symmetricKey); + email.FromLocal = Encryption.SymmetricDecrypt(email.FromLocal, symmetricKey); + email.FromDomain = Encryption.SymmetricDecrypt(email.FromDomain, symmetricKey); + + return email; + } +} diff --git a/src/Utilities/Cryptography/Encryption.cs b/src/Utilities/Cryptography/Encryption.cs index 25571d58a..7f6318e63 100644 --- a/src/Utilities/Cryptography/Encryption.cs +++ b/src/Utilities/Cryptography/Encryption.cs @@ -22,25 +22,44 @@ using Org.BouncyCastle.Security; public static class Encryption { /// - /// Derive a key used for encryption/decryption based on a user password and system salt. + /// Generates a random symmetric key for use with AES-256. /// - /// User password. - /// The salt to use for the Argon2id hash. - /// SrpArgonEncryption key as byte array. - public static byte[] DeriveKeyFromPassword(string password, string salt = "AliasVault") + /// A 256-bit (32-byte) random key as a byte array. + public static byte[] GenerateRandomSymmetricKey() { - byte[] passwordBytes = Encoding.UTF8.GetBytes(password); - byte[] saltBytes = Encoding.UTF8.GetBytes(salt); + return RandomNumberGenerator.GetBytes(32); // 256 bits + } - var argon2 = new Argon2id(passwordBytes) + /// + /// Encrypts a symmetric key using an RSA public key. + /// + /// The symmetric key to encrypt. + /// The RSA public key in JWK format. + /// The encrypted symmetric key as a base64-encoded string. + public static string EncryptSymmetricKeyWithRsa(byte[] symmetricKey, string publicKey) + { + using (var rsa = new RSACryptoServiceProvider()) { - Salt = saltBytes, - DegreeOfParallelism = 4, - MemorySize = 8192, - Iterations = 1, - }; + ImportPublicKey(rsa, publicKey); + byte[] encryptedKey = rsa.Encrypt(symmetricKey, true); + return Convert.ToBase64String(encryptedKey); + } + } - return argon2.GetBytes(32); // Generate a 256-bit key + /// + /// Decrypts an encrypted symmetric key using an RSA private key. + /// + /// The encrypted symmetric key as ciphertext. + /// The RSA private key in JWK format. + /// The encrypted symmetric key as a base64-encoded string. + public static byte[] DecryptSymmetricKeyWithRsa(string ciphertext, string privateKey) + { + using (var rsa = new RSACryptoServiceProvider()) + { + ImportPrivateKey(rsa, privateKey); + byte[] cipherBytes = Convert.FromBase64String(ciphertext); + return rsa.Decrypt(cipherBytes, true); + } } /// From fabb087874b762ad2f20809e0ba947d53d17b130 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 29 Jul 2024 16:50:58 +0200 Subject: [PATCH 04/11] Add user claims list to admin page (#117) --- .../Main/Pages/Users/View.razor | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/AliasVault.Admin/Main/Pages/Users/View.razor b/src/AliasVault.Admin/Main/Pages/Users/View.razor index a9e97e9ef..c1c20057b 100644 --- a/src/AliasVault.Admin/Main/Pages/Users/View.razor +++ b/src/AliasVault.Admin/Main/Pages/Users/View.razor @@ -65,6 +65,35 @@ else + +
+
+
+

Email claims

+ + + + + + + + + + + + @foreach (var entry in EmailClaimList) + { + + + + + + } + +
IDCreatedFilesizeDB version
@entry.Id@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")@entry.Address
+
+
+
} @@ -78,8 +107,8 @@ else private bool IsLoading { get; set; } = true; private AliasVaultUser? User { get; set; } = new(); - private List VaultList { get; set; } = new(); + private List EmailClaimList { get; set; } = new(); /// protected override async Task OnInitializedAsync() @@ -124,9 +153,14 @@ else FileSize = x.FileSize, CreatedAt = x.CreatedAt, }) - .OrderByDescending(x => x.CreatedAt) + .OrderBy(x => x.CreatedAt) .ToListAsync(); + // Load all email claims for this user. + EmailClaimList = await DbContext.UserEmailClaims.Where(x => x.UserId == User.Id) + .OrderBy(x => x.CreatedAt) + .ToListAsync(); + IsLoading = false; StateHasChanged(); } From 05a2e3942c1f3bba6a22f7b8064d5d9efeedec12 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 29 Jul 2024 17:59:38 +0200 Subject: [PATCH 05/11] Add email view modal for external API (#117) --- CONTRIBUTING.md | 1 + .../Main/Components/Email/EmailModal.razor | 81 +++++++++++++++++++ .../Main/Components/Email/RecentEmails.razor | 49 +++++++++-- .../Main/Pages/Credentials/View.razor | 2 +- src/AliasVault.Client/package-lock.json | 4 +- .../wwwroot/css/tailwind.css | 37 +++++++++ ...150925_AddEncryptionKeyTables.Designer.cs} | 5 +- ... 20240729150925_AddEncryptionKeyTables.cs} | 6 ++ .../AliasServerDbContextModelSnapshot.cs | 3 + src/Databases/AliasServerDb/UserEmailClaim.cs | 3 + 10 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 src/AliasVault.Client/Main/Components/Email/EmailModal.razor rename src/Databases/AliasServerDb/Migrations/{20240729142520_AddEncryptionKeyTables.Designer.cs => 20240729150925_AddEncryptionKeyTables.Designer.cs} (99%) rename src/Databases/AliasServerDb/Migrations/{20240729142520_AddEncryptionKeyTables.cs => 20240729150925_AddEncryptionKeyTables.cs} (96%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3e133b0e..45e149a28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,7 @@ Here is an example file with the various options explained: ``` { "ApiUrl": "http://localhost:5092", + "SmtpAllowedDomains": ["example.tld"], "UseDebugEncryptionKey": "true" } ``` diff --git a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor new file mode 100644 index 000000000..62c9497b7 --- /dev/null +++ b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor @@ -0,0 +1,81 @@ +@using AliasVault.Client.Main.Models.Spamok + +
+
+
+

@Email?.Subject

+ +
+
+

From: @Email?.FromDisplay

+

Date: @Email?.DateSystem

+
+
+
+ +
+
+
+ +
+
+
+ +@code { + /// + /// The email to show in the modal. + /// + [Parameter] + public EmailApiModel? Email { get; set; } + + /// + /// Callback when the modal is closed. + /// + [Parameter] + public EventCallback OnClose { get; set; } + + /// + /// The message body to display + /// + private string EmailBody = string.Empty; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + // Determine email body + if (Email != null) + { + // Check if there is HTML content, if not, then set default viewtype to plain + if (Email.MessageHtml is not null && !string.IsNullOrWhiteSpace(Email.MessageHtml)) + { + // No HTML is available + EmailBody = Email.MessageHtml; + } + else if (Email.MessagePlain is not null) + { + // HTML is available + EmailBody = Email.MessagePlain; + } + else + { + // No HTML is available + EmailBody = "[This email has no body.]"; + } + } + } + + /// + /// Close the modal. + /// + private Task Close() + { + return OnClose.InvokeAsync(false); + } +} diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index acba2eb0a..71c1b72a0 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -2,6 +2,11 @@ @inherits ComponentBase @inject IHttpClientFactory HttpClientFactory +@if (EmailModalVisible) +{ + +} + @if (ShowComponent) {
@@ -42,10 +47,10 @@ { - @(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))... + @(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))... - @mail.DateSystem + @mail.DateSystem } @@ -64,11 +69,13 @@ /// The email address to show recent emails for. ///
[Parameter] - public string Email { get; set; } = string.Empty; + public string EmailAddress { get; set; } = string.Empty; private List MailboxEmails { get; set; } = new(); private bool IsLoading { get; set; } = true; private bool ShowComponent { get; set; } = false; + private EmailApiModel Email { get; set; } = new(); + private bool EmailModalVisible { get; set; } /// protected override async Task OnInitializedAsync() @@ -76,7 +83,7 @@ await base.OnInitializedAsync(); // Check if email has a known SpamOK domain, if not, don't show this component. - if (Email.EndsWith("@landmail.nl")) + if (EmailAddress.EndsWith("@landmail.nl")) { ShowComponent = true; } @@ -109,17 +116,47 @@ StateHasChanged(); // Get email prefix, which is the part before the @ symbol. - string emailPrefix = Email.Split('@')[0]; + string emailPrefix = EmailAddress.Split('@')[0]; var client = HttpClientFactory.CreateClient("EmailClient"); MailboxApiModel? mailbox = await client.GetFromJsonAsync($"https://api.spamok.com/v2/EmailBox/{emailPrefix}"); if (mailbox?.Mails != null) { - MailboxEmails = mailbox.Mails; + // Show maximum of 10 recent emails. + MailboxEmails = mailbox.Mails.Take(10).ToList(); } IsLoading = false; StateHasChanged(); } + + /// + /// Open the email modal. + /// + private async Task OpenEmail(int emailId) + { + // Get email prefix, which is the part before the @ symbol. + string emailPrefix = EmailAddress.Split('@')[0]; + + // Load email from API + var client = HttpClientFactory.CreateClient("EmailClient"); + EmailApiModel? mail = await client.GetFromJsonAsync($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}"); + + if (mail != null) + { + Email = mail; + EmailModalVisible = true; + StateHasChanged(); + } + } + + /// + /// Close the email modal. + /// + private void CloseEmailModal() + { + EmailModalVisible = false; + StateHasChanged(); + } } diff --git a/src/AliasVault.Client/Main/Pages/Credentials/View.razor b/src/AliasVault.Client/Main/Pages/Credentials/View.razor index 43fb63592..63515cb8c 100644 --- a/src/AliasVault.Client/Main/Pages/Credentials/View.razor +++ b/src/AliasVault.Client/Main/Pages/Credentials/View.razor @@ -42,7 +42,7 @@ else - + @if (Alias.Notes != null && Alias.Notes.Length > 0) {
diff --git a/src/AliasVault.Client/package-lock.json b/src/AliasVault.Client/package-lock.json index 3c29e29e9..f15dcd52a 100644 --- a/src/AliasVault.Client/package-lock.json +++ b/src/AliasVault.Client/package-lock.json @@ -1,11 +1,11 @@ { - "name": "AliasVault.Client", + "name": "aliasvault.client", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "AliasVault.Client", + "name": "aliasvault.client", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/src/AliasVault.Client/wwwroot/css/tailwind.css b/src/AliasVault.Client/wwwroot/css/tailwind.css index 661759319..2e58d1d8b 100644 --- a/src/AliasVault.Client/wwwroot/css/tailwind.css +++ b/src/AliasVault.Client/wwwroot/css/tailwind.css @@ -795,6 +795,10 @@ video { height: 2.25rem; } +.h-\[700px\] { + height: 700px; +} + .h-full { height: 100%; } @@ -1017,6 +1021,10 @@ video { align-self: center; } +.overflow-auto { + overflow: auto; +} + .overflow-hidden { overflow: hidden; } @@ -1087,6 +1095,11 @@ video { border-color: rgb(34 197 94 / var(--tw-border-opacity)); } +.bg-blue-500 { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + .bg-blue-600 { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity)); @@ -1132,6 +1145,11 @@ video { background-color: rgb(22 163 74 / var(--tw-bg-opacity)); } +.bg-primary-200 { + --tw-bg-opacity: 1; + background-color: rgb(251 203 116 / var(--tw-bg-opacity)); +} + .bg-primary-600 { --tw-bg-opacity: 1; background-color: rgb(214 131 56 / var(--tw-bg-opacity)); @@ -1157,10 +1175,19 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } +.bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } +.bg-opacity-75 { + --tw-bg-opacity: 0.75; +} + .fill-primary-600 { fill: #d68338; } @@ -1483,6 +1510,11 @@ video { transition-duration: 300ms; } +.hover\:bg-blue-600:hover { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + .hover\:bg-blue-700:hover { --tw-bg-opacity: 1; background-color: rgb(29 78 216 / var(--tw-bg-opacity)); @@ -1533,6 +1565,11 @@ video { background-color: rgb(153 27 27 / var(--tw-bg-opacity)); } +.hover\:text-gray-500:hover { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity)); +} + .hover\:text-gray-900:hover { --tw-text-opacity: 1; color: rgb(17 24 39 / var(--tw-text-opacity)); diff --git a/src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.Designer.cs similarity index 99% rename from src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.Designer.cs rename to src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.Designer.cs index c88bc6263..b3aba7713 100644 --- a/src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.Designer.cs +++ b/src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace AliasServerDb.Migrations { [DbContext(typeof(AliasServerDbContext))] - [Migration("20240729142520_AddEncryptionKeyTables")] + [Migration("20240729150925_AddEncryptionKeyTables")] partial class AddEncryptionKeyTables { /// @@ -421,6 +421,9 @@ namespace AliasServerDb.Migrations b.HasKey("Id"); + b.HasIndex("Address") + .IsUnique(); + b.HasIndex("UserId"); b.ToTable("UserEmailClaims"); diff --git a/src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.cs b/src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.cs similarity index 96% rename from src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.cs rename to src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.cs index 371fe7ff6..eeae4f616 100644 --- a/src/Databases/AliasServerDb/Migrations/20240729142520_AddEncryptionKeyTables.cs +++ b/src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.cs @@ -80,6 +80,12 @@ namespace AliasServerDb.Migrations table: "Emails", column: "UserEncryptionKeyId"); + migrationBuilder.CreateIndex( + name: "IX_UserEmailClaims_Address", + table: "UserEmailClaims", + column: "Address", + unique: true); + migrationBuilder.CreateIndex( name: "IX_UserEmailClaims_UserId", table: "UserEmailClaims", diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 61a524f27..b6a9df86f 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -418,6 +418,9 @@ namespace AliasServerDb.Migrations b.HasKey("Id"); + b.HasIndex("Address") + .IsUnique(); + b.HasIndex("UserId"); b.ToTable("UserEmailClaims"); diff --git a/src/Databases/AliasServerDb/UserEmailClaim.cs b/src/Databases/AliasServerDb/UserEmailClaim.cs index 4392bfbc4..a9fe28613 100644 --- a/src/Databases/AliasServerDb/UserEmailClaim.cs +++ b/src/Databases/AliasServerDb/UserEmailClaim.cs @@ -4,14 +4,17 @@ // 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; +using Microsoft.EntityFrameworkCore; /// /// UserEmailClaim object. This object is used to reserve an email address for a user. /// +[Index(nameof(Address), IsUnique = true)] public class UserEmailClaim { /// From 4c672a0ebee6b8dd91d4e2cea18c1f576ef65800 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 29 Jul 2024 22:51:56 +0200 Subject: [PATCH 06/11] Added working client side decryption of emails (#117) --- .../Controllers/EmailBoxController.cs | 76 +++++++++ .../Controllers/EmailController.cs | 96 +++++++++++ .../Helpers/ConversionHelper.cs | 58 +++++++ .../Main/Components/Email/EmailModal.razor | 2 +- .../Main/Components/Email/RecentEmails.razor | 161 ++++++++++++++++-- .../Main/Layout/TopMenu.razor | 3 +- .../Main/Models/CredentialEdit.cs | 2 +- .../StringDateFormatAttribute.cs | 2 +- .../Services/CredentialService.cs | 3 +- .../Services/JsInteropService.cs | 20 ++- .../wwwroot/js/cryptoInterop.js | 55 +++--- .../Models/Spamok/AttachmentApiModel.cs | 2 +- .../Models/Spamok/Base/EmailApiModelBase.cs | 13 +- .../Models/Spamok/EmailApiModel.cs | 4 +- .../Models/Spamok/MailboxApiModel.cs | 2 +- .../Models/Spamok/MailboxEmailApiModel.cs | 4 +- src/Databases/AliasServerDb/Email.cs | 2 +- .../Handlers/DatabaseMessageStore.cs | 68 +++----- .../Scripts/sendEmailAllowed.sh | 2 +- .../SmtpServer/SmtpServerTests.cs | 6 +- .../Helpers/ConversionHelperTest.cs | 81 +++++++++ src/Utilities/Cryptography/Encryption.cs | 18 +- 22 files changed, 568 insertions(+), 112 deletions(-) create mode 100644 src/AliasVault.Api/Controllers/EmailBoxController.cs create mode 100644 src/AliasVault.Api/Controllers/EmailController.cs create mode 100644 src/AliasVault.Api/Helpers/ConversionHelper.cs rename src/{AliasVault.Client/Main => AliasVault.Shared}/Models/Spamok/AttachmentApiModel.cs (96%) rename src/{AliasVault.Client/Main => AliasVault.Shared}/Models/Spamok/Base/EmailApiModelBase.cs (79%) rename src/{AliasVault.Client/Main => AliasVault.Shared}/Models/Spamok/EmailApiModel.cs (90%) rename src/{AliasVault.Client/Main => AliasVault.Shared}/Models/Spamok/MailboxApiModel.cs (95%) rename src/{AliasVault.Client/Main => AliasVault.Shared}/Models/Spamok/MailboxEmailApiModel.cs (87%) create mode 100644 src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs diff --git a/src/AliasVault.Api/Controllers/EmailBoxController.cs b/src/AliasVault.Api/Controllers/EmailBoxController.cs new file mode 100644 index 000000000..8e6a1971a --- /dev/null +++ b/src/AliasVault.Api/Controllers/EmailBoxController.cs @@ -0,0 +1,76 @@ +//----------------------------------------------------------------------- +// +// 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.Api.Controllers; + +using AliasServerDb; +using AliasVault.Api.Helpers; +using AliasVault.Shared.Models.Spamok; +using Asp.Versioning; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +/// +/// Email controller for retrieving emails from the database. +/// +/// DbContext instance. +/// UserManager instance. +[ApiVersion("1")] +public class EmailBoxController(IDbContextFactory dbContextFactory, UserManager userManager) : AuthenticatedRequestController(userManager) +{ + /// + /// Get the newest version of the vault for the current user. + /// + /// The full email address including @ sign. + /// List of aliases in JSON format. + [HttpGet(template: "{to}", Name = "GetEmailBox")] + public async Task GetEmailBox(string to) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var user = await GetCurrentUserAsync(); + if (user is null) + { + return Unauthorized("Not authenticated."); + } + + // See if this user has a valid claim to the email address. + var emailClaim = await context.UserEmailClaims + .FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == to); + + if (emailClaim is null) + { + return Unauthorized("User does not have a claim to this email address."); + } + + // Retrieve emails from database. + List emails = context.Emails.AsNoTracking().Select(x => new MailboxEmailApiModel() + { + Id = x.Id, + Subject = x.Subject, + FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From), + FromDomain = x.FromDomain, + FromLocal = x.FromLocal, + ToDomain = x.ToDomain, + ToLocal = x.ToLocal, + Date = x.Date, + DateSystem = x.DateSystem, + SecondsAgo = (int)DateTime.UtcNow.Subtract(x.DateSystem).TotalSeconds, + MessagePreview = x.MessagePreview ?? string.Empty, + EncryptedSymmetricKey = x.EncryptedSymmetricKey, + EncryptionKey = x.EncryptionKey.PublicKey, + }).OrderByDescending(x => x.DateSystem).Take(75).ToList(); + + MailboxApiModel returnValue = new MailboxApiModel(); + returnValue.Address = to; + returnValue.Subscribed = false; + returnValue.Mails = emails; + + return Ok(returnValue); + } +} diff --git a/src/AliasVault.Api/Controllers/EmailController.cs b/src/AliasVault.Api/Controllers/EmailController.cs new file mode 100644 index 000000000..fdbe77a6e --- /dev/null +++ b/src/AliasVault.Api/Controllers/EmailController.cs @@ -0,0 +1,96 @@ +//----------------------------------------------------------------------- +// +// 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.Api.Controllers; + +using AliasServerDb; +using AliasVault.Api.Helpers; +using AliasVault.Shared.Models.Spamok; +using Asp.Versioning; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +/// +/// Email controller for retrieving emails from the database. +/// +/// DbContext instance. +/// UserManager instance. +[ApiVersion("1")] +public class EmailController(IDbContextFactory dbContextFactory, UserManager userManager) : AuthenticatedRequestController(userManager) +{ + /// + /// Get the newest version of the vault for the current user. + /// + /// The email ID to open. + /// List of aliases in JSON format. + [HttpGet(template: "{id}", Name = "GetEmail")] + public async Task GetEmail(int id) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var user = await GetCurrentUserAsync(); + if (user is null) + { + return Unauthorized("Not authenticated."); + } + + // Retrieve email from database. + var email = await context.Emails.Include(x => x.EncryptionKey).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (email is null) + { + return NotFound("Email not found."); + } + + // See if this user has a valid claim to the email address. + var emailClaim = await context.UserEmailClaims + .FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == email.To); + + if (emailClaim is null) + { + return Unauthorized("User does not have a claim to this email address."); + } + + var returnEmail = new EmailApiModel + { + Id = email.Id, + Subject = email.Subject, + FromDisplay = ConversionHelper.ConvertFromToFromDisplay(email.From), + FromDomain = email.FromDomain, + FromLocal = email.FromLocal, + ToDomain = email.ToDomain, + ToLocal = email.ToLocal, + Date = email.Date, + DateSystem = DateTime.SpecifyKind(email.DateSystem, DateTimeKind.Utc), + SecondsAgo = (int)DateTime.UtcNow.Subtract(email.DateSystem).TotalSeconds, + MessageHtml = email.MessageHtml, + MessagePlain = email.MessagePlain, + EncryptedSymmetricKey = email.EncryptedSymmetricKey, + EncryptionKey = email.EncryptionKey.PublicKey, + }; + + // Add attachment metadata (without the filebytes) + var attachments = context.EmailAttachments.Where(x => x.EmailId == email.Id).Select(x => new AttachmentApiModel() + { + Id = x.Id, + Email_Id = x.EmailId, + Filename = x.Filename, + MimeType = x.MimeType, + Filesize = x.Filesize, + }).ToList(); + + returnEmail.Attachments = attachments; + + // Enrich HTML by changing all anchor tags to open in new tab + if (returnEmail.MessageHtml != null && !string.IsNullOrEmpty(email.MessageHtml)) + { + returnEmail.MessageHtml = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(email.MessageHtml); + } + + return Ok(returnEmail); + } +} diff --git a/src/AliasVault.Api/Helpers/ConversionHelper.cs b/src/AliasVault.Api/Helpers/ConversionHelper.cs new file mode 100644 index 000000000..d8df8357d --- /dev/null +++ b/src/AliasVault.Api/Helpers/ConversionHelper.cs @@ -0,0 +1,58 @@ +//----------------------------------------------------------------------- +// +// 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.Api.Helpers; + +using System.Text.RegularExpressions; +using AliasServerDb; + +/// +/// Class which contains various helper methods for data conversion. +/// +public class ConversionHelper +{ + /// + /// Extract only displayname from full "From" string. E.g. "John Doe" [johndoe@john.com] becomes "John Doe". + /// + /// The full from string. + /// Stripped displayname. + public static string ConvertFromToFromDisplay(string from) + { + // Get the display name from the From field, which is everything before the first < and after the first > + string fromDisplay = from; + if (from.Contains("<")) + { + // Remove everything after the last < until the last > + fromDisplay = from.Substring(0, from.LastIndexOf("<", StringComparison.Ordinal)); + + // Remove any double quotes + fromDisplay = fromDisplay.Replace("\"", string.Empty); + + // Trim any whitespace + fromDisplay = fromDisplay.Trim(); + } + + return fromDisplay; + } + + /// + /// Convert all anchor tags to open in a new tab. + /// + /// HTML input. + /// HTML with all anchor tags converted to open in a new tab when clicked on. + public static string ConvertAnchorTagsToOpenInNewTab(string html) + { + // Match any ", + m => $"", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + + return html; + } +} diff --git a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor index 62c9497b7..3e9697ea5 100644 --- a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor +++ b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor @@ -1,4 +1,4 @@ -@using AliasVault.Client.Main.Models.Spamok +@using AliasVault.Shared.Models.Spamok
diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index 71c1b72a0..f55bc74e5 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -1,6 +1,10 @@ -@using AliasVault.Client.Main.Models.Spamok +@using AliasVault.Shared.Models.Spamok @inherits ComponentBase @inject IHttpClientFactory HttpClientFactory +@inject HttpClient HttpClient +@inject JsInteropService JsInteropService +@inject DbService DbService +@inject Config Config @if (EmailModalVisible) { @@ -21,6 +25,10 @@ { } + else if (!string.IsNullOrEmpty(Error)) + { + + } else if (MailboxEmails.Count == 0) {
No emails found.
@@ -76,6 +84,7 @@ private bool ShowComponent { get; set; } = false; private EmailApiModel Email { get; set; } = new(); private bool EmailModalVisible { get; set; } + private string Error { get; set; } = string.Empty; /// protected override async Task OnInitializedAsync() @@ -83,12 +92,46 @@ await base.OnInitializedAsync(); // Check if email has a known SpamOK domain, if not, don't show this component. - if (EmailAddress.EndsWith("@landmail.nl")) + if (IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress)) { ShowComponent = true; } } + /// + /// Returns true if the email address is from a known SpamOK domain. + /// + protected bool IsSpamOkDomain(string email) + { + return email.EndsWith("@spamok.nl") || + email.EndsWith("@spamok.de") || + email.EndsWith("@spamok.es") || + email.EndsWith("@spamok.fr") || + email.EndsWith("@spamok.com") || + email.EndsWith("@spamok.com.ua") || + email.EndsWith("@landmail.nl") || + email.EndsWith("@landmeel.nl") || + email.EndsWith("@asdasd.nl") || + email.EndsWith("@sdfsdf.nl") || + email.EndsWith("@solarflarecorp.com"); + } + + /// + /// Returns true if the email address is from a known AliasVault domain. + /// + protected bool IsAliasVaultDomain(string email) + { + foreach (var domain in Config.SmtpAllowedDomains) + { + if (email.EndsWith(domain)) + { + return true; + } + } + + return false; + } + /// protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -112,19 +155,66 @@ return; } + Error = string.Empty; + IsLoading = true; StateHasChanged(); // Get email prefix, which is the part before the @ symbol. string emailPrefix = EmailAddress.Split('@')[0]; - var client = HttpClientFactory.CreateClient("EmailClient"); - MailboxApiModel? mailbox = await client.GetFromJsonAsync($"https://api.spamok.com/v2/EmailBox/{emailPrefix}"); + MailboxApiModel? mailbox = new(); - if (mailbox?.Mails != null) + if (IsSpamOkDomain(EmailAddress)) { - // Show maximum of 10 recent emails. - MailboxEmails = mailbox.Mails.Take(10).ToList(); + // We construct a new HttpClient to avoid using the default one, which is used for the API and sends + // the Authorization header. We don't want to send the Authorization header to the external email API. + var client = HttpClientFactory.CreateClient("EmailClient"); + mailbox = await client.GetFromJsonAsync($"https://api.spamok.com/v2/EmailBox/{emailPrefix}"); + + if (mailbox?.Mails != null) + { + // Show maximum of 10 recent emails. + MailboxEmails = mailbox.Mails.Take(10).ToList(); + } + } + else if (IsAliasVaultDomain(EmailAddress)) + { + try + { + mailbox = await HttpClient.GetFromJsonAsync($"api/v1/EmailBox/{EmailAddress}"); + if (mailbox?.Mails != null) + { + // Show maximum of 10 recent emails. + MailboxEmails = mailbox.Mails.Take(10).ToList(); + } + + // Loop through emails and decrypt the subject locally. + var context = await DbService.GetDbContextAsync(); + var privateKeys = await context.EncryptionKeys.ToListAsync(); + foreach (var mail in MailboxEmails) + { + var privateKey = privateKeys.FirstOrDefault(x => x.PublicKey == mail.EncryptionKey); + if (privateKey is not null) + { + try + { + var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); + mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); + } + catch (Exception ex) + { + Error = ex.Message; + Console.WriteLine(ex); + } + } + } + } + catch (Exception ex) + { + Error = ex.Message; + Console.WriteLine(ex); + } } IsLoading = false; @@ -139,16 +229,59 @@ // Get email prefix, which is the part before the @ symbol. string emailPrefix = EmailAddress.Split('@')[0]; - // Load email from API - var client = HttpClientFactory.CreateClient("EmailClient"); - EmailApiModel? mail = await client.GetFromJsonAsync($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}"); - if (mail != null) + if (IsSpamOkDomain(EmailAddress)) { - Email = mail; - EmailModalVisible = true; - StateHasChanged(); + var client = HttpClientFactory.CreateClient("EmailClient"); + EmailApiModel? mail = await client.GetFromJsonAsync($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}"); + if (mail != null) + { + Email = mail; + EmailModalVisible = true; + StateHasChanged(); + } } + else if (IsAliasVaultDomain(EmailAddress)) + { + EmailApiModel? mail = await HttpClient.GetFromJsonAsync($"api/v1/Email/{emailId}"); + if (mail != null) + { + // Decrypt the email content locally. + var context = await DbService.GetDbContextAsync(); + var privateKey = await context.EncryptionKeys.FirstOrDefaultAsync(x => x.PublicKey == mail.EncryptionKey); + if (privateKey is not null) + { + try + { + var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); + mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); + if (mail.MessageHtml is not null) + { + mail.MessageHtml = await JsInteropService.SymmetricDecrypt(mail.MessageHtml, Convert.ToBase64String(decryptedSymmetricKey)); + } + + if (mail.MessagePlain is not null) + { + mail.MessagePlain = await JsInteropService.SymmetricDecrypt(mail.MessagePlain, Convert.ToBase64String(decryptedSymmetricKey)); + } + + mail.FromDisplay = await JsInteropService.SymmetricDecrypt(mail.FromDisplay, Convert.ToBase64String(decryptedSymmetricKey)); + mail.FromLocal = await JsInteropService.SymmetricDecrypt(mail.FromLocal, Convert.ToBase64String(decryptedSymmetricKey)); + mail.FromDomain = await JsInteropService.SymmetricDecrypt(mail.FromDomain, Convert.ToBase64String(decryptedSymmetricKey)); + } + catch (Exception ex) + { + Error = ex.Message; + } + } + + Email = mail; + EmailModalVisible = true; + StateHasChanged(); + } + + } + } ///
diff --git a/src/AliasVault.Client/Main/Layout/TopMenu.razor b/src/AliasVault.Client/Main/Layout/TopMenu.razor index 961c11dd1..67b8db4b1 100644 --- a/src/AliasVault.Client/Main/Layout/TopMenu.razor +++ b/src/AliasVault.Client/Main/Layout/TopMenu.razor @@ -1,5 +1,4 @@ -@inherits MainBase -@using AliasVault.Client.Main.Pages; +@inherits AliasVault.Client.Main.Pages.MainBase @implements IDisposable
diff --git a/src/AliasVault.Client/Main/Models/CredentialEdit.cs b/src/AliasVault.Client/Main/Models/CredentialEdit.cs index 35bac8257..ddf99afcb 100644 --- a/src/AliasVault.Client/Main/Models/CredentialEdit.cs +++ b/src/AliasVault.Client/Main/Models/CredentialEdit.cs @@ -11,7 +11,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using AliasClientDb; -using AliasVault.Client.Models.FormValidation; +using AliasVault.Client.Main.Models.FormValidation; /// /// Credential edit model. diff --git a/src/AliasVault.Client/Main/Models/FormValidation/StringDateFormatAttribute.cs b/src/AliasVault.Client/Main/Models/FormValidation/StringDateFormatAttribute.cs index 7ebc1b877..e8611b92a 100644 --- a/src/AliasVault.Client/Main/Models/FormValidation/StringDateFormatAttribute.cs +++ b/src/AliasVault.Client/Main/Models/FormValidation/StringDateFormatAttribute.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Models.FormValidation; +namespace AliasVault.Client.Main.Models.FormValidation; using System.ComponentModel.DataAnnotations; using System.Globalization; diff --git a/src/AliasVault.Client/Services/CredentialService.cs b/src/AliasVault.Client/Services/CredentialService.cs index c067d1367..48f57d393 100644 --- a/src/AliasVault.Client/Services/CredentialService.cs +++ b/src/AliasVault.Client/Services/CredentialService.cs @@ -14,7 +14,6 @@ using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; using AliasClientDb; -using AliasVault.Client.Models; using AliasVault.Shared.Models; using Microsoft.EntityFrameworkCore; using Identity = AliasGenerators.Identity.Models.Identity; @@ -279,7 +278,7 @@ public class CredentialService(HttpClient httpClient, DbService dbService) try { var apiReturn = - await httpClient.GetFromJsonAsync("api/v1/Favicon/Extract?url=" + url); + await httpClient.GetFromJsonAsync($"api/v1/Favicon/Extract?url={url}"); if (apiReturn != null && apiReturn.Image != null) { credentialObject.Service.Logo = apiReturn.Image; diff --git a/src/AliasVault.Client/Services/JsInteropService.cs b/src/AliasVault.Client/Services/JsInteropService.cs index f04c6fbf9..677c077cf 100644 --- a/src/AliasVault.Client/Services/JsInteropService.cs +++ b/src/AliasVault.Client/Services/JsInteropService.cs @@ -7,7 +7,7 @@ namespace AliasVault.Client.Services; -using System.ComponentModel; +using System.Security.Cryptography; using System.Text.Json; using Microsoft.JSInterop; @@ -93,9 +93,21 @@ public class JsInteropService(IJSRuntime jsRuntime) /// /// Decrypts a ciphertext with a private key. /// - /// Ciphertext to decrypt. + /// Ciphertext to decrypt. /// Private key to use for decryption. /// Decrypted string. - public async Task DecryptWithPrivateKey(string ciphertext, string privateKey) => - await jsRuntime.InvokeAsync("rsaInterop.decryptWithPrivateKey", ciphertext, privateKey); + public async Task DecryptWithPrivateKey(string base64Ciphertext, string privateKey) + { + try + { + // Invoke the JavaScript function and get the result as a byte array + byte[] result = await jsRuntime.InvokeAsync("rsaInterop.decryptWithPrivateKey", base64Ciphertext, privateKey); + return result; + } + catch (JSException ex) + { + Console.Error.WriteLine($"JavaScript decryption error: {ex.Message}"); + throw new CryptographicException("Decryption failed", ex); + } + } } diff --git a/src/AliasVault.Client/wwwroot/js/cryptoInterop.js b/src/AliasVault.Client/wwwroot/js/cryptoInterop.js index 4d5e1803e..11712a5aa 100644 --- a/src/AliasVault.Client/wwwroot/js/cryptoInterop.js +++ b/src/AliasVault.Client/wwwroot/js/cryptoInterop.js @@ -118,29 +118,42 @@ window.rsaInterop = { * Decrypts a ciphertext string using an RSA private key. * @param {string} ciphertext - The base64-encoded ciphertext to decrypt. * @param {string} privateKey - The private key in JWK format. - * @returns {Promise} A promise that resolves to the decrypted plaintext. + * @returns {Promise} A promise that resolves to the decrypted data as a Uint8Array. */ - decryptWithPrivateKey : async function(ciphertext, privateKey) { - const privateKeyObj = await window.crypto.subtle.importKey( - "jwk", - JSON.parse(privateKey), - { - name: "RSA-OAEP", - hash: "SHA-256", - }, - false, - ["decrypt"] - ); + decryptWithPrivateKey: async function(ciphertext, privateKey) { + try { + // Parse the private key + let parsedPrivateKey = JSON.parse(privateKey); - const cipherBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0)); - const plaintextBuffer = await window.crypto.subtle.decrypt( - { - name: "RSA-OAEP" - }, - privateKeyObj, - cipherBuffer - ); + // Import the private key + let privateKeyObj = await window.crypto.subtle.importKey( + "jwk", + parsedPrivateKey, + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + true, + ["decrypt"] + ); - return new TextDecoder().decode(plaintextBuffer); + // Decode the base64 ciphertext + let cipherBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0)); + + // Decrypt the ciphertext + let plaintextBuffer = await window.crypto.subtle.decrypt( + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + privateKeyObj, + cipherBuffer + ); + + // Return the decrypted data as a Uint8Array + return new Uint8Array(plaintextBuffer); + } catch (error) { + throw new Error(`Failed to decrypt: ${error.message}`); + } } }; diff --git a/src/AliasVault.Client/Main/Models/Spamok/AttachmentApiModel.cs b/src/AliasVault.Shared/Models/Spamok/AttachmentApiModel.cs similarity index 96% rename from src/AliasVault.Client/Main/Models/Spamok/AttachmentApiModel.cs rename to src/AliasVault.Shared/Models/Spamok/AttachmentApiModel.cs index 6f5951c8f..f8f3cb512 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/AttachmentApiModel.cs +++ b/src/AliasVault.Shared/Models/Spamok/AttachmentApiModel.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok; +namespace AliasVault.Shared.Models.Spamok; /// /// Represents an attachment for an email. diff --git a/src/AliasVault.Client/Main/Models/Spamok/Base/EmailApiModelBase.cs b/src/AliasVault.Shared/Models/Spamok/Base/EmailApiModelBase.cs similarity index 79% rename from src/AliasVault.Client/Main/Models/Spamok/Base/EmailApiModelBase.cs rename to src/AliasVault.Shared/Models/Spamok/Base/EmailApiModelBase.cs index 2c96fb79b..599007fe1 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/Base/EmailApiModelBase.cs +++ b/src/AliasVault.Shared/Models/Spamok/Base/EmailApiModelBase.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok.Base; +namespace AliasVault.Shared.Models.Spamok.Base; /// /// Represents a mailbox email API model base. @@ -61,4 +61,15 @@ public abstract class EmailApiModelBase /// Gets or sets the number of seconds ago the email was received. /// public double SecondsAgo { get; set; } + + /// + /// Gets or sets the encrypted symmetric key which was used to encrypt the email message. + /// This key is encrypted with the public key of the user. + /// + public string EncryptedSymmetricKey { get; set; } = string.Empty; + + /// + /// Gets or sets the public key of the user used to encrypt the symmetric key. + /// + public string EncryptionKey { get; set; } = string.Empty; } diff --git a/src/AliasVault.Client/Main/Models/Spamok/EmailApiModel.cs b/src/AliasVault.Shared/Models/Spamok/EmailApiModel.cs similarity index 90% rename from src/AliasVault.Client/Main/Models/Spamok/EmailApiModel.cs rename to src/AliasVault.Shared/Models/Spamok/EmailApiModel.cs index aae8f03cd..875126147 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/EmailApiModel.cs +++ b/src/AliasVault.Shared/Models/Spamok/EmailApiModel.cs @@ -5,9 +5,9 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok; +namespace AliasVault.Shared.Models.Spamok; -using AliasVault.Client.Main.Models.Spamok.Base; +using AliasVault.Shared.Models.Spamok.Base; /// /// Represents an email API model. diff --git a/src/AliasVault.Client/Main/Models/Spamok/MailboxApiModel.cs b/src/AliasVault.Shared/Models/Spamok/MailboxApiModel.cs similarity index 95% rename from src/AliasVault.Client/Main/Models/Spamok/MailboxApiModel.cs rename to src/AliasVault.Shared/Models/Spamok/MailboxApiModel.cs index c042f25fd..d6a272abc 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/MailboxApiModel.cs +++ b/src/AliasVault.Shared/Models/Spamok/MailboxApiModel.cs @@ -5,7 +5,7 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok; +namespace AliasVault.Shared.Models.Spamok; /// /// Represents a mailbox API model. diff --git a/src/AliasVault.Client/Main/Models/Spamok/MailboxEmailApiModel.cs b/src/AliasVault.Shared/Models/Spamok/MailboxEmailApiModel.cs similarity index 87% rename from src/AliasVault.Client/Main/Models/Spamok/MailboxEmailApiModel.cs rename to src/AliasVault.Shared/Models/Spamok/MailboxEmailApiModel.cs index 0e2741690..d76a50069 100644 --- a/src/AliasVault.Client/Main/Models/Spamok/MailboxEmailApiModel.cs +++ b/src/AliasVault.Shared/Models/Spamok/MailboxEmailApiModel.cs @@ -5,9 +5,9 @@ // //----------------------------------------------------------------------- -namespace AliasVault.Client.Main.Models.Spamok; +namespace AliasVault.Shared.Models.Spamok; -using AliasVault.Client.Main.Models.Spamok.Base; +using AliasVault.Shared.Models.Spamok.Base; /// /// Represents a mailbox email API model. diff --git a/src/Databases/AliasServerDb/Email.cs b/src/Databases/AliasServerDb/Email.cs index 4c7643525..fdb420aec 100644 --- a/src/Databases/AliasServerDb/Email.cs +++ b/src/Databases/AliasServerDb/Email.cs @@ -123,5 +123,5 @@ public class Email /// /// Gets or sets the collection of email attachments. /// - public virtual ICollection Attachments { get; set; } = []; + public virtual List Attachments { get; set; } = []; } diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 9f390f210..e0807742e 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -6,6 +6,7 @@ //----------------------------------------------------------------------- using Cryptography; +using SmtpServer.Mail; namespace AliasVault.SmtpService.Handlers; @@ -63,13 +64,15 @@ public class DatabaseMessageStore(ILogger logger, Config c stream.Position = 0; var message = await MimeMessage.LoadAsync(stream, cancellationToken); + // Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance. var allAddresses = transaction.To .Distinct() .ToList(); - // Limit list to 15 addresses max. (to prevent mailbomb spam abuse) + + // Limit list to 15 addresses maximum (to prevent mailbomb spam abuse). var toAddresses = allAddresses.Take(15).ToList(); - // For every toAddress + foreach (var toAddress in toAddresses) { // Check if toAddress domain is allowed. @@ -131,7 +134,8 @@ public class DatabaseMessageStore(ILogger logger, Config c return SmtpResponse.NoValidRecipientsGiven; } - var insertedId = await InsertEmailIntoDatabase(message, userPublicKey); + // Set the "to" for the email to the actual one we are looping through now. + var insertedId = await InsertEmailIntoDatabase(message, new MailAddress(toAddress.AsAddress()), userPublicKey); logger.LogInformation("Email for {ToAddress} successfully saved into database with ID {insertedId}.", toAddress.User + "@" + toAddress.Host, insertedId); } @@ -149,12 +153,13 @@ public class DatabaseMessageStore(ILogger logger, Config c /// Insert email into database. /// /// MimeMessage to save into database. + /// The recipient for this mail. /// The public key of the user to encrypt the mail contents with. - private async Task InsertEmailIntoDatabase(MimeMessage message, UserEncryptionKey userEncryptionKey) + private async Task InsertEmailIntoDatabase(MimeMessage message, MailAddress toAddress, UserEncryptionKey userEncryptionKey) { var dbContext = await dbContextFactory.CreateDbContextAsync(); - var newEmail = ConvertMimeMessageToEmail(message); + var newEmail = ConvertMimeMessageToEmail(message, toAddress); newEmail = EmailEncryption.EncryptEmail(newEmail, userEncryptionKey); // Insert the email into the database. @@ -168,9 +173,10 @@ public class DatabaseMessageStore(ILogger logger, Config c /// Convert MimeMessage to Email database object. /// /// MimeMessage object. + /// The recipient for this mail. /// Email object. /// - private static Email ConvertMimeMessageToEmail(MimeMessage message) + private static Email ConvertMimeMessageToEmail(MimeMessage message, MailAddress toAddress) { string from = ""; @@ -195,57 +201,23 @@ public class DatabaseMessageStore(ILogger logger, Config c } catch { - // If the above fails, try to find the x-sender in the mail - try - { - MailAddress fromAddress = new MailAddress(message.Headers.First(x => x.Field == "x-sender").Value.ToString()); - fromLocal = fromAddress.User; - fromDomain = fromAddress.Host; - } - catch - { - // If this fails as well, then simply use a blank value - fromLocal = ""; - fromDomain = ""; - } - } - - MailAddress toAddress; - string to; - - // Try to extract to address firstly from x-receiver address.. - try - { - to = message.Headers.First(x => x.Field == "x-receiver").Value.ToString(); - toAddress = new MailAddress(to); - } - catch - { - // If the above fails, try to find the "to" in the mail - try - { - to = message.To.FirstOrDefault()?.ToString() ?? ""; - toAddress = new MailAddress(to); - } - catch - { - // If this fails as well, then simply let it throw an error to the caller. - throw new EmailParseMissingToException("Could not find x-receiver or to address in email."); - } + // If this fails, then simply use a blank value + fromLocal = ""; + fromDomain = ""; } // Create email object var email = new Email(); email.From = from; - email.FromLocal = fromLocal; - email.FromDomain = fromDomain; + email.FromLocal = fromLocal.ToLower(); + email.FromDomain = fromDomain.ToLower(); - email.To = to; // Local part to lowercase, as mailboxes are always lowercase + email.To = toAddress.Address.ToLower(); email.ToLocal = toAddress.User.ToLower(); - email.ToDomain = toAddress.Host; + email.ToDomain = toAddress.Host.ToLower(); - email.Subject = message.Subject ?? ""; + email.Subject = message.Subject ?? string.Empty; email.MessageHtml = message.HtmlBody; email.MessagePlain = message.TextBody; email.MessageSource = message.ToString(); diff --git a/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh b/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh index c45f3a511..56720cd5e 100755 --- a/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh +++ b/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh @@ -1 +1 @@ -curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "yourname@example.tld" --upload-file testEmail1.txt +curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "test@example.tld" --upload-file testEmail1.txt diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index 25084bf0f..871d5f46b 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -124,7 +124,7 @@ public class SmtpServerTests var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); // Test non-encrypted field. - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld")); // Decrypt the email and then check all individual fields. processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); @@ -159,7 +159,7 @@ public class SmtpServerTests var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); // Test non-encrypted field. - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld")); // Decrypt the email and then check all individual fields. processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); @@ -192,7 +192,7 @@ public class SmtpServerTests var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); // Test non-encrypted field. - Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld")); // Decrypt the email and then check all individual fields. processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey); diff --git a/src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs b/src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs new file mode 100644 index 000000000..fb5a4a205 --- /dev/null +++ b/src/Tests/AliasVault.UnitTests/Helpers/ConversionHelperTest.cs @@ -0,0 +1,81 @@ +//----------------------------------------------------------------------- +// +// 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.Tests.Helpers; + +using AliasVault.Api.Helpers; + +/// +/// Tests for the CsvImportExport class. +/// +public class ConversionHelperTest +{ + /// + /// Tests the conversion of an email address with a display name to just the display name. + /// + [Test] + public void TestFromConversion() + { + string from = "\"My full Name\" "; + string convertedFrom = ConversionHelper.ConvertFromToFromDisplay(from); + + // Check that conversion works as expected. + Assert.That(convertedFrom, Is.EqualTo("My full Name")); + } + + /// + /// Tests the conversion of a simple anchor tag to open in a new tab. + /// + [Test] + public void TestAnchorTabConversionSimple() + { + string anchorHtml = ""; + string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + // Check that conversion works as expected. + Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); + } + + /// + /// Tests the conversion of a complex anchor tag with multiple attributes to open in a new tab. + /// + [Test] + public void TestAnchorTabConversionComplex1() + { + string anchorHtml = "Start hier met de training >>>"; + string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + // Check that conversion works as expected. + Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); + } + + /// + /// Tests the conversion of a complex anchor tag with nested elements to open in a new tab. + /// + [Test] + public void TestAnchorTabConversionComplex2() + { + string anchorHtml = ""; + string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + // Check that conversion works as expected. + Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); + } + + /// + /// Tests the conversion of a complex anchor tag within a table cell to open in a new tab. + /// + [Test] + public void TestAnchorTabConversionComplex3() + { + string anchorHtml = "Ontvang nu jouw prijs  >"; + string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml); + + // Check that conversion works as expected. + Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\"")); + } +} diff --git a/src/Utilities/Cryptography/Encryption.cs b/src/Utilities/Cryptography/Encryption.cs index 7f6318e63..5dec4775d 100644 --- a/src/Utilities/Cryptography/Encryption.cs +++ b/src/Utilities/Cryptography/Encryption.cs @@ -38,10 +38,13 @@ public static class Encryption /// The encrypted symmetric key as a base64-encoded string. public static string EncryptSymmetricKeyWithRsa(byte[] symmetricKey, string publicKey) { - using (var rsa = new RSACryptoServiceProvider()) + using (var rsa = RSA.Create()) { ImportPublicKey(rsa, publicKey); - byte[] encryptedKey = rsa.Encrypt(symmetricKey, true); + rsa.KeySize = 2048; + var rsaParams = RSAEncryptionPadding.OaepSHA256; + + byte[] encryptedKey = rsa.Encrypt(symmetricKey, rsaParams); return Convert.ToBase64String(encryptedKey); } } @@ -54,11 +57,14 @@ public static class Encryption /// The encrypted symmetric key as a base64-encoded string. public static byte[] DecryptSymmetricKeyWithRsa(string ciphertext, string privateKey) { - using (var rsa = new RSACryptoServiceProvider()) + using (var rsa = RSA.Create()) { ImportPrivateKey(rsa, privateKey); + rsa.KeySize = 2048; + var rsaParams = RSAEncryptionPadding.OaepSHA256; + byte[] cipherBytes = Convert.FromBase64String(ciphertext); - return rsa.Decrypt(cipherBytes, true); + return rsa.Decrypt(cipherBytes, rsaParams); } } @@ -178,7 +184,7 @@ public static class Encryption /// /// The RSA provider to import the key into. /// The public key in JWK format. - private static void ImportPublicKey(RSACryptoServiceProvider rsa, string jwk) + private static void ImportPublicKey(RSA rsa, string jwk) { var jwkObj = JsonSerializer.Deserialize(jwk); var n = Base64UrlDecode(jwkObj.GetProperty("n").GetString()!); @@ -198,7 +204,7 @@ public static class Encryption /// /// The RSA provider to import the key into. /// The private key in JWK format. - private static void ImportPrivateKey(RSACryptoServiceProvider rsa, string jwk) + private static void ImportPrivateKey(RSA rsa, string jwk) { var jwkObj = JsonSerializer.Deserialize(jwk); var n = Base64UrlDecode(jwkObj.GetProperty("n").GetString()!); From ad7e9ea5bac92a2ba8497b06328062247e2c46f5 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 29 Jul 2024 23:02:04 +0200 Subject: [PATCH 07/11] Fix E2E tests for client project (#117) --- src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs index b848e22c6..1277705b4 100644 --- a/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs +++ b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs @@ -96,6 +96,7 @@ public class ClientPlaywrightTest : PlaywrightTest var response = new { ApiUrl = ApiBaseUrl.TrimEnd('/'), + SmtpAllowedDomains = new string[] { "example.tld" }, }; await route.FulfillAsync( new RouteFulfillOptions @@ -111,6 +112,7 @@ public class ClientPlaywrightTest : PlaywrightTest var response = new { ApiUrl = ApiBaseUrl.TrimEnd('/'), + SmtpAllowedDomains = new string[] { "example.tld" }, }; await route.FulfillAsync( new RouteFulfillOptions From 31429fb5f5fe0ca6bff890ed18ca5bd1689cba83 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 29 Jul 2024 23:32:25 +0200 Subject: [PATCH 08/11] Code style refactor (#117) --- .../Controllers/EmailBoxController.cs | 4 +- .../Controllers/EmailController.cs | 4 +- .../Helpers/ConversionHelper.cs | 26 +- .../Main/Components/Email/EmailModal.razor | 6 +- .../Main/Components/Email/RecentEmails.razor | 253 ++++++++++-------- .../Services/Database/DbService.cs | 2 +- .../Services/JsInteropService.cs | 2 +- .../Handlers/DatabaseMessageStore.cs | 4 +- .../Common/ClientPlaywrightTest.cs | 6 +- .../Utilities/RsaEncryptionTests.cs | 111 -------- src/Utilities/Cryptography/EmailEncryption.cs | 2 +- src/Utilities/Cryptography/Encryption.cs | 34 --- 12 files changed, 165 insertions(+), 289 deletions(-) diff --git a/src/AliasVault.Api/Controllers/EmailBoxController.cs b/src/AliasVault.Api/Controllers/EmailBoxController.cs index 8e6a1971a..d44c1dd6a 100644 --- a/src/AliasVault.Api/Controllers/EmailBoxController.cs +++ b/src/AliasVault.Api/Controllers/EmailBoxController.cs @@ -49,7 +49,7 @@ public class EmailBoxController(IDbContextFactory dbContex } // Retrieve emails from database. - List emails = context.Emails.AsNoTracking().Select(x => new MailboxEmailApiModel() + List emails = await context.Emails.AsNoTracking().Select(x => new MailboxEmailApiModel() { Id = x.Id, Subject = x.Subject, @@ -64,7 +64,7 @@ public class EmailBoxController(IDbContextFactory dbContex MessagePreview = x.MessagePreview ?? string.Empty, EncryptedSymmetricKey = x.EncryptedSymmetricKey, EncryptionKey = x.EncryptionKey.PublicKey, - }).OrderByDescending(x => x.DateSystem).Take(75).ToList(); + }).OrderByDescending(x => x.DateSystem).Take(75).ToListAsync(); MailboxApiModel returnValue = new MailboxApiModel(); returnValue.Address = to; diff --git a/src/AliasVault.Api/Controllers/EmailController.cs b/src/AliasVault.Api/Controllers/EmailController.cs index fdbe77a6e..8d2c79b7f 100644 --- a/src/AliasVault.Api/Controllers/EmailController.cs +++ b/src/AliasVault.Api/Controllers/EmailController.cs @@ -74,14 +74,14 @@ public class EmailController(IDbContextFactory dbContextFa }; // Add attachment metadata (without the filebytes) - var attachments = context.EmailAttachments.Where(x => x.EmailId == email.Id).Select(x => new AttachmentApiModel() + var attachments = await context.EmailAttachments.Where(x => x.EmailId == email.Id).Select(x => new AttachmentApiModel() { Id = x.Id, Email_Id = x.EmailId, Filename = x.Filename, MimeType = x.MimeType, Filesize = x.Filesize, - }).ToList(); + }).ToListAsync(); returnEmail.Attachments = attachments; diff --git a/src/AliasVault.Api/Helpers/ConversionHelper.cs b/src/AliasVault.Api/Helpers/ConversionHelper.cs index d8df8357d..2186521b7 100644 --- a/src/AliasVault.Api/Helpers/ConversionHelper.cs +++ b/src/AliasVault.Api/Helpers/ConversionHelper.cs @@ -8,12 +8,11 @@ namespace AliasVault.Api.Helpers; using System.Text.RegularExpressions; -using AliasServerDb; /// /// Class which contains various helper methods for data conversion. /// -public class ConversionHelper +public static class ConversionHelper { /// /// Extract only displayname from full "From" string. E.g. "John Doe" [johndoe@john.com] becomes "John Doe". @@ -24,18 +23,20 @@ public class ConversionHelper { // Get the display name from the From field, which is everything before the first < and after the first > string fromDisplay = from; - if (from.Contains("<")) + if (!from.Contains('<')) { - // Remove everything after the last < until the last > - fromDisplay = from.Substring(0, from.LastIndexOf("<", StringComparison.Ordinal)); - - // Remove any double quotes - fromDisplay = fromDisplay.Replace("\"", string.Empty); - - // Trim any whitespace - fromDisplay = fromDisplay.Trim(); + return fromDisplay; } + // Remove everything after the last < until the last > + fromDisplay = from.Substring(0, from.LastIndexOf('<')); + + // Remove any double quotes + fromDisplay = fromDisplay.Replace("\"", string.Empty); + + // Trim any whitespace + fromDisplay = fromDisplay.Trim(); + return fromDisplay; } @@ -51,7 +52,8 @@ public class ConversionHelper html, @"", m => $"", - RegexOptions.IgnoreCase | RegexOptions.Singleline); + RegexOptions.IgnoreCase | RegexOptions.Singleline, + TimeSpan.FromSeconds(1)); return html; } diff --git a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor index 3e9697ea5..22e20c17a 100644 --- a/src/AliasVault.Client/Main/Components/Email/EmailModal.razor +++ b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor @@ -3,7 +3,7 @@
-

@Email?.Subject

+

@Email.Subject

-

From: @Email?.FromDisplay

-

Date: @Email?.DateSystem

+

From: @Email.FromDisplay

+

Date: @Email.DateSystem

diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index f55bc74e5..6e71f6c29 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -98,10 +98,26 @@ } } + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (!ShowComponent) + { + return; + } + + if (firstRender) + { + await LoadRecentEmailsAsync(); + } + } + /// /// Returns true if the email address is from a known SpamOK domain. /// - protected bool IsSpamOkDomain(string email) + private bool IsSpamOkDomain(string email) { return email.EndsWith("@spamok.nl") || email.EndsWith("@spamok.de") || @@ -119,33 +135,9 @@ /// /// Returns true if the email address is from a known AliasVault domain. /// - protected bool IsAliasVaultDomain(string email) + private bool IsAliasVaultDomain(string email) { - foreach (var domain in Config.SmtpAllowedDomains) - { - if (email.EndsWith(domain)) - { - return true; - } - } - - return false; - } - - /// - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender); - - if (!ShowComponent) - { - return; - } - - if (firstRender) - { - await LoadRecentEmailsAsync(); - } + return Config.SmtpAllowedDomains.Any(x => email.EndsWith(x)); } private async Task LoadRecentEmailsAsync() @@ -156,65 +148,19 @@ } Error = string.Empty; - IsLoading = true; StateHasChanged(); // Get email prefix, which is the part before the @ symbol. string emailPrefix = EmailAddress.Split('@')[0]; - MailboxApiModel? mailbox = new(); - if (IsSpamOkDomain(EmailAddress)) { - // We construct a new HttpClient to avoid using the default one, which is used for the API and sends - // the Authorization header. We don't want to send the Authorization header to the external email API. - var client = HttpClientFactory.CreateClient("EmailClient"); - mailbox = await client.GetFromJsonAsync($"https://api.spamok.com/v2/EmailBox/{emailPrefix}"); - - if (mailbox?.Mails != null) - { - // Show maximum of 10 recent emails. - MailboxEmails = mailbox.Mails.Take(10).ToList(); - } + await LoadSpamOkEmails(emailPrefix); } else if (IsAliasVaultDomain(EmailAddress)) { - try - { - mailbox = await HttpClient.GetFromJsonAsync($"api/v1/EmailBox/{EmailAddress}"); - if (mailbox?.Mails != null) - { - // Show maximum of 10 recent emails. - MailboxEmails = mailbox.Mails.Take(10).ToList(); - } - - // Loop through emails and decrypt the subject locally. - var context = await DbService.GetDbContextAsync(); - var privateKeys = await context.EncryptionKeys.ToListAsync(); - foreach (var mail in MailboxEmails) - { - var privateKey = privateKeys.FirstOrDefault(x => x.PublicKey == mail.EncryptionKey); - if (privateKey is not null) - { - try - { - var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); - mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); - } - catch (Exception ex) - { - Error = ex.Message; - Console.WriteLine(ex); - } - } - } - } - catch (Exception ex) - { - Error = ex.Message; - Console.WriteLine(ex); - } + await LoadAliasVaultEmails(); } IsLoading = false; @@ -232,56 +178,127 @@ if (IsSpamOkDomain(EmailAddress)) { - var client = HttpClientFactory.CreateClient("EmailClient"); - EmailApiModel? mail = await client.GetFromJsonAsync($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}"); - if (mail != null) - { - Email = mail; - EmailModalVisible = true; - StateHasChanged(); - } + await ShowSpamOkEmailInModal(emailPrefix, emailId); } else if (IsAliasVaultDomain(EmailAddress)) { - EmailApiModel? mail = await HttpClient.GetFromJsonAsync($"api/v1/Email/{emailId}"); - if (mail != null) + await ShowAliasVaultEmailInModal(emailId); + } + } + + /// + /// Load recent emails from SpamOK. + /// + private async Task LoadSpamOkEmails(string emailPrefix) + { + // We construct a new HttpClient to avoid using the default one, which is used for the API and sends + // the Authorization header. We don't want to send the Authorization header to the external email API. + var client = HttpClientFactory.CreateClient("EmailClient"); + var mailbox = await client.GetFromJsonAsync($"https://api.spamok.com/v2/EmailBox/{emailPrefix}"); + + if (mailbox?.Mails != null) + { + // Show maximum of 10 recent emails. + MailboxEmails = mailbox.Mails.Take(10).ToList(); + } + } + + /// + /// Load recent emails from SpamOK. + /// + private async Task ShowSpamOkEmailInModal(string emailPrefix, int emailId) + { + var client = HttpClientFactory.CreateClient("EmailClient"); + EmailApiModel? mail = await client.GetFromJsonAsync($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}"); + if (mail != null) + { + Email = mail; + EmailModalVisible = true; + StateHasChanged(); + } + } + + /// + /// Load recent emails from AliasVault. + /// + private async Task LoadAliasVaultEmails() + { + try + { + var mailbox = await HttpClient.GetFromJsonAsync($"api/v1/EmailBox/{EmailAddress}"); + if (mailbox?.Mails != null) { - // Decrypt the email content locally. - var context = await DbService.GetDbContextAsync(); - var privateKey = await context.EncryptionKeys.FirstOrDefaultAsync(x => x.PublicKey == mail.EncryptionKey); - if (privateKey is not null) - { - try - { - var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); - mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); - if (mail.MessageHtml is not null) - { - mail.MessageHtml = await JsInteropService.SymmetricDecrypt(mail.MessageHtml, Convert.ToBase64String(decryptedSymmetricKey)); - } - - if (mail.MessagePlain is not null) - { - mail.MessagePlain = await JsInteropService.SymmetricDecrypt(mail.MessagePlain, Convert.ToBase64String(decryptedSymmetricKey)); - } - - mail.FromDisplay = await JsInteropService.SymmetricDecrypt(mail.FromDisplay, Convert.ToBase64String(decryptedSymmetricKey)); - mail.FromLocal = await JsInteropService.SymmetricDecrypt(mail.FromLocal, Convert.ToBase64String(decryptedSymmetricKey)); - mail.FromDomain = await JsInteropService.SymmetricDecrypt(mail.FromDomain, Convert.ToBase64String(decryptedSymmetricKey)); - } - catch (Exception ex) - { - Error = ex.Message; - } - } - - Email = mail; - EmailModalVisible = true; - StateHasChanged(); + // Show maximum of 10 recent emails. + MailboxEmails = mailbox.Mails.Take(10).ToList(); } - } + // Loop through emails and decrypt the subject locally. + var context = await DbService.GetDbContextAsync(); + var privateKeys = await context.EncryptionKeys.ToListAsync(); + foreach (var mail in MailboxEmails) + { + var privateKey = privateKeys.First(x => x.PublicKey == mail.EncryptionKey); + try + { + var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); + mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); + } + catch (Exception ex) + { + Error = ex.Message; + Console.WriteLine(ex); + } + } + } + catch (Exception ex) + { + Error = ex.Message; + Console.WriteLine(ex); + } + } + + /// + /// Load recent emails from AliasVault. + /// + private async Task ShowAliasVaultEmailInModal(int emailId) + { + EmailApiModel? mail = await HttpClient.GetFromJsonAsync($"api/v1/Email/{emailId}"); + if (mail != null) + { + // Decrypt the email content locally. + var context = await DbService.GetDbContextAsync(); + var privateKey = await context.EncryptionKeys.FirstOrDefaultAsync(x => x.PublicKey == mail.EncryptionKey); + if (privateKey is not null) + { + try + { + var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); + mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); + if (mail.MessageHtml is not null) + { + mail.MessageHtml = await JsInteropService.SymmetricDecrypt(mail.MessageHtml, Convert.ToBase64String(decryptedSymmetricKey)); + } + + if (mail.MessagePlain is not null) + { + mail.MessagePlain = await JsInteropService.SymmetricDecrypt(mail.MessagePlain, Convert.ToBase64String(decryptedSymmetricKey)); + } + + mail.FromDisplay = await JsInteropService.SymmetricDecrypt(mail.FromDisplay, Convert.ToBase64String(decryptedSymmetricKey)); + mail.FromLocal = await JsInteropService.SymmetricDecrypt(mail.FromLocal, Convert.ToBase64String(decryptedSymmetricKey)); + mail.FromDomain = await JsInteropService.SymmetricDecrypt(mail.FromDomain, Convert.ToBase64String(decryptedSymmetricKey)); + } + catch (Exception ex) + { + Error = ex.Message; + } + } + + Email = mail; + EmailModalVisible = true; + StateHasChanged(); + } } /// diff --git a/src/AliasVault.Client/Services/Database/DbService.cs b/src/AliasVault.Client/Services/Database/DbService.cs index 30e332e84..b73447e91 100644 --- a/src/AliasVault.Client/Services/Database/DbService.cs +++ b/src/AliasVault.Client/Services/Database/DbService.cs @@ -460,7 +460,7 @@ public class DbService : IDisposable // Filter the list of email addresses to only include those that are in the allowed domains. emailAddresses = emailAddresses - .Where(email => _config.SmtpAllowedDomains.Any(domain => email.EndsWith(domain))) + .Where(email => _config.SmtpAllowedDomains.Exists(domain => email.EndsWith(domain))) .ToList(); var databaseVersion = await GetCurrentDatabaseVersionAsync(); diff --git a/src/AliasVault.Client/Services/JsInteropService.cs b/src/AliasVault.Client/Services/JsInteropService.cs index 677c077cf..a27dfbb14 100644 --- a/src/AliasVault.Client/Services/JsInteropService.cs +++ b/src/AliasVault.Client/Services/JsInteropService.cs @@ -106,7 +106,7 @@ public class JsInteropService(IJSRuntime jsRuntime) } catch (JSException ex) { - Console.Error.WriteLine($"JavaScript decryption error: {ex.Message}"); + await Console.Error.WriteLineAsync($"JavaScript decryption error: {ex.Message}"); throw new CryptographicException("Decryption failed", ex); } } diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index e0807742e..008b47ae1 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -93,7 +93,7 @@ public class DatabaseMessageStore(ILogger logger, Config c } // Check if the local part of the toAddress is a known alias (claimed by a user) - var dbContext = await dbContextFactory.CreateDbContextAsync(); + var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); var userEmailClaim = await dbContext.UserEmailClaims.FirstOrDefaultAsync(x => x.AddressLocal == toAddress.User.ToLowerInvariant() && x.AddressDomain == toAddress.Host.ToLowerInvariant()); @@ -115,7 +115,7 @@ public class DatabaseMessageStore(ILogger logger, Config c } // Retrieve user public encryption key from database - var userPublicKey = dbContext.UserEncryptionKeys.FirstOrDefault(x => + var userPublicKey = await dbContext.UserEncryptionKeys.FirstOrDefaultAsync(x => x.UserId == userEmailClaim.UserId && x.IsPrimary); if (userPublicKey is null) diff --git a/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs index 1277705b4..214017721 100644 --- a/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs +++ b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs @@ -89,6 +89,8 @@ public class ClientPlaywrightTest : PlaywrightTest await SetupPlaywrightBrowserAndContext(); // Intercept Blazor WASM app requests to override appsettings.json + string[] smtpAllowedDomains = ["example.tld"]; + await Context.RouteAsync( "**/appsettings.json", async route => @@ -96,7 +98,7 @@ public class ClientPlaywrightTest : PlaywrightTest var response = new { ApiUrl = ApiBaseUrl.TrimEnd('/'), - SmtpAllowedDomains = new string[] { "example.tld" }, + SmtpAllowedDomains = smtpAllowedDomains, }; await route.FulfillAsync( new RouteFulfillOptions @@ -112,7 +114,7 @@ public class ClientPlaywrightTest : PlaywrightTest var response = new { ApiUrl = ApiBaseUrl.TrimEnd('/'), - SmtpAllowedDomains = new string[] { "example.tld" }, + SmtpAllowedDomains = smtpAllowedDomains, }; await route.FulfillAsync( new RouteFulfillOptions diff --git a/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs index e52e7c1c6..30b2f3add 100644 --- a/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs +++ b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs @@ -7,7 +7,6 @@ namespace AliasVault.Tests.Utilities; -using System.Security.Cryptography; using System.Text.Json; using Cryptography; @@ -72,116 +71,6 @@ public class RsaEncryptionTests Assert.That(() => JsonSerializer.Deserialize>(PrivateKey), Throws.Nothing); } - /// - /// Tests that encryption with public key followed by decryption with private key returns the original plaintext. - /// - [Test] - public void EncryptWithPublicKey_DecryptWithPrivateKey_ShouldReturnOriginalPlaintext() - { - // Example public and private keys as generated by the JSInterop on the client. - string originalPlaintext = "Hello, RSA encryption!"; - - string ciphertext = Encryption.EncryptWithPublicKey(originalPlaintext, PublicKey); - string decryptedText = Encryption.DecryptWithPrivateKey(ciphertext, PrivateKey); - - Assert.That(decryptedText, Is.EqualTo(originalPlaintext)); - } - - /// - /// Tests that encrypting the same plaintext twice produces different ciphertexts. - /// - [Test] - public void EncryptWithPublicKey_ShouldProduceDifferentCiphertextForSamePlaintext() - { - string plaintext = "Same plaintext"; - - string ciphertext1 = Encryption.EncryptWithPublicKey(plaintext, PublicKey); - string ciphertext2 = Encryption.EncryptWithPublicKey(plaintext, PublicKey); - - Assert.That(ciphertext2, Is.Not.EqualTo(ciphertext1)); - } - - /// - /// Tests that decrypting an invalid ciphertext throws an exception. - /// - [Test] - public void DecryptWithPrivateKey_ShouldThrowExceptionForInvalidCiphertext() - { - string invalidCiphertext = "ThisIsNotValidCiphertext"; - - Assert.That( - () => Encryption.DecryptWithPrivateKey(invalidCiphertext, PrivateKey), - Throws.TypeOf()); - } - - /// - /// Tests encryption and decryption with a long plaintext string. - /// - [Test] - public void EncryptDecrypt_ShouldWorkWithLongPlaintext() - { - string longPlaintext = new string('A', 192); // 192 character string - - string ciphertext = Encryption.EncryptWithPublicKey(longPlaintext, PublicKey); - string decryptedText = Encryption.DecryptWithPrivateKey(ciphertext, PrivateKey); - - Assert.That(decryptedText, Is.EqualTo(longPlaintext)); - } - - /// - /// Tests encryption and decryption with special characters. - /// - [Test] - public void EncryptDecrypt_ShouldWorkWithSpecialCharacters() - { - string specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`"; - - string ciphertext = Encryption.EncryptWithPublicKey(specialChars, PublicKey); - string decryptedText = Encryption.DecryptWithPrivateKey(ciphertext, PrivateKey); - - Assert.That(decryptedText, Is.EqualTo(specialChars)); - } - - /// - /// Tests encryption and decryption with Unicode characters from different languages. - /// - [Test] - public void EncryptDecrypt_ShouldWorkWithUnicodeCharacters() - { - string unicodeText = "こんにちは世界! - Здравствуй, мир! - مرحبا بالعالم! - 你好,世界!"; - - string ciphertext = Encryption.EncryptWithPublicKey(unicodeText, PublicKey); - string decryptedText = Encryption.DecryptWithPrivateKey(ciphertext, PrivateKey); - - Assert.That(decryptedText, Is.EqualTo(unicodeText)); - } - - /// - /// Tests that encrypting with an invalid public key throws an exception. - /// - [Test] - public void EncryptWithPublicKey_ShouldThrowExceptionForInvalidPublicKey() - { - string invalidPublicKey = "ThisIsNotAValidPublicKey"; - string plaintext = "Test plaintext"; - - Assert.Throws(() => Encryption.EncryptWithPublicKey(plaintext, invalidPublicKey)); - } - - /// - /// Tests that decrypting with an invalid private key throws an exception. - /// - [Test] - public void DecryptWithPrivateKey_ShouldThrowExceptionForInvalidPrivateKey() - { - string invalidPrivateKey = "ThisIsNotAValidPrivateKey"; - string plaintext = "Test plaintext"; - - string ciphertext = Encryption.EncryptWithPublicKey(plaintext, PublicKey); - - Assert.Throws(() => Encryption.DecryptWithPrivateKey(ciphertext, invalidPrivateKey)); - } - /// /// Tests if GenerateRandomSymmetricKey method returns a key of correct length. /// diff --git a/src/Utilities/Cryptography/EmailEncryption.cs b/src/Utilities/Cryptography/EmailEncryption.cs index ce796b582..89b622748 100644 --- a/src/Utilities/Cryptography/EmailEncryption.cs +++ b/src/Utilities/Cryptography/EmailEncryption.cs @@ -12,7 +12,7 @@ using AliasServerDb; /// /// Helper class for encrypting and decrypting email contents. /// -public class EmailEncryption +public static class EmailEncryption { /// /// Encrypt the email contents with the user's public key. diff --git a/src/Utilities/Cryptography/Encryption.cs b/src/Utilities/Cryptography/Encryption.cs index 5dec4775d..5b06c6fe3 100644 --- a/src/Utilities/Cryptography/Encryption.cs +++ b/src/Utilities/Cryptography/Encryption.cs @@ -145,40 +145,6 @@ public static class Encryption return Encoding.UTF8.GetString(plaintextBytes).TrimEnd('\0'); } - /// - /// Encrypts a plaintext string using an RSA public key. - /// - /// The plaintext to encrypt. - /// The public key in XML format. - /// The encrypted data as a base64-encoded string. - public static string EncryptWithPublicKey(string plaintext, string publicKey) - { - using (var rsa = new RSACryptoServiceProvider()) - { - ImportPublicKey(rsa, publicKey); - byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext); - byte[] cipherBytes = rsa.Encrypt(plaintextBytes, true); - return Convert.ToBase64String(cipherBytes); - } - } - - /// - /// Decrypts a ciphertext string using an RSA private key. - /// - /// The base64-encoded ciphertext to decrypt. - /// The private key in XML format. - /// The decrypted plaintext. - public static string DecryptWithPrivateKey(string ciphertext, string privateKey) - { - using (var rsa = new RSACryptoServiceProvider()) - { - ImportPrivateKey(rsa, privateKey); - byte[] cipherBytes = Convert.FromBase64String(ciphertext); - byte[] plaintextBytes = rsa.Decrypt(cipherBytes, true); - return Encoding.UTF8.GetString(plaintextBytes); - } - } - /// /// Imports a public key from JWK format into an RSA provider. /// From 1609562499cd21dc17bc91f97b96e031c1f44eac Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 30 Jul 2024 18:36:08 +0200 Subject: [PATCH 09/11] Add test for full encryption/decryption flow (#117) --- .env.example | 3 +- README.md | 5 +- docker-compose.yml | 16 +- install.sh | 40 ++++- .../Main/Components/Email/RecentEmails.razor | 4 +- src/AliasVault.Client/entrypoint.sh | 2 +- .../AliasVault.SmtpService.csproj | 14 ++ .../Handlers/DatabaseMessageStore.cs | 113 ++++++------- .../AliasVault.SmtpService/Program.cs | 16 +- .../Workers/SmtpServerWorker.cs | 5 + .../AliasVault.E2ETests.csproj | 1 + .../Tests/Client/EmailDecryptionTest.cs | 153 ++++++++++++++++++ .../SmtpServer/SmtpServerTests.cs | 11 +- .../SmtpServer/TestHostBuilder.cs | 23 ++- 14 files changed, 312 insertions(+), 94 deletions(-) create mode 100644 src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTest.cs diff --git a/.env.example b/.env.example index 6f97da333..23265bf74 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +API_URL= JWT_KEY= -SMTP_ALLOWED_DOMAINS=example.tld +SMTP_ALLOWED_DOMAINS= SMTP_TLS_ENABLED=false diff --git a/README.md b/README.md index b69c2ca49..662f0de40 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ AliasVault is an open-source password and identity manager built with C# ASP.NET ### What makes AliasVault unique: - **Zero-knowledge architecture**: All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data. -- **Virtual identities**: Generate virtual identities with virtual (working) email addresses that are assigned to one or more passwords. +- **Built-in email server**: AliasVault includes its own email server that allows you to generate virtual email addresses for each identity. Emails sent to these addresses are instantly visible in the AliasVault app. +- **Virtual identities**: Generate virtual identities and assign them to a website, allowing you to use different email addresses and usernames for each website. Keeping your online identities separate and secure, making it harder for attackers to link your accounts. - **Open-source**: The source code is available on GitHub and can be self-hosted on your own server. > Note: AliasVault is currently in development and not yet ready for production use. The project is still in the early stages and many features are not yet implemented. You are welcome to contribute to the project by submitting pull requests or opening issues. @@ -41,7 +42,7 @@ $ git clone https://github.com/lanedirt/AliasVault.git ``` ### 2. Run the install script. -The script checks and creates a .env file with a JWT secret, generates an admin password, and manages Docker image building and container initiation. It ensures necessary configurations and services are ready for the application's operation. +The script checks and creates a .env file with a JWT secret, generates an admin password and manages Docker image building and container initiation. It ensures necessary configurations and services are ready for the application's operation. ```bash # Go to the project directory diff --git a/docker-compose.yml b/docker-compose.yml index fe43c0106..14926e584 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,8 @@ services: ports: - "8080:8082" volumes: - - ./database:/database - - ./logs:/logs + - ./database:/database:rw + - ./logs:/logs:rw restart: always env_file: - .env @@ -20,8 +20,8 @@ services: ports: - "80:8080" restart: always - environment: - - API_URL=http://localhost:81 + env_file: + - .env api: image: aliasvault-api @@ -31,8 +31,8 @@ services: ports: - "81:8081" volumes: - - ./database:/database - - ./logs:/logs + - ./database:/database:rw + - ./logs:/logs:rw env_file: - .env restart: always @@ -46,8 +46,8 @@ services: - "25:25" - "587:587" volumes: - - ./database:/database - - ./logs:/logs + - ./database:/database:rw + - ./logs:/logs:rw env_file: - .env restart: always diff --git a/install.sh b/install.sh index 8ac16a5f1..7a497a7b1 100755 --- a/install.sh +++ b/install.sh @@ -135,6 +135,25 @@ create_env_file() { fi } +# Function to check and populate the .env file with API_URL +populate_api_url() { + printf "${CYAN}> Checking API_URL...${NC}\n" + if ! grep -q "^API_URL=" "$ENV_FILE" || [ -z "$(grep "^API_URL=" "$ENV_FILE" | cut -d '=' -f2)" ]; then + DEFAULT_API_URL="http://localhost:81" + read -p "Enter the base URL where the API will be hosted (press Enter for default: $DEFAULT_API_URL): " USER_API_URL + API_URL=${USER_API_URL:-$DEFAULT_API_URL} + if grep -q "^API_URL=" "$ENV_FILE"; then + awk -v url="$API_URL" '/^API_URL=/ {$0="API_URL="url} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + else + echo "API_URL=${API_URL}" >> "$ENV_FILE" + fi + printf "${GREEN}> API_URL has been set to $API_URL in $ENV_FILE.${NC}\n" + else + API_URL=$(grep "^API_URL=" "$ENV_FILE" | cut -d '=' -f2) + printf "${GREEN}> API_URL already exists in $ENV_FILE with value: $API_URL${NC}\n" + fi +} + # Function to check and populate the .env file with JWT_KEY populate_jwt_key() { printf "${CYAN}> Checking JWT_KEY...${NC}\n" @@ -155,16 +174,30 @@ populate_jwt_key() { set_smtp_allowed_domains() { printf "${CYAN}> Setting SMTP_ALLOWED_DOMAINS...${NC}\n" if ! grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then - printf "Please enter the domains that should be allowed to send email, separated by commas: " + printf "Please enter the domains that should be allowed to receive email, separated by commas (press Enter to disable email support): " read -r smtp_allowed_domains + + # Set default value if user input is empty + smtp_allowed_domains=${smtp_allowed_domains:-"DISABLED.TLD"} + if grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE"; then awk -v domains="$smtp_allowed_domains" '/^SMTP_ALLOWED_DOMAINS=/ {$0="SMTP_ALLOWED_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" else echo "SMTP_ALLOWED_DOMAINS=${smtp_allowed_domains}" >> "$ENV_FILE" fi - printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set in $ENV_FILE.${NC}\n" + + if [ "$smtp_allowed_domains" = "DISABLED.TLD" ]; then + printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set to 'DISABLED.TLD' in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n" + else + printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set to '${smtp_allowed_domains}' in $ENV_FILE.${NC}\n" + fi else - printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists and has a value in $ENV_FILE.${NC}\n" + smtp_allowed_domains=$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2) + if [ "$smtp_allowed_domains" = "DISABLED.TLD" ]; then + printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n" + else + printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists in $ENV_FILE with value: ${smtp_allowed_domains}${NC}\n" + fi fi } @@ -281,6 +314,7 @@ main() { printf "${YELLOW}+++ Initializing .env file +++${NC}\n" printf "\n" create_env_file || exit $? + populate_api_url || exit $? populate_jwt_key || exit $? set_smtp_allowed_domains || exit $? set_smtp_tls_enabled || exit $? diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index 6e71f6c29..fb3ff8182 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -16,7 +16,7 @@

Email

-
@@ -137,7 +137,7 @@ ///
private bool IsAliasVaultDomain(string email) { - return Config.SmtpAllowedDomains.Any(x => email.EndsWith(x)); + return Config.SmtpAllowedDomains.Exists(x => email.EndsWith(x)); } private async Task LoadRecentEmailsAsync() diff --git a/src/AliasVault.Client/entrypoint.sh b/src/AliasVault.Client/entrypoint.sh index 8651a6c25..db89bc6a4 100755 --- a/src/AliasVault.Client/entrypoint.sh +++ b/src/AliasVault.Client/entrypoint.sh @@ -14,7 +14,7 @@ sed -i "s|http://localhost:5092|${API_URL}|g" /usr/share/nginx/html/appsettings. # in order to be able to receive emails. # Convert comma-separated list to JSON array -json_array=$(echo $domains | awk '{split($0,a,","); printf "["; for(i=1;i<=length(a);i++) {printf "\"%s\"", a[i]; if(i..\..\.. + + true + bin\Debug\net8.0\AliasVault.SmtpService.xml + + + + true + bin\Release\net8.0\AliasVault.SmtpService.xml + + @@ -19,6 +29,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 008b47ae1..9cdad7ed2 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -5,32 +5,27 @@ // //----------------------------------------------------------------------- -using Cryptography; -using SmtpServer.Mail; - namespace AliasVault.SmtpService.Handlers; using System.Buffers; using System.Net.Mail; using System.Text.RegularExpressions; using AliasServerDb; +using Cryptography; using Microsoft.EntityFrameworkCore; using MimeKit; using NUglify; using SmtpServer; +using SmtpServer.Mail; using SmtpServer.Protocol; using SmtpServer.Storage; -/// -/// Custom exception for when the email parsing fails to find the "to" address in the email. -/// -public class EmailParseMissingToException(string message) : Exception(message); - /// /// Database message store. /// /// ILogger instance. /// Config instance. +/// IDbContextFactory instance. public class DatabaseMessageStore(ILogger logger, Config config, IDbContextFactory dbContextFactory) : MessageStore { /// @@ -93,10 +88,15 @@ public class DatabaseMessageStore(ILogger logger, Config c } // Check if the local part of the toAddress is a known alias (claimed by a user) - var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - var userEmailClaim = await dbContext.UserEmailClaims.FirstOrDefaultAsync(x => - x.AddressLocal == toAddress.User.ToLowerInvariant() && - x.AddressDomain == toAddress.Host.ToLowerInvariant()); + var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None); + var toAddressLocal = toAddress.User.ToLowerInvariant(); + var toAddressDomain = toAddress.Host.ToLowerInvariant(); + var userEmailClaim = await dbContext.UserEmailClaims + .FirstOrDefaultAsync( + x => + x.AddressLocal == toAddressLocal && + x.AddressDomain == toAddressDomain, + CancellationToken.None); if (userEmailClaim is null) { @@ -115,8 +115,10 @@ public class DatabaseMessageStore(ILogger logger, Config c } // Retrieve user public encryption key from database - var userPublicKey = await dbContext.UserEncryptionKeys.FirstOrDefaultAsync(x => - x.UserId == userEmailClaim.UserId && x.IsPrimary); + var userPublicKey = await dbContext.UserEncryptionKeys.FirstOrDefaultAsync( + x => + x.UserId == userEmailClaim.UserId && x.IsPrimary, + CancellationToken.None); if (userPublicKey is null) { @@ -136,8 +138,10 @@ public class DatabaseMessageStore(ILogger logger, Config c // Set the "to" for the email to the actual one we are looping through now. var insertedId = await InsertEmailIntoDatabase(message, new MailAddress(toAddress.AsAddress()), userPublicKey); - logger.LogInformation("Email for {ToAddress} successfully saved into database with ID {insertedId}.", - toAddress.User + "@" + toAddress.Host, insertedId); + logger.LogInformation( + "Email for {ToAddress} successfully saved into database with ID {insertedId}.", + toAddress.User + "@" + toAddress.Host, + insertedId); } return SmtpResponse.Ok; @@ -149,36 +153,15 @@ public class DatabaseMessageStore(ILogger logger, Config c } } - /// - /// Insert email into database. - /// - /// MimeMessage to save into database. - /// The recipient for this mail. - /// The public key of the user to encrypt the mail contents with. - private async Task InsertEmailIntoDatabase(MimeMessage message, MailAddress toAddress, UserEncryptionKey userEncryptionKey) - { - var dbContext = await dbContextFactory.CreateDbContextAsync(); - - var newEmail = ConvertMimeMessageToEmail(message, toAddress); - newEmail = EmailEncryption.EncryptEmail(newEmail, userEncryptionKey); - - // Insert the email into the database. - await dbContext.Emails.AddAsync(newEmail); - await dbContext.SaveChangesAsync(); - - return newEmail.Id; - } - /// /// Convert MimeMessage to Email database object. /// /// MimeMessage object. /// The recipient for this mail. /// Email object. - /// private static Email ConvertMimeMessageToEmail(MimeMessage message, MailAddress toAddress) { - string from = ""; + var from = string.Empty; try { @@ -191,19 +174,19 @@ public class DatabaseMessageStore(ILogger logger, Config c string fromLocal; string fromDomain; + // Try to extract from address firstly from "from" in the mail. try { MailAddress fromAddress = new MailAddress(message.From.FirstOrDefault()?.ToString() ?? string.Empty); fromLocal = fromAddress.User; fromDomain = fromAddress.Host; - } catch { // If this fails, then simply use a blank value - fromLocal = ""; - fromDomain = ""; + fromLocal = string.Empty; + fromDomain = string.Empty; } // Create email object @@ -243,8 +226,8 @@ public class DatabaseMessageStore(ILogger logger, Config c /// Extracts a preview of the email message body to be used in the email listing preview in the UI. /// This so the client does not need to load the full email body. /// - /// - /// + /// Email to extract preview for. + /// Email preview as string. private static string ExtractMessagePreview(Email email) { var messagePreview = string.Empty; @@ -252,16 +235,16 @@ public class DatabaseMessageStore(ILogger logger, Config c try { - if (email.MessagePlain != null && !String.IsNullOrEmpty(email.MessagePlain) && email.MessagePlain.Length > 3) + if (email.MessagePlain != null && !string.IsNullOrEmpty(email.MessagePlain) && email.MessagePlain.Length > 3) { // Replace any newline characters with a space string plainToPlainText = Regex.Replace(email.MessagePlain, @"\t|\n|\r", " ", RegexOptions.NonBacktracking); // Remove all "-" or "=" characters if there are 3 or more in a row - plainToPlainText = Regex.Replace(plainToPlainText, @"-{3,}|\={3,}", "", RegexOptions.NonBacktracking); + plainToPlainText = Regex.Replace(plainToPlainText, @"-{3,}|\={3,}", string.Empty, RegexOptions.NonBacktracking); // Remove any non-printable characters - plainToPlainText = Regex.Replace(plainToPlainText, @"[^\u0020-\u007E]", "", RegexOptions.NonBacktracking); + plainToPlainText = Regex.Replace(plainToPlainText, @"[^\u0020-\u007E]", string.Empty, RegexOptions.NonBacktracking); // Replace multiple spaces with a single space plainToPlainText = Regex.Replace(plainToPlainText, @"\s+", " ", RegexOptions.NonBacktracking); @@ -278,13 +261,13 @@ public class DatabaseMessageStore(ILogger logger, Config c string htmlToPlainText = Uglify.HtmlToText(email.MessageHtml).ToString(); // Replace any newline characters with a space - htmlToPlainText = Regex.Replace(htmlToPlainText, @"\t|\n|\r", " ", RegexOptions.NonBacktracking); + htmlToPlainText = Regex.Replace(htmlToPlainText, @"\t|\n|\r", string.Empty, RegexOptions.NonBacktracking); // Remove all "-" or "=" characters if there are 3 or more in a row - htmlToPlainText = Regex.Replace(htmlToPlainText, @"-{3,}|\={3,}", "", RegexOptions.NonBacktracking); + htmlToPlainText = Regex.Replace(htmlToPlainText, @"-{3,}|\={3,}", string.Empty, RegexOptions.NonBacktracking); // Remove any non-printable characters - htmlToPlainText = Regex.Replace(htmlToPlainText, @"[^\u0020-\u007E]", "", RegexOptions.NonBacktracking); + htmlToPlainText = Regex.Replace(htmlToPlainText, @"[^\u0020-\u007E]", string.Empty, RegexOptions.NonBacktracking); // Replace multiple spaces with a single space htmlToPlainText = Regex.Replace(htmlToPlainText, @"\s+", " ", RegexOptions.NonBacktracking); @@ -307,8 +290,8 @@ public class DatabaseMessageStore(ILogger logger, Config c /// /// Create an EmailAttachment object from a MimeEntity attachment. /// - /// - /// + /// MimeEntity attachment. + /// EmailAttachment object. private static EmailAttachment CreateEmailAttachment(MimeEntity attachment) { byte[] fileBytes = GetAttachmentBytes(attachment); @@ -316,18 +299,18 @@ public class DatabaseMessageStore(ILogger logger, Config c return new EmailAttachment { Bytes = fileBytes, - Filename = attachment.ContentDisposition?.FileName ?? "", + Filename = attachment.ContentDisposition?.FileName ?? string.Empty, MimeType = attachment.ContentType.MimeType, Filesize = fileBytes.Length, - Date = DateTime.Now + Date = DateTime.Now, }; } /// /// Get the attachment bytes from a MimeEntity attachment. /// - /// - /// + /// MimeEntity attachment. + /// Attachment byte array. private static byte[] GetAttachmentBytes(MimeEntity attachment) { using (var memory = new MemoryStream()) @@ -344,4 +327,24 @@ public class DatabaseMessageStore(ILogger logger, Config c return memory.ToArray(); } } + + /// + /// Insert email into database. + /// + /// MimeMessage to save into database. + /// The recipient for this mail. + /// The public key of the user to encrypt the mail contents with. + private async Task InsertEmailIntoDatabase(MimeMessage message, MailAddress toAddress, UserEncryptionKey userEncryptionKey) + { + var dbContext = await dbContextFactory.CreateDbContextAsync(); + + var newEmail = ConvertMimeMessageToEmail(message, toAddress); + newEmail = EmailEncryption.EncryptEmail(newEmail, userEncryptionKey); + + // Insert the email into the database. + await dbContext.Emails.AddAsync(newEmail); + await dbContext.SaveChangesAsync(); + + return newEmail.Id; + } } diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index c9e63546c..8d51dfb5a 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -9,15 +9,15 @@ using System.Data.Common; using System.Reflection; using System.Security.Cryptography.X509Certificates; using AliasServerDb; +using AliasVault.Logging; using AliasVault.SmtpService; using AliasVault.SmtpService.Handlers; +using AliasVault.SmtpService.Workers; +using AliasVault.WorkerStatus.ServiceExtensions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using SmtpServer; using SmtpServer.Storage; -using AliasVault.Logging; -using AliasVault.SmtpService.Workers; -using AliasVault.WorkerStatus.ServiceExtensions; var builder = Host.CreateApplicationBuilder(args); builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); @@ -70,8 +70,7 @@ builder.Services.AddSingleton( .Port(587, false) .AllowUnsecureAuthentication() .Certificate(CreateCertificate()) - .SupportedSslProtocols(System.Security.Authentication.SslProtocols.Tls12) - ); + .SupportedSslProtocols(System.Security.Authentication.SslProtocols.Tls12)); } else { @@ -81,8 +80,7 @@ builder.Services.AddSingleton( .Port(25, false)) .Endpoint(serverBuilder => serverBuilder - .Port(587, false) - ); + .Port(587, false)); } return new SmtpServer.SmtpServer(options.Build(), provider.GetRequiredService()); @@ -117,14 +115,12 @@ builder.Services.AddSingleton( return cert; } - } -); + }); // ----------------------------------------------------------------------- // Register hosted services via Status library wrapper in order to monitor and control (start/stop) them via the database. // ----------------------------------------------------------------------- builder.Services.AddStatusHostedService(Assembly.GetExecutingAssembly().GetName().Name!); -// ----------------------------------------------------------------------- var host = builder.Build(); diff --git a/src/Services/AliasVault.SmtpService/Workers/SmtpServerWorker.cs b/src/Services/AliasVault.SmtpService/Workers/SmtpServerWorker.cs index 66bae290b..09e9c3a21 100644 --- a/src/Services/AliasVault.SmtpService/Workers/SmtpServerWorker.cs +++ b/src/Services/AliasVault.SmtpService/Workers/SmtpServerWorker.cs @@ -7,6 +7,11 @@ namespace AliasVault.SmtpService.Workers; +/// +/// A worker for the SMTP server. +/// +/// ILogger instance. +/// SmtpServer instance. public class SmtpServerWorker(ILogger logger, SmtpServer.SmtpServer smtpServer) : BackgroundService { /// diff --git a/src/Tests/AliasVault.E2ETests/AliasVault.E2ETests.csproj b/src/Tests/AliasVault.E2ETests/AliasVault.E2ETests.csproj index f0d5ce876..ea7b1b9b4 100644 --- a/src/Tests/AliasVault.E2ETests/AliasVault.E2ETests.csproj +++ b/src/Tests/AliasVault.E2ETests/AliasVault.E2ETests.csproj @@ -49,6 +49,7 @@ + diff --git a/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTest.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTest.cs new file mode 100644 index 000000000..366368d6c --- /dev/null +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTest.cs @@ -0,0 +1,153 @@ +//----------------------------------------------------------------------- +// +// 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.E2ETests.Tests.Client; + +using AliasVault.IntegrationTests.SmtpServer; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using MimeKit; + +/// +/// End-to-end tests for making sure errors and warnings in API are logged to database. +/// +[TestFixture] +[Category("ClientTests")] +[NonParallelizable] +public class EmailDecryptionTest : ClientPlaywrightTest +{ + /// + /// The test host instance. + /// + private IHost _testHost = null!; + + /// + /// The test host builder instance. + /// + private TestHostBuilder _testHostBuilder = null!; + + /// + /// Setup logic for every test. + /// + /// Task. + [SetUp] + public async Task Setup() + { + // Start the SMTP server test host so we can send emails to it and test encryption/decryption. + _testHostBuilder = new TestHostBuilder(); + _testHost = _testHostBuilder.Build(ApiDbContext.Database.GetDbConnection()); + await _testHost.StartAsync(); + } + + /// + /// Test if received email encrypted by server can be successfully decrypted by client. + /// + /// Async task. + [Test] + public async Task EmailEncryptionDecryptionTest() + { + // Create credential which should automatically create claim on server during database sync. + const string serviceName = "Test Service"; + const string email = "testclaim@example.tld"; + await CreateCredentialEntry(new Dictionary + { + { "service-name", serviceName }, + { "email", email }, + }); + + // Assert that the claim was created on the server. + var claim = await ApiDbContext.UserEmailClaims.Where(x => x.Address == email).FirstOrDefaultAsync(); + Assert.That(claim, Is.Not.Null, "Claim for email address not found in database. Check if credential creation and claim creation are working correctly."); + + // Assert that the users public key was created on the server. + var publicKey = await ApiDbContext.UserEncryptionKeys.Where(x => x.UserId == claim.UserId).FirstOrDefaultAsync(); + Assert.That(publicKey, Is.Not.Null, "Public key for user not found in database. Check if public key creation is working correctly."); + Assert.That(publicKey.PublicKey.Length, Is.GreaterThanOrEqualTo(100), "Public key exists but length does not match expected. Check if public key creation is working correctly."); + + // Email the SMTP server which will save the email in encrypted form in the database.. + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", email)); + const string textSubject = "Encrypted Email Subject"; + const string textBody = "This is a test email plain."; + message.Subject = textSubject; + message.Body = new BodyBuilder { TextBody = textBody }.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Assert that email was received by the server. + var emailReceived = await ApiDbContext.Emails.FirstOrDefaultAsync(x => x.To == email); + Assert.That(emailReceived, Is.Not.Null, "Email not received by server. Check SMTP server and email encryption/decryption logic."); + + // Assert that subject is not stored as plain text in the database. + Assert.That(emailReceived.Subject, Does.Not.Contain(textSubject), "Email subject stored as plain text in database. Check email encryption logic."); + + // Attempt to click on email refresh button to get new emails. + // Id = recent-email-refresh + await Page.Locator("id=recent-email-refresh").First.ClickAsync(); + + // Wait for 1 sec + await Task.Delay(1000); + + // Check if the email is visible on the page now. + var emailContent = await Page.TextContentAsync("body"); + Assert.That(emailContent, Does.Contain(textSubject), "Email not (correctly) decrypted and displayed on the page. Check email decryption logic."); + } + + /// + /// Test that adding a credential with email domain that is not in the known list to not get added as claim. + /// + /// Async task. + [Test] + public async Task EmailUnknownDomainNoClaimTest() + { + // Create credential which should automatically create claim on server during database sync. + const string serviceName = "Test Service"; + const string email = "testclaim@unknowndomain.tld"; + await CreateCredentialEntry(new Dictionary + { + { "service-name", serviceName }, + { "email", email }, + }); + + // Assert that the claim was created on the server. + var claim = await ApiDbContext.UserEmailClaims.FirstOrDefaultAsync(x => x.Address == email); + + Assert.That(claim, Is.Null, "Claim for unknown email address domain found in database. Check if claim creation domain check is working correctly."); + } + + /// + /// Tear down logic for every test. + /// + /// Task. + [TearDown] + public async Task TearDown() + { + await _testHost.StopAsync(); + _testHost.Dispose(); + } + + /// + /// Sends a message to the SMTP server. + /// + /// MimeMessage to send. + private static async Task SendMessageToSmtpServer(MimeMessage message) + { + using var client = new SmtpClient(); + + await client.ConnectAsync("localhost", 2525, SecureSocketOptions.None); + try + { + await client.SendAsync(message); + } + finally + { + await client.DisconnectAsync(true); + } + } +} diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index 871d5f46b..2b34f3ebd 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -20,11 +20,13 @@ public class SmtpServerTests { /// /// Example public key for RSA encryption tests. This is a public key generated by the JSInterop on the client. + /// We use this here to also test the server-side decryption implementation, even though this isn't a real-world scenario. /// public const string PublicKey = "{\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"encrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\"}"; /// /// Example private key for RSA encryption tests. This is a private key generated by the JSInterop on the client. + /// We use this here to also test the server-side decryption implementation, even though this isn't a real-world scenario. /// public const string PrivateKey = "{\"alg\":\"RSA-OAEP-256\",\"d\":\"KLByToUaseNym1oNkkrTRPQOHfREXywWWaTXhP8AwtXgEKomqv9G-c6aR-K-T6btY2P-oPj268I0rbnRhSEQdrsmUT5_cp8goYGJrx6MFwGlA32x6klXnus6GDsjkXJi7I5eJL17XV99CDOBtTagFxkNdaBpvClUcHTDvncQ5bGAIrNqS7KADoi-E19BxiW_GcSJiVT4H8kDHCkcgTjZx4rKJjTPqqJOLg_poDrvnTJbsjcXP80kQ1AAENRAvDGhSWzP0IYtP1DM_2FzM1s1b_SrUsS3KiO8drR2Kv-PSOvncpaNVnZGElGCraJ3B2Mm-dr3vFjkyWeWPceqyhtYoQ\",\"dp\":\"ttxRg6uB2YLWfkPKUkzAaBWniZDHM4silJX3IgexA5GJBd9GIhUiVEolc_MgmieQbZ10CC65wqcHVv82lgCeqxYHxHWLxxJCrOpvkFlYE8wr_WqOPQEzYKv3KsL6s6Fj7Pbv9WehWpXdlbJUm4Cy5cgUkdH6PXiwBSvfhCQGrYk\",\"dq\":\"YFqlDAVTfvTR2bMJulvWzd_at81CsEmR-lPo91h-3cLpxcLDOlrTP-d3Ass2I4r1PtBT1bKuuHeQ6fZmHH55a6m8XxPEs2BuIxlh9RiFfWbd66969UOnItuawf0rfGneKt1zl4st60T3KXd8-ECrLxdsvOYpOEuNzvIY_b3qitE\",\"e\":\"AQAB\",\"ext\":true,\"key_ops\":[\"decrypt\"],\"kty\":\"RSA\",\"n\":\"lW8fRfSvLQiK9uZgm_kFjHMY1SedAZlVvZ_8d_d5oqWezQhan8-Y10Qvx0NMe57sQB3ePnShJFNE33w83kgRNkOyxKJ2FOVKtRptd7CgwIt_l9TPjdrB0J0hFn9b1eit2vpQlOdP_Wa8WvW2eVdXYEMWuBU4-aj8vY2qzcmBc-HhJX-Me9oXhUscJxeqMP4_sNiN7D4I0enrmYicB3JQMhUIwMmNt-0srHTdSvHh_6vFZMqB9ohfh2D9Q0BzYcI8rGEy1RTYsmF1zYyoOOzeRGOcKCVNeLO9LZxfAdm1Eq5zv47uw543cxCZXIZPlXOVriMEtTRwaGzE_3RZmpGJqw\",\"p\":\"yUdbuDwmVwKhou5xXUxJfi1eOjN-5F88wtyR4LpgU2OvZ7m-er4hpXx5I2E-KTVX_iIp0Q9VDXhHH-WkN3qg20RXjRoxwgrggYbfdIYdrB-2kbMamq5cOf2XbXGEO8PoDXYoZprIB0EhrD4qVVykPUYg5El0hIKPdfs9LNoOEzs\",\"q\":\"vg93lGTurG0EY179tPr6Qe3ttKEN9zvQ97dZ9034DOWDoWLe-iMKG1-yKmkG4uwC8QqNnm1mPz7EqOuHPPGVTTib9NA4JdM27PUHSPKDUvp0cV4LhF6e-W7tMFk8WbJ2ACqkqhZHYgm-FDkZBCpnehNegTxipLluKa79G__ZHFE\",\"qi\":\"fnI3Wh5aYuxI0R18NTeFKjo1P7_Ck65Gc9O3CmeqiIe58EJaXQEcdwdSOG8aVmn03szXLHEnp7anNIH63f0ericbRYdCQVhcQpvsXzEM_sp4aYmwz45palrjlY4Jc6G6XQn3FwiqqRDvpnXdsunnQ62HHhxmslaEMYHQyLng2ss\"}"; @@ -98,11 +100,8 @@ public class SmtpServerTests [TearDown] public async Task TearDown() { - if (_testHost != null) - { - await _testHost.StopAsync(); - _testHost.Dispose(); - } + await _testHost.StopAsync(); + _testHost.Dispose(); } /// @@ -111,7 +110,7 @@ public class SmtpServerTests [Test] public async Task SingleEmailPlain() { - // Send an email to the SMTP server. + // Email the SMTP server. var message = new MimeMessage(); message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld")); diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs index 853bf414c..a1d720949 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -5,13 +5,12 @@ // // ----------------------------------------------------------------------- -using AliasVault.SmtpService.Handlers; -using AliasVault.SmtpService.Workers; - namespace AliasVault.IntegrationTests.SmtpServer; using System.Data.Common; using AliasServerDb; +using AliasVault.SmtpService.Handlers; +using AliasVault.SmtpService.Workers; using SmtpService; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -60,8 +59,20 @@ public class TestHostBuilder public IHost Build() { // Create a persistent in-memory database for the duration of the test. - _dbConnection = new SqliteConnection("DataSource=:memory:"); - _dbConnection.Open(); + var dbConnection = new SqliteConnection("DataSource=:memory:"); + dbConnection.Open(); + + return Build(dbConnection); + } + + /// + /// Builds the SmtpService test host with a provided database connection. + /// + /// + public IHost Build(DbConnection dbConnection) + { + // Create a persistent in-memory database for the duration of the test. + _dbConnection = dbConnection; var builder = Host.CreateDefaultBuilder() .ConfigureServices((context, services) => @@ -81,7 +92,7 @@ public class TestHostBuilder }); services.AddTransient(); - services.AddSingleton( + services.AddSingleton( provider => { var options = new SmtpServerOptionsBuilder() From 486dc67f9458751703f60b2703e87274ca57a91a Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 30 Jul 2024 22:01:09 +0200 Subject: [PATCH 10/11] Improve smtp server logic (#117) --- .../Handlers/DatabaseMessageStore.cs | 178 ++++++++++-------- .../Tests/Client/EmailDecryptionTest.cs | 2 +- .../Utilities/RsaEncryptionTests.cs | 17 +- 3 files changed, 108 insertions(+), 89 deletions(-) diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 9cdad7ed2..0b464fa63 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -40,108 +40,42 @@ public class DatabaseMessageStore(ILogger logger, Config c { try { - await using var stream = new MemoryStream(); - - var position = buffer.GetPosition(0); - while (buffer.TryGet(ref position, out var memory)) - { - stream.Write(memory.Span); - } - - // Max email filesize limit: 10MB. If the mail is larger in size, reject it. - // Because of base64 encoding which has approx 33% increase in binary size - // we multiply the limit by 1.4 to be safe. + // Check email size limit var maxEmailSizeInMegabytes = 10; - if (stream.Length > ((maxEmailSizeInMegabytes * 1024 * 1024) * 1.4)) + var maxEmailSizeInBytes = (long)((maxEmailSizeInMegabytes * 1024 * 1024) * 1.4); + if (buffer.Length > maxEmailSizeInBytes) { return SmtpResponse.SizeLimitExceeded; } - stream.Position = 0; - var message = await MimeMessage.LoadAsync(stream, cancellationToken); + var message = await LoadMessageFromBuffer(buffer, cancellationToken); // Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance. var allAddresses = transaction.To .Distinct() .ToList(); - // Limit list to 15 addresses maximum (to prevent mailbomb spam abuse). + // Limit list to 15 addresses maximum to prevent mailbomb/spam abuse. var toAddresses = allAddresses.Take(15).ToList(); + var toAddressesCount = toAddresses.Count; + var toAddressesFailCount = 0; foreach (var toAddress in toAddresses) { - // Check if toAddress domain is allowed. - if (toAddress is null || !config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) + // Process the email for each recipient separately. + var process = await ProcessEmailForRecipient(message, toAddress); + if (!process) { - // ToAddress domain is not allowed. - if (toAddresses.Count > 1) - { - // If more recipients, silently skip this one. - continue; - } - - // If only one recipient, return error. - logger.LogWarning( - "Rejected email: email for {ToAddress} is not allowed. Domain not in allowed domain list.", - toAddress?.User + "@" + toAddress?.Host); - return SmtpResponse.NoValidRecipientsGiven; + toAddressesFailCount++; } - // Check if the local part of the toAddress is a known alias (claimed by a user) - var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None); - var toAddressLocal = toAddress.User.ToLowerInvariant(); - var toAddressDomain = toAddress.Host.ToLowerInvariant(); - var userEmailClaim = await dbContext.UserEmailClaims - .FirstOrDefaultAsync( - x => - x.AddressLocal == toAddressLocal && - x.AddressDomain == toAddressDomain, - CancellationToken.None); - - if (userEmailClaim is null) + // If all recipients failed, return error to sender. + if (toAddressesFailCount == toAddressesCount) { - // Email address has no user claim with corresponding encryption key so we cannot process it. - if (toAddresses.Count > 1) - { - // If more recipients, silently skip this one. - continue; - } - - // If only one recipient, return error. - logger.LogWarning( - "Rejected email: email for {ToAddress} is not allowed. No user claim on this ToAddress.", - toAddress.User + "@" + toAddress.Host); + // No valid recipients given. + logger.LogWarning("No valid recipients in email, returning error to sender."); return SmtpResponse.NoValidRecipientsGiven; } - - // Retrieve user public encryption key from database - var userPublicKey = await dbContext.UserEncryptionKeys.FirstOrDefaultAsync( - x => - x.UserId == userEmailClaim.UserId && x.IsPrimary, - CancellationToken.None); - - if (userPublicKey is null) - { - // Email address has no user claim with corresponding encryption key so we cannot process it. - if (toAddresses.Count > 1) - { - // If more recipients, silently skip this one. - continue; - } - - // If only one recipient, return error. - logger.LogCritical( - "Rejected email: email for {ToAddress} cannot be processed. No primary encryption key found for this user.", - toAddress.User + "@" + toAddress.Host); - return SmtpResponse.NoValidRecipientsGiven; - } - - // Set the "to" for the email to the actual one we are looping through now. - var insertedId = await InsertEmailIntoDatabase(message, new MailAddress(toAddress.AsAddress()), userPublicKey); - logger.LogInformation( - "Email for {ToAddress} successfully saved into database with ID {insertedId}.", - toAddress.User + "@" + toAddress.Host, - insertedId); } return SmtpResponse.Ok; @@ -153,6 +87,26 @@ public class DatabaseMessageStore(ILogger logger, Config c } } + /// + /// Load the email message from the buffer. + /// + /// Buffer which contains the email contents. + /// CancellationToken instance. + /// MimeMessage. + private static async Task LoadMessageFromBuffer(ReadOnlySequence buffer, CancellationToken cancellationToken) + { + await using var stream = new MemoryStream(); + + var position = buffer.GetPosition(0); + while (buffer.TryGet(ref position, out var memory)) + { + stream.Write(memory.Span); + } + + stream.Position = 0; + return await MimeMessage.LoadAsync(stream, cancellationToken); + } + /// /// Convert MimeMessage to Email database object. /// @@ -328,6 +282,68 @@ public class DatabaseMessageStore(ILogger logger, Config c } } + /// + /// Process email for recipient separately. + /// + /// MimeMessage. + /// ToAddress. + /// True if success or silent skip, false if SmtpResponse.NoValidRecipientsGiven should be triggered. + private async Task ProcessEmailForRecipient(MimeMessage message, IMailbox toAddress) + { + // Check if toAddress domain is allowed. + if (!config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) + { + // ToAddress domain is not allowed. + logger.LogWarning( + "Rejected email: email for {ToAddress} is not allowed. Domain not in allowed domain list.", + toAddress?.User + "@" + toAddress?.Host); + return false; + } + + // Check if the local part of the toAddress is a known alias (claimed by a user) + var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None); + var toAddressLocal = toAddress.User.ToLowerInvariant(); + var toAddressDomain = toAddress.Host.ToLowerInvariant(); + var userEmailClaim = await dbContext.UserEmailClaims + .FirstOrDefaultAsync( + x => + x.AddressLocal == toAddressLocal && + x.AddressDomain == toAddressDomain, + CancellationToken.None); + + if (userEmailClaim is null) + { + // Email address has no user claim with corresponding encryption key so we cannot process it. + logger.LogWarning( + "Rejected email: email for {ToAddress} is not allowed. No user claim on this ToAddress.", + toAddress.User + "@" + toAddress.Host); + return false; + } + + // Retrieve user public encryption key from database + var userPublicKey = await dbContext.UserEncryptionKeys.FirstOrDefaultAsync( + x => + x.UserId == userEmailClaim.UserId && x.IsPrimary, + CancellationToken.None); + + if (userPublicKey is null) + { + // Email address has no user claim with corresponding encryption key so we cannot process it. + logger.LogCritical( + "Rejected email: email for {ToAddress} cannot be processed. No primary encryption key found for this user.", + toAddress.User + "@" + toAddress.Host); + return false; + } + + // Set the "to" for the email to the actual one we are looping through now. + var insertedId = await InsertEmailIntoDatabase(message, new MailAddress(toAddress.AsAddress()), userPublicKey); + logger.LogInformation( + "Email for {ToAddress} successfully saved into database with ID {insertedId}.", + toAddress.User + "@" + toAddress.Host, + insertedId); + return true; + } + /// /// Insert email into database. /// diff --git a/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTest.cs b/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTest.cs index 366368d6c..890c2eb71 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTest.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Client/EmailDecryptionTest.cs @@ -68,7 +68,7 @@ public class EmailDecryptionTest : ClientPlaywrightTest // Assert that the users public key was created on the server. var publicKey = await ApiDbContext.UserEncryptionKeys.Where(x => x.UserId == claim.UserId).FirstOrDefaultAsync(); Assert.That(publicKey, Is.Not.Null, "Public key for user not found in database. Check if public key creation is working correctly."); - Assert.That(publicKey.PublicKey.Length, Is.GreaterThanOrEqualTo(100), "Public key exists but length does not match expected. Check if public key creation is working correctly."); + Assert.That(publicKey.PublicKey, Has.Length.GreaterThanOrEqualTo(100), "Public key exists but length does not match expected. Check if public key creation is working correctly."); // Email the SMTP server which will save the email in encrypted form in the database.. var message = new MimeMessage(); diff --git a/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs index 30b2f3add..fe5bb0b0e 100644 --- a/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs +++ b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs @@ -62,13 +62,16 @@ public class RsaEncryptionTests [Test] public void GenerateRsaKeyPair_ShouldReturnValidKeyPair() { - Assert.That(PublicKey, Is.Not.Null); - Assert.That(PrivateKey, Is.Not.Null); - Assert.That(PublicKey, Is.Not.EqualTo(PrivateKey)); + Assert.Multiple(() => + { + Assert.That(PublicKey, Is.Not.Null); + Assert.That(PrivateKey, Is.Not.Null); + Assert.That(PublicKey, Is.Not.EqualTo(PrivateKey)); - // Verify that the keys are in valid JSON format - Assert.That(() => JsonSerializer.Deserialize>(PublicKey), Throws.Nothing); - Assert.That(() => JsonSerializer.Deserialize>(PrivateKey), Throws.Nothing); + // Verify that the keys are in valid JSON format + Assert.That(() => JsonSerializer.Deserialize>(PublicKey), Throws.Nothing); + Assert.That(() => JsonSerializer.Deserialize>(PrivateKey), Throws.Nothing); + }); } /// @@ -78,7 +81,7 @@ public class RsaEncryptionTests public void GenerateRandomSymmetricKey_ReturnsCorrectLength() { var key = Encryption.GenerateRandomSymmetricKey(); - Assert.That(key.Length, Is.EqualTo(32), "The generated key should be 32 bytes (256 bits) long."); + Assert.That(key, Has.Length.EqualTo(32), "The generated key should be 32 bytes (256 bits) long."); } /// From 175760cae686e67bfce25ad2eaf8831b29b934eb Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 30 Jul 2024 22:10:35 +0200 Subject: [PATCH 11/11] Update DatabaseMessageStore.cs (#117) --- .../AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 0b464fa63..cc6b95c43 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -288,10 +288,10 @@ public class DatabaseMessageStore(ILogger logger, Config c /// MimeMessage. /// ToAddress. /// True if success or silent skip, false if SmtpResponse.NoValidRecipientsGiven should be triggered. - private async Task ProcessEmailForRecipient(MimeMessage message, IMailbox toAddress) + private async Task ProcessEmailForRecipient(MimeMessage message, IMailbox? toAddress) { // Check if toAddress domain is allowed. - if (!config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) + if (toAddress is null || !config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) { // ToAddress domain is not allowed. logger.LogWarning(