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:
committed by
GitHub
parent
1a2590e2c3
commit
beb3bf9d1d
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -132,19 +141,19 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
|
||||
public IPrintService PrintService { get; }
|
||||
|
||||
public MailRenderingPageViewModel(IMailDialogService dialogService,
|
||||
INativeAppService nativeAppService,
|
||||
IUnderlyingThemeService underlyingThemeService,
|
||||
IMimeFileService mimeFileService,
|
||||
IMailService mailService,
|
||||
IFileService fileService,
|
||||
IWinoRequestDelegator requestDelegator,
|
||||
IStatePersistanceService statePersistenceService,
|
||||
IContactService contactService,
|
||||
IClipboardService clipboardService,
|
||||
IUnsubscriptionService unsubscriptionService,
|
||||
IPreferencesService preferencesService,
|
||||
IPrintService printService,
|
||||
IApplicationConfiguration applicationConfiguration)
|
||||
INativeAppService nativeAppService,
|
||||
IUnderlyingThemeService underlyingThemeService,
|
||||
IMimeFileService mimeFileService,
|
||||
IMailService mailService,
|
||||
IFileService fileService,
|
||||
IWinoRequestDelegator requestDelegator,
|
||||
IStatePersistanceService statePersistenceService,
|
||||
IContactService contactService,
|
||||
IClipboardService clipboardService,
|
||||
IUnsubscriptionService unsubscriptionService,
|
||||
IPreferencesService preferencesService,
|
||||
IPrintService printService,
|
||||
IApplicationConfiguration applicationConfiguration)
|
||||
{
|
||||
_dialogService = dialogService;
|
||||
NativeAppService = nativeAppService;
|
||||
@@ -716,8 +725,8 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
|
||||
if (isSaved)
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.Info_PDFSaveSuccessTitle,
|
||||
string.Format(Translator.Info_PDFSaveSuccessMessage, pdfFilePath),
|
||||
InfoBarMessageType.Success);
|
||||
string.Format(Translator.Info_PDFSaveSuccessMessage, pdfFilePath),
|
||||
InfoBarMessageType.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user