From 154c087b97f1bb8f5f8de097c317ee3f78986194 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sat, 23 Jun 2018 09:27:30 -0400 Subject: [PATCH] importers for lastpass, bitwarden, and keepassx --- src/importers/baseImporter.ts | 187 +++++++++++++++++++++ src/importers/bitwardenCsvImporter.ts | 109 ++++++++++++ src/importers/importer.ts | 5 + src/importers/keepassxCsvImporter.ts | 67 ++++++++ src/importers/lastpassCsvImporter.ts | 232 ++++++++++++++++++++++++++ src/models/domain/importResult.ts | 13 ++ 6 files changed, 613 insertions(+) create mode 100644 src/importers/baseImporter.ts create mode 100644 src/importers/bitwardenCsvImporter.ts create mode 100644 src/importers/importer.ts create mode 100644 src/importers/keepassxCsvImporter.ts create mode 100644 src/importers/lastpassCsvImporter.ts create mode 100644 src/models/domain/importResult.ts diff --git a/src/importers/baseImporter.ts b/src/importers/baseImporter.ts new file mode 100644 index 0000000000..c65813dadb --- /dev/null +++ b/src/importers/baseImporter.ts @@ -0,0 +1,187 @@ +import * as papa from 'papaparse'; + +import { LoginUriView } from '../models/view/loginUriView'; + +export abstract class BaseImporter { + protected passwordFieldNames = [ + 'password', 'pass word', 'passphrase', 'pass phrase', + 'pass', 'code', 'code word', 'codeword', + 'secret', 'secret word', 'personpwd', + 'key', 'keyword', 'key word', 'keyphrase', 'key phrase', + 'form_pw', 'wppassword', 'pin', 'pwd', 'pw', 'pword', 'passwd', + 'p', 'serial', 'serial#', 'license key', 'reg #', + + // Non-English names + 'passwort' + ]; + + protected usernameFieldNames = [ + 'user', 'name', 'user name', 'username', 'login name', + 'email', 'e-mail', 'id', 'userid', 'user id', + 'login', 'form_loginname', 'wpname', 'mail', + 'loginid', 'login id', 'log', 'personlogin', + 'first name', 'last name', 'card#', 'account #', + 'member', 'member #', + + // Non-English names + 'nom', 'benutzername' + ]; + + protected notesFieldNames = [ + "note", "notes", "comment", "comments", "memo", + "description", "free form", "freeform", + "free text", "freetext", "free", + + // Non-English names + "kommentar" + ]; + + protected uriFieldNames: string[] = [ + 'url', 'hyper link', 'hyperlink', 'link', + 'host', 'hostname', 'host name', 'server', 'address', + 'hyper ref', 'href', 'web', 'website', 'web site', 'site', + 'web-site', 'uri', + + // Non-English names + 'ort', 'adresse' + ]; + + protected parseCsv(data: string, header: boolean): any[] { + const result = papa.parse(data, { + header: header, + encoding: 'UTF-8', + }); + if (result.errors != null && result.errors.length > 0) { + result.errors.forEach((e) => { + // tslint:disable-next-line + console.warn('Error parsing row ' + e.row + ': ' + e.message); + }); + return null; + } + return result.data; + } + + protected parseSingleRowCsv(rowData: string) { + if (this.isNullOrWhitespace(rowData)) { + return null; + } + const parsedRow = this.parseCsv(rowData, false); + if (parsedRow != null && parsedRow.length > 0 && parsedRow[0].length > 0) { + return parsedRow[0]; + } + return null; + } + + protected makeUriArray(uri: string | string[]): LoginUriView[] { + if (uri == null) { + return null; + } + + if (typeof uri === 'string') { + const loginUri = new LoginUriView(); + loginUri.uri = this.fixUri(uri); + loginUri.match = null; + return [loginUri]; + } + + if (uri.length > 0) { + const returnArr: LoginUriView[] = []; + uri.forEach((u) => { + const loginUri = new LoginUriView(); + loginUri.uri = this.fixUri(u); + loginUri.match = null; + returnArr.push(loginUri); + }); + return returnArr; + } + + return null; + } + + protected fixUri(uri: string) { + if (uri == null) { + return null; + } + uri = uri.toLowerCase().trim(); + if (uri.indexOf('://') === -1 && uri.indexOf('.') >= 0) { + uri = 'http://' + uri; + } + if (uri.length > 1000) { + return uri.substring(0, 1000); + } + return uri; + } + + protected isNullOrWhitespace(str: string): boolean { + return str == null || str.trim() === ''; + } + + protected getValueOrDefault(str: string, defaultValue: string = null): string { + if (this.isNullOrWhitespace(str)) { + return defaultValue; + } + return str; + } + + protected splitNewLine(str: string): string[] { + return str.split(/(?:\r\n|\r|\n)/); + } + + // ref https://stackoverflow.com/a/5911300 + protected getCardBrand(cardNum: string) { + if (this.isNullOrWhitespace(cardNum)) { + return null; + } + + // Visa + let re = new RegExp('^4'); + if (cardNum.match(re) != null) { + return 'Visa'; + } + + // Mastercard + // Updated for Mastercard 2017 BINs expansion + if (/^(5[1-5][0-9]{14}|2(22[1-9][0-9]{12}|2[3-9][0-9]{13}|[3-6][0-9]{14}|7[0-1][0-9]{13}|720[0-9]{12}))$/ + .test(cardNum)) { + return 'Mastercard'; + } + + // AMEX + re = new RegExp('^3[47]'); + if (cardNum.match(re) != null) { + return 'Amex'; + } + + // Discover + re = new RegExp('^(6011|622(12[6-9]|1[3-9][0-9]|[2-8][0-9]{2}|9[0-1][0-9]|92[0-5]|64[4-9])|65)'); + if (cardNum.match(re) != null) { + return 'Discover'; + } + + // Diners + re = new RegExp('^36'); + if (cardNum.match(re) != null) { + return 'Diners Club'; + } + + // Diners - Carte Blanche + re = new RegExp('^30[0-5]'); + if (cardNum.match(re) != null) { + return 'Diners Club'; + } + + // JCB + re = new RegExp('^35(2[89]|[3-8][0-9])'); + if (cardNum.match(re) != null) { + return 'JCB'; + } + + // Visa Electron + re = new RegExp('^(4026|417500|4508|4844|491(3|7))'); + if (cardNum.match(re) != null) { + return 'Visa'; + } + + return null; + } +} diff --git a/src/importers/bitwardenCsvImporter.ts b/src/importers/bitwardenCsvImporter.ts new file mode 100644 index 0000000000..ce3729468e --- /dev/null +++ b/src/importers/bitwardenCsvImporter.ts @@ -0,0 +1,109 @@ +import { BaseImporter } from './baseImporter'; +import { Importer } from './importer'; + +import { ImportResult } from '../models/domain/importResult'; + +import { CipherView } from '../models/view/cipherView'; +import { FieldView } from '../models/view/fieldView'; +import { FolderView } from '../models/view/folderView'; +import { LoginView } from '../models/view/loginView'; +import { SecureNoteView } from '../models/view/secureNoteView'; + +import { CipherType } from '../enums/cipherType'; +import { FieldType } from '../enums/fieldType'; +import { SecureNoteType } from '../enums/secureNoteType'; + +export class BitwardenCsvImporter extends BaseImporter implements Importer { + import(data: string): ImportResult { + const result = new ImportResult(); + const results = this.parseCsv(data, true); + if (results == null) { + result.success = false; + return result; + } + + results.forEach((value) => { + let folderIndex = result.folders.length; + const cipherIndex = result.ciphers.length; + const hasFolder = !this.isNullOrWhitespace(value.folder); + let addFolder = hasFolder; + + if (hasFolder) { + for (let i = 0; i < result.folders.length; i++) { + if (result.folders[i].name === value.folder) { + addFolder = false; + folderIndex = i; + break; + } + } + } + + const cipher = new CipherView(); + cipher.type = CipherType.Login; + cipher.favorite = this.getValueOrDefault(value.favorite, '0') !== '0' ? true : false; + cipher.notes = this.getValueOrDefault(value.notes); + cipher.name = this.getValueOrDefault(value.name, '--'); + + if (!this.isNullOrWhitespace(value.fields)) { + const fields = this.splitNewLine(value.fields); + for (let i = 0; i < fields.length; i++) { + if (this.isNullOrWhitespace(fields[i])) { + continue; + } + + const delimPosition = fields[i].lastIndexOf(': '); + if (delimPosition === -1) { + continue; + } + + if (cipher.fields == null) { + cipher.fields = []; + } + + const field = new FieldView(); + field.name = fields[i].substr(0, delimPosition); + field.value = null; + field.type = FieldType.Text; + if (fields[i].length > (delimPosition + 2)) { + field.value = fields[i].substr(delimPosition + 2); + } + cipher.fields.push(field); + } + } + + const valueType = value.type != null ? value.type.toLowerCase() : null; + switch (valueType) { + case 'login': + case null: + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.totp = this.getValueOrDefault(value.login_totp || value.totp); + cipher.login.username = this.getValueOrDefault(value.login_username || value.username); + cipher.login.password = this.getValueOrDefault(value.login_password || value.password); + const uris = this.parseSingleRowCsv(value.login_uri || value.uri); + cipher.login.uris = this.makeUriArray(uris); + break; + case 'note': + cipher.type = CipherType.SecureNote; + cipher.secureNote = new SecureNoteView(); + cipher.secureNote.type = SecureNoteType.Generic; + break; + default: + break; + } + + result.ciphers.push(cipher); + + if (addFolder) { + const f = new FolderView(); + f.name = value.folder; + result.folders.push(f); + } + if (hasFolder) { + result.folderRelationships.set(cipherIndex, folderIndex); + } + }); + + return result; + } +} diff --git a/src/importers/importer.ts b/src/importers/importer.ts new file mode 100644 index 0000000000..453f88440d --- /dev/null +++ b/src/importers/importer.ts @@ -0,0 +1,5 @@ +import { ImportResult } from '../models/domain/importResult'; + +export interface Importer { + import(data: string): ImportResult; +} diff --git a/src/importers/keepassxCsvImporter.ts b/src/importers/keepassxCsvImporter.ts new file mode 100644 index 0000000000..30eee8f115 --- /dev/null +++ b/src/importers/keepassxCsvImporter.ts @@ -0,0 +1,67 @@ +import { BaseImporter } from './baseImporter'; +import { Importer } from './importer'; + +import { ImportResult } from '../models/domain/importResult'; + +import { CipherView } from '../models/view/cipherView'; +import { FolderView } from '../models/view/folderView'; +import { LoginView } from '../models/view/loginView'; + +import { CipherType } from '../enums/cipherType'; + +export class KeePassXCsvImporter extends BaseImporter implements Importer { + import(data: string): ImportResult { + const result = new ImportResult(); + const results = this.parseCsv(data, true); + if (results == null) { + result.success = false; + return result; + } + + results.forEach((value) => { + value.Group = !this.isNullOrWhitespace(value.Group) && value.Group.startsWith('Root/') ? + value.Group.replace('Root/', '') : value.Group; + const groupName = !this.isNullOrWhitespace(value.Group) ? value.Group.split('/').join(' > ') : null; + + let folderIndex = result.folders.length; + const cipherIndex = result.ciphers.length; + const hasFolder = groupName != null; + let addFolder = hasFolder; + + if (hasFolder) { + for (let i = 0; i < result.folders.length; i++) { + if (result.folders[i].name === groupName) { + addFolder = false; + folderIndex = i; + break; + } + } + } + + const cipher = new CipherView(); + cipher.type = CipherType.Login; + cipher.favorite = false; + cipher.notes = this.getValueOrDefault(value.Notes); + cipher.name = this.getValueOrDefault(value.Title, '--'); + cipher.login = new LoginView(); + cipher.login.username = this.getValueOrDefault(value.Username); + cipher.login.password = this.getValueOrDefault(value.Password); + cipher.login.uris = this.makeUriArray(value.URL); + + if (!this.isNullOrWhitespace(value.Title)) { + result.ciphers.push(cipher); + } + + if (addFolder) { + const f = new FolderView(); + f.name = groupName; + result.folders.push(f); + } + if (hasFolder) { + result.folderRelationships.set(cipherIndex, folderIndex); + } + }); + + return result; + } +} diff --git a/src/importers/lastpassCsvImporter.ts b/src/importers/lastpassCsvImporter.ts new file mode 100644 index 0000000000..38809d1d49 --- /dev/null +++ b/src/importers/lastpassCsvImporter.ts @@ -0,0 +1,232 @@ +import { BaseImporter } from './baseImporter'; +import { Importer } from './importer'; + +import { ImportResult } from '../models/domain/importResult'; + +import { CardView } from '../models/view/cardView'; +import { CipherView } from '../models/view/cipherView'; +import { FolderView } from '../models/view/folderView'; +import { IdentityView } from '../models/view/identityView'; +import { LoginView } from '../models/view/loginView'; +import { SecureNoteView } from '../models/view/secureNoteView'; + +import { CipherType } from '../enums/cipherType'; +import { SecureNoteType } from '../enums/secureNoteType'; + +export class LastPassCsvImporter extends BaseImporter implements Importer { + import(data: string): ImportResult { + const result = new ImportResult(); + const results = this.parseCsv(data, true); + if (results == null) { + result.success = false; + return result; + } + + results.forEach((value) => { + let folderIndex = result.folders.length; + const cipherIndex = result.ciphers.length; + const hasFolder = this.getValueOrDefault(value.grouping, '(none)') !== '(none)'; + let addFolder = hasFolder; + + if (hasFolder) { + for (let i = 0; i < result.folders.length; i++) { + if (result.folders[i].name === value.grouping) { + addFolder = false; + folderIndex = i; + break; + } + } + } + + const cipher = this.buildBaseCipher(value); + if (cipher.type === CipherType.Login) { + cipher.notes = this.getValueOrDefault(value.extra); + cipher.login = new LoginView(); + cipher.login.uris = this.makeUriArray(value.url); + cipher.login.username = this.getValueOrDefault(value.username); + cipher.login.password = this.getValueOrDefault(value.password); + } else if (cipher.type === CipherType.SecureNote) { + this.parseSecureNote(value, cipher); + } else if (cipher.type === CipherType.Card) { + cipher.card = this.parseCard(value); + cipher.notes = this.getValueOrDefault(value.notes); + } else if (cipher.type === CipherType.Identity) { + cipher.identity = this.parseIdentity(value); + cipher.notes = this.getValueOrDefault(value.notes); + if (!this.isNullOrWhitespace(value.ccnum)) { + // there is a card on this identity too + const cardCipher = this.buildBaseCipher(value); + cardCipher.identity = null; + cardCipher.type = CipherType.Card; + cardCipher.card = this.parseCard(value); + result.ciphers.push(cardCipher); + } + } + + result.ciphers.push(cipher); + + if (addFolder) { + const f = new FolderView(); + f.name = value.grouping; + result.folders.push(f); + } + if (hasFolder) { + result.folderRelationships.set(cipherIndex, folderIndex); + } + }); + + return result; + } + + private buildBaseCipher(value: any) { + const cipher = new CipherView(); + if (value.hasOwnProperty('profilename') && value.hasOwnProperty('profilelanguage')) { + // form fill + cipher.favorite = false; + cipher.name = this.getValueOrDefault(value.profilename, '--'); + cipher.type = CipherType.Card; + + if (!this.isNullOrWhitespace(value.title) || !this.isNullOrWhitespace(value.firstname) || + !this.isNullOrWhitespace(value.lastname) || !this.isNullOrWhitespace(value.address1) || + !this.isNullOrWhitespace(value.phone) || !this.isNullOrWhitespace(value.username) || + !this.isNullOrWhitespace(value.email)) { + cipher.type = CipherType.Identity; + } + } else { + // site or secure note + cipher.favorite = this.getValueOrDefault(value.fav, '0') === '1'; // TODO: if org, always false + cipher.name = this.getValueOrDefault(value.name, '--'); + cipher.type = value.url === 'http://sn' ? CipherType.SecureNote : CipherType.Login; + } + return cipher; + } + + private parseCard(value: any): CardView { + const card = new CardView(); + card.cardholderName = this.getValueOrDefault(value.ccname); + card.number = this.getValueOrDefault(value.ccnum); + card.code = this.getValueOrDefault(value.cccsc); + card.brand = this.getCardBrand(value.ccnum); + + if (!this.isNullOrWhitespace(value.ccexp) && value.ccexp.indexOf('-') > -1) { + const ccexpParts = (value.ccexp as string).split('-'); + if (ccexpParts.length > 1) { + card.expYear = ccexpParts[0]; + card.expMonth = ccexpParts[1]; + if (card.expMonth.length === 2 && card.expMonth[0] === '0') { + card.expMonth = card.expMonth[1]; + } + } + } + + return card; + } + + private parseIdentity(value: any): IdentityView { + const identity = new IdentityView(); + identity.title = this.getValueOrDefault(value.title); + identity.firstName = this.getValueOrDefault(value.firstname); + identity.middleName = this.getValueOrDefault(value.middlename); + identity.lastName = this.getValueOrDefault(value.lastname); + identity.username = this.getValueOrDefault(value.username); + identity.company = this.getValueOrDefault(value.company); + identity.ssn = this.getValueOrDefault(value.ssn); + identity.address1 = this.getValueOrDefault(value.address1); + identity.address2 = this.getValueOrDefault(value.address2); + identity.address3 = this.getValueOrDefault(value.address3); + identity.city = this.getValueOrDefault(value.city); + identity.state = this.getValueOrDefault(value.state); + identity.postalCode = this.getValueOrDefault(value.zip); + identity.country = this.getValueOrDefault(value.country); + identity.email = this.getValueOrDefault(value.email); + identity.phone = this.getValueOrDefault(value.phone); + + if (!this.isNullOrWhitespace(identity.title)) { + identity.title = identity.title.charAt(0).toUpperCase() + identity.title.slice(1); + } + + return identity; + } + + private parseSecureNote(value: any, cipher: CipherView) { + const extraParts = this.splitNewLine(value.extra); + let processedNote = false; + + if (extraParts.length) { + const typeParts = extraParts[0].split(':'); + if (typeParts.length > 1 && typeParts[0] === 'NoteType' && + (typeParts[1] === 'Credit Card' || typeParts[1] === 'Address')) { + if (typeParts[1] === 'Credit Card') { + const mappedData = this.parseSecureNoteMapping(extraParts, { + 'Number': 'number', + 'Name on Card': 'cardholderName', + 'Security Code': 'code', + }); + cipher.type = CipherType.Card; + cipher.card = mappedData[0]; + cipher.notes = mappedData[1]; + } else if (typeParts[1] === 'Address') { + const mappedData = this.parseSecureNoteMapping(extraParts, { + 'Title': 'title', + 'First Name': 'firstName', + 'Last Name': 'lastName', + 'Middle Name': 'middleName', + 'Company': 'company', + 'Address 1': 'address1', + 'Address 2': 'address2', + 'Address 3': 'address3', + 'City / Town': 'city', + 'State': 'state', + 'Zip / Postal Code': 'postalCode', + 'Country': 'country', + 'Email Address': 'email', + 'Username': 'username', + }); + cipher.type = CipherType.Identity; + cipher.identity = mappedData[0]; + cipher.notes = mappedData[1]; + } + processedNote = true; + } + } + + if (!processedNote) { + cipher.secureNote = new SecureNoteView(); + cipher.secureNote.type = SecureNoteType.Generic; + cipher.notes = this.getValueOrDefault(value.extra); + } + } + + private parseSecureNoteMapping(extraParts: string[], map: any): [T, string] { + let notes: string = null; + const dataObj: any = {}; + + extraParts.forEach((extraPart) => { + const fieldParts = extraPart.split(':'); + if (fieldParts.length < 1 || this.isNullOrWhitespace(fieldParts[0]) || + this.isNullOrWhitespace(fieldParts[1]) || fieldParts[0] === 'NoteType') { + return; + } + + if (fieldParts[0] === 'Notes') { + if (!this.isNullOrWhitespace(notes)) { + notes += ('\n' + fieldParts[1]); + } else { + notes = fieldParts[1]; + } + } else if (map.hasOwnProperty(fieldParts[0])) { + dataObj[map[fieldParts[0]]] = fieldParts[1]; + } else { + if (!this.isNullOrWhitespace(notes)) { + notes += '\n'; + } else { + notes = ''; + } + + notes += (fieldParts[0] + ': ' + fieldParts[1]); + } + }); + + return [dataObj as T, notes]; + } +} diff --git a/src/models/domain/importResult.ts b/src/models/domain/importResult.ts new file mode 100644 index 0000000000..152d08462e --- /dev/null +++ b/src/models/domain/importResult.ts @@ -0,0 +1,13 @@ +import { CipherView } from '../view/cipherView'; +import { CollectionView } from '../view/collectionView'; +import { FolderView } from '../view/folderView'; + +export class ImportResult { + success = false; + errorMessage: string; + ciphers: CipherView[] = []; + folders: FolderView[] = []; + folderRelationships: Map = new Map(); + collections: CollectionView[] = []; + collectionRelationships: Map = new Map(); +}