1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-28 10:33:31 +00:00

Merge branch 'master' into auth/pm-3797/emergency-access-refactor

This commit is contained in:
Jacob Fink
2023-10-16 12:37:38 -04:00
334 changed files with 4394 additions and 1225 deletions

View File

@@ -3,6 +3,7 @@ import { OrganizationResponse } from "../../../admin-console/models/response/org
import {
BillingSubscriptionResponse,
BillingSubscriptionUpcomingInvoiceResponse,
BillingCustomerDiscount,
} from "./subscription.response";
export class OrganizationSubscriptionResponse extends OrganizationResponse {
@@ -10,6 +11,7 @@ export class OrganizationSubscriptionResponse extends OrganizationResponse {
storageGb: number;
subscription: BillingSubscriptionResponse;
upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse;
discount: BillingCustomerDiscount;
expiration: string;
expirationWithoutGracePeriod: string;
secretsManagerBeta: boolean;
@@ -25,6 +27,8 @@ export class OrganizationSubscriptionResponse extends OrganizationResponse {
upcomingInvoice == null
? null
: new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice);
const discount = this.getResponseProperty("Discount");
this.discount = discount == null ? null : new BillingCustomerDiscount(discount);
this.expiration = this.getResponseProperty("Expiration");
this.expirationWithoutGracePeriod = this.getResponseProperty("ExpirationWithoutGracePeriod");
this.secretsManagerBeta = this.getResponseProperty("SecretsManagerBeta");

View File

@@ -7,6 +7,7 @@ export class SubscriptionResponse extends BaseResponse {
maxStorageGb: number;
subscription: BillingSubscriptionResponse;
upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse;
discount: BillingCustomerDiscount;
license: any;
expiration: string;
usingInAppPurchase: boolean;
@@ -21,11 +22,13 @@ export class SubscriptionResponse extends BaseResponse {
this.usingInAppPurchase = this.getResponseProperty("UsingInAppPurchase");
const subscription = this.getResponseProperty("Subscription");
const upcomingInvoice = this.getResponseProperty("UpcomingInvoice");
const discount = this.getResponseProperty("Discount");
this.subscription = subscription == null ? null : new BillingSubscriptionResponse(subscription);
this.upcomingInvoice =
upcomingInvoice == null
? null
: new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice);
this.discount = discount == null ? null : new BillingCustomerDiscount(discount);
}
}
@@ -88,3 +91,14 @@ export class BillingSubscriptionUpcomingInvoiceResponse extends BaseResponse {
this.amount = this.getResponseProperty("Amount");
}
}
export class BillingCustomerDiscount extends BaseResponse {
id: string;
active: boolean;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.active = this.getResponseProperty("Active");
}
}

View File

