1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-03 17:13:47 +00:00

Add support for Emergency Access (#707)

* Add support for Emergency Access

* Cleanup & Bugfix

* Apply suggestions from code review

Co-authored-by: Addison Beck <addisonbeck1@gmail.com>

* Cleanup some more imports

* Restrict emergency access invite to premium users

* Restrict editing existing emergency accesses to premium account.

* Handle changes in jslib

* Add some info messages for when you haven't been granted or invited emergency contacts

* Resolve review comments

* Update jslib

Co-authored-by: Addison Beck <addisonbeck1@gmail.com>
This commit is contained in:
Oscar Hinton
2020-12-22 16:57:44 +01:00
committed by GitHub
parent 54b68ac543
commit 3c5a972bc9
23 changed files with 1409 additions and 53 deletions

View File

@@ -16,10 +16,14 @@ import {
ChangePasswordComponent as BaseChangePasswordComponent,
} from 'jslib/angular/components/change-password.component';
import { EmergencyAccessStatusType } from 'jslib/enums/emergencyAccessStatusType';
import { Utils } from 'jslib/misc/utils';
import { CipherString } from 'jslib/models/domain/cipherString';
import { SymmetricCryptoKey } from 'jslib/models/domain/symmetricCryptoKey';
import { CipherWithIdRequest } from 'jslib/models/request/cipherWithIdRequest';
import { EmergencyAccessUpdateRequest } from 'jslib/models/request/emergencyAccessUpdateRequest';
import { FolderWithIdRequest } from 'jslib/models/request/folderWithIdRequest';
import { PasswordRequest } from 'jslib/models/request/passwordRequest';
import { UpdateKeyRequest } from 'jslib/models/request/updateKeyRequest';
@@ -160,5 +164,32 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
}
await this.apiService.postAccountKey(request);
await this.updateEmergencyAccesses(encKey[0]);
}
private async updateEmergencyAccesses(encKey: SymmetricCryptoKey) {
const emergencyAccess = await this.apiService.getEmergencyAccessTrusted();
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,
EmergencyAccessStatusType.RecoveryInitiated,
EmergencyAccessStatusType.RecoveryApproved,
];
const filteredAccesses = emergencyAccess.data.filter(d => allowedStatuses.includes(d.status));
for (const details of filteredAccesses) {
const publicKeyResponse = await this.apiService.getUserPublicKey(details.granteeId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const encryptedKey = await this.cryptoService.rsaEncrypt(encKey.key, publicKey.buffer);
const updateRequest = new EmergencyAccessUpdateRequest();
updateRequest.type = details.type;
updateRequest.waitTimeDays = details.waitTimeDays;
updateRequest.keyEncrypted = encryptedKey.encryptedString;
await this.apiService.putEmergencyAccess(details.id, updateRequest);
}
}
}

View File

