diff --git a/.editorconfig b/.editorconfig index db6a4cc8b1..44e06dc87a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,6 @@ insert_final_newline = true charset = utf-8 indent_style = space indent_size = 4 + +[*.{ts}] +quote_type = single diff --git a/jslib b/jslib index 697e755c0f..573eea66ee 160000 --- a/jslib +++ b/jslib @@ -1 +1 @@ -Subproject commit 697e755c0f43119e0811e2c9452c0d9d925970eb +Subproject commit 573eea66eeff1a763e7ed0477fbba51e02c2c5b1 diff --git a/src/app/accounts/accept-emergency.component.html b/src/app/accounts/accept-emergency.component.html new file mode 100644 index 0000000000..c67f872c50 --- /dev/null +++ b/src/app/accounts/accept-emergency.component.html @@ -0,0 +1,35 @@ +
+
+ +

+ + {{'loading' | i18n}} +

+
+
+
+
+
+

{{'emergencyAccess' | i18n}}

+
+
+

+ {{name}} + {{email}} +

+

{{'acceptEmergencyAccess' | i18n}}

+
+ +
+
+
+
+
diff --git a/src/app/accounts/accept-emergency.component.ts b/src/app/accounts/accept-emergency.component.ts new file mode 100644 index 0000000000..36bd57bdd6 --- /dev/null +++ b/src/app/accounts/accept-emergency.component.ts @@ -0,0 +1,93 @@ +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; + +import { + Toast, + ToasterService, +} from 'angular2-toaster'; + +import { ApiService } from 'jslib/abstractions/api.service'; +import { I18nService } from 'jslib/abstractions/i18n.service'; +import { StateService } from 'jslib/abstractions/state.service'; +import { UserService } from 'jslib/abstractions/user.service'; +import { EmergencyAccessAcceptRequest } from 'jslib/models/request/emergencyAccessAcceptRequest'; + +@Component({ + selector: 'app-accept-emergency', + templateUrl: 'accept-emergency.component.html', +}) +export class AcceptEmergencyComponent implements OnInit { + loading = true; + authed = false; + name: string; + email: string; + actionPromise: Promise; + + constructor(private router: Router, private toasterService: ToasterService, + private i18nService: I18nService, private route: ActivatedRoute, + private apiService: ApiService, private userService: UserService, + private stateService: StateService) { } + + ngOnInit() { + let fired = false; + this.route.queryParams.subscribe(async (qParams) => { + if (fired) { + return; + } + fired = true; + await this.stateService.remove('emergencyInvitation'); + let error = qParams.id == null || qParams.name == null || qParams.email == null || qParams.token == null; + let errorMessage: string = null; + if (!error) { + this.authed = await this.userService.isAuthenticated(); + if (this.authed) { + const request = new EmergencyAccessAcceptRequest(); + request.token = qParams.token; + try { + this.actionPromise = this.apiService.postEmergencyAccessAccept(qParams.id, request); + await this.actionPromise; + const toast: Toast = { + type: 'success', + title: this.i18nService.t('inviteAccepted'), + body: this.i18nService.t('emergencyInviteAcceptedDesc'), + timeout: 10000, + }; + this.toasterService.popAsync(toast); + this.router.navigate(['/vault']); + } catch (e) { + error = true; + errorMessage = e.message; + } + } else { + await this.stateService.save('emergencyInvitation', qParams); + this.email = qParams.email; + this.name = qParams.name; + if (this.name != null) { + // Fix URL encoding of space issue with Angular + this.name = this.name.replace(/\+/g, ' '); + } + } + } + + if (error) { + const toast: Toast = { + type: 'error', + title: null, + body: errorMessage != null ? this.i18nService.t('emergencyInviteAcceptFailedShort', errorMessage) : + this.i18nService.t('emergencyInviteAcceptFailed'), + timeout: 10000, + }; + this.toasterService.popAsync(toast); + this.router.navigate(['/']); + } + + this.loading = false; + }); + } +} diff --git a/src/app/accounts/login.component.ts b/src/app/accounts/login.component.ts index 7a3a362b5b..6317d14919 100644 --- a/src/app/accounts/login.component.ts +++ b/src/app/accounts/login.component.ts @@ -52,9 +52,12 @@ export class LoginComponent extends BaseLoginComponent { } async goAfterLogIn() { - const invite = await this.stateService.get('orgInvitation'); - if (invite != null) { - this.router.navigate(['accept-organization'], { queryParams: invite }); + const orgInvite = await this.stateService.get('orgInvitation'); + const emergencyInvite = await this.stateService.get('emergencyInvitation'); + if (orgInvite != null) { + this.router.navigate(['accept-organization'], { queryParams: orgInvite }); + } else if (emergencyInvite != null) { + this.router.navigate(['accept-emergency'], { queryParams: emergencyInvite }); } else { const loginRedirect = await this.stateService.get('loginRedirect'); if (loginRedirect != null) { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 34f3ba14f7..cf40fcc0aa 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -8,6 +8,7 @@ import { FrontendLayoutComponent } from './layouts/frontend-layout.component'; import { OrganizationLayoutComponent } from './layouts/organization-layout.component'; import { UserLayoutComponent } from './layouts/user-layout.component'; +import { AcceptEmergencyComponent } from './accounts/accept-emergency.component'; import { AcceptOrganizationComponent } from './accounts/accept-organization.component'; import { HintComponent } from './accounts/hint.component'; import { LockComponent } from './accounts/lock.component'; @@ -91,6 +92,8 @@ import { UnauthGuardService } from './services/unauth-guard.service'; import { AuthGuardService } from 'jslib/angular/services/auth-guard.service'; import { OrganizationUserType } from 'jslib/enums/organizationUserType'; +import { EmergencyAccessComponent } from './settings/emergency-access.component'; +import { EmergencyAccessViewComponent } from './settings/emergency-access-view.component'; const routes: Routes = [ { @@ -125,6 +128,11 @@ const routes: Routes = [ component: AcceptOrganizationComponent, data: { titleId: 'joinOrganization' }, }, + { + path: 'accept-emergency', + component: AcceptEmergencyComponent, + data: { titleId: 'acceptEmergency' }, + }, { path: 'recover', pathMatch: 'full', redirectTo: 'recover-2fa' }, { path: 'recover-2fa', @@ -180,6 +188,21 @@ const routes: Routes = [ component: CreateOrganizationComponent, data: { titleId: 'newOrganization' }, }, + { + path: 'emergency-access', + children: [ + { + path: '', + component: EmergencyAccessComponent, + data: { titleId: 'emergencyAccess'}, + }, + { + path: ':id', + component: EmergencyAccessViewComponent, + data: { titleId: 'emergencyAccess'}, + }, + ], + }, ], }, { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 01481f05d4..2ea1a4a01c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -27,6 +27,7 @@ import { NavbarComponent } from './layouts/navbar.component'; import { OrganizationLayoutComponent } from './layouts/organization-layout.component'; import { UserLayoutComponent } from './layouts/user-layout.component'; +import { AcceptEmergencyComponent } from './accounts/accept-emergency.component'; import { AcceptOrganizationComponent } from './accounts/accept-organization.component'; import { HintComponent } from './accounts/hint.component'; import { LockComponent } from './accounts/lock.component'; @@ -112,6 +113,12 @@ import { CreateOrganizationComponent } from './settings/create-organization.comp import { DeauthorizeSessionsComponent } from './settings/deauthorize-sessions.component'; import { DeleteAccountComponent } from './settings/delete-account.component'; import { DomainRulesComponent } from './settings/domain-rules.component'; +import { EmergencyAccessAddEditComponent } from './settings/emergency-access-add-edit.component'; +import { EmergencyAccessComponent } from './settings/emergency-access.component'; +import { EmergencyAccessConfirmComponent } from './settings/emergency-access-confirm.component'; +import { EmergencyAccessTakeoverComponent } from './settings/emergency-access-takeover.component'; +import { EmergencyAccessViewComponent } from './settings/emergency-access-view.component'; +import { EmergencyAddEditComponent } from './settings/emergency-add-edit.component'; import { LinkSsoComponent } from './settings/link-sso.component'; import { OptionsComponent } from './settings/options.component'; import { OrganizationPlansComponent } from './settings/organization-plans.component'; @@ -258,6 +265,7 @@ registerLocaleData(localeZhTw, 'zh-TW'); ], declarations: [ A11yTitleDirective, + AcceptEmergencyComponent, AccessComponent, AcceptOrganizationComponent, AccountComponent, @@ -295,6 +303,12 @@ registerLocaleData(localeZhTw, 'zh-TW'); DeleteOrganizationComponent, DomainRulesComponent, DownloadLicenseComponent, + EmergencyAccessAddEditComponent, + EmergencyAccessComponent, + EmergencyAccessConfirmComponent, + EmergencyAccessTakeoverComponent, + EmergencyAccessViewComponent, + EmergencyAddEditComponent, ExportComponent, ExposedPasswordsReportComponent, FallbackSrcDirective, @@ -409,6 +423,10 @@ registerLocaleData(localeZhTw, 'zh-TW'); DeauthorizeSessionsComponent, DeleteAccountComponent, DeleteOrganizationComponent, + EmergencyAccessAddEditComponent, + EmergencyAccessConfirmComponent, + EmergencyAccessTakeoverComponent, + EmergencyAddEditComponent, FolderAddEditComponent, ModalComponent, OrgAddEditComponent, diff --git a/src/app/settings/change-password.component.ts b/src/app/settings/change-password.component.ts index c46b4fd4a7..32bc124889 100644 --- a/src/app/settings/change-password.component.ts +++ b/src/app/settings/change-password.component.ts @@ -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); + } } } diff --git a/src/app/settings/emergency-access-add-edit.component.html b/src/app/settings/emergency-access-add-edit.component.html new file mode 100644 index 0000000000..6f62beca3d --- /dev/null +++ b/src/app/settings/emergency-access-add-edit.component.html @@ -0,0 +1,76 @@ + diff --git a/src/app/settings/emergency-access-add-edit.component.ts b/src/app/settings/emergency-access-add-edit.component.ts new file mode 100644 index 0000000000..14210d7969 --- /dev/null +++ b/src/app/settings/emergency-access-add-edit.component.ts @@ -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; + + 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(); + } +} diff --git a/src/app/settings/emergency-access-confirm.component.html b/src/app/settings/emergency-access-confirm.component.html new file mode 100644 index 0000000000..7693d05ac7 --- /dev/null +++ b/src/app/settings/emergency-access-confirm.component.html @@ -0,0 +1,38 @@ + diff --git a/src/app/settings/emergency-access-confirm.component.ts b/src/app/settings/emergency-access-confirm.component.ts new file mode 100644 index 0000000000..46ccfcb0a4 --- /dev/null +++ b/src/app/settings/emergency-access-confirm.component.ts @@ -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 { } + } +} diff --git a/src/app/settings/emergency-access-takeover.component.html b/src/app/settings/emergency-access-takeover.component.html new file mode 100644 index 0000000000..23a27703c5 --- /dev/null +++ b/src/app/settings/emergency-access-takeover.component.html @@ -0,0 +1,44 @@ + diff --git a/src/app/settings/emergency-access-takeover.component.ts b/src/app/settings/emergency-access-takeover.component.ts new file mode 100644 index 0000000000..235036c9dc --- /dev/null +++ b/src/app/settings/emergency-access-takeover.component.ts @@ -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; + + 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 { } + } +} diff --git a/src/app/settings/emergency-access-view.component.html b/src/app/settings/emergency-access-view.component.html new file mode 100644 index 0000000000..4b44896b9b --- /dev/null +++ b/src/app/settings/emergency-access-view.component.html @@ -0,0 +1,31 @@ + +
+ + + + + + + + +
+ + + {{c.name}} + + + {{'shared' | i18n}} + + + + {{'attachments' | i18n}} + +
+ {{c.subTitle}} +
+
+
+ diff --git a/src/app/settings/emergency-access-view.component.ts b/src/app/settings/emergency-access-view.component.ts new file mode 100644 index 0000000000..f1313b5419 --- /dev/null +++ b/src/app/settings/emergency-access-view.component.ts @@ -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, 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 { + 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; + } +} diff --git a/src/app/settings/emergency-access.component.html b/src/app/settings/emergency-access.component.html new file mode 100644 index 0000000000..d2225d85f3 --- /dev/null +++ b/src/app/settings/emergency-access.component.html @@ -0,0 +1,159 @@ + +

+ {{'emergencyAccessDesc' | i18n}} + + {{'learnMore' | i18n}}. + +

+ + + + + + + + + + + +
+ + + {{c.email}} + {{'invited' | i18n}} + {{'accepted' | i18n}} + {{'emergencyAccessRecoveryInitiated' | i18n}} + {{'emergencyAccessRecoveryApproved' | i18n}} + + {{'view' | i18n}} + {{'takeover' | i18n}} + + {{c.name}} + + +
+ +

{{'noTrustedContacts' | i18n}}

+ + + + + + + + + + + +
+ + + {{c.email}} + {{'invited' | i18n}} + {{'accepted' | i18n}} + {{'emergencyAccessRecoveryInitiated' | i18n}} + {{'emergencyAccessRecoveryApproved' | i18n}} + + {{'view' | i18n}} + {{'takeover' | i18n}} + + {{c.name}} + + +
+ +

{{'noGrantedAccess' | i18n}}

+ + + + diff --git a/src/app/settings/emergency-access.component.ts b/src/app/settings/emergency-access.component.ts new file mode 100644 index 0000000000..e463c85eca --- /dev/null +++ b/src/app/settings/emergency-access.component.ts @@ -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; + + 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, 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(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, 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, 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); + } +} diff --git a/src/app/settings/emergency-add-edit.component.ts b/src/app/settings/emergency-add-edit.component.ts new file mode 100644 index 0000000000..63098bc4e4 --- /dev/null +++ b/src/app/settings/emergency-add-edit.component.ts @@ -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); + } +} diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 2ed3911852..c177552963 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -28,6 +28,9 @@ {{'domainRules' | i18n}} + + {{'emergencyAccess' | i18n}} + diff --git a/src/app/vault/add-edit.component.html b/src/app/vault/add-edit.component.html index 6f93cdb706..e542763580 100644 --- a/src/app/vault/add-edit.component.html +++ b/src/app/vault/add-edit.component.html @@ -9,7 +9,7 @@ - + {{'newCustomField' | i18n}} -
+
+ [disabled]="cipher.isDeleted || viewOnly">
@@ -469,7 +469,7 @@
+ id="collection-{{i}}" name="Collection[{{i}}].Checked" [disabled]="cipher.isDeleted || viewOnly">
@@ -500,14 +500,14 @@