diff --git a/package-lock.json b/package-lock.json index 2f2cafa08a..7d73583d40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1163,7 +1163,8 @@ }, "kind-of": { "version": "6.0.2", - "resolved": "", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true } } @@ -3765,7 +3766,8 @@ }, "kind-of": { "version": "6.0.2", - "resolved": "", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true } } @@ -8330,7 +8332,8 @@ }, "kind-of": { "version": "6.0.2", - "resolved": "", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true } } diff --git a/src/abstractions/passwordReprompt.service.ts b/src/abstractions/passwordReprompt.service.ts new file mode 100644 index 0000000000..f0e694a063 --- /dev/null +++ b/src/abstractions/passwordReprompt.service.ts @@ -0,0 +1,4 @@ +export abstract class PasswordRepromptService { + protectedFields: () => string[]; + showPasswordPrompt: () => Promise; +} diff --git a/src/abstractions/platformUtils.service.ts b/src/abstractions/platformUtils.service.ts index 3ecf6bc521..84918c5254 100644 --- a/src/abstractions/platformUtils.service.ts +++ b/src/abstractions/platformUtils.service.ts @@ -26,6 +26,7 @@ export abstract class PlatformUtilsService { options?: any) => void; showDialog: (body: string, title?: string, confirmText?: string, cancelText?: string, type?: string, bodyIsHtml?: boolean) => Promise; + showPasswordDialog: (title: string, body: string, passwordValidation: (value: string) => Promise) => Promise; isDev: () => boolean; isSelfHost: () => boolean; copyToClipboard: (text: string, options?: any) => void; diff --git a/src/angular/components/add-edit.component.ts b/src/angular/components/add-edit.component.ts index 02d94eec69..c3a8cdea27 100644 --- a/src/angular/components/add-edit.component.ts +++ b/src/angular/components/add-edit.component.ts @@ -42,6 +42,7 @@ import { LoginUriView } from '../../models/view/loginUriView'; import { LoginView } from '../../models/view/loginView'; import { SecureNoteView } from '../../models/view/secureNoteView'; +import { CipherRepromptType } from '../../enums/cipherRepromptType'; import { Utils } from '../../misc/utils'; @Directive() @@ -71,6 +72,7 @@ export class AddEditComponent implements OnInit { restorePromise: Promise; checkPasswordPromise: Promise; showPassword: boolean = false; + showCardNumber: boolean = false; showCardCode: boolean = false; cipherType = CipherType; fieldType = FieldType; @@ -84,6 +86,7 @@ export class AddEditComponent implements OnInit { ownershipOptions: any[] = []; currentDate = new Date(); allowPersonal = true; + reprompt: boolean = false; protected writeableCollections: CollectionView[]; private previousCipherId: string; @@ -245,6 +248,7 @@ export class AddEditComponent implements OnInit { this.eventService.collect(EventType.Cipher_ClientViewed, this.cipherId); } this.previousCipherId = this.cipherId; + this.reprompt = this.cipher.reprompt !== CipherRepromptType.None; } async submit(): Promise { @@ -422,6 +426,13 @@ export class AddEditComponent implements OnInit { } } + async toggleCardNumber() { + this.showCardNumber = !this.showCardNumber; + if (this.showCardNumber) { + this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId); + } + } + toggleCardCode() { this.showCardCode = !this.showCardCode; document.getElementById('cardCode').focus(); @@ -488,6 +499,15 @@ export class AddEditComponent implements OnInit { } } + repromptChanged() { + this.reprompt = !this.reprompt; + if (this.reprompt) { + this.cipher.reprompt = CipherRepromptType.Password; + } else { + this.cipher.reprompt = CipherRepromptType.None; + } + } + protected async loadCollections() { const allCollections = await this.collectionService.getAllDecrypted(); return allCollections.filter(c => !c.readOnly); diff --git a/src/angular/components/view.component.ts b/src/angular/components/view.component.ts index a120ebb757..eefb051573 100644 --- a/src/angular/components/view.component.ts +++ b/src/angular/components/view.component.ts @@ -23,6 +23,8 @@ import { TokenService } from '../../abstractions/token.service'; import { TotpService } from '../../abstractions/totp.service'; import { UserService } from '../../abstractions/user.service'; +import { PasswordRepromptService } from '../../abstractions/passwordReprompt.service'; +import { CipherRepromptType } from '../../enums/cipherRepromptType'; import { AttachmentView } from '../../models/view/attachmentView'; import { CipherView } from '../../models/view/cipherView'; import { FieldView } from '../../models/view/fieldView'; @@ -42,6 +44,7 @@ export class ViewComponent implements OnDestroy, OnInit { cipher: CipherView; showPassword: boolean; + showCardNumber: boolean; showCardCode: boolean; canAccessPremium: boolean; totpCode: string; @@ -54,6 +57,7 @@ export class ViewComponent implements OnDestroy, OnInit { private totpInterval: any; private previousCipherId: string; + private passwordReprompted: boolean = false; constructor(protected cipherService: CipherService, protected totpService: TotpService, protected tokenService: TokenService, protected i18nService: I18nService, @@ -61,7 +65,7 @@ export class ViewComponent implements OnDestroy, OnInit { protected auditService: AuditService, protected win: Window, protected broadcasterService: BroadcasterService, protected ngZone: NgZone, protected changeDetectorRef: ChangeDetectorRef, protected userService: UserService, - protected eventService: EventService) { } + protected eventService: EventService, protected passwordRepromptService: PasswordRepromptService) { } ngOnInit() { this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { @@ -107,19 +111,38 @@ export class ViewComponent implements OnDestroy, OnInit { this.previousCipherId = this.cipherId; } - edit() { - this.onEditCipher.emit(this.cipher); + async edit() { + if (await this.promptPassword()) { + this.onEditCipher.emit(this.cipher); + return true; + } + + return false; } - clone() { - this.onCloneCipher.emit(this.cipher); + async clone() { + if (await this.promptPassword()) { + this.onCloneCipher.emit(this.cipher); + return true; + } + + return false; } - share() { - this.onShareCipher.emit(this.cipher); + async share() { + if (await this.promptPassword()) { + this.onShareCipher.emit(this.cipher); + return true; + } + + return false; } async delete(): Promise { + if (!await this.promptPassword()) { + return; + } + const confirmed = await this.platformUtilsService.showDialog( this.i18nService.t(this.cipher.isDeleted ? 'permanentlyDeleteItemConfirmation' : 'deleteItemConfirmation'), this.i18nService.t('deleteItem'), this.i18nService.t('yes'), this.i18nService.t('no'), 'warning'); @@ -158,14 +181,33 @@ export class ViewComponent implements OnDestroy, OnInit { return true; } - togglePassword() { + async togglePassword() { + if (!await this.promptPassword()) { + return; + } + this.showPassword = !this.showPassword; if (this.showPassword) { this.eventService.collect(EventType.Cipher_ClientToggledPasswordVisible, this.cipherId); } } - toggleCardCode() { + async toggleCardNumber() { + if (!await this.promptPassword()) { + return; + } + + this.showCardNumber = !this.showCardNumber; + if (this.showCardNumber) { + this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId); + } + } + + async toggleCardCode() { + if (!await this.promptPassword()) { + return; + } + this.showCardCode = !this.showCardCode; if (this.showCardCode) { this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId); @@ -188,7 +230,11 @@ export class ViewComponent implements OnDestroy, OnInit { } } - toggleFieldValue(field: FieldView) { + async toggleFieldValue(field: FieldView) { + if (!await this.promptPassword()) { + return; + } + const f = (field as any); f.showValue = !f.showValue; if (f.showValue) { @@ -208,11 +254,15 @@ export class ViewComponent implements OnDestroy, OnInit { this.platformUtilsService.launchUri(uri.launchUri); } - copy(value: string, typeI18nKey: string, aType: string) { + async copy(value: string, typeI18nKey: string, aType: string) { if (value == null) { return; } + if (this.passwordRepromptService.protectedFields().includes(aType) && !await this.promptPassword()) { + return; + } + const copyOptions = this.win != null ? { window: this.win } : null; this.platformUtilsService.copyToClipboard(value, copyOptions); this.platformUtilsService.showToast('info', null, @@ -273,6 +323,14 @@ export class ViewComponent implements OnDestroy, OnInit { return this.cipherService.restoreWithServer(this.cipher.id); } + protected async promptPassword() { + if (this.cipher.reprompt === CipherRepromptType.None || this.passwordReprompted) { + return true; + } + + return this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt(); + } + private cleanUp() { this.totpCode = null; this.cipher = null; diff --git a/src/cli/services/cliPlatformUtils.service.ts b/src/cli/services/cliPlatformUtils.service.ts index 2184c991b1..15d4a6aaf8 100644 --- a/src/cli/services/cliPlatformUtils.service.ts +++ b/src/cli/services/cliPlatformUtils.service.ts @@ -114,6 +114,11 @@ export class CliPlatformUtilsService implements PlatformUtilsService { throw new Error('Not implemented.'); } + showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise): + Promise { + throw new Error('Not implemented.'); + } + isDev(): boolean { return process.env.BWCLI_ENV === 'development'; } diff --git a/src/electron/services/electronPlatformUtils.service.ts b/src/electron/services/electronPlatformUtils.service.ts index fd7980d342..fe6527029b 100644 --- a/src/electron/services/electronPlatformUtils.service.ts +++ b/src/electron/services/electronPlatformUtils.service.ts @@ -23,7 +23,7 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { private deviceCache: DeviceType = null; - constructor(private i18nService: I18nService, private messagingService: MessagingService, + constructor(protected i18nService: I18nService, private messagingService: MessagingService, private isDesktopApp: boolean, private storageService: StorageService) { this.identityClientId = isDesktopApp ? 'desktop' : 'connector'; } @@ -148,6 +148,11 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { return Promise.resolve(result.response === 0); } + async showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise): + Promise { + throw new Error('Not implemented.'); + } + isDev(): boolean { return isDev(); } diff --git a/src/enums/cipherRepromptType.ts b/src/enums/cipherRepromptType.ts new file mode 100644 index 0000000000..5b2a9b2fc7 --- /dev/null +++ b/src/enums/cipherRepromptType.ts @@ -0,0 +1,4 @@ +export enum CipherRepromptType { + None = 0, + Password = 1, +} diff --git a/src/enums/eventType.ts b/src/enums/eventType.ts index 0a56391170..dfb90fcd7d 100644 --- a/src/enums/eventType.ts +++ b/src/enums/eventType.ts @@ -25,6 +25,7 @@ export enum EventType { Cipher_ClientAutofilled = 1114, Cipher_SoftDeleted = 1115, Cipher_Restored = 1116, + Cipher_ClientToggledCardNumberVisible = 1117, Collection_Created = 1300, Collection_Updated = 1301, diff --git a/src/models/data/cipherData.ts b/src/models/data/cipherData.ts index 487fc5ba6c..679db75183 100644 --- a/src/models/data/cipherData.ts +++ b/src/models/data/cipherData.ts @@ -1,3 +1,4 @@ +import { CipherRepromptType } from '../../enums/cipherRepromptType'; import { CipherType } from '../../enums/cipherType'; import { AttachmentData } from './attachmentData'; @@ -33,6 +34,7 @@ export class CipherData { passwordHistory?: PasswordHistoryData[]; collectionIds?: string[]; deletedDate: string; + reprompt: CipherRepromptType; constructor(response?: CipherResponse, userId?: string, collectionIds?: string[]) { if (response == null) { @@ -53,6 +55,7 @@ export class CipherData { this.notes = response.notes; this.collectionIds = collectionIds != null ? collectionIds : response.collectionIds; this.deletedDate = response.deletedDate; + this.reprompt = response.reprompt; switch (this.type) { case CipherType.Login: diff --git a/src/models/domain/cipher.ts b/src/models/domain/cipher.ts index 1a7aaa9032..c9cbe8d226 100644 --- a/src/models/domain/cipher.ts +++ b/src/models/domain/cipher.ts @@ -1,3 +1,4 @@ +import { CipherRepromptType } from '../../enums/cipherRepromptType'; import { CipherType } from '../../enums/cipherType'; import { CipherData } from '../data/cipherData'; @@ -37,6 +38,7 @@ export class Cipher extends Domain { passwordHistory: Password[]; collectionIds: string[]; deletedDate: Date; + reprompt: CipherRepromptType; constructor(obj?: CipherData, alreadyEncrypted: boolean = false, localData: any = null) { super(); @@ -66,6 +68,7 @@ export class Cipher extends Domain { this.collectionIds = obj.collectionIds; this.localData = localData; this.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : null; + this.reprompt = obj.reprompt; switch (this.type) { case CipherType.Login: @@ -183,6 +186,7 @@ export class Cipher extends Domain { c.type = this.type; c.collectionIds = this.collectionIds; c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null; + c.reprompt = this.reprompt; this.buildDataModel(this, c, { name: null, diff --git a/src/models/request/cipherRequest.ts b/src/models/request/cipherRequest.ts index cdd0087d9d..bbed355ad5 100644 --- a/src/models/request/cipherRequest.ts +++ b/src/models/request/cipherRequest.ts @@ -1,3 +1,4 @@ +import { CipherRepromptType } from '../../enums/cipherRepromptType'; import { CipherType } from '../../enums/cipherType'; import { Cipher } from '../domain/cipher'; @@ -29,6 +30,7 @@ export class CipherRequest { attachments: { [id: string]: string; }; attachments2: { [id: string]: AttachmentRequest; }; lastKnownRevisionDate: Date; + reprompt: CipherRepromptType; constructor(cipher: Cipher) { this.type = cipher.type; @@ -38,6 +40,7 @@ export class CipherRequest { this.notes = cipher.notes ? cipher.notes.encryptedString : null; this.favorite = cipher.favorite; this.lastKnownRevisionDate = cipher.revisionDate; + this.reprompt = cipher.reprompt; switch (this.type) { case CipherType.Login: diff --git a/src/models/response/cipherResponse.ts b/src/models/response/cipherResponse.ts index 32555a65b8..555489394e 100644 --- a/src/models/response/cipherResponse.ts +++ b/src/models/response/cipherResponse.ts @@ -2,6 +2,7 @@ import { AttachmentResponse } from './attachmentResponse'; import { BaseResponse } from './baseResponse'; import { PasswordHistoryResponse } from './passwordHistoryResponse'; +import { CipherRepromptType } from '../../enums/cipherRepromptType'; import { CardApi } from '../api/cardApi'; import { FieldApi } from '../api/fieldApi'; import { IdentityApi } from '../api/identityApi'; @@ -29,6 +30,7 @@ export class CipherResponse extends BaseResponse { passwordHistory: PasswordHistoryResponse[]; collectionIds: string[]; deletedDate: string; + reprompt: CipherRepromptType; constructor(response: any) { super(response); @@ -84,5 +86,7 @@ export class CipherResponse extends BaseResponse { if (passwordHistory != null) { this.passwordHistory = passwordHistory.map((h: any) => new PasswordHistoryResponse(h)); } + + this.reprompt = this.getResponseProperty('Reprompt') || CipherRepromptType.None; } } diff --git a/src/models/view/cardView.ts b/src/models/view/cardView.ts index 3add0356b6..db8740793c 100644 --- a/src/models/view/cardView.ts +++ b/src/models/view/cardView.ts @@ -22,6 +22,10 @@ export class CardView implements View { return this.code != null ? '•'.repeat(this.code.length) : null; } + get maskedNumber(): string { + return this.number != null ? '•'.repeat(this.number.length) : null; + } + get brand(): string { return this._brand; } diff --git a/src/models/view/cipherView.ts b/src/models/view/cipherView.ts index 4cd0daf039..f4548f60c3 100644 --- a/src/models/view/cipherView.ts +++ b/src/models/view/cipherView.ts @@ -1,3 +1,4 @@ +import { CipherRepromptType } from '../../enums/cipherRepromptType'; import { CipherType } from '../../enums/cipherType'; import { Cipher } from '../domain/cipher'; @@ -33,6 +34,7 @@ export class CipherView implements View { collectionIds: string[] = null; revisionDate: Date = null; deletedDate: Date = null; + reprompt: CipherRepromptType = null; constructor(c?: Cipher) { if (!c) { @@ -51,6 +53,7 @@ export class CipherView implements View { this.collectionIds = c.collectionIds; this.revisionDate = c.revisionDate; this.deletedDate = c.deletedDate; + this.reprompt = c.reprompt; } get subTitle(): string { diff --git a/src/services/cipher.service.ts b/src/services/cipher.service.ts index e4c6c1a6ad..520bec37a2 100644 --- a/src/services/cipher.service.ts +++ b/src/services/cipher.service.ts @@ -147,6 +147,7 @@ export class CipherService implements CipherServiceAbstraction { cipher.type = model.type; cipher.collectionIds = model.collectionIds; cipher.revisionDate = model.revisionDate; + cipher.reprompt = model.reprompt; if (key == null && cipher.organizationId != null) { key = await this.cryptoService.getOrgKey(cipher.organizationId); diff --git a/src/services/passwordReprompt.service.ts b/src/services/passwordReprompt.service.ts new file mode 100644 index 0000000000..f09727c9a5 --- /dev/null +++ b/src/services/passwordReprompt.service.ts @@ -0,0 +1,28 @@ +import { PlatformUtilsService } from '../abstractions'; + +import { CryptoService } from '../abstractions/crypto.service'; +import { I18nService } from '../abstractions/i18n.service'; +import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from '../abstractions/passwordReprompt.service'; + +export class PasswordRepromptService implements PasswordRepromptServiceAbstraction { + constructor(private i18nService: I18nService, private cryptoService: CryptoService, + private platformUtilService: PlatformUtilsService) { } + + protectedFields() { + return ['TOTP', 'Password', 'H_Field', 'Card Number', 'Security Code']; + } + + async showPasswordPrompt() { + const passwordValidator = async (value: string) => { + const keyHash = await this.cryptoService.hashPassword(value, null); + const storedKeyHash = await this.cryptoService.getKeyHash(); + + if (storedKeyHash == null || keyHash == null || storedKeyHash !== keyHash) { + return false; + } + return true; + }; + + return this.platformUtilService.showPasswordDialog(this.i18nService.t('passwordConfirmation'), this.i18nService.t('passwordConfirmationDesc'), passwordValidator); + } +}