1
0
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:
Patrick Pimentel
2025-06-23 15:39:09 -04:00
619 changed files with 24974 additions and 5442 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -212,6 +212,7 @@ export class DefaultSdkService implements SdkService {
},
},
privateKey,
signingKey: undefined,
});
// We initialize the org crypto even if the org_keys are

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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[];