From a95632294fe5f67e024a020e22db2d9e13cdfc58 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Wed, 3 Jan 2018 21:20:41 -0500 Subject: [PATCH] copy common ts code over from browser repo --- src/enums/browserType.enum.ts | 8 + src/enums/cipherType.enum.ts | 6 + src/enums/encryptionType.enum.ts | 9 + src/enums/fieldType.enum.ts | 5 + src/enums/secureNoteType.enum.ts | 3 + src/models/data/attachmentData.ts | 20 + src/models/data/cardData.ts | 20 + src/models/data/cipherData.ts | 87 ++ src/models/data/collectionData.ts | 16 + src/models/data/fieldData.ts | 16 + src/models/data/folderData.ts | 18 + src/models/data/identityData.ts | 44 + src/models/data/loginData.ts | 16 + src/models/data/secureNoteData.ts | 12 + src/models/domain/attachment.ts | 43 + src/models/domain/autofillField.ts | 22 + src/models/domain/autofillForm.ts | 7 + src/models/domain/autofillPageDetails.ts | 13 + src/models/domain/autofillScript.ts | 12 + src/models/domain/card.ts | 43 + src/models/domain/cipher.ts | 192 ++++ src/models/domain/cipherString.ts | 115 +++ src/models/domain/collection.ts | 37 + src/models/domain/domain.ts | 46 + src/models/domain/encryptedObject.ts | 8 + src/models/domain/environmentUrls.ts | 5 + src/models/domain/field.ts | 39 + src/models/domain/folder.ts | 34 + src/models/domain/identity.ts | 79 ++ src/models/domain/login.ts | 37 + src/models/domain/passwordHistory.ts | 9 + src/models/domain/secureNote.ts | 27 + src/models/domain/symmetricCryptoKey.ts | 80 ++ .../domain/symmetricCryptoKeyBuffers.ts | 9 + src/models/request/cipherRequest.ts | 89 ++ src/models/request/deviceRequest.ts | 19 + src/models/request/deviceTokenRequest.ts | 10 + src/models/request/folderRequest.ts | 12 + src/models/request/passwordHintRequest.ts | 10 + src/models/request/registerRequest.ts | 18 + src/models/request/tokenRequest.ts | 49 + src/models/request/twoFactorEmailRequest.ts | 12 + src/models/response/attachmentResponse.ts | 18 + src/models/response/cipherResponse.ts | 44 + src/models/response/collectionResponse.ts | 14 + src/models/response/deviceResponse.ts | 20 + src/models/response/domainsResponse.ts | 20 + src/models/response/errorResponse.ts | 37 + src/models/response/folderResponse.ts | 14 + src/models/response/globalDomainResponse.ts | 14 + src/models/response/identityTokenResponse.ts | 24 + src/models/response/keysResponse.ts | 12 + src/models/response/listResponse.ts | 10 + .../response/profileOrganizationResponse.ts | 30 + src/models/response/profileResponse.ts | 39 + src/models/response/syncResponse.ts | 44 + src/services/abstractions/crypto.service.ts | 28 + src/services/abstractions/utils.service.ts | 22 + src/services/api.service.ts | 454 +++++++++ src/services/appId.service.ts | 31 + src/services/autofill.service.ts | 872 ++++++++++++++++++ src/services/cipher.service.ts | 514 +++++++++++ src/services/collection.service.ts | 124 +++ src/services/constants.service.ts | 116 +++ src/services/crypto.service.ts | 597 ++++++++++++ src/services/environment.service.ts | 87 ++ src/services/folder.service.ts | 161 ++++ src/services/i18n.service.ts | 28 + src/services/lock.service.ts | 89 ++ src/services/passwordGeneration.service.ts | 237 +++++ src/services/settings.service.ts | 61 ++ src/services/sync.service.ts | 180 ++++ src/services/token.service.ts | 170 ++++ src/services/totp.service.ts | 113 +++ src/services/user.service.ts | 79 ++ src/services/utils.service.spec.ts | 113 +++ src/services/utils.service.ts | 407 ++++++++ 77 files changed, 6179 insertions(+) create mode 100644 src/enums/browserType.enum.ts create mode 100644 src/enums/cipherType.enum.ts create mode 100644 src/enums/encryptionType.enum.ts create mode 100644 src/enums/fieldType.enum.ts create mode 100644 src/enums/secureNoteType.enum.ts create mode 100644 src/models/data/attachmentData.ts create mode 100644 src/models/data/cardData.ts create mode 100644 src/models/data/cipherData.ts create mode 100644 src/models/data/collectionData.ts create mode 100644 src/models/data/fieldData.ts create mode 100644 src/models/data/folderData.ts create mode 100644 src/models/data/identityData.ts create mode 100644 src/models/data/loginData.ts create mode 100644 src/models/data/secureNoteData.ts create mode 100644 src/models/domain/attachment.ts create mode 100644 src/models/domain/autofillField.ts create mode 100644 src/models/domain/autofillForm.ts create mode 100644 src/models/domain/autofillPageDetails.ts create mode 100644 src/models/domain/autofillScript.ts create mode 100644 src/models/domain/card.ts create mode 100644 src/models/domain/cipher.ts create mode 100644 src/models/domain/cipherString.ts create mode 100644 src/models/domain/collection.ts create mode 100644 src/models/domain/domain.ts create mode 100644 src/models/domain/encryptedObject.ts create mode 100644 src/models/domain/environmentUrls.ts create mode 100644 src/models/domain/field.ts create mode 100644 src/models/domain/folder.ts create mode 100644 src/models/domain/identity.ts create mode 100644 src/models/domain/login.ts create mode 100644 src/models/domain/passwordHistory.ts create mode 100644 src/models/domain/secureNote.ts create mode 100644 src/models/domain/symmetricCryptoKey.ts create mode 100644 src/models/domain/symmetricCryptoKeyBuffers.ts create mode 100644 src/models/request/cipherRequest.ts create mode 100644 src/models/request/deviceRequest.ts create mode 100644 src/models/request/deviceTokenRequest.ts create mode 100644 src/models/request/folderRequest.ts create mode 100644 src/models/request/passwordHintRequest.ts create mode 100644 src/models/request/registerRequest.ts create mode 100644 src/models/request/tokenRequest.ts create mode 100644 src/models/request/twoFactorEmailRequest.ts create mode 100644 src/models/response/attachmentResponse.ts create mode 100644 src/models/response/cipherResponse.ts create mode 100644 src/models/response/collectionResponse.ts create mode 100644 src/models/response/deviceResponse.ts create mode 100644 src/models/response/domainsResponse.ts create mode 100644 src/models/response/errorResponse.ts create mode 100644 src/models/response/folderResponse.ts create mode 100644 src/models/response/globalDomainResponse.ts create mode 100644 src/models/response/identityTokenResponse.ts create mode 100644 src/models/response/keysResponse.ts create mode 100644 src/models/response/listResponse.ts create mode 100644 src/models/response/profileOrganizationResponse.ts create mode 100644 src/models/response/profileResponse.ts create mode 100644 src/models/response/syncResponse.ts create mode 100644 src/services/abstractions/crypto.service.ts create mode 100644 src/services/abstractions/utils.service.ts create mode 100644 src/services/api.service.ts create mode 100644 src/services/appId.service.ts create mode 100644 src/services/autofill.service.ts create mode 100644 src/services/cipher.service.ts create mode 100644 src/services/collection.service.ts create mode 100644 src/services/constants.service.ts create mode 100644 src/services/crypto.service.ts create mode 100644 src/services/environment.service.ts create mode 100644 src/services/folder.service.ts create mode 100644 src/services/i18n.service.ts create mode 100644 src/services/lock.service.ts create mode 100644 src/services/passwordGeneration.service.ts create mode 100644 src/services/settings.service.ts create mode 100644 src/services/sync.service.ts create mode 100644 src/services/token.service.ts create mode 100644 src/services/totp.service.ts create mode 100644 src/services/user.service.ts create mode 100644 src/services/utils.service.spec.ts create mode 100644 src/services/utils.service.ts diff --git a/src/enums/browserType.enum.ts b/src/enums/browserType.enum.ts new file mode 100644 index 0000000000..64da6fb16b --- /dev/null +++ b/src/enums/browserType.enum.ts @@ -0,0 +1,8 @@ +export enum BrowserType { + Chrome = 2, + Firefox = 3, + Opera = 4, + Edge = 5, + Vivaldi = 19, + Safari = 20, +} diff --git a/src/enums/cipherType.enum.ts b/src/enums/cipherType.enum.ts new file mode 100644 index 0000000000..c081fb2d8c --- /dev/null +++ b/src/enums/cipherType.enum.ts @@ -0,0 +1,6 @@ +export enum CipherType { + Login = 1, + SecureNote = 2, + Card = 3, + Identity = 4, +} diff --git a/src/enums/encryptionType.enum.ts b/src/enums/encryptionType.enum.ts new file mode 100644 index 0000000000..7a0caa6606 --- /dev/null +++ b/src/enums/encryptionType.enum.ts @@ -0,0 +1,9 @@ +export enum EncryptionType { + AesCbc256_B64 = 0, + AesCbc128_HmacSha256_B64 = 1, + AesCbc256_HmacSha256_B64 = 2, + Rsa2048_OaepSha256_B64 = 3, + Rsa2048_OaepSha1_B64 = 4, + Rsa2048_OaepSha256_HmacSha256_B64 = 5, + Rsa2048_OaepSha1_HmacSha256_B64 = 6, +} diff --git a/src/enums/fieldType.enum.ts b/src/enums/fieldType.enum.ts new file mode 100644 index 0000000000..c28b26c1da --- /dev/null +++ b/src/enums/fieldType.enum.ts @@ -0,0 +1,5 @@ +export enum FieldType { + Text = 0, + Hidden = 1, + Boolean = 2, +} diff --git a/src/enums/secureNoteType.enum.ts b/src/enums/secureNoteType.enum.ts new file mode 100644 index 0000000000..c7f3e44a78 --- /dev/null +++ b/src/enums/secureNoteType.enum.ts @@ -0,0 +1,3 @@ +export enum SecureNoteType { + Generic = 0, +} diff --git a/src/models/data/attachmentData.ts b/src/models/data/attachmentData.ts new file mode 100644 index 0000000000..2ff3ab423c --- /dev/null +++ b/src/models/data/attachmentData.ts @@ -0,0 +1,20 @@ +import { AttachmentResponse } from '../response/attachmentResponse'; + +class AttachmentData { + id: string; + url: string; + fileName: string; + size: number; + sizeName: string; + + constructor(response: AttachmentResponse) { + this.id = response.id; + this.url = response.url; + this.fileName = response.fileName; + this.size = response.size; + this.sizeName = response.sizeName; + } +} + +export { AttachmentData }; +(window as any).AttachmentData = AttachmentData; diff --git a/src/models/data/cardData.ts b/src/models/data/cardData.ts new file mode 100644 index 0000000000..f0b9f63f2e --- /dev/null +++ b/src/models/data/cardData.ts @@ -0,0 +1,20 @@ +class CardData { + cardholderName: string; + brand: string; + number: string; + expMonth: string; + expYear: string; + code: string; + + constructor(data: any) { + this.cardholderName = data.CardholderName; + this.brand = data.Brand; + this.number = data.Number; + this.expMonth = data.ExpMonth; + this.expYear = data.ExpYear; + this.code = data.Code; + } +} + +export { CardData }; +(window as any).CardData = CardData; diff --git a/src/models/data/cipherData.ts b/src/models/data/cipherData.ts new file mode 100644 index 0000000000..413612122f --- /dev/null +++ b/src/models/data/cipherData.ts @@ -0,0 +1,87 @@ +import { CipherType } from '../../enums/cipherType.enum'; + +import { AttachmentData } from './attachmentData'; +import { CardData } from './cardData'; +import { FieldData } from './fieldData'; +import { IdentityData } from './identityData'; +import { LoginData } from './loginData'; +import { SecureNoteData } from './secureNoteData'; + +import { CipherResponse } from '../response/cipherResponse'; + +class CipherData { + id: string; + organizationId: string; + folderId: string; + userId: string; + edit: boolean; + organizationUseTotp: boolean; + favorite: boolean; + revisionDate: string; + type: CipherType; + sizeName: string; + name: string; + notes: string; + login?: LoginData; + secureNote?: SecureNoteData; + card?: CardData; + identity?: IdentityData; + fields?: FieldData[]; + attachments?: AttachmentData[]; + collectionIds?: string[]; + + constructor(response: CipherResponse, userId: string, collectionIds?: string[]) { + this.id = response.id; + this.organizationId = response.organizationId; + this.folderId = response.folderId; + this.userId = userId; + this.edit = response.edit; + this.organizationUseTotp = response.organizationUseTotp; + this.favorite = response.favorite; + this.revisionDate = response.revisionDate; + this.type = response.type; + + if (collectionIds != null) { + this.collectionIds = collectionIds; + } else { + this.collectionIds = response.collectionIds; + } + + this.name = response.data.Name; + this.notes = response.data.Notes; + + switch (this.type) { + case CipherType.Login: + this.login = new LoginData(response.data); + break; + case CipherType.SecureNote: + this.secureNote = new SecureNoteData(response.data); + break; + case CipherType.Card: + this.card = new CardData(response.data); + break; + case CipherType.Identity: + this.identity = new IdentityData(response.data); + break; + default: + break; + } + + if (response.data.Fields != null) { + this.fields = []; + response.data.Fields.forEach((field: any) => { + this.fields.push(new FieldData(field)); + }); + } + + if (response.attachments != null) { + this.attachments = []; + response.attachments.forEach((attachment) => { + this.attachments.push(new AttachmentData(attachment)); + }); + } + } +} + +export { CipherData }; +(window as any).CipherData = CipherData; diff --git a/src/models/data/collectionData.ts b/src/models/data/collectionData.ts new file mode 100644 index 0000000000..f2d5fc9f03 --- /dev/null +++ b/src/models/data/collectionData.ts @@ -0,0 +1,16 @@ +import { CollectionResponse } from '../response/collectionResponse'; + +class CollectionData { + id: string; + organizationId: string; + name: string; + + constructor(response: CollectionResponse) { + this.id = response.id; + this.organizationId = response.organizationId; + this.name = response.name; + } +} + +export { CollectionData }; +(window as any).CollectionData = CollectionData; diff --git a/src/models/data/fieldData.ts b/src/models/data/fieldData.ts new file mode 100644 index 0000000000..4914bb6ef9 --- /dev/null +++ b/src/models/data/fieldData.ts @@ -0,0 +1,16 @@ +import { FieldType } from '../../enums/fieldType.enum'; + +class FieldData { + type: FieldType; + name: string; + value: string; + + constructor(response: any) { + this.type = response.Type; + this.name = response.Name; + this.value = response.Value; + } +} + +export { FieldData }; +(window as any).FieldData = FieldData; diff --git a/src/models/data/folderData.ts b/src/models/data/folderData.ts new file mode 100644 index 0000000000..6f03781cc7 --- /dev/null +++ b/src/models/data/folderData.ts @@ -0,0 +1,18 @@ +import { FolderResponse } from '../response/folderResponse'; + +class FolderData { + id: string; + userId: string; + name: string; + revisionDate: string; + + constructor(response: FolderResponse, userId: string) { + this.userId = userId; + this.name = response.name; + this.id = response.id; + this.revisionDate = response.revisionDate; + } +} + +export { FolderData }; +(window as any).FolderData = FolderData; diff --git a/src/models/data/identityData.ts b/src/models/data/identityData.ts new file mode 100644 index 0000000000..ee849166ee --- /dev/null +++ b/src/models/data/identityData.ts @@ -0,0 +1,44 @@ +class IdentityData { + title: string; + firstName: string; + middleName: string; + lastName: string; + address1: string; + address2: string; + address3: string; + city: string; + state: string; + postalCode: string; + country: string; + company: string; + email: string; + phone: string; + ssn: string; + username: string; + passportNumber: string; + licenseNumber: string; + + constructor(data: any) { + this.title = data.Title; + this.firstName = data.FirstName; + this.middleName = data.MiddleName; + this.lastName = data.LastName; + this.address1 = data.Address1; + this.address2 = data.Address2; + this.address3 = data.Address3; + this.city = data.City; + this.state = data.State; + this.postalCode = data.PostalCode; + this.country = data.Country; + this.company = data.Company; + this.email = data.Email; + this.phone = data.Phone; + this.ssn = data.SSN; + this.username = data.Username; + this.passportNumber = data.PassportNumber; + this.licenseNumber = data.LicenseNumber; + } +} + +export { IdentityData }; +(window as any).IdentityData = IdentityData; diff --git a/src/models/data/loginData.ts b/src/models/data/loginData.ts new file mode 100644 index 0000000000..de0aecc133 --- /dev/null +++ b/src/models/data/loginData.ts @@ -0,0 +1,16 @@ +class LoginData { + uri: string; + username: string; + password: string; + totp: string; + + constructor(data: any) { + this.uri = data.Uri; + this.username = data.Username; + this.password = data.Password; + this.totp = data.Totp; + } +} + +export { LoginData }; +(window as any).LoginData = LoginData; diff --git a/src/models/data/secureNoteData.ts b/src/models/data/secureNoteData.ts new file mode 100644 index 0000000000..ccfc9bd614 --- /dev/null +++ b/src/models/data/secureNoteData.ts @@ -0,0 +1,12 @@ +import { SecureNoteType } from '../../enums/secureNoteType.enum'; + +class SecureNoteData { + type: SecureNoteType; + + constructor(data: any) { + this.type = data.Type; + } +} + +export { SecureNoteData }; +(window as any).SecureNoteData = SecureNoteData; diff --git a/src/models/domain/attachment.ts b/src/models/domain/attachment.ts new file mode 100644 index 0000000000..d77152b007 --- /dev/null +++ b/src/models/domain/attachment.ts @@ -0,0 +1,43 @@ +import { AttachmentData } from '../data/attachmentData'; + +import { CipherString } from './cipherString'; +import Domain from './domain'; + +class Attachment extends Domain { + id: string; + url: string; + size: number; + sizeName: string; + fileName: CipherString; + + constructor(obj?: AttachmentData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.size = obj.size; + this.buildDomainModel(this, obj, { + id: null, + url: null, + sizeName: null, + fileName: null, + }, alreadyEncrypted, ['id', 'url', 'sizeName']); + } + + decrypt(orgId: string): Promise { + const model = { + id: this.id, + size: this.size, + sizeName: this.sizeName, + url: this.url, + }; + + return this.decryptObj(model, { + fileName: null, + }, orgId); + } +} + +export { Attachment }; +(window as any).Attachment = Attachment; diff --git a/src/models/domain/autofillField.ts b/src/models/domain/autofillField.ts new file mode 100644 index 0000000000..dfa6cdc770 --- /dev/null +++ b/src/models/domain/autofillField.ts @@ -0,0 +1,22 @@ +export default class AutofillField { + opid: string; + elementNumber: number; + visible: boolean; + viewable: boolean; + htmlID: string; + htmlName: string; + htmlClass: string; + 'label-left': string; + 'label-right': string; + 'label-top': string; + 'label-tag': string; + placeholder: string; + type: string; + value: string; + disabled: boolean; + readonly: boolean; + onePasswordFieldType: string; + form: string; + autoCompleteType: string; + selectInfo: any; +} diff --git a/src/models/domain/autofillForm.ts b/src/models/domain/autofillForm.ts new file mode 100644 index 0000000000..2d7fc4800b --- /dev/null +++ b/src/models/domain/autofillForm.ts @@ -0,0 +1,7 @@ +export default class AutofillForm { + opid: string; + htmlName: string; + htmlID: string; + htmlAction: string; + htmlMethod: string; +} diff --git a/src/models/domain/autofillPageDetails.ts b/src/models/domain/autofillPageDetails.ts new file mode 100644 index 0000000000..70f922bbe6 --- /dev/null +++ b/src/models/domain/autofillPageDetails.ts @@ -0,0 +1,13 @@ +import AutofillField from './autofillField'; +import AutofillForm from './autofillForm'; + +export default class AutofillPageDetails { + documentUUID: string; + title: string; + url: string; + documentUrl: string; + tabUrl: string; + forms: { [id: string]: AutofillForm; }; + fields: AutofillField[]; + collectedTimestamp: number; +} diff --git a/src/models/domain/autofillScript.ts b/src/models/domain/autofillScript.ts new file mode 100644 index 0000000000..875e620ea8 --- /dev/null +++ b/src/models/domain/autofillScript.ts @@ -0,0 +1,12 @@ +export default class AutofillScript { + script: string[][] = []; + documentUUID: any = {}; + properties: any = {}; + options: any = {}; + metadata: any = {}; + autosubmit: any = null; + + constructor(documentUUID: string) { + this.documentUUID = documentUUID; + } +} diff --git a/src/models/domain/card.ts b/src/models/domain/card.ts new file mode 100644 index 0000000000..3e42f7affb --- /dev/null +++ b/src/models/domain/card.ts @@ -0,0 +1,43 @@ +import { CardData } from '../data/cardData'; + +import { CipherString } from './cipherString'; +import Domain from './domain'; + +class Card extends Domain { + cardholderName: CipherString; + brand: CipherString; + number: CipherString; + expMonth: CipherString; + expYear: CipherString; + code: CipherString; + + constructor(obj?: CardData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel(this, obj, { + cardholderName: null, + brand: null, + number: null, + expMonth: null, + expYear: null, + code: null, + }, alreadyEncrypted, []); + } + + decrypt(orgId: string): Promise { + return this.decryptObj({}, { + cardholderName: null, + brand: null, + number: null, + expMonth: null, + expYear: null, + code: null, + }, orgId); + } +} + +export { Card }; +(window as any).Card = Card; diff --git a/src/models/domain/cipher.ts b/src/models/domain/cipher.ts new file mode 100644 index 0000000000..5d51c82828 --- /dev/null +++ b/src/models/domain/cipher.ts @@ -0,0 +1,192 @@ +import { CipherType } from '../../enums/cipherType.enum'; + +import { CipherData } from '../data/cipherData'; + +import { Attachment } from './attachment'; +import { Card } from './card'; +import { CipherString } from './cipherString'; +import Domain from './domain'; +import { Field } from './field'; +import { Identity } from './identity'; +import { Login } from './login'; +import { SecureNote } from './secureNote'; + +import { UtilsService } from '../../services/abstractions/utils.service'; + +class Cipher extends Domain { + id: string; + organizationId: string; + folderId: string; + name: CipherString; + notes: CipherString; + type: CipherType; + favorite: boolean; + organizationUseTotp: boolean; + edit: boolean; + localData: any; + login: Login; + identity: Identity; + card: Card; + secureNote: SecureNote; + attachments: Attachment[]; + fields: Field[]; + collectionIds: string[]; + + private utilsService: UtilsService; + + constructor(obj?: CipherData, alreadyEncrypted: boolean = false, localData: any = null) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel(this, obj, { + id: null, + organizationId: null, + folderId: null, + name: null, + notes: null, + }, alreadyEncrypted, ['id', 'organizationId', 'folderId']); + + this.type = obj.type; + this.favorite = obj.favorite; + this.organizationUseTotp = obj.organizationUseTotp; + this.edit = obj.edit; + this.collectionIds = obj.collectionIds; + this.localData = localData; + + switch (this.type) { + case CipherType.Login: + this.login = new Login(obj.login, alreadyEncrypted); + break; + case CipherType.SecureNote: + this.secureNote = new SecureNote(obj.secureNote, alreadyEncrypted); + break; + case CipherType.Card: + this.card = new Card(obj.card, alreadyEncrypted); + break; + case CipherType.Identity: + this.identity = new Identity(obj.identity, alreadyEncrypted); + break; + default: + break; + } + + if (obj.attachments != null) { + this.attachments = []; + obj.attachments.forEach((attachment) => { + this.attachments.push(new Attachment(attachment, alreadyEncrypted)); + }); + } else { + this.attachments = null; + } + + if (obj.fields != null) { + this.fields = []; + obj.fields.forEach((field) => { + this.fields.push(new Field(field, alreadyEncrypted)); + }); + } else { + this.fields = null; + } + } + + async decrypt(): Promise { + const model = { + id: this.id, + organizationId: this.organizationId, + folderId: this.folderId, + favorite: this.favorite, + type: this.type, + localData: this.localData, + login: null as any, + card: null as any, + identity: null as any, + secureNote: null as any, + subTitle: null as string, + attachments: null as any[], + fields: null as any[], + collectionIds: this.collectionIds, + }; + + await this.decryptObj(model, { + name: null, + notes: null, + }, this.organizationId); + + switch (this.type) { + case CipherType.Login: + model.login = await this.login.decrypt(this.organizationId); + model.subTitle = model.login.username; + if (model.login.uri) { + if (this.utilsService == null) { + this.utilsService = chrome.extension.getBackgroundPage() + .bitwardenMain.utilsService as UtilsService; + } + + model.login.domain = this.utilsService.getDomain(model.login.uri); + } + break; + case CipherType.SecureNote: + model.secureNote = await this.secureNote.decrypt(this.organizationId); + model.subTitle = null; + break; + case CipherType.Card: + model.card = await this.card.decrypt(this.organizationId); + model.subTitle = model.card.brand; + if (model.card.number && model.card.number.length >= 4) { + if (model.subTitle !== '') { + model.subTitle += ', '; + } + model.subTitle += ('*' + model.card.number.substr(model.card.number.length - 4)); + } + break; + case CipherType.Identity: + model.identity = await this.identity.decrypt(this.organizationId); + model.subTitle = ''; + if (model.identity.firstName) { + model.subTitle = model.identity.firstName; + } + if (model.identity.lastName) { + if (model.subTitle !== '') { + model.subTitle += ' '; + } + model.subTitle += model.identity.lastName; + } + break; + default: + break; + } + + const orgId = this.organizationId; + + if (this.attachments != null && this.attachments.length > 0) { + const attachments: any[] = []; + await this.attachments.reduce((promise, attachment) => { + return promise.then(() => { + return attachment.decrypt(orgId); + }).then((decAttachment) => { + attachments.push(decAttachment); + }); + }, Promise.resolve()); + model.attachments = attachments; + } + + if (this.fields != null && this.fields.length > 0) { + const fields: any[] = []; + await this.fields.reduce((promise, field) => { + return promise.then(() => { + return field.decrypt(orgId); + }).then((decField) => { + fields.push(decField); + }); + }, Promise.resolve()); + model.fields = fields; + } + + return model; + } +} + +export { Cipher }; +(window as any).Cipher = Cipher; diff --git a/src/models/domain/cipherString.ts b/src/models/domain/cipherString.ts new file mode 100644 index 0000000000..61aec8ca6f --- /dev/null +++ b/src/models/domain/cipherString.ts @@ -0,0 +1,115 @@ +import { EncryptionType } from '../../enums/encryptionType.enum'; +import { CryptoService } from '../../services/abstractions/crypto.service'; + +class CipherString { + encryptedString?: string; + encryptionType?: EncryptionType; + decryptedValue?: string; + cipherText?: string; + initializationVector?: string; + mac?: string; + + private cryptoService: CryptoService; + + constructor(encryptedStringOrType: string | EncryptionType, ct?: string, iv?: string, mac?: string) { + if (ct != null) { + // ct and header + const encType = encryptedStringOrType as EncryptionType; + this.encryptedString = encType + '.' + ct; + + // iv + if (iv != null) { + this.encryptedString += ('|' + iv); + } + + // mac + if (mac != null) { + this.encryptedString += ('|' + mac); + } + + this.encryptionType = encType; + this.cipherText = ct; + this.initializationVector = iv; + this.mac = mac; + + return; + } + + this.encryptedString = encryptedStringOrType as string; + if (!this.encryptedString) { + return; + } + + const headerPieces = this.encryptedString.split('.'); + let encPieces: string[] = null; + + if (headerPieces.length === 2) { + try { + this.encryptionType = parseInt(headerPieces[0], null); + encPieces = headerPieces[1].split('|'); + } catch (e) { + return; + } + } else { + encPieces = this.encryptedString.split('|'); + this.encryptionType = encPieces.length === 3 ? EncryptionType.AesCbc128_HmacSha256_B64 : + EncryptionType.AesCbc256_B64; + } + + switch (this.encryptionType) { + case EncryptionType.AesCbc128_HmacSha256_B64: + case EncryptionType.AesCbc256_HmacSha256_B64: + if (encPieces.length !== 3) { + return; + } + + this.initializationVector = encPieces[0]; + this.cipherText = encPieces[1]; + this.mac = encPieces[2]; + break; + case EncryptionType.AesCbc256_B64: + if (encPieces.length !== 2) { + return; + } + + this.initializationVector = encPieces[0]; + this.cipherText = encPieces[1]; + break; + case EncryptionType.Rsa2048_OaepSha256_B64: + case EncryptionType.Rsa2048_OaepSha1_B64: + if (encPieces.length !== 1) { + return; + } + + this.cipherText = encPieces[0]; + break; + default: + return; + } + } + + decrypt(orgId: string) { + if (this.decryptedValue) { + return Promise.resolve(this.decryptedValue); + } + + const self = this; + if (this.cryptoService == null) { + this.cryptoService = chrome.extension.getBackgroundPage() + .bitwardenMain.cryptoService as CryptoService; + } + + return this.cryptoService.getOrgKey(orgId).then((orgKey: any) => { + return self.cryptoService.decrypt(self, orgKey); + }).then((decValue: any) => { + self.decryptedValue = decValue; + return self.decryptedValue; + }).catch(() => { + self.decryptedValue = '[error: cannot decrypt]'; + return self.decryptedValue; + }); + } +} + +export { CipherString }; +(window as any).CipherString = CipherString; diff --git a/src/models/domain/collection.ts b/src/models/domain/collection.ts new file mode 100644 index 0000000000..0a5079e53a --- /dev/null +++ b/src/models/domain/collection.ts @@ -0,0 +1,37 @@ +import { CollectionData } from '../data/collectionData'; + +import { CipherString } from './cipherString'; +import Domain from './domain'; + +class Collection extends Domain { + id: string; + organizationId: string; + name: CipherString; + + constructor(obj?: CollectionData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel(this, obj, { + id: null, + organizationId: null, + name: null, + }, alreadyEncrypted, ['id', 'organizationId']); + } + + decrypt(): Promise { + const model = { + id: this.id, + organizationId: this.organizationId, + }; + + return this.decryptObj(model, { + name: null, + }, this.organizationId); + } +} + +export { Collection }; +(window as any).Collection = Collection; diff --git a/src/models/domain/domain.ts b/src/models/domain/domain.ts new file mode 100644 index 0000000000..cdb220776b --- /dev/null +++ b/src/models/domain/domain.ts @@ -0,0 +1,46 @@ +import { CipherString } from '../domain/cipherString'; + +export default abstract class Domain { + protected buildDomainModel(model: any, obj: any, map: any, alreadyEncrypted: boolean, notEncList: any[] = []) { + for (const prop in map) { + if (!map.hasOwnProperty(prop)) { + continue; + } + + const objProp = obj[(map[prop] || prop)]; + if (alreadyEncrypted === true || notEncList.indexOf(prop) > -1) { + model[prop] = objProp ? objProp : null; + } else { + model[prop] = objProp ? new CipherString(objProp) : null; + } + } + } + + protected async decryptObj(model: any, map: any, orgId: string) { + const promises = []; + const self: any = this; + + for (const prop in map) { + if (!map.hasOwnProperty(prop)) { + continue; + } + + // tslint:disable-next-line + (function (theProp) { + const p = Promise.resolve().then(() => { + const mapProp = map[theProp] || theProp; + if (self[mapProp]) { + return self[mapProp].decrypt(orgId); + } + return null; + }).then((val: any) => { + model[theProp] = val; + }); + promises.push(p); + })(prop); + } + + await Promise.all(promises); + return model; + } +} diff --git a/src/models/domain/encryptedObject.ts b/src/models/domain/encryptedObject.ts new file mode 100644 index 0000000000..668c30e262 --- /dev/null +++ b/src/models/domain/encryptedObject.ts @@ -0,0 +1,8 @@ +import SymmetricCryptoKey from './symmetricCryptoKey'; + +export default class EncryptedObject { + iv: Uint8Array; + ct: Uint8Array; + mac: Uint8Array; + key: SymmetricCryptoKey; +} diff --git a/src/models/domain/environmentUrls.ts b/src/models/domain/environmentUrls.ts new file mode 100644 index 0000000000..0eec60a114 --- /dev/null +++ b/src/models/domain/environmentUrls.ts @@ -0,0 +1,5 @@ +export default class EnvironmentUrls { + base: string; + api: string; + identity: string; +} diff --git a/src/models/domain/field.ts b/src/models/domain/field.ts new file mode 100644 index 0000000000..decc78664f --- /dev/null +++ b/src/models/domain/field.ts @@ -0,0 +1,39 @@ +import { FieldType } from '../../enums/fieldType.enum'; + +import { FieldData } from '../data/fieldData'; + +import { CipherString } from './cipherString'; +import Domain from './domain'; + +class Field extends Domain { + name: CipherString; + vault: CipherString; + type: FieldType; + + constructor(obj?: FieldData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.type = obj.type; + this.buildDomainModel(this, obj, { + name: null, + value: null, + }, alreadyEncrypted, []); + } + + decrypt(orgId: string): Promise { + const model = { + type: this.type, + }; + + return this.decryptObj(model, { + name: null, + value: null, + }, orgId); + } +} + +export { Field }; +(window as any).Field = Field; diff --git a/src/models/domain/folder.ts b/src/models/domain/folder.ts new file mode 100644 index 0000000000..180cd44e4f --- /dev/null +++ b/src/models/domain/folder.ts @@ -0,0 +1,34 @@ +import { FolderData } from '../data/folderData'; + +import { CipherString } from './cipherString'; +import Domain from './domain'; + +class Folder extends Domain { + id: string; + name: CipherString; + + constructor(obj?: FolderData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel(this, obj, { + id: null, + name: null, + }, alreadyEncrypted, ['id']); + } + + decrypt(): Promise { + const model = { + id: this.id, + }; + + return this.decryptObj(model, { + name: null, + }, null); + } +} + +export { Folder }; +(window as any).Folder = Folder; diff --git a/src/models/domain/identity.ts b/src/models/domain/identity.ts new file mode 100644 index 0000000000..ede933ac30 --- /dev/null +++ b/src/models/domain/identity.ts @@ -0,0 +1,79 @@ +import { IdentityData } from '../data/identityData'; + +import { CipherString } from './cipherString'; +import Domain from './domain'; + +class Identity extends Domain { + title: CipherString; + firstName: CipherString; + middleName: CipherString; + lastName: CipherString; + address1: CipherString; + address2: CipherString; + address3: CipherString; + city: CipherString; + state: CipherString; + postalCode: CipherString; + country: CipherString; + company: CipherString; + email: CipherString; + phone: CipherString; + ssn: CipherString; + username: CipherString; + passportNumber: CipherString; + licenseNumber: CipherString; + + constructor(obj?: IdentityData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel(this, obj, { + title: null, + firstName: null, + middleName: null, + lastName: null, + address1: null, + address2: null, + address3: null, + city: null, + state: null, + postalCode: null, + country: null, + company: null, + email: null, + phone: null, + ssn: null, + username: null, + passportNumber: null, + licenseNumber: null, + }, alreadyEncrypted, []); + } + + decrypt(orgId: string): Promise { + return this.decryptObj({}, { + title: null, + firstName: null, + middleName: null, + lastName: null, + address1: null, + address2: null, + address3: null, + city: null, + state: null, + postalCode: null, + country: null, + company: null, + email: null, + phone: null, + ssn: null, + username: null, + passportNumber: null, + licenseNumber: null, + }, orgId); + } +} + +export { Identity }; +(window as any).Identity = Identity; diff --git a/src/models/domain/login.ts b/src/models/domain/login.ts new file mode 100644 index 0000000000..8ed1e1f7c7 --- /dev/null +++ b/src/models/domain/login.ts @@ -0,0 +1,37 @@ +import { LoginData } from '../data/loginData'; + +import { CipherString } from './cipherString'; +import Domain from './domain'; + +class Login extends Domain { + uri: CipherString; + username: CipherString; + password: CipherString; + totp: CipherString; + + constructor(obj?: LoginData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel(this, obj, { + uri: null, + username: null, + password: null, + totp: null, + }, alreadyEncrypted, []); + } + + decrypt(orgId: string): Promise { + return this.decryptObj({}, { + uri: null, + username: null, + password: null, + totp: null, + }, orgId); + } +} + +export { Login }; +(window as any).Login = Login; diff --git a/src/models/domain/passwordHistory.ts b/src/models/domain/passwordHistory.ts new file mode 100644 index 0000000000..fc4eb56688 --- /dev/null +++ b/src/models/domain/passwordHistory.ts @@ -0,0 +1,9 @@ +export default class PasswordHistory { + password: string; + date: number; + + constructor(password: string, date: number) { + this.password = password; + this.date = date; + } +} diff --git a/src/models/domain/secureNote.ts b/src/models/domain/secureNote.ts new file mode 100644 index 0000000000..2c742f8ea8 --- /dev/null +++ b/src/models/domain/secureNote.ts @@ -0,0 +1,27 @@ +import { SecureNoteType } from '../../enums/secureNoteType.enum'; + +import { SecureNoteData } from '../data/secureNoteData'; + +import Domain from './domain'; + +class SecureNote extends Domain { + type: SecureNoteType; + + constructor(obj?: SecureNoteData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.type = obj.type; + } + + decrypt(orgId: string): any { + return { + type: this.type, + }; + } +} + +export { SecureNote }; +(window as any).SecureNote = SecureNote; diff --git a/src/models/domain/symmetricCryptoKey.ts b/src/models/domain/symmetricCryptoKey.ts new file mode 100644 index 0000000000..bf50795a02 --- /dev/null +++ b/src/models/domain/symmetricCryptoKey.ts @@ -0,0 +1,80 @@ +import * as forge from 'node-forge'; + +import { EncryptionType } from '../../enums/encryptionType.enum'; + +import SymmetricCryptoKeyBuffers from './symmetricCryptoKeyBuffers'; + +import UtilsService from '../../services/utils.service'; + +export default class SymmetricCryptoKey { + key: string; + keyB64: string; + encKey: string; + macKey: string; + encType: EncryptionType; + keyBuf: SymmetricCryptoKeyBuffers; + + constructor(keyBytes: string, b64KeyBytes?: boolean, encType?: EncryptionType) { + if (b64KeyBytes) { + keyBytes = forge.util.decode64(keyBytes); + } + + if (!keyBytes) { + throw new Error('Must provide keyBytes'); + } + + const buffer = (forge as any).util.createBuffer(keyBytes); + if (!buffer || buffer.length() === 0) { + throw new Error('Couldn\'t make buffer'); + } + + const bufferLength: number = buffer.length(); + + if (encType == null) { + if (bufferLength === 32) { + encType = EncryptionType.AesCbc256_B64; + } else if (bufferLength === 64) { + encType = EncryptionType.AesCbc256_HmacSha256_B64; + } else { + throw new Error('Unable to determine encType.'); + } + } + + this.key = keyBytes; + this.keyB64 = forge.util.encode64(keyBytes); + this.encType = encType; + + if (encType === EncryptionType.AesCbc256_B64 && bufferLength === 32) { + this.encKey = keyBytes; + this.macKey = null; + } else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && bufferLength === 32) { + this.encKey = buffer.getBytes(16); // first half + this.macKey = buffer.getBytes(16); // second half + } else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && bufferLength === 64) { + this.encKey = buffer.getBytes(32); // first half + this.macKey = buffer.getBytes(32); // second half + } else { + throw new Error('Unsupported encType/key length.'); + } + } + + getBuffers() { + if (this.keyBuf) { + return this.keyBuf; + } + + const key = UtilsService.fromB64ToArray(this.keyB64); + const keys = new SymmetricCryptoKeyBuffers(key.buffer); + + if (this.macKey) { + keys.encKey = key.slice(0, key.length / 2).buffer; + keys.macKey = key.slice(key.length / 2).buffer; + } else { + keys.encKey = key.buffer; + keys.macKey = null; + } + + this.keyBuf = keys; + return this.keyBuf; + } +} diff --git a/src/models/domain/symmetricCryptoKeyBuffers.ts b/src/models/domain/symmetricCryptoKeyBuffers.ts new file mode 100644 index 0000000000..5a378ad246 --- /dev/null +++ b/src/models/domain/symmetricCryptoKeyBuffers.ts @@ -0,0 +1,9 @@ +export default class SymmetricCryptoKeyBuffers { + key: ArrayBuffer; + encKey?: ArrayBuffer; + macKey?: ArrayBuffer; + + constructor(key: ArrayBuffer) { + this.key = key; + } +} diff --git a/src/models/request/cipherRequest.ts b/src/models/request/cipherRequest.ts new file mode 100644 index 0000000000..659ee5be2d --- /dev/null +++ b/src/models/request/cipherRequest.ts @@ -0,0 +1,89 @@ +import { CipherType } from '../../enums/cipherType.enum'; + +class CipherRequest { + type: CipherType; + folderId: string; + organizationId: string; + name: string; + notes: string; + favorite: boolean; + login: any; + secureNote: any; + card: any; + identity: any; + fields: any[]; + + constructor(cipher: any) { + this.type = cipher.type; + this.folderId = cipher.folderId; + this.organizationId = cipher.organizationId; + this.name = cipher.name ? cipher.name.encryptedString : null; + this.notes = cipher.notes ? cipher.notes.encryptedString : null; + this.favorite = cipher.favorite; + + switch (this.type) { + case CipherType.Login: + this.login = { + uri: cipher.login.uri ? cipher.login.uri.encryptedString : null, + username: cipher.login.username ? cipher.login.username.encryptedString : null, + password: cipher.login.password ? cipher.login.password.encryptedString : null, + totp: cipher.login.totp ? cipher.login.totp.encryptedString : null, + }; + break; + case CipherType.SecureNote: + this.secureNote = { + type: cipher.secureNote.type, + }; + break; + case CipherType.Card: + this.card = { + cardholderName: cipher.card.cardholderName ? cipher.card.cardholderName.encryptedString : null, + brand: cipher.card.brand ? cipher.card.brand.encryptedString : null, + number: cipher.card.number ? cipher.card.number.encryptedString : null, + expMonth: cipher.card.expMonth ? cipher.card.expMonth.encryptedString : null, + expYear: cipher.card.expYear ? cipher.card.expYear.encryptedString : null, + code: cipher.card.code ? cipher.card.code.encryptedString : null, + }; + break; + case CipherType.Identity: + this.identity = { + title: cipher.identity.title ? cipher.identity.title.encryptedString : null, + firstName: cipher.identity.firstName ? cipher.identity.firstName.encryptedString : null, + middleName: cipher.identity.middleName ? cipher.identity.middleName.encryptedString : null, + lastName: cipher.identity.lastName ? cipher.identity.lastName.encryptedString : null, + address1: cipher.identity.address1 ? cipher.identity.address1.encryptedString : null, + address2: cipher.identity.address2 ? cipher.identity.address2.encryptedString : null, + address3: cipher.identity.address3 ? cipher.identity.address3.encryptedString : null, + city: cipher.identity.city ? cipher.identity.city.encryptedString : null, + state: cipher.identity.state ? cipher.identity.state.encryptedString : null, + postalCode: cipher.identity.postalCode ? cipher.identity.postalCode.encryptedString : null, + country: cipher.identity.country ? cipher.identity.country.encryptedString : null, + company: cipher.identity.company ? cipher.identity.company.encryptedString : null, + email: cipher.identity.email ? cipher.identity.email.encryptedString : null, + phone: cipher.identity.phone ? cipher.identity.phone.encryptedString : null, + ssn: cipher.identity.ssn ? cipher.identity.ssn.encryptedString : null, + username: cipher.identity.username ? cipher.identity.username.encryptedString : null, + passportNumber: cipher.identity.passportNumber ? + cipher.identity.passportNumber.encryptedString : null, + licenseNumber: cipher.identity.licenseNumber ? cipher.identity.licenseNumber.encryptedString : null, + }; + break; + default: + break; + } + + if (cipher.fields) { + this.fields = []; + cipher.fields.forEach((field: any) => { + this.fields.push({ + type: field.type, + name: field.name ? field.name.encryptedString : null, + value: field.value ? field.value.encryptedString : null, + }); + }); + } + } +} + +export { CipherRequest }; +(window as any).CipherRequest = CipherRequest; diff --git a/src/models/request/deviceRequest.ts b/src/models/request/deviceRequest.ts new file mode 100644 index 0000000000..928a46557f --- /dev/null +++ b/src/models/request/deviceRequest.ts @@ -0,0 +1,19 @@ +import { BrowserType } from '../../enums/browserType.enum'; +import { UtilsService } from '../../services/abstractions/utils.service'; + +class DeviceRequest { + type: BrowserType; + name: string; + identifier: string; + pushToken?: string; + + constructor(appId: string, utilsService: UtilsService) { + this.type = utilsService.getBrowser(); + this.name = utilsService.getBrowserString(); + this.identifier = appId; + this.pushToken = null; + } +} + +export { DeviceRequest }; +(window as any).DeviceRequest = DeviceRequest; diff --git a/src/models/request/deviceTokenRequest.ts b/src/models/request/deviceTokenRequest.ts new file mode 100644 index 0000000000..69ef20bb9a --- /dev/null +++ b/src/models/request/deviceTokenRequest.ts @@ -0,0 +1,10 @@ +class DeviceTokenRequest { + pushToken: string; + + constructor() { + this.pushToken = null; + } +} + +export { DeviceTokenRequest }; +(window as any).DeviceTokenRequest = DeviceTokenRequest; diff --git a/src/models/request/folderRequest.ts b/src/models/request/folderRequest.ts new file mode 100644 index 0000000000..7ff9794b18 --- /dev/null +++ b/src/models/request/folderRequest.ts @@ -0,0 +1,12 @@ +import { Folder } from '../domain/folder'; + +class FolderRequest { + name: string; + + constructor(folder: Folder) { + this.name = folder.name ? folder.name.encryptedString : null; + } +} + +export { FolderRequest }; +(window as any).FolderRequest = FolderRequest; diff --git a/src/models/request/passwordHintRequest.ts b/src/models/request/passwordHintRequest.ts new file mode 100644 index 0000000000..4feb92944b --- /dev/null +++ b/src/models/request/passwordHintRequest.ts @@ -0,0 +1,10 @@ +class PasswordHintRequest { + email: string; + + constructor(email: string) { + this.email = email; + } +} + +export { PasswordHintRequest }; +(window as any).PasswordHintRequest = PasswordHintRequest; diff --git a/src/models/request/registerRequest.ts b/src/models/request/registerRequest.ts new file mode 100644 index 0000000000..4b80fb7884 --- /dev/null +++ b/src/models/request/registerRequest.ts @@ -0,0 +1,18 @@ +class RegisterRequest { + name: string; + email: string; + masterPasswordHash: string; + masterPasswordHint: string; + key: string; + + constructor(email: string, masterPasswordHash: string, masterPasswordHint: string, key: string) { + this.name = null; + this.email = email; + this.masterPasswordHash = masterPasswordHash; + this.masterPasswordHint = masterPasswordHint ? masterPasswordHint : null; + this.key = key; + } +} + +export { RegisterRequest }; +(window as any).RegisterRequest = RegisterRequest; diff --git a/src/models/request/tokenRequest.ts b/src/models/request/tokenRequest.ts new file mode 100644 index 0000000000..4cf7564615 --- /dev/null +++ b/src/models/request/tokenRequest.ts @@ -0,0 +1,49 @@ +import { DeviceRequest } from './deviceRequest'; + +class TokenRequest { + email: string; + masterPasswordHash: string; + token: string; + provider: number; + remember: boolean; + device?: DeviceRequest; + + constructor(email: string, masterPasswordHash: string, provider: number, + token: string, remember: boolean, device?: DeviceRequest) { + this.email = email; + this.masterPasswordHash = masterPasswordHash; + this.token = token; + this.provider = provider; + this.remember = remember; + this.device = device != null ? device : null; + } + + toIdentityToken() { + const obj: any = { + grant_type: 'password', + username: this.email, + password: this.masterPasswordHash, + scope: 'api offline_access', + client_id: 'browser', + }; + + if (this.device) { + obj.deviceType = this.device.type; + obj.deviceIdentifier = this.device.identifier; + obj.deviceName = this.device.name; + // no push tokens for browser apps yet + // obj.devicePushToken = this.device.pushToken; + } + + if (this.token && this.provider !== null && (typeof this.provider !== 'undefined')) { + obj.twoFactorToken = this.token; + obj.twoFactorProvider = this.provider; + obj.twoFactorRemember = this.remember ? '1' : '0'; + } + + return obj; + } +} + +export { TokenRequest }; +(window as any).TokenRequest = TokenRequest; diff --git a/src/models/request/twoFactorEmailRequest.ts b/src/models/request/twoFactorEmailRequest.ts new file mode 100644 index 0000000000..d540b08ecf --- /dev/null +++ b/src/models/request/twoFactorEmailRequest.ts @@ -0,0 +1,12 @@ +class TwoFactorEmailRequest { + email: string; + masterPasswordHash: string; + + constructor(email: string, masterPasswordHash: string) { + this.email = email; + this.masterPasswordHash = masterPasswordHash; + } +} + +export { TwoFactorEmailRequest }; +(window as any).TwoFactorEmailRequest = TwoFactorEmailRequest; diff --git a/src/models/response/attachmentResponse.ts b/src/models/response/attachmentResponse.ts new file mode 100644 index 0000000000..df5138650c --- /dev/null +++ b/src/models/response/attachmentResponse.ts @@ -0,0 +1,18 @@ +class AttachmentResponse { + id: string; + url: string; + fileName: string; + size: number; + sizeName: string; + + constructor(response: any) { + this.id = response.Id; + this.url = response.Url; + this.fileName = response.FileName; + this.size = response.Size; + this.sizeName = response.SizeName; + } +} + +export { AttachmentResponse }; +(window as any).AttachmentResponse = AttachmentResponse; diff --git a/src/models/response/cipherResponse.ts b/src/models/response/cipherResponse.ts new file mode 100644 index 0000000000..b2e597e23a --- /dev/null +++ b/src/models/response/cipherResponse.ts @@ -0,0 +1,44 @@ +import { AttachmentResponse } from './attachmentResponse'; + +class CipherResponse { + id: string; + organizationId: string; + folderId: string; + type: number; + favorite: boolean; + edit: boolean; + organizationUseTotp: boolean; + data: any; + revisionDate: string; + attachments: AttachmentResponse[]; + collectionIds: string[]; + + constructor(response: any) { + this.id = response.Id; + this.organizationId = response.OrganizationId; + this.folderId = response.FolderId; + this.type = response.Type; + this.favorite = response.Favorite; + this.edit = response.Edit; + this.organizationUseTotp = response.OrganizationUseTotp; + this.data = response.Data; + this.revisionDate = response.RevisionDate; + + if (response.Attachments != null) { + this.attachments = []; + response.Attachments.forEach((attachment: any) => { + this.attachments.push(new AttachmentResponse(attachment)); + }); + } + + if (response.CollectionIds) { + this.collectionIds = []; + response.CollectionIds.forEach((id: string) => { + this.collectionIds.push(id); + }); + } + } +} + +export { CipherResponse }; +(window as any).CipherResponse = CipherResponse; diff --git a/src/models/response/collectionResponse.ts b/src/models/response/collectionResponse.ts new file mode 100644 index 0000000000..8b0247720d --- /dev/null +++ b/src/models/response/collectionResponse.ts @@ -0,0 +1,14 @@ +class CollectionResponse { + id: string; + organizationId: string; + name: string; + + constructor(response: any) { + this.id = response.Id; + this.organizationId = response.OrganizationId; + this.name = response.Name; + } +} + +export { CollectionResponse }; +(window as any).CollectionResponse = CollectionResponse; diff --git a/src/models/response/deviceResponse.ts b/src/models/response/deviceResponse.ts new file mode 100644 index 0000000000..63f69fe3b3 --- /dev/null +++ b/src/models/response/deviceResponse.ts @@ -0,0 +1,20 @@ +import { BrowserType } from '../../enums/browserType.enum'; + +class DeviceResponse { + id: string; + name: number; + identifier: string; + type: BrowserType; + creationDate: string; + + constructor(response: any) { + this.id = response.Id; + this.name = response.Name; + this.identifier = response.Identifier; + this.type = response.Type; + this.creationDate = response.CreationDate; + } +} + +export { DeviceResponse }; +(window as any).DeviceResponse = DeviceResponse; diff --git a/src/models/response/domainsResponse.ts b/src/models/response/domainsResponse.ts new file mode 100644 index 0000000000..d65077147b --- /dev/null +++ b/src/models/response/domainsResponse.ts @@ -0,0 +1,20 @@ +import { GlobalDomainResponse } from './globalDomainResponse'; + +class DomainsResponse { + equivalentDomains: string[][]; + globalEquivalentDomains: GlobalDomainResponse[] = []; + + constructor(response: any) { + this.equivalentDomains = response.EquivalentDomains; + + this.globalEquivalentDomains = []; + if (response.GlobalEquivalentDomains) { + response.GlobalEquivalentDomains.forEach((domain: any) => { + this.globalEquivalentDomains.push(new GlobalDomainResponse(domain)); + }); + } + } +} + +export { DomainsResponse }; +(window as any).DomainsResponse = DomainsResponse; diff --git a/src/models/response/errorResponse.ts b/src/models/response/errorResponse.ts new file mode 100644 index 0000000000..4d976932cd --- /dev/null +++ b/src/models/response/errorResponse.ts @@ -0,0 +1,37 @@ +class ErrorResponse { + message: string; + validationErrors: { [key: string]: string[]; }; + statusCode: number; + + constructor(response: any, status: number, identityResponse?: boolean) { + let errorModel = null; + if (identityResponse && response && response.ErrorModel) { + errorModel = response.ErrorModel; + } else if (response) { + errorModel = response; + } + + if (errorModel) { + this.message = errorModel.Message; + this.validationErrors = errorModel.ValidationErrors; + } + this.statusCode = status; + } + + getSingleMessage(): string { + if (this.validationErrors) { + for (const key in this.validationErrors) { + if (!this.validationErrors.hasOwnProperty(key)) { + continue; + } + if (this.validationErrors[key].length) { + return this.validationErrors[key][0]; + } + } + } + return this.message; + } +} + +export { ErrorResponse }; +(window as any).ErrorResponse = ErrorResponse; diff --git a/src/models/response/folderResponse.ts b/src/models/response/folderResponse.ts new file mode 100644 index 0000000000..c5ff0ada70 --- /dev/null +++ b/src/models/response/folderResponse.ts @@ -0,0 +1,14 @@ +class FolderResponse { + id: string; + name: string; + revisionDate: string; + + constructor(response: any) { + this.id = response.Id; + this.name = response.Name; + this.revisionDate = response.RevisionDate; + } +} + +export { FolderResponse }; +(window as any).FolderResponse = FolderResponse; diff --git a/src/models/response/globalDomainResponse.ts b/src/models/response/globalDomainResponse.ts new file mode 100644 index 0000000000..8e9a45df11 --- /dev/null +++ b/src/models/response/globalDomainResponse.ts @@ -0,0 +1,14 @@ +class GlobalDomainResponse { + type: number; + domains: string[]; + excluded: number[]; + + constructor(response: any) { + this.type = response.Type; + this.domains = response.Domains; + this.excluded = response.Excluded; + } +} + +export { GlobalDomainResponse }; +(window as any).GlobalDomainResponse = GlobalDomainResponse; diff --git a/src/models/response/identityTokenResponse.ts b/src/models/response/identityTokenResponse.ts new file mode 100644 index 0000000000..2d188707c0 --- /dev/null +++ b/src/models/response/identityTokenResponse.ts @@ -0,0 +1,24 @@ +class IdentityTokenResponse { + accessToken: string; + expiresIn: number; + refreshToken: string; + tokenType: string; + + privateKey: string; + key: string; + twoFactorToken: string; + + constructor(response: any) { + this.accessToken = response.access_token; + this.expiresIn = response.expires_in; + this.refreshToken = response.refresh_token; + this.tokenType = response.token_type; + + this.privateKey = response.PrivateKey; + this.key = response.Key; + this.twoFactorToken = response.TwoFactorToken; + } +} + +export { IdentityTokenResponse }; +(window as any).IdentityTokenResponse = IdentityTokenResponse; diff --git a/src/models/response/keysResponse.ts b/src/models/response/keysResponse.ts new file mode 100644 index 0000000000..cb96dad51e --- /dev/null +++ b/src/models/response/keysResponse.ts @@ -0,0 +1,12 @@ +class KeysResponse { + privateKey: string; + publicKey: string; + + constructor(response: any) { + this.privateKey = response.PrivateKey; + this.publicKey = response.PublicKey; + } +} + +export { KeysResponse }; +(window as any).KeysResponse = KeysResponse; diff --git a/src/models/response/listResponse.ts b/src/models/response/listResponse.ts new file mode 100644 index 0000000000..9cf5455ada --- /dev/null +++ b/src/models/response/listResponse.ts @@ -0,0 +1,10 @@ +class ListResponse { + data: any; + + constructor(data: any) { + this.data = data; + } +} + +export { ListResponse }; +(window as any).ListResponse = ListResponse; diff --git a/src/models/response/profileOrganizationResponse.ts b/src/models/response/profileOrganizationResponse.ts new file mode 100644 index 0000000000..484857745a --- /dev/null +++ b/src/models/response/profileOrganizationResponse.ts @@ -0,0 +1,30 @@ +class ProfileOrganizationResponse { + id: string; + name: string; + useGroups: boolean; + useDirectory: boolean; + useTotp: boolean; + seats: number; + maxCollections: number; + maxStorageGb?: number; + key: string; + status: number; // TODO: map to enum + type: number; // TODO: map to enum + + constructor(response: any) { + this.id = response.Id; + this.name = response.Name; + this.useGroups = response.UseGroups; + this.useDirectory = response.UseDirectory; + this.useTotp = response.UseTotp; + this.seats = response.Seats; + this.maxCollections = response.MaxCollections; + this.maxStorageGb = response.MaxStorageGb; + this.key = response.Key; + this.status = response.Status; + this.type = response.Type; + } +} + +export { ProfileOrganizationResponse }; +(window as any).ProfileOrganizationResponse = ProfileOrganizationResponse; diff --git a/src/models/response/profileResponse.ts b/src/models/response/profileResponse.ts new file mode 100644 index 0000000000..69e4f3e9cb --- /dev/null +++ b/src/models/response/profileResponse.ts @@ -0,0 +1,39 @@ +import { ProfileOrganizationResponse } from './profileOrganizationResponse'; + +class ProfileResponse { + id: string; + name: string; + email: string; + emailVerified: boolean; + masterPasswordHint: string; + premium: boolean; + culture: string; + twoFactorEnabled: boolean; + key: string; + privateKey: string; + securityStamp: string; + organizations: ProfileOrganizationResponse[] = []; + + constructor(response: any) { + this.id = response.Id; + this.name = response.Name; + this.email = response.Email; + this.emailVerified = response.EmailVerified; + this.masterPasswordHint = response.MasterPasswordHint; + this.premium = response.Premium; + this.culture = response.Culture; + this.twoFactorEnabled = response.TwoFactorEnabled; + this.key = response.Key; + this.privateKey = response.PrivateKey; + this.securityStamp = response.SecurityStamp; + + if (response.Organizations) { + response.Organizations.forEach((org: any) => { + this.organizations.push(new ProfileOrganizationResponse(org)); + }); + } + } +} + +export { ProfileResponse }; +(window as any).ProfileResponse = ProfileResponse; diff --git a/src/models/response/syncResponse.ts b/src/models/response/syncResponse.ts new file mode 100644 index 0000000000..8acdc5202d --- /dev/null +++ b/src/models/response/syncResponse.ts @@ -0,0 +1,44 @@ +import { CipherResponse } from './cipherResponse'; +import { CollectionResponse } from './collectionResponse'; +import { DomainsResponse } from './domainsResponse'; +import { FolderResponse } from './folderResponse'; +import { ProfileResponse } from './profileResponse'; + +class SyncResponse { + profile?: ProfileResponse; + folders: FolderResponse[] = []; + collections: CollectionResponse[] = []; + ciphers: CipherResponse[] = []; + domains?: DomainsResponse; + + constructor(response: any) { + if (response.Profile) { + this.profile = new ProfileResponse(response.Profile); + } + + if (response.Folders) { + response.Folders.forEach((folder: any) => { + this.folders.push(new FolderResponse(folder)); + }); + } + + if (response.Collections) { + response.Collections.forEach((collection: any) => { + this.collections.push(new CollectionResponse(collection)); + }); + } + + if (response.Ciphers) { + response.Ciphers.forEach((cipher: any) => { + this.ciphers.push(new CipherResponse(cipher)); + }); + } + + if (response.Domains) { + this.domains = new DomainsResponse(response.Domains); + } + } +} + +export { SyncResponse }; +(window as any).SyncResponse = SyncResponse; diff --git a/src/services/abstractions/crypto.service.ts b/src/services/abstractions/crypto.service.ts new file mode 100644 index 0000000000..1fd413d34f --- /dev/null +++ b/src/services/abstractions/crypto.service.ts @@ -0,0 +1,28 @@ +import { CipherString } from '../../models/domain/cipherString'; +import SymmetricCryptoKey from '../../models/domain/symmetricCryptoKey'; + +import { ProfileOrganizationResponse } from '../../models/response/profileOrganizationResponse'; + +export interface CryptoService { + setKey(key: SymmetricCryptoKey): Promise; + setKeyHash(keyHash: string): Promise<{}>; + setEncKey(encKey: string): Promise<{}>; + setEncPrivateKey(encPrivateKey: string): Promise<{}>; + setOrgKeys(orgs: ProfileOrganizationResponse[]): Promise<{}>; + getKey(): Promise; + getKeyHash(): Promise; + getEncKey(): Promise; + getPrivateKey(): Promise; + getOrgKeys(): Promise>; + getOrgKey(orgId: string): Promise; + clearKeys(): Promise; + toggleKey(): Promise; + makeKey(password: string, salt: string): SymmetricCryptoKey; + hashPassword(password: string, key: SymmetricCryptoKey): Promise; + makeEncKey(key: SymmetricCryptoKey): Promise; + encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey, plainValueEncoding?: string): Promise; + encryptToBytes(plainValue: ArrayBuffer, key?: SymmetricCryptoKey): Promise; + decrypt(cipherString: CipherString, key?: SymmetricCryptoKey, outputEncoding?: string): Promise; + decryptFromBytes(encBuf: ArrayBuffer, key: SymmetricCryptoKey): Promise; + rsaDecrypt(encValue: string): Promise; +} diff --git a/src/services/abstractions/utils.service.ts b/src/services/abstractions/utils.service.ts new file mode 100644 index 0000000000..e52eb44fec --- /dev/null +++ b/src/services/abstractions/utils.service.ts @@ -0,0 +1,22 @@ +import { BrowserType } from '../../enums/browserType.enum'; + +export interface UtilsService { + getBrowser(): BrowserType; + getBrowserString(): string; + isFirefox(): boolean; + isChrome(): boolean; + isEdge(): boolean; + isOpera(): boolean; + analyticsId(): string; + initListSectionItemListeners(doc: Document, angular: any): void; + copyToClipboard(text: string, doc?: Document): void; + getDomain(uriString: string): string; + getHostname(uriString: string): string; + inSidebar(theWindow: Window): boolean; + inTab(theWindow: Window): boolean; + inPopout(theWindow: Window): boolean; + inPopup(theWindow: Window): boolean; + saveObjToStorage(key: string, obj: any): Promise; + removeFromStorage(key: string): Promise; + getObjFromStorage(key: string): Promise; +} diff --git a/src/services/api.service.ts b/src/services/api.service.ts new file mode 100644 index 0000000000..d97ec6c47a --- /dev/null +++ b/src/services/api.service.ts @@ -0,0 +1,454 @@ +import AppIdService from './appId.service'; +import ConstantsService from './constants.service'; +import TokenService from './token.service'; +import UtilsService from './utils.service'; + +import EnvironmentUrls from '../models/domain/environmentUrls'; + +import { CipherRequest } from '../models/request/cipherRequest'; +import { DeviceRequest } from '../models/request/deviceRequest'; +import { DeviceTokenRequest } from '../models/request/deviceTokenRequest'; +import { FolderRequest } from '../models/request/folderRequest'; +import { PasswordHintRequest } from '../models/request/passwordHintRequest'; +import { RegisterRequest } from '../models/request/registerRequest'; +import { TokenRequest } from '../models/request/tokenRequest'; +import { TwoFactorEmailRequest } from '../models/request/twoFactorEmailRequest'; + +import { AttachmentResponse } from '../models/response/attachmentResponse'; +import { CipherResponse } from '../models/response/cipherResponse'; +import { DeviceResponse } from '../models/response/deviceResponse'; +import { DomainsResponse } from '../models/response/domainsResponse'; +import { ErrorResponse } from '../models/response/errorResponse'; +import { FolderResponse } from '../models/response/folderResponse'; +import { GlobalDomainResponse } from '../models/response/globalDomainResponse'; +import { IdentityTokenResponse } from '../models/response/identityTokenResponse'; +import { KeysResponse } from '../models/response/keysResponse'; +import { ListResponse } from '../models/response/listResponse'; +import { ProfileOrganizationResponse } from '../models/response/profileOrganizationResponse'; +import { ProfileResponse } from '../models/response/profileResponse'; +import { SyncResponse } from '../models/response/syncResponse'; + +export default class ApiService { + urlsSet: boolean = false; + baseUrl: string; + identityBaseUrl: string; + logoutCallback: Function; + + constructor(private tokenService: TokenService, private utilsService: UtilsService, + logoutCallback: Function) { + this.logoutCallback = logoutCallback; + } + + setUrls(urls: EnvironmentUrls) { + this.urlsSet = true; + + if (urls.base != null) { + this.baseUrl = urls.base + '/api'; + this.identityBaseUrl = urls.base + '/identity'; + return; + } + + if (urls.api != null && urls.identity != null) { + this.baseUrl = urls.api; + this.identityBaseUrl = urls.identity; + return; + } + + /* tslint:disable */ + // Desktop + //this.baseUrl = 'http://localhost:4000'; + //this.identityBaseUrl = 'http://localhost:33656'; + + // Desktop HTTPS + //this.baseUrl = 'https://localhost:44377'; + //this.identityBaseUrl = 'https://localhost:44392'; + + // Desktop external + //this.baseUrl = 'http://192.168.1.3:4000'; + //this.identityBaseUrl = 'http://192.168.1.3:33656'; + + // Preview + //this.baseUrl = 'https://preview-api.bitwarden.com'; + //this.identityBaseUrl = 'https://preview-identity.bitwarden.com'; + + // Production + this.baseUrl = 'https://api.bitwarden.com'; + this.identityBaseUrl = 'https://identity.bitwarden.com'; + /* tslint:enable */ + } + + // Auth APIs + + async postIdentityToken(request: TokenRequest): Promise { + const response = await fetch(new Request(this.identityBaseUrl + '/connect/token', { + body: this.qsStringify(request.toIdentityToken()), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Accept': 'application/json', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'POST', + })); + + let responseJson: any = null; + const typeHeader = response.headers.get('content-type'); + if (typeHeader != null && typeHeader.indexOf('application/json') > -1) { + responseJson = await response.json(); + } + + if (responseJson != null) { + if (response.status === 200) { + return new IdentityTokenResponse(responseJson); + } else if (response.status === 400 && responseJson.TwoFactorProviders2 && + Object.keys(responseJson.TwoFactorProviders2).length) { + await this.tokenService.clearTwoFactorToken(request.email); + return responseJson.TwoFactorProviders2; + } + } + + return Promise.reject(new ErrorResponse(responseJson, response.status, true)); + } + + async refreshIdentityToken(): Promise { + try { + await this.doRefreshToken(); + } catch (e) { + return Promise.reject(null); + } + } + + // Two Factor APIs + + async postTwoFactorEmail(request: TwoFactorEmailRequest): Promise { + const response = await fetch(new Request(this.baseUrl + '/two-factor/send-email-login', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'POST', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Account APIs + + async getAccountRevisionDate(): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/accounts/revision-date', { + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + })); + + if (response.status === 200) { + return (await response.json() as number); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async postPasswordHint(request: PasswordHintRequest): Promise { + const response = await fetch(new Request(this.baseUrl + '/accounts/password-hint', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'POST', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async postRegister(request: RegisterRequest): Promise { + const response = await fetch(new Request(this.baseUrl + '/accounts/register', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'POST', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Folder APIs + + async postFolder(request: FolderRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/folders', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'POST', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new FolderResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async putFolder(id: string, request: FolderRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/folders/' + id, { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'PUT', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new FolderResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async deleteFolder(id: string): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/folders/' + id, { + cache: 'no-cache', + headers: new Headers({ + 'Authorization': authHeader, + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'DELETE', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Cipher APIs + + async postCipher(request: CipherRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'POST', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new CipherResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async putCipher(id: string, request: CipherRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id, { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'PUT', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new CipherResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async deleteCipher(id: string): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id, { + cache: 'no-cache', + headers: new Headers({ + 'Authorization': authHeader, + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'DELETE', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Attachments APIs + + async postCipherAttachment(id: string, data: FormData): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id + '/attachment', { + body: data, + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'POST', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new CipherResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async deleteCipherAttachment(id: string, attachmentId: string): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id + '/attachment/' + attachmentId, { + cache: 'no-cache', + headers: new Headers({ + 'Authorization': authHeader, + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'DELETE', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Sync APIs + + async getSync(): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/sync', { + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new SyncResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Helpers + + private async handleError(response: Response, tokenError: boolean): Promise { + if ((tokenError && response.status === 400) || response.status === 401 || response.status === 403) { + this.logoutCallback(true); + return null; + } + + let responseJson: any = null; + const typeHeader = response.headers.get('content-type'); + if (typeHeader != null && typeHeader.indexOf('application/json') > -1) { + responseJson = await response.json(); + } + + return new ErrorResponse(responseJson, response.status, tokenError); + } + + private async handleTokenState(): Promise { + let accessToken: string; + if (this.tokenService.tokenNeedsRefresh()) { + const tokenResponse = await this.doRefreshToken(); + accessToken = tokenResponse.accessToken; + } else { + accessToken = await this.tokenService.getToken(); + } + + return 'Bearer ' + accessToken; + } + + private async doRefreshToken(): Promise { + const refreshToken = await this.tokenService.getRefreshToken(); + if (refreshToken == null || refreshToken === '') { + throw new Error(); + } + + const response = await fetch(new Request(this.identityBaseUrl + '/connect/token', { + body: this.qsStringify({ + grant_type: 'refresh_token', + client_id: 'browser', + refresh_token: refreshToken, + }), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Accept': 'application/json', + 'Device-Type': this.utilsService.getBrowser().toString(), + }), + method: 'POST', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + const tokenResponse = new IdentityTokenResponse(responseJson); + await this.tokenService.setTokens(tokenResponse.accessToken, tokenResponse.refreshToken); + return tokenResponse; + } else { + const error = await this.handleError(response, true); + return Promise.reject(error); + } + } + + private qsStringify(params: any): string { + return Object.keys(params).map((key) => { + return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]); + }).join('&'); + } +} diff --git a/src/services/appId.service.ts b/src/services/appId.service.ts new file mode 100644 index 0000000000..d4e0dce5cb --- /dev/null +++ b/src/services/appId.service.ts @@ -0,0 +1,31 @@ +import UtilsService from './utils.service'; + +export default class AppIdService { + static getAppId(): Promise { + return AppIdService.makeAndGetAppId('appId'); + } + + static getAnonymousAppId(): Promise { + return AppIdService.makeAndGetAppId('anonymousAppId'); + } + + private static async makeAndGetAppId(key: string) { + const existingId = await UtilsService.getObjFromStorage(key); + if (existingId != null) { + return existingId; + } + + const guid = UtilsService.newGuid(); + await UtilsService.saveObjToStorage(key, guid); + return guid; + } + + // TODO: remove these in favor of static methods + getAppId(): Promise { + return AppIdService.getAppId(); + } + + getAnonymousAppId(): Promise { + return AppIdService.getAnonymousAppId(); + } +} diff --git a/src/services/autofill.service.ts b/src/services/autofill.service.ts new file mode 100644 index 0000000000..2b2593bffe --- /dev/null +++ b/src/services/autofill.service.ts @@ -0,0 +1,872 @@ +import { CipherType } from '../enums/cipherType.enum'; +import { FieldType } from '../enums/fieldType.enum'; + +import AutofillField from '../models/domain/autofillField'; +import AutofillPageDetails from '../models/domain/autofillPageDetails'; +import AutofillScript from '../models/domain/autofillScript'; + +import CipherService from './cipher.service'; +import TokenService from './token.service'; +import TotpService from './totp.service'; +import UtilsService from './utils.service'; + +const CardAttributes: string[] = ['autoCompleteType', 'data-stripe', 'htmlName', 'htmlID', 'label-tag', + 'placeholder', 'label-left', 'label-top']; + +const IdentityAttributes: string[] = ['autoCompleteType', 'data-stripe', 'htmlName', 'htmlID', 'label-tag', + 'placeholder', 'label-left', 'label-top']; + +const UsernameFieldNames: string[] = [ + // English + 'username', 'user name', 'email', 'email address', 'e-mail', 'e-mail address', 'userid', 'user id', + // German + 'benutzername', 'benutzer name', 'email adresse', 'e-mail adresse', 'benutzerid', 'benutzer id']; + +/* tslint:disable */ +const IsoCountries: { [id: string]: string; } = { + afghanistan: "AF", "aland islands": "AX", albania: "AL", algeria: "DZ", "american samoa": "AS", andorra: "AD", + angola: "AO", anguilla: "AI", antarctica: "AQ", "antigua and barbuda": "AG", argentina: "AR", armenia: "AM", + aruba: "AW", australia: "AU", austria: "AT", azerbaijan: "AZ", bahamas: "BS", bahrain: "BH", bangladesh: "BD", + barbados: "BB", belarus: "BY", belgium: "BE", belize: "BZ", benin: "BJ", bermuda: "BM", bhutan: "BT", bolivia: "BO", + "bosnia and herzegovina": "BA", botswana: "BW", "bouvet island": "BV", brazil: "BR", + "british indian ocean territory": "IO", "brunei darussalam": "BN", bulgaria: "BG", "burkina faso": "BF", burundi: "BI", + cambodia: "KH", cameroon: "CM", canada: "CA", "cape verde": "CV", "cayman islands": "KY", + "central african republic": "CF", chad: "TD", chile: "CL", china: "CN", "christmas island": "CX", + "cocos (keeling) islands": "CC", colombia: "CO", comoros: "KM", congo: "CG", "congo, democratic republic": "CD", + "cook islands": "CK", "costa rica": "CR", "cote d'ivoire": "CI", croatia: "HR", cuba: "CU", cyprus: "CY", + "czech republic": "CZ", denmark: "DK", djibouti: "DJ", dominica: "DM", "dominican republic": "DO", ecuador: "EC", + egypt: "EG", "el salvador": "SV", "equatorial guinea": "GQ", eritrea: "ER", estonia: "EE", ethiopia: "ET", + "falkland islands": "FK", "faroe islands": "FO", fiji: "FJ", finland: "FI", france: "FR", "french guiana": "GF", + "french polynesia": "PF", "french southern territories": "TF", gabon: "GA", gambia: "GM", georgia: "GE", germany: "DE", + ghana: "GH", gibraltar: "GI", greece: "GR", greenland: "GL", grenada: "GD", guadeloupe: "GP", guam: "GU", + guatemala: "GT", guernsey: "GG", guinea: "GN", "guinea-bissau": "GW", guyana: "GY", haiti: "HT", + "heard island & mcdonald islands": "HM", "holy see (vatican city state)": "VA", honduras: "HN", "hong kong": "HK", + hungary: "HU", iceland: "IS", india: "IN", indonesia: "ID", "iran, islamic republic of": "IR", iraq: "IQ", + ireland: "IE", "isle of man": "IM", israel: "IL", italy: "IT", jamaica: "JM", japan: "JP", jersey: "JE", + jordan: "JO", kazakhstan: "KZ", kenya: "KE", kiribati: "KI", "republic of korea": "KR", "south korea": "KR", + "democratic people's republic of korea": "KP", "north korea": "KP", kuwait: "KW", kyrgyzstan: "KG", + "lao people's democratic republic": "LA", latvia: "LV", lebanon: "LB", lesotho: "LS", liberia: "LR", + "libyan arab jamahiriya": "LY", liechtenstein: "LI", lithuania: "LT", luxembourg: "LU", macao: "MO", macedonia: "MK", + madagascar: "MG", malawi: "MW", malaysia: "MY", maldives: "MV", mali: "ML", malta: "MT", "marshall islands": "MH", + martinique: "MQ", mauritania: "MR", mauritius: "MU", mayotte: "YT", mexico: "MX", + "micronesia, federated states of": "FM", moldova: "MD", monaco: "MC", mongolia: "MN", montenegro: "ME", montserrat: "MS", + morocco: "MA", mozambique: "MZ", myanmar: "MM", namibia: "NA", nauru: "NR", nepal: "NP", netherlands: "NL", + "netherlands antilles": "AN", "new caledonia": "NC", "new zealand": "NZ", nicaragua: "NI", niger: "NE", nigeria: "NG", + niue: "NU", "norfolk island": "NF", "northern mariana islands": "MP", norway: "NO", oman: "OM", pakistan: "PK", + palau: "PW", "palestinian territory, occupied": "PS", panama: "PA", "papua new guinea": "PG", paraguay: "PY", peru: "PE", + philippines: "PH", pitcairn: "PN", poland: "PL", portugal: "PT", "puerto rico": "PR", qatar: "QA", reunion: "RE", + romania: "RO", "russian federation": "RU", rwanda: "RW", "saint barthelemy": "BL", "saint helena": "SH", + "saint kitts and nevis": "KN", "saint lucia": "LC", "saint martin": "MF", "saint pierre and miquelon": "PM", + "saint vincent and grenadines": "VC", samoa: "WS", "san marino": "SM", "sao tome and principe": "ST", + "saudi arabia": "SA", senegal: "SN", serbia: "RS", seychelles: "SC", "sierra leone": "SL", singapore: "SG", + slovakia: "SK", slovenia: "SI", "solomon islands": "SB", somalia: "SO", "south africa": "ZA", + "south georgia and sandwich isl.": "GS", spain: "ES", "sri lanka": "LK", sudan: "SD", suriname: "SR", + "svalbard and jan mayen": "SJ", swaziland: "SZ", sweden: "SE", switzerland: "CH", "syrian arab republic": "SY", + taiwan: "TW", tajikistan: "TJ", tanzania: "TZ", thailand: "TH", "timor-leste": "TL", togo: "TG", tokelau: "TK", + tonga: "TO", "trinidad and tobago": "TT", tunisia: "TN", turkey: "TR", turkmenistan: "TM", + "turks and caicos islands": "TC", tuvalu: "TV", uganda: "UG", ukraine: "UA", "united arab emirates": "AE", + "united kingdom": "GB", "united states": "US", "united states outlying islands": "UM", uruguay: "UY", + uzbekistan: "UZ", vanuatu: "VU", venezuela: "VE", vietnam: "VN", "virgin islands, british": "VG", + "virgin islands, u.s.": "VI", "wallis and futuna": "WF", "western sahara": "EH", yemen: "YE", zambia: "ZM", + zimbabwe: "ZW", +}; + +const IsoStates: { [id: string]: string; } = { + alabama: 'AL', alaska: 'AK', 'american samoa': 'AS', arizona: 'AZ', arkansas: 'AR', california: 'CA', colorado: 'CO', + connecticut: 'CT', delaware: 'DE', 'district of columbia': 'DC', 'federated states of micronesia': 'FM', florida: 'FL', + georgia: 'GA', guam: 'GU', hawaii: 'HI', idaho: 'ID', illinois: 'IL', indiana: 'IN', iowa: 'IA', kansas: 'KS', + kentucky: 'KY', louisiana: 'LA', maine: 'ME', 'marshall islands': 'MH', maryland: 'MD', massachusetts: 'MA', + michigan: 'MI', minnesota: 'MN', mississippi: 'MS', missouri: 'MO', montana: 'MT', nebraska: 'NE', nevada: 'NV', + 'new hampshire': 'NH', 'new jersey': 'NJ', 'new mexico': 'NM', 'new york': 'NY', 'north carolina': 'NC', + 'north dakota': 'ND', 'northern mariana islands': 'MP', ohio: 'OH', oklahoma: 'OK', oregon: 'OR', palau: 'PW', + pennsylvania: 'PA', 'puerto rico': 'PR', 'rhode island': 'RI', 'south carolina': 'SC', 'south dakota': 'SD', + tennessee: 'TN', texas: 'TX', utah: 'UT', vermont: 'VT', 'virgin islands': 'VI', virginia: 'VA', washington: 'WA', + 'west virginia': 'WV', wisconsin: 'WI', wyoming: 'WY', +}; + +var IsoProvinces: { [id: string]: string; } = { + alberta: 'AB', 'british columbia': 'BC', manitoba: 'MB', 'new brunswick': 'NB', 'newfoundland and labrador': 'NL', + 'nova scotia': 'NS', ontario: 'ON', 'prince edward island': 'PE', quebec: 'QC', saskatchewan: 'SK', +}; +/* tslint:enable */ + +export default class AutofillService { + constructor(public cipherService: CipherService, public tokenService: TokenService, + public totpService: TotpService, public utilsService: UtilsService) { + } + + getFormsWithPasswordFields(pageDetails: AutofillPageDetails): any[] { + const formData: any[] = []; + + const passwordFields = this.loadPasswordFields(pageDetails, true); + if (passwordFields.length === 0) { + return formData; + } + + for (const formKey in pageDetails.forms) { + if (!pageDetails.forms.hasOwnProperty(formKey)) { + continue; + } + + for (let i = 0; i < passwordFields.length; i++) { + const pf = passwordFields[i]; + if (formKey !== pf.form) { + continue; + } + + let uf = this.findUsernameField(pageDetails, pf, false, false); + if (uf == null) { + // not able to find any viewable username fields. maybe there are some "hidden" ones? + uf = this.findUsernameField(pageDetails, pf, true, false); + } + + formData.push({ + form: pageDetails.forms[formKey], + password: pf, + username: uf, + }); + break; + } + } + + return formData; + } + + async doAutoFill(options: any) { + let totpPromise: Promise = null; + const tab = await this.getActiveTab(); + if (!tab || !options.cipher || !options.pageDetails || !options.pageDetails.length) { + throw new Error('Nothing to auto-fill.'); + } + + let didAutofill = false; + options.pageDetails.forEach((pd: any) => { + // make sure we're still on correct tab + if (pd.tab.id !== tab.id || pd.tab.url !== tab.url) { + return; + } + + const fillScript = this.generateFillScript(pd.details, { + skipUsernameOnlyFill: options.skipUsernameOnlyFill || false, + cipher: options.cipher, + }); + + if (!fillScript || !fillScript.script || !fillScript.script.length) { + return; + } + + didAutofill = true; + if (!options.skipLastUsed) { + this.cipherService.updateLastUsedDate(options.cipher.id); + } + + chrome.tabs.sendMessage(tab.id, { + command: 'fillForm', + // tslint:disable-next-line + fillScript: fillScript, + }, { frameId: pd.frameId }); + + if (options.cipher.type !== CipherType.Login || totpPromise || + (options.fromBackground && this.utilsService.isFirefox()) || options.skipTotp || + !options.cipher.login.totp || !this.tokenService.getPremium()) { + return; + } + + totpPromise = this.totpService.isAutoCopyEnabled().then((enabled) => { + if (enabled) { + return this.totpService.getCode(options.cipher.login.totp); + } + + return null; + }).then((code: string) => { + if (code) { + UtilsService.copyToClipboard(code); + } + + return code; + }); + }); + + if (didAutofill) { + if (totpPromise != null) { + const totpCode = await totpPromise; + return totpCode; + } else { + return null; + } + } else { + throw new Error('Did not auto-fill.'); + } + } + + async doAutoFillForLastUsedLogin(pageDetails: any, fromCommand: boolean) { + const tab = await this.getActiveTab(); + if (!tab || !tab.url) { + return; + } + + const tabDomain = UtilsService.getDomain(tab.url); + if (tabDomain == null) { + return; + } + + const lastUsedCipher = await this.cipherService.getLastUsedForDomain(tabDomain); + if (!lastUsedCipher) { + return; + } + + await this.doAutoFill({ + cipher: lastUsedCipher, + // tslint:disable-next-line + pageDetails: pageDetails, + fromBackground: true, + skipTotp: !fromCommand, + skipLastUsed: true, + skipUsernameOnlyFill: !fromCommand, + }); + } + + // Helpers + + private getActiveTab(): Promise { + return new Promise((resolve, reject) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs: any[]) => { + if (tabs.length === 0) { + reject('No tab found.'); + } else { + resolve(tabs[0]); + } + }); + }); + } + + private generateFillScript(pageDetails: AutofillPageDetails, options: any): AutofillScript { + if (!pageDetails || !options.cipher) { + return null; + } + + let fillScript = new AutofillScript(pageDetails.documentUUID); + const filledFields: { [id: string]: AutofillField; } = {}; + const fields = options.cipher.fields; + + if (fields && fields.length) { + const fieldNames: string[] = []; + + fields.forEach((f: any) => { + if (this.hasValue(f.name)) { + fieldNames.push(f.name.toLowerCase()); + } else { + fieldNames.push(null); + } + }); + + pageDetails.fields.forEach((field: any) => { + if (filledFields.hasOwnProperty(field.opid) || !field.viewable) { + return; + } + + const matchingIndex = this.findMatchingFieldIndex(field, fieldNames); + if (matchingIndex > -1) { + let val = fields[matchingIndex].value; + if (val == null && fields[matchingIndex].type === FieldType.Boolean) { + val = 'false'; + } + + filledFields[field.opid] = field; + fillScript.script.push(['click_on_opid', field.opid]); + fillScript.script.push(['fill_by_opid', field.opid, val]); + } + }); + } + + switch (options.cipher.type) { + case CipherType.Login: + fillScript = this.generateLoginFillScript(fillScript, pageDetails, filledFields, options); + break; + case CipherType.Card: + fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options); + break; + case CipherType.Identity: + fillScript = this.generateIdentityFillScript(fillScript, pageDetails, filledFields, options); + break; + default: + return null; + } + + return fillScript; + } + + private generateLoginFillScript(fillScript: AutofillScript, pageDetails: any, + filledFields: { [id: string]: AutofillField; }, options: any): AutofillScript { + if (!options.cipher.login) { + return null; + } + + const passwords: AutofillField[] = []; + const usernames: AutofillField[] = []; + let pf: AutofillField = null; + let username: AutofillField = null; + const login = options.cipher.login; + + if (!login.password || login.password === '') { + // No password for this login. Maybe they just wanted to auto-fill some custom fields? + fillScript = this.setFillScriptForFocus(filledFields, fillScript); + return fillScript; + } + + let passwordFields = this.loadPasswordFields(pageDetails, false); + if (!passwordFields.length) { + // not able to find any viewable password fields. maybe there are some "hidden" ones? + passwordFields = this.loadPasswordFields(pageDetails, true); + } + + for (const formKey in pageDetails.forms) { + if (!pageDetails.forms.hasOwnProperty(formKey)) { + continue; + } + + const passwordFieldsForForm: AutofillField[] = []; + passwordFields.forEach((passField) => { + if (formKey === passField.form) { + passwordFieldsForForm.push(passField); + } + }); + + passwordFields.forEach((passField) => { + pf = passField; + passwords.push(pf); + + if (login.username) { + username = this.findUsernameField(pageDetails, pf, false, false); + + if (!username) { + // not able to find any viewable username fields. maybe there are some "hidden" ones? + username = this.findUsernameField(pageDetails, pf, true, false); + } + + if (username) { + usernames.push(username); + } + } + }); + } + + if (passwordFields.length && !passwords.length) { + // The page does not have any forms with password fields. Use the first password field on the page and the + // input field just before it as the username. + + pf = passwordFields[0]; + passwords.push(pf); + + if (login.username && pf.elementNumber > 0) { + username = this.findUsernameField(pageDetails, pf, false, true); + + if (!username) { + // not able to find any viewable username fields. maybe there are some "hidden" ones? + username = this.findUsernameField(pageDetails, pf, true, true); + } + + if (username) { + usernames.push(username); + } + } + } + + if (!passwordFields.length && !options.skipUsernameOnlyFill) { + // No password fields on this page. Let's try to just fuzzy fill the username. + pageDetails.fields.forEach((f: any) => { + if (f.viewable && (f.type === 'text' || f.type === 'email' || f.type === 'tel') && + this.fieldIsFuzzyMatch(f, UsernameFieldNames)) { + usernames.push(f); + } + }); + } + + usernames.forEach((u) => { + if (filledFields.hasOwnProperty(u.opid)) { + return; + } + + filledFields[u.opid] = u; + fillScript.script.push(['click_on_opid', u.opid]); + fillScript.script.push(['fill_by_opid', u.opid, login.username]); + }); + + passwords.forEach((p) => { + if (filledFields.hasOwnProperty(p.opid)) { + return; + } + + filledFields[p.opid] = p; + fillScript.script.push(['click_on_opid', p.opid]); + fillScript.script.push(['fill_by_opid', p.opid, login.password]); + }); + + fillScript = this.setFillScriptForFocus(filledFields, fillScript); + return fillScript; + } + + private generateCardFillScript(fillScript: AutofillScript, pageDetails: any, + filledFields: { [id: string]: AutofillField; }, options: any): AutofillScript { + if (!options.cipher.card) { + return null; + } + + const fillFields: { [id: string]: AutofillField; } = {}; + + pageDetails.fields.forEach((f: any) => { + CardAttributes.forEach((attr) => { + if (!f.hasOwnProperty(attr) || !f[attr] || !f.viewable) { + return; + } + + // ref https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill + // ref https://developers.google.com/web/fundamentals/design-and-ux/input/forms/ + if (!fillFields.cardholderName && this.isFieldMatch(f[attr], + ['cc-name', 'card-name', 'cardholder-name', 'cardholder', 'name'], + ['cc-name', 'card-name', 'cardholder-name', 'cardholder'])) { + fillFields.cardholderName = f; + } else if (!fillFields.number && this.isFieldMatch(f[attr], + ['cc-number', 'cc-num', 'card-number', 'card-num', 'number'], + ['cc-number', 'cc-num', 'card-number', 'card-num'])) { + fillFields.number = f; + } else if (!fillFields.exp && this.isFieldMatch(f[attr], + ['cc-exp', 'card-exp', 'cc-expiration', 'card-expiration', 'cc-ex', 'card-ex'], + [])) { + fillFields.exp = f; + } else if (!fillFields.expMonth && this.isFieldMatch(f[attr], + ['exp-month', 'cc-exp-month', 'cc-month', 'card-month', 'cc-mo', 'card-mo', 'exp-mo', + 'card-exp-mo', 'cc-exp-mo', 'card-expiration-month', 'expiration-month', + 'cc-mm', 'card-mm', 'card-exp-mm', 'cc-exp-mm', 'exp-mm'])) { + fillFields.expMonth = f; + } else if (!fillFields.expYear && this.isFieldMatch(f[attr], + ['exp-year', 'cc-exp-year', 'cc-year', 'card-year', 'cc-yr', 'card-yr', 'exp-yr', + 'card-exp-yr', 'cc-exp-yr', 'card-expiration-year', 'expiration-year', + 'cc-yy', 'card-yy', 'card-exp-yy', 'cc-exp-yy', 'exp-yy', + 'cc-yyyy', 'card-yyyy', 'card-exp-yyyy', 'cc-exp-yyyy'])) { + fillFields.expYear = f; + } else if (!fillFields.code && this.isFieldMatch(f[attr], + ['cvv', 'cvc', 'cvv2', 'cc-csc', 'cc-cvv', 'card-csc', 'card-cvv', 'cvd', + 'cid', 'cvc2', 'cnv', 'cvn2', 'cc-code', 'card-code'])) { + fillFields.code = f; + } else if (!fillFields.brand && this.isFieldMatch(f[attr], + ['cc-type', 'card-type', 'card-brand', 'cc-brand'])) { + fillFields.brand = f; + } + }); + }); + + const card = options.cipher.card; + this.makeScriptAction(fillScript, card, fillFields, filledFields, 'cardholderName'); + this.makeScriptAction(fillScript, card, fillFields, filledFields, 'number'); + this.makeScriptAction(fillScript, card, fillFields, filledFields, 'expYear'); + this.makeScriptAction(fillScript, card, fillFields, filledFields, 'code'); + this.makeScriptAction(fillScript, card, fillFields, filledFields, 'brand'); + + if (fillFields.expMonth && this.hasValue(card.expMonth)) { + let expMonth = card.expMonth; + + if (fillFields.expMonth.selectInfo && fillFields.expMonth.selectInfo.options) { + let index: number = null; + if (fillFields.expMonth.selectInfo.options.length === 12) { + index = parseInt(card.expMonth, null) - 1; + } else if (fillFields.expMonth.selectInfo.options.length === 13) { + index = parseInt(card.expMonth, null); + } + + if (index != null) { + const option = fillFields.expMonth.selectInfo.options[index]; + if (option.length > 1) { + expMonth = option[1]; + } + } + } + + filledFields[fillFields.expMonth.opid] = fillFields.expMonth; + fillScript.script.push(['click_on_opid', fillFields.expMonth.opid]); + fillScript.script.push(['fill_by_opid', fillFields.expMonth.opid, expMonth]); + } + + if (fillFields.exp && this.hasValue(card.expMonth) && this.hasValue(card.expYear)) { + let year = card.expYear; + if (year.length === 2) { + year = '20' + year; + } + + const exp = year + '-' + ('0' + card.expMonth).slice(-2); + this.makeScriptActionWithValue(fillScript, exp, fillFields.exp, filledFields); + } + + return fillScript; + } + + private generateIdentityFillScript(fillScript: AutofillScript, pageDetails: any, + filledFields: { [id: string]: AutofillField; }, options: any): AutofillScript { + if (!options.cipher.identity) { + return null; + } + + const fillFields: { [id: string]: AutofillField; } = {}; + + pageDetails.fields.forEach((f: any) => { + IdentityAttributes.forEach((attr) => { + if (!f.hasOwnProperty(attr) || !f[attr] || !f.viewable) { + return; + } + + // ref https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill + // ref https://developers.google.com/web/fundamentals/design-and-ux/input/forms/ + if (!fillFields.name && this.isFieldMatch(f[attr], + ['name', 'full-name', 'your-name'], ['full-name', 'your-name'])) { + fillFields.name = f; + } else if (!fillFields.firstName && this.isFieldMatch(f[attr], + ['f-name', 'first-name', 'given-name', 'first-n'])) { + fillFields.firstName = f; + } else if (!fillFields.middleName && this.isFieldMatch(f[attr], + ['m-name', 'middle-name', 'additional-name', 'middle-initial', 'middle-n', 'middle-i'])) { + fillFields.middleName = f; + } else if (!fillFields.lastName && this.isFieldMatch(f[attr], + ['l-name', 'last-name', 's-name', 'surname', 'family-name', 'family-n', 'last-n'])) { + fillFields.lastName = f; + } else if (!fillFields.title && this.isFieldMatch(f[attr], + ['honorific-prefix', 'prefix', 'title'])) { + fillFields.title = f; + } else if (!fillFields.email && this.isFieldMatch(f[attr], + ['e-mail', 'email-address'])) { + fillFields.email = f; + } else if (!fillFields.address && this.isFieldMatch(f[attr], + ['address', 'street-address', 'addr'], [])) { + fillFields.address = f; + } else if (!fillFields.address1 && this.isFieldMatch(f[attr], + ['address-1', 'address-line-1', 'addr-1'])) { + fillFields.address1 = f; + } else if (!fillFields.address2 && this.isFieldMatch(f[attr], + ['address-2', 'address-line-2', 'addr-2'])) { + fillFields.address2 = f; + } else if (!fillFields.address3 && this.isFieldMatch(f[attr], + ['address-3', 'address-line-3', 'addr-3'])) { + fillFields.address3 = f; + } else if (!fillFields.postalCode && this.isFieldMatch(f[attr], + ['postal', 'zip', 'zip2', 'zip-code', 'postal-code', 'post-code', 'address-zip', + 'address-postal', 'address-code', 'address-postal-code', 'address-zip-code'])) { + fillFields.postalCode = f; + } else if (!fillFields.city && this.isFieldMatch(f[attr], + ['city', 'town', 'address-level-2', 'address-city', 'address-town'])) { + fillFields.city = f; + } else if (!fillFields.state && this.isFieldMatch(f[attr], + ['state', 'province', 'provence', 'address-level-1', 'address-state', + 'address-province'])) { + fillFields.state = f; + } else if (!fillFields.country && this.isFieldMatch(f[attr], + ['country', 'country-code', 'country-name', 'address-country', 'address-country-name', + 'address-country-code'])) { + fillFields.country = f; + } else if (!fillFields.phone && this.isFieldMatch(f[attr], + ['phone', 'mobile', 'mobile-phone', 'tel', 'telephone', 'phone-number'])) { + fillFields.phone = f; + } else if (!fillFields.username && this.isFieldMatch(f[attr], + ['user-name', 'user-id', 'screen-name'])) { + fillFields.username = f; + } else if (!fillFields.company && this.isFieldMatch(f[attr], + ['company', 'company-name', 'organization', 'organization-name'])) { + fillFields.company = f; + } + }); + }); + + const identity = options.cipher.identity; + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'title'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'firstName'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'middleName'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'lastName'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'address1'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'address2'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'address3'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'city'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'postalCode'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'company'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'email'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'phone'); + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'username'); + + let filledState = false; + if (fillFields.state && identity.state && identity.state.length > 2) { + const stateLower = identity.state.toLowerCase(); + const isoState = IsoStates[stateLower] || IsoProvinces[stateLower]; + if (isoState) { + filledState = true; + this.makeScriptActionWithValue(fillScript, isoState, fillFields.state, filledFields); + } + } + + if (!filledState) { + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'state'); + } + + let filledCountry = false; + if (fillFields.country && identity.country && identity.country.length > 2) { + const countryLower = identity.country.toLowerCase(); + const isoCountry = IsoCountries[countryLower]; + if (isoCountry) { + filledCountry = true; + this.makeScriptActionWithValue(fillScript, isoCountry, fillFields.country, filledFields); + } + } + + if (!filledCountry) { + this.makeScriptAction(fillScript, identity, fillFields, filledFields, 'country'); + } + + if (fillFields.name && (identity.firstName || identity.lastName)) { + let fullName = ''; + if (this.hasValue(identity.firstName)) { + fullName = identity.firstName; + } + if (this.hasValue(identity.middleName)) { + if (fullName !== '') { + fullName += ' '; + } + fullName += identity.middleName; + } + if (this.hasValue(identity.lastName)) { + if (fullName !== '') { + fullName += ' '; + } + fullName += identity.lastName; + } + + this.makeScriptActionWithValue(fillScript, fullName, fillFields.name, filledFields); + } + + if (fillFields.address && this.hasValue(identity.address1)) { + let address = ''; + if (this.hasValue(identity.address1)) { + address = identity.address1; + } + if (this.hasValue(identity.address2)) { + if (address !== '') { + address += ', '; + } + address += identity.address2; + } + if (this.hasValue(identity.address3)) { + if (address !== '') { + address += ', '; + } + address += identity.address3; + } + + this.makeScriptActionWithValue(fillScript, address, fillFields.address, filledFields); + } + + return fillScript; + } + + private isFieldMatch(value: string, options: string[], containsOptions?: string[]): boolean { + value = value.trim().toLowerCase().replace(/[^a-zA-Z]+/g, ''); + for (let i = 0; i < options.length; i++) { + let option = options[i]; + const checkValueContains = containsOptions == null || containsOptions.indexOf(option) > -1; + option = option.replace(/-/g, ''); + if (value === option || (checkValueContains && value.indexOf(option) > -1)) { + return true; + } + } + + return false; + } + + private makeScriptAction(fillScript: AutofillScript, cipherData: any, fillFields: { [id: string]: AutofillField; }, + filledFields: { [id: string]: AutofillField; }, dataProp: string, fieldProp?: string) { + fieldProp = fieldProp || dataProp; + this.makeScriptActionWithValue(fillScript, cipherData[dataProp], fillFields[fieldProp], filledFields); + } + + private makeScriptActionWithValue(fillScript: AutofillScript, dataValue: any, field: AutofillField, + filledFields: { [id: string]: AutofillField; }) { + + let doFill = false; + if (this.hasValue(dataValue) && field) { + if (field.type === 'select-one' && field.selectInfo && field.selectInfo.options) { + for (let i = 0; i < field.selectInfo.options.length; i++) { + const option = field.selectInfo.options[i]; + for (let j = 0; j < option.length; j++) { + if (option[j].toLowerCase() === dataValue.toLowerCase()) { + doFill = true; + if (option.length > 1) { + dataValue = option[1]; + } + break; + } + } + + if (doFill) { + break; + } + } + } else { + doFill = true; + } + } + + if (doFill) { + filledFields[field.opid] = field; + fillScript.script.push(['click_on_opid', field.opid]); + fillScript.script.push(['fill_by_opid', field.opid, dataValue]); + } + } + + private loadPasswordFields(pageDetails: AutofillPageDetails, canBeHidden: boolean) { + const arr: AutofillField[] = []; + pageDetails.fields.forEach((f) => { + if (!f.disabled && !f.readonly && f.type === 'password' && (canBeHidden || f.viewable)) { + arr.push(f); + } + }); + + return arr; + } + + private findUsernameField(pageDetails: AutofillPageDetails, passwordField: AutofillField, canBeHidden: boolean, + withoutForm: boolean) { + let usernameField: AutofillField = null; + for (let i = 0; i < pageDetails.fields.length; i++) { + const f = pageDetails.fields[i]; + if (f.elementNumber >= passwordField.elementNumber) { + break; + } + + if (!f.disabled && !f.readonly && + (withoutForm || f.form === passwordField.form) && (canBeHidden || f.viewable) && + (f.type === 'text' || f.type === 'email' || f.type === 'tel')) { + usernameField = f; + + if (this.findMatchingFieldIndex(f, UsernameFieldNames) > -1) { + // We found an exact match. No need to keep looking. + break; + } + } + } + + return usernameField; + } + + private findMatchingFieldIndex(field: AutofillField, names: string[]): number { + for (let i = 0; i < names.length; i++) { + if (this.fieldPropertyIsMatch(field, 'htmlID', names[i])) { + return i; + } + if (this.fieldPropertyIsMatch(field, 'htmlName', names[i])) { + return i; + } + if (this.fieldPropertyIsMatch(field, 'label-tag', names[i])) { + return i; + } + if (this.fieldPropertyIsMatch(field, 'placeholder', names[i])) { + return i; + } + } + + return -1; + } + + private fieldPropertyIsMatch(field: any, property: string, name: string): boolean { + let fieldVal = field[property] as string; + if (!this.hasValue(fieldVal)) { + return false; + } + + fieldVal = fieldVal.trim().replace(/(?:\r\n|\r|\n)/g, ''); + if (name.startsWith('regex=')) { + try { + const regexParts = name.split('=', 2); + if (regexParts.length === 2) { + const regex = new RegExp(regexParts[1], 'i'); + return regex.test(fieldVal); + } + } catch (e) { } + } else if (name.startsWith('csv=')) { + const csvParts = name.split('=', 2); + if (csvParts.length === 2) { + const csvVals = csvParts[1].split(','); + for (let i = 0; i < csvVals.length; i++) { + const val = csvVals[i]; + if (val != null && val.trim().toLowerCase() === fieldVal.toLowerCase()) { + return true; + } + } + return false; + } + } + + return fieldVal.toLowerCase() === name; + } + + private fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean { + if (this.hasValue(field.htmlID) && this.fuzzyMatch(names, field.htmlID)) { + return true; + } + if (this.hasValue(field.htmlName) && this.fuzzyMatch(names, field.htmlName)) { + return true; + } + if (this.hasValue(field['label-tag']) && this.fuzzyMatch(names, field['label-tag'])) { + return true; + } + if (this.hasValue(field.placeholder) && this.fuzzyMatch(names, field.placeholder)) { + return true; + } + if (this.hasValue(field['label-left']) && this.fuzzyMatch(names, field['label-left'])) { + return true; + } + if (this.hasValue(field['label-top']) && this.fuzzyMatch(names, field['label-top'])) { + return true; + } + + return false; + } + + private fuzzyMatch(options: string[], value: string): boolean { + if (options == null || options.length === 0 || value == null || value === '') { + return false; + } + + value = value.replace(/(?:\r\n|\r|\n)/g, '').trim().toLowerCase(); + + for (let i = 0; i < options.length; i++) { + if (value.indexOf(options[i]) > -1) { + return true; + } + } + + return false; + } + + private hasValue(str: string): boolean { + return str && str !== ''; + } + + private setFillScriptForFocus(filledFields: { [id: string]: AutofillField; }, + fillScript: AutofillScript): AutofillScript { + let lastField: AutofillField = null; + let lastPasswordField: AutofillField = null; + + for (const opid in filledFields) { + if (filledFields.hasOwnProperty(opid) && filledFields[opid].viewable) { + lastField = filledFields[opid]; + + if (filledFields[opid].type === 'password') { + lastPasswordField = filledFields[opid]; + } + } + } + + // Prioritize password field over others. + if (lastPasswordField) { + fillScript.script.push(['focus_by_opid', lastPasswordField.opid]); + } else if (lastField) { + fillScript.script.push(['focus_by_opid', lastField.opid]); + } + + return fillScript; + } +} diff --git a/src/services/cipher.service.ts b/src/services/cipher.service.ts new file mode 100644 index 0000000000..7b0d5d08b2 --- /dev/null +++ b/src/services/cipher.service.ts @@ -0,0 +1,514 @@ +import { CipherType } from '../enums/cipherType.enum'; + +import { Cipher } from '../models/domain/cipher'; +import { CipherString } from '../models/domain/cipherString'; +import { Field } from '../models/domain/field'; +import SymmetricCryptoKey from '../models/domain/symmetricCryptoKey'; + +import { CipherData } from '../models/data/cipherData'; + +import { CipherRequest } from '../models/request/cipherRequest'; +import { CipherResponse } from '../models/response/cipherResponse'; +import { ErrorResponse } from '../models/response/errorResponse'; + +import ApiService from './api.service'; +import ConstantsService from './constants.service'; +import CryptoService from './crypto.service'; +import SettingsService from './settings.service'; +import UserService from './user.service'; +import UtilsService from './utils.service'; + +const Keys = { + ciphersPrefix: 'ciphers_', + localData: 'sitesLocalData', + neverDomains: 'neverDomains', +}; + +export default class CipherService { + static sortCiphersByLastUsed(a: any, b: any): number { + const aLastUsed = a.localData && a.localData.lastUsedDate ? a.localData.lastUsedDate as number : null; + const bLastUsed = b.localData && b.localData.lastUsedDate ? b.localData.lastUsedDate as number : null; + + if (aLastUsed != null && bLastUsed != null && aLastUsed < bLastUsed) { + return 1; + } + if (aLastUsed != null && bLastUsed == null) { + return -1; + } + + if (bLastUsed != null && aLastUsed != null && aLastUsed > bLastUsed) { + return -1; + } + if (bLastUsed != null && aLastUsed == null) { + return 1; + } + + return 0; + } + + static sortCiphersByLastUsedThenName(a: any, b: any): number { + const result = CipherService.sortCiphersByLastUsed(a, b); + if (result !== 0) { + return result; + } + + const nameA = (a.name + '_' + a.username).toUpperCase(); + const nameB = (b.name + '_' + b.username).toUpperCase(); + + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + + return 0; + } + + decryptedCipherCache: any[]; + + constructor(private cryptoService: CryptoService, private userService: UserService, + private settingsService: SettingsService, private apiService: ApiService) { + } + + clearCache(): void { + this.decryptedCipherCache = null; + } + + async encrypt(model: any): Promise { + const cipher = new Cipher(); + cipher.id = model.id; + cipher.folderId = model.folderId; + cipher.favorite = model.favorite; + cipher.organizationId = model.organizationId; + cipher.type = model.type; + cipher.collectionIds = model.collectionIds; + + const key = await this.cryptoService.getOrgKey(cipher.organizationId); + await Promise.all([ + this.encryptObjProperty(model, cipher, { + name: null, + notes: null, + }, key), + this.encryptCipherData(model, cipher, key), + this.encryptFields(model.fields, key).then((fields) => { + cipher.fields = fields; + }), + ]); + + return cipher; + } + + async encryptFields(fieldsModel: any[], key: SymmetricCryptoKey): Promise { + if (!fieldsModel || !fieldsModel.length) { + return null; + } + + const self = this; + const encFields: Field[] = []; + await fieldsModel.reduce((promise, field) => { + return promise.then(() => { + return self.encryptField(field, key); + }).then((encField: Field) => { + encFields.push(encField); + }); + }, Promise.resolve()); + + return encFields; + } + + async encryptField(fieldModel: any, key: SymmetricCryptoKey): Promise { + const field = new Field(); + field.type = fieldModel.type; + + await this.encryptObjProperty(fieldModel, field, { + name: null, + value: null, + }, key); + + return field; + } + + async get(id: string): Promise { + const userId = await this.userService.getUserId(); + const localData = await UtilsService.getObjFromStorage(Keys.localData); + const ciphers = await UtilsService.getObjFromStorage<{ [id: string]: CipherData; }>( + Keys.ciphersPrefix + userId); + if (ciphers == null || !ciphers.hasOwnProperty(id)) { + return null; + } + + return new Cipher(ciphers[id], false, localData ? localData[id] : null); + } + + async getAll(): Promise { + const userId = await this.userService.getUserId(); + const localData = await UtilsService.getObjFromStorage(Keys.localData); + const ciphers = await UtilsService.getObjFromStorage<{ [id: string]: CipherData; }>( + Keys.ciphersPrefix + userId); + const response: Cipher[] = []; + for (const id in ciphers) { + if (ciphers.hasOwnProperty(id)) { + response.push(new Cipher(ciphers[id], false, localData ? localData[id] : null)); + } + } + return response; + } + + async getAllDecrypted(): Promise { + if (this.decryptedCipherCache != null) { + return this.decryptedCipherCache; + } + + const decCiphers: any[] = []; + const key = await this.cryptoService.getKey(); + if (key == null) { + throw new Error('No key.'); + } + + const promises: any[] = []; + const ciphers = await this.getAll(); + ciphers.forEach((cipher) => { + promises.push(cipher.decrypt().then((c: any) => { + decCiphers.push(c); + })); + }); + + await Promise.all(promises); + this.decryptedCipherCache = decCiphers; + return this.decryptedCipherCache; + } + + async getAllDecryptedForGrouping(groupingId: string, folder: boolean = true): Promise { + const ciphers = await this.getAllDecrypted(); + const ciphersToReturn: any[] = []; + + ciphers.forEach((cipher) => { + if (folder && cipher.folderId === groupingId) { + ciphersToReturn.push(cipher); + } else if (!folder && cipher.collectionIds != null && cipher.collectionIds.indexOf(groupingId) > -1) { + ciphersToReturn.push(cipher); + } + }); + + return ciphersToReturn; + } + + async getAllDecryptedForDomain(domain: string, includeOtherTypes?: any[]): Promise { + if (domain == null && !includeOtherTypes) { + return Promise.resolve([]); + } + + const eqDomainsPromise = domain == null ? Promise.resolve([]) : + this.settingsService.getEquivalentDomains().then((eqDomains: any[][]) => { + let matches: any[] = []; + eqDomains.forEach((eqDomain) => { + if (eqDomain.length && eqDomain.indexOf(domain) >= 0) { + matches = matches.concat(eqDomain); + } + }); + + if (!matches.length) { + matches.push(domain); + } + + return matches; + }); + + const result = await Promise.all([eqDomainsPromise, this.getAllDecrypted()]); + const matchingDomains = result[0]; + const ciphers = result[1]; + const ciphersToReturn: any[] = []; + + ciphers.forEach((cipher) => { + if (domain && cipher.type === CipherType.Login && cipher.login.domain && + matchingDomains.indexOf(cipher.login.domain) > -1) { + ciphersToReturn.push(cipher); + } else if (includeOtherTypes && includeOtherTypes.indexOf(cipher.type) > -1) { + ciphersToReturn.push(cipher); + } + }); + + return ciphersToReturn; + } + + async getLastUsedForDomain(domain: string): Promise { + const ciphers = await this.getAllDecryptedForDomain(domain); + if (ciphers.length === 0) { + return null; + } + + const sortedCiphers = ciphers.sort(CipherService.sortCiphersByLastUsed); + return sortedCiphers[0]; + } + + async updateLastUsedDate(id: string): Promise { + let ciphersLocalData = await UtilsService.getObjFromStorage(Keys.localData); + if (!ciphersLocalData) { + ciphersLocalData = {}; + } + + if (ciphersLocalData[id]) { + ciphersLocalData[id].lastUsedDate = new Date().getTime(); + } else { + ciphersLocalData[id] = { + lastUsedDate: new Date().getTime(), + }; + } + + await UtilsService.saveObjToStorage(Keys.localData, ciphersLocalData); + + if (this.decryptedCipherCache == null) { + return; + } + + for (let i = 0; i < this.decryptedCipherCache.length; i++) { + const cached = this.decryptedCipherCache[i]; + if (cached.id === id) { + cached.localData = ciphersLocalData[id]; + break; + } + } + } + + async saveNeverDomain(domain: string): Promise { + if (domain == null) { + return; + } + + let domains = await UtilsService.getObjFromStorage<{ [id: string]: any; }>(Keys.neverDomains); + if (!domains) { + domains = {}; + } + domains[domain] = null; + await UtilsService.saveObjToStorage(Keys.neverDomains, domains); + } + + async saveWithServer(cipher: Cipher): Promise { + const request = new CipherRequest(cipher); + + let response: CipherResponse; + if (cipher.id == null) { + response = await this.apiService.postCipher(request); + cipher.id = response.id; + } else { + response = await this.apiService.putCipher(cipher.id, request); + } + + const userId = await this.userService.getUserId(); + const data = new CipherData(response, userId, cipher.collectionIds); + await this.upsert(data); + } + + saveAttachmentWithServer(cipher: Cipher, unencryptedFile: any): Promise { + const self = this; + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsArrayBuffer(unencryptedFile); + + reader.onload = async (evt: any) => { + const key = await self.cryptoService.getOrgKey(cipher.organizationId); + const encFileName = await self.cryptoService.encrypt(unencryptedFile.name, key); + const encData = await self.cryptoService.encryptToBytes(evt.target.result, key); + + const fd = new FormData(); + const blob = new Blob([encData], { type: 'application/octet-stream' }); + fd.append('data', blob, encFileName.encryptedString); + + let response: CipherResponse; + try { + response = await self.apiService.postCipherAttachment(cipher.id, fd); + } catch (e) { + reject((e as ErrorResponse).getSingleMessage()); + return; + } + + const userId = await self.userService.getUserId(); + const data = new CipherData(response, userId, cipher.collectionIds); + this.upsert(data); + resolve(new Cipher(data)); + + }; + + reader.onerror = (evt) => { + reject('Error reading file.'); + }; + }); + } + + async upsert(cipher: CipherData | CipherData[]): Promise { + const userId = await this.userService.getUserId(); + let ciphers = await UtilsService.getObjFromStorage<{ [id: string]: CipherData; }>( + Keys.ciphersPrefix + userId); + if (ciphers == null) { + ciphers = {}; + } + + if (cipher instanceof CipherData) { + const c = cipher as CipherData; + ciphers[c.id] = c; + } else { + (cipher as CipherData[]).forEach((c) => { + ciphers[c.id] = c; + }); + } + + await UtilsService.saveObjToStorage(Keys.ciphersPrefix + userId, ciphers); + this.decryptedCipherCache = null; + } + + async replace(ciphers: { [id: string]: CipherData; }): Promise { + const userId = await this.userService.getUserId(); + await UtilsService.saveObjToStorage(Keys.ciphersPrefix + userId, ciphers); + this.decryptedCipherCache = null; + } + + async clear(userId: string): Promise { + await UtilsService.removeFromStorage(Keys.ciphersPrefix + userId); + this.decryptedCipherCache = null; + } + + async delete(id: string | string[]): Promise { + const userId = await this.userService.getUserId(); + const ciphers = await UtilsService.getObjFromStorage<{ [id: string]: CipherData; }>( + Keys.ciphersPrefix + userId); + if (ciphers == null) { + return; + } + + if (typeof id === 'string') { + const i = id as string; + delete ciphers[id]; + } else { + (id as string[]).forEach((i) => { + delete ciphers[i]; + }); + } + + await UtilsService.saveObjToStorage(Keys.ciphersPrefix + userId, ciphers); + this.decryptedCipherCache = null; + } + + async deleteWithServer(id: string): Promise { + await this.apiService.deleteCipher(id); + await this.delete(id); + } + + async deleteAttachment(id: string, attachmentId: string): Promise { + const userId = await this.userService.getUserId(); + const ciphers = await UtilsService.getObjFromStorage<{ [id: string]: CipherData; }>( + Keys.ciphersPrefix + userId); + + if (ciphers == null || !ciphers.hasOwnProperty(id) || ciphers[id].attachments == null) { + return; + } + + for (let i = 0; i < ciphers[id].attachments.length; i++) { + if (ciphers[id].attachments[i].id === attachmentId) { + ciphers[id].attachments.splice(i, 1); + } + } + + await UtilsService.saveObjToStorage(Keys.ciphersPrefix + userId, ciphers); + this.decryptedCipherCache = null; + } + + async deleteAttachmentWithServer(id: string, attachmentId: string): Promise { + try { + await this.apiService.deleteCipherAttachment(id, attachmentId); + } catch (e) { + return Promise.reject((e as ErrorResponse).getSingleMessage()); + } + await this.deleteAttachment(id, attachmentId); + } + + sortCiphersByLastUsed(a: any, b: any): number { + return CipherService.sortCiphersByLastUsed(a, b); + } + + sortCiphersByLastUsedThenName(a: any, b: any): number { + return CipherService.sortCiphersByLastUsedThenName(a, b); + } + + // Helpers + + private encryptObjProperty(model: any, obj: any, map: any, key: SymmetricCryptoKey): Promise { + const promises = []; + const self = this; + + for (const prop in map) { + if (!map.hasOwnProperty(prop)) { + continue; + } + + // tslint:disable-next-line + (function (theProp, theObj) { + const p = Promise.resolve().then(() => { + const modelProp = model[(map[theProp] || theProp)]; + if (modelProp && modelProp !== '') { + return self.cryptoService.encrypt(modelProp, key); + } + return null; + }).then((val: CipherString) => { + theObj[theProp] = val; + }); + promises.push(p); + })(prop, obj); + } + + return Promise.all(promises); + } + + private encryptCipherData(cipher: Cipher, model: any, key: SymmetricCryptoKey): Promise { + switch (cipher.type) { + case CipherType.Login: + model.login = {}; + return this.encryptObjProperty(cipher.login, model.login, { + uri: null, + username: null, + password: null, + totp: null, + }, key); + case CipherType.SecureNote: + model.secureNote = { + type: cipher.secureNote.type, + }; + return Promise.resolve(); + case CipherType.Card: + model.card = {}; + return this.encryptObjProperty(cipher.card, model.card, { + cardholderName: null, + brand: null, + number: null, + expMonth: null, + expYear: null, + code: null, + }, key); + case CipherType.Identity: + model.identity = {}; + return this.encryptObjProperty(cipher.identity, model.identity, { + title: null, + firstName: null, + middleName: null, + lastName: null, + address1: null, + address2: null, + address3: null, + city: null, + state: null, + postalCode: null, + country: null, + company: null, + email: null, + phone: null, + ssn: null, + username: null, + passportNumber: null, + licenseNumber: null, + }, key); + default: + throw new Error('Unknown cipher type.'); + } + } +} diff --git a/src/services/collection.service.ts b/src/services/collection.service.ts new file mode 100644 index 0000000000..14e23118be --- /dev/null +++ b/src/services/collection.service.ts @@ -0,0 +1,124 @@ +import { CipherString } from '../models/domain/cipherString'; +import { Collection } from '../models/domain/collection'; + +import { CollectionData } from '../models/data/collectionData'; + +import CryptoService from './crypto.service'; +import UserService from './user.service'; +import UtilsService from './utils.service'; + +const Keys = { + collectionsPrefix: 'collections_', +}; + +export default class CollectionService { + decryptedCollectionCache: any[]; + + constructor(private cryptoService: CryptoService, private userService: UserService) { + } + + clearCache(): void { + this.decryptedCollectionCache = null; + } + + async get(id: string): Promise { + const userId = await this.userService.getUserId(); + const collections = await UtilsService.getObjFromStorage<{ [id: string]: CollectionData; }>( + Keys.collectionsPrefix + userId); + if (collections == null || !collections.hasOwnProperty(id)) { + return null; + } + + return new Collection(collections[id]); + } + + async getAll(): Promise { + const userId = await this.userService.getUserId(); + const collections = await UtilsService.getObjFromStorage<{ [id: string]: CollectionData; }>( + Keys.collectionsPrefix + userId); + const response: Collection[] = []; + for (const id in collections) { + if (collections.hasOwnProperty(id)) { + response.push(new Collection(collections[id])); + } + } + return response; + } + + async getAllDecrypted(): Promise { + if (this.decryptedCollectionCache != null) { + return this.decryptedCollectionCache; + } + + const key = await this.cryptoService.getKey(); + if (key == null) { + throw new Error('No key.'); + } + + const decFolders: any[] = []; + const promises: Array> = []; + const folders = await this.getAll(); + folders.forEach((folder) => { + promises.push(folder.decrypt().then((f: any) => { + decFolders.push(f); + })); + }); + + await Promise.all(promises); + this.decryptedCollectionCache = decFolders; + return this.decryptedCollectionCache; + } + + async upsert(collection: CollectionData | CollectionData[]): Promise { + const userId = await this.userService.getUserId(); + let collections = await UtilsService.getObjFromStorage<{ [id: string]: CollectionData; }>( + Keys.collectionsPrefix + userId); + if (collections == null) { + collections = {}; + } + + if (collection instanceof CollectionData) { + const c = collection as CollectionData; + collections[c.id] = c; + } else { + (collection as CollectionData[]).forEach((c) => { + collections[c.id] = c; + }); + } + + await UtilsService.saveObjToStorage(Keys.collectionsPrefix + userId, collections); + this.decryptedCollectionCache = null; + } + + async replace(collections: { [id: string]: CollectionData; }): Promise { + const userId = await this.userService.getUserId(); + await UtilsService.saveObjToStorage(Keys.collectionsPrefix + userId, collections); + this.decryptedCollectionCache = null; + } + + async clear(userId: string): Promise { + await UtilsService.removeFromStorage(Keys.collectionsPrefix + userId); + this.decryptedCollectionCache = null; + } + + async delete(id: string | string[]): Promise { + const userId = await this.userService.getUserId(); + const collections = await UtilsService.getObjFromStorage<{ [id: string]: CollectionData; }>( + Keys.collectionsPrefix + userId); + if (collections == null) { + return; + } + + if (typeof id === 'string') { + const i = id as string; + delete collections[id]; + } else { + (id as string[]).forEach((i) => { + delete collections[i]; + }); + } + + await UtilsService.saveObjToStorage(Keys.collectionsPrefix + userId, collections); + this.decryptedCollectionCache = null; + } +} diff --git a/src/services/constants.service.ts b/src/services/constants.service.ts new file mode 100644 index 0000000000..337c6174a4 --- /dev/null +++ b/src/services/constants.service.ts @@ -0,0 +1,116 @@ +import UtilsService from './utils.service'; + +export default class ConstantsService { + static readonly environmentUrlsKey: string = 'environmentUrls'; + static readonly disableGaKey: string = 'disableGa'; + static readonly disableAddLoginNotificationKey: string = 'disableAddLoginNotification'; + static readonly disableContextMenuItemKey: string = 'disableContextMenuItem'; + static readonly disableFaviconKey: string = 'disableFavicon'; + static readonly disableAutoTotpCopyKey: string = 'disableAutoTotpCopy'; + static readonly enableAutoFillOnPageLoadKey: string = 'enableAutoFillOnPageLoad'; + static readonly lockOptionKey: string = 'lockOption'; + static readonly lastActiveKey: string = 'lastActive'; + + // TODO: remove these instance properties once all references are reading from the static properties + readonly environmentUrlsKey: string = 'environmentUrls'; + readonly disableGaKey: string = 'disableGa'; + readonly disableAddLoginNotificationKey: string = 'disableAddLoginNotification'; + readonly disableContextMenuItemKey: string = 'disableContextMenuItem'; + readonly disableFaviconKey: string = 'disableFavicon'; + readonly disableAutoTotpCopyKey: string = 'disableAutoTotpCopy'; + readonly enableAutoFillOnPageLoadKey: string = 'enableAutoFillOnPageLoad'; + readonly lockOptionKey: string = 'lockOption'; + readonly lastActiveKey: string = 'lastActive'; + + // TODO: Convert these objects to enums + readonly encType: any = { + AesCbc256_B64: 0, + AesCbc128_HmacSha256_B64: 1, + AesCbc256_HmacSha256_B64: 2, + Rsa2048_OaepSha256_B64: 3, + Rsa2048_OaepSha1_B64: 4, + Rsa2048_OaepSha256_HmacSha256_B64: 5, + Rsa2048_OaepSha1_HmacSha256_B64: 6, + }; + + readonly cipherType: any = { + login: 1, + secureNote: 2, + card: 3, + identity: 4, + }; + + readonly fieldType: any = { + text: 0, + hidden: 1, + boolean: 2, + }; + + readonly twoFactorProvider: any = { + u2f: 4, + yubikey: 3, + duo: 2, + authenticator: 0, + email: 1, + remember: 5, + }; + + twoFactorProviderInfo: any[]; + + constructor(i18nService: any, utilsService: UtilsService) { + if (utilsService.isEdge()) { + // delay for i18n fetch + setTimeout(() => { + this.bootstrap(i18nService); + }, 1000); + } else { + this.bootstrap(i18nService); + } + } + + private bootstrap(i18nService: any) { + this.twoFactorProviderInfo = [ + { + type: 0, + name: i18nService.authenticatorAppTitle, + description: i18nService.authenticatorAppDesc, + active: true, + free: true, + displayOrder: 0, + priority: 1, + }, + { + type: 3, + name: i18nService.yubiKeyTitle, + description: i18nService.yubiKeyDesc, + active: true, + displayOrder: 1, + priority: 3, + }, + { + type: 2, + name: 'Duo', + description: i18nService.duoDesc, + active: true, + displayOrder: 2, + priority: 2, + }, + { + type: 4, + name: i18nService.u2fTitle, + description: i18nService.u2fDesc, + active: true, + displayOrder: 3, + priority: 4, + }, + { + type: 1, + name: i18nService.emailTitle, + description: i18nService.emailDesc, + active: true, + displayOrder: 4, + priority: 0, + }, + ]; + } +} diff --git a/src/services/crypto.service.ts b/src/services/crypto.service.ts new file mode 100644 index 0000000000..16c3c7497b --- /dev/null +++ b/src/services/crypto.service.ts @@ -0,0 +1,597 @@ +import * as forge from 'node-forge'; + +import { EncryptionType } from '../enums/encryptionType.enum'; + +import { CipherString } from '../models/domain/cipherString'; +import EncryptedObject from '../models/domain/encryptedObject'; +import SymmetricCryptoKey from '../models/domain/symmetricCryptoKey'; +import { ProfileOrganizationResponse } from '../models/response/profileOrganizationResponse'; + +import ConstantsService from './constants.service'; +import UtilsService from './utils.service'; + +import { CryptoService as CryptoServiceInterface } from './abstractions/crypto.service'; + +const Keys = { + key: 'key', + encOrgKeys: 'encOrgKeys', + encPrivateKey: 'encPrivateKey', + encKey: 'encKey', + keyHash: 'keyHash', +}; + +const SigningAlgorithm = { + name: 'HMAC', + hash: { name: 'SHA-256' }, +}; + +const AesAlgorithm = { + name: 'AES-CBC', +}; + +const Crypto = window.crypto; +const Subtle = Crypto.subtle; + +export default class CryptoService implements CryptoServiceInterface { + private key: SymmetricCryptoKey; + private encKey: SymmetricCryptoKey; + private legacyEtmKey: SymmetricCryptoKey; + private keyHash: string; + private privateKey: ArrayBuffer; + private orgKeys: Map; + + async setKey(key: SymmetricCryptoKey): Promise { + this.key = key; + + const option = await UtilsService.getObjFromStorage(ConstantsService.lockOptionKey); + if (option != null) { + // if we have a lock option set, we do not store the key + return; + } + + return UtilsService.saveObjToStorage(Keys.key, key.keyB64); + } + + setKeyHash(keyHash: string): Promise<{}> { + this.keyHash = keyHash; + return UtilsService.saveObjToStorage(Keys.keyHash, keyHash); + } + + async setEncKey(encKey: string): Promise<{}> { + if (encKey == null) { + return; + } + await UtilsService.saveObjToStorage(Keys.encKey, encKey); + this.encKey = null; + } + + async setEncPrivateKey(encPrivateKey: string): Promise<{}> { + if (encPrivateKey == null) { + return; + } + + await UtilsService.saveObjToStorage(Keys.encPrivateKey, encPrivateKey); + this.privateKey = null; + } + + setOrgKeys(orgs: ProfileOrganizationResponse[]): Promise<{}> { + const orgKeys: any = {}; + orgs.forEach((org) => { + orgKeys[org.id] = org.key; + }); + + return UtilsService.saveObjToStorage(Keys.encOrgKeys, orgKeys); + } + + async getKey(): Promise { + if (this.key != null) { + return this.key; + } + + const option = await UtilsService.getObjFromStorage(ConstantsService.lockOptionKey); + if (option != null) { + return null; + } + + const key = await UtilsService.getObjFromStorage(Keys.key); + if (key) { + this.key = new SymmetricCryptoKey(key, true); + } + + return key == null ? null : this.key; + } + + getKeyHash(): Promise { + if (this.keyHash != null) { + return Promise.resolve(this.keyHash); + } + + return UtilsService.getObjFromStorage(Keys.keyHash); + } + + async getEncKey(): Promise { + if (this.encKey != null) { + return this.encKey; + } + + const encKey = await UtilsService.getObjFromStorage(Keys.encKey); + if (encKey == null) { + return null; + } + + const key = await this.getKey(); + if (key == null) { + return null; + } + + const decEncKey = await this.decrypt(new CipherString(encKey), key, 'raw'); + if (decEncKey == null) { + return null; + } + + this.encKey = new SymmetricCryptoKey(decEncKey); + return this.encKey; + } + + async getPrivateKey(): Promise { + if (this.privateKey != null) { + return this.privateKey; + } + + const encPrivateKey = await UtilsService.getObjFromStorage(Keys.encPrivateKey); + if (encPrivateKey == null) { + return null; + } + + const privateKey = await this.decrypt(new CipherString(encPrivateKey), null, 'raw'); + const privateKeyB64 = forge.util.encode64(privateKey); + this.privateKey = UtilsService.fromB64ToArray(privateKeyB64).buffer; + return this.privateKey; + } + + async getOrgKeys(): Promise> { + if (this.orgKeys != null && this.orgKeys.size > 0) { + return this.orgKeys; + } + + const self = this; + const encOrgKeys = await UtilsService.getObjFromStorage(Keys.encOrgKeys); + if (!encOrgKeys) { + return null; + } + + const orgKeys: Map = new Map(); + let setKey = false; + + for (const orgId in encOrgKeys) { + if (!encOrgKeys.hasOwnProperty(orgId)) { + continue; + } + + const decValueB64 = await this.rsaDecrypt(encOrgKeys[orgId]); + orgKeys.set(orgId, new SymmetricCryptoKey(decValueB64, true)); + setKey = true; + } + + if (setKey) { + this.orgKeys = orgKeys; + } + + return this.orgKeys; + } + + async getOrgKey(orgId: string): Promise { + if (orgId == null) { + return null; + } + + const orgKeys = await this.getOrgKeys(); + if (orgKeys == null || !orgKeys.has(orgId)) { + return null; + } + + return orgKeys.get(orgId); + } + + clearKey(): Promise { + this.key = this.legacyEtmKey = null; + return UtilsService.removeFromStorage(Keys.key); + } + + clearKeyHash(): Promise { + this.keyHash = null; + return UtilsService.removeFromStorage(Keys.keyHash); + } + + clearEncKey(memoryOnly?: boolean): Promise { + this.encKey = null; + if (memoryOnly) { + return Promise.resolve(); + } + return UtilsService.removeFromStorage(Keys.encKey); + } + + clearPrivateKey(memoryOnly?: boolean): Promise { + this.privateKey = null; + if (memoryOnly) { + return Promise.resolve(); + } + return UtilsService.removeFromStorage(Keys.encPrivateKey); + } + + clearOrgKeys(memoryOnly?: boolean): Promise { + this.orgKeys = null; + if (memoryOnly) { + return Promise.resolve(); + } + return UtilsService.removeFromStorage(Keys.encOrgKeys); + } + + clearKeys(): Promise { + return Promise.all([ + this.clearKey(), + this.clearKeyHash(), + this.clearOrgKeys(), + this.clearEncKey(), + this.clearPrivateKey(), + ]); + } + + async toggleKey(): Promise { + const key = await this.getKey(); + const option = await UtilsService.getObjFromStorage(ConstantsService.lockOptionKey); + if (option != null || option === 0) { + // if we have a lock option set, clear the key + await this.clearKey(); + this.key = key; + return; + } + + await this.setKey(key); + } + + makeKey(password: string, salt: string): SymmetricCryptoKey { + const keyBytes: string = (forge as any).pbkdf2(forge.util.encodeUtf8(password), forge.util.encodeUtf8(salt), + 5000, 256 / 8, 'sha256'); + return new SymmetricCryptoKey(keyBytes); + } + + async hashPassword(password: string, key: SymmetricCryptoKey): Promise { + const storedKey = await this.getKey(); + key = key || storedKey; + if (!password || !key) { + throw new Error('Invalid parameters.'); + } + + const hashBits = (forge as any).pbkdf2(key.key, forge.util.encodeUtf8(password), 1, 256 / 8, 'sha256'); + return forge.util.encode64(hashBits); + } + + makeEncKey(key: SymmetricCryptoKey): Promise { + const bytes = new Uint8Array(512 / 8); + Crypto.getRandomValues(bytes); + return this.encrypt(bytes, key, 'raw'); + } + + async encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey, + plainValueEncoding: string = 'utf8'): Promise { + if (!plainValue) { + return Promise.resolve(null); + } + + let plainValueArr: Uint8Array; + if (plainValueEncoding === 'utf8') { + plainValueArr = UtilsService.fromUtf8ToArray(plainValue as string); + } else { + plainValueArr = plainValue as Uint8Array; + } + + const encValue = await this.aesEncrypt(plainValueArr.buffer, key); + const iv = UtilsService.fromBufferToB64(encValue.iv.buffer); + const ct = UtilsService.fromBufferToB64(encValue.ct.buffer); + const mac = encValue.mac ? UtilsService.fromBufferToB64(encValue.mac.buffer) : null; + return new CipherString(encValue.key.encType, iv, ct, mac); + } + + async encryptToBytes(plainValue: ArrayBuffer, key?: SymmetricCryptoKey): Promise { + const encValue = await this.aesEncrypt(plainValue, key); + let macLen = 0; + if (encValue.mac) { + macLen = encValue.mac.length; + } + + const encBytes = new Uint8Array(1 + encValue.iv.length + macLen + encValue.ct.length); + encBytes.set([encValue.key.encType]); + encBytes.set(encValue.iv, 1); + if (encValue.mac) { + encBytes.set(encValue.mac, 1 + encValue.iv.length); + } + + encBytes.set(encValue.ct, 1 + encValue.iv.length + macLen); + return encBytes.buffer; + } + + async decrypt(cipherString: CipherString, key?: SymmetricCryptoKey, + outputEncoding: string = 'utf8'): Promise { + const ivBytes: string = forge.util.decode64(cipherString.initializationVector); + const ctBytes: string = forge.util.decode64(cipherString.cipherText); + const macBytes: string = cipherString.mac ? forge.util.decode64(cipherString.mac) : null; + const decipher = await this.aesDecrypt(cipherString.encryptionType, ctBytes, ivBytes, macBytes, key); + if (!decipher) { + return null; + } + + if (outputEncoding === 'utf8') { + return decipher.output.toString('utf8'); + } else { + return decipher.output.getBytes(); + } + } + + async decryptFromBytes(encBuf: ArrayBuffer, key: SymmetricCryptoKey): Promise { + if (!encBuf) { + throw new Error('no encBuf.'); + } + + const encBytes = new Uint8Array(encBuf); + const encType = encBytes[0]; + let ctBytes: Uint8Array = null; + let ivBytes: Uint8Array = null; + let macBytes: Uint8Array = null; + + switch (encType) { + case EncryptionType.AesCbc128_HmacSha256_B64: + case EncryptionType.AesCbc256_HmacSha256_B64: + if (encBytes.length <= 49) { // 1 + 16 + 32 + ctLength + return null; + } + + ivBytes = encBytes.slice(1, 17); + macBytes = encBytes.slice(17, 49); + ctBytes = encBytes.slice(49); + break; + case EncryptionType.AesCbc256_B64: + if (encBytes.length <= 17) { // 1 + 16 + ctLength + return null; + } + + ivBytes = encBytes.slice(1, 17); + ctBytes = encBytes.slice(17); + break; + default: + return null; + } + + return await this.aesDecryptWC(encType, ctBytes.buffer, ivBytes.buffer, macBytes ? macBytes.buffer : null, key); + } + + async rsaDecrypt(encValue: string): Promise { + const headerPieces = encValue.split('.'); + let encType: EncryptionType = null; + let encPieces: string[]; + + if (headerPieces.length === 1) { + encType = EncryptionType.Rsa2048_OaepSha256_B64; + encPieces = [headerPieces[0]]; + } else if (headerPieces.length === 2) { + try { + encType = parseInt(headerPieces[0], null); + encPieces = headerPieces[1].split('|'); + } catch (e) { } + } + + switch (encType) { + case EncryptionType.Rsa2048_OaepSha256_B64: + case EncryptionType.Rsa2048_OaepSha1_B64: + if (encPieces.length !== 1) { + throw new Error('Invalid cipher format.'); + } + break; + case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: + case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: + if (encPieces.length !== 2) { + throw new Error('Invalid cipher format.'); + } + break; + default: + throw new Error('encType unavailable.'); + } + + if (encPieces == null || encPieces.length <= 0) { + throw new Error('encPieces unavailable.'); + } + + const key = await this.getEncKey(); + if (key != null && key.macKey != null && encPieces.length > 1) { + const ctBytes: string = forge.util.decode64(encPieces[0]); + const macBytes: string = forge.util.decode64(encPieces[1]); + const computedMacBytes = await this.computeMac(ctBytes, key.macKey, false); + const macsEqual = await this.macsEqual(key.macKey, macBytes, computedMacBytes); + if (!macsEqual) { + throw new Error('MAC failed.'); + } + } + + const privateKeyBytes = await this.getPrivateKey(); + if (!privateKeyBytes) { + throw new Error('No private key.'); + } + + let rsaAlgorithm: any = null; + switch (encType) { + case EncryptionType.Rsa2048_OaepSha256_B64: + case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: + rsaAlgorithm = { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' }, + }; + break; + case EncryptionType.Rsa2048_OaepSha1_B64: + case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: + rsaAlgorithm = { + name: 'RSA-OAEP', + hash: { name: 'SHA-1' }, + }; + break; + default: + throw new Error('encType unavailable.'); + } + + const privateKey = await Subtle.importKey('pkcs8', privateKeyBytes, rsaAlgorithm, false, ['decrypt']); + const ctArr = UtilsService.fromB64ToArray(encPieces[0]); + const decBytes = await Subtle.decrypt(rsaAlgorithm, privateKey, ctArr.buffer); + const b64DecValue = UtilsService.fromBufferToB64(decBytes); + return b64DecValue; + } + + // Helpers + + private async aesEncrypt(plainValue: ArrayBuffer, key: SymmetricCryptoKey): Promise { + const obj = new EncryptedObject(); + obj.key = await this.getKeyForEncryption(key); + const keyBuf = obj.key.getBuffers(); + + obj.iv = new Uint8Array(16); + Crypto.getRandomValues(obj.iv); + + const encKey = await Subtle.importKey('raw', keyBuf.encKey, AesAlgorithm, false, ['encrypt']); + const encValue = await Subtle.encrypt({ name: 'AES-CBC', iv: obj.iv }, encKey, plainValue); + obj.ct = new Uint8Array(encValue); + + if (keyBuf.macKey) { + const data = new Uint8Array(obj.iv.length + obj.ct.length); + data.set(obj.iv, 0); + data.set(obj.ct, obj.iv.length); + const mac = await this.computeMacWC(data.buffer, keyBuf.macKey); + obj.mac = new Uint8Array(mac); + } + + return obj; + } + + private async aesDecrypt(encType: EncryptionType, ctBytes: string, ivBytes: string, macBytes: string, + key: SymmetricCryptoKey): Promise { + const keyForEnc = await this.getKeyForEncryption(key); + const theKey = this.resolveLegacyKey(encType, keyForEnc); + + if (encType !== theKey.encType) { + // tslint:disable-next-line + console.error('encType unavailable.'); + return null; + } + + if (theKey.macKey != null && macBytes != null) { + const computedMacBytes = this.computeMac(ivBytes + ctBytes, theKey.macKey, false); + if (!this.macsEqual(theKey.macKey, computedMacBytes, macBytes)) { + // tslint:disable-next-line + console.error('MAC failed.'); + return null; + } + } + + const ctBuffer = (forge as any).util.createBuffer(ctBytes); + const decipher = (forge as any).cipher.createDecipher('AES-CBC', theKey.encKey); + decipher.start({ iv: ivBytes }); + decipher.update(ctBuffer); + decipher.finish(); + + return decipher; + } + + private async aesDecryptWC(encType: EncryptionType, ctBuf: ArrayBuffer, ivBuf: ArrayBuffer, + macBuf: ArrayBuffer, key: SymmetricCryptoKey): Promise { + const theKey = await this.getKeyForEncryption(key); + const keyBuf = theKey.getBuffers(); + const encKey = await Subtle.importKey('raw', keyBuf.encKey, AesAlgorithm, false, ['decrypt']); + if (!keyBuf.macKey || !macBuf) { + return null; + } + + const data = new Uint8Array(ivBuf.byteLength + ctBuf.byteLength); + data.set(new Uint8Array(ivBuf), 0); + data.set(new Uint8Array(ctBuf), ivBuf.byteLength); + const computedMacBuf = await this.computeMacWC(data.buffer, keyBuf.macKey); + if (computedMacBuf === null) { + return null; + } + + const macsMatch = await this.macsEqualWC(keyBuf.macKey, macBuf, computedMacBuf); + if (macsMatch === false) { + // tslint:disable-next-line + console.error('MAC failed.'); + return null; + } + + return await Subtle.decrypt({ name: 'AES-CBC', iv: ivBuf }, encKey, ctBuf); + } + + private computeMac(dataBytes: string, macKey: string, b64Output: boolean): string { + const hmac = (forge as any).hmac.create(); + hmac.start('sha256', macKey); + hmac.update(dataBytes); + const mac = hmac.digest(); + return b64Output ? forge.util.encode64(mac.getBytes()) : mac.getBytes(); + } + + private async computeMacWC(dataBuf: ArrayBuffer, macKeyBuf: ArrayBuffer): Promise { + const key = await Subtle.importKey('raw', macKeyBuf, SigningAlgorithm, false, ['sign']); + return await Subtle.sign(SigningAlgorithm, key, dataBuf); + } + + // Safely compare two MACs in a way that protects against timing attacks (Double HMAC Verification). + // ref: https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/ + private macsEqual(macKey: string, mac1: string, mac2: string): boolean { + const hmac = (forge as any).hmac.create(); + + hmac.start('sha256', macKey); + hmac.update(mac1); + const mac1Bytes = hmac.digest().getBytes(); + + hmac.start(null, null); + hmac.update(mac2); + const mac2Bytes = hmac.digest().getBytes(); + + return mac1Bytes === mac2Bytes; + } + + private async macsEqualWC(macKeyBuf: ArrayBuffer, mac1Buf: ArrayBuffer, mac2Buf: ArrayBuffer): Promise { + const macKey = await Subtle.importKey('raw', macKeyBuf, SigningAlgorithm, false, ['sign']); + const mac1 = await Subtle.sign(SigningAlgorithm, macKey, mac1Buf); + const mac2 = await Subtle.sign(SigningAlgorithm, macKey, mac2Buf); + + if (mac1.byteLength !== mac2.byteLength) { + return false; + } + + const arr1 = new Uint8Array(mac1); + const arr2 = new Uint8Array(mac2); + + for (let i = 0; i < arr2.length; i++) { + if (arr1[i] !== arr2[i]) { + return false; + } + } + + return true; + } + + private async getKeyForEncryption(key?: SymmetricCryptoKey): Promise { + if (key) { + return key; + } + + const encKey = await this.getEncKey(); + return encKey || (await this.getKey()); + } + + private resolveLegacyKey(encType: EncryptionType, key: SymmetricCryptoKey): SymmetricCryptoKey { + if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && key.encType === EncryptionType.AesCbc256_B64) { + // Old encrypt-then-mac scheme, make a new key + this.legacyEtmKey = this.legacyEtmKey || + new SymmetricCryptoKey(key.key, false, EncryptionType.AesCbc128_HmacSha256_B64); + return this.legacyEtmKey; + } + + return key; + } +} diff --git a/src/services/environment.service.ts b/src/services/environment.service.ts new file mode 100644 index 0000000000..098a67f749 --- /dev/null +++ b/src/services/environment.service.ts @@ -0,0 +1,87 @@ +import ApiService from './api.service'; +import ConstantsService from './constants.service'; +import UtilsService from './utils.service'; + +import EnvironmentUrls from '../models/domain/environmentUrls'; + +export default class EnvironmentService { + baseUrl: string; + webVaultUrl: string; + apiUrl: string; + identityUrl: string; + iconsUrl: string; + + constructor(private apiService: ApiService) { + } + + async setUrlsFromStorage(): Promise { + const urlsObj: any = await UtilsService.getObjFromStorage(ConstantsService.environmentUrlsKey); + const urls = urlsObj || { + base: null, + api: null, + identity: null, + icons: null, + webVault: null, + }; + + const envUrls = new EnvironmentUrls(); + + if (urls.base) { + this.baseUrl = envUrls.base = urls.base; + await this.apiService.setUrls(envUrls); + return; + } + + this.webVaultUrl = urls.webVault; + this.apiUrl = envUrls.api = urls.api; + this.identityUrl = envUrls.identity = urls.identity; + this.iconsUrl = urls.icons; + await this.apiService.setUrls(envUrls); + } + + async setUrls(urls: any): Promise { + urls.base = this.formatUrl(urls.base); + urls.webVault = this.formatUrl(urls.webVault); + urls.api = this.formatUrl(urls.api); + urls.identity = this.formatUrl(urls.identity); + urls.icons = this.formatUrl(urls.icons); + + await UtilsService.saveObjToStorage(ConstantsService.environmentUrlsKey, { + base: urls.base, + api: urls.api, + identity: urls.identity, + webVault: urls.webVault, + icons: urls.icons, + }); + + this.baseUrl = urls.base; + this.webVaultUrl = urls.webVault; + this.apiUrl = urls.api; + this.identityUrl = urls.identity; + this.iconsUrl = urls.icons; + + const envUrls = new EnvironmentUrls(); + if (this.baseUrl) { + envUrls.base = this.baseUrl; + } else { + envUrls.api = this.apiUrl; + envUrls.identity = this.identityUrl; + } + + await this.apiService.setUrls(envUrls); + return urls; + } + + private formatUrl(url: string): string { + if (url == null || url === '') { + return null; + } + + url = url.replace(/\/+$/g, ''); + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + + return url; + } +} diff --git a/src/services/folder.service.ts b/src/services/folder.service.ts new file mode 100644 index 0000000000..3337c1b0bc --- /dev/null +++ b/src/services/folder.service.ts @@ -0,0 +1,161 @@ +import { CipherString } from '../models/domain/cipherString'; +import { Folder } from '../models/domain/folder'; + +import { FolderData } from '../models/data/folderData'; + +import { FolderRequest } from '../models/request/folderRequest'; +import { FolderResponse } from '../models/response/folderResponse'; + +import ApiService from './api.service'; +import CryptoService from './crypto.service'; +import UserService from './user.service'; +import UtilsService from './utils.service'; + +const Keys = { + foldersPrefix: 'folders_', +}; + +export default class FolderService { + decryptedFolderCache: any[]; + + constructor(private cryptoService: CryptoService, private userService: UserService, + private i18nService: any, private apiService: ApiService) { + } + + clearCache(): void { + this.decryptedFolderCache = null; + } + + async encrypt(model: any): Promise { + const folder = new Folder(); + folder.id = model.id; + folder.name = await this.cryptoService.encrypt(model.name); + return folder; + } + + async get(id: string): Promise { + const userId = await this.userService.getUserId(); + const folders = await UtilsService.getObjFromStorage<{ [id: string]: FolderData; }>( + Keys.foldersPrefix + userId); + if (folders == null || !folders.hasOwnProperty(id)) { + return null; + } + + return new Folder(folders[id]); + } + + async getAll(): Promise { + const userId = await this.userService.getUserId(); + const folders = await UtilsService.getObjFromStorage<{ [id: string]: FolderData; }>( + Keys.foldersPrefix + userId); + const response: Folder[] = []; + for (const id in folders) { + if (folders.hasOwnProperty(id)) { + response.push(new Folder(folders[id])); + } + } + return response; + } + + async getAllDecrypted(): Promise { + if (this.decryptedFolderCache != null) { + return this.decryptedFolderCache; + } + + const decFolders: any[] = [{ + id: null, + name: this.i18nService.noneFolder, + }]; + + const key = await this.cryptoService.getKey(); + if (key == null) { + throw new Error('No key.'); + } + + const promises: Array> = []; + const folders = await this.getAll(); + folders.forEach((folder) => { + promises.push(folder.decrypt().then((f: any) => { + decFolders.push(f); + })); + }); + + await Promise.all(promises); + this.decryptedFolderCache = decFolders; + return this.decryptedFolderCache; + } + + async saveWithServer(folder: Folder): Promise { + const request = new FolderRequest(folder); + + let response: FolderResponse; + if (folder.id == null) { + response = await this.apiService.postFolder(request); + folder.id = response.id; + } else { + response = await this.apiService.putFolder(folder.id, request); + } + + const userId = await this.userService.getUserId(); + const data = new FolderData(response, userId); + await this.upsert(data); + } + + async upsert(folder: FolderData | FolderData[]): Promise { + const userId = await this.userService.getUserId(); + let folders = await UtilsService.getObjFromStorage<{ [id: string]: FolderData; }>( + Keys.foldersPrefix + userId); + if (folders == null) { + folders = {}; + } + + if (folder instanceof FolderData) { + const f = folder as FolderData; + folders[f.id] = f; + } else { + (folder as FolderData[]).forEach((f) => { + folders[f.id] = f; + }); + } + + await UtilsService.saveObjToStorage(Keys.foldersPrefix + userId, folders); + this.decryptedFolderCache = null; + } + + async replace(folders: { [id: string]: FolderData; }): Promise { + const userId = await this.userService.getUserId(); + await UtilsService.saveObjToStorage(Keys.foldersPrefix + userId, folders); + this.decryptedFolderCache = null; + } + + async clear(userId: string): Promise { + await UtilsService.removeFromStorage(Keys.foldersPrefix + userId); + this.decryptedFolderCache = null; + } + + async delete(id: string | string[]): Promise { + const userId = await this.userService.getUserId(); + const folders = await UtilsService.getObjFromStorage<{ [id: string]: FolderData; }>( + Keys.foldersPrefix + userId); + if (folders == null) { + return; + } + + if (typeof id === 'string') { + const i = id as string; + delete folders[id]; + } else { + (id as string[]).forEach((i) => { + delete folders[i]; + }); + } + + await UtilsService.saveObjToStorage(Keys.foldersPrefix + userId, folders); + this.decryptedFolderCache = null; + } + + async deleteWithServer(id: string): Promise { + await this.apiService.deleteFolder(id); + await this.delete(id); + } +} diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts new file mode 100644 index 0000000000..a294015f3c --- /dev/null +++ b/src/services/i18n.service.ts @@ -0,0 +1,28 @@ +import UtilsService from '../services/utils.service'; + +export default function i18nService(utilsService: UtilsService) { + const edgeMessages: any = {}; + + if (utilsService.isEdge()) { + fetch('../_locales/en/messages.json').then((file) => { + return file.json(); + }).then((locales) => { + for (const prop in locales) { + if (locales.hasOwnProperty(prop)) { + edgeMessages[prop] = chrome.i18n.getMessage(prop); + } + } + }); + + return edgeMessages; + } + + return new Proxy({}, { + get: (target, name) => { + return chrome.i18n.getMessage(name); + }, + set: (target, name, value) => { + return false; + }, + }); +} diff --git a/src/services/lock.service.ts b/src/services/lock.service.ts new file mode 100644 index 0000000000..bc58aacbab --- /dev/null +++ b/src/services/lock.service.ts @@ -0,0 +1,89 @@ +import CipherService from './cipher.service'; +import CollectionService from './collection.service'; +import ConstantsService from './constants.service'; +import CryptoService from './crypto.service'; +import FolderService from './folder.service'; +import UtilsService from './utils.service'; + +export default class LockService { + constructor(private cipherService: CipherService, private folderService: FolderService, + private collectionService: CollectionService, private cryptoService: CryptoService, + private utilsService: UtilsService, private setIcon: Function, private refreshBadgeAndMenu: Function) { + this.checkLock(); + setInterval(() => this.checkLock(), 10 * 1000); // check every 10 seconds + + const self = this; + if ((window as any).chrome.idle && (window as any).chrome.idle.onStateChanged) { + (window as any).chrome.idle.onStateChanged.addListener(async (newState: string) => { + if (newState === 'locked') { + const lockOption = await UtilsService.getObjFromStorage(ConstantsService.lockOptionKey); + if (lockOption === -2) { + self.lock(); + } + } + }); + } + } + + async checkLock(): Promise { + const popupOpen = chrome.extension.getViews({ type: 'popup' }).length > 0; + const tabOpen = chrome.extension.getViews({ type: 'tab' }).length > 0; + const sidebarView = this.sidebarViewName(); + const sidebarOpen = sidebarView != null && chrome.extension.getViews({ type: sidebarView }).length > 0; + + if (popupOpen || tabOpen || sidebarOpen) { + // Do not lock + return; + } + + const key = await this.cryptoService.getKey(); + if (key == null) { + // no key so no need to lock + return; + } + + const lockOption = await UtilsService.getObjFromStorage(ConstantsService.lockOptionKey); + if (lockOption == null || lockOption < 0) { + return; + } + + const lastActive = await UtilsService.getObjFromStorage(ConstantsService.lastActiveKey); + if (lastActive == null) { + return; + } + + const lockOptionSeconds = lockOption * 60; + const diffSeconds = ((new Date()).getTime() - lastActive) / 1000; + if (diffSeconds >= lockOptionSeconds) { + // need to lock now + await this.lock(); + } + } + + async lock(): Promise { + await Promise.all([ + this.cryptoService.clearKey(), + this.cryptoService.clearOrgKeys(true), + this.cryptoService.clearPrivateKey(true), + this.cryptoService.clearEncKey(true), + this.setIcon(), + this.refreshBadgeAndMenu(), + ]); + + this.folderService.clearCache(); + this.cipherService.clearCache(); + this.collectionService.clearCache(); + } + + // Helpers + + private sidebarViewName(): string { + if ((window as any).chrome.sidebarAction && this.utilsService.isFirefox()) { + return 'sidebar'; + } else if (this.utilsService.isOpera() && (typeof opr !== 'undefined') && opr.sidebarAction) { + return 'sidebar_panel'; + } + + return null; + } +} diff --git a/src/services/passwordGeneration.service.ts b/src/services/passwordGeneration.service.ts new file mode 100644 index 0000000000..4e52438d46 --- /dev/null +++ b/src/services/passwordGeneration.service.ts @@ -0,0 +1,237 @@ +import { CipherString } from '../models/domain/cipherString'; +import PasswordHistory from '../models/domain/passwordHistory'; + +import CryptoService from './crypto.service'; +import UtilsService from './utils.service'; + +const DefaultOptions = { + length: 14, + ambiguous: false, + number: true, + minNumber: 1, + uppercase: true, + minUppercase: 1, + lowercase: true, + minLowercase: 1, + special: false, + minSpecial: 1, +}; + +const Keys = { + options: 'passwordGenerationOptions', + history: 'generatedPasswordHistory', +}; + +const MaxPasswordsInHistory = 100; + +export default class PasswordGenerationService { + static generatePassword(options: any): string { + // overload defaults with given options + const o = Object.assign({}, DefaultOptions, options); + + // sanitize + if (o.uppercase && o.minUppercase < 0) { + o.minUppercase = 1; + } + if (o.lowercase && o.minLowercase < 0) { + o.minLowercase = 1; + } + if (o.number && o.minNumber < 0) { + o.minNumber = 1; + } + if (o.special && o.minSpecial < 0) { + o.minSpecial = 1; + } + + if (!o.length || o.length < 1) { + o.length = 10; + } + + const minLength: number = o.minUppercase + o.minLowercase + o.minNumber + o.minSpecial; + if (o.length < minLength) { + o.length = minLength; + } + + const positions: string[] = []; + if (o.lowercase && o.minLowercase > 0) { + for (let i = 0; i < o.minLowercase; i++) { + positions.push('l'); + } + } + if (o.uppercase && o.minUppercase > 0) { + for (let i = 0; i < o.minUppercase; i++) { + positions.push('u'); + } + } + if (o.number && o.minNumber > 0) { + for (let i = 0; i < o.minNumber; i++) { + positions.push('n'); + } + } + if (o.special && o.minSpecial > 0) { + for (let i = 0; i < o.minSpecial; i++) { + positions.push('s'); + } + } + while (positions.length < o.length) { + positions.push('a'); + } + + // shuffle + positions.sort(() => { + return UtilsService.secureRandomNumber(0, 1) * 2 - 1; + }); + + // build out the char sets + let allCharSet = ''; + + let lowercaseCharSet = 'abcdefghijkmnopqrstuvwxyz'; + if (o.ambiguous) { + lowercaseCharSet += 'l'; + } + if (o.lowercase) { + allCharSet += lowercaseCharSet; + } + + let uppercaseCharSet = 'ABCDEFGHIJKLMNPQRSTUVWXYZ'; + if (o.ambiguous) { + uppercaseCharSet += 'O'; + } + if (o.uppercase) { + allCharSet += uppercaseCharSet; + } + + let numberCharSet = '23456789'; + if (o.ambiguous) { + numberCharSet += '01'; + } + if (o.number) { + allCharSet += numberCharSet; + } + + const specialCharSet = '!@#$%^&*'; + if (o.special) { + allCharSet += specialCharSet; + } + + let password = ''; + for (let i = 0; i < o.length; i++) { + let positionChars: string; + switch (positions[i]) { + case 'l': + positionChars = lowercaseCharSet; + break; + case 'u': + positionChars = uppercaseCharSet; + break; + case 'n': + positionChars = numberCharSet; + break; + case 's': + positionChars = specialCharSet; + break; + case 'a': + positionChars = allCharSet; + break; + } + + const randomCharIndex = UtilsService.secureRandomNumber(0, positionChars.length - 1); + password += positionChars.charAt(randomCharIndex); + } + + return password; + } + + optionsCache: any; + history: PasswordHistory[] = []; + + constructor(private cryptoService: CryptoService) { + UtilsService.getObjFromStorage(Keys.history).then((encrypted) => { + return this.decryptHistory(encrypted); + }).then((history) => { + this.history = history; + }); + } + + generatePassword(options: any) { + return PasswordGenerationService.generatePassword(options); + } + + async getOptions() { + if (this.optionsCache == null) { + const options = await UtilsService.getObjFromStorage(Keys.options); + if (options == null) { + this.optionsCache = DefaultOptions; + } else { + this.optionsCache = options; + } + } + + return this.optionsCache; + } + + async saveOptions(options: any) { + await UtilsService.saveObjToStorage(Keys.options, options); + this.optionsCache = options; + } + + getHistory() { + return this.history || new Array(); + } + + async addHistory(password: string): Promise { + // Prevent duplicates + if (this.matchesPrevious(password)) { + return; + } + + this.history.push(new PasswordHistory(password, Date.now())); + + // Remove old items. + if (this.history.length > MaxPasswordsInHistory) { + this.history.shift(); + } + + const newHistory = await this.encryptHistory(); + return await UtilsService.saveObjToStorage(Keys.history, newHistory); + } + + async clear(): Promise { + this.history = []; + return await UtilsService.removeFromStorage(Keys.history); + } + + private async encryptHistory(): Promise { + if (this.history == null || this.history.length === 0) { + return Promise.resolve([]); + } + + const promises = this.history.map(async (item) => { + const encrypted = await this.cryptoService.encrypt(item.password); + return new PasswordHistory(encrypted.encryptedString, item.date); + }); + + return await Promise.all(promises); + } + + private async decryptHistory(history: PasswordHistory[]): Promise { + if (history == null || history.length === 0) { + return Promise.resolve([]); + } + + const promises = history.map(async (item) => { + const decrypted = await this.cryptoService.decrypt(new CipherString(item.password)); + return new PasswordHistory(decrypted, item.date); + }); + + return await Promise.all(promises); + } + + private matchesPrevious(password: string): boolean { + if (this.history == null || this.history.length === 0) { + return false; + } + + return this.history[this.history.length - 1].password === password; + } +} diff --git a/src/services/settings.service.ts b/src/services/settings.service.ts new file mode 100644 index 0000000000..25d598f83c --- /dev/null +++ b/src/services/settings.service.ts @@ -0,0 +1,61 @@ +import UserService from './user.service'; +import UtilsService from './utils.service'; + +const Keys = { + settingsPrefix: 'settings_', + equivalentDomains: 'equivalentDomains', +}; + +export default class SettingsService { + private settingsCache: any; + + constructor(private userService: UserService) { + } + + clearCache(): void { + this.settingsCache = null; + } + + getEquivalentDomains(): Promise { + return this.getSettingsKey(Keys.equivalentDomains); + } + + async setEquivalentDomains(equivalentDomains: string[][]) { + await this.setSettingsKey(Keys.equivalentDomains, equivalentDomains); + } + + async clear(userId: string): Promise { + await UtilsService.removeFromStorage(Keys.settingsPrefix + userId); + this.settingsCache = null; + } + + // Helpers + + private async getSettings(): Promise { + if (this.settingsCache == null) { + const userId = await this.userService.getUserId(); + this.settingsCache = UtilsService.getObjFromStorage(Keys.settingsPrefix + userId); + } + return this.settingsCache; + } + + private async getSettingsKey(key: string): Promise { + const settings = await this.getSettings(); + if (settings != null && settings[key]) { + return settings[key]; + } + return null; + } + + private async setSettingsKey(key: string, value: any): Promise { + const userId = await this.userService.getUserId(); + let settings = await this.getSettings(); + if (!settings) { + settings = {}; + } + + settings[key] = value; + await UtilsService.saveObjToStorage(Keys.settingsPrefix + userId, settings); + this.settingsCache = settings; + } +} diff --git a/src/services/sync.service.ts b/src/services/sync.service.ts new file mode 100644 index 0000000000..a28c401f19 --- /dev/null +++ b/src/services/sync.service.ts @@ -0,0 +1,180 @@ +import { CipherData } from '../models/data/cipherData'; +import { CollectionData } from '../models/data/collectionData'; +import { FolderData } from '../models/data/folderData'; + +import { CipherResponse } from '../models/response/cipherResponse'; +import { CollectionResponse } from '../models/response/collectionResponse'; +import { DomainsResponse } from '../models/response/domainsResponse'; +import { FolderResponse } from '../models/response/folderResponse'; +import { ProfileResponse } from '../models/response/profileResponse'; +import { SyncResponse } from '../models/response/syncResponse'; + +import ApiService from './api.service'; +import CipherService from './cipher.service'; +import CollectionService from './collection.service'; +import CryptoService from './crypto.service'; +import FolderService from './folder.service'; +import SettingsService from './settings.service'; +import UserService from './user.service'; +import UtilsService from './utils.service'; + +const Keys = { + lastSyncPrefix: 'lastSync_', +}; + +export default class SyncService { + syncInProgress: boolean = false; + + constructor(private userService: UserService, private apiService: ApiService, + private settingsService: SettingsService, private folderService: FolderService, + private cipherService: CipherService, private cryptoService: CryptoService, + private collectionService: CollectionService, private logoutCallback: Function) { + } + + async getLastSync() { + const userId = await this.userService.getUserId(); + const lastSync = await UtilsService.getObjFromStorage(Keys.lastSyncPrefix + userId); + if (lastSync) { + return new Date(lastSync); + } + + return null; + } + + async setLastSync(date: Date) { + const userId = await this.userService.getUserId(); + await UtilsService.saveObjToStorage(Keys.lastSyncPrefix + userId, date.toJSON()); + } + + syncStarted() { + this.syncInProgress = true; + chrome.runtime.sendMessage({ command: 'syncStarted' }); + } + + syncCompleted(successfully: boolean) { + this.syncInProgress = false; + // tslint:disable-next-line + chrome.runtime.sendMessage({ command: 'syncCompleted', successfully: successfully }); + } + + async fullSync(forceSync: boolean) { + this.syncStarted(); + const isAuthenticated = await this.userService.isAuthenticated(); + if (!isAuthenticated) { + this.syncCompleted(false); + return false; + } + + const now = new Date(); + const needsSyncResult = await this.needsSyncing(forceSync); + const needsSync = needsSyncResult[0]; + const skipped = needsSyncResult[1]; + + if (skipped) { + this.syncCompleted(false); + return false; + } + + if (!needsSync) { + await this.setLastSync(now); + this.syncCompleted(false); + return false; + } + + const userId = await this.userService.getUserId(); + try { + const response = await this.apiService.getSync(); + + await this.syncProfile(response.profile); + await this.syncFolders(userId, response.folders); + await this.syncCollections(response.collections); + await this.syncCiphers(userId, response.ciphers); + await this.syncSettings(userId, response.domains); + + await this.setLastSync(now); + this.syncCompleted(true); + return true; + } catch (e) { + this.syncCompleted(false); + return false; + } + } + + // Helpers + + private async needsSyncing(forceSync: boolean) { + if (forceSync) { + return [true, false]; + } + + try { + const response = await this.apiService.getAccountRevisionDate(); + const accountRevisionDate = new Date(response); + const lastSync = await this.getLastSync(); + if (lastSync != null && accountRevisionDate <= lastSync) { + return [false, false]; + } + + return [true, false]; + } catch (e) { + return [false, true]; + } + } + + private async syncProfile(response: ProfileResponse) { + const stamp = await this.userService.getSecurityStamp(); + if (stamp != null && stamp !== response.securityStamp) { + if (this.logoutCallback != null) { + this.logoutCallback(true); + } + + throw new Error('Stamp has changed'); + } + + await this.cryptoService.setEncKey(response.key); + await this.cryptoService.setEncPrivateKey(response.privateKey); + await this.cryptoService.setOrgKeys(response.organizations); + await this.userService.setSecurityStamp(response.securityStamp); + } + + private async syncFolders(userId: string, response: FolderResponse[]) { + const folders: { [id: string]: FolderData; } = {}; + response.forEach((f) => { + folders[f.id] = new FolderData(f, userId); + }); + return await this.folderService.replace(folders); + } + + private async syncCollections(response: CollectionResponse[]) { + const collections: { [id: string]: CollectionData; } = {}; + response.forEach((c) => { + collections[c.id] = new CollectionData(c); + }); + return await this.collectionService.replace(collections); + } + + private async syncCiphers(userId: string, response: CipherResponse[]) { + const ciphers: { [id: string]: CipherData; } = {}; + response.forEach((c) => { + ciphers[c.id] = new CipherData(c, userId); + }); + return await this.cipherService.replace(ciphers); + } + + private async syncSettings(userId: string, response: DomainsResponse) { + let eqDomains: string[][] = []; + if (response != null && response.equivalentDomains != null) { + eqDomains = eqDomains.concat(response.equivalentDomains); + } + + if (response != null && response.globalEquivalentDomains != null) { + response.globalEquivalentDomains.forEach((global) => { + if (global.domains.length > 0) { + eqDomains.push(global.domains); + } + }); + } + + return this.settingsService.setEquivalentDomains(eqDomains); + } +} diff --git a/src/services/token.service.ts b/src/services/token.service.ts new file mode 100644 index 0000000000..3ebaa239be --- /dev/null +++ b/src/services/token.service.ts @@ -0,0 +1,170 @@ +import ConstantsService from './constants.service'; +import UtilsService from './utils.service'; + +const Keys = { + accessToken: 'accessToken', + refreshToken: 'refreshToken', + twoFactorTokenPrefix: 'twoFactorToken_', +}; + +export default class TokenService { + token: string; + decodedToken: any; + refreshToken: string; + + setTokens(accessToken: string, refreshToken: string): Promise { + return Promise.all([ + this.setToken(accessToken), + this.setRefreshToken(refreshToken), + ]); + } + + setToken(token: string): Promise { + this.token = token; + this.decodedToken = null; + return UtilsService.saveObjToStorage(Keys.accessToken, token); + } + + async getToken(): Promise { + if (this.token != null) { + return this.token; + } + + this.token = await UtilsService.getObjFromStorage(Keys.accessToken); + return this.token; + } + + setRefreshToken(refreshToken: string): Promise { + this.refreshToken = refreshToken; + return UtilsService.saveObjToStorage(Keys.refreshToken, refreshToken); + } + + async getRefreshToken(): Promise { + if (this.refreshToken != null) { + return this.refreshToken; + } + + this.refreshToken = await UtilsService.getObjFromStorage(Keys.refreshToken); + return this.refreshToken; + } + + setTwoFactorToken(token: string, email: string): Promise { + return UtilsService.saveObjToStorage(Keys.twoFactorTokenPrefix + email, token); + } + + getTwoFactorToken(email: string): Promise { + return UtilsService.getObjFromStorage(Keys.twoFactorTokenPrefix + email); + } + + clearTwoFactorToken(email: string): Promise { + return UtilsService.removeFromStorage(Keys.twoFactorTokenPrefix + email); + } + + clearToken(): Promise { + this.token = null; + this.decodedToken = null; + this.refreshToken = null; + + return Promise.all([ + UtilsService.removeFromStorage(Keys.accessToken), + UtilsService.removeFromStorage(Keys.refreshToken), + ]); + } + + // jwthelper methods + // ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js + + decodeToken(): any { + if (this.decodedToken) { + return this.decodedToken; + } + + if (this.token == null) { + throw new Error('Token not found.'); + } + + const parts = this.token.split('.'); + if (parts.length !== 3) { + throw new Error('JWT must have 3 parts'); + } + + const decoded = UtilsService.urlBase64Decode(parts[1]); + if (decoded == null) { + throw new Error('Cannot decode the token'); + } + + this.decodedToken = JSON.parse(decoded); + return this.decodedToken; + } + + getTokenExpirationDate(): Date { + const decoded = this.decodeToken(); + if (typeof decoded.exp === 'undefined') { + return null; + } + + const d = new Date(0); // The 0 here is the key, which sets the date to the epoch + d.setUTCSeconds(decoded.exp); + return d; + } + + tokenSecondsRemaining(offsetSeconds: number = 0): number { + const d = this.getTokenExpirationDate(); + if (d == null) { + return 0; + } + + const msRemaining = d.valueOf() - (new Date().valueOf() + (offsetSeconds * 1000)); + return Math.round(msRemaining / 1000); + } + + tokenNeedsRefresh(minutes: number = 5): boolean { + const sRemaining = this.tokenSecondsRemaining(); + return sRemaining < (60 * minutes); + } + + getUserId(): string { + const decoded = this.decodeToken(); + if (typeof decoded.sub === 'undefined') { + throw new Error('No user id found'); + } + + return decoded.sub as string; + } + + getEmail(): string { + const decoded = this.decodeToken(); + if (typeof decoded.email === 'undefined') { + throw new Error('No email found'); + } + + return decoded.email as string; + } + + getName(): string { + const decoded = this.decodeToken(); + if (typeof decoded.name === 'undefined') { + throw new Error('No name found'); + } + + return decoded.name as string; + } + + getPremium(): boolean { + const decoded = this.decodeToken(); + if (typeof decoded.premium === 'undefined') { + return false; + } + + return decoded.premium as boolean; + } + + getIssuer(): string { + const decoded = this.decodeToken(); + if (typeof decoded.iss === 'undefined') { + throw new Error('No issuer found'); + } + + return decoded.iss as string; + } +} diff --git a/src/services/totp.service.ts b/src/services/totp.service.ts new file mode 100644 index 0000000000..ae4d4ec57a --- /dev/null +++ b/src/services/totp.service.ts @@ -0,0 +1,113 @@ +import ConstantsService from './constants.service'; +import UtilsService from './utils.service'; + +const b32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +const TotpAlgorithm = { + name: 'HMAC', + hash: { name: 'SHA-1' }, +}; + +export default class TotpService { + async getCode(keyb32: string): Promise { + const epoch = Math.round(new Date().getTime() / 1000.0); + const timeHex = this.leftpad(this.dec2hex(Math.floor(epoch / 30)), 16, '0'); + const timeBytes = this.hex2bytes(timeHex); + const keyBytes = this.b32tobytes(keyb32); + + if (!keyBytes.length || !timeBytes.length) { + return null; + } + + const hashHex = await this.sign(keyBytes, timeBytes); + if (!hashHex) { + return null; + } + + const offset = this.hex2dec(hashHex.substring(hashHex.length - 1)); + // tslint:disable-next-line + let otp = (this.hex2dec(hashHex.substr(offset * 2, 8)) & this.hex2dec('7fffffff')) + ''; + otp = (otp).substr(otp.length - 6, 6); + return otp; + } + + async isAutoCopyEnabled(): Promise { + return !(await UtilsService.getObjFromStorage(ConstantsService.disableAutoTotpCopyKey)); + } + + // Helpers + + private leftpad(s: string, l: number, p: string): string { + if (l + 1 >= s.length) { + s = Array(l + 1 - s.length).join(p) + s; + } + return s; + } + + private dec2hex(d: number): string { + return (d < 15.5 ? '0' : '') + Math.round(d).toString(16); + } + + private hex2dec(s: string): number { + return parseInt(s, 16); + } + + private hex2bytes(s: string): Uint8Array { + const bytes = new Uint8Array(s.length / 2); + for (let i = 0; i < s.length; i += 2) { + bytes[i / 2] = parseInt(s.substr(i, 2), 16); + } + return bytes; + } + + private buff2hex(buff: ArrayBuffer): string { + const bytes = new Uint8Array(buff); + const hex: string[] = []; + bytes.forEach((b) => { + // tslint:disable-next-line + hex.push((b >>> 4).toString(16)); + // tslint:disable-next-line + hex.push((b & 0xF).toString(16)); + }); + return hex.join(''); + } + + private b32tohex(s: string): string { + s = s.toUpperCase(); + let cleanedInput = ''; + + for (let i = 0; i < s.length; i++) { + if (b32Chars.indexOf(s[i]) < 0) { + continue; + } + + cleanedInput += s[i]; + } + s = cleanedInput; + + let bits = ''; + let hex = ''; + for (let i = 0; i < s.length; i++) { + const byteIndex = b32Chars.indexOf(s.charAt(i)); + if (byteIndex < 0) { + continue; + } + bits += this.leftpad(byteIndex.toString(2), 5, '0'); + } + for (let i = 0; i + 4 <= bits.length; i += 4) { + const chunk = bits.substr(i, 4); + hex = hex + parseInt(chunk, 2).toString(16); + } + return hex; + } + + private b32tobytes(s: string): Uint8Array { + return this.hex2bytes(this.b32tohex(s)); + } + + private async sign(keyBytes: Uint8Array, timeBytes: Uint8Array) { + const key = await window.crypto.subtle.importKey('raw', keyBytes, TotpAlgorithm, false, ['sign']); + const signature = await window.crypto.subtle.sign(TotpAlgorithm, key, timeBytes); + return this.buff2hex(signature); + } +} diff --git a/src/services/user.service.ts b/src/services/user.service.ts new file mode 100644 index 0000000000..caa56c0d6f --- /dev/null +++ b/src/services/user.service.ts @@ -0,0 +1,79 @@ +import TokenService from './token.service'; +import UtilsService from './utils.service'; + +const Keys = { + userId: 'userId', + userEmail: 'userEmail', + stamp: 'securityStamp', +}; + +export default class UserService { + userId: string; + email: string; + stamp: string; + + constructor(private tokenService: TokenService) { + } + + setUserIdAndEmail(userId: string, email: string): Promise { + this.email = email; + this.userId = userId; + + return Promise.all([ + UtilsService.saveObjToStorage(Keys.userEmail, email), + UtilsService.saveObjToStorage(Keys.userId, userId), + ]); + } + + setSecurityStamp(stamp: string): Promise { + this.stamp = stamp; + return UtilsService.saveObjToStorage(Keys.stamp, stamp); + } + + async getUserId(): Promise { + if (this.userId != null) { + return this.userId; + } + + this.userId = await UtilsService.getObjFromStorage(Keys.userId); + return this.userId; + } + + async getEmail(): Promise { + if (this.email != null) { + return this.email; + } + + this.email = await UtilsService.getObjFromStorage(Keys.userEmail); + return this.email; + } + + async getSecurityStamp(): Promise { + if (this.stamp != null) { + return this.stamp; + } + + this.stamp = await UtilsService.getObjFromStorage(Keys.stamp); + return this.stamp; + } + + async clear(): Promise { + await Promise.all([ + UtilsService.removeFromStorage(Keys.userId), + UtilsService.removeFromStorage(Keys.userEmail), + UtilsService.removeFromStorage(Keys.stamp), + ]); + + this.userId = this.email = this.stamp = null; + } + + async isAuthenticated(): Promise { + const token = await this.tokenService.getToken(); + if (token == null) { + return false; + } + + const userId = await this.getUserId(); + return userId != null; + } +} diff --git a/src/services/utils.service.spec.ts b/src/services/utils.service.spec.ts new file mode 100644 index 0000000000..c976888de8 --- /dev/null +++ b/src/services/utils.service.spec.ts @@ -0,0 +1,113 @@ +import UtilsService from './utils.service'; +import { BrowserType } from '../enums/browserType.enum'; + +describe('Utils Service', () => { + describe('getDomain', () => { + it('should fail for invalid urls', () => { + expect(UtilsService.getDomain(null)).toBeNull(); + expect(UtilsService.getDomain(undefined)).toBeNull(); + expect(UtilsService.getDomain(' ')).toBeNull(); + expect(UtilsService.getDomain('https://bit!:"_&ward.com')).toBeNull(); + expect(UtilsService.getDomain('bitwarden')).toBeNull(); + }); + + it('should handle urls without protocol', () => { + expect(UtilsService.getDomain('bitwarden.com')).toBe('bitwarden.com'); + expect(UtilsService.getDomain('wrong://bitwarden.com')).toBe('bitwarden.com'); + }); + + it('should handle valid urls', () => { + expect(UtilsService.getDomain('https://bitwarden')).toBe('bitwarden'); + expect(UtilsService.getDomain('https://bitwarden.com')).toBe('bitwarden.com'); + expect(UtilsService.getDomain('http://bitwarden.com')).toBe('bitwarden.com'); + expect(UtilsService.getDomain('http://vault.bitwarden.com')).toBe('bitwarden.com'); + expect(UtilsService.getDomain('https://user:password@bitwarden.com:8080/password/sites?and&query#hash')).toBe('bitwarden.com'); + expect(UtilsService.getDomain('https://bitwarden.unknown')).toBe('bitwarden.unknown'); + }); + + it('should support localhost and IP', () => { + expect(UtilsService.getDomain('https://localhost')).toBe('localhost'); + expect(UtilsService.getDomain('https://192.168.1.1')).toBe('192.168.1.1'); + }); + }); + + describe('getHostname', () => { + it('should fail for invalid urls', () => { + expect(UtilsService.getHostname(null)).toBeNull(); + expect(UtilsService.getHostname(undefined)).toBeNull(); + expect(UtilsService.getHostname(' ')).toBeNull(); + expect(UtilsService.getHostname('https://bit!:"_&ward.com')).toBeNull(); + expect(UtilsService.getHostname('bitwarden')).toBeNull(); + }); + + it('should handle valid urls', () => { + expect(UtilsService.getHostname('https://bitwarden.com')).toBe('bitwarden.com'); + expect(UtilsService.getHostname('http://bitwarden.com')).toBe('bitwarden.com'); + expect(UtilsService.getHostname('http://vault.bitwarden.com')).toBe('vault.bitwarden.com'); + expect(UtilsService.getHostname('https://user:password@bitwarden.com:8080/password/sites?and&query#hash')).toBe('bitwarden.com'); + }); + + it('should support localhost and IP', () => { + expect(UtilsService.getHostname('https://localhost')).toBe('localhost'); + expect(UtilsService.getHostname('https://192.168.1.1')).toBe('192.168.1.1'); + }); + }); + + describe('newGuid', () => { + it('should create a valid guid', () => { + const validGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(UtilsService.newGuid()).toMatch(validGuid); + }); + }); + + describe('getBrowser', () => { + const original = navigator.userAgent; + + // Reset the userAgent. + afterAll(() => { + Object.defineProperty(navigator, 'userAgent', { + value: original + }); + }); + + it('should detect chrome', () => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36' + }); + + const utilsService = new UtilsService(); + expect(utilsService.getBrowser()).toBe(BrowserType.Chrome); + }); + + it('should detect firefox', () => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0' + }); + + const utilsService = new UtilsService(); + expect(utilsService.getBrowser()).toBe(BrowserType.Firefox); + }); + + it('should detect opera', () => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3175.3 Safari/537.36 OPR/49.0.2695.0 (Edition developer)' + }); + + const utilsService = new UtilsService(); + expect(utilsService.getBrowser()).toBe(BrowserType.Opera); + }); + + it('should detect edge', () => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; ServiceUI 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 Edge/15.15063' + }); + + const utilsService = new UtilsService(); + expect(utilsService.getBrowser()).toBe(BrowserType.Edge); + }); + }); +}); diff --git a/src/services/utils.service.ts b/src/services/utils.service.ts new file mode 100644 index 0000000000..b2230de2b9 --- /dev/null +++ b/src/services/utils.service.ts @@ -0,0 +1,407 @@ +import * as tldjs from 'tldjs'; +import { BrowserType } from '../enums/browserType.enum'; +import { UtilsService as UtilsServiceInterface } from './abstractions/utils.service'; + +const AnalyticsIds = { + [BrowserType.Chrome]: 'UA-81915606-6', + [BrowserType.Firefox]: 'UA-81915606-7', + [BrowserType.Opera]: 'UA-81915606-8', + [BrowserType.Edge]: 'UA-81915606-9', + [BrowserType.Vivaldi]: 'UA-81915606-15', + [BrowserType.Safari]: 'UA-81915606-16', +}; + +export default class UtilsService implements UtilsServiceInterface { + static copyToClipboard(text: string, doc?: Document): void { + doc = doc || document; + if ((window as any).clipboardData && (window as any).clipboardData.setData) { + // IE specific code path to prevent textarea being shown while dialog is visible. + (window as any).clipboardData.setData('Text', text); + } else if (doc.queryCommandSupported && doc.queryCommandSupported('copy')) { + const textarea = doc.createElement('textarea'); + textarea.textContent = text; + // Prevent scrolling to bottom of page in MS Edge. + textarea.style.position = 'fixed'; + doc.body.appendChild(textarea); + textarea.select(); + + try { + // Security exception may be thrown by some browsers. + doc.execCommand('copy'); + } catch (e) { + // tslint:disable-next-line + console.warn('Copy to clipboard failed.', e); + } finally { + doc.body.removeChild(textarea); + } + } + } + + static urlBase64Decode(str: string): string { + let output = str.replace(/-/g, '+').replace(/_/g, '/'); + switch (output.length % 4) { + case 0: + break; + case 2: + output += '=='; + break; + case 3: + output += '='; + break; + default: + throw new Error('Illegal base64url string!'); + } + + return decodeURIComponent(escape(window.atob(output))); + } + + // ref: http://stackoverflow.com/a/2117523/1090359 + static newGuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + // tslint:disable-next-line + const r = Math.random() * 16 | 0; + // tslint:disable-next-line + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + // EFForg/OpenWireless + // ref https://github.com/EFForg/OpenWireless/blob/master/app/js/diceware.js + static secureRandomNumber(min: number, max: number): number { + let rval = 0; + const range = max - min + 1; + const bitsNeeded = Math.ceil(Math.log2(range)); + if (bitsNeeded > 53) { + throw new Error('We cannot generate numbers larger than 53 bits.'); + } + + const bytesNeeded = Math.ceil(bitsNeeded / 8); + const mask = Math.pow(2, bitsNeeded) - 1; + // 7776 -> (2^13 = 8192) -1 == 8191 or 0x00001111 11111111 + + // Create byte array and fill with N random numbers + const byteArray = new Uint8Array(bytesNeeded); + window.crypto.getRandomValues(byteArray); + + let p = (bytesNeeded - 1) * 8; + for (let i = 0; i < bytesNeeded; i++) { + rval += byteArray[i] * Math.pow(2, p); + p -= 8; + } + + // Use & to apply the mask and reduce the number of recursive lookups + // tslint:disable-next-line + rval = rval & mask; + + if (rval >= range) { + // Integer out of acceptable range + return UtilsService.secureRandomNumber(min, max); + } + + // Return an integer that falls within the range + return min + rval; + } + + static fromB64ToArray(str: string): Uint8Array { + const binaryString = window.atob(str); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + + static fromUtf8ToArray(str: string): Uint8Array { + const strUtf8 = unescape(encodeURIComponent(str)); + const arr = new Uint8Array(strUtf8.length); + for (let i = 0; i < strUtf8.length; i++) { + arr[i] = strUtf8.charCodeAt(i); + } + return arr; + } + + static fromBufferToB64(buffer: ArrayBuffer): string { + let binary = ''; + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + static fromBufferToUtf8(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const encodedString = String.fromCharCode.apply(null, bytes); + return decodeURIComponent(escape(encodedString)); + } + + static saveObjToStorage(key: string, obj: any) { + return new Promise((resolve) => { + chrome.storage.local.set({ [key]: obj }, () => { + resolve(); + }); + }); + } + + static removeFromStorage(key: string) { + return new Promise((resolve) => { + chrome.storage.local.remove(key, () => { + resolve(); + }); + }); + } + + static getObjFromStorage(key: string): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(key, (obj: any) => { + if (obj && (typeof obj[key] !== 'undefined') && obj[key] !== null) { + resolve(obj[key] as T); + } else { + resolve(null); + } + }); + }); + } + + static getDomain(uriString: string): string { + if (uriString == null) { + return null; + } + + uriString = uriString.trim(); + if (uriString === '') { + return null; + } + + if (uriString.startsWith('http://') || uriString.startsWith('https://')) { + try { + const url = new URL(uriString); + + if (url.hostname === 'localhost' || UtilsService.validIpAddress(url.hostname)) { + return url.hostname; + } + + const urlDomain = tldjs.getDomain(url.hostname); + return urlDomain != null ? urlDomain : url.hostname; + } catch (e) { } + } + + const domain = tldjs.getDomain(uriString); + if (domain != null) { + return domain; + } + + return null; + } + + static getHostname(uriString: string): string { + if (uriString == null) { + return null; + } + + uriString = uriString.trim(); + if (uriString === '') { + return null; + } + + if (uriString.startsWith('http://') || uriString.startsWith('https://')) { + try { + const url = new URL(uriString); + return url.hostname; + } catch (e) { } + } + + return null; + } + + private static validIpAddress(ipString: string): boolean { + // tslint:disable-next-line + const ipRegex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + return ipRegex.test(ipString); + } + + private browserCache: BrowserType = null; + private analyticsIdCache: string = null; + + getBrowser(): BrowserType { + if (this.browserCache) { + return this.browserCache; + } + + if (navigator.userAgent.indexOf('Firefox') !== -1 || navigator.userAgent.indexOf('Gecko/') !== -1) { + this.browserCache = BrowserType.Firefox; + } else if ((!!(window as any).opr && !!opr.addons) || !!(window as any).opera || + navigator.userAgent.indexOf(' OPR/') >= 0) { + this.browserCache = BrowserType.Opera; + } else if (navigator.userAgent.indexOf(' Edge/') !== -1) { + this.browserCache = BrowserType.Edge; + } else if (navigator.userAgent.indexOf(' Vivaldi/') !== -1) { + this.browserCache = BrowserType.Vivaldi; + } else if ((window as any).chrome) { + this.browserCache = BrowserType.Chrome; + } + + return this.browserCache; + } + + getBrowserString(): string { + return BrowserType[this.getBrowser()].toLowerCase(); + } + + isFirefox(): boolean { + return this.getBrowser() === BrowserType.Firefox; + } + + isChrome(): boolean { + return this.getBrowser() === BrowserType.Chrome; + } + + isEdge(): boolean { + return this.getBrowser() === BrowserType.Edge; + } + + isOpera(): boolean { + return this.getBrowser() === BrowserType.Opera; + } + + isVivaldi(): boolean { + return this.getBrowser() === BrowserType.Vivaldi; + } + + isSafari(): boolean { + return this.getBrowser() === BrowserType.Safari; + } + + analyticsId(): string { + if (this.analyticsIdCache) { + return this.analyticsIdCache; + } + + this.analyticsIdCache = AnalyticsIds[this.getBrowser()]; + return this.analyticsIdCache; + } + + initListSectionItemListeners(doc: Document, angular: any): void { + if (!doc) { + throw new Error('doc parameter required'); + } + + const sectionItems = doc.querySelectorAll( + '.list-section-item:not([data-bw-events="1"])'); + const sectionFormItems = doc.querySelectorAll( + '.list-section-item:not([data-bw-events="1"]) input, ' + + '.list-section-item:not([data-bw-events="1"]) select, ' + + '.list-section-item:not([data-bw-events="1"]) textarea'); + + sectionItems.forEach((item) => { + (item as HTMLElement).dataset.bwEvents = '1'; + + item.addEventListener('click', (e) => { + if (e.defaultPrevented) { + return; + } + + const el = e.target as HTMLElement; + + // Some elements will already focus properly + if (el.tagName != null) { + switch (el.tagName.toLowerCase()) { + case 'label': case 'input': case 'textarea': case 'select': + return; + default: + break; + } + } + + const cell = el.closest('.list-section-item'); + if (!cell) { + return; + } + + const textFilter = 'input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"])'; + const text = cell.querySelectorAll(textFilter + ', textarea'); + const checkbox = cell.querySelectorAll('input[type="checkbox"]'); + const select = cell.querySelectorAll('select'); + + if (text.length > 0) { + (text[0] as HTMLElement).focus(); + } else if (select.length > 0) { + (select[0] as HTMLElement).focus(); + } else if (checkbox.length > 0) { + const cb = checkbox[0] as HTMLInputElement; + cb.checked = !cb.checked; + if (angular) { + angular.element(checkbox[0]).triggerHandler('click'); + } + } + }, false); + }); + + sectionFormItems.forEach((item) => { + const itemCell = item.closest('.list-section-item'); + (itemCell as HTMLElement).dataset.bwEvents = '1'; + + item.addEventListener('focus', (e: Event) => { + const el = e.target as HTMLElement; + const cell = el.closest('.list-section-item'); + if (!cell) { + return; + } + + cell.classList.add('active'); + }, false); + + item.addEventListener('blur', (e: Event) => { + const el = e.target as HTMLElement; + const cell = el.closest('.list-section-item'); + if (!cell) { + return; + } + + cell.classList.remove('active'); + }, false); + }); + } + + getDomain(uriString: string): string { + return UtilsService.getDomain(uriString); + } + + getHostname(uriString: string): string { + return UtilsService.getHostname(uriString); + } + + copyToClipboard(text: string, doc?: Document) { + UtilsService.copyToClipboard(text, doc); + } + + inSidebar(theWindow: Window): boolean { + return theWindow.location.search !== '' && theWindow.location.search.indexOf('uilocation=sidebar') > -1; + } + + inTab(theWindow: Window): boolean { + return theWindow.location.search !== '' && theWindow.location.search.indexOf('uilocation=tab') > -1; + } + + inPopout(theWindow: Window): boolean { + return theWindow.location.search !== '' && theWindow.location.search.indexOf('uilocation=popout') > -1; + } + + inPopup(theWindow: Window): boolean { + return theWindow.location.search === '' || theWindow.location.search.indexOf('uilocation=') === -1 || + theWindow.location.search.indexOf('uilocation=popup') > -1; + } + + saveObjToStorage(key: string, obj: any): Promise { + return UtilsService.saveObjToStorage(key, obj); + } + + removeFromStorage(key: string): Promise { + return UtilsService.removeFromStorage(key); + } + + getObjFromStorage(key: string): Promise { + return UtilsService.getObjFromStorage(key); + } +}