Added working client side decryption of emails (#117)

This commit is contained in:
Leendert de Borst
2024-07-29 22:51:56 +02:00
parent 05a2e3942c
commit 4c672a0ebe
22 changed files with 568 additions and 112 deletions

View File

@@ -0,0 +1,76 @@
//-----------------------------------------------------------------------
// <copyright file="EmailBoxController.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
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;
/// <summary>
/// Email controller for retrieving emails from the database.
/// </summary>
/// <param name="dbContextFactory">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
[ApiVersion("1")]
public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Get the newest version of the vault for the current user.
/// </summary>
/// <param name="to">The full email address including @ sign.</param>
/// <returns>List of aliases in JSON format.</returns>
[HttpGet(template: "{to}", Name = "GetEmailBox")]
public async Task<IActionResult> 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<MailboxEmailApiModel> emails = context.Emails.AsNoTracking().Select(x => new MailboxEmailApiModel()
{
Id = x.Id,
Subject = x.Subject,
FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From),
FromDomain = x.FromDomain,
FromLocal = x.FromLocal,
ToDomain = x.ToDomain,
ToLocal = x.ToLocal,
Date = x.Date,
DateSystem = x.DateSystem,
SecondsAgo = (int)DateTime.UtcNow.Subtract(x.DateSystem).TotalSeconds,
MessagePreview = x.MessagePreview ?? string.Empty,
EncryptedSymmetricKey = x.EncryptedSymmetricKey,
EncryptionKey = x.EncryptionKey.PublicKey,
}).OrderByDescending(x => x.DateSystem).Take(75).ToList();
MailboxApiModel returnValue = new MailboxApiModel();
returnValue.Address = to;
returnValue.Subscribed = false;
returnValue.Mails = emails;
return Ok(returnValue);
}
}

View File

@@ -0,0 +1,96 @@
//-----------------------------------------------------------------------
// <copyright file="EmailController.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
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;
/// <summary>
/// Email controller for retrieving emails from the database.
/// </summary>
/// <param name="dbContextFactory">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
[ApiVersion("1")]
public class EmailController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Get the newest version of the vault for the current user.
/// </summary>
/// <param name="id">The email ID to open.</param>
/// <returns>List of aliases in JSON format.</returns>
[HttpGet(template: "{id}", Name = "GetEmail")]
public async Task<IActionResult> GetEmail(int id)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var user = await GetCurrentUserAsync();
if (user is null)
{
return Unauthorized("Not authenticated.");
}
// Retrieve email from database.
var email = await context.Emails.Include(x => x.EncryptionKey).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (email is null)
{
return NotFound("Email not found.");
}
// See if this user has a valid claim to the email address.
var emailClaim = await context.UserEmailClaims
.FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == email.To);
if (emailClaim is null)
{
return Unauthorized("User does not have a claim to this email address.");
}
var returnEmail = new EmailApiModel
{
Id = email.Id,
Subject = email.Subject,
FromDisplay = ConversionHelper.ConvertFromToFromDisplay(email.From),
FromDomain = email.FromDomain,
FromLocal = email.FromLocal,
ToDomain = email.ToDomain,
ToLocal = email.ToLocal,
Date = email.Date,
DateSystem = DateTime.SpecifyKind(email.DateSystem, DateTimeKind.Utc),
SecondsAgo = (int)DateTime.UtcNow.Subtract(email.DateSystem).TotalSeconds,
MessageHtml = email.MessageHtml,
MessagePlain = email.MessagePlain,
EncryptedSymmetricKey = email.EncryptedSymmetricKey,
EncryptionKey = email.EncryptionKey.PublicKey,
};
// Add attachment metadata (without the filebytes)
var attachments = context.EmailAttachments.Where(x => x.EmailId == email.Id).Select(x => new AttachmentApiModel()
{
Id = x.Id,
Email_Id = x.EmailId,
Filename = x.Filename,
MimeType = x.MimeType,
Filesize = x.Filesize,
}).ToList();
returnEmail.Attachments = attachments;
// Enrich HTML by changing all anchor tags to open in new tab
if (returnEmail.MessageHtml != null && !string.IsNullOrEmpty(email.MessageHtml))
{
returnEmail.MessageHtml = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(email.MessageHtml);
}
return Ok(returnEmail);
}
}

