Merge pull request #138 from lanedirt/117-add-email-ui-to-client-wasm-application-for-local-and-external-email

Add email encryption, add UI to client wasm application for local and external email
This commit is contained in:
Leendert de Borst
2024-07-30 13:21:57 -07:00
committed by GitHub
70 changed files with 3665 additions and 290 deletions

View File

@@ -1,3 +1,4 @@
API_URL=
JWT_KEY=
SMTP_ALLOWED_DOMAINS=example.tld
SMTP_ALLOWED_DOMAINS=
SMTP_TLS_ENABLED=false

View File

@@ -80,6 +80,7 @@ Here is an example file with the various options explained:
```
{
"ApiUrl": "http://localhost:5092",
"SmtpAllowedDomains": ["example.tld"],
"UseDebugEncryptionKey": "true"
}
```

View File

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

View File

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

View File

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

View File

@@ -65,6 +65,35 @@ else
</div>
</div>
</div>
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="items-center">
<div>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Email claims</h3>
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3">ID</th>
<th scope="col" class="px-4 py-3">Created</th>
<th scope="col" class="px-4 py-3">Filesize</th>
<th scope="col" class="px-4 py-3">DB version</th>
</tr>
</thead>
<tbody>
@foreach (var entry in EmailClaimList)
{
<tr class="bg-white border-b hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">@entry.Id</td>
<td class="px-4 py-3">@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
<td class="px-4 py-3">@entry.Address</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
}
@@ -78,8 +107,8 @@ else
private bool IsLoading { get; set; } = true;
private AliasVaultUser? User { get; set; } = new();
private List<Vault> VaultList { get; set; } = new();
private List<UserEmailClaim> EmailClaimList { get; set; } = new();
/// <inheritdoc />
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();
}

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 = 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);
}
}

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 = 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);
}
}

View File

@@ -65,10 +65,10 @@ public class VaultController(IDbContextFactory<AliasServerDbContext> 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<string>(), 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<string>(), vault.CreatedAt, vault.UpdatedAt));
}
/// <summary>
@@ -116,6 +116,114 @@ public class VaultController(IDbContextFactory<AliasServerDbContext> 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();
}
/// <summary>
/// Updates the user's email claims based on the provided email address list.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="userId">The ID of the user.</param>
/// <param name="newEmailAddresses">The list of new email addresses to claim.</param>
/// <returns>A task representing the asynchronous operation.</returns>
private async Task UpdateUserEmailClaims(AliasServerDbContext context, string userId, List<string> 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();
}
/// <summary>
/// Updates the user's public key based on the provided public key. If it already exists, do nothing.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="userId">The ID of the user.</param>
/// <param name="newPublicKey">The new public key to sync and set as default.</param>
/// <returns>A task representing the asynchronous operation.</returns>
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();
}
}

View File

@@ -0,0 +1,60 @@
//-----------------------------------------------------------------------
// <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;
/// <summary>
/// Class which contains various helper methods for data conversion.
/// </summary>
public static 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('<'))
{
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;
}
/// <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,
TimeSpan.FromSeconds(1));
return html;
}
}

View File

@@ -0,0 +1,27 @@
//-----------------------------------------------------------------------
// <copyright file="Config.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.Client;
/// <summary>
/// Configuration class for the Client project with values loaded from appsettings.json.
/// </summary>
public class Config
{
/// <summary>
/// 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.
/// </summary>
public string ApiUrl { get; set; } = "false";
/// <summary>
/// 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.
/// </summary>
public List<string> SmtpAllowedDomains { get; set; } = [];
}

View File

@@ -1,5 +1,4 @@
@using System.IO
@inject IJSRuntime JSRuntime
<div class="col">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
@@ -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();

View File

@@ -1,4 +1,4 @@
@inject IJSRuntime JSRuntime
@inject JsInteropService JsInteropService
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Attachments</h3>
@@ -47,7 +47,7 @@
{
if (attachment.Blob != null)
{
await JSRuntime.InvokeVoidAsync("downloadFileFromStream", attachment.Filename, attachment.Blob);
await JsInteropService.DownloadFileFromStream(attachment.Filename, attachment.Blob);
}
else
{

View File

@@ -0,0 +1,81 @@
@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">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white"><a target="_blank" href="https://spamok.com/@(Email!.ToLocal)/@(Email!.Id)">@Email.Subject</a></h2>
<button @onclick="Close" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="mt-4">
<p class="text-sm text-gray-500 dark:text-gray-400">From: @Email.FromDisplay</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Date: @Email.DateSystem</p>
</div>
<div class="mt-4 text-gray-700 dark:text-gray-300">
<div>
<iframe class="w-full h-[700px]" srcdoc="@(EmailBody ?? "<div>This email has no HTML content.</div>")">
</iframe>
</div>
</div>
<div class="mt-6 flex justify-end">
<button @onclick="Close" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Close</button>
</div>
</div>
</div>
@code {
/// <summary>
/// The email to show in the modal.
/// </summary>
[Parameter]
public EmailApiModel? Email { get; set; }
/// <summary>
/// Callback when the modal is closed.
/// </summary>
[Parameter]
public EventCallback<bool> OnClose { get; set; }
/// <summary>
/// The message body to display
/// </summary>
private string EmailBody = string.Empty;
/// <inheritdoc />
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.]";
}
}
}
/// <summary>
/// Close the modal.
/// </summary>
private Task Close()
{
return OnClose.InvokeAsync(false);
}
}

View File

