1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge branch 'main' into auth/pm-9115/implement-view-data-persistence-in-2FA-flows

This commit is contained in:
Alec Rippberger
2025-03-31 14:26:53 -05:00
58 changed files with 831 additions and 761 deletions

View File

@@ -33,6 +33,10 @@ on:
description: "Custom SDK branch"
required: false
type: string
testflight_distribute:
description: "Force distribute to TestFlight regardless of branch (useful for QA testing on feature branches)"
type: boolean
default: true
defaults:
run:
@@ -1208,21 +1212,45 @@ jobs:
path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg
if-no-files-found: error
- name: Create secrets for Fastlane
if: |
github.event_name != 'pull_request_target'
&& (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop')
run: |
brew install gsed
KEY_WITHOUT_NEWLINES=$(gsed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g' ~/private_keys/AuthKey_6TV9MKN3GP.p8)
cat << EOF > ~/secrets/appstoreconnect-fastlane.json
{
"issuer_id": "${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}",
"key_id": "6TV9MKN3GP",
"key": "$KEY_WITHOUT_NEWLINES"
}
EOF
- name: Deploy to TestFlight
id: testflight-deploy
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop')
&& (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop')
env:
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP
BRANCH: ${{ github.ref }}
run: |
xcrun altool \
--upload-app \
--type macos \
--file "$(find ./dist/mas-universal/Bitwarden*.pkg)" \
--apiKey $APP_STORE_CONNECT_AUTH_KEY \
--apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER
GIT_CHANGE="$(git show -s --format=%s)"
BRANCH=$(echo $BRANCH | sed 's/refs\/heads\///')
CHANGELOG="$BRANCH: $GIT_CHANGE"
fastlane pilot upload \
--app_identifier "com.bitwarden.desktop" \
--changelog "$CHANGELOG" \
--api_key_path $HOME/secrets/appstoreconnect-fastlane.json \
--pkg "$(find ./dist/mas-universal/Bitwarden*.pkg)"
- name: Post message to a Slack channel
id: slack-message

View File

@@ -233,11 +233,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
.pipe(
switchMap(async () => {
const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id);
const biometricSettingAvailable =
!(await BrowserApi.permissionsGranted(["nativeMessaging"])) ||
(status !== BiometricsStatus.DesktopDisconnected &&
status !== BiometricsStatus.NotEnabledInConnectedDesktopApp) ||
(await this.vaultTimeoutSettingsService.isBiometricLockSet());
const biometricSettingAvailable = await this.biometricsService.canEnableBiometricUnlock();
if (!biometricSettingAvailable) {
this.form.controls.biometric.disable({ emitEvent: false });
} else {
@@ -256,6 +252,13 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
"biometricsStatusHelptextNotEnabledInDesktop",
activeAccount.email,
);
} else if (
status === BiometricsStatus.HardwareUnavailable &&
!biometricSettingAvailable
) {
this.biometricUnavailabilityReason = this.i18nService.t(
"biometricsStatusHelptextHardwareUnavailable",
);
} else {
this.biometricUnavailabilityReason = "";
}

View File

@@ -154,121 +154,115 @@
</bit-item>
</bit-section>
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "enableAutoFillOnPageLoadSectionTitle" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-hint class="tw-mb-6 tw-text-sm">
{{ "enableAutoFillOnPageLoadDesc" | i18n }}
<span
><b>{{ "warningCapitalized" | i18n }}</b
>: {{ "experimentalFeature" | i18n }}</span
>
<a
bitLink
class="tw-no-underline"
href="https://bitwarden.com/help/auto-fill-browser/"
rel="noreferrer"
target="_blank"
>
{{ "learnMoreAboutAutofillOnPageLoadLinkText" | i18n }}
</a>
</bit-hint>
<bit-form-control>
<input
bitCheckbox
id="autofillOnPageLoad"
type="checkbox"
(change)="updateAutofillOnPageLoad()"
[(ngModel)]="enableAutofillOnPageLoad"
[disabled]="autofillOnPageLoadFromPolicy$ | async"
/>
<bit-label for="autofillOnPageLoad">{{ "enableAutoFillOnPageLoad" | i18n }}</bit-label>
<bit-hint class="tw-text-sm" *ngIf="autofillOnPageLoadFromPolicy$ | async">{{
"enterprisePolicyRequirementsApplied" | i18n
}}</bit-hint>
</bit-form-control>
<bit-form-field disableMargin>
<bit-label for="defaultAutofill">{{ "defaultAutoFillOnPageLoad" | i18n }}</bit-label>
<select
bitInput
id="defaultAutofill"
(change)="updateAutofillOnPageLoadDefault()"
[(ngModel)]="autofillOnPageLoadDefault"
[disabled]="!enableAutofillOnPageLoad"
>
<option
*ngFor="let o of autofillOnPageLoadOptions"
[ngValue]="o.value"
[label]="o.name"
></option>
</select>
<bit-hint class="tw-text-sm">
{{ "defaultAutoFillOnPageLoadDesc" | i18n }}
<form [formGroup]="autofillOnPageLoadForm">
<bit-section-header>
<legend>
<h2 bitTypography="h6">{{ "enableAutoFillOnPageLoadSectionTitle" | i18n }}</h2>
</legend>
</bit-section-header>
<bit-card>
<bit-hint class="tw-mb-6 tw-text-sm">
{{ "enableAutoFillOnPageLoadDesc" | i18n }}
<span
><b>{{ "warningCapitalized" | i18n }}</b
>: {{ "experimentalFeature" | i18n }}</span
>
<a
bitLink
class="tw-no-underline"
href="https://bitwarden.com/help/auto-fill-browser/"
rel="noreferrer"
target="_blank"
>
{{ "learnMoreAboutAutofillOnPageLoadLinkText" | i18n }}
</a>
</bit-hint>
</bit-form-field>
</bit-card>
<bit-form-control>
<input
formControlName="autofillOnPageLoad"
bitCheckbox
id="autofillOnPageLoad"
type="checkbox"
/>
<bit-label for="autofillOnPageLoad">{{ "enableAutoFillOnPageLoad" | i18n }}</bit-label>
<bit-hint class="tw-text-sm" *ngIf="autofillOnPageLoadFromPolicy$ | async">{{
"enterprisePolicyRequirementsApplied" | i18n
}}</bit-hint>
</bit-form-control>
<bit-form-field disableMargin>
<bit-label for="defaultAutofill">{{ "defaultAutoFillOnPageLoad" | i18n }}</bit-label>
<bit-select formControlName="defaultAutofill" bitInput id="defaultAutofill">
<bit-option
*ngFor="let option of autofillOnPageLoadOptions"
[label]="option.name"
[value]="option.value"
>
</bit-option>
</bit-select>
<bit-hint class="tw-text-sm">
{{ "defaultAutoFillOnPageLoadDesc" | i18n }}
</bit-hint>
</bit-form-field>
</bit-card>
</form>
</bit-section>
<bit-section [disableMargin]="!blockBrowserInjectionsByDomainEnabled">
<bit-section-header>
<h2 bitTypography="h6">{{ "additionalOptions" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-control>
<input
bitCheckbox
id="context-menu"
type="checkbox"
(change)="updateContextMenuItem()"
[(ngModel)]="enableContextMenuItem"
/>
<bit-label for="context-menu">{{ "enableContextMenuItem" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
bitCheckbox
id="totp"
type="checkbox"
(change)="updateAutoTotpCopy()"
[(ngModel)]="enableAutoTotpCopy"
/>
<bit-label for="totp">{{ "enableAutoTotpCopy" | i18n }}</bit-label>
</bit-form-control>
<bit-form-field>
<bit-label for="clearClipboard">{{ "clearClipboard" | i18n }}</bit-label>
<select
aria-describedby="clearClipboardHelp"
bitInput
id="clearClipboard"
(change)="saveClearClipboard()"
[(ngModel)]="clearClipboard"
>
<option
*ngFor="let o of clearClipboardOptions"
[label]="o.name"
[ngValue]="o.value"
></option>
</select>
<bit-hint class="tw-text-sm" id="clearClipboardHelp">
{{ "clearClipboardDesc" | i18n }}
</bit-hint>
</bit-form-field>
<bit-form-field disableMargin>
<bit-label for="defaultUriMatch">{{ "defaultUriMatchDetection" | i18n }}</bit-label>
<select
aria-describedby="defaultUriMatchHelp"
bitInput
id="defaultUriMatch"
(change)="saveDefaultUriMatch()"
[(ngModel)]="defaultUriMatch"
>
<option *ngFor="let o of uriMatchOptions" [label]="o.name" [ngValue]="o.value"></option>
</select>
<bit-hint class="tw-text-sm" id="defaultUriMatchHelp">
{{ "defaultUriMatchDetectionDesc" | i18n }}
</bit-hint>
</bit-form-field>
</bit-card>
<form [formGroup]="additionalOptionsForm">
<bit-section-header>
<h2 bitTypography="h6">{{ "additionalOptions" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-control>
<input
formControlName="enableContextMenuItem"
bitCheckbox
id="context-menu"
type="checkbox"
/>
<bit-label for="context-menu">{{ "enableContextMenuItem" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input formControlName="enableAutoTotpCopy" bitCheckbox id="totp" type="checkbox" />
<bit-label for="totp">{{ "enableAutoTotpCopy" | i18n }}</bit-label>
</bit-form-control>
<bit-form-field>
<bit-label for="clearClipboard">{{ "clearClipboard" | i18n }}</bit-label>
<bit-select
formControlName="clearClipboard"
aria-describedby="clearClipboardHelp"
bitInput
id="clearClipboard"
>
<bit-option
*ngFor="let option of clearClipboardOptions"
[label]="option.name"
[value]="option.value"
></bit-option>
</bit-select>
<bit-hint class="tw-text-sm" id="clearClipboardHelp">
{{ "clearClipboardDesc" | i18n }}
</bit-hint>
</bit-form-field>
<bit-form-field disableMargin>
<bit-label for="defaultUriMatch">{{ "defaultUriMatchDetection" | i18n }}</bit-label>
<bit-select
formControlName="defaultUriMatch"
aria-describedby="defaultUriMatchHelp"
bitInput
id="defaultUriMatch"
>
<bit-option
*ngFor="let option of uriMatchOptions"
[label]="option.name"
[value]="option.value"
></bit-option>
</bit-select>
<bit-hint class="tw-text-sm" id="defaultUriMatchHelp">
{{ "defaultUriMatchDetectionDesc" | i18n }}
</bit-hint>
</bit-form-field>
</bit-card>
</form>
</bit-section>
<bit-section *ngIf="blockBrowserInjectionsByDomainEnabled" disableMargin>
<bit-item>

View File

@@ -1,8 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
FormsModule,
ReactiveFormsModule,
FormBuilder,
FormGroup,
FormControl,
} from "@angular/forms";
import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
@@ -73,6 +80,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
SectionHeaderComponent,
SelectModule,
TypographyModule,
ReactiveFormsModule,
],
})
export class AutofillComponent implements OnInit {
@@ -94,6 +102,18 @@ export class AutofillComponent implements OnInit {
protected autofillOnPageLoadFromPolicy$ =
this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$;
protected autofillOnPageLoadForm = new FormGroup({
autofillOnPageLoad: new FormControl(),
defaultAutofill: new FormControl(),
});
protected additionalOptionsForm = new FormGroup({
enableContextMenuItem: new FormControl(),
enableAutoTotpCopy: new FormControl(),
clearClipboard: new FormControl(),
defaultUriMatch: new FormControl(),
});
enableAutofillOnPageLoad: boolean = false;
enableInlineMenu: boolean = false;
enableInlineMenuOnIconSelect: boolean = false;
@@ -121,10 +141,12 @@ export class AutofillComponent implements OnInit {
private messagingService: MessagingService,
private vaultSettingsService: VaultSettingsService,
private configService: ConfigService,
private formBuilder: FormBuilder,
private destroyRef: DestroyRef,
) {
this.autofillOnPageLoadOptions = [
{ name: i18nService.t("autoFillOnPageLoadYes"), value: true },
{ name: i18nService.t("autoFillOnPageLoadNo"), value: false },
{ name: this.i18nService.t("autoFillOnPageLoadYes"), value: true },
{ name: this.i18nService.t("autoFillOnPageLoadNo"), value: false },
];
this.clearClipboardOptions = [
{ name: i18nService.t("never"), value: ClearClipboardDelay.Never },
@@ -181,27 +203,106 @@ export class AutofillComponent implements OnInit {
this.inlineMenuVisibility === AutofillOverlayVisibility.OnFieldFocus ||
this.enableInlineMenuOnIconSelect;
this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
value
? this.autofillOnPageLoadForm.controls.autofillOnPageLoad.disable({ emitEvent: false })
: this.autofillOnPageLoadForm.controls.autofillOnPageLoad.enable({ emitEvent: false });
});
this.enableAutofillOnPageLoad = await firstValueFrom(
this.autofillSettingsService.autofillOnPageLoad$,
);
this.autofillOnPageLoadForm.controls.autofillOnPageLoad.patchValue(
this.enableAutofillOnPageLoad,
{ emitEvent: false },
);
this.autofillOnPageLoadDefault = await firstValueFrom(
this.autofillSettingsService.autofillOnPageLoadDefault$,
);
if (this.enableAutofillOnPageLoad === false) {
this.autofillOnPageLoadForm.controls.defaultAutofill.disable();
}
this.autofillOnPageLoadForm.controls.defaultAutofill.patchValue(
this.autofillOnPageLoadDefault,
{ emitEvent: false },
);
this.autofillOnPageLoadForm.controls.autofillOnPageLoad.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
void this.autofillSettingsService.setAutofillOnPageLoad(value);
this.enableDefaultAutofillControl(value);
});
this.autofillOnPageLoadForm.controls.defaultAutofill.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
void this.autofillSettingsService.setAutofillOnPageLoadDefault(value);
});
/** Additional options form */
this.enableContextMenuItem = await firstValueFrom(
this.autofillSettingsService.enableContextMenu$,
);
this.additionalOptionsForm.controls.enableContextMenuItem.patchValue(
this.enableContextMenuItem,
{ emitEvent: false },
);
this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$);
this.additionalOptionsForm.controls.enableAutoTotpCopy.patchValue(this.enableAutoTotpCopy, {
emitEvent: false,
});
this.clearClipboard = await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$);
this.additionalOptionsForm.controls.clearClipboard.patchValue(this.clearClipboard, {
emitEvent: false,
});
const defaultUriMatch = await firstValueFrom(
this.domainSettingsService.defaultUriMatchStrategy$,
);
this.defaultUriMatch = defaultUriMatch == null ? UriMatchStrategy.Domain : defaultUriMatch;
this.additionalOptionsForm.controls.defaultUriMatch.patchValue(this.defaultUriMatch, {
emitEvent: false,
});
this.additionalOptionsForm.controls.enableContextMenuItem.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
void this.autofillSettingsService.setEnableContextMenu(value);
this.messagingService.send("bgUpdateContextMenu");
});
this.additionalOptionsForm.controls.enableAutoTotpCopy.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
void this.autofillSettingsService.setAutoCopyTotp(value);
});
this.additionalOptionsForm.controls.clearClipboard.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
void this.autofillSettingsService.setClearClipboardDelay(value);
});
this.additionalOptionsForm.controls.defaultUriMatch.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
void this.domainSettingsService.setDefaultUriMatchStrategy(value);
});
const command = await this.platformUtilsService.getAutofillKeyboardShortcut();
await this.setAutofillKeyboardHelperText(command);
@@ -230,17 +331,16 @@ export class AutofillComponent implements OnInit {
await this.requestPrivacyPermission();
}
}
async updateAutofillOnPageLoad() {
await this.autofillSettingsService.setAutofillOnPageLoad(this.enableAutofillOnPageLoad);
async getAutofillOnPageLoadFromPolicy() {
await firstValueFrom(this.autofillOnPageLoadFromPolicy$);
}
async updateAutofillOnPageLoadDefault() {
await this.autofillSettingsService.setAutofillOnPageLoadDefault(this.autofillOnPageLoadDefault);
}
async saveDefaultUriMatch() {
await this.domainSettingsService.setDefaultUriMatchStrategy(this.defaultUriMatch);
enableDefaultAutofillControl(enable: boolean = true) {
if (enable) {
this.autofillOnPageLoadForm.controls.defaultAutofill.enable();
} else {
this.autofillOnPageLoadForm.controls.defaultAutofill.disable();
}
}
private async setAutofillKeyboardHelperText(command: string) {
@@ -388,19 +488,6 @@ export class AutofillComponent implements OnInit {
return await BrowserApi.permissionsGranted(["privacy"]);
}
async updateContextMenuItem() {
await this.autofillSettingsService.setEnableContextMenu(this.enableContextMenuItem);
this.messagingService.send("bgUpdateContextMenu");
}
async updateAutoTotpCopy() {
await this.autofillSettingsService.setAutoCopyTotp(this.enableAutoTotpCopy);
}
async saveClearClipboard() {
await this.autofillSettingsService.setClearClipboardDelay(this.clearClipboard);
}
async updateShowCardsCurrentTab() {
await this.vaultSettingsService.setShowCardsCurrentTab(this.showCardsCurrentTab);
}

View File

@@ -17,7 +17,6 @@ describe("InlineMenuFieldQualificationService", () => {
fields: [],
});
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
inlineMenuFieldQualificationService["inlineMenuFieldQualificationFlagSet"] = true;
});
describe("isFieldForLoginForm", () => {

View File

@@ -150,7 +150,6 @@ export class InlineMenuFieldQualificationService
this.identityPostalCodeAutocompleteValue,
]);
private totpFieldAutocompleteValue = "one-time-code";
private inlineMenuFieldQualificationFlagSet = false;
private premiumEnabled = false;
constructor() {
@@ -158,7 +157,6 @@ export class InlineMenuFieldQualificationService
sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"),
sendExtensionMessage("getUserPremiumStatus"),
]).then(([fieldQualificationFlag, premiumStatus]) => {
this.inlineMenuFieldQualificationFlagSet = !!fieldQualificationFlag?.result;
this.premiumEnabled = !!premiumStatus?.result;
});
}
@@ -170,10 +168,6 @@ export class InlineMenuFieldQualificationService
* @param pageDetails - The details of the page that the field is on.
*/
isFieldForLoginForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean {
if (!this.inlineMenuFieldQualificationFlagSet) {
return this.isFieldForLoginFormFallback(field);
}
/**
* Totp inline menu is available only for premium users.
*/
@@ -1223,18 +1217,4 @@ export class InlineMenuFieldQualificationService
return false;
}
/**
* This method represents the previous rudimentary approach to qualifying fields for login forms.
*
* @param field - The field to validate
* @deprecated - This method will only be used when the fallback flag is set to true.
*/
private isFieldForLoginFormFallback(field: AutofillField): boolean {
if (field.type === "password") {
return true;
}
return this.isUsernameField(field);
}
}