View File

@@ -0,0 +1,58 @@
//-----------------------------------------------------------------------
// <copyright file="ConversionHelper.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Helpers;
using System.Text.RegularExpressions;
using AliasServerDb;
/// <summary>
/// Class which contains various helper methods for data conversion.
/// </summary>
public class ConversionHelper
{
/// <summary>
/// Extract only displayname from full "From" string. E.g. "John Doe" [johndoe@john.com] becomes "John Doe".
/// </summary>
/// <param name="from">The full from string.</param>
/// <returns>Stripped displayname.</returns>
public static string ConvertFromToFromDisplay(string from)
{
// Get the display name from the From field, which is everything before the first < and after the first >
string fromDisplay = from;
if (from.Contains("<"))
{
// Remove everything after the last < until the last >
fromDisplay = from.Substring(0, from.LastIndexOf("<", StringComparison.Ordinal));
// Remove any double quotes
fromDisplay = fromDisplay.Replace("\"", string.Empty);
// Trim any whitespace
fromDisplay = fromDisplay.Trim();
}
return fromDisplay;
}
/// <summary>
/// Convert all anchor tags to open in a new tab.
/// </summary>
/// <param name="html">HTML input.</param>
/// <returns>HTML with all anchor tags converted to open in a new tab when clicked on.</returns>
public static string ConvertAnchorTagsToOpenInNewTab(string html)
{
// Match any <a tag with href attribute, regardless of the position of href or other attributes
html = Regex.Replace(
html,
@"<a\s+(.*?)href=([""'])(.*?)\2(.*?)>",
m => $"<a {m.Groups[1].Value}href={m.Groups[2].Value}{m.Groups[3].Value}{m.Groups[2].Value} {m.Groups[4].Value} target=\"_blank\">",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
return html;
}
}

View File

@@ -1,4 +1,4 @@
@using AliasVault.Client.Main.Models.Spamok
@using AliasVault.Shared.Models.Spamok
<div class="fixed inset-0 z-50 overflow-auto bg-gray-500 bg-opacity-75 flex items-center justify-center">
<div class="relative p-8 bg-white w-full max-w-md flex-col flex rounded-lg shadow-xl">

View File

@@ -1,6 +1,10 @@
@using AliasVault.Client.Main.Models.Spamok
@using AliasVault.Shared.Models.Spamok
@inherits ComponentBase
@inject IHttpClientFactory HttpClientFactory
@inject HttpClient HttpClient
@inject JsInteropService JsInteropService
@inject DbService DbService
@inject Config Config
@if (EmailModalVisible)
{
@@ -21,6 +25,10 @@
{
<LoadingIndicator/>
}
else if (!string.IsNullOrEmpty(Error))
{
<AlertMessageError Message="@Error" />
}
else if (MailboxEmails.Count == 0)
{
<div>No emails found.</div>
@@ -76,6 +84,7 @@
private bool ShowComponent { get; set; } = false;
private EmailApiModel Email { get; set; } = new();
private bool EmailModalVisible { get; set; }
private string Error { get; set; } = string.Empty;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -83,12 +92,46 @@
await base.OnInitializedAsync();
// Check if email has a known SpamOK domain, if not, don't show this component.
if (EmailAddress.EndsWith("@landmail.nl"))
if (IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress))
{
ShowComponent = true;
}
}
/// <summary>
/// Returns true if the email address is from a known SpamOK domain.
/// </summary>
protected bool IsSpamOkDomain(string email)
{
return email.EndsWith("@spamok.nl") ||
email.EndsWith("@spamok.de") ||
email.EndsWith("@spamok.es") ||
email.EndsWith("@spamok.fr") ||
email.EndsWith("@spamok.com") ||
email.EndsWith("@spamok.com.ua") ||
email.EndsWith("@landmail.nl") ||
email.EndsWith("@landmeel.nl") ||
email.EndsWith("@asdasd.nl") ||
email.EndsWith("@sdfsdf.nl") ||
email.EndsWith("@solarflarecorp.com");
}
/// <summary>
/// Returns true if the email address is from a known AliasVault domain.
/// </summary>
protected bool IsAliasVaultDomain(string email)
{
foreach (var domain in Config.SmtpAllowedDomains)
{
if (email.EndsWith(domain))
{
return true;
}
}
return false;
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -112,19 +155,66 @@
return;
}
Error = string.Empty;
IsLoading = true;
StateHasChanged();
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = EmailAddress.Split('@')[0];
var client = HttpClientFactory.CreateClient("EmailClient");
MailboxApiModel? mailbox = await client.GetFromJsonAsync<MailboxApiModel>($"https://api.spamok.com/v2/EmailBox/{emailPrefix}");
MailboxApiModel? mailbox = new();
if (mailbox?.Mails != null)
if (IsSpamOkDomain(EmailAddress))
{
// Show maximum of 10 recent emails.
MailboxEmails = mailbox.Mails.Take(10).ToList();
// We construct a new HttpClient to avoid using the default one, which is used for the API and sends
// the Authorization header. We don't want to send the Authorization header to the external email API.
var client = HttpClientFactory.CreateClient("EmailClient");
mailbox = await client.GetFromJsonAsync<MailboxApiModel>($"https://api.spamok.com/v2/EmailBox/{emailPrefix}");
if (mailbox?.Mails != null)
{
// Show maximum of 10 recent emails.
MailboxEmails = mailbox.Mails.Take(10).ToList();
}
}
else if (IsAliasVaultDomain(EmailAddress))
{
try
{
mailbox = await HttpClient.GetFromJsonAsync<MailboxApiModel>($"api/v1/EmailBox/{EmailAddress}");
if (mailbox?.Mails != null)
{
// Show maximum of 10 recent emails.
MailboxEmails = mailbox.Mails.Take(10).ToList();
}
// Loop through emails and decrypt the subject locally.
var context = await DbService.GetDbContextAsync();
var privateKeys = await context.EncryptionKeys.ToListAsync();
foreach (var mail in MailboxEmails)
{
var privateKey = privateKeys.FirstOrDefault(x => x.PublicKey == mail.EncryptionKey);
if (privateKey is not null)
{
try
{
var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey);
mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey));
}
catch (Exception ex)
{
Error = ex.Message;
Console.WriteLine(ex);
}
}
}
}
catch (Exception ex)
{
Error = ex.Message;
Console.WriteLine(ex);
}
}
IsLoading = false;
@@ -139,16 +229,59 @@
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = EmailAddress.Split('@')[0];
// Load email from API
var client = HttpClientFactory.CreateClient("EmailClient");
EmailApiModel? mail = await client.GetFromJsonAsync<EmailApiModel>($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}");
if (mail != null)
if (IsSpamOkDomain(EmailAddress))
{
Email = mail;
EmailModalVisible = true;
StateHasChanged();
var client = HttpClientFactory.CreateClient("EmailClient");
EmailApiModel? mail = await client.GetFromJsonAsync<EmailApiModel>($"https://api.spamok.com/v2/Email/{emailPrefix}/{emailId}");
if (mail != null)
{
Email = mail;
EmailModalVisible = true;
StateHasChanged();
}
}
else if (IsAliasVaultDomain(EmailAddress))
{
EmailApiModel? mail = await HttpClient.GetFromJsonAsync<EmailApiModel>($"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();
}
}
}
/// <summary>

