mirror of
https://github.com/bitwarden/browser
synced 2026-03-02 03:21:19 +00:00
Merge remote-tracking branch 'origin' into auth/pm-18720/change-password-component-non-dialog-v3
This commit is contained in:
@@ -228,16 +228,6 @@ export abstract class ApiService {
|
||||
request: CipherBulkRestoreRequest,
|
||||
) => Promise<ListResponse<CipherResponse>>;
|
||||
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
postCipherAttachmentLegacy: (id: string, data: FormData) => Promise<CipherResponse>;
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
postCipherAttachmentAdminLegacy: (id: string, data: FormData) => Promise<CipherResponse>;
|
||||
postCipherAttachment: (
|
||||
id: string,
|
||||
request: AttachmentRequest,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BreachAccountResponse } from "../models/response/breach-account.response";
|
||||
|
||||
export abstract class AuditService {
|
||||
passwordLeaked: (password: string) => Promise<number>;
|
||||
breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
|
||||
/**
|
||||
* Checks how many times a password has been leaked.
|
||||
* @param password The password to check.
|
||||
* @returns A promise that resolves to the number of times the password has been leaked.
|
||||
*/
|
||||
abstract passwordLeaked: (password: string) => Promise<number>;
|
||||
|
||||
/**
|
||||
* Retrieves accounts that have been breached for a given username.
|
||||
* @param username The username to check for breaches.
|
||||
* @returns A promise that resolves to an array of BreachAccountResponse objects.
|
||||
*/
|
||||
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
|
||||
}
|
||||
|
||||
@@ -16,5 +16,5 @@ export enum PolicyType {
|
||||
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
|
||||
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
|
||||
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
|
||||
RestrictedItemTypesPolicy = 15, // Restricts item types that can be created within an organization
|
||||
RestrictedItemTypes = 15, // Restricts item types that can be created within an organization
|
||||
}
|
||||
|
||||
@@ -215,15 +215,19 @@ export class DefaultPolicyService implements PolicyService {
|
||||
case PolicyType.MaximumVaultTimeout:
|
||||
// Max Vault Timeout applies to everyone except owners
|
||||
return organization.isOwner;
|
||||
// the following policies apply to everyone
|
||||
case PolicyType.PasswordGenerator:
|
||||
// password generation policy applies to everyone
|
||||
// password generation policy
|
||||
return false;
|
||||
case PolicyType.FreeFamiliesSponsorshipPolicy:
|
||||
// free Bitwarden families policy
|
||||
return false;
|
||||
case PolicyType.RestrictedItemTypes:
|
||||
// restricted item types policy
|
||||
return false;
|
||||
case PolicyType.PersonalOwnership:
|
||||
// individual vault policy applies to everyone except admins and owners
|
||||
return organization.isAdmin;
|
||||
case PolicyType.FreeFamiliesSponsorshipPolicy:
|
||||
// free Bitwarden families policy applies to everyone
|
||||
return false;
|
||||
default:
|
||||
return organization.canManagePolicies;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,58 @@
|
||||
/*
|
||||
* This enum is used to determine if a user should be forced to initially set or reset their password
|
||||
* on login (server flag) or unlock via MP (client evaluation).
|
||||
/**
|
||||
* This enum is used to determine if a user should be forced to set an initial password or
|
||||
* change their existing password upon login (communicated via server flag) or upon unlocking
|
||||
* with their master password (set via client evaluation).
|
||||
*/
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum ForceSetPasswordReason {
|
||||
/**
|
||||
* A password reset should not be forced.
|
||||
* A password set/change should not be forced.
|
||||
*/
|
||||
None,
|
||||
|
||||
/**
|
||||
* Occurs when an organization admin forces a user to reset their password.
|
||||
* Communicated via server flag.
|
||||
*/
|
||||
AdminForcePasswordReset,
|
||||
/*--------------------------
|
||||
Set Initial Password
|
||||
---------------------------*/
|
||||
|
||||
/**
|
||||
* Occurs when a user logs in / unlocks their vault with a master password that does not meet an organization's
|
||||
* master password policy that is enforced on login/unlock.
|
||||
* Only set client side b/c server can't evaluate MP.
|
||||
* Occurs when a user JIT provisions into a master-password-encryption org via SSO and must set their initial password.
|
||||
*/
|
||||
WeakMasterPassword,
|
||||
SsoNewJitProvisionedUser,
|
||||
|
||||
/**
|
||||
* Occurs when a TDE user without a password obtains the password reset permission.
|
||||
* Occurs when a TDE org user without a password obtains the password reset ("manage account recovery")
|
||||
* permission, which requires the TDE user to have/set a password.
|
||||
*
|
||||
* Set post login & decryption client side and by server in sync (to catch logged in users).
|
||||
*/
|
||||
TdeUserWithoutPasswordHasPasswordResetPermission,
|
||||
|
||||
/**
|
||||
* Occurs when TDE is disabled and master password has to be set.
|
||||
* Occurs when an org admin switches the org from trusted-device-encryption to master-password-encryption,
|
||||
* which forces the org user to set an initial password. User must not already have a master password,
|
||||
* and they must be on a previously trusted device.
|
||||
*
|
||||
* Communicated via server flag.
|
||||
*/
|
||||
TdeOffboarding,
|
||||
|
||||
/*----------------------------
|
||||
Change Existing Password
|
||||
-----------------------------*/
|
||||
|
||||
/**
|
||||
* Occurs when a new SSO user is JIT provisioned and needs to set their master password.
|
||||
* Occurs when an org admin forces a user to change their password via Account Recovery.
|
||||
*
|
||||
* Communicated via server flag.
|
||||
*/
|
||||
SsoNewJitProvisionedUser,
|
||||
AdminForcePasswordReset,
|
||||
|
||||
/**
|
||||
* Occurs when a user logs in / unlocks their vault with a master password that does not meet an org's
|
||||
* master password policy that is enforced on login/unlock.
|
||||
*
|
||||
* Only set client side b/c server can't evaluate MP.
|
||||
*/
|
||||
WeakMasterPassword,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
|
||||
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
|
||||
import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response";
|
||||
@@ -17,7 +18,7 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
|
||||
resetMasterPassword: boolean;
|
||||
privateKey: string;
|
||||
key: string;
|
||||
key?: EncString;
|
||||
twoFactorToken: string;
|
||||
kdf: KdfType;
|
||||
kdfIterations: number;
|
||||
@@ -39,7 +40,10 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
|
||||
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
|
||||
this.privateKey = this.getResponseProperty("PrivateKey");
|
||||
this.key = this.getResponseProperty("Key");
|
||||
const key = this.getResponseProperty("Key");
|
||||
if (key) {
|
||||
this.key = new EncString(key);
|
||||
}
|
||||
this.twoFactorToken = this.getResponseProperty("TwoFactorToken");
|
||||
this.kdf = this.getResponseProperty("Kdf");
|
||||
this.kdfIterations = this.getResponseProperty("KdfIterations");
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum FeatureFlag {
|
||||
OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript",
|
||||
|
||||
/* Auth */
|
||||
PM16117_SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor",
|
||||
PM16117_ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor",
|
||||
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
|
||||
|
||||
@@ -101,6 +102,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM16117_SetInitialPasswordRefactor]: FALSE,
|
||||
[FeatureFlag.PM16117_ChangeExistingPasswordRefactor]: FALSE,
|
||||
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getFeatureFlagValue,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
export class EncryptServiceImplementation implements EncryptService {
|
||||
@@ -242,6 +243,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
if (encString == null || encString.encryptedString == null) {
|
||||
throw new Error("encString is null or undefined");
|
||||
}
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded());
|
||||
}
|
||||
this.logService.debug("decrypting with javascript");
|
||||
@@ -324,6 +326,7 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
encThing.dataBytes,
|
||||
encThing.macBytes,
|
||||
).buffer;
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded());
|
||||
}
|
||||
this.logService.debug("[EncryptService] Decrypting bytes with javascript");
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
SymmetricCryptoKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
@@ -343,6 +345,24 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("calls PureCrypto when useSDKForDecryption is true", async () => {
|
||||
(encryptService as any).useSDKForDecryption = true;
|
||||
const decryptedBytes = makeStaticByteArray(10, 200);
|
||||
Object.defineProperty(SdkLoadService, "Ready", {
|
||||
value: Promise.resolve(),
|
||||
configurable: true,
|
||||
});
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt_array_buffer").mockReturnValue(decryptedBytes);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(PureCrypto.symmetric_decrypt_array_buffer).toHaveBeenCalledWith(
|
||||
encBuffer.buffer,
|
||||
key.toEncoded(),
|
||||
);
|
||||
expect(actual).toEqualBuffer(decryptedBytes);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for Aes256CbcHmac", async () => {
|
||||
const decryptedBytes = makeStaticByteArray(10, 200);
|
||||
|
||||
@@ -450,6 +470,25 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("calls PureCrypto when useSDKForDecryption is true", async () => {
|
||||
(encryptService as any).useSDKForDecryption = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
Object.defineProperty(SdkLoadService, "Ready", {
|
||||
value: Promise.resolve(),
|
||||
configurable: true,
|
||||
});
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt").mockReturnValue("data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
|
||||
expect(actual).toEqual("data");
|
||||
expect(PureCrypto.symmetric_decrypt).toHaveBeenCalledWith(
|
||||
encString.encryptedString,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for AesCbc256_HmacSha256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
|
||||
@@ -89,9 +89,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
) {
|
||||
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
|
||||
map((options) => {
|
||||
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
return options?.trustedDeviceOption != null ?? false;
|
||||
return options?.trustedDeviceOption != null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -99,9 +97,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
supportsDeviceTrustByUserId$(userId: UserId): Observable<boolean> {
|
||||
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe(
|
||||
map((options) => {
|
||||
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
return options?.trustedDeviceOption != null ?? false;
|
||||
return options?.trustedDeviceOption != null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,21 +5,23 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
|
||||
// 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 { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
|
||||
import { Organization } from "../../../admin-console/models/domain/organization";
|
||||
import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response";
|
||||
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
|
||||
import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response";
|
||||
import { TokenService } from "../../../auth/services/token.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { KeyGenerationService } from "../../../platform/services/key-generation.service";
|
||||
import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { MasterKey } from "../../../types/key";
|
||||
import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
|
||||
|
||||
@@ -50,7 +52,7 @@ describe("KeyConnectorService", () => {
|
||||
const keyConnectorUrl = "https://key-connector-url.com";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
@@ -403,6 +405,106 @@ describe("KeyConnectorService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertNewSsoUserToKeyConnector", () => {
|
||||
const tokenResponse = mock<IdentityTokenResponse>();
|
||||
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockMasterKey = getMockMasterKey();
|
||||
let mockMakeUserKeyResult: [UserKey, EncString];
|
||||
|
||||
beforeEach(() => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
|
||||
string,
|
||||
EncString,
|
||||
];
|
||||
const encString = new EncString("mockEncryptedString");
|
||||
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
|
||||
|
||||
tokenResponse.kdf = KdfType.PBKDF2_SHA256;
|
||||
tokenResponse.kdfIterations = 100000;
|
||||
tokenResponse.kdfMemory = 16;
|
||||
tokenResponse.kdfParallelism = 4;
|
||||
tokenResponse.keyConnectorUrl = keyConnectorUrl;
|
||||
|
||||
keyGenerationService.createKey.mockResolvedValue(passwordKey);
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
|
||||
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
|
||||
tokenService.getEmail.mockResolvedValue(mockEmail);
|
||||
});
|
||||
|
||||
it("sets up a new SSO user with key connector", async () => {
|
||||
await keyConnectorService.convertNewSsoUserToKeyConnector(
|
||||
tokenResponse,
|
||||
mockOrgId,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
passwordKey.keyB64,
|
||||
mockEmail,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
mockMakeUserKeyResult[1],
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
tokenResponse.keyConnectorUrl,
|
||||
expect.any(KeyConnectorUserKeyRequest),
|
||||
);
|
||||
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles api error", async () => {
|
||||
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
|
||||
|
||||
try {
|
||||
await keyConnectorService.convertNewSsoUserToKeyConnector(
|
||||
tokenResponse,
|
||||
mockOrgId,
|
||||
mockUserId,
|
||||
);
|
||||
} catch (error: any) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe("Key Connector error");
|
||||
}
|
||||
|
||||
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
passwordKey.keyB64,
|
||||
mockEmail,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
|
||||
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
|
||||
mockMakeUserKeyResult[1],
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
tokenResponse.keyConnectorUrl,
|
||||
expect.any(KeyConnectorUserKeyRequest),
|
||||
);
|
||||
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function organizationData(
|
||||
usesKeyConnector: boolean,
|
||||
keyConnectorEnabled: boolean,
|
||||
|
||||
@@ -160,7 +160,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
|
||||
const userKey = await this.keyService.makeUserKey(masterKey);
|
||||
await this.keyService.setUserKey(userKey[0], userId);
|
||||
await this.keyService.setMasterKeyEncryptedUserKey(userKey[1].encryptedString, userId);
|
||||
await this.masterPasswordService.setMasterKeyEncryptedUserKey(userKey[1], userId);
|
||||
|
||||
const [pubKey, privKey] = await this.keyService.makeKeyPair(userKey[0]);
|
||||
|
||||
|
||||
@@ -153,4 +153,41 @@ describe("MasterPasswordService", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMasterKeyEncryptedUserKey", () => {
|
||||
test.each([null as unknown as EncString, undefined as unknown as EncString])(
|
||||
"throws when the provided encryptedKey is %s",
|
||||
async (encryptedKey) => {
|
||||
await expect(sut.setMasterKeyEncryptedUserKey(encryptedKey, userId)).rejects.toThrow(
|
||||
"Encrypted Key is required.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an error if encryptedKey is malformed null", async () => {
|
||||
await expect(
|
||||
sut.setMasterKeyEncryptedUserKey(new EncString(null as unknown as string), userId),
|
||||
).rejects.toThrow("Encrypted Key is required.");
|
||||
});
|
||||
|
||||
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||
"throws when the provided userId is %s",
|
||||
async (userId) => {
|
||||
await expect(
|
||||
sut.setMasterKeyEncryptedUserKey(new EncString(testMasterKeyEncryptedKey), userId),
|
||||
).rejects.toThrow("User ID is required.");
|
||||
},
|
||||
);
|
||||
|
||||
it("calls stateProvider with the provided encryptedKey and user ID", async () => {
|
||||
const encryptedKey = new EncString(testMasterKeyEncryptedKey);
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
}
|
||||
|
||||
async setMasterKeyEncryptedUserKey(encryptedKey: EncString, userId: UserId): Promise<void> {
|
||||
if (encryptedKey == null) {
|
||||
if (encryptedKey == null || encryptedKey.encryptedString == null) {
|
||||
throw new Error("Encrypted Key is required.");
|
||||
}
|
||||
if (userId == null) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { BaseResponse } from "./base.response";
|
||||
@@ -14,7 +15,7 @@ export class ProfileResponse extends BaseResponse {
|
||||
premiumFromOrganization: boolean;
|
||||
culture: string;
|
||||
twoFactorEnabled: boolean;
|
||||
key: string;
|
||||
key?: EncString;
|
||||
avatarColor: string;
|
||||
creationDate: string;
|
||||
privateKey: string;
|
||||
@@ -36,7 +37,10 @@ export class ProfileResponse extends BaseResponse {
|
||||
this.premiumFromOrganization = this.getResponseProperty("PremiumFromOrganization");
|
||||
this.culture = this.getResponseProperty("Culture");
|
||||
this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled");
|
||||
this.key = this.getResponseProperty("Key");
|
||||
const key = this.getResponseProperty("Key");
|
||||
if (key) {
|
||||
this.key = new EncString(key);
|
||||
}
|
||||
this.avatarColor = this.getResponseProperty("AvatarColor");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.privateKey = this.getResponseProperty("PrivateKey");
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { OutgoingMessage } from "@bitwarden/sdk-internal";
|
||||
|
||||
export interface IpcMessage {
|
||||
type: "bitwarden-ipc-message";
|
||||
message: Omit<OutgoingMessage, "free">;
|
||||
message: SerializedOutgoingMessage;
|
||||
}
|
||||
|
||||
export interface SerializedOutgoingMessage extends Omit<OutgoingMessage, "free" | "payload"> {
|
||||
payload: number[];
|
||||
}
|
||||
|
||||
export function isIpcMessage(message: any): message is IpcMessage {
|
||||
|
||||
@@ -23,6 +23,8 @@ export abstract class IpcService {
|
||||
|
||||
protected async initWithClient(client: IpcClient): Promise<void> {
|
||||
this._client = client;
|
||||
await this._client.start();
|
||||
|
||||
this._messages$ = new Observable<IncomingMessage>((subscriber) => {
|
||||
let isSubscribed = true;
|
||||
const receiveLoop = async () => {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { throttle } from "./throttle";
|
||||
|
||||
describe("throttle decorator", () => {
|
||||
it("should call the function once at a time", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function once at a time for each object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
promises.push(foo2.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
expect(foo2.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function limit at a time", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function limit at a time for each object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
promises.push(foo2.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
expect(foo2.calls).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
class Foo {
|
||||
calls = 0;
|
||||
inflight = 0;
|
||||
|
||||
@throttle(1, () => "bar")
|
||||
bar(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBe(1);
|
||||
this.inflight--;
|
||||
res(a * 2);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
|
||||
@throttle(5, () => "baz")
|
||||
baz(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBeLessThanOrEqual(5);
|
||||
this.inflight--;
|
||||
res(a * 3);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
|
||||
@throttle(1, () => "qux")
|
||||
qux(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBe(1);
|
||||
this.inflight--;
|
||||
res(a * 3);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
/**
|
||||
* Use as a Decorator on async functions, it will limit how many times the function can be
|
||||
* in-flight at a time.
|
||||
*
|
||||
* Calls beyond the limit will be queued, and run when one of the active calls finishes
|
||||
*/
|
||||
export function throttle(limit: number, throttleKey: (args: any[]) => string) {
|
||||
return <T>(
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: TypedPropertyDescriptor<(...args: any[]) => Promise<T>>,
|
||||
) => {
|
||||
const originalMethod: () => Promise<T> = descriptor.value;
|
||||
const allThrottles = new Map<any, Map<string, (() => void)[]>>();
|
||||
|
||||
const getThrottles = (obj: any) => {
|
||||
let throttles = allThrottles.get(obj);
|
||||
if (throttles != null) {
|
||||
return throttles;
|
||||
}
|
||||
throttles = new Map<string, (() => void)[]>();
|
||||
allThrottles.set(obj, throttles);
|
||||
return throttles;
|
||||
};
|
||||
|
||||
return {
|
||||
value: function (...args: any[]) {
|
||||
const throttles = getThrottles(this);
|
||||
const argsThrottleKey = throttleKey(args);
|
||||
let queue = throttles.get(argsThrottleKey);
|
||||
if (queue == null) {
|
||||
queue = [];
|
||||
throttles.set(argsThrottleKey, queue);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const exec = () => {
|
||||
const onFinally = () => {
|
||||
queue.splice(queue.indexOf(exec), 1);
|
||||
if (queue.length >= limit) {
|
||||
queue[limit - 1]();
|
||||
} else if (queue.length === 0) {
|
||||
throttles.delete(argsThrottleKey);
|
||||
if (throttles.size === 0) {
|
||||
allThrottles.delete(this);
|
||||
}
|
||||
}
|
||||
};
|
||||
originalMethod
|
||||
.apply(this, args)
|
||||
.then((val: any) => {
|
||||
onFinally();
|
||||
return val;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
onFinally();
|
||||
throw err;
|
||||
})
|
||||
.then(resolve, reject);
|
||||
};
|
||||
queue.push(exec);
|
||||
if (queue.length <= limit) {
|
||||
exec();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { mockAccountServiceWith } from "../../../../spec";
|
||||
import { Account } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { CipherId, UserId } from "../../../types/guid";
|
||||
import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../../vault/enums/cipher-type";
|
||||
import { CipherData } from "../../../vault/models/data/cipher.data";
|
||||
import { Cipher } from "../../../vault/models/domain/cipher";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
|
||||
@@ -218,9 +219,11 @@ describe("FidoAuthenticatorService", () => {
|
||||
beforeEach(async () => {
|
||||
existingCipher = createCipherView({ type: CipherType.Login });
|
||||
params = await createParams({ requireResidentKey: false });
|
||||
cipherService.get.mockImplementation(async (id) =>
|
||||
id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined,
|
||||
|
||||
cipherService.ciphers$.mockImplementation((userId: UserId) =>
|
||||
of({ [existingCipher.id as CipherId]: {} as CipherData }),
|
||||
);
|
||||
|
||||
cipherService.getAllDecrypted.mockResolvedValue([existingCipher]);
|
||||
cipherService.decrypt.mockResolvedValue(existingCipher);
|
||||
});
|
||||
@@ -351,9 +354,10 @@ describe("FidoAuthenticatorService", () => {
|
||||
cipherId,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.get.mockImplementation(async (cipherId) =>
|
||||
cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined,
|
||||
cipherService.ciphers$.mockImplementation((userId: UserId) =>
|
||||
of({ [cipher.id as CipherId]: {} as CipherData }),
|
||||
);
|
||||
|
||||
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
|
||||
cipherService.decrypt.mockResolvedValue(cipher);
|
||||
cipherService.encrypt.mockImplementation(async (cipher) => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { getUserId } from "../../../auth/services/account.service";
|
||||
import { CipherId } from "../../../types/guid";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../../vault/enums/cipher-type";
|
||||
import { Cipher } from "../../../vault/models/domain/cipher";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view";
|
||||
import {
|
||||
@@ -149,7 +151,23 @@ export class Fido2AuthenticatorService<ParentWindowReference>
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
);
|
||||
const encrypted = await this.cipherService.get(cipherId, activeUserId);
|
||||
|
||||
const encrypted = await firstValueFrom(
|
||||
this.cipherService.ciphers$(activeUserId).pipe(
|
||||
map((ciphers) => ciphers[cipherId as CipherId]),
|
||||
filter((c) => c !== undefined),
|
||||
timeout({
|
||||
first: 5000,
|
||||
with: () => {
|
||||
this.logService?.error(
|
||||
`[Fido2Authenticator] Aborting because cipher with ID ${cipherId} could not be found within timeout.`,
|
||||
);
|
||||
throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
|
||||
},
|
||||
}),
|
||||
map((c) => new Cipher(c, null)),
|
||||
),
|
||||
);
|
||||
|
||||
cipher = await this.cipherService.decrypt(encrypted, activeUserId);
|
||||
|
||||
|
||||
@@ -92,6 +92,27 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
|
||||
describe("createCredential", () => {
|
||||
describe("Mapping params should handle variations in input formats", () => {
|
||||
it.each([
|
||||
[true, true],
|
||||
[false, false],
|
||||
["false", false],
|
||||
["", false],
|
||||
["true", true],
|
||||
])("requireResidentKey should handle %s as boolean %s", async (input, expected) => {
|
||||
const params = createParams({
|
||||
authenticatorSelection: { requireResidentKey: input as any },
|
||||
extensions: { credProps: true },
|
||||
});
|
||||
|
||||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||||
|
||||
const result = await client.createCredential(params, windowReference);
|
||||
|
||||
expect(result.extensions.credProps?.rk).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("input parameters validation", () => {
|
||||
// Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException.
|
||||
it("should throw error if sameOriginWithAncestors is false", async () => {
|
||||
|
||||
@@ -483,11 +483,15 @@ function mapToMakeCredentialParams({
|
||||
type: credential.type,
|
||||
})) ?? [];
|
||||
|
||||
/**
|
||||
* Quirk: Accounts for the fact that some RP's mistakenly submits 'requireResidentKey' as a string
|
||||
*/
|
||||
const requireResidentKey =
|
||||
params.authenticatorSelection?.residentKey === "required" ||
|
||||
params.authenticatorSelection?.residentKey === "preferred" ||
|
||||
(params.authenticatorSelection?.residentKey === undefined &&
|
||||
params.authenticatorSelection?.requireResidentKey === true);
|
||||
(params.authenticatorSelection?.requireResidentKey === true ||
|
||||
(params.authenticatorSelection?.requireResidentKey as unknown as string) === "true"));
|
||||
|
||||
const requireUserVerification =
|
||||
params.authenticatorSelection?.userVerification === "required" ||
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
import type {
|
||||
AssertCredentialResult,
|
||||
CreateCredentialResult,
|
||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
// @ts-strict-ignore
|
||||
export class Fido2Utils {
|
||||
static createResultToJson(result: CreateCredentialResult): any {
|
||||
return {
|
||||
id: result.credentialId,
|
||||
rawId: result.credentialId,
|
||||
response: {
|
||||
clientDataJSON: result.clientDataJSON,
|
||||
authenticatorData: result.authData,
|
||||
transports: result.transports,
|
||||
publicKey: result.publicKey,
|
||||
publicKeyAlgorithm: result.publicKeyAlgorithm,
|
||||
attestationObject: result.attestationObject,
|
||||
},
|
||||
authenticatorAttachment: "platform",
|
||||
clientExtensionResults: result.extensions,
|
||||
type: "public-key",
|
||||
};
|
||||
}
|
||||
|
||||
static getResultToJson(result: AssertCredentialResult): any {
|
||||
return {
|
||||
id: result.credentialId,
|
||||
rawId: result.credentialId,
|
||||
response: {
|
||||
clientDataJSON: result.clientDataJSON,
|
||||
authenticatorData: result.authenticatorData,
|
||||
signature: result.signature,
|
||||
userHandle: result.userHandle,
|
||||
},
|
||||
authenticatorAttachment: "platform",
|
||||
clientExtensionResults: {},
|
||||
type: "public-key",
|
||||
};
|
||||
}
|
||||
|
||||
static bufferToString(bufferSource: BufferSource): string {
|
||||
return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource))
|
||||
.replace(/\+/g, "-")
|
||||
|
||||
@@ -212,6 +212,7 @@ export class DefaultSdkService implements SdkService {
|
||||
},
|
||||
},
|
||||
privateKey,
|
||||
signingKey: undefined,
|
||||
});
|
||||
|
||||
// We initialize the org crypto even if the org_keys are
|
||||
|
||||
@@ -225,7 +225,10 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
throw new Error("Stamp has changed");
|
||||
}
|
||||
|
||||
await this.keyService.setMasterKeyEncryptedUserKey(response.key, response.id);
|
||||
// Users with no master password will not have a key.
|
||||
if (response?.key) {
|
||||
await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, response.id);
|
||||
}
|
||||
await this.keyService.setPrivateKey(response.privateKey, response.id);
|
||||
await this.keyService.setProviderKeys(response.providers, response.id);
|
||||
await this.keyService.setOrgKeys(
|
||||
|
||||
@@ -639,24 +639,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new AttachmentUploadDataResponse(r);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
async postCipherAttachmentLegacy(id: string, data: FormData): Promise<CipherResponse> {
|
||||
const r = await this.send("POST", "/ciphers/" + id + "/attachment", data, true, true);
|
||||
return new CipherResponse(r);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
async postCipherAttachmentAdminLegacy(id: string, data: FormData): Promise<CipherResponse> {
|
||||
const r = await this.send("POST", "/ciphers/" + id + "/attachment-admin", data, true, true);
|
||||
return new CipherResponse(r);
|
||||
}
|
||||
|
||||
deleteCipherAttachment(id: string, attachmentId: string): Promise<any> {
|
||||
return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, true);
|
||||
}
|
||||
|
||||
81
libs/common/src/services/audit.service.spec.ts
Normal file
81
libs/common/src/services/audit.service.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
|
||||
import { AuditService } from "./audit.service";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Polyfill global Request for Jest environment if not present
|
||||
if (typeof global.Request === "undefined") {
|
||||
global.Request = jest.fn((input: string | URL, init?: RequestInit) => {
|
||||
return { url: typeof input === "string" ? input : input.toString(), ...init };
|
||||
}) as any;
|
||||
}
|
||||
|
||||
describe("AuditService", () => {
|
||||
let auditService: AuditService;
|
||||
let mockCrypto: jest.Mocked<CryptoFunctionService>;
|
||||
let mockApi: jest.Mocked<ApiService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCrypto = {
|
||||
hash: jest.fn().mockResolvedValue(Buffer.from("AABBCCDDEEFF", "hex")),
|
||||
} as unknown as jest.Mocked<CryptoFunctionService>;
|
||||
|
||||
mockApi = {
|
||||
nativeFetch: jest.fn().mockResolvedValue({
|
||||
text: jest.fn().mockResolvedValue(`CDDEEFF:4\nDDEEFF:2\n123456:1`),
|
||||
}),
|
||||
getHibpBreach: jest.fn(),
|
||||
} as unknown as jest.Mocked<ApiService>;
|
||||
|
||||
auditService = new AuditService(mockCrypto, mockApi, 2);
|
||||
});
|
||||
|
||||
it("should not exceed max concurrent passwordLeaked requests", async () => {
|
||||
const inFlight: string[] = [];
|
||||
const maxInFlight: number[] = [];
|
||||
|
||||
// Patch fetchLeakedPasswordCount to track concurrency
|
||||
const origFetch = (auditService as any).fetchLeakedPasswordCount.bind(auditService);
|
||||
jest
|
||||
.spyOn(auditService as any, "fetchLeakedPasswordCount")
|
||||
.mockImplementation(async (password: string) => {
|
||||
inFlight.push(password);
|
||||
maxInFlight.push(inFlight.length);
|
||||
// Simulate async work to allow concurrency limiter to take effect
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
inFlight.splice(inFlight.indexOf(password), 1);
|
||||
return origFetch(password);
|
||||
});
|
||||
|
||||
const p1 = auditService.passwordLeaked("password1");
|
||||
const p2 = auditService.passwordLeaked("password2");
|
||||
const p3 = auditService.passwordLeaked("password3");
|
||||
const p4 = auditService.passwordLeaked("password4");
|
||||
|
||||
jest.advanceTimersByTime(250);
|
||||
|
||||
// Flush all pending timers and microtasks
|
||||
await jest.runAllTimersAsync();
|
||||
await Promise.all([p1, p2, p3, p4]);
|
||||
|
||||
// The max value in maxInFlight should not exceed 2 (the concurrency limit)
|
||||
expect(Math.max(...maxInFlight)).toBeLessThanOrEqual(2);
|
||||
expect((auditService as any).fetchLeakedPasswordCount).toHaveBeenCalledTimes(4);
|
||||
expect(mockCrypto.hash).toHaveBeenCalledTimes(4);
|
||||
expect(mockApi.nativeFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("should return empty array for breachedAccounts on 404", async () => {
|
||||
mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse);
|
||||
const result = await auditService.breachedAccounts("user@example.com");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should throw error for breachedAccounts on non-404 error", async () => {
|
||||
mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse);
|
||||
await expect(auditService.breachedAccounts("user@example.com")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,58 @@
|
||||
import { Subject } from "rxjs";
|
||||
import { mergeMap } from "rxjs/operators";
|
||||
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.service";
|
||||
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { BreachAccountResponse } from "../models/response/breach-account.response";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
import { throttle } from "../platform/misc/throttle";
|
||||
import { Utils } from "../platform/misc/utils";
|
||||
|
||||
const PwnedPasswordsApi = "https://api.pwnedpasswords.com/range/";
|
||||
|
||||
export class AuditService implements AuditServiceAbstraction {
|
||||
private passwordLeakedSubject = new Subject<{
|
||||
password: string;
|
||||
resolve: (count: number) => void;
|
||||
reject: (err: any) => void;
|
||||
}>();
|
||||
|
||||
constructor(
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private apiService: ApiService,
|
||||
) {}
|
||||
private readonly maxConcurrent: number = 100, // default to 100, can be overridden
|
||||
) {
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.passwordLeakedSubject
|
||||
.pipe(
|
||||
mergeMap(
|
||||
// Handle each password leak request, resolving or rejecting the associated promise.
|
||||
async (req) => {
|
||||
try {
|
||||
const count = await this.fetchLeakedPasswordCount(req.password);
|
||||
req.resolve(count);
|
||||
} catch (err) {
|
||||
req.reject(err);
|
||||
}
|
||||
},
|
||||
this.maxConcurrent, // Limit concurrent API calls
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
@throttle(100, () => "passwordLeaked")
|
||||
async passwordLeaked(password: string): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
this.passwordLeakedSubject.next({ password, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the count of leaked passwords from the Pwned Passwords API.
|
||||
* @param password The password to check.
|
||||
* @returns A promise that resolves to the number of times the password has been leaked.
|
||||
*/
|
||||
protected async fetchLeakedPasswordCount(password: string): Promise<number> {
|
||||
const hashBytes = await this.cryptoFunctionService.hash(password, "sha1");
|
||||
const hash = Utils.fromBufferToHex(hashBytes).toUpperCase();
|
||||
const hashStart = hash.substr(0, 5);
|
||||
|
||||
@@ -161,6 +161,14 @@ export class UserStateSubject<
|
||||
this.outputSubscription = userState$
|
||||
.pipe(
|
||||
switchMap((userState) => userState.state$),
|
||||
map((stored) => {
|
||||
if (stored && typeof stored === "object" && ALWAYS_UPDATE_KLUDGE in stored) {
|
||||
// related: ALWAYS_UPDATE_KLUDGE FIXME
|
||||
delete stored[ALWAYS_UPDATE_KLUDGE];
|
||||
}
|
||||
|
||||
return stored;
|
||||
}),
|
||||
this.declassify(encryptor$),
|
||||
this.adjust(combineLatestWith(constraints$)),
|
||||
takeUntil(anyComplete(account$)),
|
||||
|
||||
@@ -68,6 +68,68 @@ describe("Cipher DTO", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Decrypt should handle cipher key error", async () => {
|
||||
const cipher = new Cipher();
|
||||
cipher.id = "id";
|
||||
cipher.organizationId = "orgId";
|
||||
cipher.folderId = "folderId";
|
||||
cipher.edit = true;
|
||||
cipher.viewPassword = true;
|
||||
cipher.organizationUseTotp = true;
|
||||
cipher.favorite = false;
|
||||
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.name = mockEnc("EncryptedString");
|
||||
cipher.notes = mockEnc("EncryptedString");
|
||||
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
|
||||
cipher.deletedDate = null;
|
||||
cipher.reprompt = CipherRepromptType.None;
|
||||
cipher.key = mockEnc("EncKey");
|
||||
cipher.permissions = new CipherPermissionsApi();
|
||||
|
||||
const loginView = new LoginView();
|
||||
loginView.username = "username";
|
||||
loginView.password = "password";
|
||||
|
||||
const login = mock<Login>();
|
||||
login.decrypt.mockResolvedValue(loginView);
|
||||
cipher.login = login;
|
||||
|
||||
const keyService = mock<KeyService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const cipherService = mock<CipherService>();
|
||||
|
||||
encryptService.unwrapSymmetricKey.mockRejectedValue(new Error("Failed to unwrap key"));
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
|
||||
const cipherView = await cipher.decrypt(
|
||||
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
|
||||
);
|
||||
|
||||
expect(cipherView).toMatchObject({
|
||||
id: "id",
|
||||
organizationId: "orgId",
|
||||
folderId: "folderId",
|
||||
name: "[error: cannot decrypt]",
|
||||
type: 1,
|
||||
favorite: false,
|
||||
organizationUseTotp: true,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
decryptionFailure: true,
|
||||
collectionIds: undefined,
|
||||
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
creationDate: new Date("2022-01-01T12:00:00.000Z"),
|
||||
deletedDate: null,
|
||||
reprompt: 0,
|
||||
localData: undefined,
|
||||
permissions: new CipherPermissionsApi(),
|
||||
});
|
||||
|
||||
expect(login.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("LoginCipher", () => {
|
||||
let cipherData: CipherData;
|
||||
|
||||
@@ -887,7 +949,10 @@ describe("Cipher DTO", () => {
|
||||
reprompt: SdkCipherRepromptType.None,
|
||||
organizationUseTotp: true,
|
||||
edit: true,
|
||||
permissions: new CipherPermissionsApi(),
|
||||
permissions: {
|
||||
delete: false,
|
||||
restore: false,
|
||||
},
|
||||
viewPassword: true,
|
||||
localData: {
|
||||
lastUsedDate: "2025-04-15T12:00:00.000Z",
|
||||
|
||||
@@ -145,14 +145,15 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
if (this.key != null) {
|
||||
const encryptService = Utils.getContainerService().getEncryptService();
|
||||
|
||||
const cipherKey = await encryptService.unwrapSymmetricKey(this.key, encKey);
|
||||
if (cipherKey == null) {
|
||||
try {
|
||||
const cipherKey = await encryptService.unwrapSymmetricKey(this.key, encKey);
|
||||
encKey = cipherKey;
|
||||
bypassValidation = false;
|
||||
} catch {
|
||||
model.name = "[error: cannot decrypt]";
|
||||
model.decryptionFailure = true;
|
||||
return model;
|
||||
}
|
||||
encKey = cipherKey;
|
||||
bypassValidation = false;
|
||||
}
|
||||
|
||||
await this.decryptObj<Cipher, CipherView>(
|
||||
@@ -292,6 +293,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
const domain = new Cipher();
|
||||
const name = EncString.fromJSON(obj.name);
|
||||
const notes = EncString.fromJSON(obj.notes);
|
||||
const creationDate = obj.creationDate == null ? null : new Date(obj.creationDate);
|
||||
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
|
||||
const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
|
||||
const attachments = obj.attachments?.map((a: any) => Attachment.fromJSON(a));
|
||||
@@ -302,6 +304,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
Object.assign(domain, obj, {
|
||||
name,
|
||||
notes,
|
||||
creationDate,
|
||||
revisionDate,
|
||||
deletedDate,
|
||||
attachments,
|
||||
@@ -341,17 +344,22 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
toSdkCipher(): SdkCipher {
|
||||
const sdkCipher: SdkCipher = {
|
||||
id: this.id,
|
||||
organizationId: this.organizationId,
|
||||
folderId: this.folderId,
|
||||
collectionIds: this.collectionIds || [],
|
||||
organizationId: this.organizationId ?? undefined,
|
||||
folderId: this.folderId ?? undefined,
|
||||
collectionIds: this.collectionIds ?? [],
|
||||
key: this.key?.toJSON(),
|
||||
name: this.name.toJSON(),
|
||||
notes: this.notes?.toJSON(),
|
||||
type: this.type,
|
||||
favorite: this.favorite,
|
||||
organizationUseTotp: this.organizationUseTotp,
|
||||
favorite: this.favorite ?? false,
|
||||
organizationUseTotp: this.organizationUseTotp ?? false,
|
||||
edit: this.edit,
|
||||
permissions: this.permissions,
|
||||
permissions: this.permissions
|
||||
? {
|
||||
delete: this.permissions.delete,
|
||||
restore: this.permissions.restore,
|
||||
}
|
||||
: undefined,
|
||||
viewPassword: this.viewPassword,
|
||||
localData: this.localData
|
||||
? {
|
||||
|
||||
@@ -159,7 +159,7 @@ export class Login extends Domain {
|
||||
password: this.password?.toJSON(),
|
||||
passwordRevisionDate: this.passwordRevisionDate?.toISOString(),
|
||||
totp: this.totp?.toJSON(),
|
||||
autofillOnPageLoad: this.autofillOnPageLoad,
|
||||
autofillOnPageLoad: this.autofillOnPageLoad ?? undefined,
|
||||
fido2Credentials: this.fido2Credentials?.map((f) => f.toSdkFido2Credential()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,6 +157,15 @@ export class CardView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.assign(new CardView(), obj);
|
||||
const cardView = new CardView();
|
||||
|
||||
cardView.cardholderName = obj.cardholderName ?? null;
|
||||
cardView.brand = obj.brand ?? null;
|
||||
cardView.number = obj.number ?? null;
|
||||
cardView.expMonth = obj.expMonth ?? null;
|
||||
cardView.expYear = obj.expYear ?? null;
|
||||
cardView.code = obj.code ?? null;
|
||||
|
||||
return cardView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export class CipherView implements View, InitializerMetadata {
|
||||
}
|
||||
|
||||
const view = new CipherView();
|
||||
const creationDate = obj.creationDate == null ? null : new Date(obj.creationDate);
|
||||
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
|
||||
const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
|
||||
const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a));
|
||||
@@ -195,6 +196,7 @@ export class CipherView implements View, InitializerMetadata {
|
||||
const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph));
|
||||
|
||||
Object.assign(view, obj, {
|
||||
creationDate: creationDate,
|
||||
revisionDate: revisionDate,
|
||||
deletedDate: deletedDate,
|
||||
attachments: attachments,
|
||||
|
||||
@@ -169,6 +169,27 @@ export class IdentityView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.assign(new IdentityView(), obj);
|
||||
const identityView = new IdentityView();
|
||||
|
||||
identityView.title = obj.title ?? null;
|
||||
identityView.firstName = obj.firstName ?? null;
|
||||
identityView.middleName = obj.middleName ?? null;
|
||||
identityView.lastName = obj.lastName ?? null;
|
||||
identityView.address1 = obj.address1 ?? null;
|
||||
identityView.address2 = obj.address2 ?? null;
|
||||
identityView.address3 = obj.address3 ?? null;
|
||||
identityView.city = obj.city ?? null;
|
||||
identityView.state = obj.state ?? null;
|
||||
identityView.postalCode = obj.postalCode ?? null;
|
||||
identityView.country = obj.country ?? null;
|
||||
identityView.company = obj.company ?? null;
|
||||
identityView.email = obj.email ?? null;
|
||||
identityView.phone = obj.phone ?? null;
|
||||
identityView.ssn = obj.ssn ?? null;
|
||||
identityView.username = obj.username ?? null;
|
||||
identityView.passportNumber = obj.passportNumber ?? null;
|
||||
identityView.licenseNumber = obj.licenseNumber ?? null;
|
||||
|
||||
return identityView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +116,18 @@ export class LoginView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const passwordRevisionDate =
|
||||
obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate);
|
||||
const uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || [];
|
||||
const loginView = new LoginView();
|
||||
|
||||
return Object.assign(new LoginView(), obj, {
|
||||
passwordRevisionDate,
|
||||
uris,
|
||||
});
|
||||
loginView.username = obj.username ?? null;
|
||||
loginView.password = obj.password ?? null;
|
||||
loginView.passwordRevisionDate =
|
||||
obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate);
|
||||
loginView.totp = obj.totp ?? null;
|
||||
loginView.autofillOnPageLoad = obj.autofillOnPageLoad ?? null;
|
||||
loginView.uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || [];
|
||||
// FIDO2 credentials are not decrypted here, they remain encrypted
|
||||
loginView.fido2Credentials = null;
|
||||
|
||||
return loginView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ export class SecureNoteView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.assign(new SecureNoteView(), obj);
|
||||
const secureNoteView = new SecureNoteView();
|
||||
secureNoteView.type = obj.type ?? null;
|
||||
|
||||
return secureNoteView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,12 @@ export class SshKeyView extends ItemView {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyFingerprint = obj.fingerprint;
|
||||
const sshKeyView = new SshKeyView();
|
||||
|
||||
return Object.assign(new SshKeyView(), obj, {
|
||||
keyFingerprint,
|
||||
});
|
||||
sshKeyView.privateKey = obj.privateKey ?? null;
|
||||
sshKeyView.publicKey = obj.publicKey ?? null;
|
||||
sshKeyView.keyFingerprint = obj.fingerprint ?? null;
|
||||
|
||||
return sshKeyView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { ITreeNodeObject, TreeNode } from "./models/domain/tree-node";
|
||||
|
||||
export class ServiceUtils {
|
||||
|
||||
@@ -217,6 +217,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
cipher.organizationId = model.organizationId;
|
||||
cipher.type = model.type;
|
||||
cipher.collectionIds = model.collectionIds;
|
||||
cipher.creationDate = model.creationDate;
|
||||
cipher.revisionDate = model.revisionDate;
|
||||
cipher.reprompt = model.reprompt;
|
||||
cipher.edit = model.edit;
|
||||
@@ -448,12 +449,12 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) {
|
||||
return await this.bulkEncryptService.decryptItems(
|
||||
groupedCiphers,
|
||||
keys.orgKeys[orgId as OrganizationId] ?? keys.userKey,
|
||||
keys.orgKeys?.[orgId as OrganizationId] ?? keys.userKey,
|
||||
);
|
||||
} else {
|
||||
return await this.encryptService.decryptItems(
|
||||
groupedCiphers,
|
||||
keys.orgKeys[orgId as OrganizationId] ?? keys.userKey,
|
||||
keys.orgKeys?.[orgId as OrganizationId] ?? keys.userKey,
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
FileUploadApiMethods,
|
||||
FileUploadService,
|
||||
} from "../../../platform/abstractions/file-upload/file-upload.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
@@ -47,18 +46,7 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti
|
||||
this.generateMethods(uploadDataResponse, response, request.adminRequest),
|
||||
);
|
||||
} catch (e) {
|
||||
if (
|
||||
(e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) ||
|
||||
(e as ErrorResponse).statusCode === 405
|
||||
) {
|
||||
response = await this.legacyServerAttachmentFileUpload(
|
||||
request.adminRequest,
|
||||
cipher.id,
|
||||
encFileName,
|
||||
encData,
|
||||
dataEncKey[1],
|
||||
);
|
||||
} else if (e instanceof ErrorResponse) {
|
||||
if (e instanceof ErrorResponse) {
|
||||
throw new Error((e as ErrorResponse).getSingleMessage());
|
||||
} else {
|
||||
throw e;
|
||||
@@ -113,50 +101,4 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads.
|
||||
* This method still exists for backward compatibility with old server versions.
|
||||
*/
|
||||
async legacyServerAttachmentFileUpload(
|
||||
admin: boolean,
|
||||
cipherId: string,
|
||||
encFileName: EncString,
|
||||
encData: EncArrayBuffer,
|
||||
key: EncString,
|
||||
) {
|
||||
const fd = new FormData();
|
||||
try {
|
||||
const blob = new Blob([encData.buffer], { type: "application/octet-stream" });
|
||||
fd.append("key", key.encryptedString);
|
||||
fd.append("data", blob, encFileName.encryptedString);
|
||||
} catch (e) {
|
||||
if (Utils.isNode && !Utils.isBrowser) {
|
||||
fd.append("key", key.encryptedString);
|
||||
fd.append(
|
||||
"data",
|
||||
Buffer.from(encData.buffer) as any,
|
||||
{
|
||||
filepath: encFileName.encryptedString,
|
||||
contentType: "application/octet-stream",
|
||||
} as any,
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
let response: CipherResponse;
|
||||
try {
|
||||
if (admin) {
|
||||
response = await this.apiService.postCipherAttachmentAdminLegacy(cipherId, fd);
|
||||
} else {
|
||||
response = await this.apiService.postCipherAttachmentLegacy(cipherId, fd);
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error((e as ErrorResponse).getSingleMessage());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
import { RestrictedItemTypesService, RestrictedCipherType } from "./restricted-item-types.service";
|
||||
|
||||
describe("RestrictedItemTypesService", () => {
|
||||
let service: RestrictedItemTypesService;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let fakeAccount: Account | null;
|
||||
|
||||
const org1: Organization = { id: "org1" } as any;
|
||||
const org2: Organization = { id: "org2" } as any;
|
||||
|
||||
const policyOrg1 = {
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RestrictedItemTypes,
|
||||
enabled: true,
|
||||
data: [CipherType.Card],
|
||||
} as Policy;
|
||||
|
||||
const policyOrg2 = {
|
||||
organizationId: "org2",
|
||||
type: PolicyType.RestrictedItemTypes,
|
||||
enabled: true,
|
||||
data: [CipherType.Card],
|
||||
} as Policy;
|
||||
|
||||
beforeEach(() => {
|
||||
policyService = mock<PolicyService>();
|
||||
organizationService = mock<OrganizationService>();
|
||||
accountService = mock<AccountService>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
fakeAccount = { id: Utils.newGuid() as UserId } as Account;
|
||||
accountService.activeAccount$ = of(fakeAccount);
|
||||
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
organizationService.organizations$.mockReturnValue(of([org1, org2]));
|
||||
policyService.policiesByType$.mockReturnValue(of([]));
|
||||
|
||||
service = new RestrictedItemTypesService(
|
||||
configService,
|
||||
accountService,
|
||||
organizationService,
|
||||
policyService,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits empty array when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
const result = await firstValueFrom(service.restricted$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits empty array if no organizations exist", async () => {
|
||||
organizationService.organizations$.mockReturnValue(of([]));
|
||||
policyService.policiesByType$.mockReturnValue(of([]));
|
||||
|
||||
const result = await firstValueFrom(service.restricted$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults undefined data to [Card] and returns empty allowViewOrgIds", async () => {
|
||||
organizationService.organizations$.mockReturnValue(of([org1]));
|
||||
|
||||
const policyForOrg1 = {
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RestrictedItemTypes,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
} as Policy;
|
||||
policyService.policiesByType$.mockReturnValue(of([policyForOrg1]));
|
||||
|
||||
const result = await firstValueFrom(service.restricted$);
|
||||
expect(result).toEqual<RestrictedCipherType[]>([
|
||||
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("if one org restricts Card and another has no policy, allowViewOrgIds contains the unrestricted org", async () => {
|
||||
policyService.policiesByType$.mockReturnValue(of([policyOrg1]));
|
||||
|
||||
const result = await firstValueFrom(service.restricted$);
|
||||
expect(result).toEqual<RestrictedCipherType[]>([
|
||||
{ cipherType: CipherType.Card, allowViewOrgIds: ["org2"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty allowViewOrgIds when all orgs restrict the same type", async () => {
|
||||
organizationService.organizations$.mockReturnValue(of([org1, org2]));
|
||||
policyService.policiesByType$.mockReturnValue(of([policyOrg1, policyOrg2]));
|
||||
|
||||
const result = await firstValueFrom(service.restricted$);
|
||||
expect(result).toEqual<RestrictedCipherType[]>([
|
||||
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("aggregates multiple types and computes allowViewOrgIds correctly", async () => {
|
||||
organizationService.organizations$.mockReturnValue(of([org1, org2]));
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([
|
||||
{ ...policyOrg1, data: [CipherType.Card, CipherType.Login] } as Policy,
|
||||
{ ...policyOrg2, data: [CipherType.Card, CipherType.Identity] } as Policy,
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.restricted$);
|
||||
|
||||
expect(result).toEqual<RestrictedCipherType[]>([
|
||||
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
|
||||
{ cipherType: CipherType.Login, allowViewOrgIds: ["org2"] },
|
||||
{ cipherType: CipherType.Identity, allowViewOrgIds: ["org1"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
126
libs/common/src/vault/services/restricted-item-types.service.ts
Normal file
126
libs/common/src/vault/services/restricted-item-types.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { combineLatest, map, of, Observable } from "rxjs";
|
||||
import { switchMap, distinctUntilChanged, shareReplay } from "rxjs/operators";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
|
||||
export type RestrictedCipherType = {
|
||||
cipherType: CipherType;
|
||||
allowViewOrgIds: string[];
|
||||
};
|
||||
|
||||
type CipherLike = Cipher | CipherView;
|
||||
|
||||
export class RestrictedItemTypesService {
|
||||
/**
|
||||
* Emits an array of RestrictedCipherType objects:
|
||||
* - cipherType: each type restricted by at least one org-level policy
|
||||
* - allowViewOrgIds: org IDs that allow viewing that type
|
||||
*/
|
||||
readonly restricted$: Observable<RestrictedCipherType[]> = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy)
|
||||
.pipe(
|
||||
switchMap((flagOn) => {
|
||||
if (!flagOn) {
|
||||
return of([]);
|
||||
}
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.organizationService.organizations$(userId),
|
||||
this.policyService.policiesByType$(PolicyType.RestrictedItemTypes, userId),
|
||||
]),
|
||||
),
|
||||
map(([orgs, enabledPolicies]) => {
|
||||
// Helper to extract restricted types, defaulting to [Card]
|
||||
const restrictedTypes = (p: (typeof enabledPolicies)[number]) =>
|
||||
(p.data as CipherType[]) ?? [CipherType.Card];
|
||||
|
||||
// Union across all enabled policies
|
||||
const allRestrictedTypes = Array.from(
|
||||
new Set(enabledPolicies.flatMap(restrictedTypes)),
|
||||
);
|
||||
|
||||
return allRestrictedTypes.map((cipherType) => {
|
||||
// Determine which orgs allow viewing this type
|
||||
const allowViewOrgIds = orgs
|
||||
.filter((org) => {
|
||||
const orgPolicy = enabledPolicies.find((p) => p.organizationId === org.id);
|
||||
// no policy for this org => allows everything
|
||||
if (!orgPolicy) {
|
||||
return true;
|
||||
}
|
||||
// if this type not in their restricted list => they allow it
|
||||
return !restrictedTypes(orgPolicy).includes(cipherType);
|
||||
})
|
||||
.map((org) => org.id);
|
||||
|
||||
return { cipherType, allowViewOrgIds };
|
||||
});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
private organizationService: OrganizationService,
|
||||
private policyService: PolicyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Determines if a cipher is restricted from being viewed by the user.
|
||||
*
|
||||
* @param cipher - The cipher to check
|
||||
* @param restrictedTypes - Array of restricted cipher types (from restricted$ observable)
|
||||
* @returns true if the cipher is restricted, false otherwise
|
||||
*
|
||||
* Restriction logic:
|
||||
* - If cipher type is not restricted by any org → allowed
|
||||
* - If cipher belongs to an org that allows this type → allowed
|
||||
* - If cipher is personal vault and any org allows this type → allowed
|
||||
* - Otherwise → restricted
|
||||
*/
|
||||
isCipherRestricted(cipher: CipherLike, restrictedTypes: RestrictedCipherType[]): boolean {
|
||||
const restriction = restrictedTypes.find((r) => r.cipherType === cipher.type);
|
||||
|
||||
// If cipher type is not restricted by any organization, allow it
|
||||
if (!restriction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If cipher belongs to an organization
|
||||
if (cipher.organizationId) {
|
||||
// Check if this organization allows viewing this cipher type
|
||||
return !restriction.allowViewOrgIds.includes(cipher.organizationId);
|
||||
}
|
||||
|
||||
// For personal vault ciphers: restricted only if NO organizations allow this type
|
||||
return restriction.allowViewOrgIds.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method that combines getting restrictions and checking a cipher.
|
||||
*
|
||||
* @param cipher - The cipher to check
|
||||
* @returns Observable<boolean> indicating if the cipher is restricted
|
||||
*/
|
||||
isCipherRestricted$(cipher: CipherLike): Observable<boolean> {
|
||||
return this.restricted$.pipe(
|
||||
map((restrictedTypes) => this.isCipherRestricted(cipher, restrictedTypes)),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
libs/common/src/vault/types/cipher-menu-items.ts
Normal file
24
libs/common/src/vault/types/cipher-menu-items.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CipherType } from "../enums";
|
||||
|
||||
/**
|
||||
* Represents a menu item for creating a new cipher of a specific type
|
||||
*/
|
||||
export type CipherMenuItem = {
|
||||
/** The cipher type this menu item represents */
|
||||
type: CipherType;
|
||||
/** The icon class name (e.g., "bwi-globe") */
|
||||
icon: string;
|
||||
/** The i18n key for the label text */
|
||||
labelKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* All available cipher menu items with their associated icons and labels
|
||||
*/
|
||||
export const CIPHER_MENU_ITEMS = Object.freeze([
|
||||
{ type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" },
|
||||
{ type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" },
|
||||
{ type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" },
|
||||
{ type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "typeNote" },
|
||||
{ type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" },
|
||||
] as const) satisfies readonly CipherMenuItem[];
|
||||
Reference in New Issue
Block a user