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/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/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.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(); } diff --git a/src/AliasVault.Api/Controllers/EmailBoxController.cs b/src/AliasVault.Api/Controllers/EmailBoxController.cs new file mode 100644 index 000000000..d44c1dd6a --- /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 = await 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).ToListAsync(); + + 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..8d2c79b7f --- /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 = 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, + }).ToListAsync(); + + 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/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index c341a72a1..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, 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, vault.CreatedAt, vault.UpdatedAt)); + return Ok(new Shared.Models.WebApi.Vault(vault.VaultBlob, vault.Version, string.Empty, new List(), vault.CreatedAt, vault.UpdatedAt)); } /// @@ -116,6 +116,114 @@ 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); + } + + // Sync user public key if supplied. + if (!string.IsNullOrEmpty(model.EncryptionPublicKey)) + { + await UpdateUserPublicKey(context, user.Id, model.EncryptionPublicKey); + } + 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(); + } + + /// + /// 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.Api/Helpers/ConversionHelper.cs b/src/AliasVault.Api/Helpers/ConversionHelper.cs new file mode 100644 index 000000000..2186521b7 --- /dev/null +++ b/src/AliasVault.Api/Helpers/ConversionHelper.cs @@ -0,0 +1,60 @@ +//----------------------------------------------------------------------- +// +// 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; + +/// +/// Class which contains various helper methods for data conversion. +/// +public static 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('<')) + { + 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; + } + + /// + /// 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, + TimeSpan.FromSeconds(1)); + + return html; + } +} 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/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/Email/EmailModal.razor b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor new file mode 100644 index 000000000..22e20c17a --- /dev/null +++ b/src/AliasVault.Client/Main/Components/Email/EmailModal.razor @@ -0,0 +1,81 @@ +@using AliasVault.Shared.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..fb3ff8182 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -1,13 +1,22 @@ -@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) +{ + +} @if (ShowComponent) {

Email