View File

@@ -655,9 +655,7 @@ export default class MainBackground {
this.kdfConfigService,
this.keyGenerationService,
this.logService,
this.masterPasswordService,
this.stateProvider,
this.stateService,
);
this.keyService = new DefaultKeyService(
@@ -674,14 +672,6 @@ export default class MainBackground {
this.kdfConfigService,
);
this.biometricsService = new BackgroundBrowserBiometricsService(
runtimeNativeMessagingBackground,
this.logService,
this.keyService,
this.biometricStateService,
this.messagingService,
);
this.appIdService = new AppIdService(this.storageService, this.logService);
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
@@ -701,6 +691,15 @@ export default class MainBackground {
VaultTimeoutStringType.OnRestart, // default vault timeout
);
this.biometricsService = new BackgroundBrowserBiometricsService(
runtimeNativeMessagingBackground,
this.logService,
this.keyService,
this.biometricStateService,
this.messagingService,
this.vaultTimeoutSettingsService,
);
this.apiService = new ApiService(
this.tokenService,
this.platformUtilsService,

View File

@@ -120,9 +120,15 @@ export class NativeMessagingBackground {
this.connecting = true;
const connectedCallback = () => {
this.logService.info(
"[Native Messaging IPC] Connection to Bitwarden Desktop app established!",
);
if (!this.platformUtilsService.isSafari()) {
this.logService.info(
"[Native Messaging IPC] Connection to Bitwarden Desktop app established!",
);
} else {
this.logService.info(
"[Native Messaging IPC] Connection to Safari swift module established!",
);
}
this.connected = true;
this.connecting = false;
resolve();
@@ -131,6 +137,7 @@ export class NativeMessagingBackground {
// Safari has a bundled native component which is always available, no need to
// check if the desktop app is running.
if (this.platformUtilsService.isSafari()) {
this.isConnectedToOutdatedDesktopClient = false;
connectedCallback();
}
@@ -428,7 +435,9 @@ export class NativeMessagingBackground {
}
if (this.callbacks.has(messageId)) {
this.callbacks.get(messageId)!.resolver(message);
const callback = this.callbacks!.get(messageId)!;
this.callbacks.delete(messageId);
callback.resolver(message);
} else {
this.logService.info("[Native Messaging IPC] Received message without a callback", message);
}

View File

@@ -78,8 +78,8 @@ export default class RuntimeBackground {
BiometricsCommands.GetBiometricsStatus,
BiometricsCommands.UnlockWithBiometricsForUser,
BiometricsCommands.GetBiometricsStatusForUser,
BiometricsCommands.CanEnableBiometricUnlock,
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
"getInlineMenuFieldQualificationFeatureFlag",
"getUserPremiumStatus",
];
@@ -201,14 +201,14 @@ export default class RuntimeBackground {
case BiometricsCommands.GetBiometricsStatusForUser: {
return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId);
}
case BiometricsCommands.CanEnableBiometricUnlock: {
return await this.main.biometricsService.canEnableBiometricUnlock();
}
case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": {
return await this.configService.getFeatureFlag(
FeatureFlag.UseTreeWalkerApiForPageDetailsCollection,
);
}
case "getInlineMenuFieldQualificationFeatureFlag": {
return await this.configService.getFeatureFlag(FeatureFlag.InlineMenuFieldQualification);
}
case "getUserPremiumStatus": {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),

View File

@@ -0,0 +1,61 @@
import { mock } from "jest-mock-extended";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
import { NativeMessagingBackground } from "../../background/nativeMessaging.background";
import { BackgroundBrowserBiometricsService } from "./background-browser-biometrics.service";
describe("background browser biometrics service tests", function () {
let service: BackgroundBrowserBiometricsService;
const nativeMessagingBackground = mock<NativeMessagingBackground>();
const logService = mock<LogService>();
const keyService = mock<KeyService>();
const biometricStateService = mock<BiometricStateService>();
const messagingService = mock<MessagingService>();
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
beforeEach(() => {
jest.resetAllMocks();
service = new BackgroundBrowserBiometricsService(
() => nativeMessagingBackground,
logService,
keyService,
biometricStateService,
messagingService,
vaultTimeoutSettingsService,
);
});
describe("canEnableBiometricUnlock", () => {
const table: [BiometricsStatus, boolean, boolean][] = [
// status, already enabled, expected
// if the setting is not already on, it should only be possible to enable it if biometrics are available
[BiometricsStatus.Available, false, true],
[BiometricsStatus.HardwareUnavailable, false, false],
[BiometricsStatus.NotEnabledInConnectedDesktopApp, false, false],
[BiometricsStatus.DesktopDisconnected, false, false],
// if the setting is already on, it should always be possible to disable it
[BiometricsStatus.Available, true, true],
[BiometricsStatus.HardwareUnavailable, true, true],
[BiometricsStatus.NotEnabledInConnectedDesktopApp, true, true],
[BiometricsStatus.DesktopDisconnected, true, true],
];
test.each(table)(
"status: %s, already enabled: %s, expected: %s",
async (status, alreadyEnabled, expected) => {
service.getBiometricsStatus = jest.fn().mockResolvedValue(status);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(alreadyEnabled);
const result = await service.canEnableBiometricUnlock();
expect(result).toBe(expected);
},
);
});
});

View File

@@ -1,5 +1,6 @@
import { Injectable } from "@angular/core";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -25,6 +26,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService {
private keyService: KeyService,
private biometricStateService: BiometricStateService,
private messagingService: MessagingService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
) {
super();
}
@@ -169,4 +171,14 @@ export class BackgroundBrowserBiometricsService extends BiometricsService {
}
async setShouldAutopromptNow(value: boolean): Promise<void> {}
async canEnableBiometricUnlock(): Promise<boolean> {
const status = await this.getBiometricsStatus();
const isBiometricsAlreadyEnabled = await this.vaultTimeoutSettingsService.isBiometricLockSet();
const statusAllowsBiometric =
status !== BiometricsStatus.DesktopDisconnected &&
status !== BiometricsStatus.NotEnabledInConnectedDesktopApp &&
status !== BiometricsStatus.HardwareUnavailable;
return statusAllowsBiometric || isBiometricsAlreadyEnabled;
}
}

View File

@@ -0,0 +1,60 @@
import { mock } from "jest-mock-extended";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { ForegroundBrowserBiometricsService } from "./foreground-browser-biometrics";
jest.mock("../../platform/browser/browser-api", () => ({
BrowserApi: {
sendMessageWithResponse: jest.fn(),
permissionsGranted: jest.fn(),
},
}));
describe("foreground browser biometrics service tests", function () {
const platformUtilsService = mock<PlatformUtilsService>();
beforeEach(() => {
jest.resetAllMocks();
});
describe("canEnableBiometricUnlock", () => {
const table: [boolean, boolean, boolean, boolean][] = [
// canEnableBiometricUnlock from background, native permission granted, isSafari, expected
// needs permission prompt; always allowed
[true, false, false, true],
[false, false, false, true],
// is safari; depends on the status that the background service reports
[false, false, true, false],
[true, false, true, true],
// native permissions granted; depends on the status that the background service reports
[false, true, false, false],
[true, true, false, true],
// should never happen since safari does not use the permissions
[false, true, true, false],
[true, true, true, true],
];
test.each(table)(
"canEnableBiometric: %s, native permission granted: %s, isSafari: %s, expected: %s",
async (canEnableBiometricUnlockBackground, granted, isSafari, expected) => {
const service = new ForegroundBrowserBiometricsService(platformUtilsService);
(BrowserApi.permissionsGranted as jest.Mock).mockResolvedValue(granted);
(BrowserApi.sendMessageWithResponse as jest.Mock).mockResolvedValue({
result: canEnableBiometricUnlockBackground,
});
platformUtilsService.isSafari.mockReturnValue(isSafari);
const result = await service.canEnableBiometricUnlock();
expect(result).toBe(expected);
},
);
});
});

View File

@@ -1,3 +1,4 @@
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
@@ -8,6 +9,10 @@ import { BrowserApi } from "../../platform/browser/browser-api";
export class ForegroundBrowserBiometricsService extends BiometricsService {
shouldAutopromptNow = true;
constructor(private platformUtilsService: PlatformUtilsService) {
super();
}
async authenticateWithBiometrics(): Promise<boolean> {
const response = await BrowserApi.sendMessageWithResponse<{
result: boolean;
@@ -52,4 +57,19 @@ export class ForegroundBrowserBiometricsService extends BiometricsService {
async setShouldAutopromptNow(value: boolean): Promise<void> {
this.shouldAutopromptNow = value;
}
async canEnableBiometricUnlock(): Promise<boolean> {
const needsPermissionPrompt =
!(await BrowserApi.permissionsGranted(["nativeMessaging"])) &&
!this.platformUtilsService.isSafari();
return (
needsPermissionPrompt ||
(
await BrowserApi.sendMessageWithResponse<{
result: boolean;
error: string;
}>(BiometricsCommands.CanEnableBiometricUnlock)
).result
);
}
}

View File

@@ -318,10 +318,8 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: BiometricsService,
useFactory: () => {
return new ForegroundBrowserBiometricsService();
},
deps: [],
useClass: ForegroundBrowserBiometricsService,
deps: [PlatformUtilsService],
}),
safeProvider({
provide: SyncService,

View File

@@ -152,7 +152,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "biometricUnlock",
"command": "unlockWithBiometricsForUser",
"response": false,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
@@ -177,7 +177,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "biometricUnlock",
"command": "unlockWithBiometricsForUser",
"response": false,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
@@ -209,7 +209,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
response.userInfo = [ SFExtensionMessageKey: [
"message": [
"command": "biometricUnlock",
"command": "unlockWithBiometricsForUser",
"response": true,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"userKeyB64": result!.replacingOccurrences(of: "\"", with: ""),
@@ -220,7 +220,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "biometricUnlock",
"command": "unlockWithBiometricsForUser",
"response": true,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,

View File

@@ -24,4 +24,7 @@ export class CliBiometricsService extends BiometricsService {
}
async setShouldAutopromptNow(value: boolean): Promise<void> {}
async canEnableBiometricUnlock(): Promise<boolean> {
return false;
}
}

View File

@@ -436,9 +436,7 @@ export class ServiceContainer {
this.kdfConfigService,
this.keyGenerationService,
this.logService,
this.masterPasswordService,
this.stateProvider,
this.stateService,
);
this.keyService = new KeyService(

View File

@@ -248,6 +248,7 @@ describe("SettingsComponent", () => {
describe("biometrics enabled", () => {
beforeEach(() => {
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
desktopBiometricsService.canEnableBiometricUnlock.mockResolvedValue(true);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true);
});

View File

@@ -388,24 +388,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
});
this.supportsBiometric = this.shouldAllowBiometricSetup(
await this.biometricsService.getBiometricsStatus(),
);
this.supportsBiometric = await this.biometricsService.canEnableBiometricUnlock();
this.timerId = setInterval(async () => {
this.supportsBiometric = this.shouldAllowBiometricSetup(
await this.biometricsService.getBiometricsStatus(),
);
this.supportsBiometric = await this.biometricsService.canEnableBiometricUnlock();
}, 1000);
}
private shouldAllowBiometricSetup(biometricStatus: BiometricsStatus): boolean {
return [
BiometricsStatus.Available,
BiometricsStatus.AutoSetupNeeded,
BiometricsStatus.ManualSetupNeeded,
].includes(biometricStatus);
}
async saveVaultTimeout(newValue: VaultTimeout) {
if (newValue === VaultTimeoutStringType.Never) {
const confirmed = await this.dialogService.openSimpleDialog({

View File

@@ -163,4 +163,8 @@ export class MainBiometricsService extends DesktopBiometricsService {
async getShouldAutopromptNow(): Promise<boolean> {
return this.shouldAutoPrompt;
}
async canEnableBiometricUnlock(): Promise<boolean> {
return true;
}
}

View File

@@ -0,0 +1,44 @@
import { BiometricsStatus } from "@bitwarden/key-management";
import { RendererBiometricsService } from "./renderer-biometrics.service";
describe("renderer biometrics service tests", function () {
beforeEach(() => {
(global as any).ipc = {
keyManagement: {
biometric: {
authenticateWithBiometrics: jest.fn(),
getBiometricsStatus: jest.fn(),
unlockWithBiometricsForUser: jest.fn(),
getBiometricsStatusForUser: jest.fn(),
deleteBiometricUnlockKeyForUser: jest.fn(),
setupBiometrics: jest.fn(),
setClientKeyHalfForUser: jest.fn(),
getShouldAutoprompt: jest.fn(),
setShouldAutoprompt: jest.fn(),
},
},
};
});
describe("canEnableBiometricUnlock", () => {
const table: [BiometricsStatus, boolean][] = [
[BiometricsStatus.Available, true],
[BiometricsStatus.AutoSetupNeeded, true],
[BiometricsStatus.ManualSetupNeeded, true],
[BiometricsStatus.UnlockNeeded, false],
[BiometricsStatus.HardwareUnavailable, false],
[BiometricsStatus.PlatformUnsupported, false],
[BiometricsStatus.NotEnabledLocally, false],
];
test.each(table)("canEnableBiometricUnlock(%s) === %s", async (status, expected) => {
const service = new RendererBiometricsService();
(global as any).ipc.keyManagement.biometric.getBiometricsStatus.mockResolvedValue(status);
const result = await service.canEnableBiometricUnlock();
expect(result).toBe(expected);
});
});
});

View File

@@ -51,4 +51,13 @@ export class RendererBiometricsService extends DesktopBiometricsService {
async setShouldAutopromptNow(value: boolean): Promise<void> {
return await ipc.keyManagement.biometric.setShouldAutoprompt(value);
}
async canEnableBiometricUnlock(): Promise<boolean> {
const biometricStatus = await this.getBiometricsStatus();
return [
BiometricsStatus.Available,
BiometricsStatus.AutoSetupNeeded,
BiometricsStatus.ManualSetupNeeded,
].includes(biometricStatus);
}
}

View File

@@ -13,6 +13,8 @@ import {
Subject,
switchMap,
takeUntil,
tap,
filter,
} from "rxjs";
import { first } from "rxjs/operators";
@@ -189,10 +191,29 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.updateValueAndValidity();
}
this.organizationSelected.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((_) => {
this.organizationSelected.markAsTouched();
this.formGroup.updateValueAndValidity();
});
this.organizationSelected.valueChanges
.pipe(
tap((_) => {
if (this.organizationSelected.errors?.cannotCreateCollections) {
this.buttonDisplayName = ButtonType.Upgrade;
} else {
this.buttonDisplayName = ButtonType.Save;
}
}),
filter(() => this.organizationSelected.errors?.cannotCreateCollections),
switchMap((value) => this.findOrganizationById(value)),
takeUntil(this.destroy$),
)
.subscribe((org) => {
this.orgExceedingCollectionLimit = org;
this.organizationSelected.markAsTouched();
this.formGroup.updateValueAndValidity();
});
}
async findOrganizationById(orgId: string): Promise<Organization | undefined> {
const organizations = await firstValueFrom(this.organizations$);
return organizations.find((org) => org.id === orgId);
}
async loadOrg(orgId: string) {

View File

@@ -733,6 +733,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
submit = async () => {
if (this.taxComponent !== undefined && !this.taxComponent.validate()) {
this.taxComponent.markAllAsTouched();
return;
}

View File

@@ -1,4 +1,5 @@
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request";
@@ -11,16 +12,19 @@ export class UnlockDataRequest {
emergencyAccessUnlockData: EmergencyAccessWithIdRequest[];
organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[];
passkeyUnlockData: WebauthnRotateCredentialRequest[];
deviceKeyUnlockData: DeviceKeysUpdateRequest[];
constructor(
masterPasswordUnlockData: MasterPasswordUnlockDataRequest,
emergencyAccessUnlockData: EmergencyAccessWithIdRequest[],
organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[],
passkeyUnlockData: WebauthnRotateCredentialRequest[],
deviceTrustUnlockData: DeviceKeysUpdateRequest[],
) {
this.masterPasswordUnlockData = masterPasswordUnlockData;
this.emergencyAccessUnlockData = emergencyAccessUnlockData;
this.organizationAccountRecoveryUnlockData = organizationAccountRecoveryUnlockData;
this.passkeyUnlockData = passkeyUnlockData;
this.deviceKeyUnlockData = deviceTrustUnlockData;
}
}

View File

@@ -180,11 +180,19 @@ export class UserKeyRotationService {
newUnencryptedUserKey,
user.id,
);
const trustedDeviceUnlockData = await this.deviceTrustService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
const unlockDataRequest = new UnlockDataRequest(
masterPasswordUnlockData,
emergencyAccessUnlockData,
organizationAccountRecoveryUnlockData,
passkeyUnlockData,
trustedDeviceUnlockData,
);
const request = new RotateUserAccountKeysRequest(
@@ -198,14 +206,6 @@ export class UserKeyRotationService {
await this.apiService.postUserKeyUpdateV2(request);
this.logService.info("[Userkey rotation] Userkey rotation request posted to server");
// TODO PM-2199: Add device trust rotation support to the user key rotation endpoint
this.logService.info("[Userkey rotation] Rotating device trust...");
await this.deviceTrustService.rotateDevicesTrust(
user.id,
newUnencryptedUserKey,
newMasterKeyAuthenticationHash,
);
this.logService.info("[Userkey rotation] Device trust rotation completed");
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("rotationCompletedTitle"),

View File

@@ -24,4 +24,8 @@ export class WebBiometricsService extends BiometricsService {
}
async setShouldAutopromptNow(value: boolean): Promise<void> {}
async canEnableBiometricUnlock(): Promise<boolean> {
return false;
}
}

View File

@@ -138,6 +138,7 @@ export class VaultItemsComponent {
return canRestore$;
}),
map((canRestore) => canRestore && this.showBulkTrashOptions),
);
}

View File

@@ -21,7 +21,7 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
@@ -31,35 +31,31 @@
<ng-container *ngIf="!loading">
<p *ngIf="dataSource.data.length < 1">{{ "noClientsInList" | i18n }}</p>
<ng-container *ngIf="dataSource.data.length >= 1">
<bit-table-scroll
[dataSource]="dataSource"
[rowSize]="53"
class="tw-table tw-w-full table-hover table-list"
>
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53" class="tw-table tw-w-full">
<ng-container header>
<th bitCell colspan="2" bitSortable="organizationName">{{ "name" | i18n }}</th>
<th bitCell bitSortable="seats">{{ "numberOfUsers" | i18n }}</th>
<th bitCell bitSortable="plan">{{ "billingPlan" | i18n }}</th>
<th bitCell></th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell width="30">
<bit-avatar [text]="row.organizationName" [id]="row.id" size="small"></bit-avatar>
</td>
<td bitCell width="325">
<td bitCell width="320">
<a [routerLink]="['/organizations', row.organizationId]">{{ row.organizationName }}</a>
</td>
<td bitCell>
<td bitCell width="700">
<span>{{ row.userCount }}</span>
<span *ngIf="row.seats != null"> / {{ row.seats }}</span>
</td>
<td bitCell width="150">
<td bitCell width="250" class="tw-flex tw-flex-row tw-items-center">
<span>{{ row.plan }}</span>
</td>
<td class="table-list-options" *ngIf="manageOrganizations">
<div class="dropdown" appListDropdown>
<div appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
*ngIf="manageOrganizations"
[bitMenuTriggerFor]="removeMenu"
bitMenuItem
buttonType="secondary"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
@@ -68,12 +64,14 @@
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(row)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</a>
</div>
<bit-menu #removeMenu>
<button bitMenuItem type="button" appStopClick (click)="remove(row)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</div>
</td>
</ng-template>

View File

@@ -4,7 +4,7 @@ import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchModule } from "@bitwarden/components";
import { CardComponent, SearchModule } from "@bitwarden/components";
import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component";
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
import { VerifyBankAccountComponent } from "@bitwarden/web-vault/app/billing/shared/verify-bank-account/verify-bank-account.component";
@@ -51,6 +51,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
DangerZoneComponent,
ScrollingModule,
VerifyBankAccountComponent,
CardComponent,
],
declarations: [
AcceptProviderComponent,

View File

@@ -1,9 +1,9 @@
<div class="mt-5 d-flex justify-content-center" *ngIf="loading">
<div class="tw-mt-12 tw-flex tw-justify-center" *ngIf="loading">
<div>
<img class="mb-4 logo logo-themed" alt="Bitwarden" />
<p class="text-center">
<bit-icon class="tw-w-72 tw-block tw-mb-4" [icon]="logo"></bit-icon>
<p class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x text-muted"
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
@@ -11,25 +11,15 @@
</p>
</div>
</div>
<div class="container" *ngIf="!loading && !authed">
<div class="row justify-content-md-center mt-5">
<div class="col-5">
<p class="lead text-center mb-4">{{ "setupProvider" | i18n }}</p>
<div class="card d-block">
<div class="card-body">
<p>{{ "setupProviderLoginDesc" | i18n }}</p>
<hr />
<div class="d-flex">
<a
routerLink="/login"
[queryParams]="{ email: email }"
class="btn btn-primary btn-block"
>
{{ "logIn" | i18n }}
</a>
</div>
</div>
</div>
</div>
<div class="tw-flex tw-flex-row tw-justify-center tw-mt-12" *ngIf="!loading && !authed">
<div class="tw-w-[400px] tw-mt-5">
<h2 class="tw-flex tw-justify-center tw-mb-4">{{ "setupProvider" | i18n }}</h2>
<bit-card>
<p>{{ "setupProviderLoginDesc" | i18n }}</p>
<hr />
<button bitButton type="button" [block]="true" (click)="login()" buttonType="primary">
{{ "logIn" | i18n }}
</button>
</bit-card>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { Component } from "@angular/core";
import { Params } from "@angular/router";
import { BitwardenLogo } from "@bitwarden/auth/angular";
import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component";
@Component({
@@ -8,6 +9,7 @@ import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept
templateUrl: "setup-provider.component.html",
})
export class SetupProviderComponent extends BaseAcceptComponent {
protected logo = BitwardenLogo;
failedShortMessage = "inviteAcceptFailedShort";
failedMessage = "inviteAcceptFailed";
@@ -20,4 +22,8 @@ export class SetupProviderComponent extends BaseAcceptComponent {
async unauthedHandler(qParams: Params) {
// Empty
}
login() {
this.router.navigate(["/login"], { queryParams: { email: this.email } });
}
}

View File

@@ -38,7 +38,7 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
@@ -46,7 +46,7 @@
</ng-container>
<ng-container *ngIf="!loading">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53" class="table table-hover table-list">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53" class="tw-overflow-hidden">
<ng-container header>
<th colspan="2" bitCell bitSortable="organizationName" default>{{ "client" | i18n }}</th>
<th bitCell bitSortable="seats">{{ "assigned" | i18n }}</th>

View File

@@ -1173,9 +1173,7 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
KeyGenerationServiceAbstraction,
LogService,
MasterPasswordServiceAbstraction,
StateProvider,
StateServiceAbstraction,
],
}),
safeProvider({

View File

@@ -1,4 +1,4 @@
import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { PinKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
@@ -90,17 +90,6 @@ export abstract class PinServiceAbstraction {
*/
abstract clearUserKeyEncryptedPin(userId: UserId): Promise<void>;
/**
* Gets the old MasterKey, encrypted by the PinKey (formerly called `pinProtected`).
* Deprecated and used for migration purposes only.
*/
abstract getOldPinKeyEncryptedMasterKey: (userId: UserId) => Promise<EncryptedString | null>;
/**
* Clears the old MasterKey, encrypted by the PinKey.
*/
abstract clearOldPinKeyEncryptedMasterKey: (userId: UserId) => Promise<void>;
/**
* Makes a PinKey from the provided PIN.
*/

View File

@@ -4,11 +4,9 @@ import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import {
@@ -18,7 +16,7 @@ import {
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key";
import { PinKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfig, KdfConfigService } from "@bitwarden/key-management";
import { PinServiceAbstraction } from "../../abstractions/pin.service.abstraction";
@@ -73,19 +71,6 @@ export const USER_KEY_ENCRYPTED_PIN = new UserKeyDefinition<EncryptedString>(
},
);
/**
* The old MasterKey, encrypted by the PinKey (formerly called `pinProtected`).
* Deprecated and used for migration purposes only.
*/
export const OLD_PIN_KEY_ENCRYPTED_MASTER_KEY = new UserKeyDefinition<EncryptedString>(
PIN_DISK,
"oldPinKeyEncryptedMasterKey",
{
deserializer: (jsonValue) => jsonValue,
clearOn: ["logout"],
},
);
export class PinService implements PinServiceAbstraction {
constructor(
private accountService: AccountService,
@@ -94,9 +79,7 @@ export class PinService implements PinServiceAbstraction {
private kdfConfigService: KdfConfigService,
private keyGenerationService: KeyGenerationService,
private logService: LogService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private stateProvider: StateProvider,
private stateService: StateService,
) {}
async getPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null> {
@@ -190,9 +173,7 @@ export class PinService implements PinServiceAbstraction {
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const pinKey = await this.makePinKey(pin, email, kdfConfig);
return await this.encryptService.encrypt(userKey.key, pinKey);
}
@@ -242,20 +223,6 @@ export class PinService implements PinServiceAbstraction {
return await this.encryptService.encrypt(pin, userKey);
}
async getOldPinKeyEncryptedMasterKey(userId: UserId): Promise<EncryptedString | null> {
this.validateUserId(userId, "Cannot get oldPinKeyEncryptedMasterKey.");
return await firstValueFrom(
this.stateProvider.getUserState$(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, userId),
);
}
async clearOldPinKeyEncryptedMasterKey(userId: UserId): Promise<void> {
this.validateUserId(userId, "Cannot clear oldPinKeyEncryptedMasterKey.");
await this.stateProvider.setUserState(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, null, userId);
}
async makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey> {
const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdfConfig);
return (await this.keyGenerationService.stretchKey(pinKey)) as PinKey;
@@ -264,23 +231,13 @@ export class PinService implements PinServiceAbstraction {
async getPinLockType(userId: UserId): Promise<PinLockType> {
this.validateUserId(userId, "Cannot get PinLockType.");
/**
* We can't check the `userKeyEncryptedPin` (formerly called `protectedPin`) for both because old
* accounts only used it for MP on Restart
*/
const aUserKeyEncryptedPinIsSet = !!(await this.getUserKeyEncryptedPin(userId));
const aPinKeyEncryptedUserKeyPersistentIsSet =
!!(await this.getPinKeyEncryptedUserKeyPersistent(userId));
const anOldPinKeyEncryptedMasterKeyIsSet =
!!(await this.getOldPinKeyEncryptedMasterKey(userId));
if (aPinKeyEncryptedUserKeyPersistentIsSet || anOldPinKeyEncryptedMasterKeyIsSet) {
if (aPinKeyEncryptedUserKeyPersistentIsSet) {
return "PERSISTENT";
} else if (
aUserKeyEncryptedPinIsSet &&
!aPinKeyEncryptedUserKeyPersistentIsSet &&
!anOldPinKeyEncryptedMasterKeyIsSet
) {
} else if (aUserKeyEncryptedPinIsSet && !aPinKeyEncryptedUserKeyPersistentIsSet) {
return "EPHEMERAL";
} else {
return "DISABLED";
@@ -302,7 +259,7 @@ export class PinService implements PinServiceAbstraction {
case "DISABLED":
return false;
case "PERSISTENT":
// The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey or OldPinKeyEncryptedMasterKey set.
// The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey set.
return true;
case "EPHEMERAL": {
// The above getPinLockType call ensures that we have a UserKeyEncryptedPin set.
@@ -326,31 +283,21 @@ export class PinService implements PinServiceAbstraction {
try {
const pinLockType = await this.getPinLockType(userId);
const requireMasterPasswordOnClientRestart = pinLockType === "EPHEMERAL";
const { pinKeyEncryptedUserKey, oldPinKeyEncryptedMasterKey } =
await this.getPinKeyEncryptedKeys(pinLockType, userId);
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedKeys(pinLockType, userId);
const email = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
let userKey: UserKey;
if (oldPinKeyEncryptedMasterKey) {
userKey = await this.decryptAndMigrateOldPinKeyEncryptedMasterKey(
userId,
pin,
email,
kdfConfig,
requireMasterPasswordOnClientRestart,
oldPinKeyEncryptedMasterKey,
);
} else {
userKey = await this.decryptUserKey(userId, pin, email, kdfConfig, pinKeyEncryptedUserKey);
}
const userKey: UserKey = await this.decryptUserKey(
userId,
pin,
email,
kdfConfig,
pinKeyEncryptedUserKey,
);
if (!userKey) {
this.logService.warning(`User key null after pin key decryption.`);
return null;
@@ -394,109 +341,23 @@ export class PinService implements PinServiceAbstraction {
}
/**
* Creates a new `pinKeyEncryptedUserKey` and clears the `oldPinKeyEncryptedMasterKey`.
* @returns UserKey
*/
private async decryptAndMigrateOldPinKeyEncryptedMasterKey(
userId: UserId,
pin: string,
email: string,
kdfConfig: KdfConfig,
requireMasterPasswordOnClientRestart: boolean,
oldPinKeyEncryptedMasterKey: EncString,
): Promise<UserKey> {
this.validateUserId(userId, "Cannot decrypt and migrate oldPinKeyEncryptedMasterKey.");
const masterKey = await this.decryptMasterKeyWithPin(
userId,
pin,
email,
kdfConfig,
oldPinKeyEncryptedMasterKey,
);
const encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ userId: userId });
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
encUserKey ? new EncString(encUserKey) : undefined,
);
const pinKeyEncryptedUserKey = await this.createPinKeyEncryptedUserKey(pin, userKey, userId);
await this.storePinKeyEncryptedUserKey(
pinKeyEncryptedUserKey,
requireMasterPasswordOnClientRestart,
userId,
);
const userKeyEncryptedPin = await this.createUserKeyEncryptedPin(pin, userKey);
await this.setUserKeyEncryptedPin(userKeyEncryptedPin, userId);
await this.clearOldPinKeyEncryptedMasterKey(userId);
return userKey;
}
// Only for migration purposes
private async decryptMasterKeyWithPin(
userId: UserId,
pin: string,
salt: string,
kdfConfig: KdfConfig,
oldPinKeyEncryptedMasterKey?: EncString,
): Promise<MasterKey> {
this.validateUserId(userId, "Cannot decrypt master key with PIN.");
if (!oldPinKeyEncryptedMasterKey) {
const oldPinKeyEncryptedMasterKeyString = await this.getOldPinKeyEncryptedMasterKey(userId);
if (oldPinKeyEncryptedMasterKeyString == null) {
throw new Error("No oldPinKeyEncrytedMasterKey found.");
}
oldPinKeyEncryptedMasterKey = new EncString(oldPinKeyEncryptedMasterKeyString);
}
const pinKey = await this.makePinKey(pin, salt, kdfConfig);
const masterKey = await this.encryptService.decryptToBytes(oldPinKeyEncryptedMasterKey, pinKey);
return new SymmetricCryptoKey(masterKey) as MasterKey;
}
/**
* Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral) and `oldPinKeyEncryptedMasterKey`
* Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral)
* (if one exists) based on the user's PinLockType.
*
* @remarks The `oldPinKeyEncryptedMasterKey` (formerly `pinProtected`) is only used for migration and
* will be null for all migrated accounts.
* @throws If PinLockType is 'DISABLED' or if userId is not provided
*/
private async getPinKeyEncryptedKeys(
pinLockType: PinLockType,
userId: UserId,
): Promise<{ pinKeyEncryptedUserKey: EncString; oldPinKeyEncryptedMasterKey?: EncString }> {
): Promise<EncString> {
this.validateUserId(userId, "Cannot get PinKey encrypted keys.");
switch (pinLockType) {
case "PERSISTENT": {
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyPersistent(userId);
const oldPinKeyEncryptedMasterKey = await this.getOldPinKeyEncryptedMasterKey(userId);
return {
pinKeyEncryptedUserKey,
oldPinKeyEncryptedMasterKey: oldPinKeyEncryptedMasterKey
? new EncString(oldPinKeyEncryptedMasterKey)
: undefined,
};
return await this.getPinKeyEncryptedUserKeyPersistent(userId);
}
case "EPHEMERAL": {
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyEphemeral(userId);
return {
pinKeyEncryptedUserKey,
oldPinKeyEncryptedMasterKey: undefined, // Going forward, we only migrate non-ephemeral version
};
return await this.getPinKeyEncryptedUserKeyEphemeral(userId);
}
case "DISABLED":
throw new Error("Pin is disabled");

View File

@@ -1,11 +1,9 @@
import { mock } from "jest-mock-extended";
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";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
@@ -15,14 +13,13 @@ import {
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key";
import { PinKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService } from "@bitwarden/key-management";
import {
PinService,
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
PIN_KEY_ENCRYPTED_USER_KEY_EPHEMERAL,
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
USER_KEY_ENCRYPTED_PIN,
PinLockType,
} from "./pin.service.implementation";
@@ -31,7 +28,6 @@ describe("PinService", () => {
let sut: PinService;
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
let stateProvider: FakeStateProvider;
const cryptoFunctionService = mock<CryptoFunctionService>();
@@ -39,11 +35,9 @@ describe("PinService", () => {
const kdfConfigService = mock<KdfConfigService>();
const keyGenerationService = mock<KeyGenerationService>();
const logService = mock<LogService>();
const stateService = mock<StateService>();
const mockUserId = Utils.newGuid() as UserId;
const mockUserKey = new SymmetricCryptoKey(randomBytes(64)) as UserKey;
const mockMasterKey = new SymmetricCryptoKey(randomBytes(32)) as MasterKey;
const mockPinKey = new SymmetricCryptoKey(randomBytes(32)) as PinKey;
const mockUserEmail = "user@example.com";
const mockPin = "1234";
@@ -57,15 +51,10 @@ describe("PinService", () => {
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=",
);
const oldPinKeyEncryptedMasterKeyPostMigration: any = null;
const oldPinKeyEncryptedMasterKeyPreMigrationPersistent =
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=";
beforeEach(() => {
jest.clearAllMocks();
accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail });
masterPasswordService = new FakeMasterPasswordService();
stateProvider = new FakeStateProvider(accountService);
sut = new PinService(
@@ -75,9 +64,7 @@ describe("PinService", () => {
kdfConfigService,
keyGenerationService,
logService,
masterPasswordService,
stateProvider,
stateService,
);
});
@@ -111,12 +98,6 @@ describe("PinService", () => {
await expect(sut.clearUserKeyEncryptedPin(undefined)).rejects.toThrow(
"User ID is required. Cannot clear userKeyEncryptedPin.",
);
await expect(sut.getOldPinKeyEncryptedMasterKey(undefined)).rejects.toThrow(
"User ID is required. Cannot get oldPinKeyEncryptedMasterKey.",
);
await expect(sut.clearOldPinKeyEncryptedMasterKey(undefined)).rejects.toThrow(
"User ID is required. Cannot clear oldPinKeyEncryptedMasterKey.",
);
await expect(
sut.createPinKeyEncryptedUserKey(mockPin, mockUserKey, undefined),
).rejects.toThrow("User ID is required. Cannot create pinKeyEncryptedUserKey.");
@@ -288,31 +269,6 @@ describe("PinService", () => {
});
});
describe("oldPinKeyEncryptedMasterKey methods", () => {
describe("getOldPinKeyEncryptedMasterKey()", () => {
it("should get the oldPinKeyEncryptedMasterKey of the specified userId", async () => {
await sut.getOldPinKeyEncryptedMasterKey(mockUserId);
expect(stateProvider.mock.getUserState$).toHaveBeenCalledWith(
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
mockUserId,
);
});
});
describe("clearOldPinKeyEncryptedMasterKey()", () => {
it("should clear the oldPinKeyEncryptedMasterKey of the specified userId", async () => {
await sut.clearOldPinKeyEncryptedMasterKey(mockUserId);
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
null,
mockUserId,
);
});
});
});
describe("makePinKey()", () => {
it("should make a PinKey", async () => {
// Arrange
@@ -346,26 +302,10 @@ describe("PinService", () => {
expect(result).toBe("PERSISTENT");
});
it("should return 'PERSISTENT' if an old oldPinKeyEncryptedMasterKey is found", async () => {
// Arrange
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(null);
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
sut.getOldPinKeyEncryptedMasterKey = jest
.fn()
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPreMigrationPersistent);
// Act
const result = await sut.getPinLockType(mockUserId);
// Assert
expect(result).toBe("PERSISTENT");
});
it("should return 'EPHEMERAL' if neither a pinKeyEncryptedUserKey (persistent version) nor an old oldPinKeyEncryptedMasterKey are found, but a userKeyEncryptedPin is found", async () => {
it("should return 'EPHEMERAL' if a pinKeyEncryptedUserKey (persistent version) is not found but a userKeyEncryptedPin is found", async () => {
// Arrange
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
sut.getOldPinKeyEncryptedMasterKey = jest.fn().mockResolvedValue(null);
// Act
const result = await sut.getPinLockType(mockUserId);
@@ -374,11 +314,10 @@ describe("PinService", () => {
expect(result).toBe("EPHEMERAL");
});
it("should return 'DISABLED' if ALL three of these are NOT found: userKeyEncryptedPin, pinKeyEncryptedUserKey (persistent version), oldPinKeyEncryptedMasterKey", async () => {
it("should return 'DISABLED' if both of these are NOT found: userKeyEncryptedPin, pinKeyEncryptedUserKey (persistent version)", async () => {
// Arrange
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(null);
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
sut.getOldPinKeyEncryptedMasterKey = jest.fn().mockResolvedValue(null);
// Act
const result = await sut.getPinLockType(mockUserId);
@@ -476,46 +415,20 @@ describe("PinService", () => {
});
describe("decryptUserKeyWithPin()", () => {
async function setupDecryptUserKeyWithPinMocks(
pinLockType: PinLockType,
migrationStatus: "PRE" | "POST" = "POST",
) {
async function setupDecryptUserKeyWithPinMocks(pinLockType: PinLockType) {
sut.getPinLockType = jest.fn().mockResolvedValue(pinLockType);
mockPinEncryptedKeyDataByPinLockType(pinLockType, migrationStatus);
mockPinEncryptedKeyDataByPinLockType(pinLockType);
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
if (pinLockType === "PERSISTENT" && migrationStatus === "PRE") {
await mockDecryptAndMigrateOldPinKeyEncryptedMasterKeyFn();
} else {
mockDecryptUserKeyFn();
}
mockDecryptUserKeyFn();
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
encryptService.decryptToUtf8.mockResolvedValue(mockPin);
cryptoFunctionService.compareFast.calledWith(mockPin, "1234").mockResolvedValue(true);
}
async function mockDecryptAndMigrateOldPinKeyEncryptedMasterKeyFn() {
sut.makePinKey = jest.fn().mockResolvedValue(mockPinKey);
encryptService.decryptToBytes.mockResolvedValue(mockMasterKey.key);
stateService.getEncryptedCryptoSymmetricKey.mockResolvedValue(mockUserKey.keyB64);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey);
sut.createPinKeyEncryptedUserKey = jest
.fn()
.mockResolvedValue(pinKeyEncryptedUserKeyPersistant);
await sut.storePinKeyEncryptedUserKey(pinKeyEncryptedUserKeyPersistant, false, mockUserId);
sut.createUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
await sut.setUserKeyEncryptedPin(mockUserKeyEncryptedPin, mockUserId);
await sut.clearOldPinKeyEncryptedMasterKey(mockUserId);
}
function mockDecryptUserKeyFn() {
sut.getPinKeyEncryptedUserKeyPersistent = jest
.fn()
@@ -524,26 +437,12 @@ describe("PinService", () => {
encryptService.decryptToBytes.mockResolvedValue(mockUserKey.key);
}
function mockPinEncryptedKeyDataByPinLockType(
pinLockType: PinLockType,
migrationStatus: "PRE" | "POST" = "POST",
) {
function mockPinEncryptedKeyDataByPinLockType(pinLockType: PinLockType) {
switch (pinLockType) {
case "PERSISTENT":
sut.getPinKeyEncryptedUserKeyPersistent = jest
.fn()
.mockResolvedValue(pinKeyEncryptedUserKeyPersistant);
if (migrationStatus === "PRE") {
sut.getOldPinKeyEncryptedMasterKey = jest
.fn()
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPreMigrationPersistent);
} else {
sut.getOldPinKeyEncryptedMasterKey = jest
.fn()
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPostMigration); // null
}
break;
case "EPHEMERAL":
sut.getPinKeyEncryptedUserKeyEphemeral = jest
@@ -557,49 +456,16 @@ describe("PinService", () => {
}
}
const testCases: { pinLockType: PinLockType; migrationStatus: "PRE" | "POST" }[] = [
{ pinLockType: "PERSISTENT", migrationStatus: "PRE" },
{ pinLockType: "PERSISTENT", migrationStatus: "POST" },
{ pinLockType: "EPHEMERAL", migrationStatus: "POST" },
const testCases: { pinLockType: PinLockType }[] = [
{ pinLockType: "PERSISTENT" },
{ pinLockType: "EPHEMERAL" },
];
testCases.forEach(({ pinLockType, migrationStatus }) => {
describe(`given a ${pinLockType} PIN (${migrationStatus} migration)`, () => {
if (pinLockType === "PERSISTENT" && migrationStatus === "PRE") {
it("should clear the oldPinKeyEncryptedMasterKey from state", async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
// Act
await sut.decryptUserKeyWithPin(mockPin, mockUserId);
// Assert
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
null,
mockUserId,
);
});
it("should set the new pinKeyEncrypterUserKeyPersistent to state", async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
// Act
await sut.decryptUserKeyWithPin(mockPin, mockUserId);
// Assert
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
pinKeyEncryptedUserKeyPersistant.encryptedString,
mockUserId,
);
});
}
testCases.forEach(({ pinLockType }) => {
describe(`given a ${pinLockType} PIN)`, () => {
it(`should successfully decrypt and return user key when using a valid PIN`, async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
await setupDecryptUserKeyWithPinMocks(pinLockType);
// Act
const result = await sut.decryptUserKeyWithPin(mockPin, mockUserId);
@@ -610,7 +476,7 @@ describe("PinService", () => {
it(`should return null when PIN is incorrect and user key cannot be decrypted`, async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
await setupDecryptUserKeyWithPinMocks(pinLockType);
sut.decryptUserKeyWithPin = jest.fn().mockResolvedValue(null);
// Act
@@ -623,7 +489,7 @@ describe("PinService", () => {
// not sure if this is a realistic scenario but going to test it anyway
it(`should return null when PIN doesn't match after successful user key decryption`, async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
await setupDecryptUserKeyWithPinMocks(pinLockType);
encryptService.decryptToUtf8.mockResolvedValue("9999"); // non matching PIN
// Act

View File

@@ -2,7 +2,6 @@
// @ts-strict-ignore
import { ListResponse } from "../../models/response/list.response";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
@@ -25,10 +24,7 @@ export abstract class DevicesApiServiceAbstraction {
deviceIdentifier: string,
) => Promise<void>;
getDeviceKeys: (
deviceIdentifier: string,
secretVerificationRequest: SecretVerificationRequest,
) => Promise<ProtectedDeviceResponse>;
getDeviceKeys: (deviceIdentifier: string) => Promise<ProtectedDeviceResponse>;
/**
* Notifies the server that the device has a device key, but didn't receive any associated decryption keys.

View File

@@ -13,5 +13,5 @@ export class DeviceKeysUpdateRequest {
}
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
id: string;
deviceId: string;
}

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { RotateableKeySet } from "@bitwarden/auth/common";
import { DeviceType } from "../../../enums";
import { BaseResponse } from "../../../models/response/base.response";
import { EncString } from "../../../platform/models/domain/enc-string";
@@ -38,4 +40,12 @@ export class ProtectedDeviceResponse extends BaseResponse {
* This enabled a user to rotate the keys for all of their devices.
*/
encryptedPublicKey: EncString;
getRotateableKeyset(): RotateableKeySet {
return new RotateableKeySet(this.encryptedUserKey, this.encryptedPublicKey);
}
isTrusted(): boolean {
return this.encryptedUserKey != null && this.encryptedPublicKey != null;
}
}

View File

@@ -5,7 +5,6 @@ import { ListResponse } from "../../models/response/list.response";
import { Utils } from "../../platform/misc/utils";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
@@ -90,14 +89,11 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
);
}
async getDeviceKeys(
deviceIdentifier: string,
secretVerificationRequest: SecretVerificationRequest,
): Promise<ProtectedDeviceResponse> {
async getDeviceKeys(deviceIdentifier: string): Promise<ProtectedDeviceResponse> {
const result = await this.apiService.send(
"POST",
`/devices/${deviceIdentifier}/retrieve-keys`,
secretVerificationRequest,
null,
true,
true,
);

View File

@@ -2,6 +2,8 @@
* Feature flags.
*
* Flags MUST be short lived and SHALL be removed once enabled.
*
* Flags should be grouped by team to have visibility of ownership and cleanup.
*/
export enum FeatureFlag {
/* Admin Console Team */
@@ -9,6 +11,10 @@ export enum FeatureFlag {
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility",
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
/* Autofill */
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
@@ -16,11 +22,22 @@ export enum FeatureFlag {
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
IdpAutoSubmitLogin = "idp-auto-submit-login",
InlineMenuFieldQualification = "inline-menu-field-qualification",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
MacOsNativeCredentialSync = "macos-native-credential-sync",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
UserKeyRotationV2 = "userkey-rotation-v2",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
/* Tools */
ItemShare = "item-share",
@@ -36,22 +53,8 @@ export enum FeatureFlag {
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks",
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
UserKeyRotationV2 = "userkey-rotation-v2",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
CipherKeyEncryption = "cipher-key-encryption",
TrialPaymentOptional = "PM-8163-trial-payment",
MacOsNativeCredentialSync = "macos-native-credential-sync",
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM9115_TwoFactorFormPersistence = "pm-9115-two-factor-form-persistence",
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -64,6 +67,8 @@ const FALSE = false as boolean;
*
* DO NOT enable previously disabled flags, REMOVE them instead.
* We support true as a value as we prefer flags to "enable" not "disable".
*
* Flags should be grouped by team to have visibility of ownership and cleanup.
*/
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
@@ -71,6 +76,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
[FeatureFlag.LimitItemDeletion]: FALSE,
[FeatureFlag.SsoExternalIdVisibility]: FALSE,
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
/* Autofill */
[FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE,
@@ -78,11 +84,11 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
/* Tools */
[FeatureFlag.ItemShare]: FALSE,
@@ -98,22 +104,22 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
/* Auth */
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
[FeatureFlag.UserKeyRotationV2]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
[FeatureFlag.PM9115_TwoFactorFormPersistence]: FALSE,
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.UserKeyRotationV2]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid";
@@ -55,4 +57,9 @@ export abstract class DeviceTrustServiceAbstraction {
* Note: For debugging purposes only.
*/
recordDeviceTrustLoss: () => Promise<void>;
getRotatedData: (
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
) => Promise<DeviceKeysUpdateRequest[]>;
}

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { RotateableKeySet, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
@@ -10,6 +10,7 @@ import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
import {
DeviceKeysUpdateRequest,
OtherDeviceKeysUpdateRequest,
UpdateDevicesTrustRequest,
} from "../../../auth/models/request/update-devices-trust.request";
import { AppIdService } from "../../../platform/abstractions/app-id.service";
@@ -187,6 +188,51 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
return deviceResponse;
}
async getRotatedData(
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<DeviceKeysUpdateRequest[]> {
if (!userId) {
throw new Error("UserId is required. Cannot get rotated data.");
}
if (!oldUserKey) {
throw new Error("Old user key is required. Cannot get rotated data.");
}
if (!newUserKey) {
throw new Error("New user key is required. Cannot get rotated data.");
}
const devices = await this.devicesApiService.getDevices();
return await Promise.all(
devices.data
.filter((device) => device.isTrusted)
.map(async (device) => {
const deviceWithKeys = await this.devicesApiService.getDeviceKeys(device.identifier);
const publicKey = await this.encryptService.decryptToBytes(
deviceWithKeys.encryptedPublicKey,
oldUserKey,
);
const newEncryptedPublicKey = await this.encryptService.encrypt(publicKey, newUserKey);
const newEncryptedUserKey = await this.encryptService.rsaEncrypt(
newUserKey.key,
publicKey,
);
const newRotateableKeySet = new RotateableKeySet(
newEncryptedUserKey,
newEncryptedPublicKey,
);
const request = new OtherDeviceKeysUpdateRequest();
request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString;
request.encryptedUserKey = newRotateableKeySet.encryptedUserKey.encryptedString;
request.deviceId = device.id;
return request;
}),
);
}
async rotateDevicesTrust(
userId: UserId,
newUserKey: UserKey,
@@ -216,10 +262,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
secretVerificationRequest.masterPasswordHash = masterPasswordHash;
// Get the keys that are used in rotating a devices keys from the server
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(
deviceIdentifier,
secretVerificationRequest,
);
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(deviceIdentifier);
// Decrypt the existing device public key with the old user key
const decryptedDevicePublicKey = await this.encryptService.decryptToBytes(

View File

@@ -7,6 +7,7 @@ import {
UserDecryptionOptionsServiceAbstraction,
UserDecryptionOptions,
} from "@bitwarden/auth/common";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { KeyService } from "@bitwarden/key-management";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service";
@@ -655,6 +656,86 @@ describe("deviceTrustService", () => {
});
});
describe("getRotatedData", () => {
let fakeNewUserKey: UserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
let fakeOldUserKey: UserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const userId: UserId = Utils.newGuid() as UserId;
it("throws an error when a null user id is passed in", async () => {
await expect(
deviceTrustService.getRotatedData(fakeOldUserKey, fakeNewUserKey, null),
).rejects.toThrow("UserId is required. Cannot get rotated data.");
});
it("throws an error when a null old user key is passed in", async () => {
await expect(
deviceTrustService.getRotatedData(null, fakeNewUserKey, userId),
).rejects.toThrow("Old user key is required. Cannot get rotated data.");
});
it("throws an error when a null new user key is passed in", async () => {
await expect(
deviceTrustService.getRotatedData(fakeOldUserKey, null, userId),
).rejects.toThrow("New user key is required. Cannot get rotated data.");
});
it("returns the expected data when all required parameters are provided", async () => {
const deviceResponse = {
id: "",
userId: "",
name: "",
identifier: "",
type: DeviceType.Android,
creationDate: "",
revisionDate: "",
isTrusted: true,
};
devicesApiService.getDevices.mockResolvedValue(
new ListResponse(
{
data: [deviceResponse],
},
DeviceResponse,
),
);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(64));
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
encryptService.rsaEncrypt.mockResolvedValue(new EncString("test_encrypted_data"));
const protectedDeviceResponse = new ProtectedDeviceResponse({
id: "",
creationDate: "",
identifier: "test_device_identifier",
name: "Firefox",
type: DeviceType.FirefoxBrowser,
encryptedPublicKey: "",
encryptedUserKey: "",
});
devicesApiService.getDeviceKeys.mockResolvedValue(protectedDeviceResponse);
const fakeOldUserKeyData = new Uint8Array(64);
fakeOldUserKeyData.fill(5, 0, 1);
fakeOldUserKey = new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey;
const fakeNewUserKeyData = new Uint8Array(64);
fakeNewUserKeyData.fill(1, 0, 1);
fakeNewUserKey = new SymmetricCryptoKey(fakeNewUserKeyData) as UserKey;
const result = await deviceTrustService.getRotatedData(
fakeOldUserKey,
fakeNewUserKey,
userId,
);
expect(result).toEqual([
{
deviceId: "",
encryptedUserKey: "test_encrypted_data",
encryptedPublicKey: "test_encrypted_data",
},
]);
});
});
describe("rotateDevicesTrust", () => {
let fakeNewUserKey: UserKey = null;
@@ -708,11 +789,8 @@ describe("deviceTrustService", () => {
appIdService.getAppId.mockResolvedValue("test_device_identifier");
devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier, secretRequest) => {
if (
deviceIdentifier !== "test_device_identifier" ||
secretRequest.masterPasswordHash !== "my_password_hash"
) {
devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier) => {
if (deviceIdentifier !== "test_device_identifier") {
return Promise.resolve(null);
}

View File

@@ -163,19 +163,6 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
throw new Error("No master key found.");
}
// Try one more way to get the user key if it still wasn't found.
if (userKey == null) {
const deprecatedKey = await this.stateService.getEncryptedCryptoSymmetricKey({
userId: userId,
});
if (deprecatedKey == null) {
throw new Error("No encrypted user key found.");
}
userKey = new EncString(deprecatedKey);
}
let decUserKey: Uint8Array;
if (userKey.encryptionType === EncryptionType.AesCbc256_B64) {

View File

@@ -147,7 +147,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.masterPasswordService.clearMasterKey(lockingUserId);
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
await this.stateService.setCryptoMasterKeyAuto(null, { userId: lockingUserId });
await this.cipherService.clearCache(lockingUserId);

View File

@@ -50,14 +50,6 @@ export abstract class StateService<T extends Account = Account> {
value: boolean,
options?: StorageOptions,
) => Promise<void>;
/**
* @deprecated For migration purposes only, use getUserKeyMasterKey instead
*/
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
/**
* @deprecated For migration purposes only, use setUserKeyAuto instead
*/
setCryptoMasterKeyAuto: (value: string | null, options?: StorageOptions) => Promise<void>;
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;

View File

@@ -48,8 +48,6 @@ export class EncryptionPair<TEncrypted, TDecrypted> {
export class AccountKeys {
publicKey?: Uint8Array;
/** @deprecated July 2023, left for migration purposes*/
cryptoMasterKeyAuto?: string;
/** @deprecated July 2023, left for migration purposes*/
cryptoSymmetricKey?: EncryptionPair<string, SymmetricCryptoKey> = new EncryptionPair<
string,

View File

@@ -222,45 +222,6 @@ export class StateService<
await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options);
}
/**
* @deprecated Use UserKeyAuto instead
*/
async setCryptoMasterKeyAuto(value: string | null, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(
this.reconcileOptions(options, { keySuffix: "auto" }),
await this.defaultSecureStorageOptions(),
);
if (options?.userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.autoKey, value, options);
}
/**
* @deprecated I don't see where this is even used
*/
async getCryptoMasterKeyB64(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return null;
}
return await this.secureStorageService.get<string>(
`${options?.userId}${partialKeys.masterKey}`,
options,
);
}
/**
* @deprecated I don't see where this is even used
*/
async setCryptoMasterKeyB64(value: string, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.masterKey, value, options);
}
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
@@ -619,8 +580,6 @@ export class StateService<
await this.setUserKeyAutoUnlock(null, { userId: userId });
await this.setUserKeyBiometric(null, { userId: userId });
await this.setCryptoMasterKeyAuto(null, { userId: userId });
await this.setCryptoMasterKeyB64(null, { userId: userId });
}
protected async removeAccountFromMemory(userId: string = null): Promise<void> {

View File

@@ -390,14 +390,6 @@ export abstract class KeyService {
publicKey: string;
privateKey: EncString;
}>;
/**
* Previously, the master key was used for any additional key like the biometrics or pin key.
* We have switched to using the user key for these purposes. This method is for clearing the state
* of the older keys on logout or post migration.
* @param keySuffix The desired type of key to clear
* @param userId The desired user
*/
abstract clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string): Promise<void>;
/**
* Retrieves all the keys needed for decrypting Ciphers

View File

@@ -39,4 +39,5 @@ export abstract class BiometricsService {
abstract getShouldAutopromptNow(): Promise<boolean>;
abstract setShouldAutopromptNow(value: boolean): Promise<void>;
abstract canEnableBiometricUnlock(): Promise<boolean>;
}

View File

@@ -8,6 +8,9 @@ export enum BiometricsCommands {
/** Get biometric status for a specific user account. This includes both information about availability of cryptographic material (is the user configured for biometric unlock? is a masterpassword unlock needed? But also information about the biometric system's availability in a single status) */
GetBiometricsStatusForUser = "getBiometricsStatusForUser",
/** Checks whether the biometric unlock can be enabled. */
CanEnableBiometricUnlock = "canEnableBiometricUnlock",
// legacy
Unlock = "biometricUnlock",
IsAvailable = "biometricUnlockAvailable",

View File

@@ -252,14 +252,6 @@ describe("keyService", () => {
userId: mockUserId,
});
});
it("clears the old deprecated Auto key whenever a User Key is set", async () => {
await keyService.setUserKey(mockUserKey, mockUserId);
expect(stateService.setCryptoMasterKeyAuto).toHaveBeenCalledWith(null, {
userId: mockUserId,
});
});
});
it("throws if key is null", async () => {

View File

@@ -254,16 +254,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
// 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
this.stateService.setUserKeyAutoUnlock(null, { userId: userId });
// 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
this.clearDeprecatedKeys(KeySuffixOptions.Auto, userId);
}
if (keySuffix === KeySuffixOptions.Pin && userId != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId);
// 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
this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId);
}
}
@@ -565,7 +559,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
await this.pinService.clearPinKeyEncryptedUserKeyPersistent(userId);
await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId);
await this.pinService.clearUserKeyEncryptedPin(userId);
await this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId);
}
async makeSendKey(keyMaterial: CsprngArray): Promise<SymmetricCryptoKey> {
@@ -726,7 +719,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
} else {
await this.stateService.setUserKeyAutoUnlock(null, { userId: userId });
}
await this.clearDeprecatedKeys(KeySuffixOptions.Auto, userId);
const storePin = await this.shouldStoreKey(KeySuffixOptions.Pin, userId);
if (storePin) {
@@ -749,9 +741,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
noPreExistingPersistentKey,
userId,
);
// We can't always clear deprecated keys because the pin is only
// migrated once used to unlock
await this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId);
} else {
await this.pinService.clearPinKeyEncryptedUserKeyPersistent(userId);
await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId);
@@ -835,19 +824,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return [new SymmetricCryptoKey(newSymKey) as T, protectedSymKey];
}
// --LEGACY METHODS--
// We previously used the master key for additional keys, but now we use the user key.
// These methods support migrating the old keys to the new ones.
// TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3475)
async clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: UserId) {
if (keySuffix === KeySuffixOptions.Auto) {
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
} else if (keySuffix === KeySuffixOptions.Pin && userId != null) {
await this.pinService.clearOldPinKeyEncryptedMasterKey(userId);
}
}
userKey$(userId: UserId): Observable<UserKey | null> {
return this.stateProvider.getUser(userId, USER_KEY).state$;
}

View File

@@ -126,7 +126,7 @@ export class IndividualVaultExportService
return {
type: "application/zip",
data: blobData,
fileName: ExportHelper.getFileName("", "json"),
fileName: ExportHelper.getFileName("", "zip"),
} as ExportedVaultAsBlob;
}
@@ -250,7 +250,7 @@ export class IndividualVaultExportService
return {
type: "text/plain",
data: JSON.stringify(jsonDoc, null, " "),
fileName: ExportHelper.getFileName("", "json"),
fileName: ExportHelper.getFileName("", "encrypted_json"),
} as ExportedVaultAsString;
}

View File

@@ -109,7 +109,7 @@ export class OrganizationVaultExportService
data: onlyManagedCollections
? await this.getEncryptedManagedExport(organizationId)
: await this.getOrganizationEncryptedExport(organizationId),
fileName: ExportHelper.getFileName("org", "json"),
fileName: ExportHelper.getFileName("org", "encrypted_json"),
} as ExportedVaultAsString;
}

View File

@@ -225,6 +225,20 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
),
);
combineLatest([
this.exportForm.controls.vaultSelector.valueChanges,
this.isExportAttachmentsEnabled$,
])
.pipe(takeUntil(this.destroy$))
.subscribe(([value, isExportAttachmentsEnabled]) => {
this.organizationId = value !== "myVault" ? value : undefined;
this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip");
if (value === "myVault" && isExportAttachmentsEnabled) {
this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" });
}
});
merge(
this.exportForm.get("format").valueChanges,
this.exportForm.get("fileEncryptionType").valueChanges,
@@ -322,22 +336,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
takeUntil(this.destroy$),
)
.subscribe();
combineLatest([
this.exportForm.controls.vaultSelector.valueChanges,
this.isExportAttachmentsEnabled$,
])
.pipe(takeUntil(this.destroy$))
.subscribe(([value, isExportAttachmentsEnabled]) => {
this.organizationId = value !== "myVault" ? value : undefined;
if (value === "myVault" && isExportAttachmentsEnabled) {
if (!this.formatOptions.some((option) => option.value === "zip")) {
this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" });
}
} else {
this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip");
}
});
}
ngAfterViewInit(): void {