diff --git a/src/abstractions/passwordGeneration.service.ts b/src/abstractions/passwordGeneration.service.ts index 44f7cee32d..414e29e710 100644 --- a/src/abstractions/passwordGeneration.service.ts +++ b/src/abstractions/passwordGeneration.service.ts @@ -1,10 +1,10 @@ -import { PasswordHistory } from '../models/domain/passwordHistory'; +import { GeneratedPasswordHistory } from '../models/domain/generatedPasswordHistory'; export abstract class PasswordGenerationService { generatePassword: (options: any) => Promise; getOptions: () => any; saveOptions: (options: any) => Promise; - getHistory: () => Promise; + getHistory: () => Promise; addHistory: (password: string) => Promise; clear: () => Promise; } diff --git a/src/angular/components/password-generator-history.component.ts b/src/angular/components/password-generator-history.component.ts index a94f780a06..015d64fcc0 100644 --- a/src/angular/components/password-generator-history.component.ts +++ b/src/angular/components/password-generator-history.component.ts @@ -7,10 +7,10 @@ import { I18nService } from '../../abstractions/i18n.service'; import { PasswordGenerationService } from '../../abstractions/passwordGeneration.service'; import { PlatformUtilsService } from '../../abstractions/platformUtils.service'; -import { PasswordHistory } from '../../models/domain/passwordHistory'; +import { GeneratedPasswordHistory } from '../../models/domain/generatedPasswordHistory'; export class PasswordGeneratorHistoryComponent implements OnInit { - history: PasswordHistory[] = []; + history: GeneratedPasswordHistory[] = []; constructor(protected passwordGenerationService: PasswordGenerationService, protected analytics: Angulartics2, protected platformUtilsService: PlatformUtilsService, protected i18nService: I18nService, diff --git a/src/models/data/cipherData.ts b/src/models/data/cipherData.ts index 59a52fe53d..e3b9944de7 100644 --- a/src/models/data/cipherData.ts +++ b/src/models/data/cipherData.ts @@ -5,6 +5,7 @@ import { CardData } from './cardData'; import { FieldData } from './fieldData'; import { IdentityData } from './identityData'; import { LoginData } from './loginData'; +import { PasswordHistoryData } from './passwordHistoryData'; import { SecureNoteData } from './secureNoteData'; import { CipherResponse } from '../response/cipherResponse'; @@ -28,6 +29,7 @@ export class CipherData { identity?: IdentityData; fields?: FieldData[]; attachments?: AttachmentData[]; + passwordHistory?: PasswordHistoryData[]; collectionIds?: string[]; constructor(response?: CipherResponse, userId?: string, collectionIds?: string[]) { @@ -83,5 +85,12 @@ export class CipherData { this.attachments.push(new AttachmentData(attachment)); }); } + + if (response.passwordHistory != null) { + this.passwordHistory = []; + response.passwordHistory.forEach((ph) => { + this.passwordHistory.push(new PasswordHistoryData(ph)); + }); + } } } diff --git a/src/models/data/passwordHistoryData.ts b/src/models/data/passwordHistoryData.ts new file mode 100644 index 0000000000..1ef1c87a48 --- /dev/null +++ b/src/models/data/passwordHistoryData.ts @@ -0,0 +1,15 @@ +import { PasswordHistoryResponse } from '../response/passwordHistoryResponse'; + +export class PasswordHistoryData { + password: string; + lastUsedDate: Date; + + constructor(response?: PasswordHistoryResponse) { + if (response == null) { + return; + } + + this.password = response.password; + this.lastUsedDate = response.lastUsedDate; + } +} diff --git a/src/models/domain/cipher.ts b/src/models/domain/cipher.ts index ad5cd75587..d66ee67cc2 100644 --- a/src/models/domain/cipher.ts +++ b/src/models/domain/cipher.ts @@ -11,6 +11,7 @@ import Domain from './domain'; import { Field } from './field'; import { Identity } from './identity'; import { Login } from './login'; +import { Password } from './password'; import { SecureNote } from './secureNote'; export class Cipher extends Domain { @@ -31,6 +32,7 @@ export class Cipher extends Domain { secureNote: SecureNote; attachments: Attachment[]; fields: Field[]; + passwordHistory: Password[]; collectionIds: string[]; constructor(obj?: CipherData, alreadyEncrypted: boolean = false, localData: any = null) { @@ -90,6 +92,15 @@ export class Cipher extends Domain { } else { this.fields = null; } + + if (obj.passwordHistory != null) { + this.passwordHistory = []; + obj.passwordHistory.forEach((ph) => { + this.passwordHistory.push(new Password(ph, alreadyEncrypted)); + }); + } else { + this.passwordHistory = null; + } } async decrypt(): Promise { @@ -143,6 +154,18 @@ export class Cipher extends Domain { model.fields = fields; } + if (this.passwordHistory != null && this.passwordHistory.length > 0) { + const passwordHistory: any[] = []; + await this.passwordHistory.reduce((promise, ph) => { + return promise.then(() => { + return ph.decrypt(orgId); + }).then((decPh) => { + passwordHistory.push(decPh); + }); + }, Promise.resolve()); + model.passwordHistory = passwordHistory; + } + return model; } @@ -194,6 +217,13 @@ export class Cipher extends Domain { c.attachments.push(attachment.toAttachmentData()); }); } + + if (this.passwordHistory != null) { + c.passwordHistory = []; + this.passwordHistory.forEach((ph) => { + c.passwordHistory.push(ph.toPasswordHistoryData()); + }); + } return c; } } diff --git a/src/models/domain/passwordHistory.ts b/src/models/domain/generatedPasswordHistory.ts similarity index 79% rename from src/models/domain/passwordHistory.ts rename to src/models/domain/generatedPasswordHistory.ts index 35e7892dcd..1b08256548 100644 --- a/src/models/domain/passwordHistory.ts +++ b/src/models/domain/generatedPasswordHistory.ts @@ -1,4 +1,4 @@ -export class PasswordHistory { +export class GeneratedPasswordHistory { password: string; date: number; diff --git a/src/models/domain/index.ts b/src/models/domain/index.ts index a25fe24735..64a1d7f4e1 100644 --- a/src/models/domain/index.ts +++ b/src/models/domain/index.ts @@ -11,6 +11,6 @@ export { Folder } from './folder'; export { Identity } from './identity'; export { Login } from './login'; export { LoginUri } from './loginUri'; -export { PasswordHistory } from './passwordHistory'; +export { GeneratedPasswordHistory } from './generatedPasswordHistory'; export { SecureNote } from './secureNote'; export { SymmetricCryptoKey } from './symmetricCryptoKey'; diff --git a/src/models/domain/password.ts b/src/models/domain/password.ts new file mode 100644 index 0000000000..c60d0937fb --- /dev/null +++ b/src/models/domain/password.ts @@ -0,0 +1,39 @@ +import { PasswordHistoryData } from '../data/passwordHistoryData'; + +import { CipherString } from './cipherString'; +import Domain from './domain'; + +import { PasswordHistoryView } from '../view/passwordHistoryView'; + +export class Password extends Domain { + password: CipherString; + lastUsedDate: Date; + + constructor(obj?: PasswordHistoryData, alreadyEncrypted: boolean = false) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel(this, obj, { + password: null, + lastUsedDate: null, + }, alreadyEncrypted, ['lastUsedDate']); + } + + async decrypt(orgId: string): Promise { + const view = await this.decryptObj(new PasswordHistoryView(this), { + password: null, + }, orgId); + return view; + } + + toPasswordHistoryData(): PasswordHistoryData { + const ph = new PasswordHistoryData(); + ph.lastUsedDate = this.lastUsedDate; + this.buildDataModel(this, ph, { + password: null, + }); + return ph; + } +} diff --git a/src/models/request/cipherRequest.ts b/src/models/request/cipherRequest.ts index 5d876ebaba..01a4a9add9 100644 --- a/src/models/request/cipherRequest.ts +++ b/src/models/request/cipherRequest.ts @@ -8,6 +8,8 @@ import { IdentityApi } from '../api/identityApi'; import { LoginApi } from '../api/loginApi'; import { SecureNoteApi } from '../api/secureNoteApi'; +import { PasswordHistoryRequest } from './passwordHistoryRequest'; + export class CipherRequest { type: CipherType; folderId: string; @@ -20,6 +22,7 @@ export class CipherRequest { card: CardApi; identity: IdentityApi; fields: FieldApi[]; + passwordHistory: PasswordHistoryRequest[]; attachments: { [id: string]: string; }; constructor(cipher: Cipher) { @@ -102,6 +105,16 @@ export class CipherRequest { }); } + if (cipher.passwordHistory) { + this.passwordHistory = []; + cipher.passwordHistory.forEach((ph) => { + this.passwordHistory.push({ + lastUsedDate: ph.lastUsedDate, + password: ph.password ? ph.password.encryptedString : null, + }); + }); + } + if (cipher.attachments) { this.attachments = {}; cipher.attachments.forEach((attachment) => { diff --git a/src/models/request/passwordHistoryRequest.ts b/src/models/request/passwordHistoryRequest.ts new file mode 100644 index 0000000000..c917de1d10 --- /dev/null +++ b/src/models/request/passwordHistoryRequest.ts @@ -0,0 +1,9 @@ +export class PasswordHistoryRequest { + password: string; + lastUsedDate: Date; + + constructor(response: any) { + this.password = response.Password; + this.lastUsedDate = response.LastUsedDate; + } +} diff --git a/src/models/response/cipherResponse.ts b/src/models/response/cipherResponse.ts index 452a88d1b5..c2c9a67242 100644 --- a/src/models/response/cipherResponse.ts +++ b/src/models/response/cipherResponse.ts @@ -1,4 +1,5 @@ import { AttachmentResponse } from './attachmentResponse'; +import { PasswordHistoryResponse } from './passwordHistoryResponse'; import { CardApi } from '../api/cardApi'; import { FieldApi } from '../api/fieldApi'; @@ -23,6 +24,7 @@ export class CipherResponse { organizationUseTotp: boolean; revisionDate: Date; attachments: AttachmentResponse[]; + passwordHistory: PasswordHistoryResponse[]; collectionIds: string[]; constructor(response: any) { @@ -67,6 +69,13 @@ export class CipherResponse { }); } + if (response.PasswordHistory != null) { + this.passwordHistory = []; + response.PasswordHistory.forEach((ph: any) => { + this.passwordHistory.push(new PasswordHistoryResponse(ph)); + }); + } + if (response.CollectionIds) { this.collectionIds = []; response.CollectionIds.forEach((id: string) => { diff --git a/src/models/response/passwordHistoryResponse.ts b/src/models/response/passwordHistoryResponse.ts new file mode 100644 index 0000000000..8630da8f03 --- /dev/null +++ b/src/models/response/passwordHistoryResponse.ts @@ -0,0 +1,9 @@ +export class PasswordHistoryResponse { + password: string; + lastUsedDate: Date; + + constructor(response: any) { + this.password = response.Password; + this.lastUsedDate = response.LastUsedDate; + } +} diff --git a/src/models/view/cipherView.ts b/src/models/view/cipherView.ts index 72e68eaced..73a8d062e4 100644 --- a/src/models/view/cipherView.ts +++ b/src/models/view/cipherView.ts @@ -7,6 +7,7 @@ import { CardView } from './cardView'; import { FieldView } from './fieldView'; import { IdentityView } from './identityView'; import { LoginView } from './loginView'; +import { PasswordHistoryView } from './passwordHistoryView'; import { SecureNoteView } from './secureNoteView'; import { View } from './view'; @@ -27,6 +28,7 @@ export class CipherView implements View { secureNote: SecureNoteView; attachments: AttachmentView[]; fields: FieldView[]; + passwordHistory: PasswordHistoryView[]; collectionIds: string[]; constructor(c?: Cipher) { @@ -62,6 +64,10 @@ export class CipherView implements View { return null; } + get hasPasswordHistory(): boolean { + return this.passwordHistory && this.passwordHistory.length > 0; + } + get hasAttachments(): boolean { return this.attachments && this.attachments.length > 0; } diff --git a/src/models/view/passwordHistoryView.ts b/src/models/view/passwordHistoryView.ts new file mode 100644 index 0000000000..946424ffa9 --- /dev/null +++ b/src/models/view/passwordHistoryView.ts @@ -0,0 +1,16 @@ +import { View } from './view'; + +import { Password } from '../domain/password'; + +export class PasswordHistoryView implements View { + password: string; + lastUsedDate: Date; + + constructor(ph?: Password) { + if (!ph) { + return; + } + + this.lastUsedDate = ph.lastUsedDate; + } +} diff --git a/src/services/cipher.service.ts b/src/services/cipher.service.ts index b7b82eece9..7db6bf6fbf 100644 --- a/src/services/cipher.service.ts +++ b/src/services/cipher.service.ts @@ -1,4 +1,5 @@ import { CipherType } from '../enums/cipherType'; +import { FieldType } from '../enums/fieldType'; import { UriMatchType } from '../enums/uriMatchType'; import { CipherData } from '../models/data/cipherData'; @@ -12,6 +13,7 @@ import { Field } from '../models/domain/field'; import { Identity } from '../models/domain/identity'; import { Login } from '../models/domain/login'; import { LoginUri } from '../models/domain/loginUri'; +import { Password } from '../models/domain/password'; import { SecureNote } from '../models/domain/secureNote'; import { SymmetricCryptoKey } from '../models/domain/symmetricCryptoKey'; @@ -28,6 +30,7 @@ import { ErrorResponse } from '../models/response/errorResponse'; import { AttachmentView } from '../models/view/attachmentView'; import { CipherView } from '../models/view/cipherView'; import { FieldView } from '../models/view/fieldView'; +import { PasswordHistoryView } from '../models/view/passwordHistoryView'; import { View } from '../models/view/view'; import { ApiService } from '../abstractions/api.service'; @@ -61,6 +64,41 @@ export class CipherService implements CipherServiceAbstraction { } async encrypt(model: CipherView, key?: SymmetricCryptoKey): Promise { + // Adjust password history + if (model.id != null) { + const existingCipher = await (await this.get(model.id)).decrypt(); + if (existingCipher != null) { + model.passwordHistory = existingCipher.passwordHistory || []; + if (model.type === CipherType.Login && existingCipher.type === CipherType.Login && + existingCipher.login.password !== model.login.password) { + const ph = new PasswordHistoryView(null); + ph.password = existingCipher.login.password; + ph.lastUsedDate = new Date(); + model.passwordHistory.splice(0, 0, ph); + } + if (existingCipher.hasFields) { + const existingHiddenFields = existingCipher.fields.filter((f) => f.type === FieldType.Hidden); + const hiddenFields = model.fields == null ? [] : + model.fields.filter((f) => f.type === FieldType.Hidden); + existingHiddenFields.forEach((ef) => { + const matchedField = hiddenFields.filter((f) => f.name === ef.name); + if (matchedField.length === 0 || matchedField[0].value !== ef.value) { + const ph = new PasswordHistoryView(null); + ph.password = ef.name + ': ' + ef.value; + ph.lastUsedDate = new Date(); + model.passwordHistory.splice(0, 0, ph); + } + }); + } + } + if (model.passwordHistory != null && model.passwordHistory.length === 0) { + model.passwordHistory = null; + } else if (model.passwordHistory != null && model.passwordHistory.length > 5) { + // only save last 5 history + model.passwordHistory = model.passwordHistory.slice(0, 4); + } + } + const cipher = new Cipher(); cipher.id = model.id; cipher.folderId = model.folderId; @@ -81,6 +119,9 @@ export class CipherService implements CipherServiceAbstraction { this.encryptFields(model.fields, key).then((fields) => { cipher.fields = fields; }), + this.encryptPasswordHistories(model.passwordHistory, key).then((ph) => { + cipher.passwordHistory = ph; + }), this.encryptAttachments(model.attachments, key).then((attachments) => { cipher.attachments = attachments; }), @@ -144,6 +185,35 @@ export class CipherService implements CipherServiceAbstraction { return field; } + async encryptPasswordHistories(phModels: PasswordHistoryView[], key: SymmetricCryptoKey): Promise { + if (!phModels || !phModels.length) { + return null; + } + + const self = this; + const encPhs: Password[] = []; + await phModels.reduce((promise, ph) => { + return promise.then(() => { + return self.encryptPasswordHistory(ph, key); + }).then((encPh: Password) => { + encPhs.push(encPh); + }); + }, Promise.resolve()); + + return encPhs; + } + + async encryptPasswordHistory(phModel: PasswordHistoryView, key: SymmetricCryptoKey): Promise { + const ph = new Password(); + ph.lastUsedDate = phModel.lastUsedDate; + + await this.encryptObjProperty(phModel, ph, { + password: null, + }, key); + + return ph; + } + async get(id: string): Promise { const userId = await this.userService.getUserId(); const localData = await this.storageService.get(Keys.localData); diff --git a/src/services/passwordGeneration.service.ts b/src/services/passwordGeneration.service.ts index 0e68c0c8f8..cc5ebe4f34 100644 --- a/src/services/passwordGeneration.service.ts +++ b/src/services/passwordGeneration.service.ts @@ -1,5 +1,5 @@ import { CipherString } from '../models/domain/cipherString'; -import { PasswordHistory } from '../models/domain/passwordHistory'; +import { GeneratedPasswordHistory } from '../models/domain/generatedPasswordHistory'; import { CryptoService } from '../abstractions/crypto.service'; import { @@ -29,7 +29,7 @@ const MaxPasswordsInHistory = 100; export class PasswordGenerationService implements PasswordGenerationServiceAbstraction { private optionsCache: any; - private history: PasswordHistory[]; + private history: GeneratedPasswordHistory[]; constructor(private cryptoService: CryptoService, private storageService: StorageService) { } @@ -168,18 +168,18 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr this.optionsCache = options; } - async getHistory(): Promise { + async getHistory(): Promise { const hasKey = await this.cryptoService.hasKey(); if (!hasKey) { - return new Array(); + return new Array(); } if (!this.history) { - const encrypted = await this.storageService.get(Keys.history); + const encrypted = await this.storageService.get(Keys.history); this.history = await this.decryptHistory(encrypted); } - return this.history || new Array(); + return this.history || new Array(); } async addHistory(password: string): Promise { @@ -196,7 +196,7 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr return; } - currentHistory.unshift(new PasswordHistory(password, Date.now())); + currentHistory.unshift(new GeneratedPasswordHistory(password, Date.now())); // Remove old items. if (currentHistory.length > MaxPasswordsInHistory) { @@ -212,33 +212,33 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr return await this.storageService.remove(Keys.history); } - private async encryptHistory(history: PasswordHistory[]): Promise { + private async encryptHistory(history: GeneratedPasswordHistory[]): Promise { if (history == null || history.length === 0) { return Promise.resolve([]); } const promises = history.map(async (item) => { const encrypted = await this.cryptoService.encrypt(item.password); - return new PasswordHistory(encrypted.encryptedString, item.date); + return new GeneratedPasswordHistory(encrypted.encryptedString, item.date); }); return await Promise.all(promises); } - private async decryptHistory(history: PasswordHistory[]): Promise { + private async decryptHistory(history: GeneratedPasswordHistory[]): Promise { if (history == null || history.length === 0) { return Promise.resolve([]); } const promises = history.map(async (item) => { const decrypted = await this.cryptoService.decryptToUtf8(new CipherString(item.password)); - return new PasswordHistory(decrypted, item.date); + return new GeneratedPasswordHistory(decrypted, item.date); }); return await Promise.all(promises); } - private matchesPrevious(password: string, history: PasswordHistory[]): boolean { + private matchesPrevious(password: string, history: GeneratedPasswordHistory[]): boolean { if (history == null || history.length === 0) { return false; }