Add Enpass import option (#1643)

This commit is contained in:
Leendert de Borst
2026-02-07 16:25:50 +01:00
committed by Leendert de Borst
parent 867c0d8b01
commit c292a04ba7
8 changed files with 462 additions and 0 deletions

View File

@@ -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<ImportServiceEnpass> Logger
<ImportServiceCard
ServiceName="Enpass"
Description="@Localizer["EnpassDescription"]"
LogoUrl="img/importers/enpass.svg"
ProcessFileCallback="ProcessFile">
<p class="text-gray-700 dark:text-gray-300 mb-4">@Localizer["EnpassInstructionsPart1"]</p>
<p class="text-gray-700 dark:text-gray-300 mb-4">@Localizer["EnpassInstructionsPart2"]</p>
</ImportServiceCard>
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Main.Settings.ImportExport.ImportServices", "AliasVault.Client");
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
{
return await EnpassImporter.ImportFromCsvAsync(fileContents);
}
}

View File

@@ -28,6 +28,7 @@
<ImportServiceDashlane />
<ImportServiceDropbox />
<ImportServiceEdge />
<ImportServiceEnpass />
<ImportServiceFirefox />
<ImportServiceKeePass />
<ImportServiceKeePassXC />

View File

@@ -274,6 +274,19 @@
<value>Once you have exported the file, you can upload it below.</value>
<comment>Edge export instructions part 2</comment>
</data>
<!-- Enpass -->
<data name="EnpassDescription" xml:space="preserve">
<value>Import passwords from Enpass</value>
<comment>Description for Enpass import service</comment>
</data>
<data name="EnpassInstructionsPart1" xml:space="preserve">
<value>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)'.</value>
<comment>Enpass export instructions part 1</comment>
</data>
<data name="EnpassInstructionsPart2" xml:space="preserve">
<value>Once you have exported the file, you can upload it below.</value>
<comment>Enpass export instructions part 2</comment>
</data>
<!-- Common text that can be reused -->
<data name="UploadFileInstructionCommon" xml:space="preserve">
<value>Once you have exported the file, you can upload it below.</value>

View File

@@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 114.29 100"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.5549 0H96.7276C107.568 0 115.817 9.41092 114.045 19.7522L104.047 77.8593C101.849 90.6338 95.0271 100 81.6327 100H32.6531C19.2587 100 12.4331 90.6338 10.2354 77.8593L0.241674 19.7522C-1.53499 9.41092 6.70985 0 17.5549 0ZM66.6667 77.081V56.2687C73.5791 52.6994 79.0766 45.6546 79.0719 37.5498C79.0719 25.8398 69.254 16.3439 57.1366 16.3439C45.0238 16.3439 35.2013 25.8353 35.2013 37.5498C35.2013 45.5338 40.8779 52.4802 47.6191 56.0988V77.081C47.6191 80.1896 49.1046 81.8182 52.3202 81.8182H61.6384C64.854 81.8182 66.6667 80.1896 66.6667 77.081Z"
fill="#0D47A1"
/>
</svg>

After

Width:  |  Height:  |  Size: 728 B

View File

@@ -76,6 +76,7 @@
<EmbeddedResource Include="TestData\Exports\aliasvault_mobile_app_export.csv" />
<EmbeddedResource Include="TestData\Exports\roboform.csv" />
<EmbeddedResource Include="TestData\Exports\edge.csv" />
<EmbeddedResource Include="TestData\Exports\enpass.csv" />
</ItemGroup>
</Project>

View File

@@ -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"
1 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
2 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
3 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
4 Password Login loginpw1 *Password password Access AccessField CustomField1 Customfield
5 Securenote Note only content here

View File

@@ -1314,6 +1314,113 @@ public class ImportExportTests
});
}
/// <summary>
/// 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.
/// </summary>
/// <returns>Async task.</returns>
[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"));
});
}
/// <summary>
/// Test case for Enpass credit card conversion to Item.
/// </summary>
/// <returns>Async task.</returns>
[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"));
}
/// <summary>
/// Helper method to add a field value to an item.
/// </summary>

View File

@@ -0,0 +1,298 @@
//-----------------------------------------------------------------------
// <copyright file="EnpassImporter.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.ImportExport.Importers;
using AliasVault.ImportExport.Models;
using CsvHelper;
using CsvHelper.Configuration;
using System.Globalization;
/// <summary>
/// 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.
/// </summary>
public static class EnpassImporter
{
/// <summary>
/// Imports Enpass CSV file and converts contents to list of ImportedCredential model objects.
/// </summary>
/// <param name="fileContent">The content of the CSV file.</param>
/// <returns>The imported list of ImportedCredential objects.</returns>
public static async Task<List<ImportedCredential>> ImportFromCsvAsync(string fileContent)
{
var credentials = new List<ImportedCredential>();
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<string>();
// 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;
}
/// <summary>
/// Parses a single Enpass CSV row into an ImportedCredential.
/// The first field is the item name/type, followed by alternating key-value pairs.
/// </summary>
/// <param name="fields">The list of fields from the CSV row.</param>
/// <returns>The parsed ImportedCredential, or null if parsing fails.</returns>
private static ImportedCredential? ParseEnpassRow(List<string> 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<string, string>(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;
}
/// <summary>
/// Determines the item type based on the item name and available fields.
/// </summary>
private static ImportedItemType DetermineItemType(string itemName, Dictionary<string, string> 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;
}
/// <summary>
/// Gets the first matching value from a dictionary given multiple possible keys.
/// </summary>
private static string? GetFirstMatch(Dictionary<string, string> dict, params string[] keys)
{
foreach (var key in keys)
{
if (dict.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
/// <summary>
/// Builds notes from miscellaneous fields that don't map to standard credential fields.
/// </summary>
private static string? BuildNotes(Dictionary<string, string> fields)
{
var notesBuilder = new List<string>();
// 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;
}
/// <summary>
/// Parses credit card information from the field dictionary.
/// </summary>
private static ImportedCreditcard ParseCreditCard(Dictionary<string, string> 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;
}
/// <summary>
/// Parses identity/alias information from the field dictionary.
/// </summary>
private static ImportedAlias ParseAlias(Dictionary<string, string> 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;
}
}