-
@@ -16,6 +25,10 @@ { } + else if (!string.IsNullOrEmpty(Error)) + { + + } else if (MailboxEmails.Count == 0) {
No emails found.
@@ -42,10 +55,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 +77,14 @@ /// 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; } + private string Error { get; set; } = string.Empty; /// protected override async Task OnInitializedAsync() @@ -76,7 +92,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 (IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress)) { ShowComponent = true; } @@ -98,6 +114,32 @@ } } + /// + /// Returns true if the email address is from a known SpamOK domain. + /// + private 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. + /// + private bool IsAliasVaultDomain(string email) + { + return Config.SmtpAllowedDomains.Exists(x => email.EndsWith(x)); + } + private async Task LoadRecentEmailsAsync() { if (!ShowComponent) @@ -105,21 +147,166 @@ return; } + Error = string.Empty; IsLoading = true; 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) + if (IsSpamOkDomain(EmailAddress)) { - MailboxEmails = mailbox.Mails; + await LoadSpamOkEmails(emailPrefix); + } + else if (IsAliasVaultDomain(EmailAddress)) + { + await LoadAliasVaultEmails(); } 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]; + + + if (IsSpamOkDomain(EmailAddress)) + { + await ShowSpamOkEmailInModal(emailPrefix, emailId); + } + else if (IsAliasVaultDomain(EmailAddress)) + { + 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) + { + // 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(); + } + } + + /// + /// Close the email modal. + /// + private void CloseEmailModal() + { + EmailModalVisible = false; + StateHasChanged(); + } } 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..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
@@ -119,9 +118,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/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/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/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 903c614ec..176cb380b 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()) @@ -53,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/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/Database/DbService.cs b/src/AliasVault.Client/Services/Database/DbService.cs index 489c58748..b73447e91 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,9 +25,10 @@ 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; private SqliteConnection _sqlConnection; private AliasClientDbContext _dbContext; private bool _isSuccessfullyInitialized; @@ -37,13 +39,15 @@ public class DbService : IDisposable /// Initializes a new instance of the class. /// /// AuthService. - /// IJSRuntime. + /// JsInteropService. /// HttpClient. - public DbService(AuthService authService, IJSRuntime jsRuntime, HttpClient httpClient) + /// Config instance. + public DbService(AuthService authService, JsInteropService jsInteropService, HttpClient httpClient, Config config) { _authService = authService; - _jsRuntime = jsRuntime; + _jsInteropService = jsInteropService; _httpClient = httpClient; + _config = config; // Set the initial state of the database service. _state.UpdateState(DbServiceState.DatabaseStatus.Uninitialized); @@ -117,16 +121,19 @@ 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(); 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); + var success = await SaveToServerAsync(encryptionKey.PublicKey, encryptedBase64String); if (success) { Console.WriteLine("Database successfully saved to server."); @@ -408,7 +415,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. @@ -437,12 +444,27 @@ 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. + var emailAddresses = await _dbContext.Aliases + .Where(a => a.Email != null) + .Select(a => a.Email) + .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.Exists(domain => email.EndsWith(domain))) + .ToList(); + var databaseVersion = await GetCurrentDatabaseVersionAsync(); - var vaultObject = new Vault(encryptedDatabase, databaseVersion, DateTime.Now, DateTime.Now); + var vaultObject = new Vault(encryptedDatabase, databaseVersion, publicEncryptionKey, emailAddresses, DateTime.Now, DateTime.Now); try { @@ -454,4 +476,31 @@ 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); + return encryptionKey; + } } diff --git a/src/AliasVault.Client/Services/JsInteropService.cs b/src/AliasVault.Client/Services/JsInteropService.cs new file mode 100644 index 000000000..a27dfbb14 --- /dev/null +++ b/src/AliasVault.Client/Services/JsInteropService.cs @@ -0,0 +1,113 @@ +//----------------------------------------------------------------------- +// +// 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.Security.Cryptography; +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 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) + { + await Console.Error.WriteLineAsync($"JavaScript decryption error: {ex.Message}"); + throw new CryptographicException("Decryption failed", ex); + } + } +} diff --git a/src/AliasVault.Client/entrypoint.sh b/src/AliasVault.Client/entrypoint.sh old mode 100644 new mode 100755 index e4560236c..db89bc6a4 --- a/src/AliasVault.Client/entrypoint.sh +++ b/src/AliasVault.Client/entrypoint.sh @@ -1,12 +1,23 @@ #!/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. + +# Convert comma-separated list to JSON array +json_array=$(echo $SMTP_ALLOWED_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,105 @@ 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 data as a Uint8Array. + */ + decryptWithPrivateKey: async function(ciphertext, privateKey) { + try { + // Parse the private key + let parsedPrivateKey = JSON.parse(privateKey); + + // Import the private key + let privateKeyObj = await window.crypto.subtle.importKey( + "jwk", + parsedPrivateKey, + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + true, + ["decrypt"] + ); + + // 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/AliasVault.Shared/Models/WebApi/Vault.cs b/src/AliasVault.Shared/Models/WebApi/Vault.cs index 339af2cb1..d682f4c70 100644 --- a/src/AliasVault.Shared/Models/WebApi/Vault.cs +++ b/src/AliasVault.Shared/Models/WebApi/Vault.cs @@ -17,12 +17,16 @@ 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, 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; } @@ -37,6 +41,16 @@ 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. + /// + public List EmailAddressList { get; set; } + /// /// Gets or sets the date and time of creation. /// 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/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..dfb61a65a 100644 --- a/src/Databases/AliasServerDb/AliasVaultUser.cs +++ b/src/Databases/AliasServerDb/AliasVaultUser.cs @@ -41,4 +41,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..fdb420aec 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,25 @@ 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 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. /// @@ -102,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/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.Designer.cs new file mode 100644 index 000000000..b3aba7713 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.Designer.cs @@ -0,0 +1,707 @@ +// +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("20240729150925_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("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("EncryptedSymmetricKey") + .IsRequired() + .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("Address") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + 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/20240729150925_AddEncryptionKeyTables.cs b/src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.cs new file mode 100644 index 000000000..eeae4f616 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240729150925_AddEncryptionKeyTables.cs @@ -0,0 +1,133 @@ +// +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: "EncryptedSymmetricKey", + table: "Emails", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "UserEncryptionKeyId", + table: "Emails", + type: "TEXT", + maxLength: 255, + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + 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), + 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_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_Address", + table: "UserEmailClaims", + column: "Address", + unique: true); + + 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: "EncryptedSymmetricKey", + table: "Emails"); + + migrationBuilder.DropColumn( + name: "UserEncryptionKeyId", + table: "Emails"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index c9faa69f7..b6a9df86f 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -229,6 +229,10 @@ namespace AliasServerDb.Migrations b.Property("DateSystem") .HasColumnType("TEXT"); + b.Property("EncryptedSymmetricKey") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("From") .IsRequired() .HasColumnType("TEXT"); @@ -273,6 +277,10 @@ namespace AliasServerDb.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("UserEncryptionKeyId") + .HasMaxLength(255) + .HasColumnType("TEXT"); + b.Property("Visible") .HasColumnType("INTEGER"); @@ -286,6 +294,8 @@ namespace AliasServerDb.Migrations b.HasIndex("ToLocal"); + b.HasIndex("UserEncryptionKeyId"); + b.HasIndex("Visible"); b.ToTable("Emails"); @@ -374,6 +384,80 @@ 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("Address") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserEmailClaims"); + }); + + modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + 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 +625,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 +647,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 +658,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..a9fe28613 --- /dev/null +++ b/src/Databases/AliasServerDb/UserEmailClaim.cs @@ -0,0 +1,65 @@ +//----------------------------------------------------------------------- +// +// 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; +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 +{ + /// + /// 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..fe1b56c55 --- /dev/null +++ b/src/Databases/AliasServerDb/UserEncryptionKey.cs @@ -0,0 +1,60 @@ +//----------------------------------------------------------------------- +// +// 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 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. + /// + 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/AliasVault.SmtpService.csproj b/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj index a6017b757..7b6060041 100644 --- a/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj +++ b/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj @@ -9,6 +9,16 @@ ..\..\.. + + true + bin\Debug\net8.0\AliasVault.SmtpService.xml + + + + true + bin\Release\net8.0\AliasVault.SmtpService.xml + + @@ -19,10 +29,15 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + 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; } = []; } diff --git a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index e8f1b1f39..cc6b95c43 100644 --- a/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -11,23 +11,21 @@ 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 { /// @@ -39,6 +37,63 @@ public class DatabaseMessageStore(ILogger logger, Config c /// CancellationToken instance. /// SmtpResponse. public override async Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + try + { + // Check email size limit + var maxEmailSizeInMegabytes = 10; + var maxEmailSizeInBytes = (long)((maxEmailSizeInMegabytes * 1024 * 1024) * 1.4); + if (buffer.Length > maxEmailSizeInBytes) + { + return SmtpResponse.SizeLimitExceeded; + } + + 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. + var toAddresses = allAddresses.Take(15).ToList(); + + var toAddressesCount = toAddresses.Count; + var toAddressesFailCount = 0; + foreach (var toAddress in toAddresses) + { + // Process the email for each recipient separately. + var process = await ProcessEmailForRecipient(message, toAddress); + if (!process) + { + toAddressesFailCount++; + } + + // If all recipients failed, return error to sender. + if (toAddressesFailCount == toAddressesCount) + { + // No valid recipients given. + logger.LogWarning("No valid recipients in email, returning error to sender."); + return SmtpResponse.NoValidRecipientsGiven; + } + } + + return SmtpResponse.Ok; + } + catch (Exception ex) + { + logger.LogError(ex, "Error saving email into database."); + return SmtpResponse.MailboxUnavailable; + } + } + + /// + /// 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(); @@ -48,78 +103,19 @@ public class DatabaseMessageStore(ILogger logger, Config c 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. - 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) - { - // No toAddress, skip. - logger.LogWarning("Skip email, no toAddress available."); - return SmtpResponse.NoValidRecipientsGiven; - } - if (!config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) - { - // 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.", toAddress.User + "@" + toAddress.Host); - return SmtpResponse.NoValidRecipientsGiven; - } - - 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; - } - - /// - /// Insert email into database. - /// - /// MimeMessage to save into database. - private async Task InsertEmailIntoDatabase(MimeMessage message) - { - var dbContext = await dbContextFactory.CreateDbContextAsync(); - - var newEmail = ConvertMimeMessageToEmail(message); - - await dbContext.Emails.AddAsync(newEmail); - await dbContext.SaveChangesAsync(); - - return newEmail.Id; + return await MimeMessage.LoadAsync(stream, cancellationToken); } /// /// 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 = ""; + var from = string.Empty; try { @@ -132,67 +128,33 @@ 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 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 = string.Empty; + fromDomain = string.Empty; } // 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(); @@ -218,8 +180,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; @@ -227,16 +189,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); @@ -253,13 +215,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); @@ -282,8 +244,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); @@ -291,18 +253,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()) @@ -319,4 +281,86 @@ public class DatabaseMessageStore(ILogger logger, Config c return memory.ToArray(); } } + + /// + /// 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 (toAddress is null || !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. + /// + /// 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/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/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/Common/ClientPlaywrightTest.cs b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs index b848e22c6..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,6 +98,7 @@ public class ClientPlaywrightTest : PlaywrightTest var response = new { ApiUrl = ApiBaseUrl.TrimEnd('/'), + SmtpAllowedDomains = smtpAllowedDomains, }; await route.FulfillAsync( new RouteFulfillOptions @@ -111,6 +114,7 @@ public class ClientPlaywrightTest : PlaywrightTest var response = new { ApiUrl = ApiBaseUrl.TrimEnd('/'), + SmtpAllowedDomains = smtpAllowedDomains, }; await route.FulfillAsync( new RouteFulfillOptions 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..890c2eb71 --- /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, 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(); + 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 cb2469a2b..2b34f3ebd 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -8,17 +8,28 @@ 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. + /// 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\"}"; + /// /// The test host instance. /// @@ -39,6 +50,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(); } /// @@ -47,23 +100,20 @@ public class SmtpServerTests [TearDown] public async Task TearDown() { - if (_testHost != null) - { - await _testHost.StopAsync(); - _testHost.Dispose(); - } + await _testHost.StopAsync(); + _testHost.Dispose(); } /// - /// 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() { - // 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", "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 +121,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("claimed@example.tld")); + + // 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 +148,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 +156,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("claimed@example.tld")); + + // 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 +180,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 +189,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("claimed@example.tld")); + + // 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 +213,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 +227,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 +244,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.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() 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/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs new file mode 100644 index 000000000..fe5bb0b0e --- /dev/null +++ b/src/Tests/AliasVault.UnitTests/Utilities/RsaEncryptionTests.cs @@ -0,0 +1,139 @@ +//----------------------------------------------------------------------- +// +// 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.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\"}"; + + /// + /// 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. + /// + [Test] + public void GenerateRsaKeyPair_ShouldReturnValidKeyPair() + { + 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); + }); + } + + /// + /// Tests if GenerateRandomSymmetricKey method returns a key of correct length. + /// + [Test] + public void GenerateRandomSymmetricKey_ReturnsCorrectLength() + { + var key = Encryption.GenerateRandomSymmetricKey(); + Assert.That(key, Has.Length.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/EncryptionTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs similarity index 95% rename from src/Tests/AliasVault.UnitTests/Utilities/EncryptionTests.cs rename to src/Tests/AliasVault.UnitTests/Utilities/SrpArgonEncryptionTests.cs index b30db3c80..51338a800 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,15 +12,16 @@ 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). /// + /// 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,19 +29,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 = await Encryption.DeriveKeyFromPasswordAsync(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 +50,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 = "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"; 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"; @@ -66,8 +67,9 @@ public class EncryptionTests /// /// 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,15 +77,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 = await Encryption.DeriveKeyFromPasswordAsync(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 = await Encryption.DeriveKeyFromPasswordAsync("your-password2", salt); - Assert.Throws(() => Cryptography.Encryption.Decrypt(encrypted, key2)); + Assert.Throws(() => Encryption.SymmetricDecrypt(encrypted, key2)); } /// @@ -103,35 +105,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/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..89b622748 --- /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 static 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 5d1a4c256..5b06c6fe3 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,30 +17,55 @@ using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Security; /// -/// Encryption class. +/// SrpArgonEncryption class. /// 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. - /// Encryption 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 = RSA.Create()) { - Salt = saltBytes, - DegreeOfParallelism = 4, - MemorySize = 8192, - Iterations = 1, - }; + ImportPublicKey(rsa, publicKey); + rsa.KeySize = 2048; + var rsaParams = RSAEncryptionPadding.OaepSHA256; - return argon2.GetBytes(32); // Generate a 256-bit key + byte[] encryptedKey = rsa.Encrypt(symmetricKey, rsaParams); + return Convert.ToBase64String(encryptedKey); + } + } + + /// + /// 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 = RSA.Create()) + { + ImportPrivateKey(rsa, privateKey); + rsa.KeySize = 2048; + var rsaParams = RSAEncryptionPadding.OaepSHA256; + + byte[] cipherBytes = Convert.FromBase64String(ciphertext); + return rsa.Decrypt(cipherBytes, rsaParams); + } } /// @@ -46,7 +73,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 +91,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 +119,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 +144,74 @@ public static class Encryption return Encoding.UTF8.GetString(plaintextBytes).TrimEnd('\0'); } + + /// + /// 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(RSA 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(RSA 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); + } }