1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 08:33:29 +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:
Jeffrey Holland
2025-10-17 14:07:28 +02:00
208 changed files with 7966 additions and 1192 deletions

View File

@@ -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>>;

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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,
});
});
});
});

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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}`);
}
}

View File

@@ -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",
);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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",
);
}
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
import { PublicKeysResponseModel } from "../../response/public-keys.response";
export abstract class KeyApiService {
abstract getUserPublicKeys(id: string): Promise<PublicKeysResponseModel>;
}

View File

@@ -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);
}
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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();
});
});
});

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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$;
}
}

View File

@@ -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>;
}

View File

@@ -0,0 +1,8 @@
import { SignedSecurityState } from "../../types";
export class SecurityStateRequest {
constructor(
readonly securityState: SignedSecurityState,
readonly securityVersion: number,
) {}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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"],
},
);

View 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">;

View File

@@ -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");

View File

@@ -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"],
},
);

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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(

View File

@@ -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);

View File

@@ -86,6 +86,7 @@ describe("SendService", () => {
decryptedState.nextState([testSendViewData("1", "Test Send")]);
sendService = new SendService(
accountService,
keyService,
i18nService,
keyGenerationService,

View File

@@ -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"));

View File

@@ -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">;

View File

@@ -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>;

View File

@@ -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.

View File

@@ -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]";

View File

@@ -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.
*/