mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 10:23:52 +00:00
Merge remote-tracking branch 'origin/autofill/pm-26089/beeep-use-tracing-in-macos-provider' into feature/passkey-provider
This commit is contained in:
@@ -100,6 +100,7 @@ $icomoon-font-path: "~@bitwarden/angular/src/scss/bwicons/fonts/" !default;
|
||||
}
|
||||
|
||||
// For new icons - add their glyph name and value to the map below
|
||||
// Also add to `libs/components/src/shared/icon.ts`
|
||||
$icons: (
|
||||
"angle-down": "\e900",
|
||||
"angle-left": "\e901",
|
||||
|
||||
@@ -145,12 +145,14 @@ import {
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
|
||||
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
|
||||
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
|
||||
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
|
||||
import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service";
|
||||
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
|
||||
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
|
||||
@@ -170,13 +172,19 @@ import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/ch
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
||||
import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import {
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
MasterPasswordServiceAbstraction,
|
||||
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service";
|
||||
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
|
||||
import {
|
||||
SendPasswordService,
|
||||
DefaultSendPasswordService,
|
||||
@@ -702,6 +710,11 @@ const safeProviders: SafeProvider[] = [
|
||||
KdfConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SecurityStateService,
|
||||
useClass: DefaultSecurityStateService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RestrictedItemTypesService,
|
||||
useClass: RestrictedItemTypesService,
|
||||
@@ -780,6 +793,7 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: InternalSendService,
|
||||
useClass: SendService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
KeyService,
|
||||
I18nServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
@@ -797,6 +811,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: SendApiService,
|
||||
deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyApiService,
|
||||
useClass: DefaultKeyApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SyncService,
|
||||
useClass: DefaultSyncService,
|
||||
@@ -825,6 +844,7 @@ const safeProviders: SafeProvider[] = [
|
||||
TokenServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
StateProvider,
|
||||
SecurityStateService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1059,6 +1079,11 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: MasterPasswordServiceAbstraction,
|
||||
useExisting: InternalMasterPasswordServiceAbstraction,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: MasterPasswordUnlockService,
|
||||
useClass: DefaultMasterPasswordUnlockService,
|
||||
deps: [InternalMasterPasswordServiceAbstraction, KeyService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyConnectorServiceAbstraction,
|
||||
useClass: KeyConnectorService,
|
||||
@@ -1399,6 +1424,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: BillingApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useClass: DefaultOrganizationMetadataService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BillingAccountProfileStateService,
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
@@ -1523,6 +1553,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AccountServiceAbstraction,
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
SecurityStateService,
|
||||
ApiServiceAbstraction,
|
||||
StateProvider,
|
||||
ConfigService,
|
||||
|
||||
@@ -260,12 +260,19 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
if (this.editMode) {
|
||||
this.sendService
|
||||
.get$(this.sendId)
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
//Promise.reject will complete the BehaviourSubject, if desktop starts relying only on BehaviourSubject, this should be changed.
|
||||
concatMap((s) =>
|
||||
s instanceof Send ? s.decrypt() : Promise.reject(new Error("Failed to load send.")),
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.sendService
|
||||
.get$(this.sendId)
|
||||
.pipe(
|
||||
concatMap((s) =>
|
||||
s instanceof Send
|
||||
? s.decrypt(userId)
|
||||
: Promise.reject(new Error("Failed to load send.")),
|
||||
),
|
||||
),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/su
|
||||
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
|
||||
import { PlanResponse } from "../../billing/models/response/plan.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
|
||||
import { InvoicesResponse } from "../models/response/invoices.response";
|
||||
@@ -23,7 +24,11 @@ export abstract class BillingApiServiceAbstraction {
|
||||
): Promise<void>;
|
||||
|
||||
abstract getOrganizationBillingMetadata(
|
||||
organizationId: string,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getOrganizationBillingMetadataVNext(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response";
|
||||
|
||||
export abstract class OrganizationMetadataServiceAbstraction {
|
||||
abstract getOrganizationMetadata$(
|
||||
organizationId: OrganizationId,
|
||||
): Observable<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract refreshMetadataCache(): void;
|
||||
}
|
||||
@@ -1,35 +1,12 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class OrganizationBillingMetadataResponse extends BaseResponse {
|
||||
isEligibleForSelfHost: boolean;
|
||||
isManaged: boolean;
|
||||
isOnSecretsManagerStandalone: boolean;
|
||||
isSubscriptionUnpaid: boolean;
|
||||
hasSubscription: boolean;
|
||||
hasOpenInvoice: boolean;
|
||||
invoiceDueDate: Date | null;
|
||||
invoiceCreatedDate: Date | null;
|
||||
subPeriodEndDate: Date | null;
|
||||
isSubscriptionCanceled: boolean;
|
||||
organizationOccupiedSeats: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
|
||||
this.isManaged = this.getResponseProperty("IsManaged");
|
||||
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
|
||||
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
|
||||
this.hasSubscription = this.getResponseProperty("HasSubscription");
|
||||
this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice");
|
||||
|
||||
this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate"));
|
||||
this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate"));
|
||||
this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate"));
|
||||
this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled");
|
||||
this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats");
|
||||
}
|
||||
|
||||
private parseDate(dateString: any): Date | null {
|
||||
return dateString ? new Date(dateString) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { BillingApiServiceAbstraction } from "../abstractions";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request";
|
||||
@@ -48,7 +49,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadata(
|
||||
organizationId: string,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
@@ -61,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
return new OrganizationBillingMetadataResponse(r);
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadataVNext(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/billing/vnext/metadata",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new OrganizationBillingMetadataResponse(r);
|
||||
}
|
||||
|
||||
async getPlans(): Promise<ListResponse<PlanResponse>> {
|
||||
const r = await this.apiService.send("GET", "/plans", null, false, true);
|
||||
return new ListResponse(r, PlanResponse);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { OrganizationId } from "../../../types/guid";
|
||||
|
||||
import { DefaultOrganizationMetadataService } from "./organization-metadata.service";
|
||||
|
||||
describe("DefaultOrganizationMetadataService", () => {
|
||||
let service: DefaultOrganizationMetadataService;
|
||||
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
let featureFlagSubject: BehaviorSubject<boolean>;
|
||||
|
||||
const mockOrganizationId = newGuid() as OrganizationId;
|
||||
const mockOrganizationId2 = newGuid() as OrganizationId;
|
||||
|
||||
const createMockMetadataResponse = (
|
||||
isOnSecretsManagerStandalone = false,
|
||||
organizationOccupiedSeats = 5,
|
||||
): OrganizationBillingMetadataResponse => {
|
||||
return {
|
||||
isOnSecretsManagerStandalone,
|
||||
organizationOccupiedSeats,
|
||||
} as OrganizationBillingMetadataResponse;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
featureFlagSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
|
||||
|
||||
service = new DefaultOrganizationMetadataService(billingApiService, configService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
featureFlagSubject.complete();
|
||||
});
|
||||
|
||||
describe("getOrganizationMetadata$", () => {
|
||||
describe("feature flag OFF", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(false);
|
||||
});
|
||||
|
||||
it("calls getOrganizationBillingMetadata when feature flag is off", async () => {
|
||||
const mockResponse = createMockMetadataResponse(false, 10);
|
||||
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("does not cache metadata when feature flag is off", async () => {
|
||||
const mockResponse1 = createMockMetadataResponse(false, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(false, 15);
|
||||
billingApiService.getOrganizationBillingMetadata
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
|
||||
expect(result1).toEqual(mockResponse1);
|
||||
expect(result2).toEqual(mockResponse2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature flag ON", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(true);
|
||||
});
|
||||
|
||||
it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => {
|
||||
const mockResponse = createMockMetadataResponse(true, 15);
|
||||
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("caches metadata by organization ID when feature flag is on", async () => {
|
||||
const mockResponse = createMockMetadataResponse(true, 10);
|
||||
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
|
||||
expect(result1).toEqual(mockResponse);
|
||||
expect(result2).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("maintains separate cache entries for different organization IDs", async () => {
|
||||
const mockResponse1 = createMockMetadataResponse(true, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(false, 20);
|
||||
billingApiService.getOrganizationBillingMetadataVNext
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
|
||||
const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
|
||||
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrganizationId2,
|
||||
);
|
||||
expect(result1).toEqual(mockResponse1);
|
||||
expect(result2).toEqual(mockResponse2);
|
||||
expect(result3).toEqual(mockResponse1);
|
||||
expect(result4).toEqual(mockResponse2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareReplay behavior", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(true);
|
||||
});
|
||||
|
||||
it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
|
||||
const mockResponse = createMockMetadataResponse(true, 10);
|
||||
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
|
||||
|
||||
const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
|
||||
|
||||
const subscription1Promise = firstValueFrom(metadata$);
|
||||
const subscription2Promise = firstValueFrom(metadata$);
|
||||
const subscription3Promise = firstValueFrom(metadata$);
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([
|
||||
subscription1Promise,
|
||||
subscription2Promise,
|
||||
subscription3Promise,
|
||||
]);
|
||||
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
|
||||
expect(result1).toEqual(mockResponse);
|
||||
expect(result2).toEqual(mockResponse);
|
||||
expect(result3).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshMetadataCache", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(true);
|
||||
});
|
||||
|
||||
it("refreshes cached metadata when called with feature flag on", (done) => {
|
||||
const mockResponse1 = createMockMetadataResponse(true, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(true, 20);
|
||||
let invocationCount = 0;
|
||||
|
||||
billingApiService.getOrganizationBillingMetadataVNext
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
|
||||
next: (result) => {
|
||||
invocationCount++;
|
||||
|
||||
if (invocationCount === 1) {
|
||||
expect(result).toEqual(mockResponse1);
|
||||
} else if (invocationCount === 2) {
|
||||
expect(result).toEqual(mockResponse2);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
},
|
||||
error: done.fail,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
service.refreshMetadataCache();
|
||||
}, 10);
|
||||
});
|
||||
|
||||
it("does not trigger refresh when feature flag is disabled", async () => {
|
||||
featureFlagSubject.next(false);
|
||||
|
||||
const mockResponse1 = createMockMetadataResponse(false, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(false, 20);
|
||||
let invocationCount = 0;
|
||||
|
||||
billingApiService.getOrganizationBillingMetadata
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
|
||||
next: () => {
|
||||
invocationCount++;
|
||||
},
|
||||
});
|
||||
|
||||
// wait for initial invocation
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(invocationCount).toBe(1);
|
||||
|
||||
service.refreshMetadataCache();
|
||||
|
||||
// wait to ensure no additional invocations
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(invocationCount).toBe(1);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("bypasses cache when refreshing metadata", (done) => {
|
||||
const mockResponse1 = createMockMetadataResponse(true, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(true, 20);
|
||||
const mockResponse3 = createMockMetadataResponse(true, 30);
|
||||
let invocationCount = 0;
|
||||
|
||||
billingApiService.getOrganizationBillingMetadataVNext
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2)
|
||||
.mockResolvedValueOnce(mockResponse3);
|
||||
|
||||
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
|
||||
next: (result) => {
|
||||
invocationCount++;
|
||||
|
||||
if (invocationCount === 1) {
|
||||
expect(result).toEqual(mockResponse1);
|
||||
service.refreshMetadataCache();
|
||||
} else if (invocationCount === 2) {
|
||||
expect(result).toEqual(mockResponse2);
|
||||
service.refreshMetadataCache();
|
||||
} else if (invocationCount === 3) {
|
||||
expect(result).toEqual(mockResponse3);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3);
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
},
|
||||
error: done.fail,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { OrganizationId } from "../../../types/guid";
|
||||
import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response";
|
||||
|
||||
export class DefaultOrganizationMetadataService implements OrganizationMetadataServiceAbstraction {
|
||||
private metadataCache = new Map<
|
||||
OrganizationId,
|
||||
Observable<OrganizationBillingMetadataResponse>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
private refreshMetadataTrigger = new Subject<void>();
|
||||
|
||||
refreshMetadataCache = () => this.refreshMetadataTrigger.next();
|
||||
|
||||
getOrganizationMetadata$ = (
|
||||
organizationId: OrganizationId,
|
||||
): Observable<OrganizationBillingMetadataResponse> =>
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure)
|
||||
.pipe(
|
||||
switchMap((featureFlagEnabled) => {
|
||||
return merge(
|
||||
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled),
|
||||
this.refreshMetadataTrigger.pipe(
|
||||
filter(() => featureFlagEnabled),
|
||||
switchMap(() =>
|
||||
this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
private getOrganizationMetadataInternal$(
|
||||
organizationId: OrganizationId,
|
||||
featureFlagEnabled: boolean,
|
||||
bypassCache: boolean = false,
|
||||
): Observable<OrganizationBillingMetadataResponse> {
|
||||
if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) {
|
||||
return this.metadataCache.get(organizationId)!;
|
||||
}
|
||||
|
||||
const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
if (featureFlagEnabled) {
|
||||
this.metadataCache.set(organizationId, metadata$);
|
||||
}
|
||||
|
||||
return metadata$;
|
||||
}
|
||||
|
||||
private async fetchMetadata(
|
||||
organizationId: OrganizationId,
|
||||
featureFlagEnabled: boolean,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
if (featureFlagEnabled) {
|
||||
return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId);
|
||||
}
|
||||
|
||||
return await this.billingApiService.getOrganizationBillingMetadata(organizationId);
|
||||
}
|
||||
}
|
||||
@@ -25,17 +25,19 @@ export enum FeatureFlag {
|
||||
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
|
||||
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
|
||||
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
|
||||
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
|
||||
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
|
||||
/* Tools */
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
|
||||
UseChromiumImporter = "pm-23982-chromium-importer",
|
||||
|
||||
/* DIRT */
|
||||
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
|
||||
@@ -81,7 +83,6 @@ export const DefaultFeatureFlagValue = {
|
||||
/* Tools */
|
||||
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
|
||||
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
|
||||
[FeatureFlag.UseChromiumImporter]: FALSE,
|
||||
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,
|
||||
@@ -102,12 +103,15 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
|
||||
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
|
||||
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
|
||||
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
|
||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../../platform/models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
import { UnsignedPublicKey } from "../../types";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
|
||||
export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
@@ -309,7 +310,7 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
"encrypt",
|
||||
]);
|
||||
const buffer = await this.subtle.exportKey("spki", impPublicKey);
|
||||
return new Uint8Array(buffer);
|
||||
return new Uint8Array(buffer) as UnsignedPublicKey;
|
||||
}
|
||||
|
||||
async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise<CsprngArray> {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export const SigningKeyTypes = {
|
||||
Ed25519: "ed25519",
|
||||
} as const;
|
||||
|
||||
export type SigningKeyType = (typeof SigningKeyTypes)[keyof typeof SigningKeyTypes];
|
||||
export function parseSigningKeyTypeFromString(value: string): SigningKeyType {
|
||||
switch (value) {
|
||||
case SigningKeyTypes.Ed25519:
|
||||
return SigningKeyTypes.Ed25519;
|
||||
default:
|
||||
throw new Error(`Unknown signing key type: ${value}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { SecurityStateResponse } from "../../security-state/response/security-state.response";
|
||||
|
||||
import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response";
|
||||
import { SignatureKeyPairResponse } from "./signature-key-pair.response";
|
||||
|
||||
/**
|
||||
* The privately accessible view of an entity (account / org)'s keys.
|
||||
* This includes the full key-pairs for public-key encryption and signing, as well as the security state if available.
|
||||
*/
|
||||
export class PrivateKeysResponseModel {
|
||||
readonly publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairResponse;
|
||||
readonly signatureKeyPair: SignatureKeyPairResponse | null = null;
|
||||
readonly securityState: SecurityStateResponse | null = null;
|
||||
|
||||
constructor(response: unknown) {
|
||||
if (typeof response !== "object" || response == null) {
|
||||
throw new TypeError("Response must be an object");
|
||||
}
|
||||
|
||||
if (
|
||||
!("publicKeyEncryptionKeyPair" in response) ||
|
||||
typeof response.publicKeyEncryptionKeyPair !== "object"
|
||||
) {
|
||||
throw new TypeError("Response must contain a valid publicKeyEncryptionKeyPair");
|
||||
}
|
||||
this.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairResponse(
|
||||
response.publicKeyEncryptionKeyPair,
|
||||
);
|
||||
|
||||
if (
|
||||
"signatureKeyPair" in response &&
|
||||
typeof response.signatureKeyPair === "object" &&
|
||||
response.signatureKeyPair != null
|
||||
) {
|
||||
this.signatureKeyPair = new SignatureKeyPairResponse(response.signatureKeyPair);
|
||||
}
|
||||
|
||||
if (
|
||||
"securityState" in response &&
|
||||
typeof response.securityState === "object" &&
|
||||
response.securityState != null
|
||||
) {
|
||||
this.securityState = new SecurityStateResponse(response.securityState);
|
||||
}
|
||||
|
||||
if (
|
||||
(this.signatureKeyPair !== null && this.securityState === null) ||
|
||||
(this.signatureKeyPair === null && this.securityState !== null)
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Both signatureKeyPair and securityState must be present or absent together",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { SignedPublicKey, UnsignedPublicKey, WrappedPrivateKey } from "../../types";
|
||||
|
||||
export class PublicKeyEncryptionKeyPairResponse {
|
||||
readonly wrappedPrivateKey: WrappedPrivateKey;
|
||||
readonly publicKey: UnsignedPublicKey;
|
||||
|
||||
readonly signedPublicKey: SignedPublicKey | null = null;
|
||||
|
||||
constructor(response: unknown) {
|
||||
if (typeof response !== "object" || response == null) {
|
||||
throw new TypeError("Response must be an object");
|
||||
}
|
||||
|
||||
if (!("publicKey" in response) || typeof response.publicKey !== "string") {
|
||||
throw new TypeError("Response must contain a valid publicKey");
|
||||
}
|
||||
this.publicKey = Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey;
|
||||
|
||||
if (!("wrappedPrivateKey" in response) || typeof response.wrappedPrivateKey !== "string") {
|
||||
throw new TypeError("Response must contain a valid wrappedPrivateKey");
|
||||
}
|
||||
this.wrappedPrivateKey = response.wrappedPrivateKey as WrappedPrivateKey;
|
||||
|
||||
if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") {
|
||||
this.signedPublicKey = response.signedPublicKey as SignedPublicKey;
|
||||
} else {
|
||||
this.signedPublicKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SignedPublicKey } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UnsignedPublicKey, VerifyingKey } from "../../types";
|
||||
|
||||
/**
|
||||
* The publicly accessible view of an entity (account / org)'s keys. That includes the encryption public key, and the verifying key if available.
|
||||
*/
|
||||
export class PublicKeysResponseModel {
|
||||
readonly publicKey: UnsignedPublicKey;
|
||||
readonly verifyingKey: VerifyingKey | null;
|
||||
readonly signedPublicKey?: SignedPublicKey | null;
|
||||
|
||||
constructor(response: unknown) {
|
||||
if (typeof response !== "object" || response == null) {
|
||||
throw new TypeError("Response must be an object");
|
||||
}
|
||||
|
||||
if (!("publicKey" in response) || !(response.publicKey instanceof Uint8Array)) {
|
||||
throw new TypeError("Response must contain a valid publicKey");
|
||||
}
|
||||
this.publicKey = response.publicKey as UnsignedPublicKey;
|
||||
|
||||
if ("verifyingKey" in response && typeof response.verifyingKey === "string") {
|
||||
this.verifyingKey = response.verifyingKey as VerifyingKey;
|
||||
} else {
|
||||
this.verifyingKey = null;
|
||||
}
|
||||
|
||||
if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") {
|
||||
this.signedPublicKey = response.signedPublicKey as SignedPublicKey;
|
||||
} else {
|
||||
this.signedPublicKey = null;
|
||||
}
|
||||
|
||||
if (
|
||||
(this.signedPublicKey !== null && this.verifyingKey === null) ||
|
||||
(this.signedPublicKey === null && this.verifyingKey !== null)
|
||||
) {
|
||||
throw new TypeError(
|
||||
"Both signedPublicKey and verifyingKey must be present or absent together",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { VerifyingKey, WrappedSigningKey } from "../../types";
|
||||
|
||||
export class SignatureKeyPairResponse {
|
||||
readonly wrappedSigningKey: WrappedSigningKey;
|
||||
readonly verifyingKey: VerifyingKey;
|
||||
|
||||
constructor(response: unknown) {
|
||||
if (typeof response !== "object" || response == null) {
|
||||
throw new TypeError("Response must be an object");
|
||||
}
|
||||
|
||||
if (!("wrappedSigningKey" in response) || typeof response.wrappedSigningKey !== "string") {
|
||||
throw new TypeError("Response must contain a valid wrappedSigningKey");
|
||||
}
|
||||
this.wrappedSigningKey = response.wrappedSigningKey as WrappedSigningKey;
|
||||
|
||||
if (!("verifyingKey" in response) || typeof response.verifyingKey !== "string") {
|
||||
throw new TypeError("Response must contain a valid verifyingKey");
|
||||
}
|
||||
this.verifyingKey = response.verifyingKey as VerifyingKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PublicKeysResponseModel } from "../../response/public-keys.response";
|
||||
|
||||
export abstract class KeyApiService {
|
||||
abstract getUserPublicKeys(id: string): Promise<PublicKeysResponseModel>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { PublicKeysResponseModel } from "../response/public-keys.response";
|
||||
|
||||
import { KeyApiService } from "./abstractions/key-api-service.abstraction";
|
||||
|
||||
export class DefaultKeyApiService implements KeyApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async getUserPublicKeys(id: UserId): Promise<PublicKeysResponseModel> {
|
||||
const response = await this.apiService.send("GET", "/users/" + id + "/keys", null, true, true);
|
||||
return new PublicKeysResponseModel(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { UserKey } from "../../../types/key";
|
||||
|
||||
export abstract class MasterPasswordUnlockService {
|
||||
/**
|
||||
* Unlocks the user's account using the master password.
|
||||
* @param masterPassword The master password provided by the user.
|
||||
* @param userId The ID of the active user.
|
||||
* @returns the user's decrypted userKey.
|
||||
*/
|
||||
abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey>;
|
||||
}
|
||||
@@ -171,4 +171,12 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* An observable that emits the master password unlock data for the target user.
|
||||
* @param userId The user ID.
|
||||
* @throws If the user ID is null or undefined.
|
||||
* @returns An observable that emits the master password unlock data or null if not found.
|
||||
*/
|
||||
abstract masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "../types/master-password.types";
|
||||
|
||||
import { DefaultMasterPasswordUnlockService } from "./default-master-password-unlock.service";
|
||||
|
||||
describe("DefaultMasterPasswordUnlockService", () => {
|
||||
let sut: DefaultMasterPasswordUnlockService;
|
||||
|
||||
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
|
||||
const mockMasterPassword = "testExample";
|
||||
const mockUserId = newGuid() as UserId;
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockMasterPasswordUnlockData: MasterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
"user@example.com" as MasterPasswordSalt,
|
||||
new Argon2KdfConfig(100000, 64, 1),
|
||||
"encryptedMasterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
//Legacy data for tests
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey;
|
||||
const mockKeyHash = "localKeyHash";
|
||||
|
||||
beforeEach(() => {
|
||||
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
keyService = mock<KeyService>();
|
||||
|
||||
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService);
|
||||
|
||||
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(
|
||||
of(mockMasterPasswordUnlockData),
|
||||
);
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockResolvedValue(mockUserKey);
|
||||
|
||||
// Legacy state mocking
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue(mockKeyHash);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("unlockWithMasterPassword", () => {
|
||||
test.each([null as unknown as string, undefined as unknown as string, ""])(
|
||||
"throws when the provided master password is %s",
|
||||
async (masterPassword) => {
|
||||
await expect(sut.unlockWithMasterPassword(masterPassword, mockUserId)).rejects.toThrow(
|
||||
"Master password is required",
|
||||
);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled();
|
||||
expect(
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
|
||||
).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||
"throws when the provided master password is %s",
|
||||
async (userId) => {
|
||||
await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow(
|
||||
"User ID is required",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an error when the user doesn't have masterPasswordUnlockData", async () => {
|
||||
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null));
|
||||
|
||||
await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow(
|
||||
"Master password unlock data was not found for the user " + mockUserId,
|
||||
);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns userKey successfully", async () => {
|
||||
const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toEqual(mockUserKey);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets legacy state on success", async () => {
|
||||
const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toEqual(mockUserKey);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData.salt,
|
||||
mockMasterPasswordUnlockData.kdf,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockKeyHash, mockUserId);
|
||||
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId);
|
||||
});
|
||||
|
||||
it("throws an error if masterKey construction fails", async () => {
|
||||
keyService.makeMasterKey.mockResolvedValue(null as unknown as MasterKey);
|
||||
|
||||
await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow(
|
||||
"Master key could not be created to set legacy master password state.",
|
||||
);
|
||||
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData.salt,
|
||||
mockMasterPasswordUnlockData.kdf,
|
||||
);
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalled();
|
||||
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { MasterPasswordUnlockService } from "../abstractions/master-password-unlock.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
||||
import { MasterPasswordUnlockData } from "../types/master-password.types";
|
||||
|
||||
export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockService {
|
||||
constructor(
|
||||
private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private readonly keyService: KeyService,
|
||||
) {}
|
||||
|
||||
async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey> {
|
||||
this.validateInput(masterPassword, userId);
|
||||
|
||||
const masterPasswordUnlockData = await firstValueFrom(
|
||||
this.masterPasswordService.masterPasswordUnlockData$(userId),
|
||||
);
|
||||
|
||||
if (masterPasswordUnlockData == null) {
|
||||
throw new Error("Master password unlock data was not found for the user " + userId);
|
||||
}
|
||||
|
||||
const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData(
|
||||
masterPassword,
|
||||
masterPasswordUnlockData,
|
||||
);
|
||||
|
||||
await this.setLegacyState(masterPassword, masterPasswordUnlockData, userId);
|
||||
|
||||
return userKey;
|
||||
}
|
||||
|
||||
private validateInput(masterPassword: string, userId: UserId): void {
|
||||
if (masterPassword == null || masterPassword === "") {
|
||||
throw new Error("Master password is required");
|
||||
}
|
||||
if (userId == null) {
|
||||
throw new Error("User ID is required");
|
||||
}
|
||||
}
|
||||
|
||||
// Previously unlocking had the side effect of setting the masterKey and masterPasswordHash in state.
|
||||
// This is to preserve that behavior, once masterKey and masterPasswordHash state is removed this should be removed as well.
|
||||
private async setLegacyState(
|
||||
masterPassword: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
const masterKey = await this.keyService.makeMasterKey(
|
||||
masterPassword,
|
||||
masterPasswordUnlockData.salt,
|
||||
masterPasswordUnlockData.kdf,
|
||||
);
|
||||
|
||||
if (!masterKey) {
|
||||
throw new Error("Master key could not be created to set legacy master password state.");
|
||||
}
|
||||
|
||||
const localKeyHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
masterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
|
||||
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
}
|
||||
}
|
||||
@@ -119,4 +119,8 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
||||
): Promise<void> {
|
||||
return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
||||
}
|
||||
|
||||
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
|
||||
return this.mock.masterPasswordUnlockData$(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import * as rxjs from "rxjs";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
@@ -10,6 +9,7 @@ import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
makeSymmetricCryptoKey,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
@@ -17,7 +17,6 @@ import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-pa
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
@@ -30,25 +29,30 @@ import {
|
||||
MasterPasswordUnlockData,
|
||||
} from "../types/master-password.types";
|
||||
|
||||
import { MASTER_PASSWORD_UNLOCK_KEY, MasterPasswordService } from "./master-password.service";
|
||||
import {
|
||||
FORCE_SET_PASSWORD_REASON,
|
||||
MASTER_KEY_ENCRYPTED_USER_KEY,
|
||||
MASTER_PASSWORD_UNLOCK_KEY,
|
||||
MasterPasswordService,
|
||||
} from "./master-password.service";
|
||||
|
||||
describe("MasterPasswordService", () => {
|
||||
let sut: MasterPasswordService;
|
||||
|
||||
let stateProvider: MockProxy<StateProvider>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let keyGenerationService: MockProxy<KeyGenerationService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let accountService: FakeAccountService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
const userId = "00000000-0000-0000-0000-000000000000" as UserId;
|
||||
const mockUserState = {
|
||||
state$: of(null),
|
||||
update: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000);
|
||||
const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3);
|
||||
const salt = "test@bitwarden.com" as MasterPasswordSalt;
|
||||
const userKey = makeSymmetricCryptoKey(64, 2) as UserKey;
|
||||
const testUserKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 1);
|
||||
const testMasterKey: MasterKey = makeSymmetricCryptoKey(32, 2);
|
||||
const testStretchedMasterKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 3);
|
||||
@@ -58,17 +62,13 @@ describe("MasterPasswordService", () => {
|
||||
"2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY=";
|
||||
|
||||
beforeEach(() => {
|
||||
stateProvider = mock<StateProvider>();
|
||||
stateService = mock<StateService>();
|
||||
keyGenerationService = mock<KeyGenerationService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
logService = mock<LogService>();
|
||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
|
||||
stateProvider.getUser.mockReturnValue(mockUserState as any);
|
||||
|
||||
mockUserState.update.mockReset();
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sut = new MasterPasswordService(
|
||||
stateProvider,
|
||||
@@ -88,6 +88,10 @@ describe("MasterPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("saltForUser$", () => {
|
||||
it("throws when userid not present", async () => {
|
||||
expect(() => {
|
||||
@@ -111,12 +115,10 @@ describe("MasterPasswordService", () => {
|
||||
|
||||
await sut.setForceSetPasswordReason(reason, userId);
|
||||
|
||||
expect(stateProvider.getUser).toHaveBeenCalled();
|
||||
expect(mockUserState.update).toHaveBeenCalled();
|
||||
|
||||
// Call the update function to verify it returns the correct reason
|
||||
const updateFn = mockUserState.update.mock.calls[0][0];
|
||||
expect(updateFn(null)).toBe(reason);
|
||||
const state = await firstValueFrom(
|
||||
stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$,
|
||||
);
|
||||
expect(state).toEqual(reason);
|
||||
});
|
||||
|
||||
it("throws an error if reason is null", async () => {
|
||||
@@ -132,31 +134,29 @@ describe("MasterPasswordService", () => {
|
||||
});
|
||||
|
||||
it("does not overwrite AdminForcePasswordReset with other reasons except None", async () => {
|
||||
jest
|
||||
.spyOn(sut, "forceSetPasswordReason$")
|
||||
.mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset));
|
||||
|
||||
jest
|
||||
.spyOn(rxjs, "firstValueFrom")
|
||||
.mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset);
|
||||
stateProvider.singleUser
|
||||
.getFake(userId, FORCE_SET_PASSWORD_REASON)
|
||||
.nextState(ForceSetPasswordReason.AdminForcePasswordReset);
|
||||
|
||||
await sut.setForceSetPasswordReason(ForceSetPasswordReason.WeakMasterPassword, userId);
|
||||
|
||||
expect(mockUserState.update).not.toHaveBeenCalled();
|
||||
const state = await firstValueFrom(
|
||||
stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$,
|
||||
);
|
||||
expect(state).toEqual(ForceSetPasswordReason.AdminForcePasswordReset);
|
||||
});
|
||||
|
||||
it("allows overwriting AdminForcePasswordReset with None", async () => {
|
||||
jest
|
||||
.spyOn(sut, "forceSetPasswordReason$")
|
||||
.mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset));
|
||||
|
||||
jest
|
||||
.spyOn(rxjs, "firstValueFrom")
|
||||
.mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset);
|
||||
stateProvider.singleUser
|
||||
.getFake(userId, FORCE_SET_PASSWORD_REASON)
|
||||
.nextState(ForceSetPasswordReason.AdminForcePasswordReset);
|
||||
|
||||
await sut.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
|
||||
|
||||
expect(mockUserState.update).toHaveBeenCalled();
|
||||
const state = await firstValueFrom(
|
||||
stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$,
|
||||
);
|
||||
expect(state).toEqual(ForceSetPasswordReason.None);
|
||||
});
|
||||
});
|
||||
describe("decryptUserKeyWithMasterKey", () => {
|
||||
@@ -227,10 +227,10 @@ describe("MasterPasswordService", () => {
|
||||
|
||||
await sut.setMasterKeyEncryptedUserKey(encryptedKey, userId);
|
||||
|
||||
expect(stateProvider.getUser).toHaveBeenCalled();
|
||||
expect(mockUserState.update).toHaveBeenCalled();
|
||||
const updateFn = mockUserState.update.mock.calls[0][0];
|
||||
expect(updateFn(null)).toEqual(encryptedKey.toJSON());
|
||||
const state = await firstValueFrom(
|
||||
stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$,
|
||||
);
|
||||
expect(state).toEqual(encryptedKey.toJSON());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -328,11 +328,6 @@ describe("MasterPasswordService", () => {
|
||||
});
|
||||
|
||||
describe("setMasterPasswordUnlockData", () => {
|
||||
const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000);
|
||||
const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3);
|
||||
const salt = "test@bitwarden.com" as MasterPasswordSalt;
|
||||
const userKey = makeSymmetricCryptoKey(64, 2) as UserKey;
|
||||
|
||||
it.each([kdfPBKDF2, kdfArgon2])(
|
||||
"sets the master password unlock data kdf %o in the state",
|
||||
async (kdfConfig) => {
|
||||
@@ -345,11 +340,10 @@ describe("MasterPasswordService", () => {
|
||||
|
||||
await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
||||
|
||||
expect(stateProvider.getUser).toHaveBeenCalledWith(userId, MASTER_PASSWORD_UNLOCK_KEY);
|
||||
expect(mockUserState.update).toHaveBeenCalled();
|
||||
|
||||
const updateFn = mockUserState.update.mock.calls[0][0];
|
||||
expect(updateFn(null)).toEqual(masterPasswordUnlockData.toJSON());
|
||||
const state = await firstValueFrom(
|
||||
stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$,
|
||||
);
|
||||
expect(state).toEqual(masterPasswordUnlockData.toJSON());
|
||||
},
|
||||
);
|
||||
|
||||
@@ -373,6 +367,40 @@ describe("MasterPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("masterPasswordUnlockData$", () => {
|
||||
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||
"throws when the provided userId is %s",
|
||||
async (userId) => {
|
||||
expect(() => sut.masterPasswordUnlockData$(userId)).toThrow("userId is null or undefined.");
|
||||
},
|
||||
);
|
||||
|
||||
it("returns null when no data is set", async () => {
|
||||
stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null);
|
||||
|
||||
const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([kdfPBKDF2, kdfArgon2])(
|
||||
"returns the master password unlock data for kdf %o from state",
|
||||
async (kdfConfig) => {
|
||||
const masterPasswordUnlockData = await sut.makeMasterPasswordUnlockData(
|
||||
"test-password",
|
||||
kdfConfig,
|
||||
salt,
|
||||
userKey,
|
||||
);
|
||||
await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
|
||||
|
||||
const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId));
|
||||
|
||||
expect(result).toEqual(masterPasswordUnlockData.toJSON());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("MASTER_PASSWORD_UNLOCK_KEY", () => {
|
||||
it("has the correct configuration", () => {
|
||||
expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined();
|
||||
|
||||
@@ -50,7 +50,7 @@ const MASTER_KEY_HASH = new UserKeyDefinition<string>(MASTER_PASSWORD_DISK, "mas
|
||||
});
|
||||
|
||||
/** Disk to persist through lock */
|
||||
const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
|
||||
export const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
|
||||
MASTER_PASSWORD_DISK,
|
||||
"masterKeyEncryptedUserKey",
|
||||
{
|
||||
@@ -60,7 +60,7 @@ const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
|
||||
);
|
||||
|
||||
/** Disk to persist through lock and account switches */
|
||||
const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition<ForceSetPasswordReason>(
|
||||
export const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition<ForceSetPasswordReason>(
|
||||
MASTER_PASSWORD_DISK,
|
||||
"forceSetPasswordReason",
|
||||
{
|
||||
@@ -344,4 +344,10 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY)
|
||||
.update(() => masterPasswordUnlockData.toJSON());
|
||||
}
|
||||
|
||||
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { SignedSecurityState } from "../../types";
|
||||
|
||||
export abstract class SecurityStateService {
|
||||
/**
|
||||
* Retrieves the security state for the provided user.
|
||||
* Note: This state is not yet validated. To get a validated state, the SDK crypto client
|
||||
* must be used. This security state is validated on initialization of the SDK.
|
||||
*/
|
||||
abstract accountSecurityState$(userId: UserId): Observable<SignedSecurityState | null>;
|
||||
/**
|
||||
* Sets the security state for the provided user.
|
||||
*/
|
||||
abstract setAccountSecurityState(
|
||||
accountSecurityState: SignedSecurityState,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SignedSecurityState } from "../../types";
|
||||
|
||||
export class SecurityStateRequest {
|
||||
constructor(
|
||||
readonly securityState: SignedSecurityState,
|
||||
readonly securityVersion: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SignedSecurityState } from "../../types";
|
||||
|
||||
export class SecurityStateResponse {
|
||||
readonly securityState: SignedSecurityState | null = null;
|
||||
|
||||
constructor(response: unknown) {
|
||||
if (typeof response !== "object" || response == null) {
|
||||
throw new TypeError("Response must be an object");
|
||||
}
|
||||
|
||||
if (!("securityState" in response) || !(typeof response.securityState === "string")) {
|
||||
throw new TypeError("Response must contain a valid securityState");
|
||||
}
|
||||
this.securityState = response.securityState as SignedSecurityState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { SignedSecurityState } from "../../types";
|
||||
import { SecurityStateService } from "../abstractions/security-state.service";
|
||||
import { ACCOUNT_SECURITY_STATE } from "../state/security-state.state";
|
||||
|
||||
export class DefaultSecurityStateService implements SecurityStateService {
|
||||
constructor(protected stateProvider: StateProvider) {}
|
||||
|
||||
// Emits the provided user's security state, or null if there is no security state present for the user.
|
||||
accountSecurityState$(userId: UserId): Observable<SignedSecurityState | null> {
|
||||
return this.stateProvider.getUserState$(ACCOUNT_SECURITY_STATE, userId);
|
||||
}
|
||||
|
||||
// Sets the security state for the provided user.
|
||||
// This is not yet validated, and is only validated upon SDK initialization.
|
||||
async setAccountSecurityState(
|
||||
accountSecurityState: SignedSecurityState,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
await this.stateProvider.setUserState(ACCOUNT_SECURITY_STATE, accountSecurityState, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CRYPTO_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { SignedSecurityState } from "../../types";
|
||||
|
||||
export const ACCOUNT_SECURITY_STATE = new UserKeyDefinition<SignedSecurityState>(
|
||||
CRYPTO_DISK,
|
||||
"accountSecurityState",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
30
libs/common/src/key-management/types.ts
Normal file
30
libs/common/src/key-management/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { EncString, SignedSecurityState as SdkSignedSecurityState } from "@bitwarden/sdk-internal";
|
||||
|
||||
/**
|
||||
* A private key, encrypted with a symmetric key.
|
||||
*/
|
||||
export type WrappedPrivateKey = Opaque<EncString, "WrappedPrivateKey">;
|
||||
|
||||
/**
|
||||
* A public key, signed with the accounts signature key.
|
||||
*/
|
||||
export type SignedPublicKey = Opaque<string, "SignedPublicKey">;
|
||||
/**
|
||||
* A public key in base64 encoded SPKI-DER
|
||||
*/
|
||||
export type UnsignedPublicKey = Opaque<Uint8Array, "UnsignedPublicKey">;
|
||||
|
||||
/**
|
||||
* A signature key encrypted with a symmetric key.
|
||||
*/
|
||||
export type WrappedSigningKey = Opaque<EncString, "WrappedSigningKey">;
|
||||
/**
|
||||
* A signature public key (verifying key) in base64 encoded CoseKey format
|
||||
*/
|
||||
export type VerifyingKey = Opaque<string, "VerifyingKey">;
|
||||
/**
|
||||
* A signed security state, encoded in base64.
|
||||
*/
|
||||
export type SignedSecurityState = Opaque<SdkSignedSecurityState, "SignedSecurityState">;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PrivateKeysResponseModel } from "@bitwarden/common/key-management/keys/response/private-keys.response";
|
||||
|
||||
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
|
||||
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
|
||||
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
|
||||
@@ -18,7 +20,10 @@ export class ProfileResponse extends BaseResponse {
|
||||
key?: EncString;
|
||||
avatarColor: string;
|
||||
creationDate: string;
|
||||
// Cleanup: Can be removed after moving to accountKeys
|
||||
privateKey: string;
|
||||
// Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768
|
||||
accountKeys: PrivateKeysResponseModel | null = null;
|
||||
securityStamp: string;
|
||||
forcePasswordReset: boolean;
|
||||
usesKeyConnector: boolean;
|
||||
@@ -37,10 +42,16 @@ export class ProfileResponse extends BaseResponse {
|
||||
this.premiumFromOrganization = this.getResponseProperty("PremiumFromOrganization");
|
||||
this.culture = this.getResponseProperty("Culture");
|
||||
this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled");
|
||||
|
||||
const key = this.getResponseProperty("Key");
|
||||
if (key) {
|
||||
this.key = new EncString(key);
|
||||
}
|
||||
// Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768
|
||||
if (this.getResponseProperty("AccountKeys") != null) {
|
||||
this.accountKeys = new PrivateKeysResponseModel(this.getResponseProperty("AccountKeys"));
|
||||
}
|
||||
|
||||
this.avatarColor = this.getResponseProperty("AvatarColor");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.privateKey = this.getResponseProperty("PrivateKey");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { WrappedSigningKey } from "../../../key-management/types";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
import { CRYPTO_DISK, CRYPTO_MEMORY, UserKeyDefinition } from "../../state";
|
||||
@@ -25,3 +26,12 @@ export const USER_KEY = new UserKeyDefinition<UserKey>(CRYPTO_MEMORY, "userKey",
|
||||
deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey,
|
||||
clearOn: ["logout", "lock"],
|
||||
});
|
||||
|
||||
export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition<WrappedSigningKey>(
|
||||
CRYPTO_DISK,
|
||||
"userSigningKey",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -18,6 +18,7 @@ import { AccountInfo } from "../../../auth/abstractions/account.service";
|
||||
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
|
||||
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
|
||||
@@ -43,6 +44,7 @@ describe("DefaultSdkService", () => {
|
||||
let platformUtilsService!: MockProxy<PlatformUtilsService>;
|
||||
let kdfConfigService!: MockProxy<KdfConfigService>;
|
||||
let keyService!: MockProxy<KeyService>;
|
||||
let securityStateService!: MockProxy<SecurityStateService>;
|
||||
let configService!: MockProxy<ConfigService>;
|
||||
let service!: DefaultSdkService;
|
||||
let accountService!: FakeAccountService;
|
||||
@@ -57,6 +59,7 @@ describe("DefaultSdkService", () => {
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
keyService = mock<KeyService>();
|
||||
securityStateService = mock<SecurityStateService>();
|
||||
apiService = mock<ApiService>();
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
@@ -75,6 +78,7 @@ describe("DefaultSdkService", () => {
|
||||
accountService,
|
||||
kdfConfigService,
|
||||
keyService,
|
||||
securityStateService,
|
||||
apiService,
|
||||
fakeStateProvider,
|
||||
configService,
|
||||
@@ -100,6 +104,8 @@ describe("DefaultSdkService", () => {
|
||||
.calledWith(userId)
|
||||
.mockReturnValue(of("private-key" as EncryptedString));
|
||||
keyService.encryptedOrgKeys$.calledWith(userId).mockReturnValue(of({}));
|
||||
keyService.userSigningKey$.calledWith(userId).mockReturnValue(of(null));
|
||||
securityStateService.accountSecurityState$.calledWith(userId).mockReturnValue(of(null));
|
||||
});
|
||||
|
||||
describe("given no client override has been set for the user", () => {
|
||||
|
||||
@@ -31,6 +31,8 @@ import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { DeviceType } from "../../../enums/device-type.enum";
|
||||
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service";
|
||||
import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types";
|
||||
import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
@@ -98,6 +100,7 @@ export class DefaultSdkService implements SdkService {
|
||||
private accountService: AccountService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private keyService: KeyService,
|
||||
private securityStateService: SecurityStateService,
|
||||
private apiService: ApiService,
|
||||
private stateProvider: StateProvider,
|
||||
private configService: ConfigService,
|
||||
@@ -160,10 +163,14 @@ export class DefaultSdkService implements SdkService {
|
||||
const privateKey$ = this.keyService
|
||||
.userEncryptedPrivateKey$(userId)
|
||||
.pipe(distinctUntilChanged());
|
||||
const signingKey$ = this.keyService.userSigningKey$(userId).pipe(distinctUntilChanged());
|
||||
const userKey$ = this.keyService.userKey$(userId).pipe(distinctUntilChanged());
|
||||
const orgKeys$ = this.keyService.encryptedOrgKeys$(userId).pipe(
|
||||
distinctUntilChanged(compareValues), // The upstream observable emits different objects with the same values
|
||||
);
|
||||
const securityState$ = this.securityStateService
|
||||
.accountSecurityState$(userId)
|
||||
.pipe(distinctUntilChanged(compareValues));
|
||||
|
||||
const client$ = combineLatest([
|
||||
this.environmentService.getEnvironment$(userId),
|
||||
@@ -171,51 +178,57 @@ export class DefaultSdkService implements SdkService {
|
||||
kdfParams$,
|
||||
privateKey$,
|
||||
userKey$,
|
||||
signingKey$,
|
||||
orgKeys$,
|
||||
securityState$,
|
||||
SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded
|
||||
]).pipe(
|
||||
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
|
||||
switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => {
|
||||
// Create our own observable to be able to implement clean-up logic
|
||||
return new Observable<Rc<BitwardenClient>>((subscriber) => {
|
||||
const createAndInitializeClient = async () => {
|
||||
if (env == null || kdfParams == null || privateKey == null || userKey == null) {
|
||||
return undefined;
|
||||
}
|
||||
switchMap(
|
||||
([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => {
|
||||
// Create our own observable to be able to implement clean-up logic
|
||||
return new Observable<Rc<BitwardenClient>>((subscriber) => {
|
||||
const createAndInitializeClient = async () => {
|
||||
if (env == null || kdfParams == null || privateKey == null || userKey == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const settings = this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService, userId),
|
||||
settings,
|
||||
);
|
||||
const settings = this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService, userId),
|
||||
settings,
|
||||
);
|
||||
|
||||
await this.initializeClient(
|
||||
userId,
|
||||
client,
|
||||
account,
|
||||
kdfParams,
|
||||
privateKey,
|
||||
userKey,
|
||||
orgKeys,
|
||||
);
|
||||
await this.initializeClient(
|
||||
userId,
|
||||
client,
|
||||
account,
|
||||
kdfParams,
|
||||
privateKey,
|
||||
userKey,
|
||||
signingKey,
|
||||
securityState,
|
||||
orgKeys,
|
||||
);
|
||||
|
||||
return client;
|
||||
};
|
||||
return client;
|
||||
};
|
||||
|
||||
let client: Rc<BitwardenClient> | undefined;
|
||||
createAndInitializeClient()
|
||||
.then((c) => {
|
||||
client = c === undefined ? undefined : new Rc(c);
|
||||
let client: Rc<BitwardenClient> | undefined;
|
||||
createAndInitializeClient()
|
||||
.then((c) => {
|
||||
client = c === undefined ? undefined : new Rc(c);
|
||||
|
||||
subscriber.next(client);
|
||||
})
|
||||
.catch((e) => {
|
||||
subscriber.error(e);
|
||||
});
|
||||
subscriber.next(client);
|
||||
})
|
||||
.catch((e) => {
|
||||
subscriber.error(e);
|
||||
});
|
||||
|
||||
return () => client?.markForDisposal();
|
||||
});
|
||||
}),
|
||||
return () => client?.markForDisposal();
|
||||
});
|
||||
},
|
||||
),
|
||||
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
@@ -231,6 +244,8 @@ export class DefaultSdkService implements SdkService {
|
||||
kdfParams: KdfConfig,
|
||||
privateKey: EncryptedString,
|
||||
userKey: UserKey,
|
||||
signingKey: WrappedSigningKey | null,
|
||||
securityState: SignedSecurityState | null,
|
||||
orgKeys: Record<OrganizationId, EncString>,
|
||||
) {
|
||||
await client.crypto().initialize_user_crypto({
|
||||
@@ -248,8 +263,8 @@ export class DefaultSdkService implements SdkService {
|
||||
},
|
||||
},
|
||||
privateKey,
|
||||
signingKey: undefined,
|
||||
securityState: undefined,
|
||||
signingKey: signingKey || undefined,
|
||||
securityState: securityState || undefined,
|
||||
});
|
||||
|
||||
// We initialize the org crypto even if the org_keys are
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -72,6 +74,7 @@ describe("DefaultSyncService", () => {
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let stateProvider: MockProxy<StateProvider>;
|
||||
let securityStateService: MockProxy<SecurityStateService>;
|
||||
|
||||
let sut: DefaultSyncService;
|
||||
|
||||
@@ -101,6 +104,7 @@ describe("DefaultSyncService", () => {
|
||||
tokenService = mock();
|
||||
authService = mock();
|
||||
stateProvider = mock();
|
||||
securityStateService = mock();
|
||||
|
||||
sut = new DefaultSyncService(
|
||||
masterPasswordAbstraction,
|
||||
@@ -127,6 +131,7 @@ describe("DefaultSyncService", () => {
|
||||
tokenService,
|
||||
authService,
|
||||
stateProvider,
|
||||
securityStateService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -155,6 +160,142 @@ describe("DefaultSyncService", () => {
|
||||
stateProvider.getUser.mockReturnValue(mock());
|
||||
});
|
||||
|
||||
it("sets the correct keys for a V1 user with old response model", async () => {
|
||||
const v1Profile = {
|
||||
id: user1,
|
||||
key: "encryptedUserKey",
|
||||
privateKey: "privateKey",
|
||||
providers: [] as any[],
|
||||
organizations: [] as any[],
|
||||
providerOrganizations: [] as any[],
|
||||
avatarColor: "#fff",
|
||||
securityStamp: "stamp",
|
||||
emailVerified: true,
|
||||
verifyDevices: false,
|
||||
premiumPersonally: false,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
};
|
||||
apiService.getSync.mockResolvedValue(
|
||||
new SyncResponse({
|
||||
profile: v1Profile,
|
||||
folders: [],
|
||||
collections: [],
|
||||
ciphers: [],
|
||||
sends: [],
|
||||
domains: [],
|
||||
policies: [],
|
||||
}),
|
||||
);
|
||||
await sut.fullSync(true);
|
||||
expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
new EncString("encryptedUserKey"),
|
||||
user1,
|
||||
);
|
||||
expect(keyService.setPrivateKey).toHaveBeenCalledWith("privateKey", user1);
|
||||
expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1);
|
||||
expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1);
|
||||
});
|
||||
|
||||
it("sets the correct keys for a V1 user", async () => {
|
||||
const v1Profile = {
|
||||
id: user1,
|
||||
key: "encryptedUserKey",
|
||||
privateKey: "privateKey",
|
||||
providers: [] as any[],
|
||||
organizations: [] as any[],
|
||||
providerOrganizations: [] as any[],
|
||||
avatarColor: "#fff",
|
||||
securityStamp: "stamp",
|
||||
emailVerified: true,
|
||||
verifyDevices: false,
|
||||
premiumPersonally: false,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
accountKeys: {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: "wrappedPrivateKey",
|
||||
publicKey: "publicKey",
|
||||
},
|
||||
},
|
||||
};
|
||||
apiService.getSync.mockResolvedValue(
|
||||
new SyncResponse({
|
||||
profile: v1Profile,
|
||||
folders: [],
|
||||
collections: [],
|
||||
ciphers: [],
|
||||
sends: [],
|
||||
domains: [],
|
||||
policies: [],
|
||||
}),
|
||||
);
|
||||
await sut.fullSync(true);
|
||||
expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
new EncString("encryptedUserKey"),
|
||||
user1,
|
||||
);
|
||||
expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1);
|
||||
expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1);
|
||||
expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1);
|
||||
});
|
||||
|
||||
it("sets the correct keys for a V2 user", async () => {
|
||||
const v2Profile = {
|
||||
id: user1,
|
||||
key: "encryptedUserKey",
|
||||
providers: [] as unknown[],
|
||||
organizations: [] as unknown[],
|
||||
providerOrganizations: [] as unknown[],
|
||||
avatarColor: "#fff",
|
||||
securityStamp: "stamp",
|
||||
emailVerified: true,
|
||||
verifyDevices: false,
|
||||
premiumPersonally: false,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
privateKey: "wrappedPrivateKey",
|
||||
accountKeys: {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: "wrappedPrivateKey",
|
||||
publicKey: "publicKey",
|
||||
signedPublicKey: "signedPublicKey",
|
||||
},
|
||||
signatureKeyPair: {
|
||||
wrappedSigningKey: "wrappedSigningKey",
|
||||
verifyingKey: "verifyingKey",
|
||||
},
|
||||
securityState: {
|
||||
securityState: "securityState",
|
||||
},
|
||||
},
|
||||
};
|
||||
apiService.getSync.mockResolvedValue(
|
||||
new SyncResponse({
|
||||
profile: v2Profile,
|
||||
folders: [],
|
||||
collections: [],
|
||||
ciphers: [],
|
||||
sends: [],
|
||||
domains: [],
|
||||
policies: [],
|
||||
}),
|
||||
);
|
||||
await sut.fullSync(true);
|
||||
expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
new EncString("encryptedUserKey"),
|
||||
user1,
|
||||
);
|
||||
expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1);
|
||||
expect(keyService.setUserSigningKey).toHaveBeenCalledWith("wrappedSigningKey", user1);
|
||||
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
|
||||
"securityState",
|
||||
user1,
|
||||
);
|
||||
expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1);
|
||||
expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1);
|
||||
});
|
||||
|
||||
it("does a token refresh when option missing from options", async () => {
|
||||
await sut.fullSync(true, { allowThrowOnError: false });
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CollectionService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -98,6 +99,7 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
tokenService: TokenService,
|
||||
authService: AuthService,
|
||||
stateProvider: StateProvider,
|
||||
private securityStateService: SecurityStateService,
|
||||
) {
|
||||
super(
|
||||
tokenService,
|
||||
@@ -233,13 +235,34 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
if (response?.key) {
|
||||
await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, response.id);
|
||||
}
|
||||
await this.keyService.setPrivateKey(response.privateKey, response.id);
|
||||
|
||||
// Cleanup: Only the first branch should be kept after the server always returns accountKeys https://bitwarden.atlassian.net/browse/PM-21768
|
||||
if (response.accountKeys != null) {
|
||||
await this.keyService.setPrivateKey(
|
||||
response.accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
response.id,
|
||||
);
|
||||
if (response.accountKeys.signatureKeyPair !== null) {
|
||||
// User is V2 user
|
||||
await this.keyService.setUserSigningKey(
|
||||
response.accountKeys.signatureKeyPair.wrappedSigningKey,
|
||||
response.id,
|
||||
);
|
||||
await this.securityStateService.setAccountSecurityState(
|
||||
response.accountKeys.securityState.securityState,
|
||||
response.id,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await this.keyService.setPrivateKey(response.privateKey, response.id);
|
||||
}
|
||||
await this.keyService.setProviderKeys(response.providers, response.id);
|
||||
await this.keyService.setOrgKeys(
|
||||
response.organizations,
|
||||
response.providerOrganizations,
|
||||
response.id,
|
||||
);
|
||||
|
||||
await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor);
|
||||
await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
|
||||
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { emptyGuid, UserId } from "@bitwarden/common/types/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -97,6 +99,7 @@ describe("Send", () => {
|
||||
const text = mock<SendText>();
|
||||
text.decrypt.mockResolvedValue("textView" as any);
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const userId = emptyGuid as UserId;
|
||||
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
@@ -120,11 +123,11 @@ describe("Send", () => {
|
||||
.calledWith(send.key, userKey)
|
||||
.mockResolvedValue(makeStaticByteArray(32));
|
||||
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
|
||||
keyService.getUserKey.mockResolvedValue(userKey);
|
||||
keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey));
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
|
||||
const view = await send.decrypt();
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey");
|
||||
expect(send.name.decrypt).toHaveBeenNthCalledWith(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import Domain from "../../../../platform/models/domain/domain-base";
|
||||
@@ -73,22 +76,18 @@ export class Send extends Domain {
|
||||
}
|
||||
}
|
||||
|
||||
async decrypt(): Promise<SendView> {
|
||||
const model = new SendView(this);
|
||||
async decrypt(userId: UserId): Promise<SendView> {
|
||||
if (!userId) {
|
||||
throw new Error("User ID must not be null or undefined");
|
||||
}
|
||||
|
||||
const model = new SendView(this);
|
||||
const keyService = Utils.getContainerService().getKeyService();
|
||||
const encryptService = Utils.getContainerService().getEncryptService();
|
||||
|
||||
try {
|
||||
const sendKeyEncryptionKey = await keyService.getUserKey();
|
||||
// model.key is a seed used to derive a key, not a SymmetricCryptoKey
|
||||
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
|
||||
model.cryptoKey = await keyService.makeSendKey(model.key);
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
// TODO: error?
|
||||
}
|
||||
const sendKeyEncryptionKey = await firstValueFrom(keyService.userKey$(userId));
|
||||
// model.key is a seed used to derive a key, not a SymmetricCryptoKey
|
||||
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
|
||||
model.cryptoKey = await keyService.makeSendKey(model.key);
|
||||
|
||||
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("SendService", () => {
|
||||
decryptedState.nextState([testSendViewData("1", "Test Send")]);
|
||||
|
||||
sendService = new SendService(
|
||||
accountService,
|
||||
keyService,
|
||||
i18nService,
|
||||
keyGenerationService,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { Observable, concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
@@ -35,12 +36,16 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
map(([, record]) => Object.values(record || {}).map((data) => new Send(data))),
|
||||
);
|
||||
sendViews$ = this.stateProvider.encryptedState$.pipe(
|
||||
concatMap(([, record]) =>
|
||||
this.decryptSends(Object.values(record || {}).map((data) => new Send(data))),
|
||||
concatMap(([userId, record]) =>
|
||||
this.decryptSends(
|
||||
Object.values(record || {}).map((data) => new Send(data)),
|
||||
userId,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private keyService: KeyService,
|
||||
private i18nService: I18nService,
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
@@ -89,8 +94,9 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
);
|
||||
send.password = passwordKey.keyB64;
|
||||
}
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
if (userKey == null) {
|
||||
userKey = await this.keyService.getUserKey();
|
||||
userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
}
|
||||
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
|
||||
send.key = await this.encryptService.encryptBytes(model.key, userKey);
|
||||
@@ -111,11 +117,12 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
model.file.fileName,
|
||||
file,
|
||||
model.cryptoKey,
|
||||
userId,
|
||||
);
|
||||
send.file.fileName = name;
|
||||
fileData = data;
|
||||
} else {
|
||||
fileData = await this.parseFile(send, file, model.cryptoKey);
|
||||
fileData = await this.parseFile(send, file, model.cryptoKey, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +215,9 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
}
|
||||
|
||||
async getAllDecryptedFromState(userId: UserId): Promise<SendView[]> {
|
||||
if (!userId) {
|
||||
throw new Error("User ID must not be null or undefined");
|
||||
}
|
||||
let decSends = await this.stateProvider.getDecryptedSends();
|
||||
if (decSends != null) {
|
||||
return decSends;
|
||||
@@ -222,7 +232,7 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
const promises: Promise<any>[] = [];
|
||||
const sends = await this.getAll();
|
||||
sends.forEach((send) => {
|
||||
promises.push(send.decrypt().then((f) => decSends.push(f)));
|
||||
promises.push(send.decrypt(userId).then((f) => decSends.push(f)));
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
@@ -311,7 +321,12 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
return requests;
|
||||
}
|
||||
|
||||
private parseFile(send: Send, file: File, key: SymmetricCryptoKey): Promise<EncArrayBuffer> {
|
||||
private parseFile(
|
||||
send: Send,
|
||||
file: File,
|
||||
key: SymmetricCryptoKey,
|
||||
userId: UserId,
|
||||
): Promise<EncArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(file);
|
||||
@@ -321,6 +336,7 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
file.name,
|
||||
evt.target.result as ArrayBuffer,
|
||||
key,
|
||||
userId,
|
||||
);
|
||||
send.file.fileName = name;
|
||||
resolve(data);
|
||||
@@ -338,17 +354,18 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
fileName: string,
|
||||
data: ArrayBuffer,
|
||||
key: SymmetricCryptoKey,
|
||||
userId: UserId,
|
||||
): Promise<[EncString, EncArrayBuffer]> {
|
||||
if (key == null) {
|
||||
key = await this.keyService.getUserKey();
|
||||
key = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
}
|
||||
const encFileName = await this.encryptService.encryptString(fileName, key);
|
||||
const encFileData = await this.encryptService.encryptFileData(new Uint8Array(data), key);
|
||||
return [encFileName, encFileData];
|
||||
}
|
||||
|
||||
private async decryptSends(sends: Send[]) {
|
||||
const decryptSendPromises = sends.map((s) => s.decrypt());
|
||||
private async decryptSends(sends: Send[], userId: UserId) {
|
||||
const decryptSendPromises = sends.map((s) => s.decrypt(userId));
|
||||
const decryptedSends = await Promise.all(decryptSendPromises);
|
||||
|
||||
decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { UnsignedPublicKey } from "../key-management/types";
|
||||
import { SymmetricCryptoKey } from "../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
// symmetric keys
|
||||
@@ -15,4 +16,4 @@ export type CipherKey = Opaque<SymmetricCryptoKey, "CipherKey">;
|
||||
|
||||
// asymmetric keys
|
||||
export type UserPrivateKey = Opaque<Uint8Array, "UserPrivateKey">;
|
||||
export type UserPublicKey = Opaque<Uint8Array, "UserPublicKey">;
|
||||
export type UserPublicKey = Opaque<UnsignedPublicKey, "UserPublicKey">;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
export abstract class CipherArchiveService {
|
||||
abstract hasArchiveFlagEnabled$(): Observable<boolean>;
|
||||
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
|
||||
abstract userCanArchive$(userId: UserId): Observable<boolean>;
|
||||
abstract showArchiveVault$(userId: UserId): Observable<boolean>;
|
||||
|
||||
@@ -819,6 +819,28 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDelete", () => {
|
||||
it("clears archivedDate when soft deleting", async () => {
|
||||
const cipherId = "cipher-id-1" as CipherId;
|
||||
const archivedCipher = {
|
||||
...cipherData,
|
||||
id: cipherId,
|
||||
archivedDate: "2024-01-01T12:00:00.000Z",
|
||||
} as CipherData;
|
||||
|
||||
const ciphers = { [cipherId]: archivedCipher } as Record<CipherId, CipherData>;
|
||||
stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).nextState(ciphers);
|
||||
|
||||
await cipherService.softDelete(cipherId, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$,
|
||||
);
|
||||
expect(result[cipherId].archivedDate).toBeNull();
|
||||
expect(result[cipherId].deletedDate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace (no upsert)", () => {
|
||||
// In order to set up initial state we need to manually update the encrypted state
|
||||
// which will result in an emission. All tests will have this baseline emission.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { CipherListView } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
@@ -158,9 +159,9 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
),
|
||||
),
|
||||
switchMap(async (ciphers) => {
|
||||
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId);
|
||||
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
|
||||
await this.setFailedDecryptedCiphers(failures, userId);
|
||||
return decrypted.sort(this.getLocaleSortingFunction());
|
||||
return decrypted;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
@@ -489,7 +490,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) {
|
||||
const decryptStartTime = performance.now();
|
||||
|
||||
const result = await this.decryptCiphersWithSdk(ciphers, userId);
|
||||
const result = await this.decryptCiphersWithSdk(ciphers, userId, true);
|
||||
|
||||
this.logService.measure(decryptStartTime, "Vault", "CipherService", "decrypt complete", [
|
||||
["Items", ciphers.length],
|
||||
@@ -1421,6 +1422,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
ciphers[cipherId].deletedDate = new Date().toISOString();
|
||||
ciphers[cipherId].archivedDate = null;
|
||||
};
|
||||
|
||||
if (typeof id === "string") {
|
||||
@@ -2067,21 +2069,50 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the provided ciphers using the SDK.
|
||||
* @param ciphers The ciphers to decrypt.
|
||||
* @param userId The user ID to use for decryption.
|
||||
* @returns The decrypted ciphers.
|
||||
* Decrypts the provided ciphers using the SDK with full CipherView decryption.
|
||||
* @param ciphers The encrypted ciphers to decrypt.
|
||||
* @param userId The user ID to use for decryption keys.
|
||||
* @param fullDecryption When true, returns full CipherView objects with all fields decrypted.
|
||||
* @returns A tuple containing:
|
||||
* - Array of fully decrypted CipherView objects, sorted by locale
|
||||
* - Array of CipherView objects that failed to decrypt (marked with decryptionFailure flag)
|
||||
* @private
|
||||
*/
|
||||
private async decryptCiphersWithSdk(
|
||||
ciphers: Cipher[],
|
||||
userId: UserId,
|
||||
): Promise<[CipherView[], CipherView[]]> {
|
||||
fullDecryption: true,
|
||||
): Promise<[CipherView[], CipherView[]]>;
|
||||
/**
|
||||
* Decrypts the provided ciphers using the SDK with lightweight CipherListView decryption.
|
||||
* @param ciphers The encrypted ciphers to decrypt.
|
||||
* @param userId The user ID to use for decryption keys.
|
||||
* @param fullDecryption When false, returns lightweight CipherListView objects for better performance.
|
||||
* @returns A tuple containing:
|
||||
* - Array of lightweight CipherListView objects, sorted by locale
|
||||
* - Array of CipherView objects that failed to decrypt (marked with decryptionFailure flag)
|
||||
* @private
|
||||
*/
|
||||
private async decryptCiphersWithSdk(
|
||||
ciphers: Cipher[],
|
||||
userId: UserId,
|
||||
fullDecryption: false,
|
||||
): Promise<[CipherListView[], CipherView[]]>;
|
||||
|
||||
private async decryptCiphersWithSdk(
|
||||
ciphers: Cipher[],
|
||||
userId: UserId,
|
||||
fullDecryption: boolean = true,
|
||||
): Promise<[CipherViewLike[], CipherView[]]> {
|
||||
const [decrypted, failures] = await this.cipherEncryptionService.decryptManyWithFailures(
|
||||
ciphers,
|
||||
userId,
|
||||
);
|
||||
const decryptedViews = await Promise.all(decrypted.map((c) => this.getFullCipherView(c)));
|
||||
|
||||
const decryptedViews = fullDecryption
|
||||
? await Promise.all(decrypted.map((c) => this.getFullCipherView(c)))
|
||||
: decrypted;
|
||||
|
||||
const failedViews = failures.map((c) => {
|
||||
const cipher_view = new CipherView(c);
|
||||
cipher_view.name = "[error: cannot decrypt]";
|
||||
|
||||
@@ -27,6 +27,10 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
hasArchiveFlagEnabled$(): Observable<boolean> {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable that contains the list of ciphers that have been archived.
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[maxWidth]="maxWidth"
|
||||
[hideCardWrapper]="hideCardWrapper"
|
||||
[hideIcon]="hideIcon"
|
||||
[hideBackgroundIllustration]="hideBackgroundIllustration"
|
||||
>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet slot="secondary" name="secondary"></router-outlet>
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface AnonLayoutWrapperData {
|
||||
* Hide the card that wraps the default content. Defaults to false.
|
||||
*/
|
||||
hideCardWrapper?: boolean;
|
||||
/**
|
||||
* Hides the background illustration. Defaults to false.
|
||||
*/
|
||||
hideBackgroundIllustration?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -60,6 +64,7 @@ export class AnonLayoutWrapperComponent implements OnInit {
|
||||
protected maxWidth?: AnonLayoutMaxWidth | null;
|
||||
protected hideCardWrapper?: boolean | null;
|
||||
protected hideIcon?: boolean | null;
|
||||
protected hideBackgroundIllustration?: boolean | null;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
@@ -117,6 +122,7 @@ export class AnonLayoutWrapperComponent implements OnInit {
|
||||
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
|
||||
this.maxWidth = firstChildRouteData["maxWidth"];
|
||||
this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]);
|
||||
this.hideBackgroundIllustration = Boolean(firstChildRouteData["hideBackgroundIllustration"]);
|
||||
}
|
||||
|
||||
private listenForServiceDataChanges() {
|
||||
@@ -157,6 +163,10 @@ export class AnonLayoutWrapperComponent implements OnInit {
|
||||
this.hideCardWrapper = data.hideCardWrapper;
|
||||
}
|
||||
|
||||
if (data.hideBackgroundIllustration !== undefined) {
|
||||
this.hideBackgroundIllustration = data.hideBackgroundIllustration;
|
||||
}
|
||||
|
||||
if (data.hideIcon !== undefined) {
|
||||
this.hideIcon = data.hideIcon;
|
||||
}
|
||||
@@ -188,5 +198,6 @@ export class AnonLayoutWrapperComponent implements OnInit {
|
||||
this.maxWidth = null;
|
||||
this.hideCardWrapper = null;
|
||||
this.hideIcon = null;
|
||||
this.hideBackgroundIllustration = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,16 +68,18 @@
|
||||
</ng-container>
|
||||
</footer>
|
||||
|
||||
<div
|
||||
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
|
||||
>
|
||||
<bit-icon [icon]="leftIllustration"></bit-icon>
|
||||
</div>
|
||||
<div
|
||||
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
|
||||
>
|
||||
<bit-icon [icon]="rightIllustration"></bit-icon>
|
||||
</div>
|
||||
@if (!hideBackgroundIllustration()) {
|
||||
<div
|
||||
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
|
||||
>
|
||||
<bit-icon [icon]="leftIllustration"></bit-icon>
|
||||
</div>
|
||||
<div
|
||||
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
|
||||
>
|
||||
<bit-icon [icon]="rightIllustration"></bit-icon>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
|
||||
<ng-template #defaultContent>
|
||||
|
||||
@@ -51,6 +51,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
readonly hideFooter = input<boolean>(false);
|
||||
readonly hideIcon = input<boolean>(false);
|
||||
readonly hideCardWrapper = input<boolean>(false);
|
||||
readonly hideBackgroundIllustration = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Max width of the anon layout title, subtitle, and content areas.
|
||||
|
||||
@@ -79,6 +79,7 @@ export default {
|
||||
[hideIcon]="hideIcon"
|
||||
[hideLogo]="hideLogo"
|
||||
[hideFooter]="hideFooter"
|
||||
[hideBackgroundIllustration]="hideBackgroundIllustration"
|
||||
>
|
||||
<ng-container [ngSwitch]="contentLength">
|
||||
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-bold">Thin Content</div></div>
|
||||
@@ -125,6 +126,7 @@ export default {
|
||||
hideIcon: { control: "boolean" },
|
||||
hideLogo: { control: "boolean" },
|
||||
hideFooter: { control: "boolean" },
|
||||
hideBackgroundIllustration: { control: "boolean" },
|
||||
|
||||
contentLength: {
|
||||
control: "radio",
|
||||
@@ -145,6 +147,7 @@ export default {
|
||||
hideIcon: false,
|
||||
hideLogo: false,
|
||||
hideFooter: false,
|
||||
hideBackgroundIllustration: false,
|
||||
contentLength: "normal",
|
||||
showSecondary: false,
|
||||
},
|
||||
@@ -221,6 +224,10 @@ export const NoFooter: Story = {
|
||||
args: { hideFooter: true },
|
||||
};
|
||||
|
||||
export const NoBackgroundIllustration: Story = {
|
||||
args: { hideBackgroundIllustration: true },
|
||||
};
|
||||
|
||||
export const ReadonlyHostname: Story = {
|
||||
args: { showReadonlyHostname: true },
|
||||
};
|
||||
@@ -234,5 +241,6 @@ export const MinimalState: Story = {
|
||||
hideIcon: true,
|
||||
hideLogo: true,
|
||||
hideFooter: true,
|
||||
hideBackgroundIllustration: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,11 +36,17 @@ let nextId = 0;
|
||||
export class CalloutComponent {
|
||||
readonly type = input<CalloutTypes>("info");
|
||||
readonly icon = input<string>();
|
||||
readonly title = input<string>();
|
||||
readonly title = input<string | null>();
|
||||
readonly useAlertRole = input(false);
|
||||
readonly iconComputed = computed(() => this.icon() ?? defaultIcon[this.type()]);
|
||||
readonly iconComputed = computed(() =>
|
||||
this.icon() === undefined ? defaultIcon[this.type()] : this.icon(),
|
||||
);
|
||||
readonly titleComputed = computed(() => {
|
||||
const title = this.title();
|
||||
if (title === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const type = this.type();
|
||||
if (title == null && defaultI18n[type] != null) {
|
||||
return this.i18nService.t(defaultI18n[type]);
|
||||
|
||||
7
libs/components/src/icon-tile/icon-tile.component.html
Normal file
7
libs/components/src/icon-tile/icon-tile.component.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div
|
||||
[ngClass]="containerClasses()"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[attr.role]="ariaLabel() ? 'img' : null"
|
||||
>
|
||||
<i [ngClass]="iconClasses()" aria-hidden="true"></i>
|
||||
</div>
|
||||
111
libs/components/src/icon-tile/icon-tile.component.ts
Normal file
111
libs/components/src/icon-tile/icon-tile.component.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NgClass } from "@angular/common";
|
||||
import { Component, computed, input } from "@angular/core";
|
||||
|
||||
import { BitwardenIcon } from "../shared/icon";
|
||||
|
||||
export type IconTileVariant = "primary" | "success" | "warning" | "danger" | "muted";
|
||||
|
||||
export type IconTileSize = "small" | "default" | "large";
|
||||
|
||||
export type IconTileShape = "square" | "circle";
|
||||
|
||||
const variantStyles: Record<IconTileVariant, string[]> = {
|
||||
primary: ["tw-bg-primary-100", "tw-text-primary-700"],
|
||||
success: ["tw-bg-success-100", "tw-text-success-700"],
|
||||
warning: ["tw-bg-warning-100", "tw-text-warning-700"],
|
||||
danger: ["tw-bg-danger-100", "tw-text-danger-700"],
|
||||
muted: ["tw-bg-secondary-100", "tw-text-secondary-700"],
|
||||
};
|
||||
|
||||
const sizeStyles: Record<IconTileSize, { container: string[]; icon: string[] }> = {
|
||||
small: {
|
||||
container: ["tw-w-6", "tw-h-6"],
|
||||
icon: ["tw-text-sm"],
|
||||
},
|
||||
default: {
|
||||
container: ["tw-w-8", "tw-h-8"],
|
||||
icon: ["tw-text-base"],
|
||||
},
|
||||
large: {
|
||||
container: ["tw-w-10", "tw-h-10"],
|
||||
icon: ["tw-text-lg"],
|
||||
},
|
||||
};
|
||||
|
||||
const shapeStyles: Record<IconTileShape, Record<IconTileSize, string[]>> = {
|
||||
square: {
|
||||
small: ["tw-rounded"],
|
||||
default: ["tw-rounded-md"],
|
||||
large: ["tw-rounded-lg"],
|
||||
},
|
||||
circle: {
|
||||
small: ["tw-rounded-full"],
|
||||
default: ["tw-rounded-full"],
|
||||
large: ["tw-rounded-full"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Icon tiles are static containers that display an icon with a colored background.
|
||||
* They are similar to icon buttons but are not interactive and are used for visual
|
||||
* indicators, status representations, or decorative elements.
|
||||
*
|
||||
* Use icon tiles to:
|
||||
* - Display status or category indicators
|
||||
* - Represent different types of content
|
||||
* - Create visual hierarchy in lists or cards
|
||||
* - Show app or service icons in a consistent format
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-icon-tile",
|
||||
templateUrl: "icon-tile.component.html",
|
||||
imports: [NgClass],
|
||||
})
|
||||
export class IconTileComponent {
|
||||
/**
|
||||
* The BWI icon name
|
||||
*/
|
||||
readonly icon = input.required<BitwardenIcon>();
|
||||
|
||||
/**
|
||||
* The visual theme of the icon tile
|
||||
*/
|
||||
readonly variant = input<IconTileVariant>("primary");
|
||||
|
||||
/**
|
||||
* The size of the icon tile
|
||||
*/
|
||||
readonly size = input<IconTileSize>("default");
|
||||
|
||||
/**
|
||||
* The shape of the icon tile
|
||||
*/
|
||||
readonly shape = input<IconTileShape>("square");
|
||||
|
||||
/**
|
||||
* Optional aria-label for accessibility when the icon has semantic meaning
|
||||
*/
|
||||
readonly ariaLabel = input<string>();
|
||||
|
||||
protected readonly containerClasses = computed(() => {
|
||||
const variant = this.variant();
|
||||
const size = this.size();
|
||||
const shape = this.shape();
|
||||
|
||||
return [
|
||||
"tw-inline-flex",
|
||||
"tw-items-center",
|
||||
"tw-justify-center",
|
||||
"tw-flex-shrink-0",
|
||||
...variantStyles[variant],
|
||||
...sizeStyles[size].container,
|
||||
...shapeStyles[shape][size],
|
||||
];
|
||||
});
|
||||
|
||||
protected readonly iconClasses = computed(() => {
|
||||
const size = this.size();
|
||||
|
||||
return ["bwi", this.icon(), ...sizeStyles[size].icon];
|
||||
});
|
||||
}
|
||||
114
libs/components/src/icon-tile/icon-tile.stories.ts
Normal file
114
libs/components/src/icon-tile/icon-tile.stories.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Meta, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { BITWARDEN_ICONS } from "../shared/icon";
|
||||
|
||||
import { IconTileComponent } from "./icon-tile.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Icon Tile",
|
||||
component: IconTileComponent,
|
||||
args: {
|
||||
icon: "bwi-star",
|
||||
variant: "primary",
|
||||
size: "default",
|
||||
shape: "square",
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
options: ["primary", "success", "warning", "danger", "muted"],
|
||||
control: { type: "select" },
|
||||
},
|
||||
size: {
|
||||
options: ["small", "default", "large"],
|
||||
control: { type: "select" },
|
||||
},
|
||||
shape: {
|
||||
options: ["square", "circle"],
|
||||
control: { type: "select" },
|
||||
},
|
||||
icon: {
|
||||
options: BITWARDEN_ICONS,
|
||||
control: { type: "select" },
|
||||
},
|
||||
ariaLabel: {
|
||||
control: { type: "text" },
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://atlassian.design/components/icon/icon-tile/examples",
|
||||
},
|
||||
},
|
||||
} as Meta<IconTileComponent>;
|
||||
|
||||
type Story = StoryObj<IconTileComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-flex tw-gap-4 tw-items-center tw-flex-wrap">
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-collection" variant="primary"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Primary</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-check-circle" variant="success"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Success</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-exclamation-triangle" variant="warning"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Warning</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-error" variant="danger"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Danger</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-question-circle" variant="muted"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Muted</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-flex tw-gap-4 tw-items-center">
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-star" variant="primary" size="small"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Small</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-star" variant="primary" size="default"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Default</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-star" variant="primary" size="large"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Large</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const AllShapes: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-flex tw-gap-4 tw-items-center">
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-user" variant="primary" shape="square"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Square</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
|
||||
<bit-icon-tile icon="bwi-user" variant="primary" shape="circle"></bit-icon-tile>
|
||||
<span class="tw-text-sm tw-text-muted">Circle</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
1
libs/components/src/icon-tile/index.ts
Normal file
1
libs/components/src/icon-tile/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./icon-tile.component";
|
||||
@@ -21,6 +21,7 @@ export * from "./drawer";
|
||||
export * from "./form-field";
|
||||
export * from "./icon-button";
|
||||
export * from "./icon";
|
||||
export * from "./icon-tile";
|
||||
export * from "./input";
|
||||
export * from "./item";
|
||||
export * from "./layout";
|
||||
|
||||
@@ -64,7 +64,8 @@ export default {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-40145&t=b5tDKylm5sWm2yKo-4",
|
||||
},
|
||||
chromatic: { viewports: [640, 1280] },
|
||||
// remove disableSnapshots in CL-890
|
||||
chromatic: { viewports: [640, 1280], disableSnapshot: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ export default {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-40145&t=b5tDKylm5sWm2yKo-4",
|
||||
},
|
||||
chromatic: { viewports: [640, 1280] },
|
||||
// remove disableSnapshots in CL-890
|
||||
chromatic: { viewports: [640, 1280], disableSnapshot: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
|
||||
110
libs/components/src/shared/icon.ts
Normal file
110
libs/components/src/shared/icon.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Array of available Bitwarden Web Icons (bwi) font names.
|
||||
* These correspond to the actual icon names defined in the bwi-font.
|
||||
* This array serves as the single source of truth for all available icons.
|
||||
*/
|
||||
export const BITWARDEN_ICONS = [
|
||||
"bwi-angle-down",
|
||||
"bwi-angle-left",
|
||||
"bwi-angle-right",
|
||||
"bwi-angle-up",
|
||||
"bwi-archive",
|
||||
"bwi-bell",
|
||||
"bwi-billing",
|
||||
"bwi-bitcoin",
|
||||
"bwi-browser",
|
||||
"bwi-browser-alt",
|
||||
"bwi-brush",
|
||||
"bwi-bug",
|
||||
"bwi-business",
|
||||
"bwi-camera",
|
||||
"bwi-check",
|
||||
"bwi-check-circle",
|
||||
"bwi-cli",
|
||||
"bwi-clock",
|
||||
"bwi-clone",
|
||||
"bwi-close",
|
||||
"bwi-cog",
|
||||
"bwi-cog-f",
|
||||
"bwi-collection",
|
||||
"bwi-collection-shared",
|
||||
"bwi-credit-card",
|
||||
"bwi-dashboard",
|
||||
"bwi-desktop",
|
||||
"bwi-dollar",
|
||||
"bwi-down-solid",
|
||||
"bwi-download",
|
||||
"bwi-drag-and-drop",
|
||||
"bwi-ellipsis-h",
|
||||
"bwi-ellipsis-v",
|
||||
"bwi-envelope",
|
||||
"bwi-error",
|
||||
"bwi-exclamation-triangle",
|
||||
"bwi-external-link",
|
||||
"bwi-eye",
|
||||
"bwi-eye-slash",
|
||||
"bwi-family",
|
||||
"bwi-file",
|
||||
"bwi-file-text",
|
||||
"bwi-files",
|
||||
"bwi-filter",
|
||||
"bwi-folder",
|
||||
"bwi-generate",
|
||||
"bwi-globe",
|
||||
"bwi-hashtag",
|
||||
"bwi-id-card",
|
||||
"bwi-import",
|
||||
"bwi-info-circle",
|
||||
"bwi-key",
|
||||
"bwi-list",
|
||||
"bwi-list-alt",
|
||||
"bwi-lock",
|
||||
"bwi-lock-encrypted",
|
||||
"bwi-lock-f",
|
||||
"bwi-minus-circle",
|
||||
"bwi-mobile",
|
||||
"bwi-msp",
|
||||
"bwi-numbered-list",
|
||||
"bwi-paperclip",
|
||||
"bwi-passkey",
|
||||
"bwi-paypal",
|
||||
"bwi-pencil",
|
||||
"bwi-pencil-square",
|
||||
"bwi-plus",
|
||||
"bwi-plus-circle",
|
||||
"bwi-popout",
|
||||
"bwi-provider",
|
||||
"bwi-puzzle",
|
||||
"bwi-question-circle",
|
||||
"bwi-refresh",
|
||||
"bwi-search",
|
||||
"bwi-send",
|
||||
"bwi-share",
|
||||
"bwi-shield",
|
||||
"bwi-sign-in",
|
||||
"bwi-sign-out",
|
||||
"bwi-sliders",
|
||||
"bwi-spinner",
|
||||
"bwi-star",
|
||||
"bwi-star-f",
|
||||
"bwi-sticky-note",
|
||||
"bwi-tag",
|
||||
"bwi-trash",
|
||||
"bwi-undo",
|
||||
"bwi-universal-access",
|
||||
"bwi-unlock",
|
||||
"bwi-up-down-btn",
|
||||
"bwi-up-solid",
|
||||
"bwi-user",
|
||||
"bwi-user-monitor",
|
||||
"bwi-users",
|
||||
"bwi-vault",
|
||||
"bwi-wireless",
|
||||
"bwi-wrench",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Type-safe icon names derived from the BITWARDEN_ICONS array.
|
||||
* This ensures type safety while allowing runtime iteration and validation.
|
||||
*/
|
||||
export type BitwardenIcon = (typeof BITWARDEN_ICONS)[number];
|
||||
@@ -88,7 +88,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
|
||||
|
||||
for (const c of results.items) {
|
||||
const cipher = CipherWithIdExport.toDomain(c);
|
||||
// reset ids incase they were set for some reason
|
||||
// reset ids in case they were set for some reason
|
||||
cipher.id = null;
|
||||
cipher.organizationId = this.organizationId;
|
||||
cipher.collectionIds = null;
|
||||
@@ -131,7 +131,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
|
||||
|
||||
results.items.forEach((c) => {
|
||||
const cipher = CipherWithIdExport.toView(c);
|
||||
// reset ids incase they were set for some reason
|
||||
// reset ids in case they were set for some reason
|
||||
cipher.id = null;
|
||||
cipher.organizationId = null;
|
||||
cipher.collectionIds = null;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { emptyGuid, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
@@ -41,7 +40,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
|
||||
accountService = mock<AccountService>();
|
||||
|
||||
accountService.activeAccount$ = of({
|
||||
id: newGuid() as UserId,
|
||||
id: emptyGuid as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
@@ -52,8 +51,8 @@ describe("BitwardenPasswordProtectedImporter", () => {
|
||||
The key values below are never read, empty objects are cast as types for compilation type checking only.
|
||||
Tests specific to key contents are in key-service.spec.ts
|
||||
*/
|
||||
const mockOrgKey = {} as unknown as OrgKey;
|
||||
const mockUserKey = {} as unknown as UserKey;
|
||||
const mockOrgKey = {} as OrgKey;
|
||||
const mockUserKey = {} as UserKey;
|
||||
|
||||
keyService.orgKeys$.mockImplementation(() =>
|
||||
of({ [mockOrgId]: mockOrgKey } as Record<OrganizationId, OrgKey>),
|
||||
@@ -99,7 +98,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
accountService.activeAccount$ = of({
|
||||
id: newGuid() as UserId,
|
||||
id: emptyGuid as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
@@ -8,7 +6,7 @@ import { ImportResult } from "../../models/import-result";
|
||||
import { BaseImporter } from "../base-importer";
|
||||
import { Importer } from "../importer";
|
||||
|
||||
import { FskEntry, FskEntryTypesEnum, FskFile } from "./fsecure-fsk-types";
|
||||
import { FskEntry, FskEntryType, FskFile } from "./fsecure-fsk-types";
|
||||
|
||||
export class FSecureFskImporter extends BaseImporter implements Importer {
|
||||
parse(data: string): Promise<ImportResult> {
|
||||
@@ -19,37 +17,32 @@ export class FSecureFskImporter extends BaseImporter implements Importer {
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
|
||||
for (const key in results.data) {
|
||||
// eslint-disable-next-line
|
||||
if (!results.data.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = results.data[key];
|
||||
for (const [, value] of Object.entries(results.data)) {
|
||||
const cipher = this.parseEntry(value);
|
||||
result.ciphers.push(cipher);
|
||||
if (cipher != undefined) {
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
|
||||
private parseEntry(entry: FskEntry): CipherView {
|
||||
private parseEntry(entry: FskEntry): CipherView | undefined {
|
||||
const cipher = this.initLoginCipher();
|
||||
cipher.name = this.getValueOrDefault(entry.service);
|
||||
cipher.notes = this.getValueOrDefault(entry.notes);
|
||||
cipher.favorite = entry.favorite > 0;
|
||||
|
||||
switch (entry.type) {
|
||||
case FskEntryTypesEnum.Login:
|
||||
case FskEntryType.Login:
|
||||
this.handleLoginEntry(entry, cipher);
|
||||
break;
|
||||
case FskEntryTypesEnum.CreditCard:
|
||||
case FskEntryType.CreditCard:
|
||||
this.handleCreditCardEntry(entry, cipher);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
break;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.convertToNoteIfNeeded(cipher);
|
||||
|
||||
@@ -6,12 +6,18 @@ export interface Data {
|
||||
[key: string]: FskEntry;
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum FskEntryTypesEnum {
|
||||
Login = 1,
|
||||
CreditCard = 2,
|
||||
}
|
||||
/**
|
||||
* Represents the different types of FSK entries.
|
||||
*/
|
||||
export const FskEntryType = Object.freeze({
|
||||
Login: 1,
|
||||
CreditCard: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Type representing valid FSK entry type values.
|
||||
*/
|
||||
export type FskEntryType = (typeof FskEntryType)[keyof typeof FskEntryType];
|
||||
|
||||
export interface FskEntry {
|
||||
color: string;
|
||||
@@ -26,7 +32,7 @@ export interface FskEntry {
|
||||
rev: string | number;
|
||||
service: string;
|
||||
style: string;
|
||||
type: FskEntryTypesEnum;
|
||||
type: FskEntryType;
|
||||
url: string;
|
||||
username: string;
|
||||
createdDate: number; // UNIX timestamp
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum IdpProvider {
|
||||
Azure = 0,
|
||||
OktaAuthServer = 1,
|
||||
OktaNoAuthServer = 2,
|
||||
Google = 3,
|
||||
PingOne = 4,
|
||||
OneLogin = 5,
|
||||
}
|
||||
/**
|
||||
* Represents the different identity providers supported for authentication.
|
||||
*/
|
||||
export const IdpProvider = Object.freeze({
|
||||
Azure: 0,
|
||||
OktaAuthServer: 1,
|
||||
OktaNoAuthServer: 2,
|
||||
Google: 3,
|
||||
PingOne: 4,
|
||||
OneLogin: 5,
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* Type representing valid identity provider values.
|
||||
*/
|
||||
export type IdpProvider = (typeof IdpProvider)[keyof typeof IdpProvider];
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum LastpassLoginType {
|
||||
MasterPassword = 0,
|
||||
/**
|
||||
* Represents LastPass login types.
|
||||
*/
|
||||
export const LastpassLoginType = Object.freeze({
|
||||
MasterPassword: 0,
|
||||
// Not sure what Types 1 and 2 are?
|
||||
Federated = 3,
|
||||
}
|
||||
Federated: 3,
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* Type representing valid LastPass login type values.
|
||||
*/
|
||||
export type LastpassLoginType = (typeof LastpassLoginType)[keyof typeof LastpassLoginType];
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum OtpMethod {
|
||||
GoogleAuth,
|
||||
MicrosoftAuth,
|
||||
Yubikey,
|
||||
}
|
||||
/**
|
||||
* Represents OTP authentication methods.
|
||||
*/
|
||||
export const OtpMethod = Object.freeze({
|
||||
GoogleAuth: 0,
|
||||
MicrosoftAuth: 1,
|
||||
Yubikey: 2,
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* Type representing valid OTP method values.
|
||||
*/
|
||||
export type OtpMethod = (typeof OtpMethod)[keyof typeof OtpMethod];
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum Platform {
|
||||
Desktop,
|
||||
Mobile,
|
||||
}
|
||||
/**
|
||||
* Platform types representing different device categories.
|
||||
*/
|
||||
export const Platform = Object.freeze({
|
||||
Desktop: 0,
|
||||
Mobile: 1,
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* Type representing valid platform values.
|
||||
*/
|
||||
export type Platform = (typeof Platform)[keyof typeof Platform];
|
||||
|
||||
@@ -14,12 +14,12 @@ import { BaseImporter } from "../base-importer";
|
||||
import { Importer } from "../importer";
|
||||
|
||||
import {
|
||||
CategoryEnum,
|
||||
Category,
|
||||
Details,
|
||||
ExportData,
|
||||
FieldsEntity,
|
||||
Item,
|
||||
LoginFieldTypeEnum,
|
||||
LoginFieldType,
|
||||
Overview,
|
||||
PasswordHistoryEntity,
|
||||
SectionsEntity,
|
||||
@@ -45,38 +45,38 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
|
||||
const cipher = this.initLoginCipher();
|
||||
|
||||
const category = item.categoryUuid as CategoryEnum;
|
||||
const category = item.categoryUuid as Category;
|
||||
switch (category) {
|
||||
case CategoryEnum.Login:
|
||||
case CategoryEnum.Database:
|
||||
case CategoryEnum.Password:
|
||||
case CategoryEnum.WirelessRouter:
|
||||
case CategoryEnum.Server:
|
||||
case CategoryEnum.API_Credential:
|
||||
case Category.Login:
|
||||
case Category.Database:
|
||||
case Category.Password:
|
||||
case Category.WirelessRouter:
|
||||
case Category.Server:
|
||||
case Category.API_Credential:
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
break;
|
||||
case CategoryEnum.CreditCard:
|
||||
case CategoryEnum.BankAccount:
|
||||
case Category.CreditCard:
|
||||
case Category.BankAccount:
|
||||
cipher.type = CipherType.Card;
|
||||
cipher.card = new CardView();
|
||||
break;
|
||||
case CategoryEnum.SecureNote:
|
||||
case CategoryEnum.SoftwareLicense:
|
||||
case CategoryEnum.EmailAccount:
|
||||
case CategoryEnum.MedicalRecord:
|
||||
case Category.SecureNote:
|
||||
case Category.SoftwareLicense:
|
||||
case Category.EmailAccount:
|
||||
case Category.MedicalRecord:
|
||||
// case CategoryEnum.Document:
|
||||
cipher.type = CipherType.SecureNote;
|
||||
cipher.secureNote = new SecureNoteView();
|
||||
cipher.secureNote.type = SecureNoteType.Generic;
|
||||
break;
|
||||
case CategoryEnum.Identity:
|
||||
case CategoryEnum.DriversLicense:
|
||||
case CategoryEnum.OutdoorLicense:
|
||||
case CategoryEnum.Membership:
|
||||
case CategoryEnum.Passport:
|
||||
case CategoryEnum.RewardsProgram:
|
||||
case CategoryEnum.SocialSecurityNumber:
|
||||
case Category.Identity:
|
||||
case Category.DriversLicense:
|
||||
case Category.OutdoorLicense:
|
||||
case Category.Membership:
|
||||
case Category.Passport:
|
||||
case Category.RewardsProgram:
|
||||
case Category.SocialSecurityNumber:
|
||||
cipher.type = CipherType.Identity;
|
||||
cipher.identity = new IdentityView();
|
||||
break;
|
||||
@@ -166,10 +166,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
let fieldValue = loginField.value;
|
||||
let fieldType: FieldType = FieldType.Text;
|
||||
switch (loginField.fieldType) {
|
||||
case LoginFieldTypeEnum.Password:
|
||||
case LoginFieldType.Password:
|
||||
fieldType = FieldType.Hidden;
|
||||
break;
|
||||
case LoginFieldTypeEnum.CheckBox:
|
||||
case LoginFieldType.CheckBox:
|
||||
fieldValue = loginField.value !== "" ? "true" : "false";
|
||||
fieldType = FieldType.Boolean;
|
||||
break;
|
||||
@@ -180,8 +180,8 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
});
|
||||
}
|
||||
|
||||
private processDetails(category: CategoryEnum, details: Details, cipher: CipherView) {
|
||||
if (category !== CategoryEnum.Password) {
|
||||
private processDetails(category: Category, details: Details, cipher: CipherView) {
|
||||
if (category !== Category.Password) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
cipher.login.password = details.password;
|
||||
}
|
||||
|
||||
private processSections(category: CategoryEnum, sections: SectionsEntity[], cipher: CipherView) {
|
||||
private processSections(category: Category, sections: SectionsEntity[], cipher: CipherView) {
|
||||
if (sections == null || sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +206,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
}
|
||||
|
||||
private parseSectionFields(
|
||||
category: CategoryEnum,
|
||||
category: Category,
|
||||
fields: FieldsEntity[],
|
||||
cipher: CipherView,
|
||||
sectionTitle: string,
|
||||
@@ -232,20 +232,20 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
}
|
||||
|
||||
switch (category) {
|
||||
case CategoryEnum.Login:
|
||||
case CategoryEnum.Database:
|
||||
case CategoryEnum.EmailAccount:
|
||||
case CategoryEnum.WirelessRouter:
|
||||
case Category.Login:
|
||||
case Category.Database:
|
||||
case Category.EmailAccount:
|
||||
case Category.WirelessRouter:
|
||||
break;
|
||||
|
||||
case CategoryEnum.Server:
|
||||
case Category.Server:
|
||||
if (this.isNullOrWhitespace(cipher.login.uri) && field.id === "url") {
|
||||
cipher.login.uris = this.makeUriArray(fieldValue);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
|
||||
case CategoryEnum.API_Credential:
|
||||
case Category.API_Credential:
|
||||
if (this.fillApiCredentials(field, fieldValue, cipher)) {
|
||||
return;
|
||||
}
|
||||
@@ -258,7 +258,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (category === CategoryEnum.BankAccount) {
|
||||
if (category === Category.BankAccount) {
|
||||
if (this.fillBankAccount(field, fieldValue, cipher)) {
|
||||
return;
|
||||
}
|
||||
@@ -281,34 +281,34 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
}
|
||||
|
||||
switch (category) {
|
||||
case CategoryEnum.Identity:
|
||||
case Category.Identity:
|
||||
break;
|
||||
case CategoryEnum.DriversLicense:
|
||||
case Category.DriversLicense:
|
||||
if (this.fillDriversLicense(field, fieldValue, cipher)) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case CategoryEnum.OutdoorLicense:
|
||||
case Category.OutdoorLicense:
|
||||
if (this.fillOutdoorLicense(field, fieldValue, cipher)) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case CategoryEnum.Membership:
|
||||
case Category.Membership:
|
||||
if (this.fillMembership(field, fieldValue, cipher)) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case CategoryEnum.Passport:
|
||||
case Category.Passport:
|
||||
if (this.fillPassport(field, fieldValue, cipher)) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case CategoryEnum.RewardsProgram:
|
||||
case Category.RewardsProgram:
|
||||
if (this.fillRewardsProgram(field, fieldValue, cipher)) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case CategoryEnum.SocialSecurityNumber:
|
||||
case Category.SocialSecurityNumber:
|
||||
if (this.fillSSN(field, fieldValue, cipher)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,30 +25,36 @@ export interface VaultAttributes {
|
||||
type: string;
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum CategoryEnum {
|
||||
Login = "001",
|
||||
CreditCard = "002",
|
||||
SecureNote = "003",
|
||||
Identity = "004",
|
||||
Password = "005",
|
||||
Document = "006",
|
||||
SoftwareLicense = "100",
|
||||
BankAccount = "101",
|
||||
Database = "102",
|
||||
DriversLicense = "103",
|
||||
OutdoorLicense = "104",
|
||||
Membership = "105",
|
||||
Passport = "106",
|
||||
RewardsProgram = "107",
|
||||
SocialSecurityNumber = "108",
|
||||
WirelessRouter = "109",
|
||||
Server = "110",
|
||||
EmailAccount = "111",
|
||||
API_Credential = "112",
|
||||
MedicalRecord = "113",
|
||||
}
|
||||
/**
|
||||
* Represents the different types of items that can be stored in 1Password.
|
||||
*/
|
||||
export const Category = Object.freeze({
|
||||
Login: "001",
|
||||
CreditCard: "002",
|
||||
SecureNote: "003",
|
||||
Identity: "004",
|
||||
Password: "005",
|
||||
Document: "006",
|
||||
SoftwareLicense: "100",
|
||||
BankAccount: "101",
|
||||
Database: "102",
|
||||
DriversLicense: "103",
|
||||
OutdoorLicense: "104",
|
||||
Membership: "105",
|
||||
Passport: "106",
|
||||
RewardsProgram: "107",
|
||||
SocialSecurityNumber: "108",
|
||||
WirelessRouter: "109",
|
||||
Server: "110",
|
||||
EmailAccount: "111",
|
||||
API_Credential: "112",
|
||||
MedicalRecord: "113",
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* Represents valid 1Password category values.
|
||||
*/
|
||||
export type Category = (typeof Category)[keyof typeof Category];
|
||||
|
||||
export interface Item {
|
||||
uuid: string;
|
||||
@@ -69,23 +75,30 @@ export interface Details {
|
||||
password?: string | null;
|
||||
}
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum LoginFieldTypeEnum {
|
||||
TextOrHtml = "T",
|
||||
EmailAddress = "E",
|
||||
URL = "U",
|
||||
Number = "N",
|
||||
Password = "P",
|
||||
TextArea = "A",
|
||||
PhoneNumber = "TEL",
|
||||
CheckBox = "C",
|
||||
}
|
||||
/**
|
||||
* Represents 1Password login field types that can be stored in login items.
|
||||
*/
|
||||
export const LoginFieldType = Object.freeze({
|
||||
TextOrHtml: "T",
|
||||
EmailAddress: "E",
|
||||
URL: "U",
|
||||
Number: "N",
|
||||
Password: "P",
|
||||
TextArea: "A",
|
||||
PhoneNumber: "TEL",
|
||||
CheckBox: "C",
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* Type representing valid 1Password login field type values.
|
||||
*/
|
||||
export type LoginFieldType = (typeof LoginFieldType)[keyof typeof LoginFieldType];
|
||||
|
||||
export interface LoginFieldsEntity {
|
||||
value: string;
|
||||
id: string;
|
||||
name: string;
|
||||
fieldType: LoginFieldTypeEnum | string;
|
||||
fieldType: LoginFieldType | string;
|
||||
designation?: string | null;
|
||||
}
|
||||
export interface SectionsEntity {
|
||||
|
||||
@@ -27,12 +27,19 @@ export type ProtonPassItem = {
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum ProtonPassItemState {
|
||||
ACTIVE = 1,
|
||||
TRASHED = 2,
|
||||
}
|
||||
/**
|
||||
* Proton Pass item states as a const object.
|
||||
* Represents the different states an item can be in (active or trashed).
|
||||
*/
|
||||
export const ProtonPassItemState = Object.freeze({
|
||||
ACTIVE: 1,
|
||||
TRASHED: 2,
|
||||
} as const);
|
||||
|
||||
/**
|
||||
* Type representing valid Proton Pass item state values.
|
||||
*/
|
||||
export type ProtonPassItemState = (typeof ProtonPassItemState)[keyof typeof ProtonPassItemState];
|
||||
|
||||
export type ProtonPassItemData = {
|
||||
metadata: ProtonPassItemMetadata;
|
||||
|
||||
@@ -341,19 +341,6 @@ describe("ImportService", () => {
|
||||
expect(result.loaders).toContain(Loader.file);
|
||||
});
|
||||
|
||||
it("should exclude chromium loader when feature flag is disabled", async () => {
|
||||
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
|
||||
featureFlagSubject.next(false);
|
||||
|
||||
const metadataPromise = firstValueFrom(importService.metadata$(typeSubject));
|
||||
typeSubject.next(testType);
|
||||
|
||||
const result = await metadataPromise;
|
||||
|
||||
expect(result.loaders).not.toContain(Loader.chromium);
|
||||
expect(result.loaders).toContain(Loader.file);
|
||||
});
|
||||
|
||||
it("should update when type$ changes", async () => {
|
||||
const emissions: ImporterMetadata[] = [];
|
||||
const subscription = importService.metadata$(typeSubject).subscribe((metadata) => {
|
||||
@@ -373,27 +360,6 @@ describe("ImportService", () => {
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should update when feature flag changes", async () => {
|
||||
const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader
|
||||
const emissions: ImporterMetadata[] = [];
|
||||
|
||||
const subscription = importService.metadata$(typeSubject).subscribe((metadata) => {
|
||||
emissions.push(metadata);
|
||||
});
|
||||
|
||||
typeSubject.next(testType);
|
||||
featureFlagSubject.next(true);
|
||||
|
||||
// Wait for emissions
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(emissions).toHaveLength(2);
|
||||
expect(emissions[0].loaders).not.toContain(Loader.chromium);
|
||||
expect(emissions[1].loaders).toContain(Loader.chromium);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should update when both type$ and feature flag change", async () => {
|
||||
const emissions: ImporterMetadata[] = [];
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request";
|
||||
@@ -22,7 +22,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SemanticLogger } from "@bitwarden/common/tools/log";
|
||||
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType, toCipherTypeName } from "@bitwarden/common/vault/enums";
|
||||
@@ -138,18 +138,16 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
}
|
||||
|
||||
metadata$(type$: Observable<ImportType>): Observable<ImporterMetadata> {
|
||||
const browserEnabled$ = this.system.configService.getFeatureFlag$(
|
||||
FeatureFlag.UseChromiumImporter,
|
||||
);
|
||||
const client = this.system.environment.getClientType();
|
||||
const capabilities$ = combineLatest([type$, browserEnabled$]).pipe(
|
||||
map(([type, enabled]) => {
|
||||
const capabilities$ = combineLatest([type$]).pipe(
|
||||
map(([type]) => {
|
||||
let loaders = availableLoaders(type, client);
|
||||
|
||||
// Mac App Store is currently disabled due to sandboxing.
|
||||
let isUnsupported = this.system.environment.isMacAppStore();
|
||||
|
||||
if (enabled && type === "bravecsv") {
|
||||
// disable the chromium loader for Brave on Windows only
|
||||
if (type === "bravecsv") {
|
||||
try {
|
||||
const device = this.system.environment.getDevice();
|
||||
const isWindowsDesktop = device === DeviceType.WindowsDesktop;
|
||||
@@ -160,8 +158,8 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
isUnsupported = true;
|
||||
}
|
||||
}
|
||||
// If the feature flag is disabled, or if the browser is unsupported, remove the chromium loader
|
||||
if (!enabled || isUnsupported) {
|
||||
// If the browser is unsupported, remove the chromium loader
|
||||
if (isUnsupported) {
|
||||
loaders = loaders?.filter((loader) => loader !== Loader.chromium);
|
||||
}
|
||||
|
||||
@@ -241,10 +239,11 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
|
||||
try {
|
||||
await this.setImportTarget(importResult, organizationId, selectedImportTarget);
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
if (organizationId != null) {
|
||||
await this.handleOrganizationalImport(importResult, organizationId);
|
||||
await this.handleOrganizationalImport(importResult, organizationId, userId);
|
||||
} else {
|
||||
await this.handleIndividualImport(importResult);
|
||||
await this.handleIndividualImport(importResult, userId);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorResponse = new ErrorResponse(error, 400);
|
||||
@@ -422,16 +421,14 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIndividualImport(importResult: ImportResult) {
|
||||
private async handleIndividualImport(importResult: ImportResult, userId: UserId) {
|
||||
const request = new ImportCiphersRequest();
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
for (let i = 0; i < importResult.ciphers.length; i++) {
|
||||
const c = await this.cipherService.encrypt(importResult.ciphers[i], activeUserId);
|
||||
const c = await this.cipherService.encrypt(importResult.ciphers[i], userId);
|
||||
request.ciphers.push(new CipherRequest(c));
|
||||
}
|
||||
const userKey = await this.keyService.getUserKey(activeUserId);
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
if (importResult.folders != null) {
|
||||
for (let i = 0; i < importResult.folders.length; i++) {
|
||||
const f = await this.folderService.encrypt(importResult.folders[i], userKey);
|
||||
@@ -449,20 +446,18 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
private async handleOrganizationalImport(
|
||||
importResult: ImportResult,
|
||||
organizationId: OrganizationId,
|
||||
userId: UserId,
|
||||
) {
|
||||
const request = new ImportOrganizationCiphersRequest();
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
for (let i = 0; i < importResult.ciphers.length; i++) {
|
||||
importResult.ciphers[i].organizationId = organizationId;
|
||||
const c = await this.cipherService.encrypt(importResult.ciphers[i], activeUserId);
|
||||
const c = await this.cipherService.encrypt(importResult.ciphers[i], userId);
|
||||
request.ciphers.push(new CipherRequest(c));
|
||||
}
|
||||
if (importResult.collections != null) {
|
||||
for (let i = 0; i < importResult.collections.length; i++) {
|
||||
importResult.collections[i].organizationId = organizationId;
|
||||
const c = await this.collectionService.encrypt(importResult.collections[i], activeUserId);
|
||||
const c = await this.collectionService.encrypt(importResult.collections[i], userId);
|
||||
request.collections.push(new CollectionWithIdRequest(c));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,73 +120,87 @@
|
||||
</ng-container>
|
||||
|
||||
<!-- MP Unlock -->
|
||||
<ng-container
|
||||
*ngIf="
|
||||
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
|
||||
"
|
||||
>
|
||||
<form [bitSubmit]="submit" [formGroup]="formGroup">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||
<input
|
||||
type="password"
|
||||
formControlName="masterPassword"
|
||||
bitInput
|
||||
appAutofocus
|
||||
name="masterPassword"
|
||||
class="tw-font-mono"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
[(toggled)]="showPassword"
|
||||
></button>
|
||||
|
||||
<!-- [attr.aria-pressed]="showPassword" -->
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||
{{ "unlock" | i18n }}
|
||||
</button>
|
||||
|
||||
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||
|
||||
<ng-container *ngIf="showBiometrics">
|
||||
@if (
|
||||
(unlockWithMasterPasswordUnlockDataFlag$ | async) &&
|
||||
unlockOptions.masterPassword.enabled &&
|
||||
activeUnlockOption === UnlockOption.MasterPassword
|
||||
) {
|
||||
<bit-master-password-lock
|
||||
[(activeUnlockOption)]="activeUnlockOption"
|
||||
[unlockOptions]="unlockOptions"
|
||||
[biometricUnlockBtnText]="biometricUnlockBtnText"
|
||||
(successfulUnlock)="successfulMasterPasswordUnlock($event)"
|
||||
(logOut)="logOut()"
|
||||
></bit-master-password-lock>
|
||||
} @else {
|
||||
<ng-container
|
||||
*ngIf="
|
||||
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
|
||||
"
|
||||
>
|
||||
<form [bitSubmit]="submit" [formGroup]="formGroup">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||
<input
|
||||
type="password"
|
||||
formControlName="masterPassword"
|
||||
bitInput
|
||||
appAutofocus
|
||||
name="masterPassword"
|
||||
class="tw-font-mono"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[disabled]="!biometricsAvailable"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.Biometrics"
|
||||
>
|
||||
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
bitIconButton
|
||||
bitSuffix
|
||||
bitPasswordInputToggle
|
||||
[(toggled)]="showPassword"
|
||||
></button>
|
||||
|
||||
<ng-container *ngIf="unlockOptions.pin.enabled">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.Pin"
|
||||
>
|
||||
{{ "unlockWithPin" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
<!-- [attr.aria-pressed]="showPassword" -->
|
||||
</bit-form-field>
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||
{{ "unlock" | i18n }}
|
||||
</button>
|
||||
|
||||
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||
|
||||
<ng-container *ngIf="showBiometrics">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[disabled]="!biometricsAvailable"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.Biometrics"
|
||||
>
|
||||
<span> {{ biometricUnlockBtnText | i18n }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="unlockOptions.pin.enabled">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption = UnlockOption.Pin"
|
||||
>
|
||||
{{ "unlockWithPin" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -98,9 +99,10 @@ describe("LockComponent", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockConfigService = mock<ConfigService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
|
||||
// Setup default mock returns
|
||||
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
|
||||
@@ -156,6 +158,7 @@ describe("LockComponent", () => {
|
||||
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
||||
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
})
|
||||
.overrideProvider(DialogService, { useValue: mockDialogService })
|
||||
@@ -366,6 +369,135 @@ describe("LockComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("successfulMasterPasswordUnlock", () => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const masterPassword = "test-password";
|
||||
|
||||
beforeEach(async () => {
|
||||
component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined as unknown as UserKey, undefined as unknown as string],
|
||||
[null as unknown as UserKey, null as unknown as string],
|
||||
[mockUserKey, undefined as unknown as string],
|
||||
[mockUserKey, null as unknown as string],
|
||||
[mockUserKey, ""],
|
||||
[undefined as unknown as UserKey, masterPassword],
|
||||
[null as unknown as UserKey, masterPassword],
|
||||
])(
|
||||
"logs an error and doesn't unlock when called with invalid data",
|
||||
async (userKey, masterPassword) => {
|
||||
await component.successfulMasterPasswordUnlock({ userKey, masterPassword });
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||
"[LockComponent] successfulMasterPasswordUnlock called with invalid data.",
|
||||
);
|
||||
expect(mockKeyService.setUserKey).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, undefined, false],
|
||||
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false],
|
||||
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true],
|
||||
[true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false],
|
||||
[false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true],
|
||||
])(
|
||||
"unlocks and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service",
|
||||
async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => {
|
||||
mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue(
|
||||
of(masterPasswordPolicyOptions),
|
||||
);
|
||||
const passwordStrengthResult = { score: 1 } as ZXCVBNResult;
|
||||
mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult);
|
||||
mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword);
|
||||
|
||||
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
|
||||
|
||||
assertUnlocked();
|
||||
expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId);
|
||||
if (masterPasswordPolicyOptions?.enforceOnLogin) {
|
||||
expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
component.activeAccount!.email,
|
||||
);
|
||||
expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith(
|
||||
passwordStrengthResult.score,
|
||||
masterPassword,
|
||||
masterPasswordPolicyOptions,
|
||||
);
|
||||
}
|
||||
if (forceSetPassword) {
|
||||
expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, ClientType.Browser],
|
||||
[false, ClientType.Cli],
|
||||
[false, ClientType.Desktop],
|
||||
[false, ClientType.Web],
|
||||
])(
|
||||
"unlocks and navigate by url to previous url = %o when client type = %o and previous url was set",
|
||||
async (shouldNavigate, clientType) => {
|
||||
const previousUrl = "/test-url";
|
||||
component.clientType = clientType;
|
||||
mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl);
|
||||
|
||||
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
|
||||
|
||||
assertUnlocked();
|
||||
if (shouldNavigate) {
|
||||
expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl);
|
||||
} else {
|
||||
expect(mockRouter.navigateByUrl).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["/tabs/current", ClientType.Browser],
|
||||
[undefined, ClientType.Cli],
|
||||
["vault", ClientType.Desktop],
|
||||
["vault", ClientType.Web],
|
||||
])(
|
||||
"unlocks and navigate to success url = %o when client type = %o",
|
||||
async (navigateUrl, clientType) => {
|
||||
component.clientType = clientType;
|
||||
mockLockComponentService.getPreviousUrl.mockReturnValue(null);
|
||||
|
||||
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
|
||||
|
||||
assertUnlocked();
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]);
|
||||
},
|
||||
);
|
||||
|
||||
it("unlocks and close browser extension popout on firefox extension", async () => {
|
||||
component.shouldClosePopout = true;
|
||||
mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension);
|
||||
|
||||
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
|
||||
|
||||
assertUnlocked();
|
||||
expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function assertUnlocked(): void {
|
||||
expect(mockKeyService.setUserKey).toHaveBeenCalledWith(
|
||||
mockUserKey,
|
||||
component.activeAccount!.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe("unlockViaMasterPassword", () => {
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey;
|
||||
const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = {
|
||||
|
||||
@@ -29,10 +29,12 @@ import {
|
||||
MasterPasswordVerificationResponse,
|
||||
} from "@bitwarden/common/auth/types/verification";
|
||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -64,6 +66,8 @@ import {
|
||||
UnlockOptionValue,
|
||||
} from "../services/lock-component.service";
|
||||
|
||||
import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component";
|
||||
|
||||
const BroadcasterSubscriptionId = "LockComponent";
|
||||
|
||||
const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
||||
@@ -72,6 +76,12 @@ const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
||||
[ClientType.Browser]: "/tabs/current",
|
||||
};
|
||||
|
||||
type AfterUnlockActions = {
|
||||
passwordEvaluation?: {
|
||||
masterPassword: string;
|
||||
};
|
||||
};
|
||||
|
||||
/// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible
|
||||
/// Fixes safari autoprompt behavior
|
||||
const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
||||
@@ -87,12 +97,17 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
MasterPasswordLockComponent,
|
||||
],
|
||||
})
|
||||
export class LockComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected loading = true;
|
||||
|
||||
protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.UnlockWithMasterPasswordUnlockData,
|
||||
);
|
||||
|
||||
activeAccount: Account | null = null;
|
||||
|
||||
clientType?: ClientType;
|
||||
@@ -161,6 +176,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
private logoutService: LogoutService,
|
||||
private lockComponentService: LockComponentService,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private configService: ConfigService,
|
||||
// desktop deps
|
||||
private broadcasterService: BroadcasterService,
|
||||
) {}
|
||||
@@ -380,7 +396,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
|
||||
// If user cancels biometric prompt, userKey is undefined.
|
||||
if (userKey) {
|
||||
await this.setUserKeyAndContinue(userKey, false);
|
||||
await this.setUserKeyAndContinue(userKey);
|
||||
}
|
||||
|
||||
this.unlockingViaBiometrics = false;
|
||||
@@ -424,6 +440,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
//TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag.
|
||||
togglePassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
const input = document.getElementById(
|
||||
@@ -499,6 +516,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag.
|
||||
private validateMasterPassword(): boolean {
|
||||
if (this.formGroup?.invalid) {
|
||||
this.toastService.showToast({
|
||||
@@ -512,6 +530,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag.
|
||||
async unlockViaMasterPassword() {
|
||||
if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) {
|
||||
return;
|
||||
@@ -569,10 +588,33 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setUserKeyAndContinue(userKey, true);
|
||||
await this.setUserKeyAndContinue(userKey, {
|
||||
passwordEvaluation: { masterPassword },
|
||||
});
|
||||
}
|
||||
|
||||
private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) {
|
||||
async successfulMasterPasswordUnlock(event: {
|
||||
userKey: UserKey;
|
||||
masterPassword: string;
|
||||
}): Promise<void> {
|
||||
if (event.userKey == null || !event.masterPassword) {
|
||||
this.logService.error(
|
||||
"[LockComponent] successfulMasterPasswordUnlock called with invalid data.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setUserKeyAndContinue(event.userKey, {
|
||||
passwordEvaluation: {
|
||||
masterPassword: event.masterPassword,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected async setUserKeyAndContinue(
|
||||
key: UserKey,
|
||||
afterUnlockActions: AfterUnlockActions = {},
|
||||
): Promise<void> {
|
||||
if (this.activeAccount == null) {
|
||||
throw new Error("No active user.");
|
||||
}
|
||||
@@ -586,10 +628,10 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
// need to establish trust on the current device
|
||||
await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id);
|
||||
|
||||
await this.doContinue(evaluatePasswordAfterUnlock);
|
||||
await this.doContinue(afterUnlockActions);
|
||||
}
|
||||
|
||||
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
|
||||
private async doContinue(afterUnlockActions: AfterUnlockActions) {
|
||||
if (this.activeAccount == null) {
|
||||
throw new Error("No active user.");
|
||||
}
|
||||
@@ -597,7 +639,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
await this.biometricStateService.resetUserPromptCancelled();
|
||||
this.messagingService.send("unlocked");
|
||||
|
||||
if (evaluatePasswordAfterUnlock) {
|
||||
if (afterUnlockActions.passwordEvaluation) {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (userId == null) {
|
||||
throw new Error("No active user.");
|
||||
@@ -614,7 +656,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.requirePasswordChange()) {
|
||||
if (this.requirePasswordChange(afterUnlockActions.passwordEvaluation.masterPassword)) {
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.WeakMasterPassword,
|
||||
userId,
|
||||
@@ -676,18 +718,15 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
* Checks if the master password meets the enforced policy requirements
|
||||
* If not, returns false
|
||||
*/
|
||||
private requirePasswordChange(): boolean {
|
||||
private requirePasswordChange(masterPassword: string): boolean {
|
||||
if (
|
||||
this.enforcedMasterPasswordOptions == undefined ||
|
||||
!this.enforcedMasterPasswordOptions.enforceOnLogin ||
|
||||
this.formGroup == null ||
|
||||
this.activeAccount == null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const masterPassword = this.formGroup.controls.masterPassword.value;
|
||||
|
||||
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
|
||||
masterPassword,
|
||||
this.activeAccount.email,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||
<input
|
||||
type="password"
|
||||
formControlName="masterPassword"
|
||||
bitInput
|
||||
appAutofocus
|
||||
name="masterPassword"
|
||||
class="tw-font-mono"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-flex tw-flex-col tw-space-y-3">
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary" block>
|
||||
{{ "unlock" | i18n }}
|
||||
</button>
|
||||
|
||||
<p class="tw-text-center">{{ "or" | i18n }}</p>
|
||||
|
||||
@if (showBiometricsSwap()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
[disabled]="!biometricsAvailable()"
|
||||
block
|
||||
(click)="activeUnlockOption.set(UnlockOption.Biometrics)"
|
||||
>
|
||||
<span> {{ biometricUnlockBtnText() | i18n }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (showPinSwap()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="activeUnlockOption.set(UnlockOption.Pin)"
|
||||
>
|
||||
{{ "unlockWithPin" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut.emit()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,472 @@
|
||||
import { DebugElement } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { UnlockOption, UnlockOptions } from "../../services/lock-component.service";
|
||||
|
||||
import { MasterPasswordLockComponent } from "./master-password-lock.component";
|
||||
|
||||
describe("MasterPasswordLockComponent", () => {
|
||||
let component: MasterPasswordLockComponent;
|
||||
let fixture: ComponentFixture<MasterPasswordLockComponent>;
|
||||
|
||||
const accountService = mock<AccountService>();
|
||||
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
|
||||
const i18nService = mock<I18nService>();
|
||||
const toastService = mock<ToastService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
const mockMasterPassword = "testExample";
|
||||
const activeAccount: Account = {
|
||||
id: "user-id" as UserId,
|
||||
email: "user@example.com",
|
||||
emailVerified: true,
|
||||
name: "User",
|
||||
};
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
|
||||
const setupComponent = (
|
||||
unlockOptions: Partial<UnlockOptions> = {},
|
||||
biometricUnlockBtnText: string = "default",
|
||||
account: Account | null = activeAccount,
|
||||
) => {
|
||||
const defaultOptions: UnlockOptions = {
|
||||
masterPassword: { enabled: true },
|
||||
pin: { enabled: false },
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||
},
|
||||
};
|
||||
|
||||
accountService.activeAccount$ = of(account);
|
||||
fixture.componentRef.setInput("unlockOptions", { ...defaultOptions, ...unlockOptions });
|
||||
fixture.componentRef.setInput("biometricUnlockBtnText", biometricUnlockBtnText);
|
||||
fixture.detectChanges();
|
||||
|
||||
return {
|
||||
form: fixture.debugElement.query(By.css("form")),
|
||||
component,
|
||||
...getFormElements(fixture.debugElement.query(By.css("form"))),
|
||||
};
|
||||
};
|
||||
|
||||
const getFormElements = (form: DebugElement) => ({
|
||||
masterPasswordInput: form.query(By.css('input[formControlName="masterPassword"]')),
|
||||
toggleButton: form.query(By.css("button[bitPasswordInputToggle]")),
|
||||
submitButton: form.query(By.css('button[type="submit"]')),
|
||||
logoutButton: form.query(By.css('button[type="button"]:not([bitPasswordInputToggle])')),
|
||||
secondaryButton: form.query(By.css('button[buttonType="secondary"]')),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
i18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
MasterPasswordLockComponent,
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
],
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: MasterPasswordUnlockService, useValue: masterPasswordUnlockService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: LogService, useValue: logService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MasterPasswordLockComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe("form rendering", () => {
|
||||
let elements: ReturnType<typeof setupComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
elements = setupComponent();
|
||||
});
|
||||
|
||||
it("creates form with proper structure", () => {
|
||||
expect(component.formGroup).toBeDefined();
|
||||
expect(component.formGroup.controls.masterPassword).toBeDefined();
|
||||
});
|
||||
|
||||
const formElementTests = [
|
||||
{
|
||||
name: "master password input",
|
||||
selector: "masterPasswordInput",
|
||||
expectations: (el: HTMLInputElement) => {
|
||||
expect(el).toMatchObject({
|
||||
type: "password",
|
||||
name: "masterPassword",
|
||||
required: true,
|
||||
});
|
||||
expect(el.attributes).toHaveProperty("bitInput");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "password toggle button",
|
||||
selector: "toggleButton",
|
||||
expectations: (el: HTMLButtonElement) => {
|
||||
expect(el.type).toBe("button");
|
||||
expect(el.attributes).toHaveProperty("bitIconButton");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unlock submit button",
|
||||
selector: "submitButton",
|
||||
expectations: (el: HTMLButtonElement) => {
|
||||
expect(el).toMatchObject({
|
||||
type: "submit",
|
||||
textContent: expect.stringContaining("unlock"),
|
||||
});
|
||||
expect(el.attributes).toHaveProperty("bitButton");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "logout button",
|
||||
selector: "logoutButton",
|
||||
expectations: (el: HTMLButtonElement) => {
|
||||
expect(el).toMatchObject({
|
||||
type: "button",
|
||||
textContent: expect.stringContaining("logOut"),
|
||||
});
|
||||
expect(el.attributes).toHaveProperty("bitButton");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test.each(formElementTests)("renders $name correctly", ({ selector, expectations }) => {
|
||||
const element = elements[selector as keyof typeof elements] as DebugElement;
|
||||
expect(element).toBeTruthy();
|
||||
expectations(element.nativeElement);
|
||||
});
|
||||
|
||||
const hiddenButtonTests = [
|
||||
{
|
||||
case: "biometrics swap button when biometrics is undefined",
|
||||
setup: () =>
|
||||
setupComponent(
|
||||
{
|
||||
pin: { enabled: false },
|
||||
biometrics: {
|
||||
enabled: undefined as unknown as boolean,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
},
|
||||
"swapBiometrics",
|
||||
),
|
||||
expectHidden: true,
|
||||
},
|
||||
{
|
||||
case: "biometrics swap button when biometrics is disabled",
|
||||
setup: () => setupComponent({}, "swapBiometrics"),
|
||||
expectHidden: true,
|
||||
},
|
||||
{
|
||||
case: "PIN swap button when PIN is disabled",
|
||||
setup: () => setupComponent({}),
|
||||
expectHidden: true,
|
||||
},
|
||||
{
|
||||
case: "PIN swap button when PIN is undefined",
|
||||
setup: () =>
|
||||
setupComponent({
|
||||
pin: { enabled: undefined as unknown as boolean },
|
||||
biometrics: {
|
||||
enabled: undefined as unknown as boolean,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
}),
|
||||
expectHidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
test.each(hiddenButtonTests)("doesn't render $case", ({ setup, expectHidden }) => {
|
||||
const { secondaryButton } = setup();
|
||||
expect(!!secondaryButton).toBe(!expectHidden);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password input", () => {
|
||||
let setup: ReturnType<typeof setupComponent>;
|
||||
beforeEach(() => {
|
||||
setup = setupComponent();
|
||||
});
|
||||
|
||||
it("should bind form input to masterPassword form control", async () => {
|
||||
const input = setup.masterPasswordInput;
|
||||
expect(input).toBeTruthy();
|
||||
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
|
||||
expect(component.formGroup).toBeTruthy();
|
||||
const masterPasswordControl = component.formGroup!.get("masterPassword");
|
||||
expect(masterPasswordControl).toBeTruthy();
|
||||
|
||||
masterPasswordControl!.setValue("test-password");
|
||||
fixture.detectChanges();
|
||||
|
||||
const inputElement = input.nativeElement as HTMLInputElement;
|
||||
expect(inputElement.value).toEqual("test-password");
|
||||
});
|
||||
|
||||
it("should validate required master password field", async () => {
|
||||
const formGroup = component.formGroup;
|
||||
|
||||
// Initially form should be invalid (empty required field)
|
||||
expect(formGroup?.invalid).toEqual(true);
|
||||
expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true);
|
||||
|
||||
// Set a value
|
||||
formGroup?.get("masterPassword")?.setValue("test-password");
|
||||
|
||||
expect(formGroup?.invalid).toEqual(false);
|
||||
expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false);
|
||||
});
|
||||
|
||||
it("should toggle password visibility when toggle button is clicked", async () => {
|
||||
const toggleButton = setup.toggleButton;
|
||||
expect(toggleButton).toBeTruthy();
|
||||
expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||
const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement;
|
||||
const input = setup.masterPasswordInput;
|
||||
expect(input).toBeTruthy();
|
||||
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
|
||||
const inputElement = input.nativeElement as HTMLInputElement;
|
||||
|
||||
// Initially password should be hidden
|
||||
expect(inputElement.type).toEqual("password");
|
||||
|
||||
// Click toggle button
|
||||
toggleButtonElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(inputElement.type).toEqual("text");
|
||||
|
||||
// Click toggle button again
|
||||
toggleButtonElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(inputElement.type).toEqual("password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("emits logOut event when logout button is clicked", () => {
|
||||
const setup = setupComponent();
|
||||
let logoutEmitted = false;
|
||||
component.logOut.subscribe(() => {
|
||||
logoutEmitted = true;
|
||||
});
|
||||
|
||||
expect(setup.logoutButton).toBeTruthy();
|
||||
expect(setup.logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||
const logoutButtonElement = setup.logoutButton.nativeElement as HTMLButtonElement;
|
||||
|
||||
// Click logout button
|
||||
logoutButtonElement.click();
|
||||
|
||||
expect(logoutEmitted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap buttons", () => {
|
||||
const swapButtonScenarios = [
|
||||
{
|
||||
name: "PIN swap button when PIN is enabled",
|
||||
unlockOptions: {
|
||||
pin: { enabled: true },
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
},
|
||||
expectedText: "unlockWithPin",
|
||||
expectedUnlockOption: UnlockOption.Pin,
|
||||
shouldShow: true,
|
||||
shouldEnable: true,
|
||||
},
|
||||
{
|
||||
name: "PIN swap button when PIN is disabled",
|
||||
unlockOptions: {
|
||||
pin: { enabled: false },
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
},
|
||||
expectedText: "unlockWithPin",
|
||||
expectedUnlockOption: UnlockOption.Pin,
|
||||
shouldShow: false,
|
||||
shouldEnable: false,
|
||||
},
|
||||
{
|
||||
name: "biometrics swap button when biometrics status is available and enabled",
|
||||
unlockOptions: {
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
|
||||
},
|
||||
expectedText: "swapBiometrics",
|
||||
expectedUnlockOption: UnlockOption.Biometrics,
|
||||
shouldShow: true,
|
||||
shouldEnable: true,
|
||||
},
|
||||
{
|
||||
name: "biometrics swap button when biometrics status is available and disabled",
|
||||
unlockOptions: {
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available },
|
||||
},
|
||||
expectedText: "swapBiometrics",
|
||||
expectedUnlockOption: UnlockOption.Biometrics,
|
||||
shouldShow: true,
|
||||
shouldEnable: false,
|
||||
},
|
||||
{
|
||||
name: "biometrics swap button when biometrics biometrics status is unsupported and enabled",
|
||||
unlockOptions: {
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.PlatformUnsupported },
|
||||
},
|
||||
expectedText: "swapBiometrics",
|
||||
expectedUnlockOption: UnlockOption.Biometrics,
|
||||
shouldShow: false,
|
||||
shouldEnable: false,
|
||||
},
|
||||
{
|
||||
name: "biometrics swap button when biometrics status is unsupported and disabled",
|
||||
unlockOptions: {
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported },
|
||||
},
|
||||
expectedText: "swapBiometrics",
|
||||
expectedUnlockOption: UnlockOption.Biometrics,
|
||||
shouldShow: false,
|
||||
shouldEnable: false,
|
||||
},
|
||||
];
|
||||
|
||||
test.each(swapButtonScenarios)(
|
||||
"renders and handles $name",
|
||||
({ unlockOptions, expectedText, expectedUnlockOption, shouldShow, shouldEnable }) => {
|
||||
const { secondaryButton, component } = setupComponent(unlockOptions, expectedText);
|
||||
|
||||
if (shouldShow) {
|
||||
expect(secondaryButton).toBeTruthy();
|
||||
expect(secondaryButton.nativeElement.textContent?.trim()).toBe(expectedText);
|
||||
|
||||
if (shouldEnable) {
|
||||
secondaryButton.nativeElement.click();
|
||||
expect(component.activeUnlockOption()).toBe(expectedUnlockOption);
|
||||
} else {
|
||||
expect(secondaryButton.nativeElement.getAttribute("aria-disabled")).toBe("true");
|
||||
}
|
||||
} else {
|
||||
expect(secondaryButton).toBeFalsy();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("submit", () => {
|
||||
test.each([null, undefined as unknown as string, ""])(
|
||||
"won't unlock and show password invalid toast when master password is %s",
|
||||
async (value) => {
|
||||
component.formGroup.controls.masterPassword.setValue(value);
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: i18nService.t("errorOccurred"),
|
||||
message: i18nService.t("masterPasswordRequired"),
|
||||
});
|
||||
expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([null as unknown as Account, undefined as unknown as Account])(
|
||||
"throws error when active account is %s",
|
||||
async (value) => {
|
||||
accountService.activeAccount$ = of(value);
|
||||
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
|
||||
|
||||
await expect(component.submit()).rejects.toThrow("Null or undefined account");
|
||||
|
||||
expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows an error toast and logs the error when unlock with master password fails", async () => {
|
||||
const customError = new Error("Specialized error message");
|
||||
masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue(customError);
|
||||
accountService.activeAccount$ = of(activeAccount);
|
||||
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
activeAccount.id,
|
||||
);
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: i18nService.t("errorOccurred"),
|
||||
message: i18nService.t("invalidMasterPassword"),
|
||||
});
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[MasterPasswordLockComponent] Failed to unlock via master password",
|
||||
customError,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits userKey when unlock is successful", async () => {
|
||||
masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey);
|
||||
accountService.activeAccount$ = of(activeAccount);
|
||||
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
|
||||
let emittedEvent: { userKey: UserKey; masterPassword: string } | undefined;
|
||||
component.successfulUnlock.subscribe(
|
||||
(event: { userKey: UserKey; masterPassword: string }) => {
|
||||
emittedEvent = event;
|
||||
},
|
||||
);
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(emittedEvent?.userKey).toEqual(mockUserKey);
|
||||
expect(emittedEvent?.masterPassword).toEqual(mockMasterPassword);
|
||||
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
activeAccount.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Component, computed, inject, input, model, output } from "@angular/core";
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import {
|
||||
UnlockOption,
|
||||
UnlockOptions,
|
||||
UnlockOptionValue,
|
||||
} from "../../services/lock-component.service";
|
||||
|
||||
@Component({
|
||||
selector: "bit-master-password-lock",
|
||||
templateUrl: "master-password-lock.component.html",
|
||||
imports: [
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
],
|
||||
})
|
||||
export class MasterPasswordLockComponent {
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService);
|
||||
private readonly i18nService = inject(I18nService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly logService = inject(LogService);
|
||||
UnlockOption = UnlockOption;
|
||||
|
||||
activeUnlockOption = model.required<UnlockOptionValue>();
|
||||
|
||||
unlockOptions = input.required<UnlockOptions>();
|
||||
biometricUnlockBtnText = input.required<string>();
|
||||
showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false);
|
||||
biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false);
|
||||
showBiometricsSwap = computed(() => {
|
||||
const status = this.unlockOptions().biometrics.biometricsStatus;
|
||||
return (
|
||||
status !== BiometricsStatus.PlatformUnsupported &&
|
||||
status !== BiometricsStatus.NotEnabledLocally
|
||||
);
|
||||
});
|
||||
|
||||
successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>();
|
||||
logOut = output<void>();
|
||||
|
||||
formGroup = new FormGroup({
|
||||
masterPassword: new FormControl("", {
|
||||
validators: [Validators.required],
|
||||
updateOn: "submit",
|
||||
}),
|
||||
});
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
const masterPassword = this.formGroup.controls.masterPassword.value;
|
||||
if (this.formGroup.invalid || !masterPassword) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("masterPasswordRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
await this.unlockViaMasterPassword(masterPassword, activeUserId);
|
||||
};
|
||||
|
||||
private async unlockViaMasterPassword(
|
||||
masterPassword: string,
|
||||
activeUserId: UserId,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword(
|
||||
masterPassword,
|
||||
activeUserId,
|
||||
);
|
||||
this.successfulUnlock.emit({ userKey, masterPassword });
|
||||
} catch (error) {
|
||||
this.logService.error(
|
||||
"[MasterPasswordLockComponent] Failed to unlock via master password",
|
||||
error,
|
||||
);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("invalidMasterPassword"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { WrappedSigningKey } from "@bitwarden/common/key-management/types";
|
||||
import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -236,8 +237,10 @@ export abstract class KeyService {
|
||||
*/
|
||||
abstract getOrgKey(orgId: string): Promise<OrgKey | null>;
|
||||
/**
|
||||
* Uses the org key to derive a new symmetric key for encrypting data
|
||||
* @param key The organization's symmetric key
|
||||
* Makes a fresh attachment content encryption key and returns it along with a wrapped (encrypted) version of it.
|
||||
* @deprecated Do not use this for new code / new cryptographic designs.
|
||||
* @param key The organization's symmetric key or the user's user key to wrap the attachment key with
|
||||
* @returns The new attachment content encryption key and the wrapped version of it
|
||||
*/
|
||||
abstract makeDataEncKey<T extends UserKey | OrgKey>(
|
||||
key: T,
|
||||
@@ -272,6 +275,14 @@ export abstract class KeyService {
|
||||
* @param encPrivateKey An encrypted private key
|
||||
*/
|
||||
abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise<void>;
|
||||
/**
|
||||
* Sets the user's encrypted signing key in storage
|
||||
* In contrast to the private key, the decrypted signing key
|
||||
* is not stored in memory outside of the SDK.
|
||||
* @param encryptedSigningKey An encrypted signing key
|
||||
* @param userId The user id of the user to set the signing key for
|
||||
*/
|
||||
abstract setUserSigningKey(encryptedSigningKey: WrappedSigningKey, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets an observable stream of the given users decrypted private key, will emit null if the user
|
||||
@@ -416,7 +427,13 @@ export abstract class KeyService {
|
||||
*
|
||||
* @throws If an invalid user id is passed in.
|
||||
*/
|
||||
abstract userPublicKey$(userId: UserId): Observable<UserPublicKey | null>;
|
||||
abstract userPublicKey$(userId: UserId): Observable<Uint8Array | null>;
|
||||
|
||||
/**
|
||||
* Gets a users signing keys from local state.
|
||||
* The observable will emit null, exactly if the local state returns null.
|
||||
*/
|
||||
abstract userSigningKey$(userId: UserId): Observable<WrappedSigningKey | null>;
|
||||
|
||||
/**
|
||||
* Validates that a userkey is correct for a given user
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { UnsignedPublicKey, WrappedSigningKey } from "@bitwarden/common/key-management/types";
|
||||
import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
USER_ENCRYPTED_PRIVATE_KEY,
|
||||
USER_EVER_HAD_USER_KEY,
|
||||
USER_KEY,
|
||||
USER_KEY_ENCRYPTED_SIGNING_KEY,
|
||||
} from "@bitwarden/common/platform/services/key-state/user-key.state";
|
||||
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
import {
|
||||
@@ -432,6 +434,7 @@ describe("keyService", () => {
|
||||
USER_ENCRYPTED_ORGANIZATION_KEYS,
|
||||
USER_ENCRYPTED_PROVIDER_KEYS,
|
||||
USER_ENCRYPTED_PRIVATE_KEY,
|
||||
USER_KEY_ENCRYPTED_SIGNING_KEY,
|
||||
USER_KEY,
|
||||
])("key removal", (key: UserKeyDefinition<unknown>) => {
|
||||
it(`clears ${key.key} for the specified user when specified`, async () => {
|
||||
@@ -540,6 +543,51 @@ describe("keyService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("userSigningKey$", () => {
|
||||
it("returns the signing key when the user has a signing key set", async () => {
|
||||
const fakeSigningKey = "" as WrappedSigningKey;
|
||||
const fakeSigningKeyState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
USER_KEY_ENCRYPTED_SIGNING_KEY,
|
||||
);
|
||||
fakeSigningKeyState.nextState(fakeSigningKey);
|
||||
|
||||
const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId));
|
||||
|
||||
expect(signingKey).toEqual(fakeSigningKey);
|
||||
});
|
||||
|
||||
it("returns null when the user does not have a signing key set", async () => {
|
||||
const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId));
|
||||
|
||||
expect(signingKey).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setUserSigningKey", () => {
|
||||
it("throws if the signing key is null", async () => {
|
||||
await expect(keyService.setUserSigningKey(null as any, mockUserId)).rejects.toThrow(
|
||||
"No user signing key provided.",
|
||||
);
|
||||
});
|
||||
it("throws if the userId is null", async () => {
|
||||
await expect(
|
||||
keyService.setUserSigningKey("" as WrappedSigningKey, null as unknown as UserId),
|
||||
).rejects.toThrow("No userId provided.");
|
||||
});
|
||||
it("sets the signing key for the user", async () => {
|
||||
const fakeSigningKey = "" as WrappedSigningKey;
|
||||
const fakeSigningKeyState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
USER_KEY_ENCRYPTED_SIGNING_KEY,
|
||||
);
|
||||
fakeSigningKeyState.nextState(null);
|
||||
await keyService.setUserSigningKey(fakeSigningKey, mockUserId);
|
||||
expect(fakeSigningKeyState.nextMock).toHaveBeenCalledTimes(1);
|
||||
expect(fakeSigningKeyState.nextMock).toHaveBeenCalledWith(fakeSigningKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cipherDecryptionKeys$", () => {
|
||||
function fakePrivateKeyDecryption(encryptedPrivateKey: EncString, key: SymmetricCryptoKey) {
|
||||
const output = new Uint8Array(64);
|
||||
@@ -1132,12 +1180,12 @@ describe("keyService", () => {
|
||||
|
||||
keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject("private key"));
|
||||
cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(
|
||||
Utils.fromUtf8ToArray("public key"),
|
||||
Utils.fromUtf8ToArray("public key") as UnsignedPublicKey,
|
||||
);
|
||||
const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId));
|
||||
expect(key).toEqual({
|
||||
privateKey: "private key",
|
||||
publicKey: Utils.fromUtf8ToArray("public key"),
|
||||
publicKey: Utils.fromUtf8ToArray("public key") as UnsignedPublicKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { WrappedSigningKey } from "@bitwarden/common/key-management/types";
|
||||
import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
USER_ENCRYPTED_PRIVATE_KEY,
|
||||
USER_EVER_HAD_USER_KEY,
|
||||
USER_KEY,
|
||||
USER_KEY_ENCRYPTED_SIGNING_KEY,
|
||||
} from "@bitwarden/common/platform/services/key-state/user-key.state";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
@@ -398,8 +400,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
throw new Error("No key provided");
|
||||
}
|
||||
|
||||
const newSymKey = await this.keyGenerationService.createKey(512);
|
||||
return this.buildProtectedSymmetricKey(key, newSymKey);
|
||||
// Content encryption key is AES256_CBC_HMAC
|
||||
const cek = await this.keyGenerationService.createKey(512);
|
||||
const wrappedCek = await this.encryptService.wrapSymmetricKey(cek, key);
|
||||
return [cek, wrappedCek];
|
||||
}
|
||||
|
||||
private async clearOrgKeys(userId: UserId): Promise<void> {
|
||||
@@ -505,6 +509,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId);
|
||||
}
|
||||
|
||||
private async clearSigningKey(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, null, userId);
|
||||
}
|
||||
|
||||
async clearPinKeys(userId: UserId): Promise<void> {
|
||||
if (userId == null) {
|
||||
throw new Error("UserId is required");
|
||||
@@ -537,6 +545,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
await this.clearOrgKeys(userId);
|
||||
await this.clearProviderKeys(userId);
|
||||
await this.clearKeyPair(userId);
|
||||
await this.clearSigningKey(userId);
|
||||
await this.clearPinKeys(userId);
|
||||
await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId);
|
||||
}
|
||||
@@ -758,6 +767,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
return phrase;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* This should only be used for wrapping the user key with a master key or stretched master key.
|
||||
*/
|
||||
private async buildProtectedSymmetricKey<T extends SymmetricCryptoKey>(
|
||||
encryptionKey: SymmetricCryptoKey,
|
||||
newSymKey: SymmetricCryptoKey,
|
||||
@@ -792,7 +805,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await this.cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey;
|
||||
return await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||
}
|
||||
|
||||
userPrivateKey$(userId: UserId): Observable<UserPrivateKey | null> {
|
||||
@@ -808,7 +821,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicKey = (await this.derivePublicKey(privateKey))!;
|
||||
const publicKey = (await this.derivePublicKey(privateKey))! as UserPublicKey;
|
||||
return { privateKey, publicKey };
|
||||
}),
|
||||
);
|
||||
@@ -905,6 +918,27 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
);
|
||||
}
|
||||
|
||||
async setUserSigningKey(userSigningKey: WrappedSigningKey, userId: UserId): Promise<void> {
|
||||
if (userSigningKey == null) {
|
||||
throw new Error("No user signing key provided.");
|
||||
}
|
||||
if (userId == null) {
|
||||
throw new Error("No userId provided.");
|
||||
}
|
||||
await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, userSigningKey, userId);
|
||||
}
|
||||
|
||||
userSigningKey$(userId: UserId): Observable<WrappedSigningKey | null> {
|
||||
return this.stateProvider.getUser(userId, USER_KEY_ENCRYPTED_SIGNING_KEY).state$.pipe(
|
||||
map((encryptedSigningKey) => {
|
||||
if (encryptedSigningKey == null) {
|
||||
return null;
|
||||
}
|
||||
return encryptedSigningKey as WrappedSigningKey;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
orgKeys$(userId: UserId): Observable<Record<OrganizationId, OrgKey> | null> {
|
||||
return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys ?? null));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as crypto from "crypto";
|
||||
import * as forge from "node-forge";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { UnsignedPublicKey } from "@bitwarden/common/key-management/types";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
@@ -232,7 +233,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
|
||||
return Promise.resolve(this.toUint8Buffer(decipher));
|
||||
}
|
||||
|
||||
rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
|
||||
async rsaExtractPublicKey(privateKey: Uint8Array): Promise<UnsignedPublicKey> {
|
||||
const privateKeyByteString = Utils.fromBufferToByteString(privateKey);
|
||||
const privateKeyAsn1 = forge.asn1.fromDer(privateKeyByteString);
|
||||
const forgePrivateKey: any = forge.pki.privateKeyFromAsn1(privateKeyAsn1);
|
||||
@@ -240,11 +241,11 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
|
||||
const publicKeyAsn1 = forge.pki.publicKeyToAsn1(forgePublicKey);
|
||||
const publicKeyByteString = forge.asn1.toDer(publicKeyAsn1).data;
|
||||
const publicKeyArray = Utils.fromByteStringToArray(publicKeyByteString);
|
||||
return Promise.resolve(publicKeyArray);
|
||||
return publicKeyArray as UnsignedPublicKey;
|
||||
}
|
||||
|
||||
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> {
|
||||
return new Promise<[Uint8Array, Uint8Array]>((resolve, reject) => {
|
||||
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[UnsignedPublicKey, Uint8Array]> {
|
||||
return new Promise<[UnsignedPublicKey, Uint8Array]>((resolve, reject) => {
|
||||
forge.pki.rsa.generateKeyPair(
|
||||
{
|
||||
bits: length,
|
||||
@@ -266,7 +267,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
|
||||
const privateKeyByteString = forge.asn1.toDer(privateKeyPkcs8).getBytes();
|
||||
const privateKey = Utils.fromByteStringToArray(privateKeyByteString);
|
||||
|
||||
resolve([publicKey, privateKey]);
|
||||
resolve([publicKey as UnsignedPublicKey, privateKey]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherId, emptyGuid, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -179,7 +179,7 @@ describe("VaultExportService", () => {
|
||||
let restrictedItemTypesService: Partial<RestrictedItemTypesService>;
|
||||
let fetchMock: jest.Mock;
|
||||
|
||||
const userId = "" as UserId;
|
||||
const userId = emptyGuid as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
|
||||
@@ -201,6 +201,10 @@ export class IndividualVaultExportService
|
||||
}
|
||||
|
||||
private async getEncryptedExport(activeUserId: UserId): Promise<ExportedVaultAsString> {
|
||||
if (!activeUserId) {
|
||||
throw new Error("User ID must not be null or undefined");
|
||||
}
|
||||
|
||||
let folders: Folder[] = [];
|
||||
let ciphers: Cipher[] = [];
|
||||
const promises = [];
|
||||
@@ -225,7 +229,7 @@ export class IndividualVaultExportService
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const userKey = await this.keyService.getUserKey(activeUserId);
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
|
||||
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), userKey);
|
||||
|
||||
const jsonDoc: BitwardenEncryptedIndividualJsonExport = {
|
||||
|
||||
@@ -9,7 +9,9 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc/rxjs-operators";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CalloutModule } from "@bitwarden/components";
|
||||
import { ExportFormat } from "@bitwarden/vault-export-core";
|
||||
|
||||
@Component({
|
||||
selector: "tools-export-scope-callout",
|
||||
@@ -25,9 +27,9 @@ export class ExportScopeCalloutComponent {
|
||||
};
|
||||
|
||||
/* Optional OrganizationId, if not provided, it will display individual vault export message */
|
||||
readonly organizationId = input<string>();
|
||||
readonly organizationId = input<OrganizationId>();
|
||||
/* Optional export format, determines which individual export description to display */
|
||||
readonly exportFormat = input<string>();
|
||||
readonly exportFormat = input<ExportFormat>();
|
||||
/* The description key to use for organizational exports */
|
||||
readonly orgExportDescription = input<string>();
|
||||
|
||||
@@ -47,13 +49,13 @@ export class ExportScopeCalloutComponent {
|
||||
}
|
||||
|
||||
private async getScopeMessage(
|
||||
organizationId: string,
|
||||
exportFormat: string,
|
||||
organizationId: OrganizationId | undefined,
|
||||
exportFormat: ExportFormat | undefined,
|
||||
orgExportDescription: string,
|
||||
): Promise<void> {
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
if (organizationId != null) {
|
||||
if (organizationId) {
|
||||
// exporting from organizational vault
|
||||
const org = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getById(organizationId)),
|
||||
@@ -64,18 +66,19 @@ export class ExportScopeCalloutComponent {
|
||||
description: orgExportDescription,
|
||||
scopeIdentifier: org?.name ?? "",
|
||||
};
|
||||
} else {
|
||||
this.scopeConfig = {
|
||||
// exporting from individual vault
|
||||
title: "exportingPersonalVaultTitle",
|
||||
description:
|
||||
exportFormat === "zip"
|
||||
? "exportingIndividualVaultWithAttachmentsDescription"
|
||||
: "exportingIndividualVaultDescription",
|
||||
scopeIdentifier:
|
||||
(await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email)))) ??
|
||||
"",
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// exporting from individual vault
|
||||
this.scopeConfig = {
|
||||
title: "exportingPersonalVaultTitle",
|
||||
description:
|
||||
exportFormat === "zip"
|
||||
? "exportingIndividualVaultWithAttachmentsDescription"
|
||||
: "exportingIndividualVaultDescription",
|
||||
scopeIdentifier:
|
||||
(await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email)))) ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum EncryptedExportType {
|
||||
AccountEncrypted = 0,
|
||||
FileEncrypted = 1,
|
||||
}
|
||||
/** A type of encrypted export. */
|
||||
export const EncryptedExportType = Object.freeze({
|
||||
/** Export is encrypted using the Bitwarden account key. */
|
||||
AccountEncrypted: 0,
|
||||
/** Export is encrypted using a separate file password/key. */
|
||||
FileEncrypted: 1,
|
||||
} as const);
|
||||
|
||||
/** A type of encrypted export. */
|
||||
export type EncryptedExportType = (typeof EncryptedExportType)[keyof typeof EncryptedExportType];
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -15,7 +17,11 @@ export class LegacyPasswordHistoryDecryptor {
|
||||
|
||||
/** Decrypts a password history. */
|
||||
async decrypt(history: GeneratedPasswordHistory[]): Promise<GeneratedPasswordHistory[]> {
|
||||
const key = await this.keyService.getUserKey(this.userId);
|
||||
const key = await firstValueFrom(this.keyService.userKey$(this.userId));
|
||||
|
||||
if (key == undefined) {
|
||||
throw new Error("No user key found for decryption");
|
||||
}
|
||||
|
||||
const promises = (history ?? []).map(async (item) => {
|
||||
const encrypted = new EncString(item.password);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
@@ -12,11 +15,13 @@ import { SendFormService } from "../abstractions/send-form.service";
|
||||
|
||||
@Injectable()
|
||||
export class DefaultSendFormService implements SendFormService {
|
||||
private accountService = inject(AccountService);
|
||||
private sendApiService: SendApiService = inject(SendApiService);
|
||||
private sendService = inject(SendService);
|
||||
|
||||
async decryptSend(send: Send): Promise<SendView> {
|
||||
return await send.decrypt();
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
return await send.decrypt(userId);
|
||||
}
|
||||
|
||||
async saveSend(send: SendView, file: File | ArrayBuffer, config: SendFormConfig) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
moduleMetadata,
|
||||
StoryObj,
|
||||
} from "@storybook/angular";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -155,6 +155,20 @@ export default {
|
||||
} as NudgeStatus),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CipherArchiveService,
|
||||
useValue: {
|
||||
userCanArchive$: of(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: {
|
||||
activeAccount$: of({
|
||||
name: "User 1",
|
||||
}),
|
||||
} as Partial<AccountService>,
|
||||
},
|
||||
{
|
||||
provide: CipherFormService,
|
||||
useClass: TestAddEditFormService,
|
||||
|
||||
@@ -2,14 +2,18 @@ import { ChangeDetectorRef } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
||||
import { CipherFormService } from "../abstractions/cipher-form.service";
|
||||
@@ -23,6 +27,10 @@ describe("CipherFormComponent", () => {
|
||||
|
||||
const decryptCipher = jest.fn().mockResolvedValue(new CipherView());
|
||||
|
||||
const mockAccountService = mock<AccountService>();
|
||||
const mockCipherArchiveService = mock<CipherArchiveService>();
|
||||
const mockAddEditFormService = { saveCipher: jest.fn(), decryptCipher };
|
||||
|
||||
beforeEach(async () => {
|
||||
decryptCipher.mockClear();
|
||||
|
||||
@@ -32,13 +40,15 @@ describe("CipherFormComponent", () => {
|
||||
{ provide: ChangeDetectorRef, useValue: {} },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: ToastService, useValue: { showToast: jest.fn() } },
|
||||
{ provide: CipherFormService, useValue: { saveCipher: jest.fn(), decryptCipher } },
|
||||
{ provide: CipherFormService, useValue: mockAddEditFormService },
|
||||
{
|
||||
provide: CipherFormCacheService,
|
||||
useValue: { init: jest.fn(), getCachedCipherView: jest.fn() },
|
||||
},
|
||||
{ provide: ViewCacheService, useValue: { signal: jest.fn(() => (): any => null) } },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
@@ -53,6 +63,29 @@ describe("CipherFormComponent", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("submit", () => {
|
||||
beforeEach(() => {
|
||||
component.config = { mode: "edit" } as CipherFormConfig;
|
||||
|
||||
component["updatedCipherView"] = new CipherView();
|
||||
component["updatedCipherView"].archivedDate = new Date();
|
||||
});
|
||||
|
||||
it("should remove archivedDate when user cannot archive and cipher is archived", async () => {
|
||||
mockAccountService.activeAccount$ = of({ id: "user-id" as UserId } as Account);
|
||||
mockCipherArchiveService.userCanArchive$.mockReturnValue(of(false));
|
||||
mockAddEditFormService.saveCipher = jest.fn().mockResolvedValue(new CipherView());
|
||||
|
||||
const originalArchivedDate = component["updatedCipherView"]?.archivedDate;
|
||||
expect(originalArchivedDate).not.toBeNull();
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
|
||||
expect(mockCipherArchiveService.userCanArchive$).toHaveBeenCalledWith("user-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("website", () => {
|
||||
it("should return null if updatedCipherView is null", () => {
|
||||
component["updatedCipherView"] = null as any;
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
@@ -301,6 +304,8 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
private i18nService: I18nService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private cipherFormCacheService: CipherFormCacheService,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -342,6 +347,18 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
}
|
||||
}
|
||||
|
||||
const userCanArchive = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
|
||||
),
|
||||
);
|
||||
|
||||
// If the item is archived but user has lost archive permissions, unarchive the item.
|
||||
if (!userCanArchive && this.updatedCipherView.archivedDate) {
|
||||
this.updatedCipherView.archivedDate = null;
|
||||
}
|
||||
|
||||
const savedCipher = await this.addEditFormService.saveCipher(
|
||||
this.updatedCipherView,
|
||||
this.config,
|
||||
|
||||
Reference in New Issue
Block a user