View File

@@ -1,5 +1,4 @@
@inherits MainBase
@using AliasVault.Client.Main.Pages;
@inherits AliasVault.Client.Main.Pages.MainBase
@implements IDisposable
<header>

View File

@@ -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;
/// <summary>
/// Credential edit model.

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Models.FormValidation;
namespace AliasVault.Client.Main.Models.FormValidation;
using System.ComponentModel.DataAnnotations;
using System.Globalization;

View File

@@ -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<FaviconExtractModel>("api/v1/Favicon/Extract?url=" + url);
await httpClient.GetFromJsonAsync<FaviconExtractModel>($"api/v1/Favicon/Extract?url={url}");
if (apiReturn != null && apiReturn.Image != null)
{
credentialObject.Service.Logo = apiReturn.Image;

View File

@@ -7,7 +7,7 @@
namespace AliasVault.Client.Services;
using System.ComponentModel;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.JSInterop;
@@ -93,9 +93,21 @@ public class JsInteropService(IJSRuntime jsRuntime)
/// <summary>
/// Decrypts a ciphertext with a private key.
/// </summary>
/// <param name="ciphertext">Ciphertext to decrypt.</param>
/// <param name="base64Ciphertext">Ciphertext to decrypt.</param>
/// <param name="privateKey">Private key to use for decryption.</param>
/// <returns>Decrypted string.</returns>
public async Task<string> DecryptWithPrivateKey(string ciphertext, string privateKey) =>
await jsRuntime.InvokeAsync<string>("rsaInterop.decryptWithPrivateKey", ciphertext, privateKey);
public async Task<byte[]> DecryptWithPrivateKey(string base64Ciphertext, string privateKey)
{
try
{
// Invoke the JavaScript function and get the result as a byte array
byte[] result = await jsRuntime.InvokeAsync<byte[]>("rsaInterop.decryptWithPrivateKey", base64Ciphertext, privateKey);
return result;
}
catch (JSException ex)
{
Console.Error.WriteLine($"JavaScript decryption error: {ex.Message}");
throw new CryptographicException("Decryption failed", ex);
}
}
}

