From 5353cf03b5ad8d1b7666d93341ac8b0c302377ce Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Wed, 26 Jan 2022 23:04:55 +0100 Subject: [PATCH] BEEEP: Add importer for Keeper in json format (#608) * Add testdata, create types for keeperjson import * Create keeperjson importer and tests * Register, Create instance of keeperjson importer * Move keeperCsvImporter to keeperImporters folder * Fixed import of BaseImporter * Removed unnecessary check for key * Move instantiation of importer into beforeEach * Fixed the second import with a wrong path * Adjust types based on new test export * Add test case for empty notes and custom fields * Implement logic for failed test case * Removed test expectation --- .../keeperCsvImporter.ts | 8 +- .../keeperImporters/keeperJsonImporter.ts | 70 +++++++++++ .../keeperImporters/types/keeperJsonTypes.ts | 41 +++++++ common/src/services/import.service.ts | 6 +- .../importers/keeperJsonImporter.spec.ts | 109 ++++++++++++++++++ .../importers/testData/keeperJson/testData.ts | 90 +++++++++++++++ 6 files changed, 319 insertions(+), 5 deletions(-) rename common/src/importers/{ => keeperImporters}/keeperCsvImporter.ts (85%) create mode 100644 common/src/importers/keeperImporters/keeperJsonImporter.ts create mode 100644 common/src/importers/keeperImporters/types/keeperJsonTypes.ts create mode 100644 spec/common/importers/keeperJsonImporter.spec.ts create mode 100644 spec/common/importers/testData/keeperJson/testData.ts diff --git a/common/src/importers/keeperCsvImporter.ts b/common/src/importers/keeperImporters/keeperCsvImporter.ts similarity index 85% rename from common/src/importers/keeperCsvImporter.ts rename to common/src/importers/keeperImporters/keeperCsvImporter.ts index 88ed061e80..56861b7982 100644 --- a/common/src/importers/keeperCsvImporter.ts +++ b/common/src/importers/keeperImporters/keeperCsvImporter.ts @@ -1,9 +1,9 @@ -import { BaseImporter } from "./baseImporter"; -import { Importer } from "./importer"; +import { BaseImporter } from "../baseImporter"; +import { Importer } from "../importer"; -import { ImportResult } from "../models/domain/importResult"; +import { ImportResult } from "../../models/domain/importResult"; -import { FolderView } from "../models/view/folderView"; +import { FolderView } from "../../models/view/folderView"; export class KeeperCsvImporter extends BaseImporter implements Importer { parse(data: string): Promise { diff --git a/common/src/importers/keeperImporters/keeperJsonImporter.ts b/common/src/importers/keeperImporters/keeperJsonImporter.ts new file mode 100644 index 0000000000..47e4111420 --- /dev/null +++ b/common/src/importers/keeperImporters/keeperJsonImporter.ts @@ -0,0 +1,70 @@ +import { BaseImporter } from "../baseImporter"; +import { Importer } from "../importer"; + +import { ImportResult } from "../../models/domain/importResult"; + +import { KeeperJsonExport, RecordsEntity } from "./types/keeperJsonTypes"; + +export class KeeperJsonImporter extends BaseImporter implements Importer { + parse(data: string): Promise { + const result = new ImportResult(); + const keeperExport: KeeperJsonExport = JSON.parse(data); + if (keeperExport == null || keeperExport.records == null || keeperExport.records.length === 0) { + result.success = false; + return Promise.resolve(result); + } + + keeperExport.records.forEach((record) => { + this.parseFolders(result, record); + + const cipher = this.initLoginCipher(); + cipher.name = record.title; + cipher.login.username = record.login; + cipher.login.password = record.password; + + cipher.login.uris = this.makeUriArray(record.login_url); + cipher.notes = record.notes; + + if (record.custom_fields != null) { + let customfieldKeys = Object.keys(record.custom_fields); + if (record.custom_fields["TFC:Keeper"] != null) { + customfieldKeys = customfieldKeys.filter((item) => item !== "TFC:Keeper"); + cipher.login.totp = record.custom_fields["TFC:Keeper"]; + } + + customfieldKeys.forEach((key) => { + this.processKvp(cipher, key, record.custom_fields[key]); + }); + } + + this.convertToNoteIfNeeded(cipher); + this.cleanupCipher(cipher); + result.ciphers.push(cipher); + }); + + if (this.organization) { + this.moveFoldersToCollections(result); + } + + result.success = true; + return Promise.resolve(result); + } + + private parseFolders(result: ImportResult, record: RecordsEntity) { + if (record.folders == null || record.folders.length === 0) { + return; + } + + record.folders.forEach((item) => { + if (item.folder != null) { + this.processFolder(result, item.folder); + return; + } + + if (item.shared_folder != null) { + this.processFolder(result, item.shared_folder); + return; + } + }); + } +} diff --git a/common/src/importers/keeperImporters/types/keeperJsonTypes.ts b/common/src/importers/keeperImporters/types/keeperJsonTypes.ts new file mode 100644 index 0000000000..1f6d2ea4c2 --- /dev/null +++ b/common/src/importers/keeperImporters/types/keeperJsonTypes.ts @@ -0,0 +1,41 @@ +export interface KeeperJsonExport { + shared_folders?: SharedFoldersEntity[] | null; + records?: RecordsEntity[] | null; +} + +export interface SharedFoldersEntity { + path: string; + manage_users: boolean; + manage_records: boolean; + can_edit: boolean; + can_share: boolean; + permissions?: PermissionsEntity[] | null; +} + +export interface PermissionsEntity { + uid?: string | null; + manage_users: boolean; + manage_records: boolean; + name?: string | null; +} + +export interface RecordsEntity { + title: string; + login: string; + password: string; + login_url: string; + notes?: string; + custom_fields?: CustomFields; + folders?: FoldersEntity[] | null; +} + +export type CustomFields = { + [key: string]: string | null; +}; + +export interface FoldersEntity { + folder?: string | null; + shared_folder?: string | null; + can_edit?: boolean | null; + can_share?: boolean | null; +} diff --git a/common/src/services/import.service.ts b/common/src/services/import.service.ts index 7d8cfa9258..adf014bf71 100644 --- a/common/src/services/import.service.ts +++ b/common/src/services/import.service.ts @@ -49,7 +49,8 @@ import { Importer } from "../importers/importer"; import { KasperskyTxtImporter } from "../importers/kasperskyTxtImporter"; import { KeePass2XmlImporter } from "../importers/keepass2XmlImporter"; import { KeePassXCsvImporter } from "../importers/keepassxCsvImporter"; -import { KeeperCsvImporter } from "../importers/keeperCsvImporter"; +import { KeeperCsvImporter } from "../importers/keeperImporters/keeperCsvImporter"; +import { KeeperJsonImporter } from "../importers/keeperImporters/keeperJsonImporter"; import { LastPassCsvImporter } from "../importers/lastpassCsvImporter"; import { LogMeOnceCsvImporter } from "../importers/logMeOnceCsvImporter"; import { MeldiumCsvImporter } from "../importers/meldiumCsvImporter"; @@ -100,6 +101,7 @@ export class ImportService implements ImportServiceAbstraction { { id: "1passwordmaccsv", name: "1Password 6 and 7 Mac (csv)" }, { id: "roboformcsv", name: "RoboForm (csv)" }, { id: "keepercsv", name: "Keeper (csv)" }, + { id: "keeperjson", name: "Keeper (json)" }, { id: "enpasscsv", name: "Enpass (csv)" }, { id: "enpassjson", name: "Enpass (json)" }, { id: "safeincloudxml", name: "SafeInCloud (xml)" }, @@ -251,6 +253,8 @@ export class ImportService implements ImportServiceAbstraction { return new OnePasswordMacCsvImporter(); case "keepercsv": return new KeeperCsvImporter(); + case "keeperjson": + return new KeeperJsonImporter(); case "passworddragonxml": return new PasswordDragonXmlImporter(); case "enpasscsv": diff --git a/spec/common/importers/keeperJsonImporter.spec.ts b/spec/common/importers/keeperJsonImporter.spec.ts new file mode 100644 index 0000000000..3a4f34c282 --- /dev/null +++ b/spec/common/importers/keeperJsonImporter.spec.ts @@ -0,0 +1,109 @@ +import { Utils } from "jslib-common/misc/utils"; + +import { KeeperJsonImporter as Importer } from "jslib-common/importers/keeperImporters/keeperJsonImporter"; + +import { testData as TestData } from "./testData/keeperJson/testData"; + +describe("Keeper Json Importer", () => { + const testDataJson = JSON.stringify(TestData); + + let importer: Importer; + beforeEach(() => { + importer = new Importer(); + }); + + it("should parse login data", async () => { + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + const cipher = result.ciphers.shift(); + expect(cipher.name).toEqual("Bank Account 1"); + expect(cipher.login.username).toEqual("customer1234"); + expect(cipher.login.password).toEqual("4813fJDHF4239fdk"); + expect(cipher.login.uris.length).toEqual(1); + const uriView = cipher.login.uris.shift(); + expect(uriView.uri).toEqual("https://chase.com"); + expect(cipher.notes).toEqual("These are some notes."); + + const cipher2 = result.ciphers.shift(); + expect(cipher2.name).toEqual("Bank Account 2"); + expect(cipher2.login.username).toEqual("mybankusername"); + expect(cipher2.login.password).toEqual("w4k4k193f$^&@#*%2"); + expect(cipher2.login.uris.length).toEqual(1); + const uriView2 = cipher2.login.uris.shift(); + expect(uriView2.uri).toEqual("https://amex.com"); + expect(cipher2.notes).toEqual("Some great information here."); + + const cipher3 = result.ciphers.shift(); + expect(cipher3.name).toEqual("Some Account"); + expect(cipher3.login.username).toEqual("someUserName"); + expect(cipher3.login.password).toEqual("w4k4k1wergf$^&@#*%2"); + expect(cipher3.notes).toBeNull(); + expect(cipher3.fields).toBeNull(); + expect(cipher3.login.uris.length).toEqual(1); + const uriView3 = cipher3.login.uris.shift(); + expect(uriView3.uri).toEqual("https://example.com"); + }); + + it("should import TOTP when present", async () => { + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + const cipher = result.ciphers.shift(); + expect(cipher.login.totp).toBeNull(); + + // 2nd Cipher + const cipher2 = result.ciphers.shift(); + expect(cipher2.login.totp).toEqual( + "otpauth://totp/Amazon:me@company.com?secret=JBSWY3DPEHPK3PXP&issuer=Amazon&algorithm=SHA1&digits=6&period=30" + ); + }); + + it("should parse custom fields", async () => { + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + const cipher = result.ciphers.shift(); + expect(cipher.fields.length).toBe(1); + expect(cipher.fields[0].name).toEqual("Account Number"); + expect(cipher.fields[0].value).toEqual("123-456-789"); + + // 2nd Cipher + const cipher2 = result.ciphers.shift(); + expect(cipher2.fields.length).toBe(2); + expect(cipher2.fields[0].name).toEqual("Security Group"); + expect(cipher2.fields[0].value).toEqual("Public"); + + expect(cipher2.fields[1].name).toEqual("IP Address"); + expect(cipher2.fields[1].value).toEqual("12.45.67.8"); + }); + + it("should create folders and assigned ciphers to them", async () => { + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + const folders = result.folders; + expect(folders.length).toBe(2); + expect(folders[0].name).toBe("Optional Private Folder 1"); + expect(folders[1].name).toBe("My Customer 1"); + + expect(result.folderRelationships[0]).toEqual([0, 0]); + expect(result.folderRelationships[1]).toEqual([1, 0]); + expect(result.folderRelationships[2]).toEqual([1, 1]); + }); + + it("should create collections if part of an organization", async () => { + importer.organizationId = Utils.newGuid(); + const result = await importer.parse(testDataJson); + expect(result != null).toBe(true); + + const collections = result.collections; + expect(collections.length).toBe(2); + expect(collections[0].name).toBe("Optional Private Folder 1"); + expect(collections[1].name).toBe("My Customer 1"); + + expect(result.collectionRelationships[0]).toEqual([0, 0]); + expect(result.collectionRelationships[1]).toEqual([1, 0]); + expect(result.collectionRelationships[2]).toEqual([1, 1]); + }); +}); diff --git a/spec/common/importers/testData/keeperJson/testData.ts b/spec/common/importers/testData/keeperJson/testData.ts new file mode 100644 index 0000000000..fee8f54c45 --- /dev/null +++ b/spec/common/importers/testData/keeperJson/testData.ts @@ -0,0 +1,90 @@ +import { KeeperJsonExport } from "jslib-common/importers/keeperImporters/types/keeperJsonTypes"; + +export const testData: KeeperJsonExport = { + shared_folders: [ + { + path: "My Customer 1", + manage_users: true, + manage_records: true, + can_edit: true, + can_share: true, + permissions: [ + { + uid: "kVM96KGEoGxhskZoSTd_jw", + manage_users: true, + manage_records: true, + }, + { + name: "user@mycompany.com", + manage_users: true, + manage_records: true, + }, + ], + }, + { + path: "Testing\\My Customer 2", + manage_users: true, + manage_records: true, + can_edit: true, + can_share: true, + permissions: [ + { + uid: "ih1CggiQ-3ENXcn4G0sl-g", + manage_users: true, + manage_records: true, + }, + { + name: "user@mycompany.com", + manage_users: true, + manage_records: true, + }, + ], + }, + ], + records: [ + { + title: "Bank Account 1", + login: "customer1234", + password: "4813fJDHF4239fdk", + login_url: "https://chase.com", + notes: "These are some notes.", + custom_fields: { + "Account Number": "123-456-789", + }, + folders: [ + { + folder: "Optional Private Folder 1", + }, + ], + }, + { + title: "Bank Account 2", + login: "mybankusername", + password: "w4k4k193f$^&@#*%2", + login_url: "https://amex.com", + notes: "Some great information here.", + custom_fields: { + "Security Group": "Public", + "IP Address": "12.45.67.8", + "TFC:Keeper": + "otpauth://totp/Amazon:me@company.com?secret=JBSWY3DPEHPK3PXP&issuer=Amazon&algorithm=SHA1&digits=6&period=30", + }, + folders: [ + { + folder: "Optional Private Folder 1", + }, + { + shared_folder: "My Customer 1", + can_edit: true, + can_share: true, + }, + ], + }, + { + title: "Some Account", + login: "someUserName", + password: "w4k4k1wergf$^&@#*%2", + login_url: "https://example.com", + }, + ], +};