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:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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$
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export type SharedFlags = {
|
||||
multithreadDecryption: boolean;
|
||||
showPasswordless?: boolean;
|
||||
enableCipherKeyEncryption?: boolean;
|
||||
};
|
||||
|
||||
// required to avoid linting errors when there are no flags
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class FastmailForwarderOptions {
|
||||
|
||||
export class AnonAddyForwarderOptions {
|
||||
domain: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export class ForwardEmailForwarderOptions {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type UsernameGeneratorOptions = {
|
||||
forwardedService?: string;
|
||||
forwardedAnonAddyApiToken?: string;
|
||||
forwardedAnonAddyDomain?: string;
|
||||
forwardedAnonAddyBaseUrl?: string;
|
||||
forwardedDuckDuckGoToken?: string;
|
||||
forwardedFirefoxApiToken?: string;
|
||||
forwardedFastmailApiToken?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user