View File

@@ -118,29 +118,42 @@ window.rsaInterop = {
* Decrypts a ciphertext string using an RSA private key.
* @param {string} ciphertext - The base64-encoded ciphertext to decrypt.
* @param {string} privateKey - The private key in JWK format.
* @returns {Promise<string>} A promise that resolves to the decrypted plaintext.
* @returns {Promise<Uint8Array>} A promise that resolves to the decrypted data as a Uint8Array.
*/
decryptWithPrivateKey : async function(ciphertext, privateKey) {
const privateKeyObj = await window.crypto.subtle.importKey(
"jwk",
JSON.parse(privateKey),
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["decrypt"]
);
decryptWithPrivateKey: async function(ciphertext, privateKey) {
try {
// Parse the private key
let parsedPrivateKey = JSON.parse(privateKey);
const cipherBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0));
const plaintextBuffer = await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP"
},
privateKeyObj,
cipherBuffer
);
// Import the private key
let privateKeyObj = await window.crypto.subtle.importKey(
"jwk",
parsedPrivateKey,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["decrypt"]
);
return new TextDecoder().decode(plaintextBuffer);
// Decode the base64 ciphertext
let cipherBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0));
// Decrypt the ciphertext
let plaintextBuffer = await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP",
hash: "SHA-256",
},
privateKeyObj,
cipherBuffer
);
// Return the decrypted data as a Uint8Array
return new Uint8Array(plaintextBuffer);
} catch (error) {
throw new Error(`Failed to decrypt: ${error.message}`);
}
}
};

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models.Spamok;
namespace AliasVault.Shared.Models.Spamok;
/// <summary>
/// Represents an attachment for an email.

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models.Spamok.Base;
namespace AliasVault.Shared.Models.Spamok.Base;
/// <summary>
/// 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.
/// </summary>
public double SecondsAgo { get; set; }
/// <summary>
/// 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.
/// </summary>
public string EncryptedSymmetricKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the public key of the user used to encrypt the symmetric key.
/// </summary>
public string EncryptionKey { get; set; } = string.Empty;
}