@@ -88,6 +88,7 @@ export class CipherExport {
domain.notes = req.notes != null ? new EncString(req.notes) : null;
domain.favorite = req.favorite;
domain.reprompt = req.reprompt ?? CipherRepromptType.None;
domain.key = req.key != null ? new EncString(req.key) : null;
if (req.fields != null) {
domain.fields = req.fields.map((f) => FieldExport.toDomain(f));
@@ -135,6 +136,7 @@ export class CipherExport {
revisionDate: Date = null;
creationDate: Date = null;
deletedDate: Date = null;
key: string;
// Use build method instead of ctor so that we can control order of JSON stringify for pretty print
build(o: CipherView | CipherDomain) {
@@ -149,6 +151,7 @@ export class CipherExport {
} else {
this.name = o.name?.encryptedString;
this.notes = o.notes?.encryptedString;
this.key = o.key?.encryptedString;
}
this.favorite = o.favorite;

View File

@@ -1,4 +1,5 @@
import { Observable } from "rxjs";
import { SemVer } from "semver";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { Region } from "../environment.service";
@@ -16,6 +17,9 @@ export abstract class ConfigServiceAbstraction {
key: FeatureFlag,
defaultValue?: T
) => Promise<T>;
checkServerMeetsVersionRequirement$: (
minimumRequiredServerVersion: SemVer
) => Observable<boolean>;
/**
* Force ConfigService to fetch an updated config from the server and emit it from serverConfig$

View File

@@ -6,6 +6,7 @@ import { KeySuffixOptions, KdfType, HashPurpose } from "../../enums";
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
import { EncString } from "../models/domain/enc-string";
import {
CipherKey,
MasterKey,
OrgKey,
PinKey,
@@ -372,6 +373,11 @@ export abstract class CryptoService {
*/
rsaDecrypt: (encValue: string, privateKeyValue?: Uint8Array) => Promise<Uint8Array>;
randomNumber: (min: number, max: number) => Promise<number>;
/**
* Generates a new cipher key
* @returns A new cipher key
*/
makeCipherKey: () => Promise<CipherKey>;
/**
* Initialize all necessary crypto keys needed for a new account.

View File

@@ -4,6 +4,13 @@ interface ToastOptions {
timeout?: number;
}
export type ClipboardOptions = {
allowHistory?: boolean;
clearing?: boolean;
clearMs?: number;
window?: Window;
};
export abstract class PlatformUtilsService {
getDevice: () => DeviceType;
getDeviceString: () => string;
@@ -29,8 +36,8 @@ export abstract class PlatformUtilsService {
) => void;
isDev: () => boolean;
isSelfHost: () => boolean;
copyToClipboard: (text: string, options?: any) => void | boolean;
readFromClipboard: (options?: any) => Promise<string>;
copyToClipboard: (text: string, options?: ClipboardOptions) => void | boolean;
readFromClipboard: () => Promise<string>;
supportsBiometric: () => Promise<boolean>;
authenticateBiometric: () => Promise<boolean>;
supportsSecureStorage: () => boolean;

View File

@@ -8,5 +8,5 @@ import { InitializerMetadata } from "./initializer-metadata.interface";
* @example Cipher implements Decryptable<CipherView>
*/
export interface Decryptable<TDecrypted extends InitializerMetadata> extends InitializerMetadata {
decrypt: (key?: SymmetricCryptoKey) => Promise<TDecrypted>;
decrypt: (key: SymmetricCryptoKey) => Promise<TDecrypted>;
}

View File

@@ -3,6 +3,7 @@
export type SharedFlags = {
multithreadDecryption: boolean;
showPasswordless?: boolean;
enableCipherKeyEncryption?: boolean;
};
// required to avoid linting errors when there are no flags

View File

@@ -83,3 +83,4 @@ export type MasterKey = Opaque<SymmetricCryptoKey, "MasterKey">;
export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">;
export type OrgKey = Opaque<SymmetricCryptoKey, "OrgKey">;
export type ProviderKey = Opaque<SymmetricCryptoKey, "ProviderKey">;
export type CipherKey = Opaque<SymmetricCryptoKey, "CipherKey">;

View File

@@ -10,6 +10,7 @@ import {
merge,
timer,
} from "rxjs";
import { SemVer } from "semver";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
@@ -25,10 +26,13 @@ import { ServerConfigData } from "../../models/data/server-config.data";
const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600;
export class ConfigService implements ConfigServiceAbstraction {
private inited = false;
protected _serverConfig = new ReplaySubject<ServerConfig | null>(1);
serverConfig$ = this._serverConfig.asObservable();
private _forceFetchConfig = new Subject<void>();
private inited = false;
protected refreshTimer$ = timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS); // after 1 hour, then every hour
cloudRegion$ = this.serverConfig$.pipe(
map((config) => config?.environment?.cloudRegion ?? Region.US)
@@ -62,7 +66,7 @@ export class ConfigService implements ConfigServiceAbstraction {
// If you need to fetch a new config when an event occurs, add an observable that emits on that event here
merge(
timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS), // after 1 hour, then every hour
this.refreshTimer$, // an overridable interval
this.environmentService.urls, // when environment URLs change (including when app is started)
this._forceFetchConfig // manual
)
@@ -103,4 +107,21 @@ export class ConfigService implements ConfigServiceAbstraction {
await this.stateService.setServerConfig(data);
this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion);
}
/**
* Verifies whether the server version meets the minimum required version
* @param minimumRequiredServerVersion The minimum version required
* @returns True if the server version is greater than or equal to the minimum required version
*/
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) {
return this.serverConfig$.pipe(
map((serverConfig) => {
if (serverConfig == null) {
return false;
}
const serverVersion = new SemVer(serverConfig.version);
return serverVersion.compare(minimumRequiredServerVersion) >= 0;
})
);
}
}

View File

@@ -27,6 +27,7 @@ import { EFFLongWordList } from "../misc/wordlist";
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
import { EncString } from "../models/domain/enc-string";
import {
CipherKey,
MasterKey,
OrgKey,
PinKey,
@@ -596,6 +597,11 @@ export class CryptoService implements CryptoServiceAbstraction {
return new SymmetricCryptoKey(sendKey);
}
async makeCipherKey(): Promise<CipherKey> {
const randomBytes = await this.cryptoFunctionService.aesGenerateKey(512);
return new SymmetricCryptoKey(randomBytes) as CipherKey;
}
async clearKeys(userId?: string): Promise<any> {
await this.clearUserKey(true, userId);
await this.clearMasterKeyHash(userId);

View File

@@ -11,6 +11,10 @@ export class AnonAddyForwarder implements Forwarder {
if (options.anonaddy?.domain == null || options.anonaddy.domain === "") {
throw "Invalid addy.io domain.";
}
if (options.anonaddy?.baseUrl == null || options.anonaddy.baseUrl === "") {
throw "Invalid addy.io url.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
@@ -21,7 +25,7 @@ export class AnonAddyForwarder implements Forwarder {
"X-Requested-With": "XMLHttpRequest",
}),
};
const url = "https://app.addy.io/api/v1/aliases";
const url = options.anonaddy.baseUrl + "/api/v1/aliases";
requestInit.body = JSON.stringify({
domain: options.anonaddy.domain,
description:
@@ -37,6 +41,9 @@ export class AnonAddyForwarder implements Forwarder {
if (response.status === 401) {
throw "Invalid addy.io API token.";
}
if (response?.statusText != null) {
throw "addy.io error:\n" + response.statusText;
}
throw "Unknown addy.io error occurred.";
}
}

View File

@@ -12,6 +12,7 @@ export class FastmailForwarderOptions {
export class AnonAddyForwarderOptions {
domain: string;
baseUrl: string;
}
export class ForwardEmailForwarderOptions {

View File

@@ -10,6 +10,7 @@ export type UsernameGeneratorOptions = {
forwardedService?: string;
forwardedAnonAddyApiToken?: string;
forwardedAnonAddyDomain?: string;
forwardedAnonAddyBaseUrl?: string;
forwardedDuckDuckGoToken?: string;
forwardedFirefoxApiToken?: string;
forwardedFastmailApiToken?: string;

View File

@@ -24,6 +24,7 @@ const DefaultOptions: UsernameGeneratorOptions = {
catchallType: "random",
forwardedService: "",
forwardedAnonAddyDomain: "anonaddy.me",
forwardedAnonAddyBaseUrl: "https://app.addy.io",
forwardedForwardEmailDomain: "hideaddress.net",
};
@@ -131,6 +132,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr
forwarder = new AnonAddyForwarder();
forwarderOptions.apiKey = o.forwardedAnonAddyApiToken;
forwarderOptions.anonaddy.domain = o.forwardedAnonAddyDomain;
forwarderOptions.anonaddy.baseUrl = o.forwardedAnonAddyBaseUrl;
} else if (o.forwardedService === "firefoxrelay") {
forwarder = new FirefoxRelayForwarder();
forwarderOptions.apiKey = o.forwardedFirefoxApiToken;

View File

@@ -11,7 +11,8 @@ export abstract class CipherService {
clearCache: (userId?: string) => Promise<void>;
encrypt: (
model: CipherView,
key?: SymmetricCryptoKey,
keyForEncryption?: SymmetricCryptoKey,
keyForCipherKeyDecryption?: SymmetricCryptoKey,
originalCipher?: Cipher
) => Promise<Cipher>;
encryptFields: (fieldsModel: FieldView[], key: SymmetricCryptoKey) => Promise<Field[]>;
@@ -81,4 +82,5 @@ export abstract class CipherService {
organizationId?: string,
asAdmin?: boolean
) => Promise<void>;
getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise<any>;
}

View File

@@ -33,6 +33,7 @@ export class CipherData {
creationDate: string;
deletedDate: string;
reprompt: CipherRepromptType;
key: string;
constructor(response?: CipherResponse, collectionIds?: string[]) {
if (response == null) {
@@ -54,6 +55,7 @@ export class CipherData {
this.creationDate = response.creationDate;
this.deletedDate = response.deletedDate;
this.reprompt = response.reprompt;
this.key = response.key;
switch (this.type) {
case CipherType.Login:

View File

@@ -6,6 +6,7 @@ export class CollectionData {
name: string;
externalId: string;
readOnly: boolean;
hidePasswords: boolean;
constructor(response: CollectionDetailsResponse) {
this.id = response.id;
@@ -13,5 +14,6 @@ export class CollectionData {
this.name = response.name;
this.externalId = response.externalId;
this.readOnly = response.readOnly;
this.hidePasswords = response.hidePasswords;
}
}

View File

@@ -2,10 +2,14 @@
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { Jsonify } from "type-fest";
import { mockEnc, mockFromJson } from "../../../../spec";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils";
import { FieldType, SecureNoteType, UriMatchType } from "../../../enums";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncString } from "../../../platform/models/domain/enc-string";
import { ContainerService } from "../../../platform/services/container.service";
import { InitializerKey } from "../../../platform/services/cryptography/initializer-key";
import { CipherService } from "../../abstractions/cipher.service";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherData } from "../../models/data/cipher.data";
@@ -47,6 +51,7 @@ describe("Cipher DTO", () => {
attachments: null,
fields: null,
passwordHistory: null,
key: null,
});
});
@@ -69,6 +74,7 @@ describe("Cipher DTO", () => {
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
reprompt: CipherRepromptType.None,
key: "EncryptedString",
login: {
uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }],
username: "EncryptedString",
@@ -136,6 +142,7 @@ describe("Cipher DTO", () => {
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
key: { encryptedString: "EncryptedString", encryptionType: 0 },
login: {
passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"),
autofillOnPageLoad: false,
@@ -206,6 +213,7 @@ describe("Cipher DTO", () => {
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
const loginView = new LoginView();
loginView.username = "username";
@@ -215,7 +223,20 @@ describe("Cipher DTO", () => {
login.decrypt(Arg.any(), Arg.any()).resolves(loginView);
cipher.login = login;
const cipherView = await cipher.decrypt();
const cryptoService = Substitute.for<CryptoService>();
const encryptService = Substitute.for<EncryptService>();
const cipherService = Substitute.for<CipherService>();
encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64));
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher)
);
expect(cipherView).toMatchObject({
id: "id",
@@ -261,6 +282,7 @@ describe("Cipher DTO", () => {
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
reprompt: CipherRepromptType.None,
key: "EncKey",
secureNote: {
type: SecureNoteType.Generic,
},
@@ -292,6 +314,7 @@ describe("Cipher DTO", () => {
attachments: null,
fields: null,
passwordHistory: null,
key: { encryptedString: "EncKey", encryptionType: 0 },
});
});
@@ -318,8 +341,22 @@ describe("Cipher DTO", () => {
cipher.reprompt = CipherRepromptType.None;
cipher.secureNote = new SecureNote();
cipher.secureNote.type = SecureNoteType.Generic;
cipher.key = mockEnc("EncKey");
const cipherView = await cipher.decrypt();
const cryptoService = Substitute.for<CryptoService>();
const encryptService = Substitute.for<EncryptService>();
const cipherService = Substitute.for<CipherService>();
encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64));
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher)
);
expect(cipherView).toMatchObject({
id: "id",
@@ -373,6 +410,7 @@ describe("Cipher DTO", () => {
expYear: "EncryptedString",
code: "EncryptedString",
},
key: "EncKey",
};
});
@@ -408,6 +446,7 @@ describe("Cipher DTO", () => {
attachments: null,
fields: null,
passwordHistory: null,
key: { encryptedString: "EncKey", encryptionType: 0 },
});
});
@@ -432,6 +471,7 @@ describe("Cipher DTO", () => {
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
const cardView = new CardView();
cardView.cardholderName = "cardholderName";
@@ -441,7 +481,20 @@ describe("Cipher DTO", () => {
card.decrypt(Arg.any(), Arg.any()).resolves(cardView);
cipher.card = card;
const cipherView = await cipher.decrypt();
const cryptoService = Substitute.for<CryptoService>();
const encryptService = Substitute.for<EncryptService>();
const cipherService = Substitute.for<CipherService>();
encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64));
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher)
);
expect(cipherView).toMatchObject({
id: "id",
@@ -487,6 +540,7 @@ describe("Cipher DTO", () => {
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
reprompt: CipherRepromptType.None,
key: "EncKey",
identity: {
title: "EncryptedString",
firstName: "EncryptedString",
@@ -554,6 +608,7 @@ describe("Cipher DTO", () => {
attachments: null,
fields: null,
passwordHistory: null,
key: { encryptedString: "EncKey", encryptionType: 0 },
});
});
@@ -578,6 +633,7 @@ describe("Cipher DTO", () => {
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
cipher.key = mockEnc("EncKey");
const identityView = new IdentityView();
identityView.firstName = "firstName";
@@ -587,7 +643,20 @@ describe("Cipher DTO", () => {
identity.decrypt(Arg.any(), Arg.any()).resolves(identityView);
cipher.identity = identity;
const cipherView = await cipher.decrypt();
const cryptoService = Substitute.for<CryptoService>();
const encryptService = Substitute.for<EncryptService>();
const cipherService = Substitute.for<CipherService>();
encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64));
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher)
);
expect(cipherView).toMatchObject({
id: "id",

View File

@@ -1,6 +1,7 @@
import { Jsonify } from "type-fest";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { Utils } from "../../../platform/misc/utils";
import Domain from "../../../platform/models/domain/domain-base";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
@@ -45,6 +46,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
creationDate: Date;
deletedDate: Date;
reprompt: CipherRepromptType;
key: EncString;
constructor(obj?: CipherData, localData: LocalData = null) {
super();
@@ -61,6 +63,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
folderId: null,
name: null,
notes: null,
key: null,
},
["id", "organizationId", "folderId"]
);
@@ -117,9 +120,17 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
}
}
async decrypt(encKey?: SymmetricCryptoKey): Promise<CipherView> {
// We are passing the organizationId into the EncString.decrypt() method here, but because the encKey will always be
// present and so the organizationId will not be used.
// We will refactor the EncString.decrypt() in https://bitwarden.atlassian.net/browse/PM-3762 to remove the dependency on the organizationId.
async decrypt(encKey: SymmetricCryptoKey): Promise<CipherView> {
const model = new CipherView(this);
if (this.key != null) {
const encryptService = Utils.getContainerService().getEncryptService();
encKey = new SymmetricCryptoKey(await encryptService.decryptToBytes(this.key, encKey));
}
await this.decryptObj(
model,
{
@@ -147,14 +158,12 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
break;
}
const orgId = this.organizationId;
if (this.attachments != null && this.attachments.length > 0) {
const attachments: any[] = [];
await this.attachments.reduce((promise, attachment) => {
return promise
.then(() => {
return attachment.decrypt(orgId, encKey);
return attachment.decrypt(this.organizationId, encKey);
})
.then((decAttachment) => {
attachments.push(decAttachment);
@@ -168,7 +177,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
await this.fields.reduce((promise, field) => {
return promise
.then(() => {
return field.decrypt(orgId, encKey);
return field.decrypt(this.organizationId, encKey);
})
.then((decField) => {
fields.push(decField);
@@ -182,7 +191,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
await this.passwordHistory.reduce((promise, ph) => {
return promise
.then(() => {
return ph.decrypt(orgId, encKey);
return ph.decrypt(this.organizationId, encKey);
})
.then((decPh) => {
passwordHistory.push(decPh);
@@ -209,6 +218,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
c.creationDate = this.creationDate != null ? this.creationDate.toISOString() : null;
c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null;
c.reprompt = this.reprompt;
c.key = this.key?.encryptedString;
this.buildDataModel(this, c, {
name: null,
@@ -257,6 +267,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
const attachments = obj.attachments?.map((a: any) => Attachment.fromJSON(a));
const fields = obj.fields?.map((f: any) => Field.fromJSON(f));
const passwordHistory = obj.passwordHistory?.map((ph: any) => Password.fromJSON(ph));
const key = EncString.fromJSON(obj.key);
Object.assign(domain, obj, {
name,
@@ -266,6 +277,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
attachments,
fields,
passwordHistory,
key,
});
switch (obj.type) {

View File

@@ -13,6 +13,7 @@ describe("Collection", () => {
name: "encName",
externalId: "extId",
readOnly: true,
hidePasswords: true,
};
});
@@ -39,7 +40,7 @@ describe("Collection", () => {
name: { encryptedString: "encName", encryptionType: 0 },
externalId: "extId",
readOnly: true,
hidePasswords: null,
hidePasswords: true,
});
});

View File

@@ -29,6 +29,7 @@ export class CipherRequest {
attachments2: { [id: string]: AttachmentRequest };
lastKnownRevisionDate: Date;
reprompt: CipherRepromptType;
key: string;
constructor(cipher: Cipher) {
this.type = cipher.type;
@@ -39,6 +40,7 @@ export class CipherRequest {
this.favorite = cipher.favorite;
this.lastKnownRevisionDate = cipher.revisionDate;
this.reprompt = cipher.reprompt;
this.key = cipher.key?.encryptedString;
switch (this.type) {
case CipherType.Login:

View File

@@ -32,6 +32,7 @@ export class CipherResponse extends BaseResponse {
creationDate: string;
deletedDate: string;
reprompt: CipherRepromptType;
key: string;
constructor(response: any) {
super(response);
@@ -90,5 +91,6 @@ export class CipherResponse extends BaseResponse {
}
this.reprompt = this.getResponseProperty("Reprompt") || CipherRepromptType.None;
this.key = this.getResponseProperty("Key") || null;
}
}

View File

@@ -18,10 +18,12 @@ export class CollectionResponse extends BaseResponse {
export class CollectionDetailsResponse extends CollectionResponse {
readOnly: boolean;
hidePasswords: boolean;
constructor(response: any) {
super(response);
this.readOnly = this.getResponseProperty("ReadOnly") || false;
this.hidePasswords = this.getResponseProperty("HidePasswords") || false;
}
}

View File

@@ -1,15 +1,24 @@
// eslint-disable-next-line no-restricted-imports
import { mock, mockReset } from "jest-mock-extended";
import { of } from "rxjs";
import { makeStaticByteArray } from "../../../spec/utils";
import { ApiService } from "../../abstractions/api.service";
import { SearchService } from "../../abstractions/search.service";
import { SettingsService } from "../../abstractions/settings.service";
import { UriMatchType, FieldType } from "../../enums";
import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
import { OrgKey, SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../platform/models/domain/enc-string";
import {
CipherKey,
OrgKey,
SymmetricCryptoKey,
} from "../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../platform/services/container.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
import { CipherType } from "../enums/cipher-type";
@@ -18,9 +27,13 @@ import { Cipher } from "../models/domain/cipher";
import { CipherCreateRequest } from "../models/request/cipher-create.request";
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
import { CipherRequest } from "../models/request/cipher.request";
import { CipherView } from "../models/view/cipher.view";
import { CipherService } from "./cipher.service";
const ENCRYPTED_TEXT = "This data has been encrypted";
const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
const cipherData: CipherData = {
id: "id",
organizationId: "orgId",
@@ -35,6 +48,7 @@ const cipherData: CipherData = {
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
key: "EncKey",
reprompt: CipherRepromptType.None,
login: {
uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }],
@@ -88,6 +102,7 @@ describe("Cipher Service", () => {
const i18nService = mock<I18nService>();
const searchService = mock<SearchService>();
const encryptService = mock<EncryptService>();
const configService = mock<ConfigServiceAbstraction>();
let cipherService: CipherService;
let cipherObj: Cipher;
@@ -101,6 +116,12 @@ describe("Cipher Service", () => {
mockReset(i18nService);
mockReset(searchService);
mockReset(encryptService);
mockReset(configService);
encryptService.encryptToBytes.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
encryptService.encrypt.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT)));
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
cipherService = new CipherService(
cryptoService,
@@ -110,7 +131,8 @@ describe("Cipher Service", () => {
searchService,
stateService,
encryptService,
cipherFileUploadService
cipherFileUploadService,
configService
);
cipherObj = new Cipher(cipherData);
@@ -125,6 +147,12 @@ describe("Cipher Service", () => {
cryptoService.makeDataEncKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)))
);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false));
process.env.FLAGS = JSON.stringify({
enableCipherKeyEncryption: false,
});
const spy = jest.spyOn(cipherFileUploadService, "upload");
await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData);
@@ -216,4 +244,68 @@ describe("Cipher Service", () => {
expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj);
});
});
describe("encrypt", () => {
let cipherView: CipherView;
beforeEach(() => {
cipherView = new CipherView();
cipherView.type = CipherType.Login;
encryptService.decryptToBytes.mockReturnValue(Promise.resolve(makeStaticByteArray(64)));
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
cryptoService.makeCipherKey.mockReturnValue(
Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey)
);
cryptoService.encrypt.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT)));
});
describe("cipher.key", () => {
it("is null when enableCipherKeyEncryption flag is false", async () => {
process.env.FLAGS = JSON.stringify({
enableCipherKeyEncryption: false,
});
const cipher = await cipherService.encrypt(cipherView);
expect(cipher.key).toBeNull();
});
it("is defined when enableCipherKeyEncryption flag is true", async () => {
process.env.FLAGS = JSON.stringify({
enableCipherKeyEncryption: true,
});
const cipher = await cipherService.encrypt(cipherView);
expect(cipher.key).toBeDefined();
});
});
describe("encryptWithCipherKey", () => {
beforeEach(() => {
jest.spyOn<any, string>(cipherService, "encryptCipherWithCipherKey");
});
it("is not called when enableCipherKeyEncryption is false", async () => {
process.env.FLAGS = JSON.stringify({
enableCipherKeyEncryption: false,
});
await cipherService.encrypt(cipherView);
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
});
it("is called when enableCipherKeyEncryption is true", async () => {
process.env.FLAGS = JSON.stringify({
enableCipherKeyEncryption: true,
});
await cipherService.encrypt(cipherView);
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
});
});
});
});

View File

@@ -1,13 +1,18 @@
import { firstValueFrom } from "rxjs";
import { SemVer } from "semver";
import { ApiService } from "../../abstractions/api.service";
import { SearchService } from "../../abstractions/search.service";
import { SettingsService } from "../../abstractions/settings.service";
import { FieldType, UriMatchType } from "../../enums";
import { ErrorResponse } from "../../models/response/error.response";
import { View } from "../../models/view/view";
import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
import { flagEnabled } from "../../platform/misc/flags";
import { sequentialize } from "../../platform/misc/sequentialize";
import { Utils } from "../../platform/misc/utils";
import Domain from "../../platform/models/domain/domain-base";
@@ -47,6 +52,8 @@ import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
import { PasswordHistoryView } from "../models/view/password-history.view";
const CIPHER_KEY_ENC_MIN_SERVER_VER = new SemVer("2023.9.1");
export class CipherService implements CipherServiceAbstraction {
private sortedCiphersCache: SortedCiphersCache = new SortedCiphersCache(
this.sortCiphersByLastUsed
@@ -60,7 +67,8 @@ export class CipherService implements CipherServiceAbstraction {
private searchService: SearchService,
private stateService: StateService,
private encryptService: EncryptService,
private cipherFileUploadService: CipherFileUploadService
private cipherFileUploadService: CipherFileUploadService,
private configService: ConfigServiceAbstraction
) {}
async getDecryptedCipherCache(): Promise<CipherView[]> {
@@ -85,63 +93,18 @@ export class CipherService implements CipherServiceAbstraction {
async encrypt(
model: CipherView,
key?: SymmetricCryptoKey,
keyForEncryption?: SymmetricCryptoKey,
keyForCipherKeyDecryption?: SymmetricCryptoKey,
originalCipher: Cipher = null
): Promise<Cipher> {
// Adjust password history
if (model.id != null) {
if (originalCipher == null) {
originalCipher = await this.get(model.id);
}
if (originalCipher != null) {
const existingCipher = await originalCipher.decrypt();
model.passwordHistory = existingCipher.passwordHistory || [];
if (model.type === CipherType.Login && existingCipher.type === CipherType.Login) {
if (
existingCipher.login.password != null &&
existingCipher.login.password !== "" &&
existingCipher.login.password !== model.login.password
) {
const ph = new PasswordHistoryView();
ph.password = existingCipher.login.password;
ph.lastUsedDate = model.login.passwordRevisionDate = new Date();
model.passwordHistory.splice(0, 0, ph);
} else {
model.login.passwordRevisionDate = existingCipher.login.passwordRevisionDate;
}
}
if (existingCipher.hasFields) {
const existingHiddenFields = existingCipher.fields.filter(
(f) =>
f.type === FieldType.Hidden &&
f.name != null &&
f.name !== "" &&
f.value != null &&
f.value !== ""
);
const hiddenFields =
model.fields == null
? []
: model.fields.filter(
(f) => f.type === FieldType.Hidden && f.name != null && f.name !== ""
);
existingHiddenFields.forEach((ef) => {
const matchedField = hiddenFields.find((f) => f.name === ef.name);
if (matchedField == null || matchedField.value !== ef.value) {
const ph = new PasswordHistoryView();
ph.password = ef.name + ": " + ef.value;
ph.lastUsedDate = new Date();
model.passwordHistory.splice(0, 0, ph);
}
});
}
}
if (model.passwordHistory != null && model.passwordHistory.length === 0) {
model.passwordHistory = null;
} else if (model.passwordHistory != null && model.passwordHistory.length > 5) {
// only save last 5 history
model.passwordHistory = model.passwordHistory.slice(0, 5);
await this.updateModelfromExistingCipher(model, originalCipher);
}
this.adjustPasswordHistoryLength(model);
}
const cipher = new Cipher();
@@ -155,35 +118,32 @@ export class CipherService implements CipherServiceAbstraction {
cipher.reprompt = model.reprompt;
cipher.edit = model.edit;
if (key == null && cipher.organizationId != null) {
key = await this.cryptoService.getOrgKey(cipher.organizationId);
if (key == null) {
throw new Error("Cannot encrypt cipher for organization. No key.");
}
}
await Promise.all([
this.encryptObjProperty(
if (await this.getCipherKeyEncryptionEnabled()) {
cipher.key = originalCipher?.key ?? null;
const userOrOrgKey = await this.getKeyForCipherKeyDecryption(cipher);
// The keyForEncryption is only used for encrypting the cipher key, not the cipher itself, since cipher key encryption is enabled.
// If the caller has provided a key for cipher key encryption, use it. Otherwise, use the user or org key.
keyForEncryption ||= userOrOrgKey;
// If the caller has provided a key for cipher key decryption, use it. Otherwise, use the user or org key.
keyForCipherKeyDecryption ||= userOrOrgKey;
return this.encryptCipherWithCipherKey(
model,
cipher,
{
name: null,
notes: null,
},
key
),
this.encryptCipherData(cipher, model, key),
this.encryptFields(model.fields, key).then((fields) => {
cipher.fields = fields;
}),
this.encryptPasswordHistories(model.passwordHistory, key).then((ph) => {
cipher.passwordHistory = ph;
}),
this.encryptAttachments(model.attachments, key).then((attachments) => {
cipher.attachments = attachments;
}),
]);
return cipher;
keyForEncryption,
keyForCipherKeyDecryption
);
} else {
if (keyForEncryption == null && cipher.organizationId != null) {
keyForEncryption = await this.cryptoService.getOrgKey(cipher.organizationId);
if (keyForEncryption == null) {
throw new Error("Cannot encrypt cipher for organization. No key.");
}
}
// We want to ensure that the cipher key is null if cipher key encryption is disabled
// so that decryption uses the proper key.
cipher.key = null;
return this.encryptCipher(model, cipher, keyForEncryption);
}
}
async encryptAttachments(
@@ -579,7 +539,7 @@ export class CipherService implements CipherServiceAbstraction {
cipher.organizationId = organizationId;
cipher.collectionIds = collectionIds;
const encCipher = await this.encrypt(cipher);
const encCipher = await this.encryptSharedCipher(cipher);
const request = new CipherShareRequest(encCipher);
const response = await this.apiService.putShareCipher(cipher.id, request);
const data = new CipherData(response, collectionIds);
@@ -597,7 +557,7 @@ export class CipherService implements CipherServiceAbstraction {
cipher.organizationId = organizationId;
cipher.collectionIds = collectionIds;
promises.push(
this.encrypt(cipher).then((c) => {
this.encryptSharedCipher(cipher).then((c) => {
encCiphers.push(c);
})
);
@@ -645,14 +605,29 @@ export class CipherService implements CipherServiceAbstraction {
data: Uint8Array,
admin = false
): Promise<Cipher> {
let encKey: UserKey | OrgKey;
encKey = await this.cryptoService.getOrgKey(cipher.organizationId);
encKey ||= await this.cryptoService.getUserKeyWithLegacySupport();
const encKey = await this.getKeyForCipherKeyDecryption(cipher);
const cipherKeyEncryptionEnabled = await this.getCipherKeyEncryptionEnabled();
const dataEncKey = await this.cryptoService.makeDataEncKey(encKey);
const cipherEncKey =
cipherKeyEncryptionEnabled && cipher.key != null
? (new SymmetricCryptoKey(
await this.encryptService.decryptToBytes(cipher.key, encKey)
) as UserKey)
: encKey;
const encFileName = await this.encryptService.encrypt(filename, encKey);
const encData = await this.encryptService.encryptToBytes(data, dataEncKey[0]);
//if cipher key encryption is disabled but the item has an individual key,
//then we rollback to using the user key as the main key of encryption of the item
//in order to keep item and it's attachments with the same encryption level
if (cipher.key != null && !cipherKeyEncryptionEnabled) {
const model = await cipher.decrypt(await this.getKeyForCipherKeyDecryption(cipher));
cipher = await this.encrypt(model);
await this.updateWithServer(cipher);
}
const encFileName = await this.encryptService.encrypt(filename, cipherEncKey);
const dataEncKey = await this.cryptoService.makeDataEncKey(cipherEncKey);
const encData = await this.encryptService.encryptToBytes(new Uint8Array(data), dataEncKey[0]);
const response = await this.cipherFileUploadService.upload(
cipher,
@@ -963,8 +938,80 @@ export class CipherService implements CipherServiceAbstraction {
await this.restore(restores);
}
async getKeyForCipherKeyDecryption(cipher: Cipher): Promise<UserKey | OrgKey> {
return (
(await this.cryptoService.getOrgKey(cipher.organizationId)) ||
((await this.cryptoService.getUserKeyWithLegacySupport()) as UserKey)
);
}
// Helpers
// In the case of a cipher that is being shared with an organization, we want to decrypt the
// cipher key with the user's key and then re-encrypt it with the organization's key.
private async encryptSharedCipher(model: CipherView): Promise<Cipher> {
const keyForCipherKeyDecryption = await this.cryptoService.getUserKeyWithLegacySupport();
return await this.encrypt(model, null, keyForCipherKeyDecryption);
}
private async updateModelfromExistingCipher(
model: CipherView,
originalCipher: Cipher
): Promise<void> {
const existingCipher = await originalCipher.decrypt(
await this.getKeyForCipherKeyDecryption(originalCipher)
);
model.passwordHistory = existingCipher.passwordHistory || [];
if (model.type === CipherType.Login && existingCipher.type === CipherType.Login) {
if (
existingCipher.login.password != null &&
existingCipher.login.password !== "" &&
existingCipher.login.password !== model.login.password
) {
const ph = new PasswordHistoryView();
ph.password = existingCipher.login.password;
ph.lastUsedDate = model.login.passwordRevisionDate = new Date();
model.passwordHistory.splice(0, 0, ph);
} else {
model.login.passwordRevisionDate = existingCipher.login.passwordRevisionDate;
}
}
if (existingCipher.hasFields) {
const existingHiddenFields = existingCipher.fields.filter(
(f) =>
f.type === FieldType.Hidden &&
f.name != null &&
f.name !== "" &&
f.value != null &&
f.value !== ""
);
const hiddenFields =
model.fields == null
? []
: model.fields.filter(
(f) => f.type === FieldType.Hidden && f.name != null && f.name !== ""
);
existingHiddenFields.forEach((ef) => {
const matchedField = hiddenFields.find((f) => f.name === ef.name);
if (matchedField == null || matchedField.value !== ef.value) {
const ph = new PasswordHistoryView();
ph.password = ef.name + ": " + ef.value;
ph.lastUsedDate = new Date();
model.passwordHistory.splice(0, 0, ph);
}
});
}
}
private adjustPasswordHistoryLength(model: CipherView) {
if (model.passwordHistory != null && model.passwordHistory.length === 0) {
model.passwordHistory = null;
} else if (model.passwordHistory != null && model.passwordHistory.length > 5) {
// only save last 5 history
model.passwordHistory = model.passwordHistory.slice(0, 5);
}
}
private async shareAttachmentWithServer(
attachmentView: AttachmentView,
cipherId: string,
@@ -1193,4 +1240,69 @@ export class CipherService implements CipherServiceAbstraction {
private clearSortedCiphers() {
this.sortedCiphersCache.clear();
}
private async encryptCipher(
model: CipherView,
cipher: Cipher,
key: SymmetricCryptoKey
): Promise<Cipher> {
await Promise.all([
this.encryptObjProperty(
model,
cipher,
{
name: null,
notes: null,
},
key
),
this.encryptCipherData(cipher, model, key),
this.encryptFields(model.fields, key).then((fields) => {
cipher.fields = fields;
}),
this.encryptPasswordHistories(model.passwordHistory, key).then((ph) => {
cipher.passwordHistory = ph;
}),
this.encryptAttachments(model.attachments, key).then((attachments) => {
cipher.attachments = attachments;
}),
]);
return cipher;
}
private async encryptCipherWithCipherKey(
model: CipherView,
cipher: Cipher,
keyForCipherKeyEncryption: SymmetricCryptoKey,
keyForCipherKeyDecryption: SymmetricCryptoKey
): Promise<Cipher> {
// First, we get the key for cipher key encryption, in its decrypted form
let decryptedCipherKey: SymmetricCryptoKey;
if (cipher.key == null) {
decryptedCipherKey = await this.cryptoService.makeCipherKey();
} else {
decryptedCipherKey = new SymmetricCryptoKey(
await this.encryptService.decryptToBytes(cipher.key, keyForCipherKeyDecryption)
);
}
// Then, we have to encrypt the cipher key with the proper key.
cipher.key = await this.encryptService.encrypt(
decryptedCipherKey.key,
keyForCipherKeyEncryption
);
// Finally, we can encrypt the cipher with the decrypted cipher key.
return this.encryptCipher(model, cipher, decryptedCipherKey);
}
private async getCipherKeyEncryptionEnabled(): Promise<boolean> {
return (
flagEnabled("enableCipherKeyEncryption") &&
(await firstValueFrom(
this.configService.checkServerMeetsVersionRequirement$(CIPHER_KEY_ENC_MIN_SERVER_VER)
))
);
}
}