-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(salt): [PM-27060] #22381
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
base: main
Are you sure you want to change the base?
feat(salt): [PM-27060] #22381
Changes from all commits
525e9f6
2d0be58
97aebb2
41847d1
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 |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { | |
| } from "@bitwarden/common/auth/password-prelogin"; | ||
| import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; | ||
| import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; | ||
| import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; | ||
| import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; | ||
| import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; | ||
| import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; | ||
|
|
@@ -51,6 +52,9 @@ import { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-log | |
| const email = "hello@world.com"; | ||
| const masterPassword = "password"; | ||
| const hashedPassword = "HASHED_PASSWORD"; | ||
| // Deliberately not equal to `email`. The whole point of PM-27060 is that the server dictates the | ||
| // KDF salt, so a fixture where salt === email would pass no matter which one the strategy used. | ||
| const preloginSalt = "server.normalized+salt@world.com"; | ||
| const masterKey = new SymmetricCryptoKey( | ||
| Utils.fromB64ToArray( | ||
| "N2KWjlLpfi5uHjv+YcfUKIpZ1l+W+6HRensmIqD+BFYBf6N/dvFpJfWwYnVBdgFCK2tJTAIMLhqzIQQEUmGFgg==", | ||
|
|
@@ -62,6 +66,17 @@ const masterPasswordPolicyResponse = new MasterPasswordPolicyResponse({ | |
| EnforceOnLogin: true, | ||
| MinLength: 8, | ||
| }); | ||
| const kdfConfig = PBKDF2KdfConfig.createDefault(); | ||
|
|
||
| function credentialsWithPrefetchedData(salt: string = preloginSalt) { | ||
| return new PasswordLoginCredentials( | ||
| email, | ||
| masterPassword, | ||
| undefined, | ||
| undefined, | ||
| new PasswordPreloginData(kdfConfig, salt), | ||
| ); | ||
| } | ||
|
|
||
| describe("PasswordLoginStrategy", () => { | ||
| let accountService: FakeAccountService; | ||
|
|
@@ -127,10 +142,14 @@ describe("PasswordLoginStrategy", () => { | |
| }); | ||
|
|
||
| passwordPreloginService.getPreloginData$.mockReturnValue( | ||
| of(new PasswordPreloginData(PBKDF2KdfConfig.createDefault())), | ||
| of(new PasswordPreloginData(PBKDF2KdfConfig.createDefault(), preloginSalt)), | ||
| ); | ||
| legacyCompatKeyService.makeMasterKey.mockResolvedValue(masterKey); | ||
|
|
||
| // Default to the flag off so the pre-PM-27060 behavior stays the baseline; tests that exercise | ||
| // the SDK prelogin path opt in explicitly. | ||
| configService.getFeatureFlag.mockResolvedValue(false); | ||
|
|
||
| legacyCompatKeyService.hashMasterKey | ||
| .calledWith(masterPassword, expect.anything()) | ||
| .mockResolvedValue(hashedPassword); | ||
|
|
@@ -231,14 +250,7 @@ describe("PasswordLoginStrategy", () => { | |
| }); | ||
|
|
||
| it("does not call getPreloginData$ when preFetchedPreloginData is provided", async () => { | ||
| const preloginData = new PasswordPreloginData(PBKDF2KdfConfig.createDefault()); | ||
| const credentialsWithPrefetch = new PasswordLoginCredentials( | ||
| email, | ||
| masterPassword, | ||
| undefined, | ||
| undefined, | ||
| preloginData, | ||
| ); | ||
| const credentialsWithPrefetch = credentialsWithPrefetchedData(); | ||
|
|
||
| await passwordLoginStrategy.logIn(credentialsWithPrefetch); | ||
|
|
||
|
|
@@ -260,6 +272,129 @@ describe("PasswordLoginStrategy", () => { | |
|
|
||
| expect(passwordPreloginService.clearCache).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| // PM-27060: when prelogin comes from the SDK, the server dictates the KDF salt and the client | ||
| // must derive the master key from it rather than from the email the user typed. The flag gates | ||
| // this so it can be switched off if normalization diverges during the transition. | ||
| describe("salt selection", () => { | ||
| it("reads the PM27060_PasswordPreloginFromSdk flag", async () => { | ||
| await passwordLoginStrategy.logIn(credentials); | ||
|
|
||
| expect(configService.getFeatureFlag).toHaveBeenCalledWith( | ||
| FeatureFlag.PM27060_PasswordPreloginFromSdk, | ||
| ); | ||
| }); | ||
|
|
||
| describe("when the flag is on", () => { | ||
| beforeEach(() => { | ||
| configService.getFeatureFlag.mockResolvedValue(true); | ||
| }); | ||
|
|
||
| it("derives the master key from the prelogin salt for prefetched data", async () => { | ||
| await passwordLoginStrategy.logIn(credentialsWithPrefetchedData()); | ||
|
|
||
| expect(legacyCompatKeyService.makeMasterKey).toHaveBeenCalledWith( | ||
| masterPassword, | ||
| preloginSalt, | ||
| kdfConfig, | ||
| ); | ||
| expect(legacyCompatKeyService.makeMasterKey).not.toHaveBeenCalledWith( | ||
| masterPassword, | ||
| email, | ||
| expect.anything(), | ||
| ); | ||
| }); | ||
|
|
||
| it("derives the master key from the prelogin salt for freshly fetched data", async () => { | ||
| // credentials from the outer beforeEach carries no prefetched data, so the strategy | ||
| // fetches via passwordPreloginService, which is stubbed to return preloginSalt. | ||
| await passwordLoginStrategy.logIn(credentials); | ||
|
|
||
| expect(legacyCompatKeyService.makeMasterKey).toHaveBeenCalledWith( | ||
| masterPassword, | ||
| preloginSalt, | ||
| PBKDF2KdfConfig.createDefault(), | ||
| ); | ||
| expect(legacyCompatKeyService.makeMasterKey).not.toHaveBeenCalledWith( | ||
| masterPassword, | ||
| email, | ||
| expect.anything(), | ||
| ); | ||
| }); | ||
|
|
||
| it("passes the salt through verbatim without re-normalizing it", async () => { | ||
| // The server owns normalization under PM-27060. Trimming or lower-casing here would | ||
| // produce a different master key than the one the vault was encrypted with. | ||
| const unnormalizedSalt = " MiXeD.Case@World.Com "; | ||
|
|
||
| await passwordLoginStrategy.logIn(credentialsWithPrefetchedData(unnormalizedSalt)); | ||
|
|
||
| expect(legacyCompatKeyService.makeMasterKey).toHaveBeenCalledWith( | ||
| masterPassword, | ||
| unnormalizedSalt, | ||
| kdfConfig, | ||
| ); | ||
| }); | ||
|
JaredSnider-Bitwarden marked this conversation as resolved.
|
||
|
|
||
| it("passes an absent salt straight through", async () => { | ||
| // PM-28143: Salt is nullable server-side during the transition. This documents current | ||
| // behavior โ the strategy does not fall back to the email and does not throw. | ||
| // Built inline rather than via the helper, whose default parameter would substitute | ||
| // preloginSalt for an explicit undefined. | ||
| const noSaltCredentials = new PasswordLoginCredentials( | ||
| email, | ||
| masterPassword, | ||
| undefined, | ||
| undefined, | ||
| new PasswordPreloginData(kdfConfig, undefined as unknown as string), | ||
| ); | ||
|
|
||
| await passwordLoginStrategy.logIn(noSaltCredentials); | ||
|
|
||
| expect(legacyCompatKeyService.makeMasterKey).toHaveBeenCalledWith( | ||
| masterPassword, | ||
| undefined, | ||
| kdfConfig, | ||
| ); | ||
| }); | ||
|
Comment on lines
+339
to
+359
Contributor
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. |
||
| }); | ||
|
|
||
| describe("when the flag is off", () => { | ||
| beforeEach(() => { | ||
| configService.getFeatureFlag.mockResolvedValue(false); | ||
| }); | ||
|
|
||
| it("derives the master key from the entered email for prefetched data", async () => { | ||
| await passwordLoginStrategy.logIn(credentialsWithPrefetchedData()); | ||
|
|
||
| expect(legacyCompatKeyService.makeMasterKey).toHaveBeenCalledWith( | ||
| masterPassword, | ||
| email, | ||
| kdfConfig, | ||
| ); | ||
| expect(legacyCompatKeyService.makeMasterKey).not.toHaveBeenCalledWith( | ||
| masterPassword, | ||
| preloginSalt, | ||
| expect.anything(), | ||
| ); | ||
| }); | ||
|
|
||
| it("derives the master key from the entered email for freshly fetched data", async () => { | ||
| await passwordLoginStrategy.logIn(credentials); | ||
|
|
||
| expect(legacyCompatKeyService.makeMasterKey).toHaveBeenCalledWith( | ||
| masterPassword, | ||
| email, | ||
| PBKDF2KdfConfig.createDefault(), | ||
| ); | ||
| expect(legacyCompatKeyService.makeMasterKey).not.toHaveBeenCalledWith( | ||
| masterPassword, | ||
| preloginSalt, | ||
| expect.anything(), | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("evaluateMasterPasswordIfRequired", () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { | |
| PasswordPreloginData, | ||
| PasswordPreloginService, | ||
| } from "@bitwarden/common/auth/password-prelogin"; | ||
| import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; | ||
| import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; | ||
| import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; | ||
| import { UserId } from "@bitwarden/common/types/guid"; | ||
|
|
@@ -136,13 +137,29 @@ export class PasswordLoginStrategy extends LoginStrategy { | |
| email: string, | ||
| preFetchedPreloginData?: PasswordPreloginData, | ||
| ): Promise<MasterKey> { | ||
| const useSdkForPrelogin = await this.configService.getFeatureFlag( | ||
| FeatureFlag.PM27060_PasswordPreloginFromSdk, | ||
| ); | ||
|
|
||
| // if we have prefetched prelogin data, use it | ||
| if (preFetchedPreloginData) { | ||
| return this.legacyCompatKeyService.makeMasterKey( | ||
| masterPassword, | ||
| email, | ||
| preFetchedPreloginData.kdfConfig, | ||
| ); | ||
| // If we are using the sdk to fetch the prelogin data, only then do we want to | ||
| // use the salt that is passed back from the prelogin response in building the master key. | ||
| // This gives us the ability to turn off the feature of using the returned salt from salt | ||
| // in the event of bad normalization occurring during the transition. | ||
| if (useSdkForPrelogin) { | ||
| return this.legacyCompatKeyService.makeMasterKey( | ||
| masterPassword, | ||
| preFetchedPreloginData.salt, | ||
| preFetchedPreloginData.kdfConfig, | ||
| ); | ||
|
Comment on lines
+150
to
+155
Contributor
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. โ QUESTION: Details
// libs/legacy-crypto/src/services/legacy-compat-key.service.ts:86
email = email.trim().toLowerCase();So the server-supplied salt is not used verbatim, despite the spec comment ("passes the salt through verbatim without re-normalizing itโฆ Trimming or lower-casing here would produce a different master key"). If the server ever dictates a salt whose casing or whitespace differs from If server normalization is guaranteed to match, consider updating the spec comment so it doesn't assert a guarantee the runtime doesn't provide. If not, the salt needs a derivation path that skips the legacy normalization.
Contributor
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. @Patrick-Pimentel-Bitwarden , this is something we have to consider. |
||
| } else { | ||
| return this.legacyCompatKeyService.makeMasterKey( | ||
| masterPassword, | ||
| email, | ||
| preFetchedPreloginData.kdfConfig, | ||
| ); | ||
| } | ||
|
Comment on lines
+150
to
+162
Contributor
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. ๐จ : It's a bit more clear to me if we use this ternary construction + many less lines of code. |
||
| } | ||
|
|
||
| // No prefetched data โ fetch now. PasswordPreloginData.fromResponse validates the KDF config. | ||
|
|
@@ -152,7 +169,23 @@ export class PasswordLoginStrategy extends LoginStrategy { | |
| throw new Error("KDF config is required"); | ||
| } | ||
|
|
||
| return this.legacyCompatKeyService.makeMasterKey(masterPassword, email, preloginData.kdfConfig); | ||
| // If we are using the sdk to fetch the prelogin data, only then do we want to | ||
| // use the salt that is passed back from the prelogin response in building the master key. | ||
| // This gives us the ability to turn off the feature of using the returned salt from salt | ||
| // in the event of bad normalization occurring during the transition. | ||
| if (useSdkForPrelogin) { | ||
| return this.legacyCompatKeyService.makeMasterKey( | ||
| masterPassword, | ||
| preloginData.salt, | ||
| preloginData.kdfConfig, | ||
| ); | ||
| } else { | ||
| return this.legacyCompatKeyService.makeMasterKey( | ||
| masterPassword, | ||
| email, | ||
| preloginData.kdfConfig, | ||
| ); | ||
| } | ||
|
Comment on lines
+172
to
+188
Contributor
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. |
||
| } | ||
|
|
||
| private async evaluateMasterPasswordIfRequired( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,17 +36,26 @@ describe("DefaultPasswordPreloginService", () => { | |
| const emailB = "b@example.com"; | ||
| const identityUrl = "https://identity.bitwarden.com"; | ||
|
|
||
| // The API and SDK paths return different salts so tests can prove which source was used. | ||
| const apiSalt = "api-salt"; | ||
| const sdkSalt = "sdk-salt"; | ||
|
|
||
| // PBKDF2 is used as a stand-in throughout; KDF type coverage is in password-prelogin.model.spec.ts. | ||
| const response = new PasswordPreloginResponse({ | ||
| Kdf: 0, | ||
| KdfIterations: PBKDF2KdfConfig.ITERATIONS.defaultValue, | ||
| KdfSettings: { KdfType: 0, Iterations: PBKDF2KdfConfig.ITERATIONS.defaultValue }, | ||
| Salt: apiSalt, | ||
| }); | ||
| const sdkResponse: SdkPasswordPreloginResponse = { | ||
| kdf: { pBKDF2: { iterations: PBKDF2KdfConfig.ITERATIONS.defaultValue } }, | ||
| salt: "test-salt", | ||
| salt: sdkSalt, | ||
| }; | ||
| const expectedData = new PasswordPreloginData( | ||
| new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue), | ||
| apiSalt, | ||
| ); | ||
| const expectedSdkData = new PasswordPreloginData( | ||
| new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue), | ||
| sdkSalt, | ||
| ); | ||
|
|
||
| beforeEach(() => { | ||
|
|
@@ -93,10 +102,45 @@ describe("DefaultPasswordPreloginService", () => { | |
|
|
||
| const result = await firstValueFrom(sut.getPreloginData$(email)); | ||
|
|
||
| expect(result).toEqual(expectedData); | ||
| expect(result).toEqual(expectedSdkData); | ||
| expect(apiService.getPreloginData).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("carries the SDK-supplied salt onto the model when the flag is on", async () => { | ||
| configService.getFeatureFlag.mockResolvedValue(true); | ||
|
|
||
| const result = await firstValueFrom(sut.getPreloginData$(email)); | ||
|
|
||
| // PasswordLoginStrategy derives the master key from this salt when the flag is on, so it | ||
| // must come from the SDK response rather than the email the caller passed in. | ||
| expect(result.salt).toBe(sdkSalt); | ||
| expect(result.salt).not.toBe(email); | ||
| }); | ||
|
|
||
| it("carries the API-supplied salt onto the model when the flag is off", async () => { | ||
| const result = await firstValueFrom(sut.getPreloginData$(email)); | ||
|
|
||
| // The salt is still mapped when the flag is off โ PasswordLoginStrategy simply ignores it | ||
| // and derives from the entered email instead. | ||
| expect(result.salt).toBe(apiSalt); | ||
| }); | ||
|
Comment on lines
+109
to
+126
Contributor
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. |
||
|
|
||
| it("emits an undefined salt when the SDK omits one", async () => { | ||
| // PM-28143: salt is nullable while the server transition is in flight. Documents current | ||
| // behavior โ the service does not substitute a fallback. | ||
| configService.getFeatureFlag.mockResolvedValue(true); | ||
| sdkService.client.auth | ||
| .mockDeep() | ||
| .login.mockDeep() | ||
| .get_password_prelogin.mockResolvedValue({ | ||
| kdf: { pBKDF2: { iterations: PBKDF2KdfConfig.ITERATIONS.defaultValue } }, | ||
| } as SdkPasswordPreloginResponse); | ||
|
|
||
| const result = await firstValueFrom(sut.getPreloginData$(email)); | ||
|
|
||
| expect(result.salt).toBeUndefined(); | ||
| }); | ||
|
Comment on lines
+128
to
+142
Contributor
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. |
||
|
|
||
| it("checks the feature flag with the expected key", async () => { | ||
| await firstValueFrom(sut.getPreloginData$(email)); | ||
|
|
||
|
|
||
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.
๐จ : leaving a comment w/ the PM-27060 reference is worse vs just explaining that server dictates salt.