-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[PM-16603] Implement userkey rotation v2 #12646
base: main
Are you sure you want to change the base?
Changes from all commits
d4caf6f
4e3a44e
9cae88a
e3cd3cd
b2e3759
b1f50e6
098161f
de68e21
77fa885
0ac616b
90729a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,6 +13,8 @@ | |
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; | ||
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; | ||
import { getUserId } from "@bitwarden/common/auth/services/account.service"; | ||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; | ||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; | ||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; | ||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; | ||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; | ||
|
@@ -35,11 +37,13 @@ | |
extends BaseChangePasswordComponent | ||
implements OnInit, OnDestroy | ||
{ | ||
loading = false; | ||
rotateUserKey = false; | ||
currentMasterPassword: string; | ||
masterPasswordHint: string; | ||
checkForBreaches = true; | ||
characterMinimumMessage = ""; | ||
userkeyRotationV2 = false; | ||
|
||
constructor( | ||
i18nService: I18nService, | ||
|
@@ -56,9 +60,10 @@ | |
private userVerificationService: UserVerificationService, | ||
private keyRotationService: UserKeyRotationService, | ||
kdfConfigService: KdfConfigService, | ||
masterPasswordService: InternalMasterPasswordServiceAbstraction, | ||
protected masterPasswordService: InternalMasterPasswordServiceAbstraction, | ||
accountService: AccountService, | ||
toastService: ToastService, | ||
private configService: ConfigService, | ||
) { | ||
super( | ||
i18nService, | ||
|
@@ -75,6 +80,8 @@ | |
} | ||
|
||
async ngOnInit() { | ||
this.userkeyRotationV2 = await this.configService.getFeatureFlag(FeatureFlag.UserKeyRotationV2); | ||
|
||
if (!(await this.userVerificationService.hasMasterPassword())) { | ||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. | ||
// eslint-disable-next-line @typescript-eslint/no-floating-promises | ||
|
@@ -135,6 +142,132 @@ | |
} | ||
|
||
async submit() { | ||
if (this.userkeyRotationV2) { | ||
await this.submitNew(); | ||
} else { | ||
await this.submitOld(); | ||
} | ||
} | ||
|
||
async submitNew() { | ||
if (this.currentMasterPassword == null || this.currentMasterPassword === "") { | ||
this.toastService.showToast({ | ||
variant: "error", | ||
title: this.i18nService.t("errorOccurred"), | ||
message: this.i18nService.t("masterPasswordRequired"), | ||
}); | ||
return false; | ||
} | ||
|
||
this.loading = true; | ||
if ( | ||
this.masterPasswordHint != null && | ||
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase() | ||
) { | ||
this.toastService.showToast({ | ||
variant: "error", | ||
title: this.i18nService.t("errorOccurred"), | ||
message: this.i18nService.t("hintEqualsPassword"), | ||
}); | ||
this.loading = false; | ||
return; | ||
} | ||
|
||
this.leakedPassword = false; | ||
if (this.checkForBreaches) { | ||
this.leakedPassword = (await this.auditService.passwordLeaked(this.masterPassword)) > 0; | ||
} | ||
|
||
if (!(await this.strongPassword())) { | ||
this.loading = false; | ||
return; | ||
} | ||
|
||
try { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not seeing where this check that was getting ran in the old flow
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since I split the two submit flows now, I added this on top of the new one. |
||
if (this.rotateUserKey) { | ||
await this.syncService.fullSync(true); | ||
const user = await firstValueFrom(this.accountService.activeAccount$); | ||
await this.keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( | ||
this.currentMasterPassword, | ||
this.masterPassword, | ||
user, | ||
this.masterPasswordHint, | ||
); | ||
} else { | ||
await this.updatePassword(this.masterPassword); | ||
} | ||
} catch (e) { | ||
this.toastService.showToast({ | ||
variant: "error", | ||
title: this.i18nService.t("errorOccurred"), | ||
message: e.message, | ||
}); | ||
} | ||
this.loading = false; | ||
} | ||
|
||
// todo: move this to a service | ||
JaredSnider-Bitwarden marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// https://bitwarden.atlassian.net/browse/PM-17108 | ||
private async updatePassword(newMasterPassword: string) { | ||
const currentMasterPassword = this.currentMasterPassword; | ||
const { userId, email } = await firstValueFrom( | ||
this.accountService.activeAccount$.pipe(map((a) => ({ userId: a?.id, email: a?.email }))), | ||
); | ||
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId)); | ||
|
||
const currentMasterKey = await this.keyService.makeMasterKey( | ||
currentMasterPassword, | ||
email, | ||
kdfConfig, | ||
); | ||
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( | ||
currentMasterKey, | ||
userId, | ||
); | ||
if (decryptedUserKey == null) { | ||
this.toastService.showToast({ | ||
variant: "error", | ||
title: null, | ||
message: this.i18nService.t("invalidMasterPassword"), | ||
}); | ||
return; | ||
} | ||
|
||
const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig); | ||
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey( | ||
newMasterKey, | ||
decryptedUserKey, | ||
); | ||
|
||
const request = new PasswordRequest(); | ||
request.masterPasswordHash = await this.keyService.hashMasterKey( | ||
this.currentMasterPassword, | ||
currentMasterKey, | ||
); | ||
request.masterPasswordHint = this.masterPasswordHint; | ||
request.newMasterPasswordHash = await this.keyService.hashMasterKey( | ||
newMasterPassword, | ||
newMasterKey, | ||
); | ||
request.key = newMasterKeyEncryptedUserKey[1].encryptedString; | ||
try { | ||
await this.apiService.postPassword(request); | ||
this.toastService.showToast({ | ||
variant: "success", | ||
title: this.i18nService.t("masterPasswordChanged"), | ||
message: this.i18nService.t("masterPasswordChangedDesc"), | ||
}); | ||
this.messagingService.send("logout"); | ||
} catch { | ||
this.toastService.showToast({ | ||
variant: "error", | ||
title: null, | ||
message: this.i18nService.t("errorOccurred"), | ||
}); | ||
} | ||
} | ||
|
||
async submitOld() { | ||
if ( | ||
this.masterPasswordHint != null && | ||
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase() | ||
|
@@ -240,6 +373,6 @@ | |
|
||
private async updateKey() { | ||
const user = await firstValueFrom(this.accountService.activeAccount$); | ||
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user); | ||
await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(this.masterPassword, user); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
export class AccountKeysRequest { | ||
// Other keys encrypted by the userkey | ||
userKeyEncryptedAccountPrivateKey: string; | ||
accountPublicKey: string; | ||
|
||
constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) { | ||
this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey; | ||
this.accountPublicKey = accountPublicKey; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { Argon2KdfConfig, KdfConfig, KdfType } from "@bitwarden/key-management"; | ||
|
||
export class MasterPasswordUnlockDataRequest { | ||
kdfType: KdfType = KdfType.PBKDF2_SHA256; | ||
kdfIterations: number = 0; | ||
kdfMemory?: number; | ||
kdfParallelism?: number; | ||
|
||
email: string; | ||
masterKeyAuthenticationHash: string; | ||
|
||
masterKeyEncryptedUserKey: string; | ||
|
||
masterPasswordHint?: string; | ||
|
||
constructor( | ||
kdfConfig: KdfConfig, | ||
email: string, | ||
masterKeyAuthenticationHash: string, | ||
masterKeyEncryptedUserKey: string, | ||
masterPasswordHash?: string, | ||
) { | ||
this.kdfType = kdfConfig.kdfType; | ||
this.kdfIterations = kdfConfig.iterations; | ||
if (kdfConfig.kdfType === KdfType.Argon2id) { | ||
this.kdfMemory = (kdfConfig as Argon2KdfConfig).memory; | ||
this.kdfParallelism = (kdfConfig as Argon2KdfConfig).parallelism; | ||
Check warning on line 27 in apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts Codecov / codecov/patchapps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts#L26-L27
|
||
} | ||
|
||
this.email = email; | ||
this.masterKeyAuthenticationHash = masterKeyAuthenticationHash; | ||
this.masterKeyEncryptedUserKey = masterKeyEncryptedUserKey; | ||
this.masterPasswordHint = masterPasswordHash; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { AccountKeysRequest } from "./account-keys.request"; | ||
import { UnlockDataRequest } from "./unlock-data.request"; | ||
import { UserDataRequest as AccountDataRequest } from "./userdata.request"; | ||
|
||
export class RotateUserAccountKeysRequest { | ||
constructor( | ||
accountUnlockData: UnlockDataRequest, | ||
accountKeys: AccountKeysRequest, | ||
accountData: AccountDataRequest, | ||
oldMasterKeyAuthenticationHash: string, | ||
) { | ||
this.accountUnlockData = accountUnlockData; | ||
this.accountKeys = accountKeys; | ||
this.accountData = accountData; | ||
this.oldMasterKeyAuthenticationHash = oldMasterKeyAuthenticationHash; | ||
} | ||
|
||
// Authentication for the request | ||
oldMasterKeyAuthenticationHash: string; | ||
|
||
// All methods to get to the userkey | ||
accountUnlockData: UnlockDataRequest; | ||
|
||
// Other keys encrypted by the userkey | ||
accountKeys: AccountKeysRequest; | ||
|
||
// User vault data encrypted by the userkey | ||
accountData: AccountDataRequest; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common"; | ||
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; | ||
|
||
import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request"; | ||
|
||
import { MasterPasswordUnlockDataRequest } from "./master-password-unlock-data.request"; | ||
|
||
export class UnlockDataRequest { | ||
// All methods to get to the userkey | ||
masterPasswordUnlockData: MasterPasswordUnlockDataRequest; | ||
emergencyAccessUnlockData: EmergencyAccessWithIdRequest[]; | ||
organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[]; | ||
passkeyUnlockData: WebauthnRotateCredentialRequest[]; | ||
|
||
constructor( | ||
masterPasswordUnlockData: MasterPasswordUnlockDataRequest, | ||
emergencyAccessUnlockData: EmergencyAccessWithIdRequest[], | ||
organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[], | ||
passkeyUnlockData: WebauthnRotateCredentialRequest[], | ||
) { | ||
this.masterPasswordUnlockData = masterPasswordUnlockData; | ||
this.emergencyAccessUnlockData = emergencyAccessUnlockData; | ||
this.organizationAccountRecoveryUnlockData = organizationAccountRecoveryUnlockData; | ||
this.passkeyUnlockData = passkeyUnlockData; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; | ||
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request"; | ||
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; | ||
|
||
export class UserDataRequest { | ||
ciphers: CipherWithIdRequest[]; | ||
folders: FolderWithIdRequest[]; | ||
sends: SendWithIdRequest[]; | ||
|
||
constructor( | ||
ciphers: CipherWithIdRequest[], | ||
folders: FolderWithIdRequest[], | ||
sends: SendWithIdRequest[], | ||
) { | ||
this.ciphers = ciphers; | ||
this.folders = folders; | ||
this.sends = sends; | ||
Comment on lines
+15
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since our API design is expecting empty arrays should we add in some handling for null and undefined here to make sure we only pass empty arrays or the appropriate array of data objects? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checking in the rotateUserKeyMasterPasswordAndEncryptedData now, and throwing if any of them is null. |
||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally, when we're writing things behind a feature flag, we don't want to be making significant changes to pre-existing logic/code.
๐ญ What about branching off the feature flag at a higher level?
This would also make it a good time to extract what we think should be in its on service and use the new service only in v2.