View File

@@ -5,9 +5,9 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models.Spamok;
namespace AliasVault.Shared.Models.Spamok;
using AliasVault.Client.Main.Models.Spamok.Base;
using AliasVault.Shared.Models.Spamok.Base;
/// <summary>
/// Represents an email API model.

View File

@@ -5,7 +5,7 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models.Spamok;
namespace AliasVault.Shared.Models.Spamok;
/// <summary>
/// Represents a mailbox API model.

View File

@@ -5,9 +5,9 @@
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Models.Spamok;
namespace AliasVault.Shared.Models.Spamok;
using AliasVault.Client.Main.Models.Spamok.Base;
using AliasVault.Shared.Models.Spamok.Base;
/// <summary>
/// Represents a mailbox email API model.

View File

@@ -123,5 +123,5 @@ public class Email
/// <summary>
/// Gets or sets the collection of email attachments.
/// </summary>
public virtual ICollection<EmailAttachment> Attachments { get; set; } = [];
public virtual List<EmailAttachment> Attachments { get; set; } = [];
}

View File

@@ -6,6 +6,7 @@
//-----------------------------------------------------------------------
using Cryptography;
using SmtpServer.Mail;
namespace AliasVault.SmtpService.Handlers;
@@ -63,13 +64,15 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
stream.Position = 0;
var message = await MimeMessage.LoadAsync(stream, cancellationToken);
// Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance.
var allAddresses = transaction.To
.Distinct()
.ToList();
// Limit list to 15 addresses max. (to prevent mailbomb spam abuse)
// Limit list to 15 addresses maximum (to prevent mailbomb spam abuse).
var toAddresses = allAddresses.Take(15).ToList();
// For every toAddress
foreach (var toAddress in toAddresses)
{
// Check if toAddress domain is allowed.
@@ -131,7 +134,8 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
return SmtpResponse.NoValidRecipientsGiven;
}
var insertedId = await InsertEmailIntoDatabase(message, userPublicKey);
// Set the "to" for the email to the actual one we are looping through now.
var insertedId = await InsertEmailIntoDatabase(message, new MailAddress(toAddress.AsAddress()), userPublicKey);
logger.LogInformation("Email for {ToAddress} successfully saved into database with ID {insertedId}.",
toAddress.User + "@" + toAddress.Host, insertedId);
}
@@ -149,12 +153,13 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
/// Insert email into database.
/// </summary>
/// <param name="message">MimeMessage to save into database.</param>
/// <param name="toAddress">The recipient for this mail.</param>
/// <param name="userEncryptionKey">The public key of the user to encrypt the mail contents with.</param>
private async Task<int> InsertEmailIntoDatabase(MimeMessage message, UserEncryptionKey userEncryptionKey)
private async Task<int> InsertEmailIntoDatabase(MimeMessage message, MailAddress toAddress, UserEncryptionKey userEncryptionKey)
{
var dbContext = await dbContextFactory.CreateDbContextAsync();
var newEmail = ConvertMimeMessageToEmail(message);
var newEmail = ConvertMimeMessageToEmail(message, toAddress);
newEmail = EmailEncryption.EncryptEmail(newEmail, userEncryptionKey);
// Insert the email into the database.
@@ -168,9 +173,10 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
/// Convert MimeMessage to Email database object.
/// </summary>
/// <param name="message">MimeMessage object.</param>
/// <param name="toAddress">The recipient for this mail.</param>
/// <returns>Email object.</returns>
/// <exception cref="EmailParseMissingToException"></exception>
private static Email ConvertMimeMessageToEmail(MimeMessage message)
private static Email ConvertMimeMessageToEmail(MimeMessage message, MailAddress toAddress)
{
string from = "";
@@ -195,57 +201,23 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
}
catch
{
// If the above fails, try to find the x-sender in the mail
try
{
MailAddress fromAddress = new MailAddress(message.Headers.First(x => x.Field == "x-sender").Value.ToString());
fromLocal = fromAddress.User;
fromDomain = fromAddress.Host;
}
catch
{
// If this fails as well, then simply use a blank value
fromLocal = "";
fromDomain = "";
}
}
MailAddress toAddress;
string to;
// Try to extract to address firstly from x-receiver address..
try
{
to = message.Headers.First(x => x.Field == "x-receiver").Value.ToString();
toAddress = new MailAddress(to);
}
catch
{
// If the above fails, try to find the "to" in the mail
try
{
to = message.To.FirstOrDefault()?.ToString() ?? "";
toAddress = new MailAddress(to);
}
catch
{
// If this fails as well, then simply let it throw an error to the caller.
throw new EmailParseMissingToException("Could not find x-receiver or to address in email.");
}
// If this fails, then simply use a blank value
fromLocal = "";
fromDomain = "";
}
// Create email object
var email = new Email();
email.From = from;
email.FromLocal = fromLocal;
email.FromDomain = fromDomain;
email.FromLocal = fromLocal.ToLower();
email.FromDomain = fromDomain.ToLower();
email.To = to;
// Local part to lowercase, as mailboxes are always lowercase
email.To = toAddress.Address.ToLower();
email.ToLocal = toAddress.User.ToLower();
email.ToDomain = toAddress.Host;
email.ToDomain = toAddress.Host.ToLower();
email.Subject = message.Subject ?? "";
email.Subject = message.Subject ?? string.Empty;
email.MessageHtml = message.HtmlBody;
email.MessagePlain = message.TextBody;
email.MessageSource = message.ToString();

View File

@@ -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

View File

@@ -124,7 +124,7 @@ public class SmtpServerTests
var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync();
// Test non-encrypted field.
Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" <claimed@example.tld>"));
Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld"));
// Decrypt the email and then check all individual fields.
processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey);
@@ -159,7 +159,7 @@ public class SmtpServerTests
var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync();
// Test non-encrypted field.
Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" <claimed@example.tld>"));
Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld"));
// Decrypt the email and then check all individual fields.
processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey);
@@ -192,7 +192,7 @@ public class SmtpServerTests
var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync();
// Test non-encrypted field.
Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" <claimed@example.tld>"));
Assert.That(processedEmail.To, Is.EqualTo("claimed@example.tld"));
// Decrypt the email and then check all individual fields.
processedEmail = EmailEncryption.DecryptEmail(processedEmail, PrivateKey);