@@ -0,0 +1,76 @@
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="userAddEditTitle">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="modal-header">
<h2 class="modal-title" id="userAddEditTitle">
<span class="badge badge-primary" *ngIf="readOnly">{{'premium' | i18n}}</span>
{{title}}
<small class="text-muted" *ngIf="name">{{name}}</small>
</h2>
<button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" *ngIf="loading">
<i class="fa fa-spinner fa-spin text-muted" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</div>
<div class="modal-body" *ngIf="!loading">
<ng-container *ngIf="!editMode">
<p>{{'inviteEmergencyContactDesc' | i18n}}</p>
<div class="form-group mb-4">
<label for="email">{{'email' | i18n}}</label>
<input id="email" class="form-control" type="text" name="Email" [(ngModel)]="email" required>
</div>
</ng-container>
<h3>
{{'userAccess' | i18n}}
<a target="_blank" rel="noopener" appA11yTitle="{{'learnMore' | i18n}}"
href="https://bitwarden.com/help/article/user-types-access-control/#user-types">
<i class="fa fa-question-circle-o" aria-hidden="true"></i>
</a>
</h3>
<div class="form-check mt-2 form-check-block">
<input class="form-check-input" type="radio" name="userType" id="emergencyTypeView"
[value]="emergencyAccessType.View" [(ngModel)]="type">
<label class="form-check-label" for="emergencyTypeView">
{{'view' | i18n}}
<small>{{'viewDesc' | i18n}}</small>
</label>
</div>
<div class="form-check mt-2 form-check-block">
<input class="form-check-input" type="radio" name="userType" id="emergencyTypeTakeover"
[value]="emergencyAccessType.Takeover" [(ngModel)]="type" [disabled]="readOnly">
<label class="form-check-label" for="emergencyTypeTakeover">
{{'takeover' | i18n}}
<small>{{'takeoverDesc' | i18n}}</small>
</label>
</div>
<div class="form-group col-6 mt-4">
<label for="waitTime">{{'waitTime' | i18n}}</label>
<select id="waitTime" name="waitTime" [(ngModel)]="waitTime" class="form-control" [disabled]="readOnly">
<option *ngFor="let o of waitTimes" [ngValue]="o.value">{{o.name}}</option>
</select>
<small class="text-muted">{{'waitTimeDesc' | i18n}}</small>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" [disabled]="loading || readOnly">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true" *ngIf="loading"></i>
<span *ngIf="!loading">{{'save' | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary"
data-dismiss="modal">{{'cancel' | i18n}}</button>
<div class="ml-auto">
<button #deleteBtn type="button" (click)="delete()" class="btn btn-outline-danger"
appA11yTitle="{{'delete' | i18n}}" *ngIf="editMode" [disabled]="deleteBtn.loading"
[appApiAction]="deletePromise">
<i class="fa fa-trash-o fa-lg fa-fw" [hidden]="deleteBtn.loading" aria-hidden="true"></i>
<i class="fa fa-spinner fa-spin fa-lg fa-fw" [hidden]="!deleteBtn.loading"
title="{{'loading' | i18n}}" aria-hidden="true"></i>
</button>
</div>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,99 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { ApiService } from 'jslib/abstractions/api.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { EmergencyAccessType } from 'jslib/enums/emergencyAccessType';
import { EmergencyAccessInviteRequest } from 'jslib/models/request/emergencyAccessInviteRequest';
import { EmergencyAccessUpdateRequest } from 'jslib/models/request/emergencyAccessUpdateRequest';
@Component({
selector: 'emergency-access-add-edit',
templateUrl: 'emergency-access-add-edit.component.html',
})
export class EmergencyAccessAddEditComponent implements OnInit {
@Input() name: string;
@Input() emergencyAccessId: string;
@Output() onSaved = new EventEmitter();
@Output() onDeleted = new EventEmitter();
loading = true;
readOnly: boolean = false;
editMode: boolean = false;
title: string;
email: string;
type: EmergencyAccessType = EmergencyAccessType.View;
formPromise: Promise<any>;
emergencyAccessType = EmergencyAccessType;
waitTimes: { name: string; value: number; }[];
waitTime: number;
constructor(private apiService: ApiService, private i18nService: I18nService,
private toasterService: ToasterService) { }
async ngOnInit() {
this.editMode = this.loading = this.emergencyAccessId != null;
this.waitTimes = [
{ name: this.i18nService.t('oneDay'), value: 1 },
{ name: this.i18nService.t('days', '2'), value: 2 },
{ name: this.i18nService.t('days', '7'), value: 7 },
{ name: this.i18nService.t('days', '14'), value: 14 },
{ name: this.i18nService.t('days', '30'), value: 30 },
{ name: this.i18nService.t('days', '90'), value: 90 },
];
if (this.editMode) {
this.editMode = true;
this.title = this.i18nService.t('editEmergencyContact');
try {
const emergencyAccess = await this.apiService.getEmergencyAccess(this.emergencyAccessId);
this.type = emergencyAccess.type;
this.waitTime = emergencyAccess.waitTimeDays;
} catch { }
} else {
this.title = this.i18nService.t('inviteEmergencyContact');
this.waitTime = this.waitTimes[2].value;
}
this.loading = false;
}
async submit() {
try {
if (this.editMode) {
const request = new EmergencyAccessUpdateRequest();
request.type = this.type;
request.waitTimeDays = this.waitTime;
this.formPromise = this.apiService.putEmergencyAccess(this.emergencyAccessId, request);
} else {
const request = new EmergencyAccessInviteRequest();
request.email = this.email.trim();
request.type = this.type;
request.waitTimeDays = this.waitTime;
this.formPromise = this.apiService.postEmergencyAccessInvite(request);
}
await this.formPromise;
this.toasterService.popAsync('success', null,
this.i18nService.t(this.editMode ? 'editedUserId' : 'invitedUsers', this.name));
this.onSaved.emit();
} catch { }
}
async delete() {
this.onDeleted.emit();
}
}

View File

@@ -0,0 +1,38 @@
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="confirmUserTitle">
<div class="modal-dialog" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h2 class="modal-title" id="confirmUserTitle">
{{'confirmUser' | i18n}}
<small class="text-muted" *ngIf="name">{{name}}</small>
</h2>
<button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
{{'fingerprintEnsureIntegrityVerify' | i18n}}
<a href="https://help.bitwarden.com/article/fingerprint-phrase/" target="_blank" rel="noopener">
{{'learnMore' | i18n}}</a>
</p>
<p><code>{{fingerprint}}</code></p>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="dontAskAgain" name="DontAskAgain"
[(ngModel)]="dontAskAgain">
<label class="form-check-label" for="dontAskAgain">
{{'dontAskFingerprintAgain' | i18n}}
</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'confirm' | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary"
data-dismiss="modal">{{'cancel' | i18n}}</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,61 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { ConstantsService } from 'jslib/services/constants.service';
import { ApiService } from 'jslib/abstractions/api.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { StorageService } from 'jslib/abstractions/storage.service';
import { Utils } from 'jslib/misc/utils';
@Component({
selector: 'emergency-access-confirm',
templateUrl: 'emergency-access-confirm.component.html',
})
export class EmergencyAccessConfirmComponent implements OnInit {
@Input() name: string;
@Input() userId: string;
@Input() emergencyAccessId: string;
@Output() onConfirmed = new EventEmitter();
dontAskAgain = false;
loading = true;
fingerprint: string;
constructor(private apiService: ApiService, private cryptoService: CryptoService,
private storageService: StorageService) { }
async ngOnInit() {
try {
const publicKeyResponse = await this.apiService.getUserPublicKey(this.userId);
if (publicKeyResponse != null) {
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const fingerprint = await this.cryptoService.getFingerprint(this.userId, publicKey.buffer);
if (fingerprint != null) {
this.fingerprint = fingerprint.join('-');
}
}
} catch { }
this.loading = false;
}
async submit() {
if (this.loading) {
return;
}
if (this.dontAskAgain) {
await this.storageService.save(ConstantsService.autoConfirmFingerprints, true);
}
try {
this.onConfirmed.emit();
} catch { }
}
}

View File

@@ -0,0 +1,44 @@
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="userAddEditTitle">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="modal-header">
<h2 class="modal-title" id="userAddEditTitle">
{{'takeover' | i18n}}
<small class="text-muted" *ngIf="name">{{name}}</small>
</h2>
<button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<app-callout type="warning">{{'loggedOutWarning' | i18n}}</app-callout>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="masterPassword">{{'newMasterPass' | i18n}}</label>
<input id="masterPassword" type="password" name="NewMasterPasswordHash" class="form-control mb-1"
[(ngModel)]="masterPassword" (input)="updatePasswordStrength()" required appInputVerbatim
autocomplete="new-password">
<app-password-strength [score]="masterPasswordScore" [showText]="true"></app-password-strength>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label for="masterPasswordRetype">{{'confirmNewMasterPass' | i18n}}</label>
<input id="masterPasswordRetype" type="password" name="MasterPasswordRetype"
class="form-control" [(ngModel)]="masterPasswordRetype" required appInputVerbatim
autocomplete="new-password">
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'save' | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">{{'cancel' | i18n}}</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,81 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { ApiService } from 'jslib/abstractions/api.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PasswordGenerationService } from 'jslib/abstractions/passwordGeneration.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { PolicyService } from 'jslib/abstractions/policy.service';
import { UserService } from 'jslib/abstractions/user.service';
import { ChangePasswordComponent } from 'jslib/angular/components/change-password.component';
import { KdfType } from 'jslib/enums/kdfType';
import { SymmetricCryptoKey } from 'jslib/models/domain/symmetricCryptoKey';
import { EmergencyAccessPasswordRequest } from 'jslib/models/request/emergencyAccessPasswordRequest';
@Component({
selector: 'emergency-access-takeover',
templateUrl: 'emergency-access-takeover.component.html',
})
export class EmergencyAccessTakeoverComponent extends ChangePasswordComponent implements OnInit {
@Output() onDone = new EventEmitter();
@Input() emergencyAccessId: string;
@Input() name: string;
@Input() email: string;
@Input() kdf: KdfType;
@Input() kdfIterations: number;
formPromise: Promise<any>;
constructor(i18nService: I18nService, cryptoService: CryptoService,
messagingService: MessagingService, userService: UserService,
passwordGenerationService: PasswordGenerationService,
platformUtilsService: PlatformUtilsService, policyService: PolicyService,
private apiService: ApiService, private toasterService: ToasterService) {
super(i18nService, cryptoService, messagingService, userService, passwordGenerationService,
platformUtilsService, policyService);
}
// tslint:disable-next-line
async ngOnInit() { }
async submit() {
if (!await this.strongPassword()) {
return;
}
const takeoverResponse = await this.apiService.postEmergencyAccessTakeover(this.emergencyAccessId);
const oldKeyBuffer = await this.cryptoService.rsaDecrypt(takeoverResponse.keyEncrypted);
const oldEncKey = new SymmetricCryptoKey(oldKeyBuffer);
if (oldEncKey == null) {
this.toasterService.popAsync('error', this.i18nService.t('errorOccurred'), this.i18nService.t('unexpectedError'));
return;
}
const key = await this.cryptoService.makeKey(this.masterPassword, this.email, takeoverResponse.kdf, takeoverResponse.kdfIterations);
const masterPasswordHash = await this.cryptoService.hashPassword(this.masterPassword, key);
const encKey = await this.cryptoService.remakeEncKey(key, oldEncKey);
const request = new EmergencyAccessPasswordRequest();
request.newMasterPasswordHash = masterPasswordHash;
request.key = encKey[1].encryptedString;
this.apiService.postEmergencyAccessPassword(this.emergencyAccessId, request);
try {
this.onDone.emit();
} catch { }
}
}

View File

@@ -0,0 +1,31 @@
<div class="page-header">
<h1>{{'vault' | i18n}}</h1>
</div>
<div class="mt-4">
<ng-container *ngIf="ciphers.length">
<table class="table table-hover table-list table-ciphers">
<tbody>
<tr *ngFor="let c of ciphers">
<td class="table-list-icon">
<app-vault-icon [cipher]="c"></app-vault-icon>
</td>
<td class="reduced-lh wrap">
<a href="#" appStopClick (click)="selectCipher(c)" title="{{'editItem' | i18n}}">{{c.name}}</a>
<ng-container *ngIf="!organization && c.organizationId">
<i class="fa fa-share-alt" appStopProp title="{{'shared' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'shared' | i18n}}</span>
</ng-container>
<ng-container *ngIf="c.hasAttachments">
<i class="fa fa-paperclip" appStopProp title="{{'attachments' | i18n}}"
aria-hidden="true"></i>
<span class="sr-only">{{'attachments' | i18n}}</span>
</ng-container>
<br>
<small>{{c.subTitle}}</small>
</td>
</tr>
</tbody>
</table>
</ng-container>
</div>
<ng-template #cipherAddEdit></ng-template>

View File

@@ -0,0 +1,94 @@
import {
Component,
ComponentFactoryResolver,
OnInit,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from 'jslib/abstractions/api.service';
import { CipherService } from 'jslib/abstractions/cipher.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { CipherData } from 'jslib/models/data';
import { Cipher, SymmetricCryptoKey } from 'jslib/models/domain';
import { EmergencyAccessViewResponse } from 'jslib/models/response/emergencyAccessResponse';
import { CipherView } from 'jslib/models/view/cipherView';
import { ModalComponent } from '../modal.component';
import { EmergencyAddEditComponent } from './emergency-add-edit.component';
@Component({
selector: 'emergency-access-view',
templateUrl: 'emergency-access-view.component.html',
})
export class EmergencyAccessViewComponent implements OnInit {
@ViewChild('cipherAddEdit', { read: ViewContainerRef, static: true }) cipherAddEditModalRef: ViewContainerRef;
id: string;
ciphers: CipherView[] = [];
private modal: ModalComponent = null;
constructor(private cipherService: CipherService, private cryptoService: CryptoService,
private componentFactoryResolver: ComponentFactoryResolver, private router: Router,
private route: ActivatedRoute, private apiService: ApiService) { }
ngOnInit() {
this.route.params.subscribe((qParams) => {
if (qParams.id == null) {
return this.router.navigate(['settings/emergency-access']);
}
this.id = qParams.id;
this.load();
});
}
selectCipher(cipher: CipherView) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.cipherAddEditModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<EmergencyAddEditComponent>(EmergencyAddEditComponent, this.cipherAddEditModalRef);
childComponent.cipherId = cipher == null ? null : cipher.id;
childComponent.cipher = cipher;
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
return childComponent;
}
async load() {
const response = await this.apiService.postEmergencyAccessView(this.id);
this.ciphers = await this.getAllCiphers(response);
}
protected async getAllCiphers(response: EmergencyAccessViewResponse): Promise<CipherView[]> {
const ciphers = response.ciphers;
const decCiphers: CipherView[] = [];
const oldKeyBuffer = await this.cryptoService.rsaDecrypt(response.keyEncrypted);
const oldEncKey = new SymmetricCryptoKey(oldKeyBuffer);
const promises: any[] = [];
ciphers.forEach((cipherResponse) => {
const cipherData = new CipherData(cipherResponse);
const cipher = new Cipher(cipherData);
promises.push(cipher.decrypt(oldEncKey).then((c) => decCiphers.push(c)));
});
await Promise.all(promises);
decCiphers.sort(this.cipherService.getLocaleSortingFunction());
return decCiphers;
}
}

View File

@@ -0,0 +1,159 @@
<div class="page-header">
<h1>{{'emergencyAccess' | i18n}}</h1>
</div>
<p>
{{'emergencyAccessDesc' | i18n}}
<a href="https://help.bitwarden.com/article/fingerprint-phrase/" target="_blank" rel="noopener">
{{'learnMore' | i18n}}.
</a>
</p>
<div class="page-header d-flex">
<h2>
{{'trustedEmergencyContacts' | i18n}}
<a href="#" appStopClick class="badge badge-primary" *ngIf="!canAccessPremium" (click)="premiumRequired()">
{{'premium' | i18n}}
</a>
</h2>
<div class="ml-auto d-flex">
<button class="btn btn-sm btn-outline-primary ml-3" type="button" (click)="invite()" [disabled]="!canAccessPremium">
<i aria-hidden="true" class="fa fa-plus fa-fw"></i>
{{'addEmergencyContact' |i18n}}
</button>
</div>
</div>
<table class="table table-hover table-list mb-0" *ngIf="trustedContacts && trustedContacts.length">
<tbody>
<tr *ngFor="let c of trustedContacts; let i = index">
<td width="30">
<app-avatar [data]="c.name || c.email" [email]="c.email" size="25" [circle]="true"
[fontSize]="14"></app-avatar>
</td>
<td>
<a href="#" appStopClick (click)="edit(c)">{{c.email}}</a>
<span class="badge badge-secondary"
*ngIf="c.status === emergencyAccessStatusType.Invited">{{'invited' | i18n}}</span>
<span class="badge badge-warning"
*ngIf="c.status === emergencyAccessStatusType.Accepted">{{'accepted' | i18n}}</span>
<span class="badge badge-warning"
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated">{{'emergencyAccessRecoveryInitiated' | i18n}}</span>
<span class="badge badge-success"
*ngIf="c.status === emergencyAccessStatusType.RecoveryApproved">{{'emergencyAccessRecoveryApproved' | i18n}}</span>
<span class="badge badge-primary"
*ngIf="c.type === emergencyAccessType.View">{{'view' | i18n}}</span>
<span class="badge badge-primary"
*ngIf="c.type === emergencyAccessType.Takeover">{{'takeover' | i18n}}</span>
<small class="text-muted d-block" *ngIf="c.name">{{c.name}}</small>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
appA11yTitle="{{'options' | i18n}}">
<i class="fa fa-cog fa-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#" appStopClick (click)="reinvite(c)"
*ngIf="c.status === emergencyAccessStatusType.Invited">
<i class="fa fa-fw fa-envelope-o" aria-hidden="true"></i>
{{'resendInvitation' | i18n}}
</a>
<a class="dropdown-item text-success" href="#" appStopClick (click)="confirm(c)"
*ngIf="c.status === emergencyAccessStatusType.Accepted">
<i class="fa fa-fw fa-check" aria-hidden="true"></i>
{{'confirm' | i18n}}
</a>
<a class="dropdown-item text-success" href="#" appStopClick (click)="approve(c)"
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated">
<i class="fa fa-fw fa-check" aria-hidden="true"></i>
{{'approve' | i18n}}
</a>
<a class="dropdown-item text-warning" href="#" appStopClick (click)="reject(c)"
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated || c.status === emergencyAccessStatusType.RecoveryApproved">
<i class="fa fa-fw fa-remove" aria-hidden="true"></i>
{{'reject' | i18n}}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(c)">
<i class="fa fa-fw fa-remove" aria-hidden="true"></i>
{{'remove' | i18n}}
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<p *ngIf="!trustedContacts || !trustedContacts.length">{{'noTrustedContacts' | i18n}}</p>
<div class="page-header spaced-header">
<h2>{{'designatedEmergencyContacts' | i18n}}</h2>
</div>
<table class="table table-hover table-list mb-0" *ngIf="grantedContacts && grantedContacts.length">
<tbody>
<tr *ngFor="let c of grantedContacts; let i = index">
<td width="30">
<app-avatar [data]="c.name || c.email" [email]="c.email" size="25" [circle]="true"
[fontSize]="14"></app-avatar>
</td>
<td>
<span>{{c.email}}</span>
<span class="badge badge-secondary"
*ngIf="c.status === emergencyAccessStatusType.Invited">{{'invited' | i18n}}</span>
<span class="badge badge-warning"
*ngIf="c.status === emergencyAccessStatusType.Accepted">{{'accepted' | i18n}}</span>
<span class="badge badge-warning"
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated">{{'emergencyAccessRecoveryInitiated' | i18n}}</span>
<span class="badge badge-success"
*ngIf="c.status === emergencyAccessStatusType.RecoveryApproved">{{'emergencyAccessRecoveryApproved' | i18n}}</span>
<span class="badge badge-primary"
*ngIf="c.type === emergencyAccessType.View">{{'view' | i18n}}</span>
<span class="badge badge-primary"
*ngIf="c.type === emergencyAccessType.Takeover">{{'takeover' | i18n}}</span>
<small class="text-muted d-block" *ngIf="c.name">{{c.name}}</small>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
appA11yTitle="{{'options' | i18n}}">
<i class="fa fa-cog fa-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#" appStopClick (click)="requestAccess(c)"
*ngIf="c.status === emergencyAccessStatusType.Confirmed">
<i class="fa fa-fw fa-envelope-o" aria-hidden="true"></i>
{{'requestAccess' | i18n}}
</a>
<a class="dropdown-item" href="#" appStopClick (click)="takeover(c)"
*ngIf="c.status === emergencyAccessStatusType.RecoveryApproved && c.type === emergencyAccessType.Takeover">
<i class="fa fa-fw fa-key" aria-hidden="true"></i>
{{'takeover' | i18n}}
</a>
<a class="dropdown-item" [routerLink]="c.id"
*ngIf="c.status === emergencyAccessStatusType.RecoveryApproved && c.type === emergencyAccessType.View">
<i class="fa fa-fw fa-eye" aria-hidden="true"></i>
{{'view' | i18n}}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(c)">
<i class="fa fa-fw fa-remove" aria-hidden="true"></i>
{{'remove' | i18n}}
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<p *ngIf="!grantedContacts || !grantedContacts.length">{{'noGrantedAccess' | i18n}}</p>
<ng-template #addEdit></ng-template>
<ng-template #takeoverTemplate></ng-template>
<ng-template #confirmTemplate></ng-template>

View File

@@ -0,0 +1,274 @@
import { Component, ComponentFactoryResolver, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { ApiService } from 'jslib/abstractions/api.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { StorageService } from 'jslib/abstractions/storage.service';
import { UserService } from 'jslib/abstractions/user.service';
import { EmergencyAccessStatusType } from 'jslib/enums/emergencyAccessStatusType';
import { EmergencyAccessType } from 'jslib/enums/emergencyAccessType';
import { Utils } from 'jslib/misc/utils';
import { EmergencyAccessConfirmRequest } from 'jslib/models/request/emergencyAccessConfirmRequest';
import { EmergencyAccessGranteeDetailsResponse, EmergencyAccessGrantorDetailsResponse } from 'jslib/models/response/emergencyAccessResponse';
import { ConstantsService } from 'jslib/services/constants.service';
import { ModalComponent } from '../modal.component';
import { EmergencyAccessAddEditComponent } from './emergency-access-add-edit.component';
import { EmergencyAccessConfirmComponent } from './emergency-access-confirm.component';
import { EmergencyAccessTakeoverComponent } from './emergency-access-takeover.component';
@Component({
selector: 'emergency-access',
templateUrl: 'emergency-access.component.html',
})
export class EmergencyAccessComponent implements OnInit {
@ViewChild('addEdit', { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef;
@ViewChild('takeoverTemplate', { read: ViewContainerRef, static: true}) takeoverModalRef: ViewContainerRef;
@ViewChild('confirmTemplate', { read: ViewContainerRef, static: true }) confirmModalRef: ViewContainerRef;
canAccessPremium: boolean;
trustedContacts: EmergencyAccessGranteeDetailsResponse[];
grantedContacts: EmergencyAccessGrantorDetailsResponse[];
emergencyAccessType = EmergencyAccessType;
emergencyAccessStatusType = EmergencyAccessStatusType;
actionPromise: Promise<any>;
private modal: ModalComponent = null;
constructor(private apiService: ApiService, private i18nService: I18nService,
private componentFactoryResolver: ComponentFactoryResolver,
private platformUtilsService: PlatformUtilsService,
private toasterService: ToasterService, private cryptoService: CryptoService,
private storageService: StorageService, private userService: UserService,
private messagingService: MessagingService) { }
async ngOnInit() {
this.canAccessPremium = await this.userService.canAccessPremium();
this.load();
}
async load() {
this.trustedContacts = (await this.apiService.getEmergencyAccessTrusted()).data;
this.grantedContacts = (await this.apiService.getEmergencyAccessGranted()).data;
}
async premiumRequired() {
if (!this.canAccessPremium) {
this.messagingService.send('premiumRequired');
return;
}
}
edit(details: EmergencyAccessGranteeDetailsResponse) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.addEditModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<EmergencyAccessAddEditComponent>(
EmergencyAccessAddEditComponent, this.addEditModalRef);
childComponent.name = details?.name ?? details?.email;
childComponent.emergencyAccessId = details?.id;
childComponent.readOnly = !this.canAccessPremium;
childComponent.onSaved.subscribe(() => {
this.modal.close();
this.load();
});
childComponent.onDeleted.subscribe(() => {
this.modal.close();
this.remove(details);
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
}
invite() {
this.edit(null);
}
async reinvite(contact: EmergencyAccessGranteeDetailsResponse) {
if (this.actionPromise != null) {
return;
}
this.actionPromise = this.apiService.postEmergencyAccessReinvite(contact.id);
await this.actionPromise;
this.toasterService.popAsync('success', null, this.i18nService.t('hasBeenReinvited', contact.email));
this.actionPromise = null;
}
async confirm(contact: EmergencyAccessGranteeDetailsResponse) {
function updateUser() {
contact.status = EmergencyAccessStatusType.Confirmed;
}
if (this.actionPromise != null) {
return;
}
const autoConfirm = await this.storageService.get<boolean>(ConstantsService.autoConfirmFingerprints);
if (autoConfirm == null || !autoConfirm) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.confirmModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<EmergencyAccessConfirmComponent>(
EmergencyAccessConfirmComponent, this.confirmModalRef);
childComponent.name = contact?.name ?? contact?.email;
childComponent.emergencyAccessId = contact.id;
childComponent.userId = contact?.granteeId;
childComponent.onConfirmed.subscribe(async () => {
this.modal.close();
await this.doConfirmation(contact);
updateUser();
this.toasterService.popAsync('success', null, this.i18nService.t('hasBeenConfirmed', contact.name || contact.email));
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
return;
}
this.actionPromise = this.doConfirmation(contact);
await this.actionPromise;
updateUser();
this.toasterService.popAsync('success', null, this.i18nService.t('hasBeenConfirmed', contact.name || contact.email));
this.actionPromise = null;
}
async remove(details: EmergencyAccessGranteeDetailsResponse | EmergencyAccessGrantorDetailsResponse) {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('removeUserConfirmation'), details.name || details.email,
this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) {
return false;
}
try {
await this.apiService.deleteEmergencyAccess(details.id);
this.toasterService.popAsync('success', null, this.i18nService.t('removedUserId', details.name || details.email));
if (details instanceof EmergencyAccessGranteeDetailsResponse) {
this.removeGrantee(details);
} else {
this.removeGrantor(details);
}
} catch { }
}
async requestAccess(details: EmergencyAccessGrantorDetailsResponse) {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('requestAccessConfirmation', details.waitTimeDays.toString()),
details.name || details.email,
this.i18nService.t('requestAccess'),
this.i18nService.t('no'),
'warning'
);
if (!confirmed) {
return false;
}
await this.apiService.postEmergencyAccessInitiate(details.id);
details.status = EmergencyAccessStatusType.RecoveryInitiated;
this.toasterService.popAsync('success', null, this.i18nService.t('requestSent', details.name || details.email));
}
async approve(details: EmergencyAccessGranteeDetailsResponse) {
const type = this.i18nService.t(details.type === EmergencyAccessType.View ? 'view' : 'takeover');
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('approveAccessConfirmation', details.name, type),
details.name || details.email,
this.i18nService.t('approve'),
this.i18nService.t('no'),
'warning'
);
if (!confirmed) {
return false;
}
await this.apiService.postEmergencyAccessApprove(details.id);
details.status = EmergencyAccessStatusType.RecoveryApproved;
this.toasterService.popAsync('success', null, this.i18nService.t('emergencyApproved', details.name || details.email));
}
async reject(details: EmergencyAccessGranteeDetailsResponse) {
await this.apiService.postEmergencyAccessReject(details.id);
details.status = EmergencyAccessStatusType.Confirmed;
this.toasterService.popAsync('success', null, this.i18nService.t('emergencyRejected', details.name || details.email));
}
async takeover(details: EmergencyAccessGrantorDetailsResponse) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.addEditModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<EmergencyAccessTakeoverComponent>(
EmergencyAccessTakeoverComponent, this.takeoverModalRef);
childComponent.name = details != null ? details.name || details.email : null;
childComponent.email = details.email;
childComponent.emergencyAccessId = details != null ? details.id : null;
childComponent.onDone.subscribe(() => {
this.modal.close();
this.toasterService.popAsync('success', null, this.i18nService.t('passwordResetFor', details.name || details.email));
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
}
private removeGrantee(details: EmergencyAccessGranteeDetailsResponse) {
const index = this.trustedContacts.indexOf(details);
if (index > -1) {
this.trustedContacts.splice(index, 1);
}
}
private removeGrantor(details: EmergencyAccessGrantorDetailsResponse) {
const index = this.grantedContacts.indexOf(details);
if (index > -1) {
this.grantedContacts.splice(index, 1);
}
}
// Encrypt the master password hash using the grantees public key, and send it to bitwarden for escrow.
private async doConfirmation(details: EmergencyAccessGranteeDetailsResponse) {
const encKey = await this.cryptoService.getEncKey();
const publicKeyResponse = await this.apiService.getUserPublicKey(details.granteeId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
try {
// tslint:disable-next-line
console.log('User\'s fingerprint: ' +
(await this.cryptoService.getFingerprint(details.granteeId, publicKey.buffer)).join('-'));
} catch { }
const encryptedKey = await this.cryptoService.rsaEncrypt(encKey.key, publicKey.buffer);
const request = new EmergencyAccessConfirmRequest();
request.key = encryptedKey.encryptedString;
await this.apiService.postEmergencyAccessConfirm(details.id, request);
}
}

View File

@@ -0,0 +1,47 @@
import { Component } from '@angular/core';
import { AuditService } from 'jslib/abstractions/audit.service';
import { CipherService } from 'jslib/abstractions/cipher.service';
import { CollectionService } from 'jslib/abstractions/collection.service';
import { EventService } from 'jslib/abstractions/event.service';
import { FolderService } from 'jslib/abstractions/folder.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PasswordGenerationService } from 'jslib/abstractions/passwordGeneration.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { PolicyService } from 'jslib/abstractions/policy.service';
import { StateService } from 'jslib/abstractions/state.service';
import { TotpService } from 'jslib/abstractions/totp.service';
import { UserService } from 'jslib/abstractions/user.service';
import { Cipher } from 'jslib/models/domain/cipher';
import { AddEditComponent as BaseAddEditComponent } from '../vault/add-edit.component';
@Component({
selector: 'app-org-vault-add-edit',
templateUrl: '../vault/add-edit.component.html',
})
export class EmergencyAddEditComponent extends BaseAddEditComponent {
originalCipher: Cipher = null;
viewOnly = true;
constructor(cipherService: CipherService, folderService: FolderService,
i18nService: I18nService, platformUtilsService: PlatformUtilsService,
auditService: AuditService, stateService: StateService,
userService: UserService, collectionService: CollectionService,
totpService: TotpService, passwordGenerationService: PasswordGenerationService,
messagingService: MessagingService, eventService: EventService, policyService: PolicyService) {
super(cipherService, folderService, i18nService, platformUtilsService, auditService, stateService,
userService, collectionService, totpService, passwordGenerationService, messagingService,
eventService, policyService);
}
async load() {
this.title = this.i18nService.t('viewItem');
}
protected async loadCipher() {
return Promise.resolve(this.originalCipher);
}
}

View File

@@ -28,6 +28,9 @@
<a routerLink="domain-rules" class="list-group-item" routerLinkActive="active">
{{'domainRules' | i18n}}
</a>
<a routerLink="emergency-access" class="list-group-item" routerLinkActive="active">
{{'emergencyAccess' | i18n}}
</a>
</div>
</div>
</div>