From c292a04ba79d78b8d330e9cbb236cb6d8fe22be7 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 7 Feb 2026 16:25:50 +0100 Subject: [PATCH] Add Enpass import option (#1643) --- .../Components/ImportServiceEnpass.razor | 25 ++ .../Settings/ImportExport/ImportExport.razor | 1 + .../ImportExport/ImportServices.en.resx | 13 + .../wwwroot/img/importers/enpass.svg | 12 + .../AliasVault.UnitTests.csproj | 1 + .../TestData/Exports/enpass.csv | 5 + .../Utilities/ImportExportTests.cs | 107 +++++++ .../Importers/EnpassImporter.cs | 298 ++++++++++++++++++ 8 files changed, 462 insertions(+) create mode 100644 apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceEnpass.razor create mode 100644 apps/server/AliasVault.Client/wwwroot/img/importers/enpass.svg create mode 100644 apps/server/Tests/AliasVault.UnitTests/TestData/Exports/enpass.csv create mode 100644 apps/server/Utilities/AliasVault.ImportExport/Importers/EnpassImporter.cs diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceEnpass.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceEnpass.razor new file mode 100644 index 000000000..b11c2d7e1 --- /dev/null +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceEnpass.razor @@ -0,0 +1,25 @@ +@using AliasVault.ImportExport.Models +@using AliasVault.ImportExport.Importers +@using Microsoft.Extensions.Localization +@inject NavigationManager NavigationManager +@inject GlobalNotificationService GlobalNotificationService +@inject IStringLocalizerFactory LocalizerFactory +@inject ILogger Logger + + +

@Localizer["EnpassInstructionsPart1"]

+

@Localizer["EnpassInstructionsPart2"]

+
+ +@code { + private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Main.Settings.ImportExport.ImportServices", "AliasVault.Client"); + + private static async Task> ProcessFile(string fileContents) + { + return await EnpassImporter.ImportFromCsvAsync(fileContents); + } +} diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor index 0d053e296..e524522ce 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor @@ -28,6 +28,7 @@ + diff --git a/apps/server/AliasVault.Client/Resources/Components/Main/Settings/ImportExport/ImportServices.en.resx b/apps/server/AliasVault.Client/Resources/Components/Main/Settings/ImportExport/ImportServices.en.resx index 76ed09caa..367812d7a 100644 --- a/apps/server/AliasVault.Client/Resources/Components/Main/Settings/ImportExport/ImportServices.en.resx +++ b/apps/server/AliasVault.Client/Resources/Components/Main/Settings/ImportExport/ImportServices.en.resx @@ -274,6 +274,19 @@ Once you have exported the file, you can upload it below. Edge export instructions part 2 + + + Import passwords from Enpass + Description for Enpass import service + + + In order to import your Enpass passwords, you need to export them as a CSV file. You can do this by opening Enpass, going to 'Menu' > 'File' > 'Export' and selecting 'CSV file (.csv)'. + Enpass export instructions part 1 + + + Once you have exported the file, you can upload it below. + Enpass export instructions part 2 + Once you have exported the file, you can upload it below. diff --git a/apps/server/AliasVault.Client/wwwroot/img/importers/enpass.svg b/apps/server/AliasVault.Client/wwwroot/img/importers/enpass.svg new file mode 100644 index 000000000..6850c5094 --- /dev/null +++ b/apps/server/AliasVault.Client/wwwroot/img/importers/enpass.svg @@ -0,0 +1,12 @@ + + + diff --git a/apps/server/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj b/apps/server/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj index d4597f840..30da00a22 100644 --- a/apps/server/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj +++ b/apps/server/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj @@ -76,6 +76,7 @@ + diff --git a/apps/server/Tests/AliasVault.UnitTests/TestData/Exports/enpass.csv b/apps/server/Tests/AliasVault.UnitTests/TestData/Exports/enpass.csv new file mode 100644 index 000000000..2c5a1b14c --- /dev/null +++ b/apps/server/Tests/AliasVault.UnitTests/TestData/Exports/enpass.csv @@ -0,0 +1,5 @@ +"Credit Card","Cardholder","ccholder","Type","cctype","Number","1234123412341234","*CVC","1234","*PIN","1234","Expiry date","12/28","INTERNET BANKING","","Username","","*Login password","","*Transaction password","","Website","","ADDITIONAL DETAILS","","Issuing bank","Issuingbank","Issued on","01/20","Valid from","01/20","Credit limit","5000","Withdrawal limit","5000","Interest rate","10","If lost, call","" +"Google","Username","usergoogle","E-mail","email@email.com","*Password","password","Website","https://accounts.google.com/","ADDITIONAL DETAILS","","Phone number","003112345678","One-time code","PLW4SB3PQ7MKVXY2MXF4NEXS6Y","Security question","secquestion","*Security answer","secanswer","Secnotes here" +"Identity","Initial","J","First name","John","Middle name","middle","Last name","Johnson","Gender","Male","Birth date","01-01-1970","Social Security Number","123123123","HOME ADDRESS","","Street","Street","City","City","State","State","Country","Country","ZIP","ZIP","CONTACT DETAILS","","Phone number","003112345678","Phone home","","Phone work","","WORK DETAILS","","Occupation","Occupation","Company","Company","Department","Department","Job title","Job title","Organization","Organization","WORK ADDRESS","","Street","","City","","State","","Country","","ZIP","","SOCIAL","","Twitter","","Facebook","","LinkedIn","","Skype","","Instagram","","Yahoo","","MSN","","ICQ","","AIM","","ADDITIONAL DETAILS","","Website","","Username","","E-mail","","*Password","","Secret question","","*Secret answer","","Signature","" +"Password","Login","loginpw1","*Password","password","Access","AccessField","CustomField1","Customfield" +"Securenote","Note only content here" diff --git a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs index dd1d170a0..be73d060a 100644 --- a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs +++ b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs @@ -1314,6 +1314,113 @@ public class ImportExportTests }); } + /// + /// Test case for importing credentials from Enpass CSV and ensuring all values are present. + /// Enpass uses a unique format with alternating key-value pairs instead of headers. + /// + /// Async task. + [Test] + public async Task ImportCredentialsFromEnpassCsv() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.enpass.csv"); + + // Act + var importedCredentials = await EnpassImporter.ImportFromCsvAsync(fileContent); + + // Assert - Should import 5 records + Assert.That(importedCredentials, Has.Count.EqualTo(5)); + + // Test credit card + var creditCard = importedCredentials.First(c => c.ServiceName == "Credit Card"); + Assert.Multiple(() => + { + Assert.That(creditCard.ItemType, Is.EqualTo(ImportedItemType.Creditcard)); + Assert.That(creditCard.Creditcard, Is.Not.Null); + Assert.That(creditCard.Creditcard!.CardholderName, Is.EqualTo("ccholder")); + Assert.That(creditCard.Creditcard.Number, Is.EqualTo("1234123412341234")); + Assert.That(creditCard.Creditcard.Cvv, Is.EqualTo("1234")); + Assert.That(creditCard.Creditcard.Pin, Is.EqualTo("1234")); + Assert.That(creditCard.Creditcard.ExpiryMonth, Is.EqualTo("12")); + Assert.That(creditCard.Creditcard.ExpiryYear, Is.EqualTo("28")); + }); + + // Test Google login with TOTP + var googleLogin = importedCredentials.First(c => c.ServiceName == "Google"); + Assert.Multiple(() => + { + Assert.That(googleLogin.ItemType, Is.EqualTo(ImportedItemType.Login)); + Assert.That(googleLogin.Username, Is.EqualTo("usergoogle")); + Assert.That(googleLogin.Email, Is.EqualTo("email@email.com")); + Assert.That(googleLogin.Password, Is.EqualTo("password")); + Assert.That(googleLogin.ServiceUrls?.FirstOrDefault(), Is.EqualTo("https://accounts.google.com/")); + Assert.That(googleLogin.TwoFactorSecret, Is.EqualTo("PLW4SB3PQ7MKVXY2MXF4NEXS6Y")); + Assert.That(googleLogin.Notes, Does.Contain("Security question: secquestion")); + Assert.That(googleLogin.Notes, Does.Contain("Security answer: secanswer")); + }); + + // Test identity + var identity = importedCredentials.First(c => c.ServiceName == "Identity"); + Assert.Multiple(() => + { + Assert.That(identity.ItemType, Is.EqualTo(ImportedItemType.Alias)); + Assert.That(identity.Alias, Is.Not.Null); + Assert.That(identity.Alias!.FirstName, Is.EqualTo("John")); + Assert.That(identity.Alias.LastName, Is.EqualTo("Johnson")); + Assert.That(identity.Alias.Gender, Is.EqualTo("Male")); + Assert.That(identity.Alias.BirthDate, Is.EqualTo(new DateTime(1970, 1, 1))); + }); + + // Test password entry + var passwordEntry = importedCredentials.First(c => c.ServiceName == "Password"); + Assert.Multiple(() => + { + Assert.That(passwordEntry.ItemType, Is.EqualTo(ImportedItemType.Login)); + Assert.That(passwordEntry.Username, Is.EqualTo("loginpw1")); + Assert.That(passwordEntry.Password, Is.EqualTo("password")); + }); + + // Test secure note + var secureNote = importedCredentials.First(c => c.ServiceName == "Securenote"); + Assert.Multiple(() => + { + Assert.That(secureNote.ItemType, Is.EqualTo(ImportedItemType.Note)); + Assert.That(secureNote.Notes, Is.EqualTo("Note only content here")); + }); + } + + /// + /// Test case for Enpass credit card conversion to Item. + /// + /// Async task. + [Test] + public async Task EnpassCreditCardConversion() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.enpass.csv"); + + // Act + var importedCredentials = await EnpassImporter.ImportFromCsvAsync(fileContent); + var creditCardCredential = importedCredentials.First(c => c.ServiceName == "Credit Card"); + var items = BaseImporter.ConvertToItem([creditCardCredential]); + + // Assert + var creditCardItem = items[0]; + Assert.That(creditCardItem.ItemType, Is.EqualTo(ItemType.CreditCard)); + + var cardNumber = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardNumber); + Assert.That(cardNumber?.Value, Is.EqualTo("1234123412341234")); + + var cardholderName = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardCardholderName); + Assert.That(cardholderName?.Value, Is.EqualTo("ccholder")); + + var cardCvv = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardCvv); + Assert.That(cardCvv?.Value, Is.EqualTo("1234")); + + var cardPin = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardPin); + Assert.That(cardPin?.Value, Is.EqualTo("1234")); + } + /// /// Helper method to add a field value to an item. /// diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/EnpassImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/EnpassImporter.cs new file mode 100644 index 000000000..4f50528d8 --- /dev/null +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/EnpassImporter.cs @@ -0,0 +1,298 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.ImportExport.Importers; + +using AliasVault.ImportExport.Models; +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; + +/// +/// Imports credentials from Enpass Password Manager. +/// Enpass uses a unique CSV format where each row contains alternating key-value pairs +/// instead of traditional column headers. +/// +public static class EnpassImporter +{ + /// + /// Imports Enpass CSV file and converts contents to list of ImportedCredential model objects. + /// + /// The content of the CSV file. + /// The imported list of ImportedCredential objects. + public static async Task> ImportFromCsvAsync(string fileContent) + { + var credentials = new List(); + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = false, + BadDataFound = null, + MissingFieldFound = null, + }; + + using var reader = new StringReader(fileContent); + using var csv = new CsvReader(reader, config); + + while (await csv.ReadAsync()) + { + var fields = new List(); + + // Get the parser to access raw field data + var parser = csv.Parser; + var record = parser.Record; + + if (record == null || record.Length < 1) + { + continue; + } + + foreach (var field in record) + { + fields.Add(field ?? string.Empty); + } + + var credential = ParseEnpassRow(fields); + if (credential != null) + { + credentials.Add(credential); + } + } + + return credentials; + } + + /// + /// Parses a single Enpass CSV row into an ImportedCredential. + /// The first field is the item name/type, followed by alternating key-value pairs. + /// + /// The list of fields from the CSV row. + /// The parsed ImportedCredential, or null if parsing fails. + private static ImportedCredential? ParseEnpassRow(List fields) + { + if (fields.Count < 1) + { + return null; + } + + var itemName = fields[0]; + + // Build a dictionary of field name -> field value from alternating pairs + var fieldDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < fields.Count - 1; i += 2) + { + var key = fields[i].Trim(); + var value = fields[i + 1]; + + // Skip empty keys or section headers (all caps with no value) + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) + { + continue; + } + + // Remove leading asterisk from field names (Enpass marks sensitive fields with *) + if (key.StartsWith("*")) + { + key = key.Substring(1); + } + + // Store the first occurrence of each key (some may repeat) + if (!fieldDict.ContainsKey(key)) + { + fieldDict[key] = value; + } + } + + // Determine item type based on name and available fields + var itemType = DetermineItemType(itemName, fieldDict); + + var credential = new ImportedCredential + { + ServiceName = itemName, + ItemType = itemType, + }; + + // Extract common fields + credential.Username = GetFirstMatch(fieldDict, "Username", "Login", "E-mail"); + credential.Password = GetFirstMatch(fieldDict, "Password", "Login password"); + credential.Email = GetFirstMatch(fieldDict, "E-mail", "Email"); + credential.Notes = BuildNotes(fieldDict); + + // Extract URL + var url = GetFirstMatch(fieldDict, "Website", "URL"); + if (!string.IsNullOrWhiteSpace(url)) + { + credential.ServiceUrls = BaseImporter.ParseUrls(url); + } + + // Extract TOTP + credential.TwoFactorSecret = GetFirstMatch(fieldDict, "One-time code", "TOTP", "OTP"); + + // Handle credit card + if (itemType == ImportedItemType.Creditcard) + { + credential.Creditcard = ParseCreditCard(fieldDict); + } + + // Handle identity/alias + if (itemType == ImportedItemType.Alias) + { + credential.Alias = ParseAlias(fieldDict); + } + + // Handle secure note (the second field is the note content if only 2 fields) + if (itemType == ImportedItemType.Note && fields.Count == 2) + { + credential.Notes = fields[1]; + } + + return credential; + } + + /// + /// Determines the item type based on the item name and available fields. + /// + private static ImportedItemType DetermineItemType(string itemName, Dictionary fields) + { + var lowerName = itemName.ToLowerInvariant(); + + if (lowerName.Contains("credit card") || lowerName.Contains("creditcard") || + fields.ContainsKey("CVC") || fields.ContainsKey("Cardholder")) + { + return ImportedItemType.Creditcard; + } + + if (lowerName == "identity" || fields.ContainsKey("First name") || + fields.ContainsKey("Social Security Number")) + { + return ImportedItemType.Alias; + } + + if (lowerName == "securenote" || lowerName == "secure note" || lowerName == "note") + { + return ImportedItemType.Note; + } + + // Default to login for Password entries and anything with login credentials + return ImportedItemType.Login; + } + + /// + /// Gets the first matching value from a dictionary given multiple possible keys. + /// + private static string? GetFirstMatch(Dictionary dict, params string[] keys) + { + foreach (var key in keys) + { + if (dict.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + /// + /// Builds notes from miscellaneous fields that don't map to standard credential fields. + /// + private static string? BuildNotes(Dictionary fields) + { + var notesBuilder = new List(); + + // Fields that might contain notes + var noteKeys = new[] { "Note", "Notes", "Security question", "Security answer", "Secret question", "Secret answer" }; + + foreach (var key in noteKeys) + { + if (fields.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) + { + notesBuilder.Add($"{key}: {value}"); + } + } + + // Check for fields ending in "notes" + foreach (var kvp in fields) + { + if (kvp.Key.EndsWith("notes", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(kvp.Value)) + { + notesBuilder.Add(kvp.Value); + } + } + + return notesBuilder.Count > 0 ? string.Join("\n", notesBuilder) : null; + } + + /// + /// Parses credit card information from the field dictionary. + /// + private static ImportedCreditcard ParseCreditCard(Dictionary fields) + { + var card = new ImportedCreditcard + { + CardholderName = GetFirstMatch(fields, "Cardholder", "Cardholder Name", "Name on Card"), + Number = GetFirstMatch(fields, "Number", "Card Number"), + Cvv = GetFirstMatch(fields, "CVC", "CVV", "Security Code"), + Pin = GetFirstMatch(fields, "PIN"), + }; + + // Parse expiry date (format: MM/YY or MM/YYYY) + var expiry = GetFirstMatch(fields, "Expiry date", "Expiry", "Expires", "Valid thru"); + if (!string.IsNullOrWhiteSpace(expiry)) + { + var parts = expiry.Split('/'); + if (parts.Length == 2) + { + card.ExpiryMonth = parts[0].Trim(); + var year = parts[1].Trim(); + // Convert 2-digit year to 2-digit format (keep as-is) + card.ExpiryYear = year.Length == 4 ? year.Substring(2) : year; + } + } + + return card; + } + + /// + /// Parses identity/alias information from the field dictionary. + /// + private static ImportedAlias ParseAlias(Dictionary fields) + { + var alias = new ImportedAlias + { + FirstName = GetFirstMatch(fields, "First name", "Firstname"), + LastName = GetFirstMatch(fields, "Last name", "Lastname"), + Gender = GetFirstMatch(fields, "Gender"), + }; + + // Parse birth date (format: DD-MM-YYYY or similar) + var birthDate = GetFirstMatch(fields, "Birth date", "Birthdate", "Birthday", "Date of birth"); + if (!string.IsNullOrWhiteSpace(birthDate)) + { + if (DateTime.TryParse(birthDate, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + alias.BirthDate = date; + } + else + { + // Try parsing DD-MM-YYYY format + var formats = new[] { "dd-MM-yyyy", "MM-dd-yyyy", "dd/MM/yyyy", "MM/dd/yyyy" }; + foreach (var format in formats) + { + if (DateTime.TryParseExact(birthDate, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out date)) + { + alias.BirthDate = date; + break; + } + } + } + } + + return alias; + } +}