From 6f5ae7c17e4592bf2a44e1b9fd1d1a2ed2d45128 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 29 Mar 2025 16:41:19 +0100 Subject: [PATCH] Add 1Password importer (#542) --- .../Components/ImportService1Password.razor | 21 +++ .../Components/ImportServiceCard.razor | 68 +++++---- .../Settings/ImportExport/ImportExport.razor | 1 + .../wwwroot/css/tailwind.css | 14 -- .../wwwroot/img/importers/1password.svg | 143 ++++++++++++++++++ .../AliasVault.UnitTests.csproj | 2 + .../TestData/Exports/1password_8.csv | 5 + .../Utilities/ImportExportTests.cs | 48 +++++- .../Importers/OnePasswordImporter.cs | 54 +++++++ .../Models/Imports/OnePasswordCsvRecord.cs | 73 +++++++++ 10 files changed, 378 insertions(+), 51 deletions(-) create mode 100644 src/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportService1Password.razor create mode 100644 src/AliasVault.Client/wwwroot/img/importers/1password.svg create mode 100644 src/Tests/AliasVault.UnitTests/TestData/Exports/1password_8.csv create mode 100644 src/Utilities/AliasVault.ImportExport/Importers/OnePasswordImporter.cs create mode 100644 src/Utilities/AliasVault.ImportExport/Models/Imports/OnePasswordCsvRecord.cs diff --git a/src/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportService1Password.razor b/src/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportService1Password.razor new file mode 100644 index 000000000..751258f9b --- /dev/null +++ b/src/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportService1Password.razor @@ -0,0 +1,21 @@ +@using AliasVault.ImportExport.Models +@using AliasVault.ImportExport.Importers +@inject NavigationManager NavigationManager +@inject GlobalNotificationService GlobalNotificationService +@inject ILogger Logger + + +

In order to import your 1Password vault, you need to export it as a CSV file. You can do this by logging into your 1Password account in the 1Password 8 desktop app (Windows / MacOS / Linux), going to the 'File' menu and selecting 'Export' (to CSV).

+

Once you have exported the file, you can upload it below.

+
+ +@code { + private async Task> ProcessFile(string fileContents) + { + return await OnePasswordImporter.ImportFromCsvAsync(fileContents); + } +} diff --git a/src/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceCard.razor b/src/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceCard.razor index 8508c884b..f805e199c 100644 --- a/src/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceCard.razor +++ b/src/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceCard.razor @@ -39,7 +39,7 @@
-
+
@ServiceName logo

Import from @ServiceName

+
+ @ChildContent +
+

Upload your @ServiceName export file:

+ +
+
+ +
break; @@ -113,19 +115,21 @@ break; case ImportStep.Confirm: - @if (IsImporting) - { - - } - else { -
-

Are you sure you want to import (@ImportedCredentials.Count) credentials? Note: the import process can take a short while.

-
-
- - -
- } +
+ @if (IsImporting) + { + + } + else { +
+

Are you sure you want to import (@ImportedCredentials.Count) credentials? Note: the import process can take a short while.

+
+
+ + +
+ } +
break; }
diff --git a/src/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor b/src/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor index 5c7c5bb22..06f8ee4e1 100644 --- a/src/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor +++ b/src/AliasVault.Client/Main/Pages/Settings/ImportExport/ImportExport.razor @@ -23,6 +23,7 @@
+
diff --git a/src/AliasVault.Client/wwwroot/css/tailwind.css b/src/AliasVault.Client/wwwroot/css/tailwind.css index 592e2aa73..aa1649fc9 100644 --- a/src/AliasVault.Client/wwwroot/css/tailwind.css +++ b/src/AliasVault.Client/wwwroot/css/tailwind.css @@ -769,10 +769,6 @@ video { margin-left: 0.75rem; } -.ml-6 { - margin-left: 1.5rem; -} - .ml-auto { margin-left: auto; } @@ -2644,11 +2640,6 @@ video { background-color: rgb(133 77 14 / var(--tw-bg-opacity)); } -.dark\:bg-yellow-900:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(113 63 18 / var(--tw-bg-opacity)); -} - .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; } @@ -2743,11 +2734,6 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } -.dark\:text-yellow-100:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(254 249 195 / var(--tw-text-opacity)); -} - .dark\:text-yellow-400:is(.dark *) { --tw-text-opacity: 1; color: rgb(250 204 21 / var(--tw-text-opacity)); diff --git a/src/AliasVault.Client/wwwroot/img/importers/1password.svg b/src/AliasVault.Client/wwwroot/img/importers/1password.svg new file mode 100644 index 000000000..359d49322 --- /dev/null +++ b/src/AliasVault.Client/wwwroot/img/importers/1password.svg @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj b/src/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj index b220bfae8..73468a7c5 100644 --- a/src/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj +++ b/src/Tests/AliasVault.UnitTests/AliasVault.UnitTests.csproj @@ -65,6 +65,8 @@ + + diff --git a/src/Tests/AliasVault.UnitTests/TestData/Exports/1password_8.csv b/src/Tests/AliasVault.UnitTests/TestData/Exports/1password_8.csv new file mode 100644 index 000000000..4fc7a3979 --- /dev/null +++ b/src/Tests/AliasVault.UnitTests/TestData/Exports/1password_8.csv @@ -0,0 +1,5 @@ +Title,Url,Username,Password,OTPAuth,Favorite,Archived,Tags,Notes +Test record 2 with 2FA,,username2fa,password2fa,otpauth://totp/Strongbox?secret=PLW4SB3PQ7MKVXY2MXF4NEXS6Y&period=30&algorithm=SHA1&digits=6,false,false,,Notes about 2FA record +1Password Account (dpatel),https://my.1password.com,derekpatel@aliasvault.net,passwordexample,,false,false,Starter Kit,You can use this login to sign in to your account on 1password.com. +Test record 1,https://nu.nl,username1,password1,,false,false,, +Password entry,,passwordusername,passwordpassword,,false,false,, diff --git a/src/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs b/src/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs index 444fd0555..0a577748d 100644 --- a/src/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs +++ b/src/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs @@ -20,6 +20,7 @@ public class ImportExportTests /// /// Test case for importing credentials from CSV and ensuring all values are present. /// + /// Async task. [Test] public async Task ImportCredentialsFromCsv() { @@ -79,16 +80,15 @@ public class ImportExportTests Assert.That(importedCredential.ServiceUrl, Is.EqualTo(credential.Service.Url)); Assert.That(importedCredential.Username, Is.EqualTo(credential.Username)); Assert.That(importedCredential.Notes, Is.EqualTo(credential.Notes)); - Assert.That(importedCredential.CreatedAt, Is.EqualTo(credential.CreatedAt)); - Assert.That(importedCredential.UpdatedAt, Is.EqualTo(credential.UpdatedAt)); + Assert.That(importedCredential.CreatedAt?.Date, Is.EqualTo(credential.CreatedAt.Date)); + Assert.That(importedCredential.UpdatedAt?.Date, Is.EqualTo(credential.UpdatedAt.Date)); Assert.That(importedCredential.Alias!.Gender, Is.EqualTo(credential.Alias!.Gender)); Assert.That(importedCredential.Alias!.FirstName, Is.EqualTo(credential.Alias!.FirstName)); Assert.That(importedCredential.Alias!.LastName, Is.EqualTo(credential.Alias!.LastName)); Assert.That(importedCredential.Alias!.NickName, Is.EqualTo(credential.Alias!.NickName)); Assert.That(importedCredential.Alias!.BirthDate, Is.EqualTo(credential.Alias!.BirthDate)); - Assert.That(importedCredential.Alias!.Email, Is.EqualTo(credential.Alias!.Email)); - Assert.That(importedCredential.Alias!.CreatedAt, Is.EqualTo(credential.Alias!.CreatedAt)); - Assert.That(importedCredential.Alias!.UpdatedAt, Is.EqualTo(credential.Alias!.UpdatedAt)); + Assert.That(importedCredential.Alias!.CreatedAt?.Date, Is.EqualTo(credential.Alias!.CreatedAt.Date)); + Assert.That(importedCredential.Alias!.UpdatedAt?.Date, Is.EqualTo(credential.Alias!.UpdatedAt.Date)); Assert.That(importedCredential.Password, Is.EqualTo(credential.Passwords.First().Value)); }); } @@ -165,4 +165,42 @@ public class ImportExportTests Assert.That(sampleCredential.Password, Is.EqualTo("&3V_$z?Aiw-_x+nbYj")); }); } + + /// + /// Test case for importing credentials from 1Password CSV and ensuring all values are present. + /// + /// Async task. + [Test] + public async Task ImportCredentialsFrom1PasswordCsv() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.1password_8.csv"); + + // Act + var importedCredentials = await OnePasswordImporter.ImportFromCsvAsync(fileContent); + + // Assert + Assert.That(importedCredentials, Has.Count.EqualTo(4)); + + // Test specific entries + var twoFactorCredential = importedCredentials.First(c => c.Username == "username2fa"); + Assert.Multiple(() => + { + Assert.That(twoFactorCredential.ServiceName, Is.EqualTo("Test record 2 with 2FA")); + Assert.That(twoFactorCredential.Username, Is.EqualTo("username2fa")); + Assert.That(twoFactorCredential.Password, Is.EqualTo("password2fa")); + Assert.That(twoFactorCredential.TwoFactorSecret, Is.EqualTo("otpauth://totp/Strongbox?secret=PLW4SB3PQ7MKVXY2MXF4NEXS6Y&period=30&algorithm=SHA1&digits=6")); + Assert.That(twoFactorCredential.Notes, Is.EqualTo("Notes about 2FA record")); + }); + + var onePasswordAccount = importedCredentials.First(c => c.ServiceName == "1Password Account (dpatel)"); + Assert.Multiple(() => + { + Assert.That(onePasswordAccount.ServiceName, Is.EqualTo("1Password Account (dpatel)")); + Assert.That(onePasswordAccount.ServiceUrl, Is.EqualTo("https://my.1password.com")); + Assert.That(onePasswordAccount.Username, Is.EqualTo("derekpatel@aliasvault.net")); + Assert.That(onePasswordAccount.Password, Is.EqualTo("passwordexample")); + Assert.That(onePasswordAccount.Notes, Is.EqualTo("You can use this login to sign in to your account on 1password.com.")); + }); + } } diff --git a/src/Utilities/AliasVault.ImportExport/Importers/OnePasswordImporter.cs b/src/Utilities/AliasVault.ImportExport/Importers/OnePasswordImporter.cs new file mode 100644 index 000000000..c36f1324e --- /dev/null +++ b/src/Utilities/AliasVault.ImportExport/Importers/OnePasswordImporter.cs @@ -0,0 +1,54 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT 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; +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; + +/// +/// Imports credentials from 1Password. +/// +public class OnePasswordImporter +{ + /// + /// Imports 1Password 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) + { + using var reader = new StringReader(fileContent); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)); + + var credentials = new List(); + await foreach (var record in csv.GetRecordsAsync()) + { + var credential = new ImportedCredential + { + ServiceName = record.Title, + ServiceUrl = record.Url, + Username = record.Username, + Password = record.Password, + TwoFactorSecret = record.OTPAuth, + Notes = record.Notes + }; + + credentials.Add(credential); + } + + if (credentials.Count == 0) + { + throw new InvalidOperationException("No records found in the CSV file."); + } + + return credentials; + } +} \ No newline at end of file diff --git a/src/Utilities/AliasVault.ImportExport/Models/Imports/OnePasswordCsvRecord.cs b/src/Utilities/AliasVault.ImportExport/Models/Imports/OnePasswordCsvRecord.cs new file mode 100644 index 000000000..6f4dd954b --- /dev/null +++ b/src/Utilities/AliasVault.ImportExport/Models/Imports/OnePasswordCsvRecord.cs @@ -0,0 +1,73 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +using AliasVault.ImportExport.Converters; +using CsvHelper.Configuration.Attributes; + +namespace AliasVault.ImportExport.Models.Imports; + +/// +/// Represents a 1Password CSV record that is being imported from a 1Password CSV export file. +/// +public class OnePasswordCsvRecord +{ + /// + /// Gets or sets the title of the item. + /// + [Name("Title")] + public string Title { 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 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 the OTP (One-Time Password) authentication secret. + /// + [Name("OTPAuth")] + public string? OTPAuth { get; set; } + + /// + /// Gets or sets whether the item is favorited. + /// + [Name("Favorite")] + [TypeConverter(typeof(BooleanConverter))] + public bool Favorite { get; set; } + + /// + /// Gets or sets whether the item is archived. + /// + [Name("Archived")] + [TypeConverter(typeof(BooleanConverter))] + public bool Archived { get; set; } + + /// + /// Gets or sets the service URL. + /// + [Name("Tags")] + public string? Tags { get; set; } + + /// + /// Gets or sets any additional notes. + /// + [Name("Notes")] + public string? Notes { get; set; } +}