feat: S/MIME signing and encryption (#693)

* feat: add S/MIME certificate management

- Introduced `ISmimeCertificateService` interface for managing S/MIME certificates.
- Implemented `SmimeCertificateService` class to handle certificate operations.
- Updated `WinoPage` enum to include `SignatureAndEncryptionPage`.
- Added resource entries in `resources.json` for S/MIME related messages.
- Created `SignatureAndEncryptionPage` view and logic for user interaction.
- Modified configuration files to integrate the new service and page.
- Updated project files to include necessary dependencies for certificate management.

* refactor(SmimeCertificateService): ♻️ Use constant for certificate name

Refactored the `SmimeCertificateService` to replace the hardcoded string "Wino Mail Certificate" with a constant `CertificateFriendlyName`. This change enhances code maintainability by centralizing the definition of the certificate's friendly name.

• Introduced a constant for the certificate's friendly name.
• Updated the certificate retrieval and import logic to use the new constant.

* feat(alias):  Add S/Mime certificate selection for every alias

Added new properties and methods in `MailAccountAlias` to manage signing and encryption certificates, including their thumbprints. This enhancement allows for better handling of S/Mime certificates within the application.

• Introduced new properties for signing and encryption certificates.
• Updated `resources.json` with new translations for S/Mime certificates.
• Enhanced `AliasManagementPageViewModel` to include a dependency on the S/Mime certificate service and updated alias loading methods.
• Modified `AliasManagementPage.xaml` to include ComboBox controls for selecting certificates.
• Implemented methods in `AliasManagementPage.xaml.cs` to handle certificate selection from dropdowns.

This change improves the user experience by allowing users to select and manage their S/Mime certificates directly within the alias management interface.

* feat(mail):  Add S/MIME support and file picker updates

Enhanced the `MailRenderModel` class by adding a new property `IsSmimeSigned` to indicate if an email is S/MIME signed. The constructor has been updated to accept `MailRenderingOptions`.

Updated the file selection logic in `DialogServiceBase` to replace the `FolderPicker` with a `FileSavePicker`, streamlining the process of saving files. Removed unnecessary commented code and added logic to handle file extensions.

In `MailRenderingPageViewModel`, a new property `IsSmimeSigned` reflects the S/MIME status of the current render model, along with a new method `ShowSmimeCertificateInfoAsync` to display S/MIME certificate details.

Added a `HyperlinkButton` in `MailRenderingPage.xaml` to indicate S/MIME status, which is only visible for signed emails, providing a tooltip and command for more information.

In `MimeFileService`, implemented logic to detect S/MIME signatures in messages and exclude S/MIME signature parts from attachments.

* refactor(viewmodel): ♻️ Replace dialog service messages

Refactored the `SignatureAndEncryptionPageViewModel.cs` to replace calls to `_dialogService.ShowMessageAsync` with `_dialogService.InfoBarMessage`. This change improves the handling of success messages during certificate import and removal processes.

* feat(mail):  Add S/MIME encryption indicator

Implemented support for S/MIME email handling in the MailRenderingPageViewModel. This includes the addition of a new property to check if an email is encrypted and updates to methods for displaying S/MIME certificate information.

A new column was added in the MailRenderingPage.xaml to indicate if an email is encrypted, along with updated tooltips and commands. The MimeFileService was also modified to detect S/MIME encryption and to exclude S/MIME signature certificates during attachment processing.

* fix: Added missing property

* feat: Added S/Mime decryption and signing verification and improvements

* i18n(resources): 🌐 Add S/MIME translation strings

Added new translation strings for S/MIME functionalities in `resources.json`, including messages for signatures and certificates in both English and Italian. The code has been updated to utilize these new translation strings, enhancing the application's internationalization.

Updated `MailRenderingPageViewModel.cs` to use the new translation strings for signature and certificate messages, improving code readability and consistency with translations. Additionally, the tooltips for S/MIME signing and encryption buttons in `MailRenderingPage.xaml` have been updated to use the new translation strings, enhancing the user experience for Italian-speaking users.

* fix: Extract body from MultipartSigned message

* feat(smime):  Enhance S/MIME certificate handling

Updated the `SmimeCertificateService` to improve the loading of PKCS12 certificate collections by adding `X509KeyStorageFlags.DefaultKeySet` and `X509KeyStorageFlags.Exportable` for better key management.

In `ComposePageViewModel`, imported necessary namespaces for S/MIME certificate handling and added a new dependency for `ISmimeCertificateService`. Implemented logic in `OpenAttachmentAsync` to load alias certificates and manage message signing and encryption based on user-selected certificates.

This change enhances the security and flexibility of email handling within the application.

* feat: Replaced Smime encryption certificate combobox with checkbox

Cert selection is useless for encryption

* feat: Added S/Mime togglebuttons when composing an email

* i18n(translations): 🌐 Add new composer translations

Added new translation strings for composer features, including themes, text formatting, and S/MIME signing and encryption options. Updated button labels to utilize these new strings, enhancing the application's internationalization.

Additionally, removed an obsolete string related to S/MIME certificate file information.

* Example for relay command and fix settings pages runtime error

* refactor(viewmodel): ♻️ Update certificate import/export commands

Refactored the certificate import and export commands in the `SignatureAndEncryptionPageViewModel`. Changed methods from `async void` to `async Task` for better error handling and tracking of asynchronous operations. Added `[RelayCommand]` attributes to improve adherence to the MVVM pattern.

Updated the XAML file to bind buttons directly to the new command methods, removing the need for event handlers. This enhances separation of concerns and simplifies the code.

Removed obsolete event handlers from the code-behind file, streamlining the implementation.

* fix: export folderPath parameter contains file name

* fix: QRESYNC initial modseq should be 1 (#734)

* Fix typo in reorder accounts dialog (#754)

* fix: Missing commas in translations files

* fix: merge issues

* Fix mege conflicts.

* Some more conflict fixes.

* Fixing context.

* Fixing saving file with suggested file name.

---------

Co-authored-by: Aleh Khantsevich <aleh.khantsevich@gmail.com>
Co-authored-by: Konstantin Shkel <null+github@pcho.la>
Co-authored-by: Cas Cornelissen <cas.cornelissen@onefinity.io>
Co-authored-by: Burak Kaan Köse <bkaankose@outlook.com>
This commit is contained in:
Maicol Battistini
2025-11-23 20:56:57 +01:00
committed by GitHub
parent 1a2590e2c3
commit beb3bf9d1d
28 changed files with 1079 additions and 52 deletions
@@ -1,4 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Security.Cryptography.X509Certificates;
using SQLite;
namespace Wino.Core.Domain.Entities.Mail;
@@ -59,4 +61,13 @@ public class MailAccountAlias : RemoteAccountAlias
/// Root aliases can't be deleted.
/// </summary>
public bool CanDelete => !IsRootAlias;
public string SelectedSigningCertificateThumbprint { get; set; }
public bool IsSmimeEncryptionEnabled { get; set; }
[Ignore]
public X509Certificate2 SelectedSigningCertificate { get; set; }
[Ignore]
public ObservableCollection<X509Certificate2> Certificates { get; set; } = [];
}
+2 -1
View File
@@ -30,5 +30,6 @@ public enum WinoPage
KeyboardShortcutsPage,
CalendarPage,
CalendarSettingsPage,
EventDetailsPage
EventDetailsPage,
SignatureAndEncryptionPage
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Security.Cryptography.X509Certificates;
namespace Wino.Core.Domain.Interfaces;
public interface ISmimeCertificateService
{
public IEnumerable<X509Certificate2> GetCertificates(StoreName storeName = StoreName.My, StoreLocation storeLocation = StoreLocation.CurrentUser, string emailAddress = null);
public void ImportCertificate(string fileExtension, byte[] rawData, string password = null, StoreName storeName = StoreName.My, StoreLocation storeLocation = StoreLocation.CurrentUser);
public void RemoveCertificate(string thumbprint, StoreName storeName = StoreName.My, StoreLocation storeLocation = StoreLocation.CurrentUser);
}
@@ -10,4 +10,5 @@ namespace Wino.Core.Domain.Models.Common;
public record SharedFile(string FullFilePath, byte[] Data)
{
public string FileName => Path.GetFileName(FullFilePath);
public string FileExtension => Path.GetExtension(FullFilePath)?.ToLowerInvariant();
}
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using MimeKit;
using MimeKit.Cryptography;
using MimeKit.Text;
using MimeKit.Tnef;
@@ -18,6 +19,7 @@ public class HtmlPreviewVisitor : MimeVisitor
readonly string tempDir;
public string Body { get; set; }
public Dictionary<IDigitalSignature, bool> Signatures = [];
/// <summary>
/// Creates a new HtmlPreviewVisitor.
@@ -65,6 +67,12 @@ public class HtmlPreviewVisitor : MimeVisitor
stack.RemoveAt(stack.Count - 1);
}
protected override void VisitMultipartSigned(MultipartSigned signed)
{
VerifySignatures(signed.Verify());
VisitMultipart(signed);
}
// look up the image based on the img src url within our multipart/related stack
bool TryGetImage(string url, out MimePart image)
{
@@ -246,9 +254,47 @@ public class HtmlPreviewVisitor : MimeVisitor
}
protected override void VisitMimePart(MimePart entity)
{
if (entity is ApplicationPkcs7Mime { SecureMimeType: SecureMimeType.EnvelopedData } encrypted)
{
encrypted.Decrypt().Accept(this);
}
else if (entity is ApplicationPkcs7Mime { SecureMimeType: SecureMimeType.SignedData } signed)
{
MimeEntity extracted;
VerifySignatures(signed.Verify(out extracted));
extracted.Accept(this);
}
else
{
// realistically, if we've gotten this far, then we can treat this as an attachment
// even if the IsAttachment property is false.
attachments.Add(entity);
}
}
private void VerifySignatures(DigitalSignatureCollection signatures)
{
foreach (var signature in signatures)
{
try
{
bool valid = signature.Verify();
Signatures.Add(signature, valid);
// If valid is true, then it signifies that the signed content has not
// been modified since this particular signer signed the content.
//
// However, if it is false, then it indicates that the signed content
// has been modified.
}
catch (DigitalSignatureVerifyException)
{
// There was an error verifying the signature.
Signatures.Add(signature, false);
}
}
}
}
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using MimeKit;
using MimeKit.Cryptography;
namespace Wino.Core.Domain.Models.Reader;
@@ -15,6 +16,11 @@ public class MailRenderModel
public UnsubscribeInfo UnsubscribeInfo { get; set; }
public Dictionary<IDigitalSignature, bool> Signatures = [];
// Indicates if the mail is S/MIME encrypted
public bool IsSmimeEncrypted { get; set; }
public MailRenderModel(string renderHtml, MailRenderingOptions mailRenderingOptions = null)
{
RenderHtml = renderHtml;
@@ -727,6 +727,59 @@
"WinoUpgradeMessage": "Upgrade to Unlimited Accounts",
"WinoUpgradeRemainingAccountsMessage": "{0} out of {1} free accounts used.",
"Yesterday": "Yesterday",
"Smime_ImportCertificates_Success": "Certificates imported successfully.",
"Smime_ImportCertificates_Error": "Error importing certificates: {0}",
"Smime_RemoveCertificates_Confirm": "Do you really want to remove the certificates {0}?",
"Smime_RemoveCertificates_Success": "Certificates removed.",
"Smime_ExportCertificates_Success": "Certificates exported.",
"Smime_ExportCertificates_Error": "Error exporting certificates.",
"Smime_CertificateDetails": "Subject: {0}\nIssuer: {1}\nValid from: {2}\nValid to: {3}\nThumbprint: {4}",
"Smime_CertificatePassword_Title": "Certificate password required",
"Smime_CertificatePassword_Placeholder": "Certificate password for {0} (optional)",
"Smime_Confirm_Title": "Confirm",
"Buttons_OK": "OK",
"SettingsSignatureAndEncryption_Title": "Signature and Encryption",
"SettingsSignatureAndEncryption_Description": "Manage S/MIME certificates for signing and encrypting emails.",
"SettingsSignatureAndEncryption_MyCertificatesHeader": "My certificates",
"SettingsSignatureAndEncryption_MyCertificatesDescription": "Personal certificates for signing and encryption",
"SettingsSignatureAndEncryption_RecipientCertificatesHeader": "Recipient certificates",
"SettingsSignatureAndEncryption_RecipientCertificatesDescription": "Recipient certificates for decryption",
"SettingsSignatureAndEncryption_NameColumn": "Name",
"SettingsSignatureAndEncryption_ExpiresColumn": "Expires on",
"SettingsSignatureAndEncryption_ThumbprintColumn": "Thumbprint",
"Buttons_Remove": "Remove",
"Buttons_Export": "Export",
"Buttons_Import": "Import",
"SettingsSignatureAndEncryption_SigningCertificate": "S/Mime Signing Certificate",
"SettingsSignatureAndEncryption_EncryptionCertificate": "S/Mime Encryption",
"SettingsSignatureAndEncryption_SigningCertificatePlaceholder": "None",
"SmimeSignaturesInMessage": "Signatures in this message:",
"SmimeSignatureEntry": "• {0} {1} ({2}, valid through {3} - {4})",
"SmimeSigningCertificateInfoTitle": "S/MIME Signing Certificate Info",
"SmimeCertificateInfoTitle": "S/MIME Certificate Info",
"SmimeNoCertificateFileFound": "No certificate file found",
"SmimeSaveCertificate": "Save certificate...",
"SmimeCertificate": "S/MIME Certificate",
"SmimeCertificateSavedTo": "Certificate saved to {0}",
"SmimeSignedTooltip": "This message is signed with an S/Mime certificate. Click for more details",
"SmimeEncryptedTooltip": "This message is encrypted with an S/Mime certificate.",
"SmimeCertificateFileInfo": "File: {0}\nType: {1}\nSize: {2:N0} bytes",
"Composer_LightTheme": "Light Theme",
"Composer_DarkTheme": "Dark Theme",
"Composer_Outdent": "Outdent",
"Composer_Indent": "Indent",
"Composer_BulletList": "Bullet List",
"Composer_OrderedList": "Ordered List",
"Composer_Stroke": "Stroke",
"Composer_Bold": "Bold",
"Composer_Italic": "Italic",
"Composer_Underline": "Underline",
"Composer_CcBcc": "Cc & Bcc",
"Composer_EnableSmimeSignature": "Enable/disable S/MIME signature",
"Composer_EnableSmimeEncryption": "Enable/disable S/MIME encryption",
"Composer_CertificateExpires": "Expires on: ",
"Composer_SmimeSignature": "S/MIME Signature",
"Composer_SmimeEncryption": "S/MIME Encryption",
"SettingsAppPreferences_EmailSyncInterval_Title": "Email sync interval",
"SettingsAppPreferences_EmailSyncInterval_Description": "Automatic email synchronization interval (minutes). This setting will be applied only after restarting Wino Mail.",
"ContactsPage_Title": "Contacts",
@@ -762,5 +815,3 @@
"ContactsPage_EmptyState": "No contacts to display",
"ContactsPage_AddFirstContact": "Add your first contact"
}
@@ -38,6 +38,7 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
WinoPage.LanguageTimePage => Translator.SettingsLanguageTime_Title,
WinoPage.AppPreferencesPage => Translator.SettingsAppPreferences_Title,
WinoPage.CalendarSettingsPage => Translator.SettingsCalendarSettings_Title,
WinoPage.SignatureAndEncryptionPage => Translator.SettingsSignatureAndEncryption_Title,
WinoPage.KeyboardShortcutsPage => "Keyboard Shortcuts",
_ => throw new NotImplementedException()
};
@@ -61,6 +61,9 @@ internal class QResyncSynchronizer : ImapSynchronizationStrategyBase
// Perform QRESYNC synchronization.
var localHighestModSeq = (ulong)folder.HighestModeSeq;
// HIGHESTMODSEQ must be a positive integer, 0 is illegal.
// It's harmless to set it to 1, as RFC-compliant server without mod-seq would ignore this parameter.
if (localHighestModSeq == 0) localHighestModSeq = 1;
remoteFolder.MessagesVanished += OnMessagesVanished;
remoteFolder.MessageFlagsChanged += OnMessageFlagsChanged;
@@ -115,6 +118,7 @@ internal class QResyncSynchronizer : ImapSynchronizationStrategyBase
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
{
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
if (localHighestModSeq == 0) localHighestModSeq = 1;
return await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -20,6 +21,7 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
{
private readonly IMailDialogService _dialogService;
private readonly IAccountService _accountService;
private readonly ISmimeCertificateService _smimeCertificateService;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSynchronizeAliases))]
@@ -31,10 +33,12 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
public bool CanSynchronizeAliases => Account?.IsAliasSyncSupported ?? false;
public AliasManagementPageViewModel(IMailDialogService dialogService,
IAccountService accountService)
IAccountService accountService,
ISmimeCertificateService smimeCertificateService)
{
_dialogService = dialogService;
_accountService = accountService;
_smimeCertificateService = smimeCertificateService;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
@@ -51,7 +55,22 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
private async Task LoadAliasesAsync()
{
AccountAliases = await _accountService.GetAccountAliasesAsync(Account.Id);
var aliases = await _accountService.GetAccountAliasesAsync(Account.Id);
foreach (var alias in aliases)
{
alias.Certificates.Clear();
alias.Certificates.Add(null); // First blank optioon
var certs = _smimeCertificateService.GetCertificates()
.Where(cert => cert.Subject.Contains(alias.AliasAddress, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var cert in certs)
alias.Certificates.Add(cert);
alias.SelectedSigningCertificate = !string.IsNullOrEmpty(alias.SelectedSigningCertificateThumbprint)
? alias.Certificates.FirstOrDefault(c => c?.Thumbprint == alias.SelectedSigningCertificateThumbprint)
: null;
}
AccountAliases = aliases;
}
[RelayCommand]
@@ -148,4 +167,20 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel
await _accountService.DeleteAccountAliasAsync(alias.Id);
await LoadAliasesAsync();
}
public async Task SetAliasSmimeEncryption(MailAccountAlias alias, bool value)
{
alias.IsSmimeEncryptionEnabled = value;
await _accountService.UpdateAccountAliasesAsync(Account.Id, AccountAliases);
await LoadAliasesAsync();
}
public async Task SetSelectedSigningCertificate(MailAccountAlias alias, X509Certificate2 cert)
{
alias.SelectedSigningCertificate = cert;
alias.SelectedSigningCertificateThumbprint = cert?.Thumbprint;
await _accountService.UpdateAccountAliasesAsync(Account.Id, AccountAliases);
await LoadAliasesAsync();
}
}
+94 -10
View File
@@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using MimeKit;
using MimeKit.Cryptography;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
@@ -68,19 +70,26 @@ public partial class ComposePageViewModel : MailBaseViewModel
private MailAccount composingAccount;
[ObservableProperty]
private List<MailAccountAlias> availableAliases;
public partial List<MailAccountAlias> AvailableAliases { get; set; }
[ObservableProperty]
public partial MailAccountAlias SelectedAlias { get; set; }
[ObservableProperty]
public partial bool IsDraggingOverComposerGrid { get; set; }
[ObservableProperty]
public partial bool IsDraggingOverFilesDropZone { get; set; }
[ObservableProperty]
public partial bool IsDraggingOverImagesDropZone { get; set; }
[ObservableProperty]
public partial bool IsSmimeSignatureEnabled { get; set; }
[ObservableProperty]
public partial bool IsSmimeEncryptionEnabled { get; set; }
[ObservableProperty]
private MailAccountAlias selectedAlias;
public partial X509Certificate2 SelectedSigningCertificate { get; set; }
[ObservableProperty]
private bool isDraggingOverComposerGrid;
public ObservableCollection<X509Certificate2> AvailableCertificates = [];
[ObservableProperty]
private bool isDraggingOverFilesDropZone;
[ObservableProperty]
private bool isDraggingOverImagesDropZone;
public bool AreCertificatesAvailable => AvailableCertificates.Count > 0;
public ObservableCollection<MailAttachmentViewModel> IncludedAttachments { get; set; } = [];
public ObservableCollection<MailAccount> Accounts { get; set; } = [];
@@ -102,6 +111,7 @@ public partial class ComposePageViewModel : MailBaseViewModel
public readonly IFontService FontService;
public readonly IPreferencesService PreferencesService;
public readonly IContactService ContactService;
public readonly ISmimeCertificateService _smimeCertificateService;
public ComposePageViewModel(IMailDialogService dialogService,
IMailService mailService,
@@ -113,7 +123,8 @@ public partial class ComposePageViewModel : MailBaseViewModel
IWinoRequestDelegator worker,
IContactService contactService,
IFontService fontService,
IPreferencesService preferencesService)
IPreferencesService preferencesService,
ISmimeCertificateService smimeCertificateService)
{
NativeAppService = nativeAppService;
ContactService = contactService;
@@ -127,6 +138,38 @@ public partial class ComposePageViewModel : MailBaseViewModel
_fileService = fileService;
_accountService = accountService;
_worker = worker;
_smimeCertificateService = smimeCertificateService;
foreach (var cert in _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias?.AliasAddress))
{
if (cert != null)
{
AvailableCertificates.Add(cert);
}
}
}
partial void OnSelectedAliasChanged(MailAccountAlias value)
{
if (value != null)
{
IsSmimeSignatureEnabled = value.SelectedSigningCertificateThumbprint != null;
IsSmimeEncryptionEnabled = value.IsSmimeEncryptionEnabled;
AvailableCertificates.Clear();
var certs = _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias.AliasAddress);
foreach (var cert in certs)
{
AvailableCertificates.Add(cert);
}
SelectedSigningCertificate = AvailableCertificates
.Where(c => c.Thumbprint == SelectedAlias.SelectedSigningCertificateThumbprint).FirstOrDefault() ?? AvailableCertificates.FirstOrDefault();
}
}
partial void OnSelectedSigningCertificateChanged(X509Certificate2 value)
{
IsSmimeSignatureEnabled = value != null;
}
[RelayCommand]
@@ -213,6 +256,47 @@ public partial class ComposePageViewModel : MailBaseViewModel
var assignedAccount = CurrentMailDraftItem.MailCopy.AssignedAccount;
var sentFolder = await _folderService.GetSpecialFolderByAccountIdAsync(assignedAccount.Id, SpecialFolderType.Sent);
// Load alias certs
var certs = _smimeCertificateService.GetCertificates(emailAddress: SelectedAlias.AliasAddress);
if (IsSmimeSignatureEnabled)
{
var signingCertificate = !string.IsNullOrEmpty(SelectedAlias.SelectedSigningCertificateThumbprint)
? certs.FirstOrDefault(c => c?.Thumbprint == SelectedAlias.SelectedSigningCertificateThumbprint)
: null;
var signer = new CmsSigner(signingCertificate) { DigestAlgorithm = DigestAlgorithm.Sha1 };
if (IsSmimeEncryptionEnabled)
{
var recipients = new CmsRecipientCollection();
var cmsRecipients = CurrentMimeMessage.To.Mailboxes
.Select(mailbox => new CmsRecipient(
_smimeCertificateService.GetCertificates(emailAddress: mailbox.Address).FirstOrDefault() ?? _smimeCertificateService.GetCertificates(StoreName.AddressBook, emailAddress: mailbox.Address).FirstOrDefault()
));
foreach (var recipient in cmsRecipients)
{
recipients.Add(recipient);
}
CurrentMimeMessage.Body = ApplicationPkcs7Mime.SignAndEncrypt(signer, recipients, CurrentMimeMessage.Body);
}
else
{
// CurrentMimeMessage.Body = MultipartSigned.Create(signer, CurrentMimeMessage.Body);
CurrentMimeMessage.Body = ApplicationPkcs7Mime.Sign(signer, CurrentMimeMessage.Body);
}
}
else if (IsSmimeEncryptionEnabled)
{
// var encryptionCertificate = !string.IsNullOrEmpty(SelectedAlias.SelectedEncryptionCertificateThumbprint)
// ? certs.FirstOrDefault(c => c?.Thumbprint == SelectedAlias.SelectedEncryptionCertificateThumbprint)
// : null;
// Encrypt the message if encryption certificate is selected.
CurrentMimeMessage.Body = ApplicationPkcs7Mime.Encrypt(CurrentMimeMessage.To.Mailboxes, CurrentMimeMessage.Body);
}
using MemoryStream memoryStream = new();
CurrentMimeMessage.WriteTo(FormatOptions.Default, memoryStream);
byte[] buffer = memoryStream.GetBuffer();
@@ -11,6 +11,7 @@ using CommunityToolkit.Mvvm.Messaging;
using MailKit;
using MimeKit;
using MimeKit.Cryptography;
using Serilog;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
@@ -62,7 +63,11 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
public bool ShouldDisplayDownloadProgress => IsIndetermineProgress || (CurrentDownloadPercentage > 0 && CurrentDownloadPercentage <= 100);
public bool CanUnsubscribe => CurrentRenderModel?.UnsubscribeInfo?.CanUnsubscribe ?? false;
public bool IsSmimeSigned => (CurrentRenderModel?.Signatures?.Count ?? 0) > 0;
public bool IsSmimeEncrypted => CurrentRenderModel?.IsSmimeEncrypted ?? false;
public bool IsJunkMail => initializedMailItemViewModel?.MailCopy.AssignedFolder != null && initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk;
public bool SmimeSignaturesValid => CurrentRenderModel?.Signatures?.Any(x => x.Value) ?? false;
public bool SmimeSignaturesInvalid => !SmimeSignaturesValid;
public bool IsImageRenderingDisabled
{
@@ -102,6 +107,10 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanUnsubscribe))]
[NotifyPropertyChangedFor(nameof(IsSmimeSigned))]
[NotifyPropertyChangedFor(nameof(IsSmimeEncrypted))]
[NotifyPropertyChangedFor(nameof(SmimeSignaturesValid))]
[NotifyPropertyChangedFor(nameof(SmimeSignaturesInvalid))]
public partial MailRenderModel CurrentRenderModel { get; set; }
[ObservableProperty]
@@ -800,6 +809,72 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
});
}
[RelayCommand]
private async Task ShowSmimeSigningCertificateInfoAsync()
{
if (IsSmimeSigned)
{
MimePart signaturePart;
if (initializedMimeMessageInformation?.MimeMessage?.Body is MultipartSigned signed && signed[1] is MimePart signaturePart1)
{
signaturePart = signaturePart1;
}
else if (initializedMimeMessageInformation?.MimeMessage?.Body is ApplicationPkcs7Mime pkcs7)
{
signaturePart = null;
}
else
{
//_dialogService.InfoBarMessage(Translator.Info_SmimeSignatureNotFoundTitle, Translator.Info_SmimeSignatureNotFoundMessage, InfoBarMessageType.Error);
return;
}
string info = $"{Translator.SmimeSignaturesInMessage}:\n";
foreach (var (signature, valid) in CurrentRenderModel.Signatures)
{
info += string.Format(Translator.SmimeSignatureEntry, valid ? "✅" : "❌", signature.SignerCertificate.Name, signature.SignerCertificate.Fingerprint, signature.SignerCertificate.CreationDate, signature.SignerCertificate.ExpirationDate);
}
await ShowSmimeCertificateInfoAsync(signaturePart, info, Translator.SmimeSigningCertificateInfoTitle);
}
}
private async Task ShowSmimeCertificateInfoAsync(MimePart certificateAttachment, string additionalInfo = "", string title = null)
{
{
if (certificateAttachment == null)
{
await _dialogService.ShowConfirmationDialogAsync(
$"{additionalInfo}\n{Translator.SmimeNoCertificateFileFound}", title ?? Translator.SmimeCertificateInfoTitle, Translator.Buttons_OK);
return;
}
var fileName = certificateAttachment.FileName ?? "smime.p7s";
var contentType = certificateAttachment.ContentType?.MimeType ?? "application/pkcs7-signature";
var size = certificateAttachment.Content?.Stream?.Length ?? 0;
var info = string.Format(Translator.SmimeCertificateFileInfo, fileName, contentType, size);
var result = await _dialogService.ShowConfirmationDialogAsync(
$"{additionalInfo}\n{info}", title ?? Translator.SmimeCertificateInfoTitle,
Translator.SmimeSaveCertificate);
if (result)
{
var pickedPath = await _dialogService.PickFilePathAsync(fileName);
if (!string.IsNullOrEmpty(pickedPath))
{
var pickedDirectory = Path.GetDirectoryName(pickedPath);
var pickedFileName = Path.GetFileName(pickedPath);
await using (var stream = await _fileService.GetFileStreamAsync(pickedDirectory, pickedFileName))
{
await certificateAttachment.Content!.DecodeToAsync(stream);
}
_dialogService.InfoBarMessage(Translator.SmimeCertificate, string.Format(Translator.SmimeCertificateSavedTo, pickedPath),
InfoBarMessageType.Success);
}
}
}
}
protected override void RegisterRecipients()
{
base.RegisterRecipients();
@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Common;
namespace Wino.Mail.ViewModels;
public partial class SignatureAndEncryptionPageViewModel : MailBaseViewModel
{
private readonly ISmimeCertificateService _smimeCertificateService;
private readonly IDialogServiceBase _dialogService;
private readonly IFileService _fileService;
public ObservableCollection<X509Certificate2> PersonalCertificates { get; } = [];
public ObservableCollection<X509Certificate2> RecipientCertificates { get; } = [];
public List<X509Certificate2> SelectedPersonalCertificates { get; } = [];
public List<X509Certificate2> SelectedRecipientCertificates { get; } = [];
public bool PersonalCertificatesEmpty => PersonalCertificates.Count == 0;
public SignatureAndEncryptionPageViewModel(
IDialogServiceBase dialogService,
ISmimeCertificateService smimeCertificateService,
IFileService fileService
)
{
_dialogService = dialogService;
_fileService = fileService;
_smimeCertificateService = smimeCertificateService;
PersonalCertificates.CollectionChanged += (s, e) => { OnPropertyChanged(nameof(PersonalCertificatesEmpty)); };
LoadAllCertificates();
}
private void LoadAllCertificates()
{
PersonalCertificates.Clear();
var personalCerts = _smimeCertificateService.GetCertificates();
foreach (var cert in personalCerts)
{
PersonalCertificates.Add(cert);
}
// Recipient certificates
RecipientCertificates.Clear();
var recipientCerts = _smimeCertificateService.GetCertificates(storeName: StoreName.AddressBook);
foreach (var cert in recipientCerts)
{
RecipientCertificates.Add(cert);
}
}
[RelayCommand]
public async Task ImportPersonalCertificatesAsync()
{
await ImportCertificates(StoreName.My);
}
[RelayCommand]
public async Task ImportRecipientCertificatesAsync()
{
await ImportCertificates(StoreName.AddressBook);
}
private async Task ImportCertificates(StoreName storeName)
{
var files = await PickCertificateFilesAsync();
var failedImports = new List<string>();
var successCount = 0;
foreach (var file in files)
{
string password = null;
if (file.FileExtension.Equals(".pfx") || file.FileExtension.Equals(".p12"))
{
password = await PromptForPasswordAsync(file.FileName);
}
try
{
_smimeCertificateService.ImportCertificate(file.FileExtension, file.Data, password,
storeName: storeName);
successCount++;
}
catch (Exception ex)
{
failedImports.Add($"{file.FileName}: {ex.Message}");
}
}
LoadAllCertificates();
if (successCount > 0)
{
_dialogService.InfoBarMessage(
string.Format(Translator.Smime_ImportCertificates_Success),
Translator.GeneralTitle_Info,
InfoBarMessageType.Success);
}
if (failedImports.Count > 0)
{
await _dialogService.ShowMessageAsync(
$"{Translator.Smime_ImportCertificates_Error}\n\n{string.Join("\n", failedImports)}",
Translator.GeneralTitle_Warning,
Core.Domain.Enums.WinoCustomMessageDialogIcon.Warning);
}
}
[RelayCommand]
public async Task RemovePersonalCertificatesAsync()
{
await RemoveCertificatesAsync(SelectedPersonalCertificates, StoreName.My);
}
[RelayCommand]
public async Task RemoveRecipientCertificatesAsync()
{
await RemoveCertificatesAsync(SelectedRecipientCertificates, StoreName.AddressBook);
}
private async Task RemoveCertificatesAsync(List<X509Certificate2> certificates, StoreName storeName)
{
if (certificates.Any())
{
var confirm = await ConfirmAsync(string.Format(Translator.Smime_RemoveCertificates_Confirm,
string.Join(", ", certificates.Select(cert => cert.Subject))));
if (confirm)
{
foreach (var cert in certificates)
{
_smimeCertificateService.RemoveCertificate(cert.Thumbprint, storeName: storeName);
}
LoadAllCertificates();
_dialogService.InfoBarMessage(
Translator.Smime_RemoveCertificates_Success,
Translator.GeneralTitle_Info,
InfoBarMessageType.Success
);
}
}
}
[RelayCommand]
public async Task ExportPersonalCertificatesAsync()
{
await ExportCertificatesAsync(SelectedPersonalCertificates);
}
[RelayCommand]
public async Task ExportRecipientCertificatesAsync()
{
await ExportCertificatesAsync(SelectedRecipientCertificates);
}
// Export logic for .cer or .pem
private async Task ExportCertificatesAsync(IEnumerable<X509Certificate2> cert)
{
var failedExports = new List<string>();
var successCount = 0;
foreach (var certificate in cert)
{
var fileName = $"{certificate.Subject.Replace("CN=", "")}.cer";
var path = await _dialogService.PickFilePathAsync(fileName);
if (path != null)
{
var folderPath = System.IO.Path.GetDirectoryName(path);
await using var stream = await _fileService.GetFileStreamAsync(folderPath, fileName);
if (stream != null)
{
try
{
var certificateData = certificate.Export(X509ContentType.Cert);
await stream.WriteAsync(certificateData, 0, certificateData.Length);
await stream.FlushAsync();
successCount++;
}
catch (Exception ex)
{
failedExports.Add($"{certificate.Subject}: {ex.Message}");
}
}
else
{
failedExports.Add($"{certificate.Subject}: File stream error");
}
}
}
if (successCount > 0)
{
_dialogService.InfoBarMessage(
Translator.Smime_ExportCertificates_Success,
Translator.GeneralTitle_Info,
InfoBarMessageType.Success
);
}
if (failedExports.Count > 0)
{
await _dialogService.ShowMessageAsync(
$"{Translator.Smime_ExportCertificates_Error}\n\n{string.Join("\n", failedExports)}",
Translator.GeneralTitle_Warning,
Core.Domain.Enums.WinoCustomMessageDialogIcon.Warning);
}
}
private async Task ShowCertificateDetailsAsync(X509Certificate2 cert)
{
var details = string.Format(Translator.Smime_CertificateDetails, cert.Subject, cert.Issuer, cert.NotBefore,
cert.NotAfter, cert.Thumbprint);
await _dialogService.ShowMessageAsync(details, Translator.GeneralTitle_Info,
Core.Domain.Enums.WinoCustomMessageDialogIcon.Information);
}
// Confirmation dialog
private async Task<bool> ConfirmAsync(string message)
{
return await _dialogService.ShowConfirmationDialogAsync(message, Translator.Smime_Confirm_Title,
Translator.Buttons_Yes);
}
// File picker for importing certificates
private async Task<List<SharedFile>> PickCertificateFilesAsync()
{
return await _dialogService.PickFilesAsync(".pfx", ".p12", ".cer", ".crt");
}
// Ask for password for .pfx/.p12
private async Task<string> PromptForPasswordAsync(string fileName)
{
return await _dialogService.ShowTextInputDialogAsync("",
Translator.Smime_CertificatePassword_Title,
string.Format(Translator.Smime_CertificatePassword_Placeholder, fileName), Translator.Buttons_OK);
}
}
@@ -13,6 +13,7 @@
<PackageReference Include="EmailValidation" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Sentry.Serilog" />
<PackageReference Include="MimeKit" />
<PackageReference Include="System.Reactive" />
</ItemGroup>
<ItemGroup>
+4 -2
View File
@@ -8,15 +8,15 @@ using Microsoft.Toolkit.Uwp.Notifications;
using Microsoft.UI.Xaml;
using Microsoft.Windows.AppLifecycle;
using Microsoft.Windows.AppNotifications;
using MimeKit.Cryptography;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Mail.WinUI;
using Wino.Mail.WinUI.Interfaces;
using Wino.Mail.Services;
using Wino.Mail.ViewModels;
using Wino.Mail.WinUI.Interfaces;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.Server;
using Wino.Services;
@@ -31,6 +31,7 @@ public partial class App : WinoApplication, IRecipient<NewMailSynchronizationReq
InitializeComponent();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
CryptographyContext.Register(typeof(WindowsSecureMimeContext));
RegisterRecipients();
}
@@ -83,6 +84,7 @@ public partial class App : WinoApplication, IRecipient<NewMailSynchronizationReq
services.AddTransient(typeof(AppPreferencesPageViewModel));
services.AddTransient(typeof(AliasManagementPageViewModel));
services.AddTransient(typeof(ContactsPageViewModel));
services.AddTransient(typeof(SignatureAndEncryptionPageViewModel));
}
#endregion
+3
View File
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Wino.Core.Domain.Interfaces;
using Wino.Core.ViewModels;
using Wino.Core.WinUI.Services;
using Wino.Mail.WinUI.Services;
using Wino.Services;
@@ -19,6 +20,8 @@ public static class CoreUWPContainerSetup
services.AddSingleton<IPreferencesService, PreferencesService>();
services.AddSingleton<INewThemeService, NewThemeService>();
services.AddSingleton<IStatePersistanceService, StatePersistenceService>();
services.AddSingleton<ISmimeCertificateService, SmimeCertificateService>();
services.AddSingleton<IThumbnailService, ThumbnailService>();
services.AddSingleton<IDialogServiceBase, DialogServiceBase>();
services.AddTransient<IConfigurationService, ConfigurationService>();
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
@@ -15,9 +16,9 @@ using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Common;
using Wino.Core.Domain.Models.Printing;
using Wino.Dialogs;
using Wino.Mail.WinUI.Dialogs;
using Wino.Mail.WinUI.Extensions;
using Wino.Dialogs;
using Wino.Messaging.Client.Shell;
using WinRT.Interop;
@@ -48,7 +49,7 @@ public class DialogServiceBase : IDialogServiceBase
{
var picker = new FolderPicker()
{
SuggestedStartLocation = PickerLocationId.Desktop
SuggestedStartLocation = PickerLocationId.Desktop,
};
picker.FileTypeFilter.Add("*");
@@ -61,7 +62,7 @@ public class DialogServiceBase : IDialogServiceBase
StorageApplicationPermissions.FutureAccessList.Add(folder);
return folder.Path;
return $"{Path.Combine(folder.Path, saveFileName)}";
//var picker = new FileSavePicker
//{
@@ -61,6 +61,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.EditAccountDetailsPage => typeof(EditAccountDetailsPage),
WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage),
WinoPage.ContactsPage => typeof(ContactsPage),
WinoPage.SignatureAndEncryptionPage => typeof(SignatureAndEncryptionPage),
_ => null,
};
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.WinUI.Services;
public class SmimeCertificateService : ISmimeCertificateService
{
private const string CertificateFriendlyName = "Wino Mail Certificate";
/// <summary>
/// Retrieves all personal certificates from the current user's certificate store.
/// </summary>
/// <remarks>This method enumerates certificates in the current user's "My" certificate store that have a
/// private key and at least one extension. The store is opened in read-only mode.</remarks>
/// <returns>An enumerable collection of <see cref="X509Certificate2"/> objects representing the personal certificates that
/// meet the specified criteria. If no matching certificates are found, the collection will be empty.</returns>
public IEnumerable<X509Certificate2> GetCertificates(StoreName storeName = StoreName.My, StoreLocation storeLocation = StoreLocation.CurrentUser, string emailAddress = null)
{
using var store = new X509Store(storeName, storeLocation);
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.Where(cert => cert.FriendlyName == CertificateFriendlyName);
return emailAddress != null ? certs.Where(cert => cert.Subject.Contains(emailAddress, StringComparison.OrdinalIgnoreCase)) : certs;
}
public void ImportCertificate(string fileExtension, byte[] rawData, string password = null, StoreName storeName = StoreName.My, StoreLocation storeLocation = StoreLocation.CurrentUser)
{
X509Certificate2Collection collection = [];
if (fileExtension is ".p12" or ".pfx")
{
collection.AddRange(X509CertificateLoader.LoadPkcs12Collection(rawData, password, X509KeyStorageFlags.DefaultKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable));
} else
{
collection.Add(X509CertificateLoader.LoadCertificate(rawData));
}
foreach (var cert in collection)
{
cert.FriendlyName = CertificateFriendlyName;
}
using var store = new X509Store(storeName, storeLocation);
store.Open(OpenFlags.ReadWrite);
store.AddRange(collection);
store.Close();
}
public void RemoveCertificate(string thumbprint, StoreName storeName = StoreName.My, StoreLocation storeLocation = StoreLocation.CurrentUser)
{
using var store = new X509Store(storeName, storeLocation);
store.Open(OpenFlags.ReadWrite);
var cert = store.Certificates.FirstOrDefault(c => c.Thumbprint == thumbprint);
if (cert != null)
{
store.Remove(cert);
}
}
}
@@ -0,0 +1,8 @@
using Wino.Mail.ViewModels;
using Wino.Mail.WinUI;
namespace Wino.Views.Abstract;
public abstract class SignatureAndEncryptionPageAbstract : BasePage<SignatureAndEncryptionPageViewModel>
{
}
File diff suppressed because one or more lines are too long
@@ -179,6 +179,8 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
@@ -205,6 +207,48 @@
<TextBlock VerticalAlignment="Center" Text="{x:Bind domain:Translator.Unsubscribe}" />
</StackPanel>
</HyperlinkButton>
<!-- S/MIME Signed Email Indicator -->
<HyperlinkButton
Grid.Column="2"
VerticalAlignment="Center"
Margin="0,0,0,4"
ToolTipService.ToolTip="{x:Bind domain:Translator.SmimeSignedTooltip}"
Visibility="{x:Bind ViewModel.IsSmimeSigned, Mode=OneWay}"
Command="{x:Bind ViewModel.ShowSmimeSigningCertificateInfoCommand}">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Width="Auto" Height="Auto">
<Viewbox Width="16" Height="16" Margin="0,0,4,0">
<PathIcon
Data="M15 18c.835.629 1.875 1.001 3 1.001a4.978 4.978 0 0 0 3-.999v3.246a.75.75 0 0 1-1.09.67l-.09-.055L18 20.591l-1.82 1.272a.75.75 0 0 1-1.172-.51l-.007-.105L15 18.001Zm4.25-14.996a2.75 2.75 0 0 1 2.745 2.582l.005.168.001 5.246a5.027 5.027 0 0 0-1.5-1.331L20.5 5.754a1.25 1.25 0 0 0-1.122-1.244l-.128-.006H4.75a1.25 1.25 0 0 0-1.244 1.122l-.006.128v9.5c0 .647.492 1.18 1.122 1.243l.128.007h8.92c.1.172.21.338.33.496v1.004H4.75a2.75 2.75 0 0 1-2.745-2.583L2 15.254v-9.5a2.75 2.75 0 0 1 2.582-2.745l.168-.005h14.5ZM18 10A4 4 0 1 1 18 18 4 4 0 0 1 18 10Zm-6.75 2.5a.75.75 0 0 1 .102 1.493L11.25 14h-4.5a.75.75 0 0 1-.102-1.493l.102-.007h4.5Zm6-5.5a.75.75 0 0 1 .102 1.493l-.102.007H6.75a.75.75 0 0 1-.102-1.493L6.75 7h10.5Z" />
</Viewbox>
<muxc:InfoBadge
Visibility="{x:Bind ViewModel.SmimeSignaturesInvalid, Mode=OneWay}"
Width="8"
Height="8"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Style="{ThemeResource CautionIconInfoBadgeStyle}"/>
<muxc:InfoBadge
Visibility="{x:Bind ViewModel.SmimeSignaturesValid, Mode=OneWay}"
Width="8"
Height="8"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Style="{ThemeResource SuccessIconInfoBadgeStyle}"/>
</Grid>
</HyperlinkButton>
<!-- S/MIME Encrypted Email Indicator -->
<Viewbox Width="16" Height="16"
Grid.Column="3"
VerticalAlignment="Center"
Margin="0,0,0,4"
ToolTipService.ToolTip="{x:Bind domain:Translator.SmimeEncryptedTooltip}"
Visibility="{x:Bind ViewModel.IsSmimeEncrypted, Mode=OneWay}">
<PathIcon
Data="M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z" />
</Viewbox>
</Grid>
<CommandBar
x:Name="RendererBar"
@@ -24,7 +24,9 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
<ColumnDefinition Width="50" />
<ColumnDefinition Width="50" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid VerticalAlignment="Center">
@@ -65,8 +67,26 @@
CommandParameter="{x:Bind}"
IsChecked="{x:Bind IsPrimary, Mode=OneWay}" />
<Button
<ComboBox
Grid.Column="3"
HorizontalAlignment="Center"
DisplayMemberPath="Subject"
DropDownClosed="SigningCertificateDropDownClosed"
IsEnabled="{x:Bind IsSmimeEncryptionEnabled}"
ItemsSource="{x:Bind Certificates, Mode=OneWay}"
PlaceholderText="{x:Bind domain:Translator.SettingsSignatureAndEncryption_SigningCertificatePlaceholder}"
SelectedItem="{Binding SelectedSigningCertificate, Mode=TwoWay}" />
<CheckBox
Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Click="SmimeClicked"
IsChecked="{x:Bind IsSmimeEncryptionEnabled, Mode=OneTime}" />
<Button
Grid.Column="5"
HorizontalAlignment="Right"
Click="DeleteAlias_Click"
CommandParameter="{x:Bind}"
@@ -98,6 +118,8 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
<ColumnDefinition Width="50" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
@@ -173,6 +195,16 @@
Grid.Row="2"
Grid.Column="2"
Text="{x:Bind domain:Translator.AccountAlias_Column_IsPrimaryAlias}" />
<TextBlock
Grid.Row="2"
Grid.Column="3"
Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_SigningCertificate}" />
<TextBlock
Grid.Row="2"
Grid.Column="4"
Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_EncryptionCertificate}" />
</Grid>
</ListView.Header>
</ListView>
@@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Entities.Mail;
using Wino.Views.Abstract;
@@ -26,4 +27,30 @@ public sealed partial class AliasManagementPage : AliasManagementPageAbstract
ViewModel.DeleteAliasCommand.Execute(alias);
}
}
private async void SigningCertificateDropDownClosed(object sender, object e)
{
var (alias, cert) = GetAliasAndSelectedCertificateForCombobox(sender);
if (alias is not null)
{
await ViewModel.SetSelectedSigningCertificate(alias, cert);
}
}
private static (MailAccountAlias alias, X509Certificate2 cert) GetAliasAndSelectedCertificateForCombobox(object sender)
{
var comboBox = sender as ComboBox;
var alias = comboBox?.DataContext as MailAccountAlias;
var selected = comboBox?.SelectedItem as X509Certificate2;
return (alias, selected);
}
private async void SmimeClicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
var checkBox = sender as CheckBox;
if (checkBox?.DataContext is MailAccountAlias alias)
{
await ViewModel.SetAliasSmimeEncryption(alias, checkBox.IsChecked ?? false);
}
}
}
@@ -0,0 +1,184 @@
<abstract:SignatureAndEncryptionPageAbstract
x:Class="Wino.Views.Settings.SignatureAndEncryptionPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer>
<StackPanel Spacing="{StaticResource SettingsCardSpacing}">
<StackPanel.ChildrenTransitions>
<TransitionCollection>
<RepositionThemeTransition IsStaggeringEnabled="False" />
</TransitionCollection>
</StackPanel.ChildrenTransitions>
<controls:SettingsExpander
Description="{x:Bind domain:Translator.SettingsSignatureAndEncryption_MyCertificatesDescription, Mode=OneWay}"
Header="{x:Bind domain:Translator.SettingsSignatureAndEncryption_MyCertificatesHeader, Mode=OneWay}"
IsExpanded="True">
<controls:SettingsExpander.Items>
<controls:SettingsCard>
<!--TODO: Not working
<TextBlock Visibility="{Binding ViewModel.PersonalCertificatesEmpty, Mode=OneWay}">
<Run Text="There are no items." />
</TextBlock>-->
<ListView
x:Name="PersonalCertList"
MaxHeight="180"
ItemsSource="{x:Bind ViewModel.PersonalCertificates, Mode=OneWay}"
SelectionChanged="PersonalCertList_SelectionChanged"
SelectionMode="Multiple">
<ListView.Header>
<Grid Padding="16,12" ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="26" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="1"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_NameColumn, Mode=OneWay}" />
<TextBlock
Grid.Column="2"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_ExpiresColumn, Mode=OneWay}" />
<TextBlock
Grid.Column="3"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_ThumbprintColumn, Mode=OneWay}" />
</Grid>
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate>
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Text="{Binding Subject}" />
<TextBlock
Grid.Column="2"
FontSize="10"
Foreground="Gray"
Text="{Binding NotAfter}" />
<TextBlock
Grid.Column="3"
FontSize="10"
Foreground="Gray"
Text="{Binding Thumbprint}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
</ListView.ItemContainerStyle>
</ListView>
</controls:SettingsCard>
<controls:SettingsCard>
<StackPanel Orientation="Horizontal" Spacing="12">
<Button Command="{x:Bind ViewModel.RemovePersonalCertificatesCommand}" Content="{x:Bind domain:Translator.Buttons_Remove}" />
<Button Command="{x:Bind ViewModel.ExportPersonalCertificatesCommand}" Content="{x:Bind domain:Translator.Buttons_Export}" />
<Button Command="{x:Bind ViewModel.ImportPersonalCertificatesCommand}" Content="{x:Bind domain:Translator.Buttons_Import}" />
</StackPanel>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
<controls:SettingsExpander
Description="{x:Bind domain:Translator.SettingsSignatureAndEncryption_RecipientCertificatesDescription, Mode=OneWay}"
Header="{x:Bind domain:Translator.SettingsSignatureAndEncryption_RecipientCertificatesHeader, Mode=OneWay}"
IsExpanded="False">
<controls:SettingsExpander.Items>
<!--<StackPanel Padding="12" Spacing="12">-->
<!--TODO: Not working
<TextBlock Visibility="{Binding ViewModel.RecipientCertificatesEmpty, Mode=OneWay}">
<Run Text="There are no items." />
</TextBlock>-->
<controls:SettingsCard>
<ListView
x:Name="RecipientCertList"
Height="180"
ItemsSource="{x:Bind ViewModel.RecipientCertificates, Mode=OneWay}"
SelectionChanged="RecipientCertList_SelectionChanged"
SelectionMode="Multiple">
<ListView.Header>
<Grid Padding="16,12" ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="26" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="1"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_NameColumn, Mode=OneWay}" />
<TextBlock
Grid.Column="2"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_ExpiresColumn, Mode=OneWay}" />
<TextBlock
Grid.Column="3"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind domain:Translator.SettingsSignatureAndEncryption_ThumbprintColumn, Mode=OneWay}" />
</Grid>
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate>
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Text="{Binding Subject}" />
<TextBlock
Grid.Column="2"
FontSize="10"
Foreground="Gray"
Text="{Binding NotAfter}" />
<TextBlock
Grid.Column="3"
FontSize="10"
Foreground="Gray"
Text="{Binding Thumbprint}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
</ListView.ItemContainerStyle>
</ListView>
</controls:SettingsCard>
<controls:SettingsCard>
<StackPanel
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="12">
<Button Command="{x:Bind ViewModel.RemoveRecipientCertificatesCommand}" Content="{x:Bind domain:Translator.Buttons_Remove}" />
<Button Command="{x:Bind ViewModel.ExportRecipientCertificatesCommand}" Content="{x:Bind domain:Translator.Buttons_Export}" />
<Button Command="{x:Bind ViewModel.ImportRecipientCertificatesCommand}" Content="{x:Bind domain:Translator.Buttons_Import}" />
</StackPanel>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
</StackPanel>
</ScrollViewer>
</abstract:SignatureAndEncryptionPageAbstract>
@@ -0,0 +1,34 @@
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using Microsoft.UI.Xaml.Controls;
using Wino.Views.Abstract;
namespace Wino.Views.Settings;
public sealed partial class SignatureAndEncryptionPage : SignatureAndEncryptionPageAbstract
{
public SignatureAndEncryptionPage()
{
InitializeComponent();
}
private void PersonalCertList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
foreach (var item in e.RemovedItems.OfType<X509Certificate2>())
{
ViewModel.SelectedPersonalCertificates.Remove(item);
}
ViewModel.SelectedPersonalCertificates.AddRange(e.AddedItems.OfType<X509Certificate2>());
}
private void RecipientCertList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
foreach (var item in e.RemovedItems.OfType<X509Certificate2>())
{
ViewModel.SelectedRecipientCertificates.Remove(item);
}
ViewModel.SelectedRecipientCertificates.AddRange(e.AddedItems.OfType<X509Certificate2>());
}
}
+16 -1
View File
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MimeKit;
using MimeKit.Cryptography;
using Serilog;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
@@ -147,12 +148,26 @@ public class MimeFileService : IMimeFileService
var renderingModel = new MailRenderModel(finalRenderHtml, options);
// Create attachments.
renderingModel.Signatures = visitor.Signatures;
// S/MIME encryption detection: if the body is ApplicationPkcs7Mime and SecureMimeType is EnvelopedData
renderingModel.IsSmimeEncrypted = message.Body is ApplicationPkcs7Mime encrypted &&
encrypted.SecureMimeType == SecureMimeType.EnvelopedData;
// Create attachments.
foreach (var attachment in visitor.Attachments)
{
if (attachment.IsAttachment && attachment is MimePart attachmentPart)
{
// Exclude S/MIME encryption/decryption certificates
var contentType = attachmentPart.ContentType?.MimeType?.ToLowerInvariant();
var fileName = attachmentPart.FileName?.ToLowerInvariant();
if ((contentType == "application/pkcs7-signature"
|| contentType == "application/x-pkcs7-signature"
&& fileName == "smime.p7s") || (contentType == "application/pkcs7-mime"
|| contentType == "application/x-pkcs7-mime"
&& fileName == "smime.p7m"))
continue;
renderingModel.Attachments.Add(attachmentPart);
}
}