1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-21 20:04:02 +00:00

Merge remote-tracking branch 'origin/main' into uif/CL-927/anon-layout-header-actions-slot

This commit is contained in:
Bryan Cunningham
2025-12-04 11:08:23 -05:00
28 changed files with 282 additions and 367 deletions

View File

@@ -259,7 +259,7 @@ describe("LoginStrategy", () => {
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
UserDecryptionOptions.fromResponse(idTokenResponse),
UserDecryptionOptions.fromIdentityTokenResponse(idTokenResponse),
);
expect(masterPasswordService.mock.setMasterPasswordUnlockData).toHaveBeenCalledWith(
new MasterPasswordUnlockData(

View File

@@ -199,7 +199,7 @@ export abstract class LoginStrategy {
// as the user decryption options help determine the available timeout actions.
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
UserDecryptionOptions.fromResponse(tokenResponse),
UserDecryptionOptions.fromIdentityTokenResponse(tokenResponse),
);
if (tokenResponse.userDecryptionOptions?.masterPasswordUnlock != null) {

View File

@@ -503,67 +503,6 @@ describe("SsoLoginStrategy", () => {
HasMasterPassword: false,
KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl },
});
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray,
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
await ssoLoginStrategy.logIn(credentials);
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl, userId);
});
it("converts new SSO user with no master password to Key Connector on first login", async () => {
tokenResponse.key = undefined;
tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLoginStrategy.logIn(credentials);
expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith(
{
kdfConfig: new Argon2KdfConfig(10, 64, 4),
keyConnectorUrl: keyConnectorUrl,
organizationId: ssoOrgId,
},
userId,
);
});
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray,
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await ssoLoginStrategy.logIn(credentials);
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
});
});
describe("Key Connector Pre-TDE", () => {
let tokenResponse: IdentityTokenResponse;
beforeEach(() => {
tokenResponse = identityTokenResponseFactory();
tokenResponse.userDecryptionOptions = null;
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {

View File

@@ -157,22 +157,12 @@ export class SsoLoginStrategy extends LoginStrategy {
// In order for us to set the master key from Key Connector, we need to have a Key Connector URL
// and the user must not have a master password.
return userHasKeyConnectorUrl && !userHasMasterPassword;
} else {
// In pre-TDE versions of the server, the userDecryptionOptions will not be present.
// In this case, we can determine if the user has a master password and has a Key Connector URL by
// just checking the keyConnectorUrl property. This is because the server short-circuits on the response
// and will not pass back the URL in the response if the user has a master password.
// TODO: remove compatibility check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
return tokenResponse.keyConnectorUrl != null;
}
}
private getKeyConnectorUrl(tokenResponse: IdentityTokenResponse): string {
// TODO: remove tokenResponse.keyConnectorUrl reference after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
return (
tokenResponse.keyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl
);
return userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl;
}
// TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request)

View File

@@ -112,10 +112,11 @@ export class UserDecryptionOptions {
* @throws If the response is nullish, this method will throw an error. User decryption options
* are required for client initialization.
*/
// TODO: Change response type to `UserDecryptionOptionsResponse` after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
static fromResponse(response: IdentityTokenResponse): UserDecryptionOptions {
static fromIdentityTokenResponse(response: IdentityTokenResponse): UserDecryptionOptions {
if (response == null) {
throw new Error("User Decryption Options are required for client initialization.");
throw new Error(
"User Decryption Options are required for client initialization. Response is nullish.",
);
}
const decryptionOptions = new UserDecryptionOptions();
@@ -134,17 +135,9 @@ export class UserDecryptionOptions {
responseOptions.keyConnectorOption,
);
} else {
// If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so
// we must base our decryption options on the presence of the keyConnectorUrl.
// Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE
// server versions, a master password short-circuited the addition of the keyConnectorUrl to the response.
// TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const usingKeyConnector = response.keyConnectorUrl != null;
decryptionOptions.hasMasterPassword = !usingKeyConnector;
if (usingKeyConnector) {
decryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption();
decryptionOptions.keyConnectorOption.keyConnectorUrl = response.keyConnectorUrl;
}
throw new Error(
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
);
}
return decryptionOptions;
}

View File

@@ -10,6 +10,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { PreloginResponse } from "@bitwarden/common/auth/models/response/prelogin.response";
import { UserDecryptionOptionsResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -496,6 +497,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);
apiService.postPrelogin.mockResolvedValue(
@@ -563,6 +565,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);
@@ -692,6 +695,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);

View File

@@ -26,7 +26,6 @@ export class IdentityTokenResponse extends BaseResponse {
forcePasswordReset: boolean;
masterPasswordPolicy: MasterPasswordPolicyResponse;
apiUseKeyConnector: boolean;
keyConnectorUrl: string;
userDecryptionOptions?: UserDecryptionOptionsResponse;
@@ -70,7 +69,7 @@ export class IdentityTokenResponse extends BaseResponse {
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy"),
);

View File

@@ -1,6 +1,10 @@
import { Opaque } from "type-fest";
import { EncString, SignedSecurityState as SdkSignedSecurityState } from "@bitwarden/sdk-internal";
import {
EncString,
SignedSecurityState as SdkSignedSecurityState,
SignedPublicKey as SdkSignedPublicKey,
} from "@bitwarden/sdk-internal";
/**
* A private key, encrypted with a symmetric key.
@@ -10,7 +14,7 @@ export type WrappedPrivateKey = Opaque<EncString, "WrappedPrivateKey">;
/**
* A public key, signed with the accounts signature key.
*/
export type SignedPublicKey = Opaque<string, "SignedPublicKey">;
export type SignedPublicKey = Opaque<SdkSignedPublicKey, "SignedPublicKey">;
/**
* A public key in base64 encoded SPKI-DER
*/

View File

@@ -1,5 +1,5 @@
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
import { WrappedSigningKey } from "../../../key-management/types";
import { SignedPublicKey, WrappedSigningKey } from "../../../key-management/types";
import { UserKey } from "../../../types/key";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { CRYPTO_DISK, CRYPTO_MEMORY, UserKeyDefinition } from "../../state";
@@ -35,3 +35,12 @@ export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition<WrappedSigni
clearOn: ["logout"],
},
);
export const USER_SIGNED_PUBLIC_KEY = new UserKeyDefinition<SignedPublicKey>(
CRYPTO_DISK,
"userSignedPublicKey",
{
deserializer: (obj) => obj,
clearOn: ["logout"],
},
);

View File

@@ -105,6 +105,7 @@ describe("DefaultSdkService", () => {
.mockReturnValue(of("private-key" as EncryptedString));
keyService.encryptedOrgKeys$.calledWith(userId).mockReturnValue(of({}));
keyService.userSigningKey$.calledWith(userId).mockReturnValue(of(null));
keyService.userSignedPublicKey$.calledWith(userId).mockReturnValue(of(null));
securityStateService.accountSecurityState$.calledWith(userId).mockReturnValue(of(null));
});

View File

@@ -16,6 +16,7 @@ import {
} from "rxjs";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserKey } from "@bitwarden/common/types/key";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key-management";
@@ -24,15 +25,14 @@ import {
ClientSettings,
TokenProvider,
UnsignedSharedKey,
WrappedAccountCryptographicState,
} from "@bitwarden/sdk-internal";
import { ApiService } from "../../../abstractions/api.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service";
import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types";
import { OrganizationId, UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
@@ -174,6 +174,9 @@ export class DefaultSdkService implements SdkService {
const securityState$ = this.securityStateService
.accountSecurityState$(userId)
.pipe(distinctUntilChanged(compareValues));
const signedPublicKey$ = this.keyService
.userSignedPublicKey$(userId)
.pipe(distinctUntilChanged(compareValues));
const client$ = combineLatest([
this.environmentService.getEnvironment$(userId),
@@ -184,11 +187,22 @@ export class DefaultSdkService implements SdkService {
signingKey$,
orgKeys$,
securityState$,
signedPublicKey$,
SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded
]).pipe(
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
switchMap(
([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => {
([
env,
account,
kdfParams,
privateKey,
userKey,
signingKey,
orgKeys,
securityState,
signedPublicKey,
]) => {
// Create our own observable to be able to implement clean-up logic
return new Observable<Rc<PasswordManagerClient>>((subscriber) => {
const createAndInitializeClient = async () => {
@@ -202,15 +216,31 @@ export class DefaultSdkService implements SdkService {
settings,
);
let accountCryptographicState: WrappedAccountCryptographicState;
if (signingKey != null && securityState != null && signedPublicKey != null) {
accountCryptographicState = {
V2: {
private_key: privateKey,
signing_key: signingKey,
security_state: securityState,
signed_public_key: signedPublicKey,
},
};
} else {
accountCryptographicState = {
V1: {
private_key: privateKey,
},
};
}
await this.initializeClient(
userId,
client,
account,
kdfParams,
privateKey,
userKey,
signingKey,
securityState,
accountCryptographicState,
orgKeys,
);
@@ -245,10 +275,8 @@ export class DefaultSdkService implements SdkService {
client: PasswordManagerClient,
account: AccountInfo,
kdfParams: KdfConfig,
privateKey: EncryptedString,
userKey: UserKey,
signingKey: WrappedSigningKey | null,
securityState: SignedSecurityState | null,
accountCryptographicState: WrappedAccountCryptographicState,
orgKeys: Record<OrganizationId, EncString>,
) {
await client.crypto().initialize_user_crypto({
@@ -265,9 +293,7 @@ export class DefaultSdkService implements SdkService {
parallelism: kdfParams.parallelism,
},
},
privateKey,
signingKey: signingKey || undefined,
securityState: securityState || undefined,
accountCryptographicState: accountCryptographicState,
});
// We initialize the org crypto even if the org_keys are

View File

@@ -253,6 +253,10 @@ export class DefaultSyncService extends CoreSyncService {
response.accountKeys.securityState.securityState,
response.id,
);
await this.keyService.setSignedPublicKey(
response.accountKeys.publicKeyEncryptionKeyPair.signedPublicKey,
response.id,
);
}
} else {
await this.keyService.setPrivateKey(response.privateKey, response.id);

View File

@@ -14,7 +14,7 @@ type CharacterType = "letter" | "emoji" | "special" | "number";
@Component({
selector: "bit-color-password",
template: `@for (character of passwordCharArray(); track $index; let i = $index) {
<span [class]="getCharacterClass(character)">
<span [class]="getCharacterClass(character)" class="tw-font-mono">
<span>{{ character }}</span>
@if (showCount()) {
<span class="tw-whitespace-nowrap tw-text-xs tw-leading-5 tw-text-main">{{ i + 1 }}</span>

View File

@@ -4,7 +4,7 @@ import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for
import { ColorPasswordComponent } from "./color-password.component";
const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z ";
const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z0O ";
export default {
title: "Component Library/Color Password",

View File

@@ -7,7 +7,7 @@ import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { WrappedSigningKey } from "@bitwarden/common/key-management/types";
import { SignedPublicKey, WrappedSigningKey } from "@bitwarden/common/key-management/types";
import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid";
@@ -428,4 +428,8 @@ export abstract class KeyService {
* @param userId The user id for the key
*/
abstract validateUserKey(key: UserKey, userId: UserId): Promise<boolean>;
abstract setSignedPublicKey(signedPublicKey: SignedPublicKey, userId: UserId): Promise<void>;
abstract userSignedPublicKey$(userId: UserId): Observable<SignedPublicKey | null>;
}

View File

@@ -28,7 +28,7 @@ import {
EncryptedString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { WrappedSigningKey } from "@bitwarden/common/key-management/types";
import { SignedPublicKey, WrappedSigningKey } from "@bitwarden/common/key-management/types";
import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout";
import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -46,6 +46,7 @@ import {
USER_EVER_HAD_USER_KEY,
USER_KEY,
USER_KEY_ENCRYPTED_SIGNING_KEY,
USER_SIGNED_PUBLIC_KEY,
} from "@bitwarden/common/platform/services/key-state/user-key.state";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -1013,4 +1014,12 @@ export class DefaultKeyService implements KeyServiceAbstraction {
}),
);
}
async setSignedPublicKey(signedPublicKey: SignedPublicKey, userId: UserId): Promise<void> {
await this.stateProvider.setUserState(USER_SIGNED_PUBLIC_KEY, signedPublicKey, userId);
}
userSignedPublicKey$(userId: UserId): Observable<SignedPublicKey | null> {
return this.stateProvider.getUserState$(USER_SIGNED_PUBLIC_KEY, userId);
}
}