View File

@@ -0,0 +1,81 @@
//-----------------------------------------------------------------------
// <copyright file="ConversionHelperTest.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Tests.Helpers;
using AliasVault.Api.Helpers;
/// <summary>
/// Tests for the CsvImportExport class.
/// </summary>
public class ConversionHelperTest
{
/// <summary>
/// Tests the conversion of an email address with a display name to just the display name.
/// </summary>
[Test]
public void TestFromConversion()
{
string from = "\"My full Name\" <myname@example.com>";
string convertedFrom = ConversionHelper.ConvertFromToFromDisplay(from);
// Check that conversion works as expected.
Assert.That(convertedFrom, Is.EqualTo("My full Name"));
}
/// <summary>
/// Tests the conversion of a simple anchor tag to open in a new tab.
/// </summary>
[Test]
public void TestAnchorTabConversionSimple()
{
string anchorHtml = "<a href=\"https://dutchamzmasters.lt.acemlnb.com/Prod/link-tracker?redirectUrl=aHR0cHMlM0ElMkYlMkZ3d3cuZHV0Y2hhbXptYXN0ZXJzLmNvbSUyRnRoYW5rLXlvdTloN3poZ3Rp&amp;sig=CpED3rRPX48ddoWTUZURadAYPYgPppT312jUNnvUCPo5&amp;iat=1679512450&amp;a=%7C%7C25799960%7C%7C&amp;account=dutchamzmasters%2Eactivehosted%2Ecom&amp;email=DQeVbqE%2Fy2FD5V3I2cvSxXjJCI3Tg5qfUHKGneOhzjJYZ1kM3LVZcQ%3D%3D%3AvdAW7N7fs1pZlI1ib%2BNbsMYz5m4FssAR&amp;s=5241db963ffe25d6f4b762fc00038ee2&amp;i=163A299A10A816\"></a>";
string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
// Check that conversion works as expected.
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
}
/// <summary>
/// Tests the conversion of a complex anchor tag with multiple attributes to open in a new tab.
/// </summary>
[Test]
public void TestAnchorTabConversionComplex1()
{
string anchorHtml = "<a\nhref=\"https://dutchamzmasters.lt.acemlnb.com/Prod/link-tracker?redirectUrl=aHR0cHMlM0ElMkYlMkZ3d3cuZHV0Y2hhbXptYXN0ZXJzLmNvbSUyRnRoYW5rLXlvdTloN3poZ3Rp&sig=CpED3rRPX48ddoWTUZURadAYPYgPppT312jUNnvUCPo5&iat=1679512450&a=%7C%7C25799960%7C%7C&account=dutchamzmasters%2Eactivehosted%2Ecom&email=DQeVbqE%2Fy2FD5V3I2cvSxXjJCI3Tg5qfUHKGneOhzjJYZ1kM3LVZcQ%3D%3D%3AvdAW7N7fs1pZlI1ib%2BNbsMYz5m4FssAR&s=5241db963ffe25d6f4b762fc00038ee2&i=163A299A10A816\" data-ac-default-color=\"1\" style=\"margin: 0; outline: none; padding: 0; color: #045FB4; text-decoration: underline; font-weight: bold;\"><span style=\"color: ; font-size: inherit; font-weight: inherit; line-height: inherit; text-decoration: inherit;\">Start hier met de training &gt;&gt;&gt;</span></a>";
string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
// Check that conversion works as expected.
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
}
/// <summary>
/// Tests the conversion of a complex anchor tag with nested elements to open in a new tab.
/// </summary>
[Test]
public void TestAnchorTabConversionComplex2()
{
string anchorHtml = "<div class=\"btn btn--flat btn--large\" style=\"Margin-bottom: 20px;text-align: center;\">\n <!--[if !mso]><!--><a style=\"border-radius: 4px;display: inline-block;font-size: 14px;font-weight: bold;line-height: 24px;padding: 12px 24px;text-align: center;text-decoration: none !important;transition: opacity 0.1s ease-in;color: #212529 !important;background-color: #ffdd55;font-family: Open Sans, sans-serif;\" href=\"https://eazegamesbv.cmail19.com/t/j-l-sktidll-ddhdthjhkj-j/\">Haal je beloning op</a><!--<![endif]-->\n <!--[if mso]><p style=\"line-height:0;margin:0;\">&nbsp;</p><v:roundrect xmlns:v=\"urn:schemas-microsoft-com:vml\" href=\"https://eazegamesbv.cmail19.com/t/j-l-sktidll-ddhdthjhkj-j/\" style=\"width:136.5pt\" arcsize=\"9%\" fillcolor=\"#FFDD55\" stroke=\"f\"><v:textbox style=\"mso-fit-shape-to-text:t\" inset=\"0pt,8.25pt,0pt,8.25pt\"><center style=\"font-size:14px;line-height:24px;color:#212529;font-family:Open Sans,sans-serif;font-weight:bold;mso-line-height-rule:exactly;mso-text-raise:1.5px\">Haal je beloning op</center></v:textbox></v:roundrect><![endif]--></div>";
string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
// Check that conversion works as expected.
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
}
/// <summary>
/// Tests the conversion of a complex anchor tag within a table cell to open in a new tab.
/// </summary>
[Test]
public void TestAnchorTabConversionComplex3()
{
string anchorHtml = "<td style=\"word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; font-family: Helvetica, Arial, sans-serif; font-weight: normal; margin: 0; Margin: 0; font-size: 16px; line-height: 1.3; text-align: center; color: #fefefe; background: #f17130; border-radius: 5px; border: 0 solid #f17130; width: 400px; padding: 5px; border-collapse: collapse;\"><a href=\"https://click.info.wijkopenautos.nl/f/a/chSbfTJZeP5dGZaVjOIUlw~~/AABMyAA~/RgRnzW26P0SwaHR0cHM6Ly93d3cud2lqa29wZW5hdXRvcy5ubC9pbnNwZWN0aW9uL2UwZTg0M2Y4NGEzZDRjYjc4MDYwMGU2NDEzNzc1NmEyLz9NSUQ9TkxfQ1JNXzFfM18wXzE5NjY3MF8yNDE5NTgwMTEyNDdfMSZ0bXM9MTcwOTg5Mzc4MyZ1dG1fc291cmNlPUNSTSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj0zXzdXBXNwY2V1Qgpl6bro6mX9yH0mUhJidWxhYmVlckBhc2Rhc2QubmxYBAAAAAw~\" style=\"margin: 0; Margin: 0; line-height: 1.3; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: bold; color: #fefefe; text-decoration: none; display: inline-block; background: #f17130; border: 0 solid #f17130; width: 400px; text-align: center; padding: 5px; border-radius: 5px;\">Ontvang nu jouw prijs&nbsp;&nbsp;<b>&gt;</b></a></td>";
string convertedAnchorTags = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(anchorHtml);
// Check that conversion works as expected.
Assert.That(convertedAnchorTags, Does.Contain("target=\"_blank\""));
}
}

