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:
42
.github/workflows/build-desktop.yml
vendored
42
.github/workflows/build-desktop.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ describe("InlineMenuFieldQualificationService", () => {
|
||||
fields: [],
|
||||
});
|
||||
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
|
||||
inlineMenuFieldQualificationService["inlineMenuFieldQualificationFlagSet"] = true;
|
||||
});
|
||||
|
||||
describe("isFieldForLoginForm", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,10 +318,8 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BiometricsService,
|
||||
useFactory: () => {
|
||||
return new ForegroundBrowserBiometricsService();
|
||||
},
|
||||
deps: [],
|
||||
useClass: ForegroundBrowserBiometricsService,
|
||||
deps: [PlatformUtilsService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SyncService,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,4 +24,7 @@ export class CliBiometricsService extends BiometricsService {
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,9 +436,7 @@ export class ServiceContainer {
|
||||
this.kdfConfigService,
|
||||
this.keyGenerationService,
|
||||
this.logService,
|
||||
this.masterPasswordService,
|
||||
this.stateProvider,
|
||||
this.stateService,
|
||||
);
|
||||
|
||||
this.keyService = new KeyService(
|
||||
|
||||
@@ -248,6 +248,7 @@ describe("SettingsComponent", () => {
|
||||
describe("biometrics enabled", () => {
|
||||
beforeEach(() => {
|
||||
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
|
||||
desktopBiometricsService.canEnableBiometricUnlock.mockResolvedValue(true);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -163,4 +163,8 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return this.shouldAutoPrompt;
|
||||
}
|
||||
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -733,6 +733,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
submit = async () => {
|
||||
if (this.taxComponent !== undefined && !this.taxComponent.validate()) {
|
||||
this.taxComponent.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -24,4 +24,8 @@ export class WebBiometricsService extends BiometricsService {
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ export class VaultItemsComponent {
|
||||
|
||||
return canRestore$;
|
||||
}),
|
||||
map((canRestore) => canRestore && this.showBulkTrashOptions),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1173,9 +1173,7 @@ const safeProviders: SafeProvider[] = [
|
||||
KdfConfigService,
|
||||
KeyGenerationServiceAbstraction,
|
||||
LogService,
|
||||
MasterPasswordServiceAbstraction,
|
||||
StateProvider,
|
||||
StateServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -13,5 +13,5 @@ export class DeviceKeysUpdateRequest {
|
||||
}
|
||||
|
||||
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,4 +39,5 @@ export abstract class BiometricsService {
|
||||
|
||||
abstract getShouldAutopromptNow(): Promise<boolean>;
|
||||
abstract setShouldAutopromptNow(value: boolean): Promise<void>;
|
||||
abstract canEnableBiometricUnlock(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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$;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user