@@ -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)
{
<EmailModal Email="@Email" OnClose="CloseEmailModal" />
}
@if (ShowComponent)
{
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex justify-between">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Email</h3>
<button @onclick="LoadRecentEmailsAsync" type="button" class="text-blue-700 border border-blue-700 hover:bg-blue-700 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:focus:ring-blue-800 dark:hover:bg-blue-500">
<button id="recent-email-refresh" @onclick="LoadRecentEmailsAsync" type="button" class="text-blue-700 border border-blue-700 hover:bg-blue-700 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:focus:ring-blue-800 dark:hover:bg-blue-500">
Refresh
</button>
</div>
@@ -16,6 +25,10 @@
{
<LoadingIndicator/>
}
else if (!string.IsNullOrEmpty(Error))
{
<AlertMessageError Message="@Error" />
}
else if (MailboxEmails.Count == 0)
{
<div>No emails found.</div>
@@ -42,10 +55,10 @@
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<a target="_blank" href="https://spamok.com/@mail.ToLocal/@mail.Id">@(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))...</a>
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))...</span>
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<a target="_blank" href="https://spamok.com/@mail.ToLocal/@mail.Id">@mail.DateSystem</a>
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.DateSystem</span>
</td>
</tr>
}
@@ -64,11 +77,14 @@
/// The email address to show recent emails for.
/// </summary>
[Parameter]
public string Email { get; set; } = string.Empty;
public string EmailAddress { get; set; } = string.Empty;
private List<MailboxEmailApiModel> 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;
/// <inheritdoc />
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 @@
}
}
/// <summary>
/// Returns true if the email address is from a known SpamOK domain.
/// </summary>
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");
}
/// <summary>
/// Returns true if the email address is from a known AliasVault domain.
/// </summary>
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<MailboxApiModel>($"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();
}
/// <summary>
/// Open the email modal.
/// </summary>
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);
}
}
/// <summary>
/// Load recent emails from SpamOK.
/// </summary>
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<MailboxApiModel>($"https://api.spamok.com/v2/EmailBox/{emailPrefix}");
if (mailbox?.Mails != null)
{
// Show maximum of 10 recent emails.
MailboxEmails = mailbox.Mails.Take(10).ToList();
}
}
/// <summary>
/// Load recent emails from SpamOK.
/// </summary>
private async Task ShowSpamOkEmailInModal(string emailPrefix, int emailId)
{
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();
}
}
/// <summary>
/// Load recent emails from AliasVault.
/// </summary>
private async Task LoadAliasVaultEmails()
{
try
{
var 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.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);
}
}
/// <summary>
/// Load recent emails from AliasVault.
/// </summary>
private async Task ShowAliasVaultEmailInModal(int emailId)
{
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>
/// Close the email modal.
/// </summary>
private void CloseEmailModal()
{
EmailModalVisible = false;
StateHasChanged();
}
}

View File

@@ -1,5 +1,5 @@
@inject ClipboardCopyService ClipboardCopyService
@inject IJSRuntime JsRuntime
@inject JsInteropService JsInteropService
@implements IDisposable
<label for="@_inputId" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
@@ -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

View File

@@ -1,5 +1,4 @@
@inject ClipboardCopyService ClipboardCopyService
@inject IJSRuntime JsRuntime
<label for="@Id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<div class="relative">

View File

@@ -1,5 +1,4 @@
@inherits MainBase
@using AliasVault.Client.Main.Pages;
@inherits AliasVault.Client.Main.Pages.MainBase
@implements IDisposable
<header>
@@ -119,9 +118,9 @@
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await Js.InvokeVoidAsync("window.initTopMenu");
await JsInteropService.InitTopMenu();
DotNetObjectReference<TopMenu> objRef = DotNetObjectReference.Create(this);
await Js.InvokeVoidAsync("window.registerClickOutsideHandler", objRef);
await JsInteropService.RegisterClickOutsideHandler(objRef);
}
}

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