View File

@@ -38,10 +38,13 @@ public static class Encryption
/// <returns>The encrypted symmetric key as a base64-encoded string.</returns>
public static string EncryptSymmetricKeyWithRsa(byte[] symmetricKey, string publicKey)
{
using (var rsa = new RSACryptoServiceProvider())
using (var rsa = RSA.Create())
{
ImportPublicKey(rsa, publicKey);
byte[] encryptedKey = rsa.Encrypt(symmetricKey, true);
rsa.KeySize = 2048;
var rsaParams = RSAEncryptionPadding.OaepSHA256;
byte[] encryptedKey = rsa.Encrypt(symmetricKey, rsaParams);
return Convert.ToBase64String(encryptedKey);
}
}
@@ -54,11 +57,14 @@ public static class Encryption
/// <returns>The encrypted symmetric key as a base64-encoded string.</returns>
public static byte[] DecryptSymmetricKeyWithRsa(string ciphertext, string privateKey)
{
using (var rsa = new RSACryptoServiceProvider())
using (var rsa = RSA.Create())
{
ImportPrivateKey(rsa, privateKey);
rsa.KeySize = 2048;
var rsaParams = RSAEncryptionPadding.OaepSHA256;
byte[] cipherBytes = Convert.FromBase64String(ciphertext);
return rsa.Decrypt(cipherBytes, true);
return rsa.Decrypt(cipherBytes, rsaParams);
}
}
@@ -178,7 +184,7 @@ public static class Encryption
/// </summary>
/// <param name="rsa">The RSA provider to import the key into.</param>
/// <param name="jwk">The public key in JWK format.</param>
private static void ImportPublicKey(RSACryptoServiceProvider rsa, string jwk)
private static void ImportPublicKey(RSA rsa, string jwk)
{
var jwkObj = JsonSerializer.Deserialize<JsonElement>(jwk);
var n = Base64UrlDecode(jwkObj.GetProperty("n").GetString()!);
@@ -198,7 +204,7 @@ public static class Encryption
/// </summary>
/// <param name="rsa">The RSA provider to import the key into.</param>
/// <param name="jwk">The private key in JWK format.</param>
private static void ImportPrivateKey(RSACryptoServiceProvider rsa, string jwk)
private static void ImportPrivateKey(RSA rsa, string jwk)
{
var jwkObj = JsonSerializer.Deserialize<JsonElement>(jwk);
var n = Base64UrlDecode(jwkObj.GetProperty("n").GetString()!);