From 215835340ac1ed4ed96e780b4e28f7eb3b9ce8f5 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 27 Jan 2026 19:34:08 +0100 Subject: [PATCH] Add NordPass import method (#1474) --- .../Components/ImportServiceNordPass.razor | 25 +++ .../Settings/ImportExport/ImportExport.razor | 1 + .../ImportExport/ImportServices.en.resx | 13 ++ .../wwwroot/css/tailwind.css | 20 +-- .../wwwroot/img/importers/nordpass.svg | 1 + .../AliasVault.UnitTests.csproj | 1 + .../TestData/Exports/nordpass.csv | 8 + .../Utilities/ImportExportTests.cs | 129 +++++++++++++++ .../Importers/NordPassImporter.cs | 150 +++++++++++++++++ .../Models/Imports/NordPassCsvRecord.cs | 154 ++++++++++++++++++ 10 files changed, 492 insertions(+), 10 deletions(-) create mode 100644 apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceNordPass.razor create mode 100644 apps/server/AliasVault.Client/wwwroot/img/importers/nordpass.svg create mode 100644 apps/server/Tests/AliasVault.UnitTests/TestData/Exports/nordpass.csv create mode 100644 apps/server/Utilities/AliasVault.ImportExport/Importers/NordPassImporter.cs create mode 100644 apps/server/Utilities/AliasVault.ImportExport/Models/Imports/NordPassCsvRecord.cs diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceNordPass.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceNordPass.razor new file mode 100644 index 000000000..35d473751 --- /dev/null +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceNordPass.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["NordPassInstructionsPart1"]

+

@Localizer["NordPassInstructionsPart2"]

+
+ +@code { + private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Main.Settings.ImportExport.ImportServices", "AliasVault.Client"); + + private static async Task> ProcessFile(string fileContents) + { + return await NordPassImporter.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 38ea70d87..026ba0b8d 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor @@ -31,6 +31,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 e81286f7d..eea476a43 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 @@ -191,6 +191,19 @@ Once you have exported the file, you can upload it below. KeePassXC export instructions part 2 + + + Import passwords from NordPass + Description for NordPass import service + + + In order to import your NordPass passwords, you need to export them as a CSV file. You can do this by opening the NordPass app or web vault, going to 'Settings' > 'Export Items', and selecting the CSV format. + NordPass export instructions part 1 + + + Once you have exported the file, you can upload it below. + NordPass export instructions part 2 + Import passwords from Proton Pass diff --git a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css index f2da53133..5cfa6461e 100644 --- a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css @@ -2712,6 +2712,11 @@ video { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +.hover\:border-gray-300:hover { + --tw-border-opacity: 1; + border-color: rgb(209 213 219 / var(--tw-border-opacity)); +} + .hover\:border-orange-400:hover { --tw-border-opacity: 1; border-color: rgb(251 146 60 / var(--tw-border-opacity)); @@ -2727,11 +2732,6 @@ video { border-color: rgb(244 149 65 / var(--tw-border-opacity)); } -.hover\:border-gray-300:hover { - --tw-border-opacity: 1; - border-color: rgb(209 213 219 / var(--tw-border-opacity)); -} - .hover\:bg-blue-600:hover { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity)); @@ -3622,6 +3622,11 @@ video { color: rgb(107 114 128 / var(--tw-text-opacity)); } +.dark\:hover\:border-gray-500:hover:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} + .dark\:hover\:border-orange-500:hover:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(249 115 22 / var(--tw-border-opacity)); @@ -3632,11 +3637,6 @@ video { border-color: rgb(244 149 65 / var(--tw-border-opacity)); } -.dark\:hover\:border-gray-500:hover:is(.dark *) { - --tw-border-opacity: 1; - border-color: rgb(107 114 128 / var(--tw-border-opacity)); -} - .dark\:hover\:bg-blue-500:hover:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(59 130 246 / var(--tw-bg-opacity)); diff --git a/apps/server/AliasVault.Client/wwwroot/img/importers/nordpass.svg b/apps/server/AliasVault.Client/wwwroot/img/importers/nordpass.svg new file mode 100644 index 000000000..1294f9628 --- /dev/null +++ b/apps/server/AliasVault.Client/wwwroot/img/importers/nordpass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/server/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj b/apps/server/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj index 06ad64f40..4a80949a6 100644 --- a/apps/server/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj +++ b/apps/server/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj @@ -72,6 +72,7 @@ + diff --git a/apps/server/Tests/AliasVault.UnitTests/TestData/Exports/nordpass.csv b/apps/server/Tests/AliasVault.UnitTests/TestData/Exports/nordpass.csv new file mode 100644 index 000000000..621eb04cb --- /dev/null +++ b/apps/server/Tests/AliasVault.UnitTests/TestData/Exports/nordpass.csv @@ -0,0 +1,8 @@ +name,url,additional_urls,username,password,note,cardholdername,cardnumber,cvc,pin,expirydate,zipcode,folder,full_name,phone_number,email,address1,address2,city,country,state,type,custom_fields +Password title,http://google.nl,,email@example.tld,password,,,,,,,,Business,,,,,,,,,password,"[{""type"":""text"",""label"":""CustomFieldName1"",""value"":""Test""}]" +SecureNote1,,,,,"This is my secure note content + +Test test",,,,,,,,,,,,,,,,note, +Creditcard Visa,,,,,,Holdername,1234123412341234123,1231,1231,12/28,1231AB,,,,,,,,,,credit_card, +Business,,,,,,,,,,,,,,,,,,,,,folder, +Root item,,,rootuser,rootpass,,,,,,,,,,,,,,,,,password, diff --git a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs index 9be45b701..762d6744f 100644 --- a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs +++ b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs @@ -667,6 +667,135 @@ public class ImportExportTests }); } + /// + /// Test case for importing credentials from NordPass CSV and ensuring all values are present. + /// + /// Async task. + [Test] + public async Task ImportCredentialsFromNordPassCsv() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.nordpass.csv"); + + // Act + var importedCredentials = await NordPassImporter.ImportFromCsvAsync(fileContent); + + // Assert - Should import 4 records (folder entry is skipped) + Assert.That(importedCredentials, Has.Count.EqualTo(4)); + + // Test regular password credential + var passwordCredential = importedCredentials.First(c => c.ServiceName == "Password title"); + Assert.Multiple(() => + { + Assert.That(passwordCredential.ServiceName, Is.EqualTo("Password title")); + Assert.That(passwordCredential.ServiceUrl, Is.EqualTo("http://google.nl")); + Assert.That(passwordCredential.Username, Is.EqualTo("email@example.tld")); + Assert.That(passwordCredential.Password, Is.EqualTo("password")); + Assert.That(passwordCredential.FolderPath, Is.EqualTo("Business")); + Assert.That(passwordCredential.ItemType, Is.EqualTo(ImportedItemType.Login)); + Assert.That(passwordCredential.Notes, Does.Contain("[{\"type\":\"text\",\"label\":\"CustomFieldName1\",\"value\":\"Test\"}]")); + }); + + // Test secure note + var secureNote = importedCredentials.First(c => c.ServiceName == "SecureNote1"); + Assert.Multiple(() => + { + Assert.That(secureNote.ServiceName, Is.EqualTo("SecureNote1")); + Assert.That(secureNote.ServiceUrl, Is.Null); + Assert.That(secureNote.Username, Is.Empty); + Assert.That(secureNote.Password, Is.Empty); + Assert.That(secureNote.ItemType, Is.EqualTo(ImportedItemType.Note)); + Assert.That(secureNote.Notes, Does.Contain("This is my secure note content")); + Assert.That(secureNote.Notes, Does.Contain("Test test")); + }); + + // Test credit card + var creditCard = importedCredentials.First(c => c.ServiceName == "Creditcard Visa"); + Assert.Multiple(() => + { + Assert.That(creditCard.ServiceName, Is.EqualTo("Creditcard Visa")); + Assert.That(creditCard.ItemType, Is.EqualTo(ImportedItemType.Creditcard)); + Assert.That(creditCard.Creditcard, Is.Not.Null); + Assert.That(creditCard.Creditcard!.CardholderName, Is.EqualTo("Holdername")); + Assert.That(creditCard.Creditcard.Number, Is.EqualTo("1234123412341234123")); + Assert.That(creditCard.Creditcard.Cvv, Is.EqualTo("1231")); + Assert.That(creditCard.Creditcard.Pin, Is.EqualTo("1231")); + Assert.That(creditCard.Creditcard.ExpiryMonth, Is.EqualTo("12")); + Assert.That(creditCard.Creditcard.ExpiryYear, Is.EqualTo("28")); + }); + + // Test root item (no folder) + var rootItem = importedCredentials.First(c => c.ServiceName == "Root item"); + Assert.Multiple(() => + { + Assert.That(rootItem.ServiceName, Is.EqualTo("Root item")); + Assert.That(rootItem.Username, Is.EqualTo("rootuser")); + Assert.That(rootItem.Password, Is.EqualTo("rootpass")); + Assert.That(rootItem.FolderPath, Is.Null); + Assert.That(rootItem.ItemType, Is.EqualTo(ImportedItemType.Login)); + }); + } + + /// + /// Test case for NordPass folder import. + /// + /// Async task. + [Test] + public async Task NordPassFolderImport() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.nordpass.csv"); + + // Act + var importedCredentials = await NordPassImporter.ImportFromCsvAsync(fileContent); + + // Assert - verify folder path is extracted + var folderNames = BaseImporter.CollectUniqueFolderNames(importedCredentials); + Assert.That(folderNames, Does.Contain("Business")); + + var credentialWithFolder = importedCredentials.First(c => c.FolderPath == "Business"); + Assert.That(credentialWithFolder.ServiceName, Is.EqualTo("Password title")); + } + + /// + /// Test case for NordPass credit card detection and parsing. + /// + /// Async task. + [Test] + public async Task NordPassCreditCardDetectionAndParsing() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.nordpass.csv"); + + // Act + var importedCredentials = await NordPassImporter.ImportFromCsvAsync(fileContent); + + // Assert - verify credit card is detected and parsed + var creditCardCredential = importedCredentials.First(c => c.ServiceName == "Creditcard Visa"); + Assert.That(creditCardCredential.ItemType, Is.EqualTo(ImportedItemType.Creditcard)); + Assert.That(creditCardCredential.Creditcard, Is.Not.Null); + Assert.That(creditCardCredential.Creditcard!.CardholderName, Is.EqualTo("Holdername")); + Assert.That(creditCardCredential.Creditcard.Number, Is.EqualTo("1234123412341234123")); + Assert.That(creditCardCredential.Creditcard.Cvv, Is.EqualTo("1231")); + Assert.That(creditCardCredential.Creditcard.Pin, Is.EqualTo("1231")); + Assert.That(creditCardCredential.Creditcard.ExpiryMonth, Is.EqualTo("12")); + Assert.That(creditCardCredential.Creditcard.ExpiryYear, Is.EqualTo("28")); + + // Convert to item and verify fields + var items = BaseImporter.ConvertToItem([creditCardCredential]); + 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("1234123412341234123")); + + var cardholderName = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardCardholderName); + Assert.That(cardholderName?.Value, Is.EqualTo("Holdername")); + + var cardPin = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardPin); + Assert.That(cardPin?.Value, Is.EqualTo("1231")); + } + /// /// Test case for importing credentials from AliasVault Mobile App CSV export and ensuring all values are present. /// diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/NordPassImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/NordPassImporter.cs new file mode 100644 index 000000000..413ef5715 --- /dev/null +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/NordPassImporter.cs @@ -0,0 +1,150 @@ +//----------------------------------------------------------------------- +// +// 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 AliasVault.ImportExport.Models.Imports; + +/// +/// Imports credentials from NordPass. +/// +public static class NordPassImporter +{ + /// + /// Imports NordPass 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 records = await BaseImporter.ImportCsvDataAsync(fileContent); + + var credentials = new List(); + foreach (var record in records) + { + // Skip folder entries - NordPass exports folder rows with type "folder" which are not credentials. + if (string.Equals(record.Type, "folder", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var itemType = MapNordPassType(record.Type); + ImportedCreditcard? creditcard = null; + + // Parse credit card data if any credit card fields are present. + if (HasCreditcardData(record)) + { + creditcard = ParseCreditcard(record); + itemType ??= ImportedItemType.Creditcard; + } + + // Build notes, appending custom fields if present. + var notes = record.Note; + if (!string.IsNullOrWhiteSpace(record.CustomFields)) + { + notes = string.IsNullOrWhiteSpace(notes) + ? record.CustomFields + : notes + Environment.NewLine + record.CustomFields; + } + + var credential = new ImportedCredential + { + ServiceName = record.Name, + ServiceUrl = string.IsNullOrWhiteSpace(record.Url) ? null : record.Url, + Email = record.Email, + Username = record.Username, + Password = record.Password, + Notes = notes, + FolderPath = string.IsNullOrWhiteSpace(record.Folder) ? null : record.Folder, + ItemType = itemType, + Creditcard = creditcard, + }; + + credentials.Add(credential); + } + + return credentials; + } + + /// + /// Maps NordPass type values to ImportedItemType. + /// NordPass types: password, note, credit_card, identity. + /// + private static ImportedItemType? MapNordPassType(string? nordPassType) + { + if (string.IsNullOrWhiteSpace(nordPassType)) + { + return null; + } + + return nordPassType.ToLowerInvariant() switch + { + "password" => ImportedItemType.Login, + "note" => ImportedItemType.Note, + "credit_card" => ImportedItemType.Creditcard, + "identity" => ImportedItemType.Alias, + _ => ImportedItemType.Login, + }; + } + + /// + /// Checks whether the NordPass record contains credit card data. + /// + private static bool HasCreditcardData(NordPassCsvRecord record) + { + return !string.IsNullOrWhiteSpace(record.CardNumber) || + !string.IsNullOrWhiteSpace(record.CardholderName) || + !string.IsNullOrWhiteSpace(record.Cvc) || + !string.IsNullOrWhiteSpace(record.ExpiryDate); + } + + /// + /// Parses credit card data from a NordPass record. + /// + private static ImportedCreditcard ParseCreditcard(NordPassCsvRecord record) + { + var creditcard = new ImportedCreditcard + { + CardholderName = record.CardholderName, + Number = record.CardNumber, + Cvv = record.Cvc, + Pin = record.Pin, + }; + + // Parse expiry date - NordPass uses various formats, commonly "MM/YYYY" or "MMYYYY". + if (!string.IsNullOrWhiteSpace(record.ExpiryDate)) + { + ParseExpiryDate(record.ExpiryDate, creditcard); + } + + return creditcard; + } + + /// + /// Parses NordPass expiry date string into month and year components. + /// Handles formats like "MM/YYYY", "MM/YY", and "MMYYYY". + /// + private static void ParseExpiryDate(string expiryDate, ImportedCreditcard creditcard) + { + if (expiryDate.Contains('/')) + { + var parts = expiryDate.Split('/'); + if (parts.Length == 2) + { + creditcard.ExpiryMonth = parts[0].Trim().PadLeft(2, '0'); + creditcard.ExpiryYear = parts[1].Trim(); + } + } + else if (expiryDate.Length == 6) + { + // Format: MMYYYY + creditcard.ExpiryMonth = expiryDate[..2]; + creditcard.ExpiryYear = expiryDate[2..]; + } + } +} diff --git a/apps/server/Utilities/AliasVault.ImportExport/Models/Imports/NordPassCsvRecord.cs b/apps/server/Utilities/AliasVault.ImportExport/Models/Imports/NordPassCsvRecord.cs new file mode 100644 index 000000000..7bfaa6e20 --- /dev/null +++ b/apps/server/Utilities/AliasVault.ImportExport/Models/Imports/NordPassCsvRecord.cs @@ -0,0 +1,154 @@ +//----------------------------------------------------------------------- +// +// 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.Models.Imports; + +using CsvHelper.Configuration.Attributes; + +/// +/// Represents a NordPass CSV record that is being imported from a NordPass CSV export file. +/// +public class NordPassCsvRecord +{ + /// + /// Gets or sets the name of the item. + /// + [Name("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the URL of the item. + /// + [Name("url")] + public string Url { get; set; } = string.Empty; + + /// + /// Gets or sets the additional URLs of the item. + /// + [Name("additional_urls")] + public string? AdditionalUrls { get; set; } + + /// + /// Gets or sets the username of the item. + /// + [Name("username")] + public string Username { get; set; } = string.Empty; + + /// + /// Gets or sets the password of the item. + /// + [Name("password")] + public string Password { get; set; } = string.Empty; + + /// + /// Gets or sets any additional notes. + /// + [Name("note")] + public string? Note { get; set; } + + /// + /// Gets or sets the cardholder name (for credit card items). + /// + [Name("cardholdername")] + public string? CardholderName { get; set; } + + /// + /// Gets or sets the card number (for credit card items). + /// + [Name("cardnumber")] + public string? CardNumber { get; set; } + + /// + /// Gets or sets the CVC/CVV code (for credit card items). + /// + [Name("cvc")] + public string? Cvc { get; set; } + + /// + /// Gets or sets the PIN (for credit card items). + /// + [Name("pin")] + public string? Pin { get; set; } + + /// + /// Gets or sets the expiry date (for credit card items). + /// + [Name("expirydate")] + public string? ExpiryDate { get; set; } + + /// + /// Gets or sets the zip code (for identity items). + /// + [Name("zipcode")] + public string? ZipCode { get; set; } + + /// + /// Gets or sets the folder name. + /// + [Name("folder")] + public string? Folder { get; set; } + + /// + /// Gets or sets the full name (for identity items). + /// + [Name("full_name")] + public string? FullName { get; set; } + + /// + /// Gets or sets the phone number (for identity items). + /// + [Name("phone_number")] + public string? PhoneNumber { get; set; } + + /// + /// Gets or sets the email address (for identity items). + /// + [Name("email")] + public string? Email { get; set; } + + /// + /// Gets or sets the first address line (for identity items). + /// + [Name("address1")] + public string? Address1 { get; set; } + + /// + /// Gets or sets the second address line (for identity items). + /// + [Name("address2")] + public string? Address2 { get; set; } + + /// + /// Gets or sets the city (for identity items). + /// + [Name("city")] + public string? City { get; set; } + + /// + /// Gets or sets the country (for identity items). + /// + [Name("country")] + public string? Country { get; set; } + + /// + /// Gets or sets the state (for identity items). + /// + [Name("state")] + public string? State { get; set; } + + /// + /// Gets or sets the type of the item (e.g., password, note, card, identity). + /// + [Name("type")] + public string? Type { get; set; } + + /// + /// Gets or sets custom fields as a JSON or delimited string. + /// + [Name("custom_fields")] + public string? CustomFields { get; set; } +}