@@ -42,7 +42,7 @@ else
</div>
</div>
</div>
<RecentEmails Email="@Alias.Alias.Email" />
<RecentEmails EmailAddress="@Alias.Alias.Email" />
@if (Alias.Notes != null && Alias.Notes.Length > 0)
{
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">

View File

@@ -49,10 +49,10 @@ public class MainBase : OwningComponentBase
public GlobalLoadingService GlobalLoadingSpinner { get; set; } = null!;
/// <summary>
/// Gets or sets the IJSRuntime.
/// Gets or sets the JsInteropService.
/// </summary>
[Inject]
public IJSRuntime Js { get; set; } = null!;
public JsInteropService JsInteropService { get; set; } = null!;
/// <summary>
/// Gets or sets the DbService.

View File

@@ -40,8 +40,6 @@
</div>
</div>
@if (IsImporting)
{
<p>Loading...</p>
@@ -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)
{

View File

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

View File

@@ -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<CredentialService>();
builder.Services.AddScoped<DbService>();
builder.Services.AddScoped<GlobalNotificationService>();
builder.Services.AddScoped<GlobalLoadingService>();
builder.Services.AddScoped<JsInteropService>();
builder.Services.AddSingleton<ClipboardCopyService>();
builder.Services.AddAuthorizationCore();

View File

@@ -98,7 +98,7 @@ public class AuthService(HttpClient httpClient, ILocalStorageService localStorag
/// <summary>
/// Get encryption key.
/// </summary>
/// <returns>Encryption key as byte[].</returns>
/// <returns>SrpArgonEncryption key as byte[].</returns>
public byte[] GetEncryptionKeyAsync()
{
return _encryptionKey;
@@ -107,7 +107,7 @@ public class AuthService(HttpClient httpClient, ILocalStorageService localStorag
/// <summary>
/// Get encryption key as base64 string.
/// </summary>
/// <returns>Encryption key as base64 string.</returns>
/// <returns>SrpArgonEncryption key as base64 string.</returns>
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
/// <summary>
/// Stores the encryption key asynchronously in-memory.
/// </summary>
/// <param name="newKey">Encryption key.</param>
/// <param name="newKey">SrpArgonEncryption key.</param>
public void StoreEncryptionKey(byte[] newKey)
{
_encryptionKey = newKey;

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

@@ -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 <see cref="DbService"/> class.
/// </summary>
/// <param name="authService">AuthService.</param>
/// <param name="jsRuntime">IJSRuntime.</param>
/// <param name="jsInteropService">JsInteropService.</param>
/// <param name="httpClient">HttpClient.</param>
public DbService(AuthService authService, IJSRuntime jsRuntime, HttpClient httpClient)
/// <param name="config">Config instance.</param>
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<string>("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<string>("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
/// <summary>
/// Save encrypted database blob to server.
/// </summary>
/// <param name="publicEncryptionKey">RSA public key that server requires in order to encrypt data for user such as received emails.</param>
/// <param name="encryptedDatabase">Encrypted database as string.</param>
/// <returns>True if save action succeeded.</returns>
private async Task<bool> SaveToServerAsync(string encryptedDatabase)
private async Task<bool> 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;
}
}
/// <summary>
/// Get the default public/private encryption key, if it does not yet exist, create it.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private async Task<EncryptionKey> 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;
}
}

View File

@@ -0,0 +1,113 @@
//-----------------------------------------------------------------------
// <copyright file="JsInteropService.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.Client.Services;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.JSInterop;
/// <summary>
/// JavaScript interop service for calling JavaScript functions from C#.
/// </summary>
/// <param name="jsRuntime">IJSRuntime.</param>
public class JsInteropService(IJSRuntime jsRuntime)
{
/// <summary>
/// Symmetrically encrypts a string using the provided encryption key.
/// </summary>
/// <param name="plaintext">Plain text to encrypt.</param>
/// <param name="encryptionKey">Encryption key to use.</param>
/// <returns>Encrypted ciphertext.</returns>
public async Task<string> SymmetricEncrypt(string plaintext, string encryptionKey) =>
await jsRuntime.InvokeAsync<string>("cryptoInterop.encrypt", plaintext, encryptionKey);
/// <summary>
/// Symmetrically decrypts a string using the provided encryption key.
/// </summary>
/// <param name="ciphertext">Cipher text to decrypt.</param>
/// <param name="encryptionKey">Encryption key to use.</param>
/// <returns>Encrypted ciphertext.</returns>
public async Task<string> SymmetricDecrypt(string ciphertext, string encryptionKey) =>
await jsRuntime.InvokeAsync<string>("cryptoInterop.decrypt", ciphertext, encryptionKey);
/// <summary>
/// Downloads a file from a stream.
/// </summary>
/// <param name="filename">Filename of the download.</param>
/// <param name="blob">Blob byte array to download.</param>
/// <returns>Task.</returns>
public async Task DownloadFileFromStream(string filename, byte[] blob) =>
await jsRuntime.InvokeVoidAsync("downloadFileFromStream", filename, blob);
/// <summary>
/// Copy a string to the browsers clipboard.
/// </summary>
/// <param name="value">Value to copy to clipboard.</param>
/// <returns>Task.</returns>
public async Task CopyToClipboard(string value) =>
await jsRuntime.InvokeVoidAsync("navigator.clipboard.writeText", value);
/// <summary>
/// Initializes the top menu.
/// </summary>
/// <returns>Task.</returns>
public async Task InitTopMenu() =>
await jsRuntime.InvokeVoidAsync("window.initTopMenu");
/// <summary>
/// Registers a click outside handler.
/// </summary>
/// <typeparam name="TComponent">Component type.</typeparam>
/// <param name="objRef">DotNetObjectReference.</param>
/// <returns>Task.</returns>
public async Task RegisterClickOutsideHandler<TComponent>(DotNetObjectReference<TComponent> objRef)
where TComponent : class
{
await jsRuntime.InvokeVoidAsync("window.registerClickOutsideHandler", objRef);
}
/// <summary>
/// Generates a new RSA key pair.
/// </summary>
/// <returns>Tuple with public and private key.</returns>
public async Task<(string PublicKey, string PrivateKey)> GenerateRsaKeyPair()
{
var result = await jsRuntime.InvokeAsync<JsonElement>("rsaInterop.generateRsaKeyPair");
return (result.GetProperty("publicKey").GetString()!, result.GetProperty("privateKey").GetString()!);
}
/// <summary>
/// Encrypts a plaintext with a public key.
/// </summary>
/// <param name="plaintext">Plain text to encrypt.</param>
/// <param name="publicKey">Public key to use for encryption.</param>
/// <returns>Encrypted ciphertext.</returns>
public async Task<string> EncryptWithPublicKey(string plaintext, string publicKey) =>
await jsRuntime.InvokeAsync<string>("rsaInterop.encryptWithPublicKey", plaintext, publicKey);
/// <summary>
/// Decrypts a ciphertext with a private key.
/// </summary>
/// <param name="base64Ciphertext">Ciphertext to decrypt.</param>
/// <param name="privateKey">Private key to use for decryption.</param>
/// <returns>Decrypted string.</returns>
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)
{
await Console.Error.WriteLineAsync($"JavaScript decryption error: {ex.Message}");
throw new CryptographicException("Decryption failed", ex);
}
}
}

11
src/AliasVault.Client/entrypoint.sh Normal file → Executable file
View File

@@ -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<length(a)) printf ","} printf "]"}')
# Use sed to update the SmtpAllowedDomains field in appsettings.json
sed -i.bak "s|\"SmtpAllowedDomains\": \[.*\]|\"SmtpAllowedDomains\": $json_array|" /usr/share/nginx/html/appsettings.json
# Start the application
nginx -g "daemon off;"

View File

@@ -1,11 +1,11 @@
{
"name": "AliasVault.Client",
"name": "aliasvault.client",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "AliasVault.Client",
"name": "aliasvault.client",
"version": "1.0.0",
"license": "ISC",
"dependencies": {

View File

@@ -1,3 +1,4 @@
{
"ApiUrl": "http://localhost:5092"
"ApiUrl": "http://localhost:5092",
"SmtpAllowedDomains": ["example.tld"]
}

View File

@@ -795,6 +795,10 @@ video {
height: 2.25rem;
}
.h-\[700px\] {
height: 700px;
}
.h-full {
height: 100%;
}
@@ -1017,6 +1021,10 @@ video {
align-self: center;
}
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
@@ -1087,6 +1095,11 @@ video {
border-color: rgb(34 197 94 / var(--tw-border-opacity));
}
.bg-blue-500 {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
@@ -1132,6 +1145,11 @@ video {
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
}
.bg-primary-200 {
--tw-bg-opacity: 1;
background-color: rgb(251 203 116 / var(--tw-bg-opacity));
}
.bg-primary-600 {
--tw-bg-opacity: 1;
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
@@ -1157,10 +1175,19 @@ video {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-gray-500 {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
.bg-opacity-75 {
--tw-bg-opacity: 0.75;
}
.fill-primary-600 {
fill: #d68338;
}
@@ -1483,6 +1510,11 @@ video {
transition-duration: 300ms;
}
.hover\:bg-blue-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
@@ -1533,6 +1565,11 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:text-gray-500:hover {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity));
}
.hover\:text-gray-900:hover {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));

View File

@@ -1,3 +1,7 @@
/**
* AES (symmetric) encryption and decryption functions.
* @type {{encrypt: (function(*, *): Promise<string>), decrypt: (function(*, *): Promise<string>)}}
*/
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<string>), encryptWithPublicKey: (function(string, string): Promise<string>), 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<string>} 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<Uint8Array>} 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}`);
}
}
};

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

@@ -17,12 +17,16 @@ public class Vault
/// </summary>
/// <param name="blob">Blob.</param>
/// <param name="version">Version of the vault data model (migration).</param>
/// <param name="encryptionPublicKey">Public encryption key that server requires to encrypt user data such as received emails.</param>
/// <param name="emailAddressList">List of email addresses that are used in the vault and should be registered.</param>
/// <param name="createdAt">CreatedAt.</param>
/// <param name="updatedAt">UpdatedAt.</param>
public Vault(string blob, string version, DateTime createdAt, DateTime updatedAt)
public Vault(string blob, string version, string encryptionPublicKey, List<string> emailAddressList, DateTime createdAt, DateTime updatedAt)
{
Blob = blob;
Version = version;
EncryptionPublicKey = encryptionPublicKey;
EmailAddressList = emailAddressList;
CreatedAt = createdAt;
UpdatedAt = updatedAt;
}
@@ -37,6 +41,16 @@ public class Vault
/// </summary>
public string Version { get; set; }
/// <summary>
/// Gets or sets the public encryption key that server requires to encrypt user data such as received emails.
/// </summary>
public string EncryptionPublicKey { get; set; }
/// <summary>
/// Gets or sets the list of email addresses that are used in the vault and should be registered on the server.
/// </summary>
public List<string> EmailAddressList { get; set; }
/// <summary>
/// Gets or sets the date and time of creation.
/// </summary>

View File

@@ -68,6 +68,11 @@ public class AliasClientDbContext : DbContext
/// </summary>
public DbSet<Service> Services { get; set; } = null!;
/// <summary>
/// Gets or sets the EncryptionKey DbSet.
/// </summary>
public DbSet<EncryptionKey> EncryptionKeys { get; set; } = null!;
/// <summary>
/// The OnModelCreating method.
/// </summary>

View File

@@ -0,0 +1,48 @@
//-----------------------------------------------------------------------
// <copyright file="EncryptionKey.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 AliasClientDb;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// The EncryptionKey entity.
/// </summary>
public class EncryptionKey
{
/// <summary>
/// Gets or sets the encryption key primary key.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the public key.
/// </summary>
[StringLength(2000)]
public string PublicKey { get; set; } = null!;
/// <summary>
/// Gets or sets the private key.
/// </summary>
[StringLength(2000)]
public string PrivateKey { get; set; } = null!;
/// <summary>
/// Gets or sets a value indicating whether this public/private key is the primary key to use by default.
/// </summary>
public bool IsPrimary { get; set; }
/// <summary>
/// Gets or sets the created timestamp.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the updated timestamp.
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,308 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AddressCity")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressCountry")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressState")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressStreet")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressZipCode")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("BankAccountIBAN")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("BirthDate")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Gender")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Hobbies")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("NickName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("PhoneMobile")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Aliases");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Attachment");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AliasId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<Guid>("ServiceId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AliasId");
b.HasIndex("ServiceId");
b.ToTable("Credentials");
});
modelBuilder.Entity("AliasClientDb.EncryptionKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("EncryptionKeys");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<byte[]>("Logo")
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,39 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasClientDb.Migrations
{
/// <inheritdoc />
public partial class _110AddPkiTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EncryptionKeys",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
PublicKey = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
PrivateKey = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
IsPrimary = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EncryptionKeys", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EncryptionKeys");
}
}
}

View File

@@ -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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("EncryptionKeys");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.Property<Guid>("Id")

View File

@@ -100,6 +100,16 @@ public class AliasServerDbContext : WorkerStatusDbContext
/// </summary>
public DbSet<EmailAttachment> EmailAttachments { get; set; }
/// <summary>
/// Gets or sets the UserEmailClaims DbSet.
/// </summary>
public DbSet<UserEmailClaim> UserEmailClaims { get; set; }
/// <summary>
/// Gets or sets the UserEncryptionKeys DbSet.
/// </summary>
public DbSet<UserEncryptionKey> UserEncryptionKeys { get; set; }
/// <summary>
/// Gets or sets the Logs DbSet.
/// </summary>
@@ -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<UserEmailClaim>()
.HasOne(l => l.User)
.WithMany(c => c.EmailClaims)
.HasForeignKey(l => l.UserId)
.OnDelete(DeleteBehavior.Cascade);
// Configure Email - UserEncryptionKey relationship
modelBuilder.Entity<Email>()
.HasOne(l => l.EncryptionKey)
.WithMany(c => c.Emails)
.HasForeignKey(l => l.UserEncryptionKeyId)
.OnDelete(DeleteBehavior.NoAction);
// Configure UserEncryptionKey - AliasVaultUser relationship
modelBuilder.Entity<UserEncryptionKey>()
.HasOne(l => l.User)
.WithMany(c => c.EncryptionKeys)
.HasForeignKey(l => l.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
/// <summary>

View File

@@ -41,4 +41,14 @@ public class AliasVaultUser : IdentityUser
/// Gets or sets the collection of vaults.
/// </summary>
public virtual ICollection<Vault> Vaults { get; set; } = [];
/// <summary>
/// Gets or sets the collection of EmailClaims.
/// </summary>
public virtual ICollection<UserEmailClaim> EmailClaims { get; set; } = [];
/// <summary>
/// Gets or sets the collection of EncryptionKeys.
/// </summary>
public virtual ICollection<UserEncryptionKey> EncryptionKeys { get; set; } = [];
}

View File

@@ -7,6 +7,8 @@
namespace AliasServerDb;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
/// <summary>
@@ -24,6 +26,25 @@ public class Email
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets encryption key foreign key.
/// </summary>
[StringLength(255)]
public Guid UserEncryptionKeyId { get; set; }
/// <summary>
/// Gets or sets foreign key to the UserEncryptionKey object which contains the public key used for encrypting
/// the symmetric encryption key.
/// </summary>
[ForeignKey("UserEncryptionKeyId")]
public virtual UserEncryptionKey EncryptionKey { get; set; } = null!;
/// <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; } = null!;
/// <summary>
/// Gets or sets the subject of the email.
/// </summary>
@@ -102,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

@@ -0,0 +1,707 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AdminRoles");
});
modelBuilder.Entity("AliasServerDb.AdminUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastPasswordChanged")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AdminUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AliasVaultRoles");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("Salt")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.Property<string>("Verifier")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AliasVaultUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceIdentifier")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("ExpireDate")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AliasVaultUserRefreshTokens");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<DateTime>("DateSystem")
.HasColumnType("TEXT");
b.Property<string>("EncryptedSymmetricKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MessageHtml")
.HasColumnType("TEXT");
b.Property<string>("MessagePlain")
.HasColumnType("TEXT");
b.Property<string>("MessagePreview")
.HasColumnType("TEXT");
b.Property<string>("MessageSource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("PushNotificationSent")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("To")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("UserEncryptionKeyId")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("Bytes")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<int>("EmailId")
.HasColumnType("INTEGER");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Filesize")
.HasColumnType("INTEGER");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EmailId");
b.ToTable("EmailAttachments");
});
modelBuilder.Entity("AliasServerDb.Log", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Application")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Exception")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("LogEvent")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("LogEvent");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MessageTemplate")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Properties")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimeStamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Application");
b.HasIndex("TimeStamp");
b.ToTable("Logs", (string)null);
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AddressDomain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AddressLocal")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserEncryptionKeys");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("FileSize")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("VaultBlob")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Vaults");
});
modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CurrentStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("DesiredStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("Heartbeat")
.HasColumnType("TEXT");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar");
b.HasKey("Id");
b.ToTable("WorkerServiceStatuses");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,133 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddEncryptionKeyTables : Migration
{
/// <inheritdoc />
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<string>(
name: "EncryptedSymmetricKey",
table: "Emails",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<Guid>(
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<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
Address = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
AddressLocal = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
AddressDomain = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
PublicKey = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
IsPrimary = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -229,6 +229,10 @@ namespace AliasServerDb.Migrations
b.Property<DateTime>("DateSystem")
.HasColumnType("TEXT");
b.Property<string>("EncryptedSymmetricKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
@@ -273,6 +277,10 @@ namespace AliasServerDb.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("UserEncryptionKeyId")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AddressDomain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AddressLocal")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserEncryptionKeys");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("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
}
}

View File

@@ -0,0 +1,65 @@
//-----------------------------------------------------------------------
// <copyright file="UserEmailClaim.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 AliasServerDb;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// UserEmailClaim object. This object is used to reserve an email address for a user.
/// </summary>
[Index(nameof(Address), IsUnique = true)]
public class UserEmailClaim
{
/// <summary>
/// Gets or sets the ID.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets user ID foreign key.
/// </summary>
[StringLength(255)]
public string UserId { get; set; } = null!;
/// <summary>
/// Gets or sets foreign key to the AliasVaultUser object.
/// </summary>
[ForeignKey("UserId")]
public virtual AliasVaultUser User { get; set; } = null!;
/// <summary>
/// Gets or sets the full email address.
/// </summary>
[StringLength(255)]
public string Address { get; set; } = null!;
/// <summary>
/// Gets or sets the email adress local part.
/// </summary>
[StringLength(255)]
public string AddressLocal { get; set; } = null!;
/// <summary>
/// Gets or sets the email adress domain part.
/// </summary>
[StringLength(255)]
public string AddressDomain { get; set; } = null!;
/// <summary>
/// Gets or sets created timestamp.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets updated timestamp.
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,60 @@
//-----------------------------------------------------------------------
// <copyright file="UserEncryptionKey.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 AliasServerDb;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
/// <summary>
/// UserEncryptionKey object. This object is used for storing user public keys for encryption.
/// </summary>
public class UserEncryptionKey
{
/// <summary>
/// Gets or sets the ID.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets user ID foreign key.
/// </summary>
[StringLength(255)]
public string UserId { get; set; } = null!;
/// <summary>
/// Gets or sets foreign key to the AliasVaultUser object.
/// </summary>
[ForeignKey("UserId")]
public virtual AliasVaultUser User { get; set; } = null!;
/// <summary>
/// Gets or sets the public key.
/// </summary>
[StringLength(2000)]
public string PublicKey { get; set; } = null!;
/// <summary>
/// Gets or sets a value indicating whether this public key is the primary key to use by default.
/// </summary>
public bool IsPrimary { get; set; }
/// <summary>
/// Gets or sets created timestamp.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets updated timestamp.
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the collection of Emails that are using this encryption key.
/// </summary>
public virtual ICollection<Email> Emails { get; set; } = [];
}

View File

@@ -9,6 +9,16 @@
<DockerfileContext>..\..\..</DockerfileContext>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DocumentationFile>bin\Debug\net8.0\AliasVault.SmtpService.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DocumentationFile>bin\Release\net8.0\AliasVault.SmtpService.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
@@ -19,10 +29,15 @@
<PackageReference Include="MimeKit" Version="4.7.1" />
<PackageReference Include="NUglify" Version="1.21.9" />
<PackageReference Include="SmtpServer" Version="10.0.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
<ProjectReference Include="..\..\Utilities\Cryptography\Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -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.
/// </summary>
public List<String> AllowedToDomains { get; set; } = [];
public List<string> AllowedToDomains { get; set; } = [];
}

View File

@@ -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;
/// <summary>
/// Custom exception for when the email parsing fails to find the "to" address in the email.
/// </summary>
public class EmailParseMissingToException(string message) : Exception(message);
/// <summary>
/// Database message store.
/// </summary>
/// <param name="logger">ILogger instance.</param>
/// <param name="config">Config instance.</param>
/// <param name="dbContextFactory">IDbContextFactory instance.</param>
public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config config, IDbContextFactory<AliasServerDbContext> dbContextFactory) : MessageStore
{
/// <summary>
@@ -39,6 +37,63 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
/// <param name="cancellationToken">CancellationToken instance.</param>
/// <returns>SmtpResponse.</returns>
public override async Task<SmtpResponse> SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence<byte> 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;
}
}
/// <summary>
/// Load the email message from the buffer.
/// </summary>
/// <param name="buffer">Buffer which contains the email contents.</param>
/// <param name="cancellationToken">CancellationToken instance.</param>
/// <returns>MimeMessage.</returns>
private static async Task<MimeMessage> LoadMessageFromBuffer(ReadOnlySequence<byte> buffer, CancellationToken cancellationToken)
{
await using var stream = new MemoryStream();
@@ -48,78 +103,19 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> 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;
}
/// <summary>
/// Insert email into database.
/// </summary>
/// <param name="message">MimeMessage to save into database.</param>
private async Task<int> 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);
}
/// <summary>
/// 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 = "";
var from = string.Empty;
try
{
@@ -132,67 +128,33 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> 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<DatabaseMessageStore> 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.
/// </summary>
/// <param name="email"></param>
/// <returns></returns>
/// <param name="email">Email to extract preview for.</param>
/// <returns>Email preview as string.</returns>
private static string ExtractMessagePreview(Email email)
{
var messagePreview = string.Empty;
@@ -227,16 +189,16 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> 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<DatabaseMessageStore> 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<DatabaseMessageStore> logger, Config c
/// <summary>
/// Create an EmailAttachment object from a MimeEntity attachment.
/// </summary>
/// <param name="attachment"></param>
/// <returns></returns>
/// <param name="attachment">MimeEntity attachment.</param>
/// <returns>EmailAttachment object.</returns>
private static EmailAttachment CreateEmailAttachment(MimeEntity attachment)
{
byte[] fileBytes = GetAttachmentBytes(attachment);
@@ -291,18 +253,18 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> 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,
};
}
/// <summary>
/// Get the attachment bytes from a MimeEntity attachment.
/// </summary>
/// <param name="attachment"></param>
/// <returns></returns>
/// <param name="attachment">MimeEntity attachment.</param>
/// <returns>Attachment byte array.</returns>
private static byte[] GetAttachmentBytes(MimeEntity attachment)
{
using (var memory = new MemoryStream())
@@ -319,4 +281,86 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
return memory.ToArray();
}
}
/// <summary>
/// Process email for recipient separately.
/// </summary>
/// <param name="message">MimeMessage.</param>
/// <param name="toAddress">ToAddress.</param>
/// <returns>True if success or silent skip, false if SmtpResponse.NoValidRecipientsGiven should be triggered.</returns>
private async Task<bool> 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;
}
/// <summary>
/// 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, 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;
}
}

View File

@@ -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<IServiceProvider>());
@@ -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<SmtpServerWorker, AliasServerDbContext>(Assembly.GetExecutingAssembly().GetName().Name!);
// -----------------------------------------------------------------------
var host = builder.Build();

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

@@ -7,6 +7,11 @@
namespace AliasVault.SmtpService.Workers;
/// <summary>
/// A worker for the SMTP server.
/// </summary>
/// <param name="logger">ILogger instance.</param>
/// <param name="smtpServer">SmtpServer instance.</param>
public class SmtpServerWorker(ILogger<SmtpServerWorker> logger, SmtpServer.SmtpServer smtpServer) : BackgroundService
{
/// <inheritdoc />

View File

@@ -49,6 +49,7 @@
<ProjectReference Include="..\..\AliasVault.Admin\AliasVault.Admin.csproj" />
<ProjectReference Include="..\..\AliasVault.Api\AliasVault.Api.csproj" />
<ProjectReference Include="..\..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\AliasVault.IntegrationTests\AliasVault.IntegrationTests.csproj" />
<ProjectReference Include="..\Server\AliasVault.E2ETests.Client.Server\AliasVault.E2ETests.Client.Server.csproj" />
</ItemGroup>

View File

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

View File

@@ -0,0 +1,153 @@
//-----------------------------------------------------------------------
// <copyright file="EmailDecryptionTest.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.E2ETests.Tests.Client;
using AliasVault.IntegrationTests.SmtpServer;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using MimeKit;
/// <summary>
/// End-to-end tests for making sure errors and warnings in API are logged to database.
/// </summary>
[TestFixture]
[Category("ClientTests")]
[NonParallelizable]
public class EmailDecryptionTest : ClientPlaywrightTest
{
/// <summary>
/// The test host instance.
/// </summary>
private IHost _testHost = null!;
/// <summary>
/// The test host builder instance.
/// </summary>
private TestHostBuilder _testHostBuilder = null!;
/// <summary>
/// Setup logic for every test.
/// </summary>
/// <returns>Task.</returns>
[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();
}
/// <summary>
/// Test if received email encrypted by server can be successfully decrypted by client.
/// </summary>
/// <returns>Async task.</returns>
[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<string, string>
{
{ "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.");
}
/// <summary>
/// Test that adding a credential with email domain that is not in the known list to not get added as claim.
/// </summary>
/// <returns>Async task.</returns>
[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<string, string>
{
{ "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.");
}
/// <summary>
/// Tear down logic for every test.
/// </summary>
/// <returns>Task.</returns>
[TearDown]
public async Task TearDown()
{
await _testHost.StopAsync();
_testHost.Dispose();
}
/// <summary>
/// Sends a message to the SMTP server.
/// </summary>
/// <param name="message">MimeMessage to send.</param>
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);
}
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
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\"}";
/// <summary>
/// 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.
/// </summary>
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\"}";
/// <summary>
/// The test host instance.
/// </summary>
@@ -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();
}
/// <summary>
@@ -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();
}
/// <summary>
/// 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.
/// </summary>
[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\" <sender@example.com>"));
Assert.That(processedEmail.FromLocal, Is.EqualTo("sender"));
Assert.That(processedEmail.FromDomain, Is.EqualTo("example.com"));
Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" <recipient@example.tld>"));
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 = "<html><body><h1>This is a test email html.</h1></body></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\" <recipient@example.tld>"));
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 = "<html><body><h1>This is a test email multipart.</h1></body></html>";
@@ -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\" <recipient@example.tld>"));
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
}
/// <summary>
/// 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.
/// </summary>
[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<SmtpCommandException>(async () => await SendMessageToSmtpServer(message));
}
/// <summary>
/// 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.
/// </summary>
[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<SmtpCommandException>(async () => await SendMessageToSmtpServer(message));
}
/// <summary>
/// Sends a message to the SMTP server.
/// </summary>

View File

@@ -5,13 +5,12 @@
// </copyright>
// -----------------------------------------------------------------------
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);
}
/// <summary>
/// Builds the SmtpService test host with a provided database connection.
/// </summary>
/// <returns></returns>
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<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<global::SmtpServer.SmtpServer>(
services.AddSingleton<SmtpServer>(
provider =>
{
var options = new SmtpServerOptionsBuilder()

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

@@ -0,0 +1,139 @@
//-----------------------------------------------------------------------
// <copyright file="RsaEncryptionTests.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.Utilities;
using System.Text.Json;
using Cryptography;
/// <summary>
/// Tests for the SrpArgonEncryption class.
/// </summary>
public class RsaEncryptionTests
{
/// <summary>
/// Example public key for RSA encryption tests. This is a public key generated by the JSInterop on the client.
/// </summary>
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\"}";
/// <summary>
/// Example private key for RSA encryption tests. This is a private key generated by the JSInterop on the client.
/// </summary>
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\"}";
/// <summary>
/// Full flow test for server-side email encryption and client-side decryption.
/// </summary>
[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));
}
/// <summary>
/// Tests that GenerateRsaKeyPair method returns a valid key pair.
/// </summary>
[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<Dictionary<string, object>>(PublicKey), Throws.Nothing);
Assert.That(() => JsonSerializer.Deserialize<Dictionary<string, object>>(PrivateKey), Throws.Nothing);
});
}
/// <summary>
/// Tests if GenerateRandomSymmetricKey method returns a key of correct length.
/// </summary>
[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.");
}
/// <summary>
/// Tests if GenerateRandomSymmetricKey method generates different keys on consecutive calls.
/// </summary>
[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.");
}
/// <summary>
/// Tests if EncryptSymmetricKey method correctly encrypts a symmetric key.
/// </summary>
[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.");
}
/// <summary>
/// Tests if a symmetric key can be correctly encrypted and then decrypted.
/// </summary>
[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.");
}
/// <summary>
/// Tests if EncryptSymmetricKey method throws an exception when given an invalid public key.
/// </summary>
[Test]
public void EncryptSymmetricKey_WithInvalidPublicKey_ThrowsException()
{
var symmetricKey = Encryption.GenerateRandomSymmetricKey();
var invalidPublicKey = "invalid_key";
Assert.Throws<JsonException>(
() => Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, invalidPublicKey),
"Encrypting with an invalid public key should throw an ArgumentException.");
}
}

View File

@@ -30,4 +30,8 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Databases\AliasServerDb\AliasServerDb.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,92 @@
//-----------------------------------------------------------------------
// <copyright file="EmailEncryption.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 Cryptography;
using AliasServerDb;
/// <summary>
/// Helper class for encrypting and decrypting email contents.
/// </summary>
public static class EmailEncryption
{
/// <summary>
/// Encrypt the email contents with the user's public key.
/// </summary>
/// <param name="email">The plain text email object to encrypt.</param>
/// <param name="userEncryptionKey">The user public encryption key to use for the encryption.</param>
/// <returns>Email object with all sensitive fields encrypted.</returns>
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;
}
/// <summary>
/// Decrypt the email contents with the user's private key.
/// </summary>
/// <param name="email">The plain text email object to decrypt.</param>
/// <param name="userPrivateKey">The user private encryption key to use for the decryption.</param>
/// <returns>Email object with all sensitive fields decrypted.</returns>
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;
}
}

View File

@@ -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;
/// <summary>
/// Encryption class.
/// SrpArgonEncryption class.
/// </summary>
public static class Encryption
{
/// <summary>
/// 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.
/// </summary>
/// <param name="password">User password.</param>
/// <param name="salt">The salt to use for the Argon2id hash.</param>
/// <returns>Encryption key as byte array.</returns>
public static byte[] DeriveKeyFromPassword(string password, string salt = "AliasVault")
/// <returns>A 256-bit (32-byte) random key as a byte array.</returns>
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)
/// <summary>
/// Encrypts a symmetric key using an RSA public key.
/// </summary>
/// <param name="symmetricKey">The symmetric key to encrypt.</param>
/// <param name="publicKey">The RSA public key in JWK format.</param>
/// <returns>The encrypted symmetric key as a base64-encoded string.</returns>
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);
}
}
/// <summary>
/// Decrypts an encrypted symmetric key using an RSA private key.
/// </summary>
/// <param name="ciphertext">The encrypted symmetric key as ciphertext.</param>
/// <param name="privateKey">The RSA private key in JWK format.</param>
/// <returns>The encrypted symmetric key as a base64-encoded string.</returns>
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);
}
}
/// <summary>
@@ -46,7 +73,7 @@ public static class Encryption
/// </summary>
/// <param name="password">User password.</param>
/// <param name="salt">The salt to use for the Argon2id hash.</param>
/// <returns>Encryption key as byte array.</returns>
/// <returns>SrpArgonEncryption key as byte array.</returns>
public static async Task<byte[]> DeriveKeyFromPasswordAsync(string password, string salt)
{
byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
@@ -64,12 +91,12 @@ public static class Encryption
}
/// <summary>
/// Encrypt a plaintext string using AES-256 GCM.
/// SymmetricEncrypt a plaintext string using AES-256 GCM.
/// </summary>
/// <param name="plaintext">The plaintext string.</param>
/// <param name="key">Key to use for encryption (must be 32 bytes for AES-256).</param>
/// <returns>The encrypted string (ciphertext).</returns>
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
}
/// <summary>
/// Decrypt a ciphertext string using AES-256 GCM.
/// SymmetricDecrypt a ciphertext string using AES-256 GCM.
/// </summary>
/// <param name="ciphertext">The encrypted string (ciphertext).</param>
/// <param name="key">The key used to originally encrypt the string.</param>
/// <returns>The original plaintext string.</returns>
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');
}
/// <summary>
/// Imports a public key from JWK format into an RSA provider.
/// </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(RSA rsa, string jwk)
{
var jwkObj = JsonSerializer.Deserialize<JsonElement>(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);
}
/// <summary>
/// Imports a private key from JWK format into an RSA provider.
/// </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(RSA rsa, string jwk)
{
var jwkObj = JsonSerializer.Deserialize<JsonElement>(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);
}
/// <summary>
/// Decodes a Base64Url-encoded string to a byte array.
/// </summary>
/// <param name="base64Url">The Base64Url-encoded string.</param>
/// <returns>The decoded byte array.</returns>
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);
}
}