mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 16:23:44 +00:00
Platform/pm 19/platform team file moves (#5460)
* Rename service-factory folder * Move cryptographic service factories * Move crypto models * Move crypto services * Move domain base class * Platform code owners * Move desktop log services * Move log files * Establish component library ownership * Move background listeners * Move background background * Move localization to Platform * Move browser alarms to Platform * Move browser state to Platform * Move CLI state to Platform * Move Desktop native concerns to Platform * Move flag and misc to Platform * Lint fixes * Move electron state to platform * Move web state to Platform * Move lib state to Platform * Fix broken tests * Rename interface to idiomatic TS * `npm run prettier` 🤖 * Resolve review feedback * Set platform as owners of web core and shared * Expand moved services * Fix test types --------- Co-authored-by: Hinton <hinton@users.noreply.github.com>
This commit is contained in:
4
libs/common/src/platform/abstractions/app-id.service.ts
Normal file
4
libs/common/src/platform/abstractions/app-id.service.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export abstract class AppIdService {
|
||||
getAppId: () => Promise<string>;
|
||||
getAnonymousAppId: () => Promise<string>;
|
||||
}
|
||||
21
libs/common/src/platform/abstractions/broadcaster.service.ts
Normal file
21
libs/common/src/platform/abstractions/broadcaster.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface MessageBase {
|
||||
command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the observable from the appropriate service instead.
|
||||
*/
|
||||
export abstract class BroadcasterService {
|
||||
/**
|
||||
* @deprecated Use the observable from the appropriate service instead.
|
||||
*/
|
||||
send: (message: MessageBase, id?: string) => void;
|
||||
/**
|
||||
* @deprecated Use the observable from the appropriate service instead.
|
||||
*/
|
||||
subscribe: (id: string, messageCallback: (message: MessageBase) => void) => void;
|
||||
/**
|
||||
* @deprecated Use the observable from the appropriate service instead.
|
||||
*/
|
||||
unsubscribe: (id: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ServerConfigResponse } from "../../models/response/server-config.response";
|
||||
|
||||
export abstract class ConfigApiServiceAbstraction {
|
||||
get: () => Promise<ServerConfigResponse>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
|
||||
import { ServerConfig } from "./server-config";
|
||||
|
||||
export abstract class ConfigServiceAbstraction {
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
fetchServerConfig: () => Promise<ServerConfig>;
|
||||
getFeatureFlagBool: (key: FeatureFlag, defaultValue?: boolean) => Promise<boolean>;
|
||||
getFeatureFlagString: (key: FeatureFlag, defaultValue?: string) => Promise<string>;
|
||||
getFeatureFlagNumber: (key: FeatureFlag, defaultValue?: number) => Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
ServerConfigData,
|
||||
ThirdPartyServerConfigData,
|
||||
EnvironmentServerConfigData,
|
||||
} from "../../models/data/server-config.data";
|
||||
|
||||
const dayInMilliseconds = 24 * 3600 * 1000;
|
||||
const eighteenHoursInMilliseconds = 18 * 3600 * 1000;
|
||||
|
||||
export class ServerConfig {
|
||||
version: string;
|
||||
gitHash: string;
|
||||
server?: ThirdPartyServerConfigData;
|
||||
environment?: EnvironmentServerConfigData;
|
||||
utcDate: Date;
|
||||
featureStates: { [key: string]: string } = {};
|
||||
|
||||
constructor(serverConfigData: ServerConfigData) {
|
||||
this.version = serverConfigData.version;
|
||||
this.gitHash = serverConfigData.gitHash;
|
||||
this.server = serverConfigData.server;
|
||||
this.utcDate = new Date(serverConfigData.utcDate);
|
||||
this.environment = serverConfigData.environment;
|
||||
this.featureStates = serverConfigData.featureStates;
|
||||
|
||||
if (this.server?.name == null && this.server?.url == null) {
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getAgeInMilliseconds(): number {
|
||||
return new Date().getTime() - this.utcDate?.getTime();
|
||||
}
|
||||
|
||||
isValid(): boolean {
|
||||
return this.getAgeInMilliseconds() <= dayInMilliseconds;
|
||||
}
|
||||
|
||||
expiresSoon(): boolean {
|
||||
return this.getAgeInMilliseconds() >= eighteenHoursInMilliseconds;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<ServerConfig>): ServerConfig {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ServerConfig(obj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { DecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class CryptoFunctionService {
|
||||
pbkdf2: (
|
||||
password: string | ArrayBuffer,
|
||||
salt: string | ArrayBuffer,
|
||||
algorithm: "sha256" | "sha512",
|
||||
iterations: number
|
||||
) => Promise<ArrayBuffer>;
|
||||
argon2: (
|
||||
password: string | ArrayBuffer,
|
||||
salt: string | ArrayBuffer,
|
||||
iterations: number,
|
||||
memory: number,
|
||||
parallelism: number
|
||||
) => Promise<ArrayBuffer>;
|
||||
hkdf: (
|
||||
ikm: ArrayBuffer,
|
||||
salt: string | ArrayBuffer,
|
||||
info: string | ArrayBuffer,
|
||||
outputByteSize: number,
|
||||
algorithm: "sha256" | "sha512"
|
||||
) => Promise<ArrayBuffer>;
|
||||
hkdfExpand: (
|
||||
prk: ArrayBuffer,
|
||||
info: string | ArrayBuffer,
|
||||
outputByteSize: number,
|
||||
algorithm: "sha256" | "sha512"
|
||||
) => Promise<ArrayBuffer>;
|
||||
hash: (
|
||||
value: string | ArrayBuffer,
|
||||
algorithm: "sha1" | "sha256" | "sha512" | "md5"
|
||||
) => Promise<ArrayBuffer>;
|
||||
hmac: (
|
||||
value: ArrayBuffer,
|
||||
key: ArrayBuffer,
|
||||
algorithm: "sha1" | "sha256" | "sha512"
|
||||
) => Promise<ArrayBuffer>;
|
||||
compare: (a: ArrayBuffer, b: ArrayBuffer) => Promise<boolean>;
|
||||
hmacFast: (
|
||||
value: ArrayBuffer | string,
|
||||
key: ArrayBuffer | string,
|
||||
algorithm: "sha1" | "sha256" | "sha512"
|
||||
) => Promise<ArrayBuffer | string>;
|
||||
compareFast: (a: ArrayBuffer | string, b: ArrayBuffer | string) => Promise<boolean>;
|
||||
aesEncrypt: (data: ArrayBuffer, iv: ArrayBuffer, key: ArrayBuffer) => Promise<ArrayBuffer>;
|
||||
aesDecryptFastParameters: (
|
||||
data: string,
|
||||
iv: string,
|
||||
mac: string,
|
||||
key: SymmetricCryptoKey
|
||||
) => DecryptParameters<ArrayBuffer | string>;
|
||||
aesDecryptFast: (parameters: DecryptParameters<ArrayBuffer | string>) => Promise<string>;
|
||||
aesDecrypt: (data: ArrayBuffer, iv: ArrayBuffer, key: ArrayBuffer) => Promise<ArrayBuffer>;
|
||||
rsaEncrypt: (
|
||||
data: ArrayBuffer,
|
||||
publicKey: ArrayBuffer,
|
||||
algorithm: "sha1" | "sha256"
|
||||
) => Promise<ArrayBuffer>;
|
||||
rsaDecrypt: (
|
||||
data: ArrayBuffer,
|
||||
privateKey: ArrayBuffer,
|
||||
algorithm: "sha1" | "sha256"
|
||||
) => Promise<ArrayBuffer>;
|
||||
rsaExtractPublicKey: (privateKey: ArrayBuffer) => Promise<ArrayBuffer>;
|
||||
rsaGenerateKeyPair: (length: 1024 | 2048 | 4096) => Promise<[ArrayBuffer, ArrayBuffer]>;
|
||||
randomBytes: (length: number) => Promise<CsprngArray>;
|
||||
}
|
||||
86
libs/common/src/platform/abstractions/crypto.service.ts
Normal file
86
libs/common/src/platform/abstractions/crypto.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
|
||||
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
|
||||
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
|
||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||
import { KeySuffixOptions, KdfType, HashPurpose } from "../../enums";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class CryptoService {
|
||||
setKey: (key: SymmetricCryptoKey) => Promise<any>;
|
||||
setKeyHash: (keyHash: string) => Promise<void>;
|
||||
setEncKey: (encKey: string) => Promise<void>;
|
||||
setEncPrivateKey: (encPrivateKey: string) => Promise<void>;
|
||||
setOrgKeys: (
|
||||
orgs: ProfileOrganizationResponse[],
|
||||
providerOrgs: ProfileProviderOrganizationResponse[]
|
||||
) => Promise<void>;
|
||||
setProviderKeys: (orgs: ProfileProviderResponse[]) => Promise<void>;
|
||||
getKey: (keySuffix?: KeySuffixOptions, userId?: string) => Promise<SymmetricCryptoKey>;
|
||||
getKeyFromStorage: (keySuffix: KeySuffixOptions, userId?: string) => Promise<SymmetricCryptoKey>;
|
||||
getKeyHash: () => Promise<string>;
|
||||
compareAndUpdateKeyHash: (masterPassword: string, key: SymmetricCryptoKey) => Promise<boolean>;
|
||||
getEncKey: (key?: SymmetricCryptoKey) => Promise<SymmetricCryptoKey>;
|
||||
getPublicKey: () => Promise<ArrayBuffer>;
|
||||
getPrivateKey: () => Promise<ArrayBuffer>;
|
||||
getFingerprint: (userId: string, publicKey?: ArrayBuffer) => Promise<string[]>;
|
||||
getOrgKeys: () => Promise<Map<string, SymmetricCryptoKey>>;
|
||||
getOrgKey: (orgId: string) => Promise<SymmetricCryptoKey>;
|
||||
getProviderKey: (providerId: string) => Promise<SymmetricCryptoKey>;
|
||||
getKeyForUserEncryption: (key?: SymmetricCryptoKey) => Promise<SymmetricCryptoKey>;
|
||||
hasKey: () => Promise<boolean>;
|
||||
hasKeyInMemory: (userId?: string) => Promise<boolean>;
|
||||
hasKeyStored: (keySuffix?: KeySuffixOptions, userId?: string) => Promise<boolean>;
|
||||
hasEncKey: () => Promise<boolean>;
|
||||
clearKey: (clearSecretStorage?: boolean, userId?: string) => Promise<any>;
|
||||
clearKeyHash: () => Promise<any>;
|
||||
clearEncKey: (memoryOnly?: boolean, userId?: string) => Promise<any>;
|
||||
clearKeyPair: (memoryOnly?: boolean, userId?: string) => Promise<any>;
|
||||
clearOrgKeys: (memoryOnly?: boolean, userId?: string) => Promise<any>;
|
||||
clearProviderKeys: (memoryOnly?: boolean) => Promise<any>;
|
||||
clearPinProtectedKey: () => Promise<any>;
|
||||
clearKeys: (userId?: string) => Promise<any>;
|
||||
toggleKey: () => Promise<any>;
|
||||
makeKey: (
|
||||
password: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig
|
||||
) => Promise<SymmetricCryptoKey>;
|
||||
makeKeyFromPin: (
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig,
|
||||
protectedKeyCs?: EncString
|
||||
) => Promise<SymmetricCryptoKey>;
|
||||
makeShareKey: () => Promise<[EncString, SymmetricCryptoKey]>;
|
||||
makeKeyPair: (key?: SymmetricCryptoKey) => Promise<[string, EncString]>;
|
||||
makePinKey: (
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig
|
||||
) => Promise<SymmetricCryptoKey>;
|
||||
makeSendKey: (keyMaterial: ArrayBuffer) => Promise<SymmetricCryptoKey>;
|
||||
hashPassword: (
|
||||
password: string,
|
||||
key: SymmetricCryptoKey,
|
||||
hashPurpose?: HashPurpose
|
||||
) => Promise<string>;
|
||||
makeEncKey: (key: SymmetricCryptoKey) => Promise<[SymmetricCryptoKey, EncString]>;
|
||||
remakeEncKey: (
|
||||
key: SymmetricCryptoKey,
|
||||
encKey?: SymmetricCryptoKey
|
||||
) => Promise<[SymmetricCryptoKey, EncString]>;
|
||||
encrypt: (plainValue: string | ArrayBuffer, key?: SymmetricCryptoKey) => Promise<EncString>;
|
||||
encryptToBytes: (plainValue: ArrayBuffer, key?: SymmetricCryptoKey) => Promise<EncArrayBuffer>;
|
||||
rsaEncrypt: (data: ArrayBuffer, publicKey?: ArrayBuffer) => Promise<EncString>;
|
||||
rsaDecrypt: (encValue: string, privateKeyValue?: ArrayBuffer) => Promise<ArrayBuffer>;
|
||||
decryptToBytes: (encString: EncString, key?: SymmetricCryptoKey) => Promise<ArrayBuffer>;
|
||||
decryptToUtf8: (encString: EncString, key?: SymmetricCryptoKey) => Promise<string>;
|
||||
decryptFromBytes: (encBuffer: EncArrayBuffer, key: SymmetricCryptoKey) => Promise<ArrayBuffer>;
|
||||
randomNumber: (min: number, max: number) => Promise<number>;
|
||||
validateKey: (key: SymmetricCryptoKey) => Promise<boolean>;
|
||||
}
|
||||
21
libs/common/src/platform/abstractions/encrypt.service.ts
Normal file
21
libs/common/src/platform/abstractions/encrypt.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Decryptable } from "../interfaces/decryptable.interface";
|
||||
import { Encrypted } from "../interfaces/encrypted";
|
||||
import { InitializerMetadata } from "../interfaces/initializer-metadata.interface";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class EncryptService {
|
||||
abstract encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise<EncString>;
|
||||
abstract encryptToBytes: (
|
||||
plainValue: ArrayBuffer,
|
||||
key?: SymmetricCryptoKey
|
||||
) => Promise<EncArrayBuffer>;
|
||||
abstract decryptToUtf8: (encString: EncString, key: SymmetricCryptoKey) => Promise<string>;
|
||||
abstract decryptToBytes: (encThing: Encrypted, key: SymmetricCryptoKey) => Promise<ArrayBuffer>;
|
||||
abstract resolveLegacyKey: (key: SymmetricCryptoKey, encThing: Encrypted) => SymmetricCryptoKey;
|
||||
abstract decryptItems: <T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey
|
||||
) => Promise<T[]>;
|
||||
}
|
||||
42
libs/common/src/platform/abstractions/environment.service.ts
Normal file
42
libs/common/src/platform/abstractions/environment.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export type Urls = {
|
||||
base?: string;
|
||||
webVault?: string;
|
||||
api?: string;
|
||||
identity?: string;
|
||||
icons?: string;
|
||||
notifications?: string;
|
||||
events?: string;
|
||||
keyConnector?: string;
|
||||
scim?: string;
|
||||
};
|
||||
|
||||
export type PayPalConfig = {
|
||||
businessId?: string;
|
||||
buttonAction?: string;
|
||||
};
|
||||
|
||||
export abstract class EnvironmentService {
|
||||
urls: Observable<Urls>;
|
||||
|
||||
hasBaseUrl: () => boolean;
|
||||
getNotificationsUrl: () => string;
|
||||
getWebVaultUrl: () => string;
|
||||
getSendUrl: () => string;
|
||||
getIconsUrl: () => string;
|
||||
getApiUrl: () => string;
|
||||
getIdentityUrl: () => string;
|
||||
getEventsUrl: () => string;
|
||||
getKeyConnectorUrl: () => string;
|
||||
getScimUrl: () => string;
|
||||
setUrlsFromStorage: () => Promise<void>;
|
||||
setUrls: (urls: Urls) => Promise<Urls>;
|
||||
getUrls: () => Urls;
|
||||
isCloud: () => boolean;
|
||||
/**
|
||||
* @remarks For desktop and browser use only.
|
||||
* For web, use PlatformUtilsService.isSelfHost()
|
||||
*/
|
||||
isSelfHosted: () => boolean;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FileDownloadRequest } from "./file-download.request";
|
||||
|
||||
export class FileDownloadBuilder {
|
||||
get blobOptions(): any {
|
||||
const options = this._request.blobOptions ?? {};
|
||||
if (options.type == null) {
|
||||
options.type = this.fileType;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
get blob(): Blob {
|
||||
if (this.blobOptions != null) {
|
||||
return new Blob([this._request.blobData], this.blobOptions);
|
||||
} else {
|
||||
return new Blob([this._request.blobData]);
|
||||
}
|
||||
}
|
||||
|
||||
get downloadMethod(): "save" | "open" {
|
||||
if (this._request.downloadMethod != null) {
|
||||
return this._request.downloadMethod;
|
||||
}
|
||||
return this.fileType != "application/pdf" ? "save" : "open";
|
||||
}
|
||||
|
||||
private get fileType() {
|
||||
const fileNameLower = this._request.fileName.toLowerCase();
|
||||
if (fileNameLower.endsWith(".pdf")) {
|
||||
return "application/pdf";
|
||||
} else if (fileNameLower.endsWith(".xlsx")) {
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
} else if (fileNameLower.endsWith(".docx")) {
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
} else if (fileNameLower.endsWith(".pptx")) {
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
} else if (fileNameLower.endsWith(".csv")) {
|
||||
return "text/csv";
|
||||
} else if (fileNameLower.endsWith(".png")) {
|
||||
return "image/png";
|
||||
} else if (fileNameLower.endsWith(".jpg") || fileNameLower.endsWith(".jpeg")) {
|
||||
return "image/jpeg";
|
||||
} else if (fileNameLower.endsWith(".gif")) {
|
||||
return "image/gif";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor(private readonly _request: FileDownloadRequest) {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type FileDownloadRequest = {
|
||||
fileName: string;
|
||||
blobData: BlobPart;
|
||||
blobOptions?: BlobPropertyBag;
|
||||
downloadMethod?: "save" | "open";
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FileDownloadRequest } from "./file-download.request";
|
||||
|
||||
export abstract class FileDownloadService {
|
||||
download: (request: FileDownloadRequest) => void;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FileUploadType } from "../../../enums";
|
||||
import { EncArrayBuffer } from "../../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../models/domain/enc-string";
|
||||
|
||||
export abstract class FileUploadService {
|
||||
upload: (
|
||||
uploadData: { url: string; fileUploadType: FileUploadType },
|
||||
fileName: EncString,
|
||||
encryptedFileData: EncArrayBuffer,
|
||||
fileUploadMethods: FileUploadApiMethods
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export type FileUploadApiMethods = {
|
||||
postDirect: (fileData: FormData) => Promise<void>;
|
||||
renewFileUploadUrl: () => Promise<string>;
|
||||
rollback: () => Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AbstractControl } from "@angular/forms";
|
||||
export interface AllValidationErrors {
|
||||
controlName: string;
|
||||
errorName: string;
|
||||
}
|
||||
|
||||
export interface FormGroupControls {
|
||||
[key: string]: AbstractControl;
|
||||
}
|
||||
|
||||
export abstract class FormValidationErrorsService {
|
||||
getFormValidationErrors: (controls: FormGroupControls) => AllValidationErrors[];
|
||||
}
|
||||
7
libs/common/src/platform/abstractions/i18n.service.ts
Normal file
7
libs/common/src/platform/abstractions/i18n.service.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { TranslationService } from "./translation.service";
|
||||
|
||||
export abstract class I18nService extends TranslationService {
|
||||
locale$: Observable<string>;
|
||||
}
|
||||
9
libs/common/src/platform/abstractions/log.service.ts
Normal file
9
libs/common/src/platform/abstractions/log.service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { LogLevelType } from "../../enums";
|
||||
|
||||
export abstract class LogService {
|
||||
debug: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
write: (level: LogLevelType, message: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export abstract class MessagingService {
|
||||
send: (subscriber: string, arg?: any) => void;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ClientType, DeviceType } from "../../enums";
|
||||
|
||||
interface ToastOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export abstract class PlatformUtilsService {
|
||||
getDevice: () => DeviceType;
|
||||
getDeviceString: () => string;
|
||||
getClientType: () => ClientType;
|
||||
isFirefox: () => boolean;
|
||||
isChrome: () => boolean;
|
||||
isEdge: () => boolean;
|
||||
isOpera: () => boolean;
|
||||
isVivaldi: () => boolean;
|
||||
isSafari: () => boolean;
|
||||
isMacAppStore: () => boolean;
|
||||
isViewOpen: () => Promise<boolean>;
|
||||
launchUri: (uri: string, options?: any) => void;
|
||||
getApplicationVersion: () => Promise<string>;
|
||||
getApplicationVersionNumber: () => Promise<string>;
|
||||
supportsWebAuthn: (win: Window) => boolean;
|
||||
supportsDuo: () => boolean;
|
||||
showToast: (
|
||||
type: "error" | "success" | "warning" | "info",
|
||||
title: string,
|
||||
text: string | string[],
|
||||
options?: ToastOptions
|
||||
) => void;
|
||||
isDev: () => boolean;
|
||||
isSelfHost: () => boolean;
|
||||
copyToClipboard: (text: string, options?: any) => void | boolean;
|
||||
readFromClipboard: (options?: any) => Promise<string>;
|
||||
supportsBiometric: () => Promise<boolean>;
|
||||
authenticateBiometric: () => Promise<boolean>;
|
||||
supportsSecureStorage: () => boolean;
|
||||
getAutofillKeyboardShortcut: () => Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export abstract class StateMigrationService {
|
||||
needsMigration: () => Promise<boolean>;
|
||||
migrate: () => Promise<void>;
|
||||
}
|
||||
396
libs/common/src/platform/abstractions/state.service.ts
Normal file
396
libs/common/src/platform/abstractions/state.service.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { CollectionData } from "../../admin-console/models/data/collection.data";
|
||||
import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data";
|
||||
import { OrganizationData } from "../../admin-console/models/data/organization.data";
|
||||
import { PolicyData } from "../../admin-console/models/data/policy.data";
|
||||
import { ProviderData } from "../../admin-console/models/data/provider.data";
|
||||
import { Policy } from "../../admin-console/models/domain/policy";
|
||||
import { CollectionView } from "../../admin-console/models/view/collection.view";
|
||||
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
|
||||
import { ForceResetPasswordReason } from "../../auth/models/domain/force-reset-password-reason";
|
||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||
import { BiometricKey } from "../../auth/types/biometric-key";
|
||||
import { KdfType, ThemeType, UriMatchType } from "../../enums";
|
||||
import { EventData } from "../../models/data/event.data";
|
||||
import { WindowState } from "../../models/domain/window-state";
|
||||
import { GeneratedPasswordHistory } from "../../tools/generator/password";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
import { FolderData } from "../../vault/models/data/folder.data";
|
||||
import { LocalData } from "../../vault/models/data/local.data";
|
||||
import { CipherView } from "../../vault/models/view/cipher.view";
|
||||
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
|
||||
import { ServerConfigData } from "../models/data/server-config.data";
|
||||
import { Account, AccountSettingsSettings } from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { StorageOptions } from "../models/domain/storage-options";
|
||||
import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class StateService<T extends Account = Account> {
|
||||
accounts$: Observable<{ [userId: string]: T }>;
|
||||
activeAccount$: Observable<string>;
|
||||
activeAccountUnlocked$: Observable<boolean>;
|
||||
|
||||
addAccount: (account: T) => Promise<void>;
|
||||
setActiveUser: (userId: string) => Promise<void>;
|
||||
clean: (options?: StorageOptions) => Promise<void>;
|
||||
init: () => Promise<void>;
|
||||
|
||||
getAccessToken: (options?: StorageOptions) => Promise<string>;
|
||||
setAccessToken: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getAddEditCipherInfo: (options?: StorageOptions) => Promise<AddEditCipherInfo>;
|
||||
setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise<void>;
|
||||
getAlwaysShowDock: (options?: StorageOptions) => Promise<boolean>;
|
||||
setAlwaysShowDock: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getApiKeyClientId: (options?: StorageOptions) => Promise<string>;
|
||||
setApiKeyClientId: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getApiKeyClientSecret: (options?: StorageOptions) => Promise<string>;
|
||||
setApiKeyClientSecret: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getAutoConfirmFingerPrints: (options?: StorageOptions) => Promise<boolean>;
|
||||
setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getAutoFillOnPageLoadDefault: (options?: StorageOptions) => Promise<boolean>;
|
||||
setAutoFillOnPageLoadDefault: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getBiometricAwaitingAcceptance: (options?: StorageOptions) => Promise<boolean>;
|
||||
setBiometricAwaitingAcceptance: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
|
||||
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getBiometricText: (options?: StorageOptions) => Promise<string>;
|
||||
setBiometricText: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getBiometricUnlock: (options?: StorageOptions) => Promise<boolean>;
|
||||
setBiometricUnlock: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getCanAccessPremium: (options?: StorageOptions) => Promise<boolean>;
|
||||
getHasPremiumPersonally: (options?: StorageOptions) => Promise<boolean>;
|
||||
setHasPremiumPersonally: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
setHasPremiumFromOrganization: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getHasPremiumFromOrganization: (options?: StorageOptions) => Promise<boolean>;
|
||||
getClearClipboard: (options?: StorageOptions) => Promise<number>;
|
||||
setClearClipboard: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getCollapsedGroupings: (options?: StorageOptions) => Promise<string[]>;
|
||||
setCollapsedGroupings: (value: string[], options?: StorageOptions) => Promise<void>;
|
||||
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
|
||||
setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getCryptoMasterKey: (options?: StorageOptions) => Promise<SymmetricCryptoKey>;
|
||||
setCryptoMasterKey: (value: SymmetricCryptoKey, options?: StorageOptions) => Promise<void>;
|
||||
getCryptoMasterKeyAuto: (options?: StorageOptions) => Promise<string>;
|
||||
setCryptoMasterKeyAuto: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getCryptoMasterKeyB64: (options?: StorageOptions) => Promise<string>;
|
||||
setCryptoMasterKeyB64: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<string>;
|
||||
hasCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<boolean>;
|
||||
setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
|
||||
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCollections: (options?: StorageOptions) => Promise<CollectionView[]>;
|
||||
setDecryptedCollections: (value: CollectionView[], options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<SymmetricCryptoKey>;
|
||||
setDecryptedCryptoSymmetricKey: (
|
||||
value: SymmetricCryptoKey,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getDecryptedOrganizationKeys: (
|
||||
options?: StorageOptions
|
||||
) => Promise<Map<string, SymmetricCryptoKey>>;
|
||||
setDecryptedOrganizationKeys: (
|
||||
value: Map<string, SymmetricCryptoKey>,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getDecryptedPasswordGenerationHistory: (
|
||||
options?: StorageOptions
|
||||
) => Promise<GeneratedPasswordHistory[]>;
|
||||
setDecryptedPasswordGenerationHistory: (
|
||||
value: GeneratedPasswordHistory[],
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getDecryptedPinProtected: (options?: StorageOptions) => Promise<EncString>;
|
||||
setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this, use PolicyService
|
||||
*/
|
||||
getDecryptedPolicies: (options?: StorageOptions) => Promise<Policy[]>;
|
||||
/**
|
||||
* @deprecated Do not call this, use PolicyService
|
||||
*/
|
||||
setDecryptedPolicies: (value: Policy[], options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedPrivateKey: (options?: StorageOptions) => Promise<ArrayBuffer>;
|
||||
setDecryptedPrivateKey: (value: ArrayBuffer, options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedProviderKeys: (options?: StorageOptions) => Promise<Map<string, SymmetricCryptoKey>>;
|
||||
setDecryptedProviderKeys: (
|
||||
value: Map<string, SymmetricCryptoKey>,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
getDecryptedSends: (options?: StorageOptions) => Promise<SendView[]>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
|
||||
getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>;
|
||||
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>;
|
||||
getDisableAddLoginNotification: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableAddLoginNotification: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDisableAutoBiometricsPrompt: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableAutoBiometricsPrompt: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDisableAutoTotpCopy: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableAutoTotpCopy: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDisableBadgeCounter: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableBadgeCounter: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDisableChangedPasswordNotification: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableChangedPasswordNotification: (
|
||||
value: boolean,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getDisableContextMenuItem: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableContextMenuItem: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this, use SettingsService
|
||||
*/
|
||||
getDisableFavicon: (options?: StorageOptions) => Promise<boolean>;
|
||||
/**
|
||||
* @deprecated Do not call this, use SettingsService
|
||||
*/
|
||||
setDisableFavicon: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDisableGa: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableGa: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDismissedAutofillCallout: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDismissedAutofillCallout: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDontShowCardsCurrentTab: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDontShowCardsCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDontShowIdentitiesCurrentTab: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDontShowIdentitiesCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
|
||||
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getDeviceKey: (options?: StorageOptions) => Promise<DeviceKey | null>;
|
||||
setDeviceKey: (value: DeviceKey, options?: StorageOptions) => Promise<void>;
|
||||
getEmail: (options?: StorageOptions) => Promise<string>;
|
||||
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableAlwaysOnTop: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableAutoFillOnPageLoad: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableAutoFillOnPageLoad: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableBrowserIntegrationFingerprint: (
|
||||
value: boolean,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEnableCloseToTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableCloseToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableDuckDuckGoBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableDuckDuckGoBrowserIntegration: (
|
||||
value: boolean,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEnableFullWidth: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableFullWidth: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableMinimizeToTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableMinimizeToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableStartToTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableStartToTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableTray: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableTray: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEncryptedCiphers: (options?: StorageOptions) => Promise<{ [id: string]: CipherData }>;
|
||||
setEncryptedCiphers: (
|
||||
value: { [id: string]: CipherData },
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEncryptedCollections: (options?: StorageOptions) => Promise<{ [id: string]: CollectionData }>;
|
||||
setEncryptedCollections: (
|
||||
value: { [id: string]: CollectionData },
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
|
||||
setEncryptedCryptoSymmetricKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use FolderService
|
||||
*/
|
||||
getEncryptedFolders: (options?: StorageOptions) => Promise<{ [id: string]: FolderData }>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use FolderService
|
||||
*/
|
||||
setEncryptedFolders: (
|
||||
value: { [id: string]: FolderData },
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEncryptedOrganizationKeys: (
|
||||
options?: StorageOptions
|
||||
) => Promise<{ [orgId: string]: EncryptedOrganizationKeyData }>;
|
||||
setEncryptedOrganizationKeys: (
|
||||
value: { [orgId: string]: EncryptedOrganizationKeyData },
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEncryptedPasswordGenerationHistory: (
|
||||
options?: StorageOptions
|
||||
) => Promise<GeneratedPasswordHistory[]>;
|
||||
setEncryptedPasswordGenerationHistory: (
|
||||
value: GeneratedPasswordHistory[],
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEncryptedPinProtected: (options?: StorageOptions) => Promise<string>;
|
||||
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use PolicyService
|
||||
*/
|
||||
getEncryptedPolicies: (options?: StorageOptions) => Promise<{ [id: string]: PolicyData }>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use PolicyService
|
||||
*/
|
||||
setEncryptedPolicies: (
|
||||
value: { [id: string]: PolicyData },
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getEncryptedPrivateKey: (options?: StorageOptions) => Promise<string>;
|
||||
setEncryptedPrivateKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEncryptedProviderKeys: (options?: StorageOptions) => Promise<any>;
|
||||
setEncryptedProviderKeys: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
getEncryptedSends: (options?: StorageOptions) => Promise<{ [id: string]: SendData }>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise<void>;
|
||||
getEntityId: (options?: StorageOptions) => Promise<string>;
|
||||
setEntityId: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEntityType: (options?: StorageOptions) => Promise<any>;
|
||||
setEntityType: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEnvironmentUrls: (options?: StorageOptions) => Promise<EnvironmentUrls>;
|
||||
setEnvironmentUrls: (value: EnvironmentUrls, options?: StorageOptions) => Promise<void>;
|
||||
getEquivalentDomains: (options?: StorageOptions) => Promise<string[][]>;
|
||||
setEquivalentDomains: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEventCollection: (options?: StorageOptions) => Promise<EventData[]>;
|
||||
setEventCollection: (value: EventData[], options?: StorageOptions) => Promise<void>;
|
||||
getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getForcePasswordResetReason: (options?: StorageOptions) => Promise<ForceResetPasswordReason>;
|
||||
setForcePasswordResetReason: (
|
||||
value: ForceResetPasswordReason,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getInstalledVersion: (options?: StorageOptions) => Promise<string>;
|
||||
setInstalledVersion: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
|
||||
getKdfConfig: (options?: StorageOptions) => Promise<KdfConfig>;
|
||||
setKdfConfig: (kdfConfig: KdfConfig, options?: StorageOptions) => Promise<void>;
|
||||
getKdfType: (options?: StorageOptions) => Promise<KdfType>;
|
||||
setKdfType: (value: KdfType, options?: StorageOptions) => Promise<void>;
|
||||
getKeyHash: (options?: StorageOptions) => Promise<string>;
|
||||
setKeyHash: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getLastActive: (options?: StorageOptions) => Promise<number>;
|
||||
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getLastSync: (options?: StorageOptions) => Promise<string>;
|
||||
setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getLocalData: (options?: StorageOptions) => Promise<{ [cipherId: string]: LocalData }>;
|
||||
setLocalData: (
|
||||
value: { [cipherId: string]: LocalData },
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getLocale: (options?: StorageOptions) => Promise<string>;
|
||||
setLocale: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getMainWindowSize: (options?: StorageOptions) => Promise<number>;
|
||||
setMainWindowSize: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
|
||||
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getNeverDomains: (options?: StorageOptions) => Promise<{ [id: string]: any }>;
|
||||
setNeverDomains: (value: { [id: string]: any }, options?: StorageOptions) => Promise<void>;
|
||||
getNoAutoPromptBiometricsText: (options?: StorageOptions) => Promise<string>;
|
||||
setNoAutoPromptBiometricsText: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>;
|
||||
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
|
||||
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
getEmergencyAccessInvitation: (options?: StorageOptions) => Promise<any>;
|
||||
setEmergencyAccessInvitation: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use OrganizationService
|
||||
*/
|
||||
getOrganizations: (options?: StorageOptions) => Promise<{ [id: string]: OrganizationData }>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use OrganizationService
|
||||
*/
|
||||
setOrganizations: (
|
||||
value: { [id: string]: OrganizationData },
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<any>;
|
||||
setPasswordGenerationOptions: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
getUsernameGenerationOptions: (options?: StorageOptions) => Promise<any>;
|
||||
setUsernameGenerationOptions: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
getGeneratorOptions: (options?: StorageOptions) => Promise<any>;
|
||||
setGeneratorOptions: (value: any, options?: StorageOptions) => Promise<void>;
|
||||
getProtectedPin: (options?: StorageOptions) => Promise<string>;
|
||||
setProtectedPin: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getProviders: (options?: StorageOptions) => Promise<{ [id: string]: ProviderData }>;
|
||||
setProviders: (value: { [id: string]: ProviderData }, options?: StorageOptions) => Promise<void>;
|
||||
getPublicKey: (options?: StorageOptions) => Promise<ArrayBuffer>;
|
||||
setPublicKey: (value: ArrayBuffer, options?: StorageOptions) => Promise<void>;
|
||||
getRefreshToken: (options?: StorageOptions) => Promise<string>;
|
||||
setRefreshToken: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getRememberedEmail: (options?: StorageOptions) => Promise<string>;
|
||||
setRememberedEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getSecurityStamp: (options?: StorageOptions) => Promise<string>;
|
||||
setSecurityStamp: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SettingsService
|
||||
*/
|
||||
getSettings: (options?: StorageOptions) => Promise<AccountSettingsSettings>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SettingsService
|
||||
*/
|
||||
setSettings: (value: AccountSettingsSettings, options?: StorageOptions) => Promise<void>;
|
||||
getSsoCodeVerifier: (options?: StorageOptions) => Promise<string>;
|
||||
setSsoCodeVerifier: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getSsoOrgIdentifier: (options?: StorageOptions) => Promise<string>;
|
||||
setSsoOrganizationIdentifier: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getSsoState: (options?: StorageOptions) => Promise<string>;
|
||||
setSsoState: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getTheme: (options?: StorageOptions) => Promise<ThemeType>;
|
||||
setTheme: (value: ThemeType, options?: StorageOptions) => Promise<void>;
|
||||
getTwoFactorToken: (options?: StorageOptions) => Promise<string>;
|
||||
setTwoFactorToken: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getUserId: (options?: StorageOptions) => Promise<string>;
|
||||
getUsesKeyConnector: (options?: StorageOptions) => Promise<boolean>;
|
||||
setUsesKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getVaultTimeout: (options?: StorageOptions) => Promise<number>;
|
||||
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
|
||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
|
||||
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getStateVersion: () => Promise<number>;
|
||||
setStateVersion: (value: number) => Promise<void>;
|
||||
getWindow: () => Promise<WindowState>;
|
||||
setWindow: (value: WindowState) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use ConfigService
|
||||
*/
|
||||
getServerConfig: (options?: StorageOptions) => Promise<ServerConfigData>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use ConfigService
|
||||
*/
|
||||
setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise<void>;
|
||||
|
||||
getAvatarColor: (options?: StorageOptions) => Promise<string | null | undefined>;
|
||||
setAvatarColor: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getActivateAutoFillOnPageLoadFromPolicy: (
|
||||
options?: StorageOptions
|
||||
) => Promise<boolean | undefined>;
|
||||
setActivateAutoFillOnPageLoadFromPolicy: (
|
||||
value: boolean,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getSMOnboardingTasks: (
|
||||
options?: StorageOptions
|
||||
) => Promise<Record<string, Record<string, boolean>>>;
|
||||
setSMOnboardingTasks: (
|
||||
value: Record<string, Record<string, boolean>>,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
}
|
||||
17
libs/common/src/platform/abstractions/storage.service.ts
Normal file
17
libs/common/src/platform/abstractions/storage.service.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { MemoryStorageOptions, StorageOptions } from "../models/domain/storage-options";
|
||||
|
||||
export abstract class AbstractStorageService {
|
||||
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
|
||||
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
|
||||
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
|
||||
abstract remove(key: string, options?: StorageOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class AbstractMemoryStorageService extends AbstractStorageService {
|
||||
// Used to identify the service in the session sync decorator framework
|
||||
static readonly TYPE = "MemoryStorageService";
|
||||
readonly type = AbstractMemoryStorageService.TYPE;
|
||||
|
||||
abstract get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T>;
|
||||
abstract getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T>;
|
||||
}
|
||||
8
libs/common/src/platform/abstractions/system.service.ts
Normal file
8
libs/common/src/platform/abstractions/system.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
|
||||
export abstract class SystemService {
|
||||
startProcessReload: (authService: AuthService) => Promise<void>;
|
||||
cancelProcessReload: () => void;
|
||||
clearClipboard: (clipboardValue: string, timeoutMs?: number) => Promise<void>;
|
||||
clearPendingClipboard: () => Promise<any>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export abstract class TranslationService {
|
||||
supportedTranslationLocales: string[];
|
||||
translationLocale: string;
|
||||
collator: Intl.Collator;
|
||||
localeNames: Map<string, string>;
|
||||
t: (id: string, p1?: string | number, p2?: string | number, p3?: string | number) => string;
|
||||
translate: (id: string, p1?: string, p2?: string, p3?: string) => string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export abstract class ValidationService {
|
||||
showError: (data: any) => string[];
|
||||
}
|
||||
13
libs/common/src/platform/factories/account-factory.ts
Normal file
13
libs/common/src/platform/factories/account-factory.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Account } from "../models/domain/account";
|
||||
|
||||
export class AccountFactory<T extends Account = Account> {
|
||||
private accountConstructor: new (init: Partial<T>) => T;
|
||||
|
||||
constructor(accountConstructor: new (init: Partial<T>) => T) {
|
||||
this.accountConstructor = accountConstructor;
|
||||
}
|
||||
|
||||
create(args: Partial<T>) {
|
||||
return new this.accountConstructor(args);
|
||||
}
|
||||
}
|
||||
13
libs/common/src/platform/factories/global-state-factory.ts
Normal file
13
libs/common/src/platform/factories/global-state-factory.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
|
||||
export class GlobalStateFactory<T extends GlobalState = GlobalState> {
|
||||
private globalStateConstructor: new (init: Partial<T>) => T;
|
||||
|
||||
constructor(globalStateConstructor: new (init: Partial<T>) => T) {
|
||||
this.globalStateConstructor = globalStateConstructor;
|
||||
}
|
||||
|
||||
create(args?: Partial<T>) {
|
||||
return new this.globalStateConstructor(args);
|
||||
}
|
||||
}
|
||||
29
libs/common/src/platform/factories/state-factory.ts
Normal file
29
libs/common/src/platform/factories/state-factory.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Account } from "../models/domain/account";
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
|
||||
import { AccountFactory } from "./account-factory";
|
||||
import { GlobalStateFactory } from "./global-state-factory";
|
||||
|
||||
export class StateFactory<
|
||||
TGlobal extends GlobalState = GlobalState,
|
||||
TAccount extends Account = Account
|
||||
> {
|
||||
private globalStateFactory: GlobalStateFactory<TGlobal>;
|
||||
private accountFactory: AccountFactory<TAccount>;
|
||||
|
||||
constructor(
|
||||
globalStateConstructor: new (init: Partial<TGlobal>) => TGlobal,
|
||||
accountConstructor: new (init: Partial<TAccount>) => TAccount
|
||||
) {
|
||||
this.globalStateFactory = new GlobalStateFactory(globalStateConstructor);
|
||||
this.accountFactory = new AccountFactory(accountConstructor);
|
||||
}
|
||||
|
||||
createGlobal(args: Partial<TGlobal>): TGlobal {
|
||||
return this.globalStateFactory.create(args);
|
||||
}
|
||||
|
||||
createAccount(args: Partial<TAccount>): TAccount {
|
||||
return this.accountFactory.create(args);
|
||||
}
|
||||
}
|
||||
12
libs/common/src/platform/interfaces/decryptable.interface.ts
Normal file
12
libs/common/src/platform/interfaces/decryptable.interface.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
import { InitializerMetadata } from "./initializer-metadata.interface";
|
||||
|
||||
/**
|
||||
* An object that contains EncStrings and knows how to decrypt them. This is usually a domain object with the
|
||||
* corresponding view object as the type argument.
|
||||
* @example Cipher implements Decryptable<CipherView>
|
||||
*/
|
||||
export interface Decryptable<TDecrypted extends InitializerMetadata> extends InitializerMetadata {
|
||||
decrypt: (key?: SymmetricCryptoKey) => Promise<TDecrypted>;
|
||||
}
|
||||
8
libs/common/src/platform/interfaces/encrypted.ts
Normal file
8
libs/common/src/platform/interfaces/encrypted.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { EncryptionType } from "../../enums";
|
||||
|
||||
export interface Encrypted {
|
||||
encryptionType?: EncryptionType;
|
||||
dataBytes: ArrayBuffer;
|
||||
macBytes: ArrayBuffer;
|
||||
ivBytes: ArrayBuffer;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { InitializerKey } from "../services/cryptography/initializer-key";
|
||||
|
||||
/**
|
||||
* This interface enables deserialization of arbitrary objects by recording their class name as an enum, which
|
||||
* will survive serialization. The enum can then be matched to a constructor or factory method for deserialization.
|
||||
* See get-class-initializer.ts for the initializer map.
|
||||
*/
|
||||
export interface InitializerMetadata {
|
||||
initializerKey: InitializerKey;
|
||||
toJSON?: () => { initializerKey: InitializerKey };
|
||||
}
|
||||
99
libs/common/src/platform/misc/flags.spec.ts
Normal file
99
libs/common/src/platform/misc/flags.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { flagEnabled, devFlagEnabled, devFlagValue } from "./flags";
|
||||
|
||||
describe("flagEnabled", () => {
|
||||
beforeEach(() => {
|
||||
process.env.FLAGS = JSON.stringify({});
|
||||
});
|
||||
|
||||
it("returns true by default", () => {
|
||||
expect(flagEnabled<any>("nonExistentFlag")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true if enabled", () => {
|
||||
process.env.FLAGS = JSON.stringify({
|
||||
newFeature: true,
|
||||
});
|
||||
|
||||
expect(flagEnabled<any>("newFeature")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if disabled", () => {
|
||||
process.env.FLAGS = JSON.stringify({
|
||||
newFeature: false,
|
||||
});
|
||||
|
||||
expect(flagEnabled<any>("newFeature")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("devFlagEnabled", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEV_FLAGS = JSON.stringify({});
|
||||
});
|
||||
|
||||
describe("in a development environment", () => {
|
||||
beforeEach(() => {
|
||||
process.env.ENV = "development";
|
||||
});
|
||||
|
||||
it("returns true by default", () => {
|
||||
expect(devFlagEnabled<any>("nonExistentFlag")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true if enabled", () => {
|
||||
process.env.DEV_FLAGS = JSON.stringify({
|
||||
devHack: true,
|
||||
});
|
||||
|
||||
expect(devFlagEnabled<any>("devHack")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true if truthy", () => {
|
||||
process.env.DEV_FLAGS = JSON.stringify({
|
||||
devHack: { key: 3 },
|
||||
});
|
||||
|
||||
expect(devFlagEnabled<any>("devHack")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if disabled", () => {
|
||||
process.env.DEV_FLAGS = JSON.stringify({
|
||||
devHack: false,
|
||||
});
|
||||
|
||||
expect(devFlagEnabled<any>("devHack")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("always returns false in prod", () => {
|
||||
process.env.ENV = "production";
|
||||
process.env.DEV_FLAGS = JSON.stringify({
|
||||
devHack: true,
|
||||
});
|
||||
|
||||
expect(devFlagEnabled<any>("devHack")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("devFlagValue", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEV_FLAGS = JSON.stringify({});
|
||||
process.env.ENV = "development";
|
||||
});
|
||||
|
||||
it("throws if dev flag is disabled", () => {
|
||||
process.env.DEV_FLAGS = JSON.stringify({
|
||||
devHack: false,
|
||||
});
|
||||
|
||||
expect(() => devFlagValue<any>("devHack")).toThrow("it is protected by a disabled dev flag");
|
||||
});
|
||||
|
||||
it("returns the dev flag value", () => {
|
||||
process.env.DEV_FLAGS = JSON.stringify({
|
||||
devHack: "Hello world",
|
||||
});
|
||||
|
||||
expect(devFlagValue<any>("devHack")).toBe("Hello world");
|
||||
});
|
||||
});
|
||||
64
libs/common/src/platform/misc/flags.ts
Normal file
64
libs/common/src/platform/misc/flags.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// required to avoid linting errors when there are no flags
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
export type SharedFlags = {
|
||||
multithreadDecryption: boolean;
|
||||
showPasswordless?: boolean;
|
||||
};
|
||||
|
||||
// required to avoid linting errors when there are no flags
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
export type SharedDevFlags = {};
|
||||
|
||||
function getFlags<T>(envFlags: string | T): T {
|
||||
if (typeof envFlags === "string") {
|
||||
return JSON.parse(envFlags) as T;
|
||||
} else {
|
||||
return envFlags as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a feature flag from environment.
|
||||
* All flags default to "on" (true).
|
||||
* Only use for shared code in `libs`, otherwise use the client-specific function.
|
||||
* @param flag The name of the feature flag to check
|
||||
* @returns The value of the flag
|
||||
*/
|
||||
export function flagEnabled<Flags extends SharedFlags>(flag: keyof Flags): boolean {
|
||||
const flags = getFlags<Flags>(process.env.FLAGS);
|
||||
return flags[flag] == null || !!flags[flag];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a dev flag from environment.
|
||||
* Will always return false unless in development.
|
||||
* Only use for shared code in `libs`, otherwise use the client-specific function.
|
||||
* @param flag The name of the dev flag to check
|
||||
* @returns The value of the flag
|
||||
*/
|
||||
export function devFlagEnabled<DevFlags extends SharedDevFlags>(flag: keyof DevFlags): boolean {
|
||||
if (process.env.ENV !== "development") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const devFlags = getFlags<DevFlags>(process.env.DEV_FLAGS);
|
||||
return devFlags[flag] == null || !!devFlags[flag];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a dev flag from environment.
|
||||
* Will always return false unless in development.
|
||||
* @param flag The name of the dev flag to check
|
||||
* @returns The value of the flag
|
||||
* @throws Error if the flag is not enabled
|
||||
*/
|
||||
export function devFlagValue<DevFlags extends SharedDevFlags>(
|
||||
flag: keyof DevFlags
|
||||
): DevFlags[keyof DevFlags] {
|
||||
if (!devFlagEnabled(flag)) {
|
||||
throw new Error(`This method should not be called, it is protected by a disabled dev flag.`);
|
||||
}
|
||||
|
||||
const devFlags = getFlags<DevFlags>(process.env.DEV_FLAGS);
|
||||
return devFlags[flag];
|
||||
}
|
||||
127
libs/common/src/platform/misc/sequentialize.spec.ts
Normal file
127
libs/common/src/platform/misc/sequentialize.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { sequentialize } from "./sequentialize";
|
||||
|
||||
describe("sequentialize decorator", () => {
|
||||
it("should call the function once", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function once for each instance of the object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
promises.push(foo2.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
expect(foo2.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function once with key function", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function again when already resolved", async () => {
|
||||
const foo = new Foo();
|
||||
await foo.bar(1);
|
||||
expect(foo.calls).toBe(1);
|
||||
await foo.bar(1);
|
||||
expect(foo.calls).toBe(2);
|
||||
});
|
||||
|
||||
it("should call the function again when already resolved with a key function", async () => {
|
||||
const foo = new Foo();
|
||||
await foo.baz(1);
|
||||
expect(foo.calls).toBe(1);
|
||||
await foo.baz(1);
|
||||
expect(foo.calls).toBe(2);
|
||||
});
|
||||
|
||||
it("should call the function for each argument", async () => {
|
||||
const foo = new Foo();
|
||||
await Promise.all([foo.bar(1), foo.bar(1), foo.bar(2), foo.bar(2), foo.bar(3), foo.bar(3)]);
|
||||
expect(foo.calls).toBe(3);
|
||||
});
|
||||
|
||||
it("should call the function for each argument with key function", async () => {
|
||||
const foo = new Foo();
|
||||
await Promise.all([foo.baz(1), foo.baz(1), foo.baz(2), foo.baz(2), foo.baz(3), foo.baz(3)]);
|
||||
expect(foo.calls).toBe(3);
|
||||
});
|
||||
|
||||
it("should return correct result for each call", async () => {
|
||||
const foo = new Foo();
|
||||
const allRes: number[] = [];
|
||||
|
||||
await Promise.all([
|
||||
foo.bar(1).then((res) => allRes.push(res)),
|
||||
foo.bar(1).then((res) => allRes.push(res)),
|
||||
foo.bar(2).then((res) => allRes.push(res)),
|
||||
foo.bar(2).then((res) => allRes.push(res)),
|
||||
foo.bar(3).then((res) => allRes.push(res)),
|
||||
foo.bar(3).then((res) => allRes.push(res)),
|
||||
]);
|
||||
expect(foo.calls).toBe(3);
|
||||
expect(allRes.length).toBe(6);
|
||||
allRes.sort();
|
||||
expect(allRes).toEqual([2, 2, 4, 4, 6, 6]);
|
||||
});
|
||||
|
||||
it("should return correct result for each call with key function", async () => {
|
||||
const foo = new Foo();
|
||||
const allRes: number[] = [];
|
||||
|
||||
await Promise.all([
|
||||
foo.baz(1).then((res) => allRes.push(res)),
|
||||
foo.baz(1).then((res) => allRes.push(res)),
|
||||
foo.baz(2).then((res) => allRes.push(res)),
|
||||
foo.baz(2).then((res) => allRes.push(res)),
|
||||
foo.baz(3).then((res) => allRes.push(res)),
|
||||
foo.baz(3).then((res) => allRes.push(res)),
|
||||
]);
|
||||
expect(foo.calls).toBe(3);
|
||||
expect(allRes.length).toBe(6);
|
||||
allRes.sort();
|
||||
expect(allRes).toEqual([3, 3, 6, 6, 9, 9]);
|
||||
});
|
||||
});
|
||||
|
||||
class Foo {
|
||||
calls = 0;
|
||||
|
||||
@sequentialize((args) => "bar" + args[0])
|
||||
bar(a: number): Promise<number> {
|
||||
this.calls++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res(a * 2);
|
||||
}, Math.random() * 100);
|
||||
});
|
||||
}
|
||||
|
||||
@sequentialize((args) => "baz" + args[0])
|
||||
baz(a: number): Promise<number> {
|
||||
this.calls++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res(a * 3);
|
||||
}, Math.random() * 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
57
libs/common/src/platform/misc/sequentialize.ts
Normal file
57
libs/common/src/platform/misc/sequentialize.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Use as a Decorator on async functions, it will prevent multiple 'active' calls as the same time
|
||||
*
|
||||
* If a promise was returned from a previous call to this function, that hasn't yet resolved it will
|
||||
* be returned, instead of calling the original function again
|
||||
*
|
||||
* Results are not cached, once the promise has returned, the next call will result in a fresh call
|
||||
*
|
||||
* Read more at https://github.com/bitwarden/jslib/pull/7
|
||||
*/
|
||||
export function sequentialize(cacheKey: (args: any[]) => string) {
|
||||
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||
const originalMethod: () => Promise<any> = descriptor.value;
|
||||
const caches = new Map<any, Map<string, Promise<any>>>();
|
||||
|
||||
const getCache = (obj: any) => {
|
||||
let cache = caches.get(obj);
|
||||
if (cache != null) {
|
||||
return cache;
|
||||
}
|
||||
cache = new Map<string, Promise<any>>();
|
||||
caches.set(obj, cache);
|
||||
return cache;
|
||||
};
|
||||
|
||||
return {
|
||||
value: function (...args: any[]) {
|
||||
const cache = getCache(this);
|
||||
const argsCacheKey = cacheKey(args);
|
||||
let response = cache.get(argsCacheKey);
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const onFinally = () => {
|
||||
cache.delete(argsCacheKey);
|
||||
if (cache.size === 0) {
|
||||
caches.delete(this);
|
||||
}
|
||||
};
|
||||
response = originalMethod
|
||||
.apply(this, args)
|
||||
.then((val: any) => {
|
||||
onFinally();
|
||||
return val;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
onFinally();
|
||||
throw err;
|
||||
});
|
||||
|
||||
cache.set(argsCacheKey, response);
|
||||
return response;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
110
libs/common/src/platform/misc/throttle.spec.ts
Normal file
110
libs/common/src/platform/misc/throttle.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { sequentialize } from "./sequentialize";
|
||||
import { throttle } from "./throttle";
|
||||
|
||||
describe("throttle decorator", () => {
|
||||
it("should call the function once at a time", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function once at a time for each object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
promises.push(foo2.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
expect(foo2.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function limit at a time", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should call the function limit at a time for each object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
promises.push(foo2.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(10);
|
||||
expect(foo2.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should work together with sequentialize", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.qux(Math.floor(i / 2) * 2));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
class Foo {
|
||||
calls = 0;
|
||||
inflight = 0;
|
||||
|
||||
@throttle(1, () => "bar")
|
||||
bar(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBe(1);
|
||||
this.inflight--;
|
||||
res(a * 2);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
|
||||
@throttle(5, () => "baz")
|
||||
baz(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBeLessThanOrEqual(5);
|
||||
this.inflight--;
|
||||
res(a * 3);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
|
||||
@sequentialize((args) => "qux" + args[0])
|
||||
@throttle(1, () => "qux")
|
||||
qux(a: number) {
|
||||
this.calls++;
|
||||
this.inflight++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
expect(this.inflight).toBe(1);
|
||||
this.inflight--;
|
||||
res(a * 3);
|
||||
}, Math.random() * 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
69
libs/common/src/platform/misc/throttle.ts
Normal file
69
libs/common/src/platform/misc/throttle.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Use as a Decorator on async functions, it will limit how many times the function can be
|
||||
* in-flight at a time.
|
||||
*
|
||||
* Calls beyond the limit will be queued, and run when one of the active calls finishes
|
||||
*/
|
||||
export function throttle(limit: number, throttleKey: (args: any[]) => string) {
|
||||
return <T>(
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: TypedPropertyDescriptor<(...args: any[]) => Promise<T>>
|
||||
) => {
|
||||
const originalMethod: () => Promise<T> = descriptor.value;
|
||||
const allThrottles = new Map<any, Map<string, (() => void)[]>>();
|
||||
|
||||
const getThrottles = (obj: any) => {
|
||||
let throttles = allThrottles.get(obj);
|
||||
if (throttles != null) {
|
||||
return throttles;
|
||||
}
|
||||
throttles = new Map<string, (() => void)[]>();
|
||||
allThrottles.set(obj, throttles);
|
||||
return throttles;
|
||||
};
|
||||
|
||||
return {
|
||||
value: function (...args: any[]) {
|
||||
const throttles = getThrottles(this);
|
||||
const argsThrottleKey = throttleKey(args);
|
||||
let queue = throttles.get(argsThrottleKey);
|
||||
if (queue == null) {
|
||||
queue = [];
|
||||
throttles.set(argsThrottleKey, queue);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const exec = () => {
|
||||
const onFinally = () => {
|
||||
queue.splice(queue.indexOf(exec), 1);
|
||||
if (queue.length >= limit) {
|
||||
queue[limit - 1]();
|
||||
} else if (queue.length === 0) {
|
||||
throttles.delete(argsThrottleKey);
|
||||
if (throttles.size === 0) {
|
||||
allThrottles.delete(this);
|
||||
}
|
||||
}
|
||||
};
|
||||
originalMethod
|
||||
.apply(this, args)
|
||||
.then((val: any) => {
|
||||
onFinally();
|
||||
return val;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
onFinally();
|
||||
throw err;
|
||||
})
|
||||
.then(resolve, reject);
|
||||
};
|
||||
queue.push(exec);
|
||||
if (queue.length <= limit) {
|
||||
exec();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
361
libs/common/src/platform/misc/utils.spec.ts
Normal file
361
libs/common/src/platform/misc/utils.spec.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import * as path from "path";
|
||||
|
||||
import { Utils } from "./utils";
|
||||
|
||||
describe("Utils Service", () => {
|
||||
describe("getDomain", () => {
|
||||
it("should fail for invalid urls", () => {
|
||||
expect(Utils.getDomain(null)).toBeNull();
|
||||
expect(Utils.getDomain(undefined)).toBeNull();
|
||||
expect(Utils.getDomain(" ")).toBeNull();
|
||||
expect(Utils.getDomain('https://bit!:"_&ward.com')).toBeNull();
|
||||
expect(Utils.getDomain("bitwarden")).toBeNull();
|
||||
});
|
||||
|
||||
it("should fail for data urls", () => {
|
||||
expect(Utils.getDomain("data:image/jpeg;base64,AAA")).toBeNull();
|
||||
});
|
||||
|
||||
it("should fail for about urls", () => {
|
||||
expect(Utils.getDomain("about")).toBeNull();
|
||||
expect(Utils.getDomain("about:")).toBeNull();
|
||||
expect(Utils.getDomain("about:blank")).toBeNull();
|
||||
});
|
||||
|
||||
it("should fail for file url", () => {
|
||||
expect(Utils.getDomain("file:///C://somefolder/form.pdf")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle urls without protocol", () => {
|
||||
expect(Utils.getDomain("bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("wrong://bitwarden.com")).toBe("bitwarden.com");
|
||||
});
|
||||
|
||||
it("should handle valid urls", () => {
|
||||
expect(Utils.getDomain("bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("http://bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("https://bitwarden.com")).toBe("bitwarden.com");
|
||||
|
||||
expect(Utils.getDomain("www.bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("http://www.bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("https://www.bitwarden.com")).toBe("bitwarden.com");
|
||||
|
||||
expect(Utils.getDomain("vault.bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("http://vault.bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("https://vault.bitwarden.com")).toBe("bitwarden.com");
|
||||
|
||||
expect(Utils.getDomain("www.vault.bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("http://www.vault.bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("https://www.vault.bitwarden.com")).toBe("bitwarden.com");
|
||||
|
||||
expect(
|
||||
Utils.getDomain("user:password@bitwarden.com:8080/password/sites?and&query#hash")
|
||||
).toBe("bitwarden.com");
|
||||
expect(
|
||||
Utils.getDomain("http://user:password@bitwarden.com:8080/password/sites?and&query#hash")
|
||||
).toBe("bitwarden.com");
|
||||
expect(
|
||||
Utils.getDomain("https://user:password@bitwarden.com:8080/password/sites?and&query#hash")
|
||||
).toBe("bitwarden.com");
|
||||
|
||||
expect(Utils.getDomain("bitwarden.unknown")).toBe("bitwarden.unknown");
|
||||
expect(Utils.getDomain("http://bitwarden.unknown")).toBe("bitwarden.unknown");
|
||||
expect(Utils.getDomain("https://bitwarden.unknown")).toBe("bitwarden.unknown");
|
||||
});
|
||||
|
||||
it("should handle valid urls with an underscore in subdomain", () => {
|
||||
expect(Utils.getDomain("my_vault.bitwarden.com/")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("http://my_vault.bitwarden.com/")).toBe("bitwarden.com");
|
||||
expect(Utils.getDomain("https://my_vault.bitwarden.com/")).toBe("bitwarden.com");
|
||||
});
|
||||
|
||||
it("should support urls containing umlauts", () => {
|
||||
expect(Utils.getDomain("bütwarden.com")).toBe("bütwarden.com");
|
||||
expect(Utils.getDomain("http://bütwarden.com")).toBe("bütwarden.com");
|
||||
expect(Utils.getDomain("https://bütwarden.com")).toBe("bütwarden.com");
|
||||
|
||||
expect(Utils.getDomain("subdomain.bütwarden.com")).toBe("bütwarden.com");
|
||||
expect(Utils.getDomain("http://subdomain.bütwarden.com")).toBe("bütwarden.com");
|
||||
expect(Utils.getDomain("https://subdomain.bütwarden.com")).toBe("bütwarden.com");
|
||||
});
|
||||
|
||||
it("should support punycode urls", () => {
|
||||
expect(Utils.getDomain("xn--btwarden-65a.com")).toBe("xn--btwarden-65a.com");
|
||||
expect(Utils.getDomain("xn--btwarden-65a.com")).toBe("xn--btwarden-65a.com");
|
||||
expect(Utils.getDomain("xn--btwarden-65a.com")).toBe("xn--btwarden-65a.com");
|
||||
|
||||
expect(Utils.getDomain("subdomain.xn--btwarden-65a.com")).toBe("xn--btwarden-65a.com");
|
||||
expect(Utils.getDomain("http://subdomain.xn--btwarden-65a.com")).toBe("xn--btwarden-65a.com");
|
||||
expect(Utils.getDomain("https://subdomain.xn--btwarden-65a.com")).toBe(
|
||||
"xn--btwarden-65a.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("should support localhost", () => {
|
||||
expect(Utils.getDomain("localhost")).toBe("localhost");
|
||||
expect(Utils.getDomain("http://localhost")).toBe("localhost");
|
||||
expect(Utils.getDomain("https://localhost")).toBe("localhost");
|
||||
});
|
||||
|
||||
it("should support localhost with subdomain", () => {
|
||||
expect(Utils.getDomain("subdomain.localhost")).toBe("localhost");
|
||||
expect(Utils.getDomain("http://subdomain.localhost")).toBe("localhost");
|
||||
expect(Utils.getDomain("https://subdomain.localhost")).toBe("localhost");
|
||||
});
|
||||
|
||||
it("should support IPv4", () => {
|
||||
expect(Utils.getDomain("192.168.1.1")).toBe("192.168.1.1");
|
||||
expect(Utils.getDomain("http://192.168.1.1")).toBe("192.168.1.1");
|
||||
expect(Utils.getDomain("https://192.168.1.1")).toBe("192.168.1.1");
|
||||
});
|
||||
|
||||
it("should support IPv6", () => {
|
||||
expect(Utils.getDomain("[2620:fe::fe]")).toBe("2620:fe::fe");
|
||||
expect(Utils.getDomain("http://[2620:fe::fe]")).toBe("2620:fe::fe");
|
||||
expect(Utils.getDomain("https://[2620:fe::fe]")).toBe("2620:fe::fe");
|
||||
});
|
||||
|
||||
it("should reject invalid hostnames", () => {
|
||||
expect(Utils.getDomain("https://mywebsite.com$.mywebsite.com")).toBeNull();
|
||||
expect(Utils.getDomain("https://mywebsite.com!.mywebsite.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHostname", () => {
|
||||
it("should fail for invalid urls", () => {
|
||||
expect(Utils.getHostname(null)).toBeNull();
|
||||
expect(Utils.getHostname(undefined)).toBeNull();
|
||||
expect(Utils.getHostname(" ")).toBeNull();
|
||||
expect(Utils.getHostname('https://bit!:"_&ward.com')).toBeNull();
|
||||
});
|
||||
|
||||
it("should fail for data urls", () => {
|
||||
expect(Utils.getHostname("data:image/jpeg;base64,AAA")).toBeNull();
|
||||
});
|
||||
|
||||
it("should fail for about urls", () => {
|
||||
expect(Utils.getHostname("about")).toBe("about");
|
||||
expect(Utils.getHostname("about:")).toBeNull();
|
||||
expect(Utils.getHostname("about:blank")).toBeNull();
|
||||
});
|
||||
|
||||
it("should fail for file url", () => {
|
||||
expect(Utils.getHostname("file:///C:/somefolder/form.pdf")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle valid urls", () => {
|
||||
expect(Utils.getHostname("bitwarden")).toBe("bitwarden");
|
||||
expect(Utils.getHostname("http://bitwarden")).toBe("bitwarden");
|
||||
expect(Utils.getHostname("https://bitwarden")).toBe("bitwarden");
|
||||
|
||||
expect(Utils.getHostname("bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getHostname("http://bitwarden.com")).toBe("bitwarden.com");
|
||||
expect(Utils.getHostname("https://bitwarden.com")).toBe("bitwarden.com");
|
||||
|
||||
expect(Utils.getHostname("www.bitwarden.com")).toBe("www.bitwarden.com");
|
||||
expect(Utils.getHostname("http://www.bitwarden.com")).toBe("www.bitwarden.com");
|
||||
expect(Utils.getHostname("https://www.bitwarden.com")).toBe("www.bitwarden.com");
|
||||
|
||||
expect(Utils.getHostname("vault.bitwarden.com")).toBe("vault.bitwarden.com");
|
||||
expect(Utils.getHostname("http://vault.bitwarden.com")).toBe("vault.bitwarden.com");
|
||||
expect(Utils.getHostname("https://vault.bitwarden.com")).toBe("vault.bitwarden.com");
|
||||
|
||||
expect(Utils.getHostname("www.vault.bitwarden.com")).toBe("www.vault.bitwarden.com");
|
||||
expect(Utils.getHostname("http://www.vault.bitwarden.com")).toBe("www.vault.bitwarden.com");
|
||||
expect(Utils.getHostname("https://www.vault.bitwarden.com")).toBe("www.vault.bitwarden.com");
|
||||
|
||||
expect(
|
||||
Utils.getHostname("user:password@bitwarden.com:8080/password/sites?and&query#hash")
|
||||
).toBe("bitwarden.com");
|
||||
expect(
|
||||
Utils.getHostname("https://user:password@bitwarden.com:8080/password/sites?and&query#hash")
|
||||
).toBe("bitwarden.com");
|
||||
expect(Utils.getHostname("https://bitwarden.unknown")).toBe("bitwarden.unknown");
|
||||
});
|
||||
|
||||
it("should handle valid urls with an underscore in subdomain", () => {
|
||||
expect(Utils.getHostname("my_vault.bitwarden.com/")).toBe("my_vault.bitwarden.com");
|
||||
expect(Utils.getHostname("http://my_vault.bitwarden.com/")).toBe("my_vault.bitwarden.com");
|
||||
expect(Utils.getHostname("https://my_vault.bitwarden.com/")).toBe("my_vault.bitwarden.com");
|
||||
});
|
||||
|
||||
it("should support urls containing umlauts", () => {
|
||||
expect(Utils.getHostname("bütwarden.com")).toBe("bütwarden.com");
|
||||
expect(Utils.getHostname("http://bütwarden.com")).toBe("bütwarden.com");
|
||||
expect(Utils.getHostname("https://bütwarden.com")).toBe("bütwarden.com");
|
||||
|
||||
expect(Utils.getHostname("subdomain.bütwarden.com")).toBe("subdomain.bütwarden.com");
|
||||
expect(Utils.getHostname("http://subdomain.bütwarden.com")).toBe("subdomain.bütwarden.com");
|
||||
expect(Utils.getHostname("https://subdomain.bütwarden.com")).toBe("subdomain.bütwarden.com");
|
||||
});
|
||||
|
||||
it("should support punycode urls", () => {
|
||||
expect(Utils.getHostname("xn--btwarden-65a.com")).toBe("xn--btwarden-65a.com");
|
||||
expect(Utils.getHostname("xn--btwarden-65a.com")).toBe("xn--btwarden-65a.com");
|
||||
expect(Utils.getHostname("xn--btwarden-65a.com")).toBe("xn--btwarden-65a.com");
|
||||
|
||||
expect(Utils.getHostname("subdomain.xn--btwarden-65a.com")).toBe(
|
||||
"subdomain.xn--btwarden-65a.com"
|
||||
);
|
||||
expect(Utils.getHostname("http://subdomain.xn--btwarden-65a.com")).toBe(
|
||||
"subdomain.xn--btwarden-65a.com"
|
||||
);
|
||||
expect(Utils.getHostname("https://subdomain.xn--btwarden-65a.com")).toBe(
|
||||
"subdomain.xn--btwarden-65a.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("should support localhost", () => {
|
||||
expect(Utils.getHostname("localhost")).toBe("localhost");
|
||||
expect(Utils.getHostname("http://localhost")).toBe("localhost");
|
||||
expect(Utils.getHostname("https://localhost")).toBe("localhost");
|
||||
});
|
||||
|
||||
it("should support localhost with subdomain", () => {
|
||||
expect(Utils.getHostname("subdomain.localhost")).toBe("subdomain.localhost");
|
||||
expect(Utils.getHostname("http://subdomain.localhost")).toBe("subdomain.localhost");
|
||||
expect(Utils.getHostname("https://subdomain.localhost")).toBe("subdomain.localhost");
|
||||
});
|
||||
|
||||
it("should support IPv4", () => {
|
||||
expect(Utils.getHostname("192.168.1.1")).toBe("192.168.1.1");
|
||||
expect(Utils.getHostname("http://192.168.1.1")).toBe("192.168.1.1");
|
||||
expect(Utils.getHostname("https://192.168.1.1")).toBe("192.168.1.1");
|
||||
});
|
||||
|
||||
it("should support IPv6", () => {
|
||||
expect(Utils.getHostname("[2620:fe::fe]")).toBe("2620:fe::fe");
|
||||
expect(Utils.getHostname("http://[2620:fe::fe]")).toBe("2620:fe::fe");
|
||||
expect(Utils.getHostname("https://[2620:fe::fe]")).toBe("2620:fe::fe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("newGuid", () => {
|
||||
it("should create a valid guid", () => {
|
||||
const validGuid =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
expect(Utils.newGuid()).toMatch(validGuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromByteStringToArray", () => {
|
||||
it("should handle null", () => {
|
||||
expect(Utils.fromByteStringToArray(null)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapToRecord", () => {
|
||||
it("should handle null", () => {
|
||||
expect(Utils.mapToRecord(null)).toEqual(null);
|
||||
});
|
||||
|
||||
it("should handle empty map", () => {
|
||||
expect(Utils.mapToRecord(new Map())).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle convert a Map to a Record", () => {
|
||||
const map = new Map([
|
||||
["key1", "value1"],
|
||||
["key2", "value2"],
|
||||
]);
|
||||
expect(Utils.mapToRecord(map)).toEqual({ key1: "value1", key2: "value2" });
|
||||
});
|
||||
|
||||
it("should handle convert a Map to a Record with non-string keys", () => {
|
||||
const map = new Map([
|
||||
[1, "value1"],
|
||||
[2, "value2"],
|
||||
]);
|
||||
const result = Utils.mapToRecord(map);
|
||||
expect(result).toEqual({ 1: "value1", 2: "value2" });
|
||||
expect(Utils.recordToMap(result)).toEqual(map);
|
||||
});
|
||||
|
||||
it("should not convert an object if it's not a map", () => {
|
||||
const obj = { key1: "value1", key2: "value2" };
|
||||
expect(Utils.mapToRecord(obj as any)).toEqual(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordToMap", () => {
|
||||
it("should handle null", () => {
|
||||
expect(Utils.recordToMap(null)).toEqual(null);
|
||||
});
|
||||
|
||||
it("should handle empty record", () => {
|
||||
expect(Utils.recordToMap({})).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("should handle convert a Record to a Map", () => {
|
||||
const record = { key1: "value1", key2: "value2" };
|
||||
expect(Utils.recordToMap(record)).toEqual(new Map(Object.entries(record)));
|
||||
});
|
||||
|
||||
it("should handle convert a Record to a Map with non-string keys", () => {
|
||||
const record = { 1: "value1", 2: "value2" };
|
||||
const result = Utils.recordToMap(record);
|
||||
expect(result).toEqual(
|
||||
new Map([
|
||||
[1, "value1"],
|
||||
[2, "value2"],
|
||||
])
|
||||
);
|
||||
expect(Utils.mapToRecord(result)).toEqual(record);
|
||||
});
|
||||
|
||||
it("should not convert an object if already a map", () => {
|
||||
const map = new Map([
|
||||
["key1", "value1"],
|
||||
["key2", "value2"],
|
||||
]);
|
||||
expect(Utils.recordToMap(map as any)).toEqual(map);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeRFC3986URIComponent", () => {
|
||||
it("returns input string with expected encoded chars", () => {
|
||||
expect(Utils.encodeRFC3986URIComponent("test'user@example.com")).toBe(
|
||||
"test%27user%40example.com"
|
||||
);
|
||||
expect(Utils.encodeRFC3986URIComponent("(test)user@example.com")).toBe(
|
||||
"%28test%29user%40example.com"
|
||||
);
|
||||
expect(Utils.encodeRFC3986URIComponent("testuser!@example.com")).toBe(
|
||||
"testuser%21%40example.com"
|
||||
);
|
||||
expect(Utils.encodeRFC3986URIComponent("Test*User@example.com")).toBe(
|
||||
"Test%2AUser%40example.com"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("removes a single traversal", () => {
|
||||
expect(Utils.normalizePath("../test")).toBe("test");
|
||||
});
|
||||
|
||||
it("removes deep traversals", () => {
|
||||
expect(Utils.normalizePath("../../test")).toBe("test");
|
||||
});
|
||||
|
||||
it("removes intermediate traversals", () => {
|
||||
expect(Utils.normalizePath("test/../test")).toBe("test");
|
||||
});
|
||||
|
||||
it("removes multiple encoded traversals", () => {
|
||||
expect(
|
||||
Utils.normalizePath("api/sends/access/..%2f..%2f..%2fapi%2fsends%2faccess%2fsendkey")
|
||||
).toBe(path.normalize("api/sends/access/sendkey"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrl", () => {
|
||||
it("assumes a http protocol if no protocol is specified", () => {
|
||||
const urlString = "www.exampleapp.com.au:4000";
|
||||
|
||||
const actual = Utils.getUrl(urlString);
|
||||
|
||||
expect(actual.protocol).toBe("http:");
|
||||
});
|
||||
});
|
||||
});
|
||||
572
libs/common/src/platform/misc/utils.ts
Normal file
572
libs/common/src/platform/misc/utils.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/* eslint-disable no-useless-escape */
|
||||
import * as path from "path";
|
||||
|
||||
import { Observable, of, switchMap } from "rxjs";
|
||||
import { getHostname, parse } from "tldts";
|
||||
import { Merge } from "type-fest";
|
||||
|
||||
import { CryptoService } from "../abstractions/crypto.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
import { I18nService } from "../abstractions/i18n.service";
|
||||
|
||||
const nodeURL = typeof window === "undefined" ? require("url") : null;
|
||||
|
||||
declare global {
|
||||
/* eslint-disable-next-line no-var */
|
||||
var bitwardenContainerService: BitwardenContainerService;
|
||||
}
|
||||
|
||||
interface BitwardenContainerService {
|
||||
getCryptoService: () => CryptoService;
|
||||
getEncryptService: () => EncryptService;
|
||||
}
|
||||
|
||||
export class Utils {
|
||||
static inited = false;
|
||||
static isNode = false;
|
||||
static isBrowser = true;
|
||||
static isMobileBrowser = false;
|
||||
static isAppleMobileBrowser = false;
|
||||
static global: typeof global = null;
|
||||
// Transpiled version of /\p{Emoji_Presentation}/gu using https://mothereff.in/regexpu. Used for compatability in older browsers.
|
||||
static regexpEmojiPresentation =
|
||||
/(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])/g;
|
||||
static readonly validHosts: string[] = ["localhost"];
|
||||
static readonly minimumPasswordLength = 12;
|
||||
static readonly DomainMatchBlacklist = new Map<string, Set<string>>([
|
||||
["google.com", new Set(["script.google.com"])],
|
||||
]);
|
||||
|
||||
static init() {
|
||||
if (Utils.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
Utils.inited = true;
|
||||
Utils.isNode =
|
||||
typeof process !== "undefined" &&
|
||||
(process as any).release != null &&
|
||||
(process as any).release.name === "node";
|
||||
Utils.isBrowser = typeof window !== "undefined";
|
||||
|
||||
Utils.isMobileBrowser = Utils.isBrowser && this.isMobile(window);
|
||||
Utils.isAppleMobileBrowser = Utils.isBrowser && this.isAppleMobile(window);
|
||||
|
||||
if (Utils.isNode) {
|
||||
Utils.global = global;
|
||||
} else if (Utils.isBrowser) {
|
||||
Utils.global = window;
|
||||
} else {
|
||||
// If it's not browser or node then it must be a service worker
|
||||
Utils.global = self;
|
||||
}
|
||||
}
|
||||
|
||||
static fromB64ToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "base64"));
|
||||
} else {
|
||||
const binaryString = Utils.global.atob(str);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
static fromUrlB64ToArray(str: string): Uint8Array {
|
||||
return Utils.fromB64ToArray(Utils.fromUrlB64ToB64(str));
|
||||
}
|
||||
|
||||
static fromHexToArray(str: string): Uint8Array {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "hex"));
|
||||
} else {
|
||||
const bytes = new Uint8Array(str.length / 2);
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(str.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
static fromUtf8ToArray(str: string): Uint8Array {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "utf8"));
|
||||
} else {
|
||||
const strUtf8 = unescape(encodeURIComponent(str));
|
||||
const arr = new Uint8Array(strUtf8.length);
|
||||
for (let i = 0; i < strUtf8.length; i++) {
|
||||
arr[i] = strUtf8.charCodeAt(i);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
static fromByteStringToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
const arr = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
arr[i] = str.charCodeAt(i);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
static fromBufferToB64(buffer: ArrayBuffer): string {
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(buffer).toString("base64");
|
||||
} else {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return Utils.global.btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
|
||||
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
|
||||
}
|
||||
|
||||
static fromB64toUrlB64(b64Str: string) {
|
||||
return b64Str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
static fromBufferToUtf8(buffer: ArrayBuffer): string {
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(buffer).toString("utf8");
|
||||
} else {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const encodedString = String.fromCharCode.apply(null, bytes);
|
||||
return decodeURIComponent(escape(encodedString));
|
||||
}
|
||||
}
|
||||
|
||||
static fromBufferToByteString(buffer: ArrayBuffer): string {
|
||||
return String.fromCharCode.apply(null, new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
// ref: https://stackoverflow.com/a/40031979/1090359
|
||||
static fromBufferToHex(buffer: ArrayBuffer): string {
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(buffer).toString("hex");
|
||||
} else {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
return Array.prototype.map
|
||||
.call(bytes, (x: number) => ("00" + x.toString(16)).slice(-2))
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
static fromUrlB64ToB64(urlB64Str: string): string {
|
||||
let output = urlB64Str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
switch (output.length % 4) {
|
||||
case 0:
|
||||
break;
|
||||
case 2:
|
||||
output += "==";
|
||||
break;
|
||||
case 3:
|
||||
output += "=";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Illegal base64url string!");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static fromUrlB64ToUtf8(urlB64Str: string): string {
|
||||
return Utils.fromB64ToUtf8(Utils.fromUrlB64ToB64(urlB64Str));
|
||||
}
|
||||
|
||||
static fromUtf8ToB64(utfStr: string): string {
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(utfStr, "utf8").toString("base64");
|
||||
} else {
|
||||
return decodeURIComponent(escape(Utils.global.btoa(utfStr)));
|
||||
}
|
||||
}
|
||||
|
||||
static fromUtf8ToUrlB64(utfStr: string): string {
|
||||
return Utils.fromBufferToUrlB64(Utils.fromUtf8ToArray(utfStr));
|
||||
}
|
||||
|
||||
static fromB64ToUtf8(b64Str: string): string {
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(b64Str, "base64").toString("utf8");
|
||||
} else {
|
||||
return decodeURIComponent(escape(Utils.global.atob(b64Str)));
|
||||
}
|
||||
}
|
||||
|
||||
// ref: http://stackoverflow.com/a/2117523/1090359
|
||||
static newGuid(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
static isGuid(id: string) {
|
||||
return RegExp(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
"i"
|
||||
).test(id);
|
||||
}
|
||||
|
||||
static getHostname(uriString: string): string {
|
||||
if (Utils.isNullOrWhitespace(uriString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
uriString = uriString.trim();
|
||||
|
||||
if (uriString.startsWith("data:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (uriString.startsWith("about:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (uriString.startsWith("file:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Does uriString contain invalid characters
|
||||
// TODO Needs to possibly be extended, although '!' is a reserved character
|
||||
if (uriString.indexOf("!") > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname(uriString, { validHosts: this.validHosts });
|
||||
if (hostname != null) {
|
||||
return hostname;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static getHost(uriString: string): string {
|
||||
const url = Utils.getUrl(uriString);
|
||||
try {
|
||||
return url != null && url.host !== "" ? url.host : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static getDomain(uriString: string): string {
|
||||
if (Utils.isNullOrWhitespace(uriString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
uriString = uriString.trim();
|
||||
|
||||
if (uriString.startsWith("data:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (uriString.startsWith("about:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parseResult = parse(uriString, { validHosts: this.validHosts });
|
||||
if (parseResult != null && parseResult.hostname != null) {
|
||||
if (parseResult.hostname === "localhost" || parseResult.isIp) {
|
||||
return parseResult.hostname;
|
||||
}
|
||||
|
||||
if (parseResult.domain != null) {
|
||||
return parseResult.domain;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static getQueryParams(uriString: string): Map<string, string> {
|
||||
const url = Utils.getUrl(uriString);
|
||||
if (url == null || url.search == null || url.search === "") {
|
||||
return null;
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
const pairs = (url.search[0] === "?" ? url.search.substr(1) : url.search).split("&");
|
||||
pairs.forEach((pair) => {
|
||||
const parts = pair.split("=");
|
||||
if (parts.length < 1) {
|
||||
return;
|
||||
}
|
||||
map.set(
|
||||
decodeURIComponent(parts[0]).toLowerCase(),
|
||||
parts[1] == null ? "" : decodeURIComponent(parts[1])
|
||||
);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
static getSortFunction<T>(
|
||||
i18nService: I18nService,
|
||||
prop: { [K in keyof T]: T[K] extends string ? K : never }[keyof T]
|
||||
): (a: T, b: T) => number {
|
||||
return (a, b) => {
|
||||
if (a[prop] == null && b[prop] != null) {
|
||||
return -1;
|
||||
}
|
||||
if (a[prop] != null && b[prop] == null) {
|
||||
return 1;
|
||||
}
|
||||
if (a[prop] == null && b[prop] == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The `as unknown as string` here is unfortunate because typescript doesn't property understand that the return of T[prop] will be a string
|
||||
return i18nService.collator
|
||||
? i18nService.collator.compare(a[prop] as unknown as string, b[prop] as unknown as string)
|
||||
: (a[prop] as unknown as string).localeCompare(b[prop] as unknown as string);
|
||||
};
|
||||
}
|
||||
|
||||
static isNullOrWhitespace(str: string): boolean {
|
||||
return str == null || typeof str !== "string" || str.trim() === "";
|
||||
}
|
||||
|
||||
static isNullOrEmpty(str: string): boolean {
|
||||
return str == null || typeof str !== "string" || str == "";
|
||||
}
|
||||
|
||||
static isPromise(obj: any): obj is Promise<unknown> {
|
||||
return (
|
||||
obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function"
|
||||
);
|
||||
}
|
||||
|
||||
static nameOf<T>(name: string & keyof T) {
|
||||
return name;
|
||||
}
|
||||
|
||||
static assign<T>(target: T, source: Partial<T>): T {
|
||||
return Object.assign(target, source);
|
||||
}
|
||||
|
||||
static iterateEnum<O extends object, K extends keyof O = keyof O>(obj: O) {
|
||||
return (Object.keys(obj).filter((k) => Number.isNaN(+k)) as K[]).map((k) => obj[k]);
|
||||
}
|
||||
|
||||
static getUrl(uriString: string): URL {
|
||||
if (this.isNullOrWhitespace(uriString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
uriString = uriString.trim();
|
||||
|
||||
return Utils.getUrlObject(uriString);
|
||||
}
|
||||
|
||||
static camelToPascalCase(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* There are a few ways to calculate text color for contrast, this one seems to fit accessibility guidelines best.
|
||||
* https://stackoverflow.com/a/3943023/6869691
|
||||
*
|
||||
* @param {string} bgColor
|
||||
* @param {number} [threshold] see stackoverflow link above
|
||||
* @param {boolean} [svgTextFill]
|
||||
* Indicates if this method is performed on an SVG <text> 'fill' attribute (e.g. <text fill="black"></text>).
|
||||
* This check is necessary because the '!important' tag cannot be used in a 'fill' attribute.
|
||||
*/
|
||||
static pickTextColorBasedOnBgColor(bgColor: string, threshold = 186, svgTextFill = false) {
|
||||
const bgColorHexNums = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor;
|
||||
const r = parseInt(bgColorHexNums.substring(0, 2), 16); // hexToR
|
||||
const g = parseInt(bgColorHexNums.substring(2, 4), 16); // hexToG
|
||||
const b = parseInt(bgColorHexNums.substring(4, 6), 16); // hexToB
|
||||
const blackColor = svgTextFill ? "black" : "black !important";
|
||||
const whiteColor = svgTextFill ? "white" : "white !important";
|
||||
return r * 0.299 + g * 0.587 + b * 0.114 > threshold ? blackColor : whiteColor;
|
||||
}
|
||||
|
||||
static stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += ("00" + value.toString(16)).substr(-2);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Will throw an error if the ContainerService has not been attached to the window object
|
||||
*/
|
||||
static getContainerService(): BitwardenContainerService {
|
||||
if (this.global.bitwardenContainerService == null) {
|
||||
throw new Error("global bitwardenContainerService not initialized.");
|
||||
}
|
||||
return this.global.bitwardenContainerService;
|
||||
}
|
||||
|
||||
static validateHexColor(color: string) {
|
||||
return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts map to a Record<string, V> with the same data. Inverse of recordToMap
|
||||
* Useful in toJSON methods, since Maps are not serializable
|
||||
* @param map
|
||||
* @returns
|
||||
*/
|
||||
static mapToRecord<K extends string | number, V>(map: Map<K, V>): Record<string, V> {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(map instanceof Map)) {
|
||||
return map;
|
||||
}
|
||||
return Object.fromEntries(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts record to a Map<string, V> with the same data. Inverse of mapToRecord
|
||||
* Useful in fromJSON methods, since Maps are not serializable
|
||||
*
|
||||
* Warning: If the record has string keys that are numbers, they will be converted to numbers in the map
|
||||
* @param record
|
||||
* @returns
|
||||
*/
|
||||
static recordToMap<K extends string | number, V>(record: Record<K, V>): Map<K, V> {
|
||||
if (record == null) {
|
||||
return null;
|
||||
} else if (record instanceof Map) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const entries = Object.entries(record);
|
||||
if (entries.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
if (isNaN(Number(entries[0][0]))) {
|
||||
return new Map(entries) as Map<K, V>;
|
||||
} else {
|
||||
return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map<K, V>;
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies Object.assign, but converts the type nicely using Type-Fest Merge<Destination, Source> */
|
||||
static merge<Destination, Source>(
|
||||
destination: Destination,
|
||||
source: Source
|
||||
): Merge<Destination, Source> {
|
||||
return Object.assign(destination, source) as unknown as Merge<Destination, Source>;
|
||||
}
|
||||
|
||||
/**
|
||||
* encodeURIComponent escapes all characters except the following:
|
||||
* alphabetic, decimal digits, - _ . ! ~ * ' ( )
|
||||
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
*/
|
||||
static encodeRFC3986URIComponent(str: string): string {
|
||||
return encodeURIComponent(str).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a path for defense against attacks like traversals
|
||||
* @param denormalizedPath
|
||||
* @returns
|
||||
*/
|
||||
static normalizePath(denormalizedPath: string): string {
|
||||
return path.normalize(decodeURIComponent(denormalizedPath)).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
}
|
||||
|
||||
private static isMobile(win: Window) {
|
||||
let mobile = false;
|
||||
((a) => {
|
||||
if (
|
||||
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
|
||||
a
|
||||
) ||
|
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
|
||||
a.substr(0, 4)
|
||||
)
|
||||
) {
|
||||
mobile = true;
|
||||
}
|
||||
})(win.navigator.userAgent || win.navigator.vendor || (win as any).opera);
|
||||
return mobile || win.navigator.userAgent.match(/iPad/i) != null;
|
||||
}
|
||||
|
||||
static delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an observable from a function that returns a promise.
|
||||
* Similar to the rxjs function {@link from} with one big exception:
|
||||
* {@link from} will not re-execute the function when observers resubscribe.
|
||||
* {@link Util.asyncToObservable} will execute `generator` for every
|
||||
* subscribe, making it ideal if the value ever needs to be refreshed.
|
||||
* */
|
||||
static asyncToObservable<T>(generator: () => Promise<T>): Observable<T> {
|
||||
return of(undefined).pipe(switchMap(() => generator()));
|
||||
}
|
||||
|
||||
private static isAppleMobile(win: Window) {
|
||||
return (
|
||||
win.navigator.userAgent.match(/iPhone/i) != null ||
|
||||
win.navigator.userAgent.match(/iPad/i) != null
|
||||
);
|
||||
}
|
||||
|
||||
private static getUrlObject(uriString: string): URL {
|
||||
// All the methods below require a protocol to properly parse a URL string
|
||||
// Assume http if no other protocol is present
|
||||
const hasProtocol = uriString.indexOf("://") > -1;
|
||||
if (!hasProtocol && uriString.indexOf(".") > -1) {
|
||||
uriString = "http://" + uriString;
|
||||
} else if (!hasProtocol) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (nodeURL != null) {
|
||||
return new nodeURL.URL(uriString);
|
||||
}
|
||||
|
||||
return new URL(uriString);
|
||||
} catch (e) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Utils.init();
|
||||
7779
libs/common/src/platform/misc/wordlist.ts
Normal file
7779
libs/common/src/platform/misc/wordlist.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
EnvironmentServerConfigData,
|
||||
ServerConfigData,
|
||||
ThirdPartyServerConfigData,
|
||||
} from "./server-config.data";
|
||||
|
||||
describe("ServerConfigData", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should create a ServerConfigData from a JSON object", () => {
|
||||
const json = {
|
||||
version: "1.0.0",
|
||||
gitHash: "1234567890",
|
||||
server: {
|
||||
name: "test",
|
||||
url: "https://test.com",
|
||||
},
|
||||
environment: {
|
||||
vault: "https://vault.com",
|
||||
api: "https://api.com",
|
||||
identity: "https://identity.com",
|
||||
notifications: "https://notifications.com",
|
||||
sso: "https://sso.com",
|
||||
},
|
||||
utcDate: "2020-01-01T00:00:00.000Z",
|
||||
featureStates: { feature: "state" },
|
||||
};
|
||||
const serverConfigData = ServerConfigData.fromJSON(json);
|
||||
|
||||
expect(serverConfigData).toEqual(json);
|
||||
});
|
||||
|
||||
it("should be an instance of ServerConfigData", () => {
|
||||
const serverConfigData = ServerConfigData.fromJSON({} as any);
|
||||
|
||||
expect(serverConfigData).toBeInstanceOf(ServerConfigData);
|
||||
});
|
||||
|
||||
it("should deserialize sub objects", () => {
|
||||
const serverConfigData = ServerConfigData.fromJSON({
|
||||
server: {},
|
||||
environment: {},
|
||||
} as any);
|
||||
|
||||
expect(serverConfigData.server).toBeInstanceOf(ThirdPartyServerConfigData);
|
||||
expect(serverConfigData.environment).toBeInstanceOf(EnvironmentServerConfigData);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
libs/common/src/platform/models/data/server-config.data.ts
Normal file
70
libs/common/src/platform/models/data/server-config.data.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
ServerConfigResponse,
|
||||
ThirdPartyServerConfigResponse,
|
||||
EnvironmentServerConfigResponse,
|
||||
} from "../response/server-config.response";
|
||||
|
||||
export class ServerConfigData {
|
||||
version: string;
|
||||
gitHash: string;
|
||||
server?: ThirdPartyServerConfigData;
|
||||
environment?: EnvironmentServerConfigData;
|
||||
utcDate: string;
|
||||
featureStates: { [key: string]: string } = {};
|
||||
|
||||
constructor(serverConfigResponse: Partial<ServerConfigResponse>) {
|
||||
this.version = serverConfigResponse?.version;
|
||||
this.gitHash = serverConfigResponse?.gitHash;
|
||||
this.server = serverConfigResponse?.server
|
||||
? new ThirdPartyServerConfigData(serverConfigResponse.server)
|
||||
: null;
|
||||
this.utcDate = new Date().toISOString();
|
||||
this.environment = serverConfigResponse?.environment
|
||||
? new EnvironmentServerConfigData(serverConfigResponse.environment)
|
||||
: null;
|
||||
this.featureStates = serverConfigResponse?.featureStates;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<ServerConfigData>): ServerConfigData {
|
||||
return Object.assign(new ServerConfigData({}), obj, {
|
||||
server: obj?.server ? ThirdPartyServerConfigData.fromJSON(obj.server) : null,
|
||||
environment: obj?.environment ? EnvironmentServerConfigData.fromJSON(obj.environment) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ThirdPartyServerConfigData {
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
constructor(response: Partial<ThirdPartyServerConfigResponse>) {
|
||||
this.name = response.name;
|
||||
this.url = response.url;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<ThirdPartyServerConfigData>): ThirdPartyServerConfigData {
|
||||
return Object.assign(new ThirdPartyServerConfigData({}), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export class EnvironmentServerConfigData {
|
||||
vault: string;
|
||||
api: string;
|
||||
identity: string;
|
||||
notifications: string;
|
||||
sso: string;
|
||||
|
||||
constructor(response: Partial<EnvironmentServerConfigResponse>) {
|
||||
this.vault = response.vault;
|
||||
this.api = response.api;
|
||||
this.identity = response.identity;
|
||||
this.notifications = response.notifications;
|
||||
this.sso = response.sso;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<EnvironmentServerConfigData>): EnvironmentServerConfigData {
|
||||
return Object.assign(new EnvironmentServerConfigData({}), obj);
|
||||
}
|
||||
}
|
||||
61
libs/common/src/platform/models/domain/account-keys.spec.ts
Normal file
61
libs/common/src/platform/models/domain/account-keys.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { AccountKeys, EncryptionPair } from "./account";
|
||||
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
|
||||
describe("AccountKeys", () => {
|
||||
describe("toJSON", () => {
|
||||
it("should serialize itself", () => {
|
||||
const keys = new AccountKeys();
|
||||
const buffer = makeStaticByteArray(64).buffer;
|
||||
keys.publicKey = buffer;
|
||||
|
||||
const bufferSpy = jest.spyOn(Utils, "fromBufferToByteString");
|
||||
keys.toJSON();
|
||||
expect(bufferSpy).toHaveBeenCalledWith(buffer);
|
||||
});
|
||||
|
||||
it("should serialize public key as a string", () => {
|
||||
const keys = new AccountKeys();
|
||||
keys.publicKey = Utils.fromByteStringToArray("hello").buffer;
|
||||
const json = JSON.stringify(keys);
|
||||
expect(json).toContain('"publicKey":"hello"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize public key to a buffer", () => {
|
||||
const keys = AccountKeys.fromJSON({
|
||||
publicKey: "hello",
|
||||
});
|
||||
expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello").buffer);
|
||||
});
|
||||
|
||||
it("should deserialize cryptoMasterKey", () => {
|
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
AccountKeys.fromJSON({} as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize organizationKeys", () => {
|
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
AccountKeys.fromJSON({ organizationKeys: [{ orgId: "keyJSON" }] } as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize providerKeys", () => {
|
||||
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
|
||||
AccountKeys.fromJSON({ providerKeys: [{ providerId: "keyJSON" }] } as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deserialize privateKey", () => {
|
||||
const spy = jest.spyOn(EncryptionPair, "fromJSON");
|
||||
AccountKeys.fromJSON({
|
||||
privateKey: { encrypted: "encrypted", decrypted: "decrypted" },
|
||||
} as any);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AccountProfile } from "./account";
|
||||
|
||||
describe("AccountProfile", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(AccountProfile.fromJSON({})).toBeInstanceOf(AccountProfile);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { AccountSettings, EncryptionPair } from "./account";
|
||||
import { EncString } from "./enc-string";
|
||||
|
||||
describe("AccountSettings", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(AccountSettings.fromJSON(JSON.parse("{}"))).toBeInstanceOf(AccountSettings);
|
||||
});
|
||||
|
||||
it("should deserialize pinProtected", () => {
|
||||
const accountSettings = new AccountSettings();
|
||||
accountSettings.pinProtected = EncryptionPair.fromJSON<string, EncString>({
|
||||
encrypted: "encrypted",
|
||||
decrypted: "3.data",
|
||||
});
|
||||
const jsonObj = JSON.parse(JSON.stringify(accountSettings));
|
||||
const actual = AccountSettings.fromJSON(jsonObj);
|
||||
|
||||
expect(actual.pinProtected).toBeInstanceOf(EncryptionPair);
|
||||
expect(actual.pinProtected.encrypted).toEqual("encrypted");
|
||||
expect(actual.pinProtected.decrypted.encryptedString).toEqual("3.data");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AccountTokens } from "./account";
|
||||
|
||||
describe("AccountTokens", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(AccountTokens.fromJSON({})).toBeInstanceOf(AccountTokens);
|
||||
});
|
||||
});
|
||||
});
|
||||
23
libs/common/src/platform/models/domain/account.spec.ts
Normal file
23
libs/common/src/platform/models/domain/account.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Account, AccountKeys, AccountProfile, AccountSettings, AccountTokens } from "./account";
|
||||
|
||||
describe("Account", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(Account.fromJSON({})).toBeInstanceOf(Account);
|
||||
});
|
||||
|
||||
it("should call all the sub-fromJSONs", () => {
|
||||
const keysSpy = jest.spyOn(AccountKeys, "fromJSON");
|
||||
const profileSpy = jest.spyOn(AccountProfile, "fromJSON");
|
||||
const settingsSpy = jest.spyOn(AccountSettings, "fromJSON");
|
||||
const tokensSpy = jest.spyOn(AccountTokens, "fromJSON");
|
||||
|
||||
Account.fromJSON({});
|
||||
|
||||
expect(keysSpy).toHaveBeenCalled();
|
||||
expect(profileSpy).toHaveBeenCalled();
|
||||
expect(settingsSpy).toHaveBeenCalled();
|
||||
expect(tokensSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
316
libs/common/src/platform/models/domain/account.ts
Normal file
316
libs/common/src/platform/models/domain/account.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { CollectionData } from "../../../admin-console/models/data/collection.data";
|
||||
import { EncryptedOrganizationKeyData } from "../../../admin-console/models/data/encrypted-organization-key.data";
|
||||
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
|
||||
import { PolicyData } from "../../../admin-console/models/data/policy.data";
|
||||
import { ProviderData } from "../../../admin-console/models/data/provider.data";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { CollectionView } from "../../../admin-console/models/view/collection.view";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls";
|
||||
import { ForceResetPasswordReason } from "../../../auth/models/domain/force-reset-password-reason";
|
||||
import { KdfType, UriMatchType } from "../../../enums";
|
||||
import { EventData } from "../../../models/data/event.data";
|
||||
import { GeneratedPasswordHistory } from "../../../tools/generator/password";
|
||||
import { SendData } from "../../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../../tools/send/models/view/send.view";
|
||||
import { DeepJsonify } from "../../../types/deep-jsonify";
|
||||
import { CipherData } from "../../../vault/models/data/cipher.data";
|
||||
import { FolderData } from "../../../vault/models/data/folder.data";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
|
||||
import { EncString } from "./enc-string";
|
||||
import { DeviceKey, SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
|
||||
export class EncryptionPair<TEncrypted, TDecrypted> {
|
||||
encrypted?: TEncrypted;
|
||||
decrypted?: TDecrypted;
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
encrypted: this.encrypted,
|
||||
decrypted:
|
||||
this.decrypted instanceof ArrayBuffer
|
||||
? Utils.fromBufferToByteString(this.decrypted)
|
||||
: this.decrypted,
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON<TEncrypted, TDecrypted>(
|
||||
obj: { encrypted?: Jsonify<TEncrypted>; decrypted?: string | Jsonify<TDecrypted> },
|
||||
decryptedFromJson?: (decObj: Jsonify<TDecrypted> | string) => TDecrypted,
|
||||
encryptedFromJson?: (encObj: Jsonify<TEncrypted>) => TEncrypted
|
||||
) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pair = new EncryptionPair<TEncrypted, TDecrypted>();
|
||||
if (obj?.encrypted != null) {
|
||||
pair.encrypted = encryptedFromJson
|
||||
? encryptedFromJson(obj.encrypted)
|
||||
: (obj.encrypted as TEncrypted);
|
||||
}
|
||||
if (obj?.decrypted != null) {
|
||||
pair.decrypted = decryptedFromJson
|
||||
? decryptedFromJson(obj.decrypted)
|
||||
: (obj.decrypted as TDecrypted);
|
||||
}
|
||||
return pair;
|
||||
}
|
||||
}
|
||||
|
||||
export class DataEncryptionPair<TEncrypted, TDecrypted> {
|
||||
encrypted?: { [id: string]: TEncrypted };
|
||||
decrypted?: TDecrypted[];
|
||||
}
|
||||
|
||||
// This is a temporary structure to handle migrated `DataEncryptionPair` to
|
||||
// avoid needing a data migration at this stage. It should be replaced with
|
||||
// proper data migrations when `DataEncryptionPair` is deprecated.
|
||||
export class TemporaryDataEncryption<TEncrypted> {
|
||||
encrypted?: { [id: string]: TEncrypted };
|
||||
}
|
||||
|
||||
export class AccountData {
|
||||
ciphers?: DataEncryptionPair<CipherData, CipherView> = new DataEncryptionPair<
|
||||
CipherData,
|
||||
CipherView
|
||||
>();
|
||||
folders? = new TemporaryDataEncryption<FolderData>();
|
||||
localData?: any;
|
||||
sends?: DataEncryptionPair<SendData, SendView> = new DataEncryptionPair<SendData, SendView>();
|
||||
collections?: DataEncryptionPair<CollectionData, CollectionView> = new DataEncryptionPair<
|
||||
CollectionData,
|
||||
CollectionView
|
||||
>();
|
||||
policies?: DataEncryptionPair<PolicyData, Policy> = new DataEncryptionPair<PolicyData, Policy>();
|
||||
passwordGenerationHistory?: EncryptionPair<
|
||||
GeneratedPasswordHistory[],
|
||||
GeneratedPasswordHistory[]
|
||||
> = new EncryptionPair<GeneratedPasswordHistory[], GeneratedPasswordHistory[]>();
|
||||
addEditCipherInfo?: any;
|
||||
eventCollection?: EventData[];
|
||||
organizations?: { [id: string]: OrganizationData };
|
||||
providers?: { [id: string]: ProviderData };
|
||||
}
|
||||
|
||||
export class AccountKeys {
|
||||
cryptoMasterKey?: SymmetricCryptoKey;
|
||||
cryptoMasterKeyAuto?: string;
|
||||
cryptoMasterKeyB64?: string;
|
||||
cryptoMasterKeyBiometric?: string;
|
||||
cryptoSymmetricKey?: EncryptionPair<string, SymmetricCryptoKey> = new EncryptionPair<
|
||||
string,
|
||||
SymmetricCryptoKey
|
||||
>();
|
||||
deviceKey?: DeviceKey;
|
||||
organizationKeys?: EncryptionPair<
|
||||
{ [orgId: string]: EncryptedOrganizationKeyData },
|
||||
Record<string, SymmetricCryptoKey>
|
||||
> = new EncryptionPair<
|
||||
{ [orgId: string]: EncryptedOrganizationKeyData },
|
||||
Record<string, SymmetricCryptoKey>
|
||||
>();
|
||||
providerKeys?: EncryptionPair<any, Record<string, SymmetricCryptoKey>> = new EncryptionPair<
|
||||
any,
|
||||
Record<string, SymmetricCryptoKey>
|
||||
>();
|
||||
privateKey?: EncryptionPair<string, ArrayBuffer> = new EncryptionPair<string, ArrayBuffer>();
|
||||
publicKey?: ArrayBuffer;
|
||||
apiKeyClientSecret?: string;
|
||||
|
||||
toJSON() {
|
||||
return Utils.merge(this, {
|
||||
publicKey: Utils.fromBufferToByteString(this.publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
static fromJSON(obj: DeepJsonify<AccountKeys>): AccountKeys {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new AccountKeys(), {
|
||||
cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey),
|
||||
cryptoSymmetricKey: EncryptionPair.fromJSON(
|
||||
obj?.cryptoSymmetricKey,
|
||||
SymmetricCryptoKey.fromJSON
|
||||
),
|
||||
organizationKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.organizationKeys),
|
||||
providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys),
|
||||
privateKey: EncryptionPair.fromJSON<string, ArrayBuffer>(
|
||||
obj?.privateKey,
|
||||
(decObj: string) => Utils.fromByteStringToArray(decObj).buffer
|
||||
),
|
||||
publicKey: Utils.fromByteStringToArray(obj?.publicKey)?.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
static initRecordEncryptionPairsFromJSON(obj: any) {
|
||||
return EncryptionPair.fromJSON(obj, (decObj: any) => {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record: Record<string, SymmetricCryptoKey> = {};
|
||||
for (const id in decObj) {
|
||||
record[id] = SymmetricCryptoKey.fromJSON(decObj[id]);
|
||||
}
|
||||
return record;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountProfile {
|
||||
apiKeyClientId?: string;
|
||||
authenticationStatus?: AuthenticationStatus;
|
||||
convertAccountToKeyConnector?: boolean;
|
||||
name?: string;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
entityId?: string;
|
||||
entityType?: string;
|
||||
everBeenUnlocked?: boolean;
|
||||
forcePasswordResetReason?: ForceResetPasswordReason;
|
||||
hasPremiumPersonally?: boolean;
|
||||
hasPremiumFromOrganization?: boolean;
|
||||
lastSync?: string;
|
||||
userId?: string;
|
||||
usesKeyConnector?: boolean;
|
||||
keyHash?: string;
|
||||
kdfIterations?: number;
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
kdfType?: KdfType;
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountProfile>): AccountProfile {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new AccountProfile(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountSettings {
|
||||
autoConfirmFingerPrints?: boolean;
|
||||
autoFillOnPageLoadDefault?: boolean;
|
||||
biometricUnlock?: boolean;
|
||||
clearClipboard?: number;
|
||||
collapsedGroupings?: string[];
|
||||
defaultUriMatch?: UriMatchType;
|
||||
disableAddLoginNotification?: boolean;
|
||||
disableAutoBiometricsPrompt?: boolean;
|
||||
disableAutoTotpCopy?: boolean;
|
||||
disableBadgeCounter?: boolean;
|
||||
disableChangedPasswordNotification?: boolean;
|
||||
disableContextMenuItem?: boolean;
|
||||
disableGa?: boolean;
|
||||
dismissedAutoFillOnPageLoadCallout?: boolean;
|
||||
dontShowCardsCurrentTab?: boolean;
|
||||
dontShowIdentitiesCurrentTab?: boolean;
|
||||
enableAlwaysOnTop?: boolean;
|
||||
enableAutoFillOnPageLoad?: boolean;
|
||||
enableBiometric?: boolean;
|
||||
enableFullWidth?: boolean;
|
||||
environmentUrls: EnvironmentUrls = new EnvironmentUrls();
|
||||
equivalentDomains?: any;
|
||||
minimizeOnCopyToClipboard?: boolean;
|
||||
neverDomains?: { [id: string]: any };
|
||||
passwordGenerationOptions?: any;
|
||||
usernameGenerationOptions?: any;
|
||||
generatorOptions?: any;
|
||||
pinProtected?: EncryptionPair<string, EncString> = new EncryptionPair<string, EncString>();
|
||||
protectedPin?: string;
|
||||
settings?: AccountSettingsSettings; // TODO: Merge whatever is going on here into the AccountSettings model properly
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string = "lock";
|
||||
serverConfig?: ServerConfigData;
|
||||
approveLoginRequests?: boolean;
|
||||
avatarColor?: string;
|
||||
activateAutoFillOnPageLoadFromPolicy?: boolean;
|
||||
smOnboardingTasks?: Record<string, Record<string, boolean>>;
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new AccountSettings(), obj, {
|
||||
environmentUrls: EnvironmentUrls.fromJSON(obj?.environmentUrls),
|
||||
pinProtected: EncryptionPair.fromJSON<string, EncString>(
|
||||
obj?.pinProtected,
|
||||
EncString.fromJSON
|
||||
),
|
||||
serverConfig: ServerConfigData.fromJSON(obj?.serverConfig),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountSettingsSettings = {
|
||||
equivalentDomains?: string[][];
|
||||
};
|
||||
|
||||
export class AccountTokens {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
securityStamp?: string;
|
||||
|
||||
static fromJSON(obj: Jsonify<AccountTokens>): AccountTokens {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new AccountTokens(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export class Account {
|
||||
data?: AccountData = new AccountData();
|
||||
keys?: AccountKeys = new AccountKeys();
|
||||
profile?: AccountProfile = new AccountProfile();
|
||||
settings?: AccountSettings = new AccountSettings();
|
||||
tokens?: AccountTokens = new AccountTokens();
|
||||
|
||||
constructor(init: Partial<Account>) {
|
||||
Object.assign(this, {
|
||||
data: {
|
||||
...new AccountData(),
|
||||
...init?.data,
|
||||
},
|
||||
keys: {
|
||||
...new AccountKeys(),
|
||||
...init?.keys,
|
||||
},
|
||||
profile: {
|
||||
...new AccountProfile(),
|
||||
...init?.profile,
|
||||
},
|
||||
settings: {
|
||||
...new AccountSettings(),
|
||||
...init?.settings,
|
||||
},
|
||||
tokens: {
|
||||
...new AccountTokens(),
|
||||
...init?.tokens,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<Account>): Account {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new Account({}), json, {
|
||||
keys: AccountKeys.fromJSON(json?.keys),
|
||||
profile: AccountProfile.fromJSON(json?.profile),
|
||||
settings: AccountSettings.fromJSON(json?.settings),
|
||||
tokens: AccountTokens.fromJSON(json?.tokens),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class DecryptParameters<T> {
|
||||
encKey: T;
|
||||
data: T;
|
||||
iv: T;
|
||||
macKey: T;
|
||||
mac: T;
|
||||
macData: T;
|
||||
}
|
||||
83
libs/common/src/platform/models/domain/domain-base.ts
Normal file
83
libs/common/src/platform/models/domain/domain-base.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { View } from "../../../models/view/view";
|
||||
|
||||
import { EncString } from "./enc-string";
|
||||
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
|
||||
// https://contributing.bitwarden.com/architecture/clients/data-model#domain
|
||||
export default class Domain {
|
||||
protected buildDomainModel<D extends Domain>(
|
||||
domain: D,
|
||||
dataObj: any,
|
||||
map: any,
|
||||
notEncList: any[] = []
|
||||
) {
|
||||
for (const prop in map) {
|
||||
// eslint-disable-next-line
|
||||
if (!map.hasOwnProperty(prop)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objProp = dataObj[map[prop] || prop];
|
||||
if (notEncList.indexOf(prop) > -1) {
|
||||
(domain as any)[prop] = objProp ? objProp : null;
|
||||
} else {
|
||||
(domain as any)[prop] = objProp ? new EncString(objProp) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
protected buildDataModel<D extends Domain>(
|
||||
domain: D,
|
||||
dataObj: any,
|
||||
map: any,
|
||||
notEncStringList: any[] = []
|
||||
) {
|
||||
for (const prop in map) {
|
||||
// eslint-disable-next-line
|
||||
if (!map.hasOwnProperty(prop)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objProp = (domain as any)[map[prop] || prop];
|
||||
if (notEncStringList.indexOf(prop) > -1) {
|
||||
(dataObj as any)[prop] = objProp != null ? objProp : null;
|
||||
} else {
|
||||
(dataObj as any)[prop] = objProp != null ? (objProp as EncString).encryptedString : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async decryptObj<T extends View>(
|
||||
viewModel: T,
|
||||
map: any,
|
||||
orgId: string,
|
||||
key: SymmetricCryptoKey = null
|
||||
): Promise<T> {
|
||||
const promises = [];
|
||||
const self: any = this;
|
||||
|
||||
for (const prop in map) {
|
||||
// eslint-disable-next-line
|
||||
if (!map.hasOwnProperty(prop)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(function (theProp) {
|
||||
const p = Promise.resolve()
|
||||
.then(() => {
|
||||
const mapProp = map[theProp] || theProp;
|
||||
if (self[mapProp]) {
|
||||
return self[mapProp].decrypt(orgId, key);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.then((val: any) => {
|
||||
(viewModel as any)[theProp] = val;
|
||||
});
|
||||
promises.push(p);
|
||||
})(prop);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
return viewModel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { EncryptionType } from "../../../enums";
|
||||
|
||||
import { EncArrayBuffer } from "./enc-array-buffer";
|
||||
|
||||
describe("encArrayBuffer", () => {
|
||||
describe("parses the buffer", () => {
|
||||
test.each([
|
||||
[EncryptionType.AesCbc128_HmacSha256_B64, "AesCbc128_HmacSha256_B64"],
|
||||
[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"],
|
||||
])("with %c%s", (encType: EncryptionType) => {
|
||||
const iv = makeStaticByteArray(16, 10);
|
||||
const mac = makeStaticByteArray(32, 20);
|
||||
// We use the minimum data length of 1 to test the boundary of valid lengths
|
||||
const data = makeStaticByteArray(1, 100);
|
||||
|
||||
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
|
||||
array.set([encType]);
|
||||
array.set(iv, 1);
|
||||
array.set(mac, 1 + iv.byteLength);
|
||||
array.set(data, 1 + iv.byteLength + mac.byteLength);
|
||||
|
||||
const actual = new EncArrayBuffer(array.buffer);
|
||||
|
||||
expect(actual.encryptionType).toEqual(encType);
|
||||
expect(actual.ivBytes).toEqualBuffer(iv);
|
||||
expect(actual.macBytes).toEqualBuffer(mac);
|
||||
expect(actual.dataBytes).toEqualBuffer(data);
|
||||
});
|
||||
|
||||
it("with AesCbc256_B64", () => {
|
||||
const encType = EncryptionType.AesCbc256_B64;
|
||||
const iv = makeStaticByteArray(16, 10);
|
||||
// We use the minimum data length of 1 to test the boundary of valid lengths
|
||||
const data = makeStaticByteArray(1, 100);
|
||||
|
||||
const array = new Uint8Array(1 + iv.byteLength + data.byteLength);
|
||||
array.set([encType]);
|
||||
array.set(iv, 1);
|
||||
array.set(data, 1 + iv.byteLength);
|
||||
|
||||
const actual = new EncArrayBuffer(array.buffer);
|
||||
|
||||
expect(actual.encryptionType).toEqual(encType);
|
||||
expect(actual.ivBytes).toEqualBuffer(iv);
|
||||
expect(actual.dataBytes).toEqualBuffer(data);
|
||||
expect(actual.macBytes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("throws if the buffer has an invalid length", () => {
|
||||
test.each([
|
||||
[EncryptionType.AesCbc128_HmacSha256_B64, 50, "AesCbc128_HmacSha256_B64"],
|
||||
[EncryptionType.AesCbc256_HmacSha256_B64, 50, "AesCbc256_HmacSha256_B64"],
|
||||
[EncryptionType.AesCbc256_B64, 18, "AesCbc256_B64"],
|
||||
])("with %c%c%s", (encType: EncryptionType, minLength: number) => {
|
||||
// Generate invalid byte array
|
||||
// Minus 1 to leave room for the encType, minus 1 to make it invalid
|
||||
const invalidBytes = makeStaticByteArray(minLength - 2);
|
||||
|
||||
const invalidArray = new Uint8Array(1 + invalidBytes.buffer.byteLength);
|
||||
invalidArray.set([encType]);
|
||||
invalidArray.set(invalidBytes, 1);
|
||||
|
||||
expect(() => new EncArrayBuffer(invalidArray.buffer)).toThrow(
|
||||
"Error parsing encrypted ArrayBuffer"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't parse the buffer if the encryptionType is not supported", () => {
|
||||
// Starting at 9 implicitly gives us an invalid encType
|
||||
const bytes = makeStaticByteArray(50, 9);
|
||||
expect(() => new EncArrayBuffer(bytes)).toThrow("Error parsing encrypted ArrayBuffer");
|
||||
});
|
||||
});
|
||||
73
libs/common/src/platform/models/domain/enc-array-buffer.ts
Normal file
73
libs/common/src/platform/models/domain/enc-array-buffer.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { EncryptionType } from "../../../enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { Encrypted } from "../../interfaces/encrypted";
|
||||
|
||||
const ENC_TYPE_LENGTH = 1;
|
||||
const IV_LENGTH = 16;
|
||||
const MAC_LENGTH = 32;
|
||||
const MIN_DATA_LENGTH = 1;
|
||||
|
||||
export class EncArrayBuffer implements Encrypted {
|
||||
readonly encryptionType: EncryptionType = null;
|
||||
readonly dataBytes: ArrayBuffer = null;
|
||||
readonly ivBytes: ArrayBuffer = null;
|
||||
readonly macBytes: ArrayBuffer = null;
|
||||
|
||||
constructor(readonly buffer: ArrayBuffer) {
|
||||
const encBytes = new Uint8Array(buffer);
|
||||
const encType = encBytes[0];
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64: {
|
||||
const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + MIN_DATA_LENGTH;
|
||||
if (encBytes.length < minimumLength) {
|
||||
this.throwDecryptionError();
|
||||
}
|
||||
|
||||
this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH).buffer;
|
||||
this.macBytes = encBytes.slice(
|
||||
ENC_TYPE_LENGTH + IV_LENGTH,
|
||||
ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH
|
||||
).buffer;
|
||||
this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH).buffer;
|
||||
break;
|
||||
}
|
||||
case EncryptionType.AesCbc256_B64: {
|
||||
const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MIN_DATA_LENGTH;
|
||||
if (encBytes.length < minimumLength) {
|
||||
this.throwDecryptionError();
|
||||
}
|
||||
|
||||
this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH).buffer;
|
||||
this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH).buffer;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.throwDecryptionError();
|
||||
}
|
||||
|
||||
this.encryptionType = encType;
|
||||
}
|
||||
|
||||
private throwDecryptionError() {
|
||||
throw new Error(
|
||||
"Error parsing encrypted ArrayBuffer: data is corrupted or has an invalid format."
|
||||
);
|
||||
}
|
||||
|
||||
static async fromResponse(response: {
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
}): Promise<EncArrayBuffer> {
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer == null) {
|
||||
throw new Error("Cannot create EncArrayBuffer from Response - Response is empty");
|
||||
}
|
||||
return new EncArrayBuffer(buffer);
|
||||
}
|
||||
|
||||
static fromB64(b64: string) {
|
||||
const buffer = Utils.fromB64ToArray(b64).buffer;
|
||||
return new EncArrayBuffer(buffer);
|
||||
}
|
||||
}
|
||||
266
libs/common/src/platform/models/domain/enc-string.spec.ts
Normal file
266
libs/common/src/platform/models/domain/enc-string.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Substitute, Arg } from "@fluffy-spoon/substitute";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { EncryptionType } from "../../../enums";
|
||||
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CryptoService } from "../../abstractions/crypto.service";
|
||||
import { ContainerService } from "../../services/container.service";
|
||||
|
||||
import { EncString } from "./enc-string";
|
||||
|
||||
describe("EncString", () => {
|
||||
afterEach(() => {
|
||||
(window as any).bitwardenContainerService = undefined;
|
||||
});
|
||||
|
||||
describe("Rsa2048_OaepSha256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "3.data",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSerializedEncString", () => {
|
||||
it("is true if valid", () => {
|
||||
expect(EncString.isSerializedEncString("3.data")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false if invalid", () => {
|
||||
expect(EncString.isSerializedEncString("3.data|test")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse existing", () => {
|
||||
it("valid", () => {
|
||||
const encString = new EncString("3.data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "3.data",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("3.data|test");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "3.data|test",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
|
||||
|
||||
const cryptoService = Substitute.for<CryptoService>();
|
||||
cryptoService.getOrgKey(null).resolves(null);
|
||||
|
||||
const encryptService = Substitute.for<EncryptService>();
|
||||
encryptService.decryptToUtf8(encString, Arg.any()).resolves("decrypted");
|
||||
|
||||
beforeEach(() => {
|
||||
(window as any).bitwardenContainerService = new ContainerService(
|
||||
cryptoService,
|
||||
encryptService
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts correctly", async () => {
|
||||
const decrypted = await encString.decrypt(null);
|
||||
|
||||
expect(decrypted).toBe("decrypted");
|
||||
});
|
||||
|
||||
it("result should be cached", async () => {
|
||||
const decrypted = await encString.decrypt(null);
|
||||
encryptService.received(1).decryptToUtf8(Arg.any(), Arg.any());
|
||||
|
||||
expect(decrypted).toBe("decrypted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AesCbc256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "0.iv|data",
|
||||
encryptionType: 0,
|
||||
iv: "iv",
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSerializedEncString", () => {
|
||||
it("is true if valid", () => {
|
||||
expect(EncString.isSerializedEncString("0.iv|data")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false if invalid", () => {
|
||||
expect(EncString.isSerializedEncString("0.iv|data|mac")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse existing", () => {
|
||||
it("valid", () => {
|
||||
const encString = new EncString("0.iv|data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "0.iv|data",
|
||||
encryptionType: 0,
|
||||
iv: "iv",
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("0.iv|data|mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "0.iv|data|mac",
|
||||
encryptionType: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AesCbc256_HmacSha256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "2.iv|data|mac",
|
||||
encryptionType: 2,
|
||||
iv: "iv",
|
||||
mac: "mac",
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSerializedEncString", () => {
|
||||
it("is true if valid", () => {
|
||||
expect(EncString.isSerializedEncString("2.iv|data|mac")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false if invalid", () => {
|
||||
expect(EncString.isSerializedEncString("2.iv|data")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("valid", () => {
|
||||
const encString = new EncString("2.iv|data|mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "2.iv|data|mac",
|
||||
encryptionType: 2,
|
||||
iv: "iv",
|
||||
mac: "mac",
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("2.iv|data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "2.iv|data",
|
||||
encryptionType: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Exit early if null", () => {
|
||||
const encString = new EncString(null);
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let encString: EncString;
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoService = mock<CryptoService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
encString = new EncString(null);
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(
|
||||
cryptoService,
|
||||
encryptService
|
||||
);
|
||||
});
|
||||
|
||||
it("handles value it can't decrypt", async () => {
|
||||
encryptService.decryptToUtf8.mockRejectedValue("error");
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(
|
||||
cryptoService,
|
||||
encryptService
|
||||
);
|
||||
|
||||
const decrypted = await encString.decrypt(null);
|
||||
|
||||
expect(decrypted).toBe("[error: cannot decrypt]");
|
||||
|
||||
expect(encString).toEqual({
|
||||
decryptedValue: "[error: cannot decrypt]",
|
||||
encryptedString: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses provided key without depending on CryptoService", async () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
|
||||
await encString.decrypt(null, key);
|
||||
|
||||
expect(cryptoService.getKeyForUserEncryption).not.toHaveBeenCalled();
|
||||
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, key);
|
||||
});
|
||||
|
||||
it("gets an organization key if required", async () => {
|
||||
const orgKey = mock<SymmetricCryptoKey>();
|
||||
|
||||
cryptoService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey);
|
||||
|
||||
await encString.decrypt("orgId", null);
|
||||
|
||||
expect(cryptoService.getOrgKey).toHaveBeenCalledWith("orgId");
|
||||
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, orgKey);
|
||||
});
|
||||
|
||||
it("gets the user's decryption key if required", async () => {
|
||||
const userKey = mock<SymmetricCryptoKey>();
|
||||
|
||||
cryptoService.getKeyForUserEncryption.mockResolvedValue(userKey);
|
||||
|
||||
await encString.decrypt(null, null);
|
||||
|
||||
expect(cryptoService.getKeyForUserEncryption).toHaveBeenCalledWith();
|
||||
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, userKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toJSON", () => {
|
||||
it("Should be represented by the encrypted string", () => {
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv");
|
||||
|
||||
expect(encString.toJSON()).toBe(encString.encryptedString);
|
||||
});
|
||||
|
||||
it("returns null if object is null", () => {
|
||||
expect(EncString.fromJSON(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
167
libs/common/src/platform/models/domain/enc-string.ts
Normal file
167
libs/common/src/platform/models/domain/enc-string.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncryptionType, EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE } from "../../../enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { Encrypted } from "../../interfaces/encrypted";
|
||||
|
||||
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
|
||||
export class EncString implements Encrypted {
|
||||
encryptedString?: string;
|
||||
encryptionType?: EncryptionType;
|
||||
decryptedValue?: string;
|
||||
data?: string;
|
||||
iv?: string;
|
||||
mac?: string;
|
||||
|
||||
constructor(
|
||||
encryptedStringOrType: string | EncryptionType,
|
||||
data?: string,
|
||||
iv?: string,
|
||||
mac?: string
|
||||
) {
|
||||
if (data != null) {
|
||||
this.initFromData(encryptedStringOrType as EncryptionType, data, iv, mac);
|
||||
} else {
|
||||
this.initFromEncryptedString(encryptedStringOrType as string);
|
||||
}
|
||||
}
|
||||
|
||||
get ivBytes(): ArrayBuffer {
|
||||
return this.iv == null ? null : Utils.fromB64ToArray(this.iv).buffer;
|
||||
}
|
||||
|
||||
get macBytes(): ArrayBuffer {
|
||||
return this.mac == null ? null : Utils.fromB64ToArray(this.mac).buffer;
|
||||
}
|
||||
|
||||
get dataBytes(): ArrayBuffer {
|
||||
return this.data == null ? null : Utils.fromB64ToArray(this.data).buffer;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.encryptedString;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<EncString>): EncString {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EncString(obj);
|
||||
}
|
||||
|
||||
private initFromData(encType: EncryptionType, data: string, iv: string, mac: string) {
|
||||
if (iv != null) {
|
||||
this.encryptedString = encType + "." + iv + "|" + data;
|
||||
} else {
|
||||
this.encryptedString = encType + "." + data;
|
||||
}
|
||||
|
||||
// mac
|
||||
if (mac != null) {
|
||||
this.encryptedString += "|" + mac;
|
||||
}
|
||||
|
||||
this.encryptionType = encType;
|
||||
this.data = data;
|
||||
this.iv = iv;
|
||||
this.mac = mac;
|
||||
}
|
||||
|
||||
private initFromEncryptedString(encryptedString: string) {
|
||||
this.encryptedString = encryptedString as string;
|
||||
if (!this.encryptedString) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { encType, encPieces } = EncString.parseEncryptedString(this.encryptedString);
|
||||
this.encryptionType = encType;
|
||||
|
||||
if (encPieces.length !== EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64:
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
this.mac = encPieces[2];
|
||||
break;
|
||||
case EncryptionType.AesCbc256_B64:
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
this.data = encPieces[0];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static parseEncryptedString(encryptedString: string): {
|
||||
encType: EncryptionType;
|
||||
encPieces: string[];
|
||||
} {
|
||||
const headerPieces = encryptedString.split(".");
|
||||
let encType: EncryptionType;
|
||||
let encPieces: string[] = null;
|
||||
|
||||
if (headerPieces.length === 2) {
|
||||
try {
|
||||
encType = parseInt(headerPieces[0], null);
|
||||
encPieces = headerPieces[1].split("|");
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
encPieces = encryptedString.split("|");
|
||||
encType =
|
||||
encPieces.length === 3
|
||||
? EncryptionType.AesCbc128_HmacSha256_B64
|
||||
: EncryptionType.AesCbc256_B64;
|
||||
}
|
||||
|
||||
return {
|
||||
encType,
|
||||
encPieces,
|
||||
};
|
||||
}
|
||||
|
||||
static isSerializedEncString(s: string): boolean {
|
||||
const { encType, encPieces } = this.parseEncryptedString(s);
|
||||
|
||||
return EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType] === encPieces.length;
|
||||
}
|
||||
|
||||
async decrypt(orgId: string, key: SymmetricCryptoKey = null): Promise<string> {
|
||||
if (this.decryptedValue != null) {
|
||||
return this.decryptedValue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (key == null) {
|
||||
key = await this.getKeyForDecryption(orgId);
|
||||
}
|
||||
if (key == null) {
|
||||
throw new Error("No key to decrypt EncString with orgId " + orgId);
|
||||
}
|
||||
|
||||
const encryptService = Utils.getContainerService().getEncryptService();
|
||||
this.decryptedValue = await encryptService.decryptToUtf8(this, key);
|
||||
} catch (e) {
|
||||
this.decryptedValue = "[error: cannot decrypt]";
|
||||
}
|
||||
return this.decryptedValue;
|
||||
}
|
||||
|
||||
private async getKeyForDecryption(orgId: string) {
|
||||
const cryptoService = Utils.getContainerService().getCryptoService();
|
||||
return orgId != null
|
||||
? await cryptoService.getOrgKey(orgId)
|
||||
: await cryptoService.getKeyForUserEncryption();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export class EncryptedObject {
|
||||
iv: ArrayBuffer;
|
||||
data: ArrayBuffer;
|
||||
mac: ArrayBuffer;
|
||||
key: SymmetricCryptoKey;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { EncryptionPair } from "./account";
|
||||
|
||||
describe("EncryptionPair", () => {
|
||||
describe("toJSON", () => {
|
||||
it("should populate decryptedSerialized for buffer arrays", () => {
|
||||
const pair = new EncryptionPair<string, ArrayBuffer>();
|
||||
pair.decrypted = Utils.fromByteStringToArray("hello").buffer;
|
||||
const json = pair.toJSON();
|
||||
expect(json.decrypted).toEqual("hello");
|
||||
});
|
||||
|
||||
it("should serialize encrypted and decrypted", () => {
|
||||
const pair = new EncryptionPair<string, string>();
|
||||
pair.encrypted = "hello";
|
||||
pair.decrypted = "world";
|
||||
const json = pair.toJSON();
|
||||
expect(json.encrypted).toEqual("hello");
|
||||
expect(json.decrypted).toEqual("world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize encrypted and decrypted", () => {
|
||||
const pair = EncryptionPair.fromJSON({
|
||||
encrypted: "hello",
|
||||
decrypted: "world",
|
||||
});
|
||||
expect(pair.encrypted).toEqual("hello");
|
||||
expect(pair.decrypted).toEqual("world");
|
||||
});
|
||||
});
|
||||
});
|
||||
39
libs/common/src/platform/models/domain/global-state.ts
Normal file
39
libs/common/src/platform/models/domain/global-state.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls";
|
||||
import { StateVersion, ThemeType } from "../../../enums";
|
||||
import { WindowState } from "../../../models/domain/window-state";
|
||||
|
||||
export class GlobalState {
|
||||
enableAlwaysOnTop?: boolean;
|
||||
installedVersion?: string;
|
||||
locale?: string;
|
||||
organizationInvitation?: any;
|
||||
emergencyAccessInvitation?: any;
|
||||
ssoCodeVerifier?: string;
|
||||
ssoOrganizationIdentifier?: string;
|
||||
ssoState?: string;
|
||||
rememberedEmail?: string;
|
||||
theme?: ThemeType = ThemeType.System;
|
||||
window?: WindowState = new WindowState();
|
||||
twoFactorToken?: string;
|
||||
disableFavicon?: boolean;
|
||||
biometricAwaitingAcceptance?: boolean;
|
||||
biometricFingerprintValidated?: boolean;
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string;
|
||||
loginRedirect?: any;
|
||||
mainWindowSize?: number;
|
||||
enableBiometrics?: boolean;
|
||||
biometricText?: string;
|
||||
noAutoPromptBiometricsText?: string;
|
||||
stateVersion: StateVersion = StateVersion.One;
|
||||
environmentUrls: EnvironmentUrls = new EnvironmentUrls();
|
||||
enableTray?: boolean;
|
||||
enableMinimizeToTray?: boolean;
|
||||
enableCloseToTray?: boolean;
|
||||
enableStartToTray?: boolean;
|
||||
openAtLogin?: boolean;
|
||||
alwaysShowDock?: boolean;
|
||||
enableBrowserIntegration?: boolean;
|
||||
enableBrowserIntegrationFingerprint?: boolean;
|
||||
enableDuckDuckGoBrowserIntegration?: boolean;
|
||||
}
|
||||
31
libs/common/src/platform/models/domain/state.spec.ts
Normal file
31
libs/common/src/platform/models/domain/state.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Account } from "./account";
|
||||
import { State } from "./state";
|
||||
|
||||
describe("state", () => {
|
||||
describe("fromJSON", () => {
|
||||
it("should deserialize to an instance of itself", () => {
|
||||
expect(State.fromJSON({}, () => new Account({}))).toBeInstanceOf(State);
|
||||
});
|
||||
|
||||
it("should always assign an object to accounts", () => {
|
||||
const state = State.fromJSON({}, () => new Account({}));
|
||||
expect(state.accounts).not.toBeNull();
|
||||
expect(state.accounts).toEqual({});
|
||||
});
|
||||
|
||||
it("should build an account map", () => {
|
||||
const accountsSpy = jest.spyOn(Account, "fromJSON");
|
||||
const state = State.fromJSON(
|
||||
{
|
||||
accounts: {
|
||||
userId: {},
|
||||
},
|
||||
},
|
||||
Account.fromJSON
|
||||
);
|
||||
|
||||
expect(state.accounts["userId"]).toBeInstanceOf(Account);
|
||||
expect(accountsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
47
libs/common/src/platform/models/domain/state.ts
Normal file
47
libs/common/src/platform/models/domain/state.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Account } from "./account";
|
||||
import { GlobalState } from "./global-state";
|
||||
|
||||
export class State<
|
||||
TGlobalState extends GlobalState = GlobalState,
|
||||
TAccount extends Account = Account
|
||||
> {
|
||||
accounts: { [userId: string]: TAccount } = {};
|
||||
globals: TGlobalState;
|
||||
activeUserId: string;
|
||||
authenticatedAccounts: string[] = [];
|
||||
accountActivity: { [userId: string]: number } = {};
|
||||
|
||||
constructor(globals: TGlobalState) {
|
||||
this.globals = globals;
|
||||
}
|
||||
|
||||
// TODO, make Jsonify<State,TGlobalState,TAccount> work. It currently doesn't because Globals doesn't implement Jsonify.
|
||||
static fromJSON<TGlobalState extends GlobalState, TAccount extends Account>(
|
||||
obj: any,
|
||||
accountDeserializer: (json: Jsonify<TAccount>) => TAccount
|
||||
): State<TGlobalState, TAccount> {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(new State(null), obj, {
|
||||
accounts: State.buildAccountMapFromJSON(obj?.accounts, accountDeserializer),
|
||||
});
|
||||
}
|
||||
|
||||
private static buildAccountMapFromJSON<TAccount extends Account>(
|
||||
jsonAccounts: { [userId: string]: Jsonify<TAccount> },
|
||||
accountDeserializer: (json: Jsonify<TAccount>) => TAccount
|
||||
) {
|
||||
if (!jsonAccounts) {
|
||||
return {};
|
||||
}
|
||||
const accounts: { [userId: string]: TAccount } = {};
|
||||
for (const userId in jsonAccounts) {
|
||||
accounts[userId] = accountDeserializer(jsonAccounts[userId]);
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
}
|
||||
13
libs/common/src/platform/models/domain/storage-options.ts
Normal file
13
libs/common/src/platform/models/domain/storage-options.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { HtmlStorageLocation, StorageLocation } from "../../../enums";
|
||||
|
||||
export type StorageOptions = {
|
||||
storageLocation?: StorageLocation;
|
||||
useSecureStorage?: boolean;
|
||||
userId?: string;
|
||||
htmlStorageLocation?: HtmlStorageLocation;
|
||||
keySuffix?: string;
|
||||
};
|
||||
|
||||
export type MemoryStorageOptions<T> = StorageOptions & { deserializer?: (obj: Jsonify<T>) => T };
|
||||
@@ -0,0 +1,86 @@
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { EncryptionType } from "../../../enums";
|
||||
|
||||
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
|
||||
describe("SymmetricCryptoKey", () => {
|
||||
it("errors if no key", () => {
|
||||
const t = () => {
|
||||
new SymmetricCryptoKey(null);
|
||||
};
|
||||
|
||||
expect(t).toThrowError("Must provide key");
|
||||
});
|
||||
|
||||
describe("guesses encKey from key length", () => {
|
||||
it("AesCbc256_B64", () => {
|
||||
const key = makeStaticByteArray(32);
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
encKey: key,
|
||||
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
encType: 0,
|
||||
key: key,
|
||||
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
macKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("AesCbc128_HmacSha256_B64", () => {
|
||||
const key = makeStaticByteArray(32);
|
||||
const cryptoKey = new SymmetricCryptoKey(key, EncryptionType.AesCbc128_HmacSha256_B64);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
encKey: key.slice(0, 16),
|
||||
encKeyB64: "AAECAwQFBgcICQoLDA0ODw==",
|
||||
encType: 1,
|
||||
key: key,
|
||||
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
macKey: key.slice(16, 32),
|
||||
macKeyB64: "EBESExQVFhcYGRobHB0eHw==",
|
||||
});
|
||||
});
|
||||
|
||||
it("AesCbc256_HmacSha256_B64", () => {
|
||||
const key = makeStaticByteArray(64);
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
encKey: key.slice(0, 32),
|
||||
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
encType: 2,
|
||||
key: key,
|
||||
keyB64:
|
||||
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==",
|
||||
macKey: key.slice(32, 64),
|
||||
macKeyB64: "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=",
|
||||
});
|
||||
});
|
||||
|
||||
it("unknown length", () => {
|
||||
const t = () => {
|
||||
new SymmetricCryptoKey(makeStaticByteArray(30));
|
||||
};
|
||||
|
||||
expect(t).toThrowError("Unable to determine encType.");
|
||||
});
|
||||
});
|
||||
|
||||
it("toJSON creates object for serialization", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64).buffer);
|
||||
const actual = key.toJSON();
|
||||
|
||||
const expected = { keyB64: key.keyB64 };
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("fromJSON hydrates new object", () => {
|
||||
const expected = new SymmetricCryptoKey(makeStaticByteArray(64).buffer);
|
||||
const actual = SymmetricCryptoKey.fromJSON({ keyB64: expected.keyB64 });
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
expect(actual).toBeInstanceOf(SymmetricCryptoKey);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Jsonify, Opaque } from "type-fest";
|
||||
|
||||
import { EncryptionType } from "../../../enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
|
||||
export class SymmetricCryptoKey {
|
||||
key: ArrayBuffer;
|
||||
encKey?: ArrayBuffer;
|
||||
macKey?: ArrayBuffer;
|
||||
encType: EncryptionType;
|
||||
|
||||
keyB64: string;
|
||||
encKeyB64: string;
|
||||
macKeyB64: string;
|
||||
|
||||
meta: any;
|
||||
|
||||
constructor(key: ArrayBuffer, encType?: EncryptionType) {
|
||||
if (key == null) {
|
||||
throw new Error("Must provide key");
|
||||
}
|
||||
|
||||
if (encType == null) {
|
||||
if (key.byteLength === 32) {
|
||||
encType = EncryptionType.AesCbc256_B64;
|
||||
} else if (key.byteLength === 64) {
|
||||
encType = EncryptionType.AesCbc256_HmacSha256_B64;
|
||||
} else {
|
||||
throw new Error("Unable to determine encType.");
|
||||
}
|
||||
}
|
||||
|
||||
this.key = key;
|
||||
this.encType = encType;
|
||||
|
||||
if (encType === EncryptionType.AesCbc256_B64 && key.byteLength === 32) {
|
||||
this.encKey = key;
|
||||
this.macKey = null;
|
||||
} else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && key.byteLength === 32) {
|
||||
this.encKey = key.slice(0, 16);
|
||||
this.macKey = key.slice(16, 32);
|
||||
} else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && key.byteLength === 64) {
|
||||
this.encKey = key.slice(0, 32);
|
||||
this.macKey = key.slice(32, 64);
|
||||
} else {
|
||||
throw new Error("Unsupported encType/key length.");
|
||||
}
|
||||
|
||||
if (this.key != null) {
|
||||
this.keyB64 = Utils.fromBufferToB64(this.key);
|
||||
}
|
||||
if (this.encKey != null) {
|
||||
this.encKeyB64 = Utils.fromBufferToB64(this.encKey);
|
||||
}
|
||||
if (this.macKey != null) {
|
||||
this.macKeyB64 = Utils.fromBufferToB64(this.macKey);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
// The whole object is constructed from the initial key, so just store the B64 key
|
||||
return { keyB64: this.keyB64 };
|
||||
}
|
||||
|
||||
static fromString(s: string): SymmetricCryptoKey {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = Utils.fromB64ToArray(s).buffer;
|
||||
return new SymmetricCryptoKey(arrayBuffer);
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<SymmetricCryptoKey>): SymmetricCryptoKey {
|
||||
return SymmetricCryptoKey.fromString(obj?.keyB64);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup all separate key types as opaque types
|
||||
export type DeviceKey = Opaque<SymmetricCryptoKey, "DeviceKey">;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class ServerConfigResponse extends BaseResponse {
|
||||
version: string;
|
||||
gitHash: string;
|
||||
server: ThirdPartyServerConfigResponse;
|
||||
environment: EnvironmentServerConfigResponse;
|
||||
featureStates: { [key: string]: string } = {};
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
if (response == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.version = this.getResponseProperty("Version");
|
||||
this.gitHash = this.getResponseProperty("GitHash");
|
||||
this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server"));
|
||||
this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment"));
|
||||
this.featureStates = this.getResponseProperty("FeatureStates");
|
||||
}
|
||||
}
|
||||
|
||||
export class EnvironmentServerConfigResponse extends BaseResponse {
|
||||
vault: string;
|
||||
api: string;
|
||||
identity: string;
|
||||
notifications: string;
|
||||
sso: string;
|
||||
|
||||
constructor(data: any = null) {
|
||||
super(data);
|
||||
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.vault = this.getResponseProperty("Vault");
|
||||
this.api = this.getResponseProperty("Api");
|
||||
this.identity = this.getResponseProperty("Identity");
|
||||
this.notifications = this.getResponseProperty("Notifications");
|
||||
this.sso = this.getResponseProperty("Sso");
|
||||
}
|
||||
}
|
||||
|
||||
export class ThirdPartyServerConfigResponse extends BaseResponse {
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
constructor(data: any = null) {
|
||||
super(data);
|
||||
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.name = this.getResponseProperty("Name");
|
||||
this.url = this.getResponseProperty("Url");
|
||||
}
|
||||
}
|
||||
31
libs/common/src/platform/services/app-id.service.ts
Normal file
31
libs/common/src/platform/services/app-id.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { HtmlStorageLocation } from "../../enums";
|
||||
import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
export class AppIdService implements AppIdServiceAbstraction {
|
||||
constructor(private storageService: AbstractStorageService) {}
|
||||
|
||||
getAppId(): Promise<string> {
|
||||
return this.makeAndGetAppId("appId");
|
||||
}
|
||||
|
||||
getAnonymousAppId(): Promise<string> {
|
||||
return this.makeAndGetAppId("anonymousAppId");
|
||||
}
|
||||
|
||||
private async makeAndGetAppId(key: string) {
|
||||
const existingId = await this.storageService.get<string>(key, {
|
||||
htmlStorageLocation: HtmlStorageLocation.Local,
|
||||
});
|
||||
if (existingId != null) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
const guid = Utils.newGuid();
|
||||
await this.storageService.save(key, guid, {
|
||||
htmlStorageLocation: HtmlStorageLocation.Local,
|
||||
});
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
34
libs/common/src/platform/services/broadcaster.service.ts
Normal file
34
libs/common/src/platform/services/broadcaster.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
BroadcasterService as BroadcasterServiceAbstraction,
|
||||
MessageBase,
|
||||
} from "../abstractions/broadcaster.service";
|
||||
|
||||
export class BroadcasterService implements BroadcasterServiceAbstraction {
|
||||
subscribers: Map<string, (message: MessageBase) => void> = new Map<
|
||||
string,
|
||||
(message: MessageBase) => void
|
||||
>();
|
||||
|
||||
send(message: MessageBase, id?: string) {
|
||||
if (id != null) {
|
||||
if (this.subscribers.has(id)) {
|
||||
this.subscribers.get(id)(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscribers.forEach((value) => {
|
||||
value(message);
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(id: string, messageCallback: (message: MessageBase) => void) {
|
||||
this.subscribers.set(id, messageCallback);
|
||||
}
|
||||
|
||||
unsubscribe(id: string) {
|
||||
if (this.subscribers.has(id)) {
|
||||
this.subscribers.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { ConfigApiServiceAbstraction as ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ServerConfigResponse } from "../../models/response/server-config.response";
|
||||
|
||||
export class ConfigApiService implements ConfigApiServiceAbstraction {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async get(): Promise<ServerConfigResponse> {
|
||||
const r = await this.apiService.send("GET", "/config", null, false, true);
|
||||
return new ServerConfigResponse(r);
|
||||
}
|
||||
}
|
||||
96
libs/common/src/platform/services/config/config.service.ts
Normal file
96
libs/common/src/platform/services/config/config.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Injectable, OnDestroy } from "@angular/core";
|
||||
import { BehaviorSubject, Subject, concatMap, from, takeUntil, timer } from "rxjs";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction";
|
||||
import { ServerConfig } from "../../abstractions/config/server-config";
|
||||
import { EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { ServerConfigData } from "../../models/data/server-config.data";
|
||||
|
||||
@Injectable()
|
||||
export class ConfigService implements ConfigServiceAbstraction, OnDestroy {
|
||||
protected _serverConfig = new BehaviorSubject<ServerConfig | null>(null);
|
||||
serverConfig$ = this._serverConfig.asObservable();
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private configApiService: ConfigApiServiceAbstraction,
|
||||
private authService: AuthService,
|
||||
private environmentService: EnvironmentService
|
||||
) {
|
||||
// Re-fetch the server config every hour
|
||||
timer(0, 1000 * 3600)
|
||||
.pipe(concatMap(() => from(this.fetchServerConfig())))
|
||||
.subscribe((serverConfig) => {
|
||||
this._serverConfig.next(serverConfig);
|
||||
});
|
||||
|
||||
this.environmentService.urls.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
||||
this.fetchServerConfig();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async fetchServerConfig(): Promise<ServerConfig> {
|
||||
try {
|
||||
const response = await this.configApiService.get();
|
||||
|
||||
if (response != null) {
|
||||
const data = new ServerConfigData(response);
|
||||
const serverConfig = new ServerConfig(data);
|
||||
this._serverConfig.next(serverConfig);
|
||||
if ((await this.authService.getAuthStatus()) === AuthenticationStatus.LoggedOut) {
|
||||
return serverConfig;
|
||||
}
|
||||
await this.stateService.setServerConfig(data);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getFeatureFlagBool(key: FeatureFlag, defaultValue = false): Promise<boolean> {
|
||||
return await this.getFeatureFlag(key, defaultValue);
|
||||
}
|
||||
|
||||
async getFeatureFlagString(key: FeatureFlag, defaultValue = ""): Promise<string> {
|
||||
return await this.getFeatureFlag(key, defaultValue);
|
||||
}
|
||||
|
||||
async getFeatureFlagNumber(key: FeatureFlag, defaultValue = 0): Promise<number> {
|
||||
return await this.getFeatureFlag(key, defaultValue);
|
||||
}
|
||||
|
||||
private async getFeatureFlag<T>(key: FeatureFlag, defaultValue: T): Promise<T> {
|
||||
const serverConfig = await this.buildServerConfig();
|
||||
if (
|
||||
serverConfig == null ||
|
||||
serverConfig.featureStates == null ||
|
||||
serverConfig.featureStates[key] == null
|
||||
) {
|
||||
return defaultValue;
|
||||
}
|
||||
return serverConfig.featureStates[key] as T;
|
||||
}
|
||||
|
||||
private async buildServerConfig(): Promise<ServerConfig> {
|
||||
const data = await this.stateService.getServerConfig();
|
||||
const domain = data ? new ServerConfig(data) : this._serverConfig.getValue();
|
||||
|
||||
if (domain == null || !domain.isValid() || domain.expiresSoon()) {
|
||||
const value = await this.fetchServerConfig();
|
||||
return value ?? domain;
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { interceptConsole, restoreConsole } from "../../../spec";
|
||||
|
||||
import { ConsoleLogService } from "./console-log.service";
|
||||
|
||||
let caughtMessage: any;
|
||||
|
||||
describe("ConsoleLogService", () => {
|
||||
let logService: ConsoleLogService;
|
||||
beforeEach(() => {
|
||||
caughtMessage = {};
|
||||
interceptConsole(caughtMessage);
|
||||
logService = new ConsoleLogService(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
it("filters messages below the set threshold", () => {
|
||||
logService = new ConsoleLogService(true, () => true);
|
||||
logService.debug("debug");
|
||||
logService.info("info");
|
||||
logService.warning("warning");
|
||||
logService.error("error");
|
||||
|
||||
expect(caughtMessage).toEqual({});
|
||||
});
|
||||
it("only writes debug messages in dev mode", () => {
|
||||
logService = new ConsoleLogService(false);
|
||||
|
||||
logService.debug("debug message");
|
||||
expect(caughtMessage.log).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes debug/info messages to console.log", () => {
|
||||
logService.debug("this is a debug message");
|
||||
expect(caughtMessage).toMatchObject({
|
||||
log: { "0": "this is a debug message" },
|
||||
});
|
||||
|
||||
logService.info("this is an info message");
|
||||
expect(caughtMessage).toMatchObject({
|
||||
log: { "0": "this is an info message" },
|
||||
});
|
||||
});
|
||||
it("writes warning messages to console.warn", () => {
|
||||
logService.warning("this is a warning message");
|
||||
expect(caughtMessage).toMatchObject({
|
||||
warn: { 0: "this is a warning message" },
|
||||
});
|
||||
});
|
||||
it("writes error messages to console.error", () => {
|
||||
logService.error("this is an error message");
|
||||
expect(caughtMessage).toMatchObject({
|
||||
error: { 0: "this is an error message" },
|
||||
});
|
||||
});
|
||||
});
|
||||
57
libs/common/src/platform/services/console-log.service.ts
Normal file
57
libs/common/src/platform/services/console-log.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { LogLevelType } from "../../enums";
|
||||
import { LogService as LogServiceAbstraction } from "../abstractions/log.service";
|
||||
|
||||
export class ConsoleLogService implements LogServiceAbstraction {
|
||||
protected timersMap: Map<string, [number, number]> = new Map();
|
||||
|
||||
constructor(
|
||||
protected isDev: boolean,
|
||||
protected filter: (level: LogLevelType) => boolean = null
|
||||
) {}
|
||||
|
||||
debug(message: string) {
|
||||
if (!this.isDev) {
|
||||
return;
|
||||
}
|
||||
this.write(LogLevelType.Debug, message);
|
||||
}
|
||||
|
||||
info(message: string) {
|
||||
this.write(LogLevelType.Info, message);
|
||||
}
|
||||
|
||||
warning(message: string) {
|
||||
this.write(LogLevelType.Warning, message);
|
||||
}
|
||||
|
||||
error(message: string) {
|
||||
this.write(LogLevelType.Error, message);
|
||||
}
|
||||
|
||||
write(level: LogLevelType, message: string) {
|
||||
if (this.filter != null && this.filter(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case LogLevelType.Debug:
|
||||
// eslint-disable-next-line
|
||||
console.log(message);
|
||||
break;
|
||||
case LogLevelType.Info:
|
||||
// eslint-disable-next-line
|
||||
console.log(message);
|
||||
break;
|
||||
case LogLevelType.Warning:
|
||||
// eslint-disable-next-line
|
||||
console.warn(message);
|
||||
break;
|
||||
case LogLevelType.Error:
|
||||
// eslint-disable-next-line
|
||||
console.error(message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
libs/common/src/platform/services/container.service.ts
Normal file
32
libs/common/src/platform/services/container.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CryptoService } from "../abstractions/crypto.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
export class ContainerService {
|
||||
constructor(private cryptoService: CryptoService, private encryptService: EncryptService) {}
|
||||
|
||||
attachToGlobal(global: any) {
|
||||
if (!global.bitwardenContainerService) {
|
||||
global.bitwardenContainerService = this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Will throw if CryptoService was not instantiated and provided to the ContainerService constructor
|
||||
*/
|
||||
getCryptoService(): CryptoService {
|
||||
if (this.cryptoService == null) {
|
||||
throw new Error("ContainerService.cryptoService not initialized.");
|
||||
}
|
||||
return this.cryptoService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Will throw if EncryptService was not instantiated and provided to the ContainerService constructor
|
||||
*/
|
||||
getEncryptService(): EncryptService {
|
||||
if (this.encryptService == null) {
|
||||
throw new Error("ContainerService.encryptService not initialized.");
|
||||
}
|
||||
return this.encryptService;
|
||||
}
|
||||
}
|
||||
38
libs/common/src/platform/services/crypto.service.spec.ts
Normal file
38
libs/common/src/platform/services/crypto.service.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { mock, mockReset } from "jest-mock-extended";
|
||||
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { CryptoService } from "../services/crypto.service";
|
||||
|
||||
describe("cryptoService", () => {
|
||||
let cryptoService: CryptoService;
|
||||
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const platformUtilService = mock<PlatformUtilsService>();
|
||||
const logService = mock<LogService>();
|
||||
const stateService = mock<StateService>();
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(cryptoFunctionService);
|
||||
mockReset(encryptService);
|
||||
mockReset(platformUtilService);
|
||||
mockReset(logService);
|
||||
mockReset(stateService);
|
||||
|
||||
cryptoService = new CryptoService(
|
||||
cryptoFunctionService,
|
||||
encryptService,
|
||||
platformUtilService,
|
||||
logService,
|
||||
stateService
|
||||
);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(cryptoService).not.toBeFalsy();
|
||||
});
|
||||
});
|
||||
850
libs/common/src/platform/services/crypto.service.ts
Normal file
850
libs/common/src/platform/services/crypto.service.ts
Normal file
@@ -0,0 +1,850 @@
|
||||
import * as bigInt from "big-integer";
|
||||
|
||||
import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data";
|
||||
import { BaseEncryptedOrganizationKey } from "../../admin-console/models/domain/encrypted-organization-key";
|
||||
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
|
||||
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
|
||||
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
|
||||
import { KdfConfig } from "../../auth/models/domain/kdf-config";
|
||||
import {
|
||||
KeySuffixOptions,
|
||||
HashPurpose,
|
||||
KdfType,
|
||||
DEFAULT_ARGON2_ITERATIONS,
|
||||
DEFAULT_ARGON2_MEMORY,
|
||||
DEFAULT_ARGON2_PARALLELISM,
|
||||
EncryptionType,
|
||||
} from "../../enums";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { sequentialize } from "../misc/sequentialize";
|
||||
import { EFFLongWordList } from "../misc/wordlist";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export class CryptoService implements CryptoServiceAbstraction {
|
||||
constructor(
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected encryptService: EncryptService,
|
||||
protected platformUtilService: PlatformUtilsService,
|
||||
protected logService: LogService,
|
||||
protected stateService: StateService
|
||||
) {}
|
||||
|
||||
async setKey(key: SymmetricCryptoKey, userId?: string): Promise<any> {
|
||||
await this.stateService.setCryptoMasterKey(key, { userId: userId });
|
||||
await this.storeKey(key, userId);
|
||||
}
|
||||
|
||||
async setKeyHash(keyHash: string): Promise<void> {
|
||||
await this.stateService.setKeyHash(keyHash);
|
||||
}
|
||||
|
||||
async setEncKey(encKey: string): Promise<void> {
|
||||
if (encKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stateService.setDecryptedCryptoSymmetricKey(null);
|
||||
await this.stateService.setEncryptedCryptoSymmetricKey(encKey);
|
||||
}
|
||||
|
||||
async setEncPrivateKey(encPrivateKey: string): Promise<void> {
|
||||
if (encPrivateKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stateService.setDecryptedPrivateKey(null);
|
||||
await this.stateService.setEncryptedPrivateKey(encPrivateKey);
|
||||
}
|
||||
|
||||
async setOrgKeys(
|
||||
orgs: ProfileOrganizationResponse[] = [],
|
||||
providerOrgs: ProfileProviderOrganizationResponse[] = []
|
||||
): Promise<void> {
|
||||
const encOrgKeyData: { [orgId: string]: EncryptedOrganizationKeyData } = {};
|
||||
|
||||
orgs.forEach((org) => {
|
||||
encOrgKeyData[org.id] = {
|
||||
type: "organization",
|
||||
key: org.key,
|
||||
};
|
||||
});
|
||||
|
||||
providerOrgs.forEach((org) => {
|
||||
encOrgKeyData[org.id] = {
|
||||
type: "provider",
|
||||
providerId: org.providerId,
|
||||
key: org.key,
|
||||
};
|
||||
});
|
||||
|
||||
await this.stateService.setDecryptedOrganizationKeys(null);
|
||||
return await this.stateService.setEncryptedOrganizationKeys(encOrgKeyData);
|
||||
}
|
||||
|
||||
async setProviderKeys(providers: ProfileProviderResponse[]): Promise<void> {
|
||||
const providerKeys: any = {};
|
||||
providers.forEach((provider) => {
|
||||
providerKeys[provider.id] = provider.key;
|
||||
});
|
||||
|
||||
await this.stateService.setDecryptedProviderKeys(null);
|
||||
return await this.stateService.setEncryptedProviderKeys(providerKeys);
|
||||
}
|
||||
|
||||
async getKey(keySuffix?: KeySuffixOptions, userId?: string): Promise<SymmetricCryptoKey> {
|
||||
const inMemoryKey = await this.stateService.getCryptoMasterKey({ userId: userId });
|
||||
|
||||
if (inMemoryKey != null) {
|
||||
return inMemoryKey;
|
||||
}
|
||||
|
||||
keySuffix ||= KeySuffixOptions.Auto;
|
||||
const symmetricKey = await this.getKeyFromStorage(keySuffix, userId);
|
||||
|
||||
if (symmetricKey != null) {
|
||||
// TODO: Refactor here so get key doesn't also set key
|
||||
this.setKey(symmetricKey, userId);
|
||||
}
|
||||
|
||||
return symmetricKey;
|
||||
}
|
||||
|
||||
async getKeyFromStorage(
|
||||
keySuffix: KeySuffixOptions,
|
||||
userId?: string
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
const key = await this.retrieveKeyFromStorage(keySuffix, userId);
|
||||
if (key != null) {
|
||||
const symmetricKey = new SymmetricCryptoKey(Utils.fromB64ToArray(key).buffer);
|
||||
|
||||
if (!(await this.validateKey(symmetricKey))) {
|
||||
this.logService.warning("Wrong key, throwing away stored key");
|
||||
await this.clearSecretKeyStore(userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return symmetricKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getKeyHash(): Promise<string> {
|
||||
return await this.stateService.getKeyHash();
|
||||
}
|
||||
|
||||
async compareAndUpdateKeyHash(masterPassword: string, key: SymmetricCryptoKey): Promise<boolean> {
|
||||
const storedKeyHash = await this.getKeyHash();
|
||||
if (masterPassword != null && storedKeyHash != null) {
|
||||
const localKeyHash = await this.hashPassword(
|
||||
masterPassword,
|
||||
key,
|
||||
HashPurpose.LocalAuthorization
|
||||
);
|
||||
if (localKeyHash != null && storedKeyHash === localKeyHash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: remove serverKeyHash check in 1-2 releases after everyone's keyHash has been updated
|
||||
const serverKeyHash = await this.hashPassword(
|
||||
masterPassword,
|
||||
key,
|
||||
HashPurpose.ServerAuthorization
|
||||
);
|
||||
if (serverKeyHash != null && storedKeyHash === serverKeyHash) {
|
||||
await this.setKeyHash(localKeyHash);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@sequentialize(() => "getEncKey")
|
||||
getEncKey(key: SymmetricCryptoKey = null): Promise<SymmetricCryptoKey> {
|
||||
return this.getEncKeyHelper(key);
|
||||
}
|
||||
|
||||
async getPublicKey(): Promise<ArrayBuffer> {
|
||||
const inMemoryPublicKey = await this.stateService.getPublicKey();
|
||||
if (inMemoryPublicKey != null) {
|
||||
return inMemoryPublicKey;
|
||||
}
|
||||
|
||||
const privateKey = await this.getPrivateKey();
|
||||
if (privateKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||
await this.stateService.setPublicKey(publicKey);
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
async getPrivateKey(): Promise<ArrayBuffer> {
|
||||
const decryptedPrivateKey = await this.stateService.getDecryptedPrivateKey();
|
||||
if (decryptedPrivateKey != null) {
|
||||
return decryptedPrivateKey;
|
||||
}
|
||||
|
||||
const encPrivateKey = await this.stateService.getEncryptedPrivateKey();
|
||||
if (encPrivateKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const privateKey = await this.decryptToBytes(new EncString(encPrivateKey), null);
|
||||
await this.stateService.setDecryptedPrivateKey(privateKey);
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
async getFingerprint(userId: string, publicKey?: ArrayBuffer): Promise<string[]> {
|
||||
if (publicKey == null) {
|
||||
publicKey = await this.getPublicKey();
|
||||
}
|
||||
if (publicKey === null) {
|
||||
throw new Error("No public key available.");
|
||||
}
|
||||
const keyFingerprint = await this.cryptoFunctionService.hash(publicKey, "sha256");
|
||||
const userFingerprint = await this.cryptoFunctionService.hkdfExpand(
|
||||
keyFingerprint,
|
||||
userId,
|
||||
32,
|
||||
"sha256"
|
||||
);
|
||||
return this.hashPhrase(userFingerprint);
|
||||
}
|
||||
|
||||
@sequentialize(() => "getOrgKeys")
|
||||
async getOrgKeys(): Promise<Map<string, SymmetricCryptoKey>> {
|
||||
const result: Map<string, SymmetricCryptoKey> = new Map<string, SymmetricCryptoKey>();
|
||||
const decryptedOrganizationKeys = await this.stateService.getDecryptedOrganizationKeys();
|
||||
if (decryptedOrganizationKeys != null && decryptedOrganizationKeys.size > 0) {
|
||||
return decryptedOrganizationKeys;
|
||||
}
|
||||
|
||||
const encOrgKeyData = await this.stateService.getEncryptedOrganizationKeys();
|
||||
if (encOrgKeyData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let setKey = false;
|
||||
|
||||
for (const orgId of Object.keys(encOrgKeyData)) {
|
||||
if (result.has(orgId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encOrgKey = BaseEncryptedOrganizationKey.fromData(encOrgKeyData[orgId]);
|
||||
const decOrgKey = await encOrgKey.decrypt(this);
|
||||
result.set(orgId, decOrgKey);
|
||||
|
||||
setKey = true;
|
||||
}
|
||||
|
||||
if (setKey) {
|
||||
await this.stateService.setDecryptedOrganizationKeys(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getOrgKey(orgId: string): Promise<SymmetricCryptoKey> {
|
||||
if (orgId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orgKeys = await this.getOrgKeys();
|
||||
if (orgKeys == null || !orgKeys.has(orgId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return orgKeys.get(orgId);
|
||||
}
|
||||
|
||||
@sequentialize(() => "getProviderKeys")
|
||||
async getProviderKeys(): Promise<Map<string, SymmetricCryptoKey>> {
|
||||
const providerKeys: Map<string, SymmetricCryptoKey> = new Map<string, SymmetricCryptoKey>();
|
||||
const decryptedProviderKeys = await this.stateService.getDecryptedProviderKeys();
|
||||
if (decryptedProviderKeys != null && decryptedProviderKeys.size > 0) {
|
||||
return decryptedProviderKeys;
|
||||
}
|
||||
|
||||
const encProviderKeys = await this.stateService.getEncryptedProviderKeys();
|
||||
if (encProviderKeys == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let setKey = false;
|
||||
|
||||
for (const orgId in encProviderKeys) {
|
||||
// eslint-disable-next-line
|
||||
if (!encProviderKeys.hasOwnProperty(orgId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const decValue = await this.rsaDecrypt(encProviderKeys[orgId]);
|
||||
providerKeys.set(orgId, new SymmetricCryptoKey(decValue));
|
||||
setKey = true;
|
||||
}
|
||||
|
||||
if (setKey) {
|
||||
await this.stateService.setDecryptedProviderKeys(providerKeys);
|
||||
}
|
||||
|
||||
return providerKeys;
|
||||
}
|
||||
|
||||
async getProviderKey(providerId: string): Promise<SymmetricCryptoKey> {
|
||||
if (providerId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerKeys = await this.getProviderKeys();
|
||||
if (providerKeys == null || !providerKeys.has(providerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return providerKeys.get(providerId);
|
||||
}
|
||||
|
||||
async hasKey(): Promise<boolean> {
|
||||
return (
|
||||
(await this.hasKeyInMemory()) ||
|
||||
(await this.hasKeyStored(KeySuffixOptions.Auto)) ||
|
||||
(await this.hasKeyStored(KeySuffixOptions.Biometric))
|
||||
);
|
||||
}
|
||||
|
||||
async hasKeyInMemory(userId?: string): Promise<boolean> {
|
||||
return (await this.stateService.getCryptoMasterKey({ userId: userId })) != null;
|
||||
}
|
||||
|
||||
async hasKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise<boolean> {
|
||||
switch (keySuffix) {
|
||||
case KeySuffixOptions.Auto:
|
||||
return (await this.stateService.getCryptoMasterKeyAuto({ userId: userId })) != null;
|
||||
case KeySuffixOptions.Biometric:
|
||||
return (await this.stateService.hasCryptoMasterKeyBiometric({ userId: userId })) === true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async hasEncKey(): Promise<boolean> {
|
||||
return (await this.stateService.getEncryptedCryptoSymmetricKey()) != null;
|
||||
}
|
||||
|
||||
async clearKey(clearSecretStorage = true, userId?: string): Promise<any> {
|
||||
await this.stateService.setCryptoMasterKey(null, { userId: userId });
|
||||
if (clearSecretStorage) {
|
||||
await this.clearSecretKeyStore(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async clearStoredKey(keySuffix: KeySuffixOptions) {
|
||||
keySuffix === KeySuffixOptions.Auto
|
||||
? await this.stateService.setCryptoMasterKeyAuto(null)
|
||||
: await this.stateService.setCryptoMasterKeyBiometric(null);
|
||||
}
|
||||
|
||||
async clearKeyHash(userId?: string): Promise<any> {
|
||||
return await this.stateService.setKeyHash(null, { userId: userId });
|
||||
}
|
||||
|
||||
async clearEncKey(memoryOnly?: boolean, userId?: string): Promise<void> {
|
||||
await this.stateService.setDecryptedCryptoSymmetricKey(null, { userId: userId });
|
||||
if (!memoryOnly) {
|
||||
await this.stateService.setEncryptedCryptoSymmetricKey(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
async clearKeyPair(memoryOnly?: boolean, userId?: string): Promise<any> {
|
||||
const keysToClear: Promise<void>[] = [
|
||||
this.stateService.setDecryptedPrivateKey(null, { userId: userId }),
|
||||
this.stateService.setPublicKey(null, { userId: userId }),
|
||||
];
|
||||
if (!memoryOnly) {
|
||||
keysToClear.push(this.stateService.setEncryptedPrivateKey(null, { userId: userId }));
|
||||
}
|
||||
return Promise.all(keysToClear);
|
||||
}
|
||||
|
||||
async clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise<void> {
|
||||
await this.stateService.setDecryptedOrganizationKeys(null, { userId: userId });
|
||||
if (!memoryOnly) {
|
||||
await this.stateService.setEncryptedOrganizationKeys(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
async clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise<void> {
|
||||
await this.stateService.setDecryptedProviderKeys(null, { userId: userId });
|
||||
if (!memoryOnly) {
|
||||
await this.stateService.setEncryptedProviderKeys(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
async clearPinProtectedKey(userId?: string): Promise<any> {
|
||||
return await this.stateService.setEncryptedPinProtected(null, { userId: userId });
|
||||
}
|
||||
|
||||
async clearKeys(userId?: string): Promise<any> {
|
||||
await this.clearKey(true, userId);
|
||||
await this.clearKeyHash(userId);
|
||||
await this.clearOrgKeys(false, userId);
|
||||
await this.clearProviderKeys(false, userId);
|
||||
await this.clearEncKey(false, userId);
|
||||
await this.clearKeyPair(false, userId);
|
||||
await this.clearPinProtectedKey(userId);
|
||||
}
|
||||
|
||||
async toggleKey(): Promise<any> {
|
||||
const key = await this.getKey();
|
||||
|
||||
await this.setKey(key);
|
||||
}
|
||||
|
||||
async makeKey(
|
||||
password: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
let key: ArrayBuffer = null;
|
||||
if (kdf == null || kdf === KdfType.PBKDF2_SHA256) {
|
||||
if (kdfConfig.iterations == null) {
|
||||
kdfConfig.iterations = 5000;
|
||||
} else if (kdfConfig.iterations < 5000) {
|
||||
throw new Error("PBKDF2 iteration minimum is 5000.");
|
||||
}
|
||||
key = await this.cryptoFunctionService.pbkdf2(password, salt, "sha256", kdfConfig.iterations);
|
||||
} else if (kdf == KdfType.Argon2id) {
|
||||
if (kdfConfig.iterations == null) {
|
||||
kdfConfig.iterations = DEFAULT_ARGON2_ITERATIONS;
|
||||
} else if (kdfConfig.iterations < 2) {
|
||||
throw new Error("Argon2 iteration minimum is 2.");
|
||||
}
|
||||
|
||||
if (kdfConfig.memory == null) {
|
||||
kdfConfig.memory = DEFAULT_ARGON2_MEMORY;
|
||||
} else if (kdfConfig.memory < 16) {
|
||||
throw new Error("Argon2 memory minimum is 16 MB");
|
||||
} else if (kdfConfig.memory > 1024) {
|
||||
throw new Error("Argon2 memory maximum is 1024 MB");
|
||||
}
|
||||
|
||||
if (kdfConfig.parallelism == null) {
|
||||
kdfConfig.parallelism = DEFAULT_ARGON2_PARALLELISM;
|
||||
} else if (kdfConfig.parallelism < 1) {
|
||||
throw new Error("Argon2 parallelism minimum is 1.");
|
||||
}
|
||||
|
||||
const saltHash = await this.cryptoFunctionService.hash(salt, "sha256");
|
||||
key = await this.cryptoFunctionService.argon2(
|
||||
password,
|
||||
saltHash,
|
||||
kdfConfig.iterations,
|
||||
kdfConfig.memory * 1024, // convert to KiB from MiB
|
||||
kdfConfig.parallelism
|
||||
);
|
||||
} else {
|
||||
throw new Error("Unknown Kdf.");
|
||||
}
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
|
||||
async makeKeyFromPin(
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig,
|
||||
protectedKeyCs: EncString = null
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
if (protectedKeyCs == null) {
|
||||
const pinProtectedKey = await this.stateService.getEncryptedPinProtected();
|
||||
if (pinProtectedKey == null) {
|
||||
throw new Error("No PIN protected key found.");
|
||||
}
|
||||
protectedKeyCs = new EncString(pinProtectedKey);
|
||||
}
|
||||
const pinKey = await this.makePinKey(pin, salt, kdf, kdfConfig);
|
||||
const decKey = await this.decryptToBytes(protectedKeyCs, pinKey);
|
||||
return new SymmetricCryptoKey(decKey);
|
||||
}
|
||||
|
||||
async makeShareKey(): Promise<[EncString, SymmetricCryptoKey]> {
|
||||
const shareKey = await this.cryptoFunctionService.randomBytes(64);
|
||||
const publicKey = await this.getPublicKey();
|
||||
const encShareKey = await this.rsaEncrypt(shareKey, publicKey);
|
||||
return [encShareKey, new SymmetricCryptoKey(shareKey)];
|
||||
}
|
||||
|
||||
async makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]> {
|
||||
const keyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
|
||||
const publicB64 = Utils.fromBufferToB64(keyPair[0]);
|
||||
const privateEnc = await this.encrypt(keyPair[1], key);
|
||||
return [publicB64, privateEnc];
|
||||
}
|
||||
|
||||
async makePinKey(
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdf: KdfType,
|
||||
kdfConfig: KdfConfig
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
const pinKey = await this.makeKey(pin, salt, kdf, kdfConfig);
|
||||
return await this.stretchKey(pinKey);
|
||||
}
|
||||
|
||||
async makeSendKey(keyMaterial: ArrayBuffer): Promise<SymmetricCryptoKey> {
|
||||
const sendKey = await this.cryptoFunctionService.hkdf(
|
||||
keyMaterial,
|
||||
"bitwarden-send",
|
||||
"send",
|
||||
64,
|
||||
"sha256"
|
||||
);
|
||||
return new SymmetricCryptoKey(sendKey);
|
||||
}
|
||||
|
||||
async hashPassword(
|
||||
password: string,
|
||||
key: SymmetricCryptoKey,
|
||||
hashPurpose?: HashPurpose
|
||||
): Promise<string> {
|
||||
if (key == null) {
|
||||
key = await this.getKey();
|
||||
}
|
||||
if (password == null || key == null) {
|
||||
throw new Error("Invalid parameters.");
|
||||
}
|
||||
|
||||
const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1;
|
||||
const hash = await this.cryptoFunctionService.pbkdf2(key.key, password, "sha256", iterations);
|
||||
return Utils.fromBufferToB64(hash);
|
||||
}
|
||||
|
||||
async makeEncKey(key: SymmetricCryptoKey): Promise<[SymmetricCryptoKey, EncString]> {
|
||||
const theKey = await this.getKeyForUserEncryption(key);
|
||||
const encKey = await this.cryptoFunctionService.randomBytes(64);
|
||||
return this.buildEncKey(theKey, encKey);
|
||||
}
|
||||
|
||||
async remakeEncKey(
|
||||
key: SymmetricCryptoKey,
|
||||
encKey?: SymmetricCryptoKey
|
||||
): Promise<[SymmetricCryptoKey, EncString]> {
|
||||
if (encKey == null) {
|
||||
encKey = await this.getEncKey();
|
||||
}
|
||||
return this.buildEncKey(key, encKey.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.encrypt
|
||||
*/
|
||||
async encrypt(plainValue: string | ArrayBuffer, key?: SymmetricCryptoKey): Promise<EncString> {
|
||||
key = await this.getKeyForUserEncryption(key);
|
||||
return await this.encryptService.encrypt(plainValue, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.encryptToBytes
|
||||
*/
|
||||
async encryptToBytes(plainValue: ArrayBuffer, key?: SymmetricCryptoKey): Promise<EncArrayBuffer> {
|
||||
key = await this.getKeyForUserEncryption(key);
|
||||
return this.encryptService.encryptToBytes(plainValue, key);
|
||||
}
|
||||
|
||||
async rsaEncrypt(data: ArrayBuffer, publicKey?: ArrayBuffer): Promise<EncString> {
|
||||
if (publicKey == null) {
|
||||
publicKey = await this.getPublicKey();
|
||||
}
|
||||
if (publicKey == null) {
|
||||
throw new Error("Public key unavailable.");
|
||||
}
|
||||
|
||||
const encBytes = await this.cryptoFunctionService.rsaEncrypt(data, publicKey, "sha1");
|
||||
return new EncString(EncryptionType.Rsa2048_OaepSha1_B64, Utils.fromBufferToB64(encBytes));
|
||||
}
|
||||
|
||||
async rsaDecrypt(encValue: string, privateKeyValue?: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
const headerPieces = encValue.split(".");
|
||||
let encType: EncryptionType = null;
|
||||
let encPieces: string[];
|
||||
|
||||
if (headerPieces.length === 1) {
|
||||
encType = EncryptionType.Rsa2048_OaepSha256_B64;
|
||||
encPieces = [headerPieces[0]];
|
||||
} else if (headerPieces.length === 2) {
|
||||
try {
|
||||
encType = parseInt(headerPieces[0], null);
|
||||
encPieces = headerPieces[1].split("|");
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: // HmacSha256 types are deprecated
|
||||
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
|
||||
break;
|
||||
default:
|
||||
throw new Error("encType unavailable.");
|
||||
}
|
||||
|
||||
if (encPieces == null || encPieces.length <= 0) {
|
||||
throw new Error("encPieces unavailable.");
|
||||
}
|
||||
|
||||
const data = Utils.fromB64ToArray(encPieces[0]).buffer;
|
||||
const privateKey = privateKeyValue ?? (await this.getPrivateKey());
|
||||
if (privateKey == null) {
|
||||
throw new Error("No private key.");
|
||||
}
|
||||
|
||||
let alg: "sha1" | "sha256" = "sha1";
|
||||
switch (encType) {
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64:
|
||||
alg = "sha256";
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
|
||||
break;
|
||||
default:
|
||||
throw new Error("encType unavailable.");
|
||||
}
|
||||
|
||||
return this.cryptoFunctionService.rsaDecrypt(data, privateKey, alg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToBytes
|
||||
*/
|
||||
async decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise<ArrayBuffer> {
|
||||
const keyForEnc = await this.getKeyForUserEncryption(key);
|
||||
return this.encryptService.decryptToBytes(encString, keyForEnc);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToUtf8
|
||||
*/
|
||||
async decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise<string> {
|
||||
key = await this.getKeyForUserEncryption(key);
|
||||
return await this.encryptService.decryptToUtf8(encString, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
|
||||
* and then call encryptService.decryptToBytes
|
||||
*/
|
||||
async decryptFromBytes(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<ArrayBuffer> {
|
||||
if (encBuffer == null) {
|
||||
throw new Error("No buffer provided for decryption.");
|
||||
}
|
||||
|
||||
key = await this.getKeyForUserEncryption(key);
|
||||
|
||||
return this.encryptService.decryptToBytes(encBuffer, key);
|
||||
}
|
||||
|
||||
// EFForg/OpenWireless
|
||||
// ref https://github.com/EFForg/OpenWireless/blob/master/app/js/diceware.js
|
||||
async randomNumber(min: number, max: number): Promise<number> {
|
||||
let rval = 0;
|
||||
const range = max - min + 1;
|
||||
const bitsNeeded = Math.ceil(Math.log2(range));
|
||||
if (bitsNeeded > 53) {
|
||||
throw new Error("We cannot generate numbers larger than 53 bits.");
|
||||
}
|
||||
|
||||
const bytesNeeded = Math.ceil(bitsNeeded / 8);
|
||||
const mask = Math.pow(2, bitsNeeded) - 1;
|
||||
// 7776 -> (2^13 = 8192) -1 == 8191 or 0x00001111 11111111
|
||||
|
||||
// Fill a byte array with N random numbers
|
||||
const byteArray = new Uint8Array(await this.cryptoFunctionService.randomBytes(bytesNeeded));
|
||||
|
||||
let p = (bytesNeeded - 1) * 8;
|
||||
for (let i = 0; i < bytesNeeded; i++) {
|
||||
rval += byteArray[i] * Math.pow(2, p);
|
||||
p -= 8;
|
||||
}
|
||||
|
||||
// Use & to apply the mask and reduce the number of recursive lookups
|
||||
rval = rval & mask;
|
||||
|
||||
if (rval >= range) {
|
||||
// Integer out of acceptable range
|
||||
return this.randomNumber(min, max);
|
||||
}
|
||||
|
||||
// Return an integer that falls within the range
|
||||
return min + rval;
|
||||
}
|
||||
|
||||
async validateKey(key: SymmetricCryptoKey) {
|
||||
try {
|
||||
const encPrivateKey = await this.stateService.getEncryptedPrivateKey();
|
||||
const encKey = await this.getEncKeyHelper(key);
|
||||
if (encPrivateKey == null || encKey == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const privateKey = await this.decryptToBytes(new EncString(encPrivateKey), encKey);
|
||||
await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---HELPERS---
|
||||
|
||||
protected async storeKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
const storeAuto = await this.shouldStoreKey(KeySuffixOptions.Auto, userId);
|
||||
|
||||
if (storeAuto) {
|
||||
await this.storeAutoKey(key, userId);
|
||||
} else {
|
||||
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
protected async storeAutoKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId });
|
||||
}
|
||||
|
||||
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: string) {
|
||||
let shouldStoreKey = false;
|
||||
if (keySuffix === KeySuffixOptions.Auto) {
|
||||
const vaultTimeout = await this.stateService.getVaultTimeout({ userId: userId });
|
||||
shouldStoreKey = vaultTimeout == null;
|
||||
} else if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const biometricUnlock = await this.stateService.getBiometricUnlock({ userId: userId });
|
||||
shouldStoreKey = biometricUnlock && this.platformUtilService.supportsSecureStorage();
|
||||
}
|
||||
return shouldStoreKey;
|
||||
}
|
||||
|
||||
protected async retrieveKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string) {
|
||||
return keySuffix === KeySuffixOptions.Auto
|
||||
? await this.stateService.getCryptoMasterKeyAuto({ userId: userId })
|
||||
: await this.stateService.getCryptoMasterKeyBiometric({ userId: userId });
|
||||
}
|
||||
|
||||
async getKeyForUserEncryption(key?: SymmetricCryptoKey): Promise<SymmetricCryptoKey> {
|
||||
if (key != null) {
|
||||
return key;
|
||||
}
|
||||
|
||||
const encKey = await this.getEncKey();
|
||||
if (encKey != null) {
|
||||
return encKey;
|
||||
}
|
||||
|
||||
// Legacy support: encryption used to be done with the user key (derived from master password).
|
||||
// Users who have not migrated will have a null encKey and must use the user key instead.
|
||||
return await this.getKey();
|
||||
}
|
||||
|
||||
private async stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey> {
|
||||
const newKey = new Uint8Array(64);
|
||||
const encKey = await this.cryptoFunctionService.hkdfExpand(key.key, "enc", 32, "sha256");
|
||||
const macKey = await this.cryptoFunctionService.hkdfExpand(key.key, "mac", 32, "sha256");
|
||||
newKey.set(new Uint8Array(encKey));
|
||||
newKey.set(new Uint8Array(macKey), 32);
|
||||
return new SymmetricCryptoKey(newKey.buffer);
|
||||
}
|
||||
|
||||
private async hashPhrase(hash: ArrayBuffer, minimumEntropy = 64) {
|
||||
const entropyPerWord = Math.log(EFFLongWordList.length) / Math.log(2);
|
||||
let numWords = Math.ceil(minimumEntropy / entropyPerWord);
|
||||
|
||||
const hashArr = Array.from(new Uint8Array(hash));
|
||||
const entropyAvailable = hashArr.length * 4;
|
||||
if (numWords * entropyPerWord > entropyAvailable) {
|
||||
throw new Error("Output entropy of hash function is too small");
|
||||
}
|
||||
|
||||
const phrase: string[] = [];
|
||||
let hashNumber = bigInt.fromArray(hashArr, 256);
|
||||
while (numWords--) {
|
||||
const remainder = hashNumber.mod(EFFLongWordList.length);
|
||||
hashNumber = hashNumber.divide(EFFLongWordList.length);
|
||||
phrase.push(EFFLongWordList[remainder as any]);
|
||||
}
|
||||
return phrase;
|
||||
}
|
||||
|
||||
private async buildEncKey(
|
||||
key: SymmetricCryptoKey,
|
||||
encKey: ArrayBuffer
|
||||
): Promise<[SymmetricCryptoKey, EncString]> {
|
||||
let encKeyEnc: EncString = null;
|
||||
if (key.key.byteLength === 32) {
|
||||
const newKey = await this.stretchKey(key);
|
||||
encKeyEnc = await this.encrypt(encKey, newKey);
|
||||
} else if (key.key.byteLength === 64) {
|
||||
encKeyEnc = await this.encrypt(encKey, key);
|
||||
} else {
|
||||
throw new Error("Invalid key size.");
|
||||
}
|
||||
return [new SymmetricCryptoKey(encKey), encKeyEnc];
|
||||
}
|
||||
|
||||
private async clearSecretKeyStore(userId?: string): Promise<void> {
|
||||
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
|
||||
await this.stateService.setCryptoMasterKeyBiometric(null, { userId: userId });
|
||||
}
|
||||
|
||||
private async getEncKeyHelper(key: SymmetricCryptoKey = null): Promise<SymmetricCryptoKey> {
|
||||
const inMemoryKey = await this.stateService.getDecryptedCryptoSymmetricKey();
|
||||
if (inMemoryKey != null) {
|
||||
return inMemoryKey;
|
||||
}
|
||||
|
||||
const encKey = await this.stateService.getEncryptedCryptoSymmetricKey();
|
||||
if (encKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key == null) {
|
||||
key = await this.getKey();
|
||||
}
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let decEncKey: ArrayBuffer;
|
||||
const encKeyCipher = new EncString(encKey);
|
||||
if (encKeyCipher.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
decEncKey = await this.decryptToBytes(encKeyCipher, key);
|
||||
} else if (encKeyCipher.encryptionType === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const newKey = await this.stretchKey(key);
|
||||
decEncKey = await this.decryptToBytes(encKeyCipher, newKey);
|
||||
} else {
|
||||
throw new Error("Unsupported encKey type.");
|
||||
}
|
||||
if (decEncKey == null) {
|
||||
return null;
|
||||
}
|
||||
const symmetricCryptoKey = new SymmetricCryptoKey(decEncKey);
|
||||
await this.stateService.setDecryptedCryptoSymmetricKey(symmetricCryptoKey);
|
||||
return symmetricCryptoKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { EncryptionType } from "../../../enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { CryptoFunctionService } from "../../abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { Decryptable } from "../../interfaces/decryptable.interface";
|
||||
import { Encrypted } from "../../interfaces/encrypted";
|
||||
import { InitializerMetadata } from "../../interfaces/initializer-metadata.interface";
|
||||
import { EncArrayBuffer } from "../../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../models/domain/enc-string";
|
||||
import { EncryptedObject } from "../../models/domain/encrypted-object";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
|
||||
export class EncryptServiceImplementation implements EncryptService {
|
||||
constructor(
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
protected logMacFailures: boolean
|
||||
) {}
|
||||
|
||||
async encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (plainValue == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
let plainBuf: ArrayBuffer;
|
||||
if (typeof plainValue === "string") {
|
||||
plainBuf = Utils.fromUtf8ToArray(plainValue).buffer;
|
||||
} else {
|
||||
plainBuf = plainValue;
|
||||
}
|
||||
|
||||
const encObj = await this.aesEncrypt(plainBuf, key);
|
||||
const iv = Utils.fromBufferToB64(encObj.iv);
|
||||
const data = Utils.fromBufferToB64(encObj.data);
|
||||
const mac = encObj.mac != null ? Utils.fromBufferToB64(encObj.mac) : null;
|
||||
return new EncString(encObj.key.encType, data, iv, mac);
|
||||
}
|
||||
|
||||
async encryptToBytes(plainValue: ArrayBuffer, key: SymmetricCryptoKey): Promise<EncArrayBuffer> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
const encValue = await this.aesEncrypt(plainValue, key);
|
||||
let macLen = 0;
|
||||
if (encValue.mac != null) {
|
||||
macLen = encValue.mac.byteLength;
|
||||
}
|
||||
|
||||
const encBytes = new Uint8Array(1 + encValue.iv.byteLength + macLen + encValue.data.byteLength);
|
||||
encBytes.set([encValue.key.encType]);
|
||||
encBytes.set(new Uint8Array(encValue.iv), 1);
|
||||
if (encValue.mac != null) {
|
||||
encBytes.set(new Uint8Array(encValue.mac), 1 + encValue.iv.byteLength);
|
||||
}
|
||||
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen);
|
||||
return new EncArrayBuffer(encBytes.buffer);
|
||||
}
|
||||
|
||||
async decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise<string> {
|
||||
if (key == null) {
|
||||
throw new Error("No key provided for decryption.");
|
||||
}
|
||||
|
||||
key = this.resolveLegacyKey(key, encString);
|
||||
|
||||
if (key.macKey != null && encString?.mac == null) {
|
||||
this.logService.error("mac required.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.encType !== encString.encryptionType) {
|
||||
this.logService.error("encType unavailable.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(
|
||||
encString.data,
|
||||
encString.iv,
|
||||
encString.mac,
|
||||
key
|
||||
);
|
||||
if (fastParams.macKey != null && fastParams.mac != null) {
|
||||
const computedMac = await this.cryptoFunctionService.hmacFast(
|
||||
fastParams.macData,
|
||||
fastParams.macKey,
|
||||
"sha256"
|
||||
);
|
||||
const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac);
|
||||
if (!macsEqual) {
|
||||
this.logMacFailed("mac failed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.cryptoFunctionService.aesDecryptFast(fastParams);
|
||||
}
|
||||
|
||||
async decryptToBytes(encThing: Encrypted, key: SymmetricCryptoKey): Promise<ArrayBuffer> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (encThing == null) {
|
||||
throw new Error("Nothing provided for decryption.");
|
||||
}
|
||||
|
||||
key = this.resolveLegacyKey(key, encThing);
|
||||
|
||||
if (key.macKey != null && encThing.macBytes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.encType !== encThing.encryptionType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.macKey != null && encThing.macBytes != null) {
|
||||
const macData = new Uint8Array(encThing.ivBytes.byteLength + encThing.dataBytes.byteLength);
|
||||
macData.set(new Uint8Array(encThing.ivBytes), 0);
|
||||
macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength);
|
||||
const computedMac = await this.cryptoFunctionService.hmac(
|
||||
macData.buffer,
|
||||
key.macKey,
|
||||
"sha256"
|
||||
);
|
||||
if (computedMac === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac);
|
||||
if (!macsMatch) {
|
||||
this.logMacFailed("mac failed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.cryptoFunctionService.aesDecrypt(
|
||||
encThing.dataBytes,
|
||||
encThing.ivBytes,
|
||||
key.encKey
|
||||
);
|
||||
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey
|
||||
): Promise<T[]> {
|
||||
if (items == null || items.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await Promise.all(items.map((item) => item.decrypt(key)));
|
||||
}
|
||||
|
||||
private async aesEncrypt(data: ArrayBuffer, key: SymmetricCryptoKey): Promise<EncryptedObject> {
|
||||
const obj = new EncryptedObject();
|
||||
obj.key = key;
|
||||
obj.iv = await this.cryptoFunctionService.randomBytes(16);
|
||||
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, obj.key.encKey);
|
||||
|
||||
if (obj.key.macKey != null) {
|
||||
const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength);
|
||||
macData.set(new Uint8Array(obj.iv), 0);
|
||||
macData.set(new Uint8Array(obj.data), obj.iv.byteLength);
|
||||
obj.mac = await this.cryptoFunctionService.hmac(macData.buffer, obj.key.macKey, "sha256");
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
private logMacFailed(msg: string) {
|
||||
if (this.logMacFailures) {
|
||||
this.logService.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform into new key for the old encrypt-then-mac scheme if required, otherwise return the current key unchanged
|
||||
* @param encThing The encrypted object (e.g. encString or encArrayBuffer) that you want to decrypt
|
||||
*/
|
||||
resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey {
|
||||
if (
|
||||
encThing.encryptionType === EncryptionType.AesCbc128_HmacSha256_B64 &&
|
||||
key.encType === EncryptionType.AesCbc256_B64
|
||||
) {
|
||||
return new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Decryptable } from "../../interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
import { ConsoleLogService } from "../console-log.service";
|
||||
import { ContainerService } from "../container.service";
|
||||
import { WebCryptoFunctionService } from "../web-crypto-function.service";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
import { getClassInitializer } from "./get-class-initializer";
|
||||
|
||||
const workerApi: Worker = self as any;
|
||||
|
||||
let inited = false;
|
||||
let encryptService: EncryptServiceImplementation;
|
||||
|
||||
/**
|
||||
* Bootstrap the worker environment with services required for decryption
|
||||
*/
|
||||
export function init() {
|
||||
const cryptoFunctionService = new WebCryptoFunctionService(self);
|
||||
const logService = new ConsoleLogService(false);
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
|
||||
const bitwardenContainerService = new ContainerService(null, encryptService);
|
||||
bitwardenContainerService.attachToGlobal(self);
|
||||
|
||||
inited = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for messages and decrypt their contents
|
||||
*/
|
||||
workerApi.addEventListener("message", async (event: { data: string }) => {
|
||||
if (!inited) {
|
||||
init();
|
||||
}
|
||||
|
||||
const request: {
|
||||
id: string;
|
||||
items: Jsonify<Decryptable<any>>[];
|
||||
key: Jsonify<SymmetricCryptoKey>;
|
||||
} = JSON.parse(event.data);
|
||||
|
||||
const key = SymmetricCryptoKey.fromJSON(request.key);
|
||||
const items = request.items.map((jsonItem) => {
|
||||
const initializer = getClassInitializer<Decryptable<any>>(jsonItem.initializerKey);
|
||||
return initializer(jsonItem);
|
||||
});
|
||||
const result = await encryptService.decryptItems(items, key);
|
||||
|
||||
workerApi.postMessage({
|
||||
id: request.id,
|
||||
items: JSON.stringify(result),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Cipher } from "../../../vault/models/domain/cipher";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { InitializerMetadata } from "../../interfaces/initializer-metadata.interface";
|
||||
|
||||
import { InitializerKey } from "./initializer-key";
|
||||
|
||||
/**
|
||||
* Internal reference of classes so we can reconstruct objects properly.
|
||||
* Each entry should be keyed using the Decryptable.initializerKey property
|
||||
*/
|
||||
const classInitializers: Record<InitializerKey, (obj: any) => any> = {
|
||||
[InitializerKey.Cipher]: Cipher.fromJSON,
|
||||
[InitializerKey.CipherView]: CipherView.fromJSON,
|
||||
};
|
||||
|
||||
export function getClassInitializer<T extends InitializerMetadata>(
|
||||
className: InitializerKey
|
||||
): (obj: Jsonify<T>) => T {
|
||||
return classInitializers[className];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum InitializerKey {
|
||||
Cipher = 0,
|
||||
CipherView = 1,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defaultIfEmpty, filter, firstValueFrom, fromEvent, map, Subject, takeUntil } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { Decryptable } from "../../interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "../../interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
import { getClassInitializer } from "./get-class-initializer";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
const workerTTL = 3 * 60000; // 3 minutes
|
||||
|
||||
export class MultithreadEncryptServiceImplementation extends EncryptServiceImplementation {
|
||||
private worker: Worker;
|
||||
private timeout: any;
|
||||
|
||||
private clear$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Sends items to a web worker to decrypt them.
|
||||
* This utilises multithreading to decrypt items faster without interrupting other operations (e.g. updating UI).
|
||||
*/
|
||||
async decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey
|
||||
): Promise<T[]> {
|
||||
if (items == null || items.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logService.info("Starting decryption using multithreading");
|
||||
|
||||
this.worker ??= new Worker(
|
||||
new URL(
|
||||
/* webpackChunkName: 'encrypt-worker' */
|
||||
"@bitwarden/common/platform/services/cryptography/encrypt.worker.ts",
|
||||
import.meta.url
|
||||
)
|
||||
);
|
||||
|
||||
this.restartTimeout();
|
||||
|
||||
const request = {
|
||||
id: Utils.newGuid(),
|
||||
items: items,
|
||||
key: key,
|
||||
};
|
||||
|
||||
this.worker.postMessage(JSON.stringify(request));
|
||||
|
||||
return await firstValueFrom(
|
||||
fromEvent(this.worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === request.id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
const initializer = getClassInitializer<T>(jsonItem.initializerKey);
|
||||
return initializer(jsonItem);
|
||||
})
|
||||
),
|
||||
takeUntil(this.clear$),
|
||||
defaultIfEmpty([])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.clear$.next();
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.clearTimeout();
|
||||
}
|
||||
|
||||
private restartTimeout() {
|
||||
this.clearTimeout();
|
||||
this.timeout = setTimeout(() => this.clear(), workerTTL);
|
||||
}
|
||||
|
||||
private clearTimeout() {
|
||||
if (this.timeout != null) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
190
libs/common/src/platform/services/encrypt.service.spec.ts
Normal file
190
libs/common/src/platform/services/encrypt.service.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { mockReset, mock } from "jest-mock-extended";
|
||||
|
||||
import { makeStaticByteArray } from "../../../spec";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { EncryptServiceImplementation } from "../services/cryptography/encrypt.service.implementation";
|
||||
|
||||
describe("EncryptService", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
let encryptService: EncryptServiceImplementation;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(cryptoFunctionService);
|
||||
mockReset(logService);
|
||||
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
});
|
||||
|
||||
describe("encryptToBytes", () => {
|
||||
const plainValue = makeStaticByteArray(16, 1);
|
||||
const iv = makeStaticByteArray(16, 30);
|
||||
const mac = makeStaticByteArray(32, 40);
|
||||
const encryptedData = makeStaticByteArray(20, 50);
|
||||
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.encryptToBytes(plainValue, null)).rejects.toThrow(
|
||||
"No encryption key"
|
||||
);
|
||||
});
|
||||
|
||||
describe("encrypts data", () => {
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService.randomBytes
|
||||
.calledWith(16)
|
||||
.mockResolvedValueOnce(iv.buffer as CsprngArray);
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(encryptedData.buffer);
|
||||
});
|
||||
|
||||
it("using a key which supports mac", async () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const encType = EncryptionType.AesCbc128_HmacSha256_B64;
|
||||
key.encType = encType;
|
||||
|
||||
key.macKey = makeStaticByteArray(16, 20);
|
||||
|
||||
cryptoFunctionService.hmac.mockResolvedValue(mac.buffer);
|
||||
|
||||
const actual = await encryptService.encryptToBytes(plainValue, key);
|
||||
|
||||
expect(actual.encryptionType).toEqual(encType);
|
||||
expect(actual.ivBytes).toEqualBuffer(iv);
|
||||
expect(actual.macBytes).toEqualBuffer(mac);
|
||||
expect(actual.dataBytes).toEqualBuffer(encryptedData);
|
||||
expect(actual.buffer.byteLength).toEqual(
|
||||
1 + iv.byteLength + mac.byteLength + encryptedData.byteLength
|
||||
);
|
||||
});
|
||||
|
||||
it("using a key which doesn't support mac", async () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const encType = EncryptionType.AesCbc256_B64;
|
||||
key.encType = encType;
|
||||
|
||||
key.macKey = null;
|
||||
|
||||
const actual = await encryptService.encryptToBytes(plainValue, key);
|
||||
|
||||
expect(cryptoFunctionService.hmac).not.toBeCalled();
|
||||
|
||||
expect(actual.encryptionType).toEqual(encType);
|
||||
expect(actual.ivBytes).toEqualBuffer(iv);
|
||||
expect(actual.macBytes).toBeNull();
|
||||
expect(actual.dataBytes).toEqualBuffer(encryptedData);
|
||||
expect(actual.buffer.byteLength).toEqual(1 + iv.byteLength + encryptedData.byteLength);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptToBytes", () => {
|
||||
const encType = EncryptionType.AesCbc256_HmacSha256_B64;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 100), encType);
|
||||
const computedMac = new Uint8Array(1).buffer;
|
||||
const encBuffer = new EncArrayBuffer(makeStaticByteArray(60, encType));
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService.hmac.mockResolvedValue(computedMac);
|
||||
});
|
||||
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.decryptToBytes(encBuffer, null)).rejects.toThrow(
|
||||
"No encryption key"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if no encrypted value is provided", () => {
|
||||
return expect(encryptService.decryptToBytes(null, key)).rejects.toThrow(
|
||||
"Nothing provided for decryption"
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key", async () => {
|
||||
const decryptedBytes = makeStaticByteArray(10, 200).buffer;
|
||||
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1).buffer);
|
||||
cryptoFunctionService.compare.mockResolvedValue(true);
|
||||
cryptoFunctionService.aesDecrypt.mockResolvedValueOnce(decryptedBytes);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.dataBytes),
|
||||
expect.toEqualBuffer(encBuffer.ivBytes),
|
||||
expect.toEqualBuffer(key.encKey)
|
||||
);
|
||||
|
||||
expect(actual).toEqualBuffer(decryptedBytes);
|
||||
});
|
||||
|
||||
it("compares macs using CryptoFunctionService", async () => {
|
||||
const expectedMacData = new Uint8Array(
|
||||
encBuffer.ivBytes.byteLength + encBuffer.dataBytes.byteLength
|
||||
);
|
||||
expectedMacData.set(new Uint8Array(encBuffer.ivBytes));
|
||||
expectedMacData.set(new Uint8Array(encBuffer.dataBytes), encBuffer.ivBytes.byteLength);
|
||||
|
||||
await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(cryptoFunctionService.hmac).toBeCalledWith(
|
||||
expect.toEqualBuffer(expectedMacData),
|
||||
key.macKey,
|
||||
"sha256"
|
||||
);
|
||||
|
||||
expect(cryptoFunctionService.compare).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.macBytes),
|
||||
expect.toEqualBuffer(computedMac)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null if macs don't match", async () => {
|
||||
cryptoFunctionService.compare.mockResolvedValue(false);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
expect(cryptoFunctionService.compare).toHaveBeenCalled();
|
||||
expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled();
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if encTypes don't match", async () => {
|
||||
key.encType = EncryptionType.AesCbc256_B64;
|
||||
cryptoFunctionService.compare.mockResolvedValue(true);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(actual).toBeNull();
|
||||
expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLegacyKey", () => {
|
||||
it("creates a legacy key if required", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32), EncryptionType.AesCbc256_B64);
|
||||
const encString = mock<EncString>();
|
||||
encString.encryptionType = EncryptionType.AesCbc128_HmacSha256_B64;
|
||||
|
||||
const actual = encryptService.resolveLegacyKey(key, encString);
|
||||
|
||||
const expected = new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("does not create a legacy key if not required", async () => {
|
||||
const encType = EncryptionType.AesCbc256_HmacSha256_B64;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64), encType);
|
||||
const encString = mock<EncString>();
|
||||
encString.encryptionType = encType;
|
||||
|
||||
const actual = encryptService.resolveLegacyKey(key, encString);
|
||||
|
||||
expect(actual).toEqual(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
227
libs/common/src/platform/services/environment.service.ts
Normal file
227
libs/common/src/platform/services/environment.service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { concatMap, Observable, Subject } from "rxjs";
|
||||
|
||||
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
|
||||
import {
|
||||
EnvironmentService as EnvironmentServiceAbstraction,
|
||||
Urls,
|
||||
} from "../abstractions/environment.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
|
||||
export class EnvironmentService implements EnvironmentServiceAbstraction {
|
||||
private readonly urlsSubject = new Subject<Urls>();
|
||||
urls: Observable<Urls> = this.urlsSubject;
|
||||
|
||||
protected baseUrl: string;
|
||||
protected webVaultUrl: string;
|
||||
protected apiUrl: string;
|
||||
protected identityUrl: string;
|
||||
protected iconsUrl: string;
|
||||
protected notificationsUrl: string;
|
||||
protected eventsUrl: string;
|
||||
private keyConnectorUrl: string;
|
||||
private scimUrl: string = null;
|
||||
|
||||
constructor(private stateService: StateService) {
|
||||
this.stateService.activeAccount$
|
||||
.pipe(
|
||||
concatMap(async () => {
|
||||
await this.setUrlsFromStorage();
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
hasBaseUrl() {
|
||||
return this.baseUrl != null;
|
||||
}
|
||||
|
||||
getNotificationsUrl() {
|
||||
if (this.notificationsUrl != null) {
|
||||
return this.notificationsUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl != null) {
|
||||
return this.baseUrl + "/notifications";
|
||||
}
|
||||
|
||||
return "https://notifications.bitwarden.com";
|
||||
}
|
||||
|
||||
getWebVaultUrl() {
|
||||
if (this.webVaultUrl != null) {
|
||||
return this.webVaultUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl;
|
||||
}
|
||||
return "https://vault.bitwarden.com";
|
||||
}
|
||||
|
||||
getSendUrl() {
|
||||
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
|
||||
? "https://send.bitwarden.com/#"
|
||||
: this.getWebVaultUrl() + "/#/send/";
|
||||
}
|
||||
|
||||
getIconsUrl() {
|
||||
if (this.iconsUrl != null) {
|
||||
return this.iconsUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl + "/icons";
|
||||
}
|
||||
|
||||
return "https://icons.bitwarden.net";
|
||||
}
|
||||
|
||||
getApiUrl() {
|
||||
if (this.apiUrl != null) {
|
||||
return this.apiUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl + "/api";
|
||||
}
|
||||
|
||||
return "https://api.bitwarden.com";
|
||||
}
|
||||
|
||||
getIdentityUrl() {
|
||||
if (this.identityUrl != null) {
|
||||
return this.identityUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl + "/identity";
|
||||
}
|
||||
|
||||
return "https://identity.bitwarden.com";
|
||||
}
|
||||
|
||||
getEventsUrl() {
|
||||
if (this.eventsUrl != null) {
|
||||
return this.eventsUrl;
|
||||
}
|
||||
|
||||
if (this.baseUrl) {
|
||||
return this.baseUrl + "/events";
|
||||
}
|
||||
|
||||
return "https://events.bitwarden.com";
|
||||
}
|
||||
|
||||
getKeyConnectorUrl() {
|
||||
return this.keyConnectorUrl;
|
||||
}
|
||||
|
||||
getScimUrl() {
|
||||
if (this.scimUrl != null) {
|
||||
return this.scimUrl + "/v2";
|
||||
}
|
||||
|
||||
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
|
||||
? "https://scim.bitwarden.com/v2"
|
||||
: this.getWebVaultUrl() + "/scim/v2";
|
||||
}
|
||||
|
||||
async setUrlsFromStorage(): Promise<void> {
|
||||
const urls: any = await this.stateService.getEnvironmentUrls();
|
||||
const envUrls = new EnvironmentUrls();
|
||||
|
||||
this.baseUrl = envUrls.base = urls.base;
|
||||
this.webVaultUrl = urls.webVault;
|
||||
this.apiUrl = envUrls.api = urls.api;
|
||||
this.identityUrl = envUrls.identity = urls.identity;
|
||||
this.iconsUrl = urls.icons;
|
||||
this.notificationsUrl = urls.notifications;
|
||||
this.eventsUrl = envUrls.events = urls.events;
|
||||
this.keyConnectorUrl = urls.keyConnector;
|
||||
// scimUrl is not saved to storage
|
||||
}
|
||||
|
||||
async setUrls(urls: Urls): Promise<Urls> {
|
||||
urls.base = this.formatUrl(urls.base);
|
||||
urls.webVault = this.formatUrl(urls.webVault);
|
||||
urls.api = this.formatUrl(urls.api);
|
||||
urls.identity = this.formatUrl(urls.identity);
|
||||
urls.icons = this.formatUrl(urls.icons);
|
||||
urls.notifications = this.formatUrl(urls.notifications);
|
||||
urls.events = this.formatUrl(urls.events);
|
||||
urls.keyConnector = this.formatUrl(urls.keyConnector);
|
||||
|
||||
// scimUrl cannot be cleared
|
||||
urls.scim = this.formatUrl(urls.scim) ?? this.scimUrl;
|
||||
|
||||
await this.stateService.setEnvironmentUrls({
|
||||
base: urls.base,
|
||||
api: urls.api,
|
||||
identity: urls.identity,
|
||||
webVault: urls.webVault,
|
||||
icons: urls.icons,
|
||||
notifications: urls.notifications,
|
||||
events: urls.events,
|
||||
keyConnector: urls.keyConnector,
|
||||
// scimUrl is not saved to storage
|
||||
});
|
||||
|
||||
this.baseUrl = urls.base;
|
||||
this.webVaultUrl = urls.webVault;
|
||||
this.apiUrl = urls.api;
|
||||
this.identityUrl = urls.identity;
|
||||
this.iconsUrl = urls.icons;
|
||||
this.notificationsUrl = urls.notifications;
|
||||
this.eventsUrl = urls.events;
|
||||
this.keyConnectorUrl = urls.keyConnector;
|
||||
this.scimUrl = urls.scim;
|
||||
|
||||
this.urlsSubject.next(urls);
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
getUrls() {
|
||||
return {
|
||||
base: this.baseUrl,
|
||||
webVault: this.webVaultUrl,
|
||||
api: this.apiUrl,
|
||||
identity: this.identityUrl,
|
||||
icons: this.iconsUrl,
|
||||
notifications: this.notificationsUrl,
|
||||
events: this.eventsUrl,
|
||||
keyConnector: this.keyConnectorUrl,
|
||||
scim: this.scimUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private formatUrl(url: string): string {
|
||||
if (url == null || url === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
url = url.replace(/\/+$/g, "");
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
|
||||
return url.trim();
|
||||
}
|
||||
|
||||
isCloud(): boolean {
|
||||
return ["https://api.bitwarden.com", "https://vault.bitwarden.com/api"].includes(
|
||||
this.getApiUrl()
|
||||
);
|
||||
}
|
||||
|
||||
isSelfHosted(): boolean {
|
||||
return ![
|
||||
"http://vault.bitwarden.com",
|
||||
"https://vault.bitwarden.com",
|
||||
"http://vault.bitwarden.eu",
|
||||
"https://vault.bitwarden.eu",
|
||||
"http://vault.qa.bitwarden.pw",
|
||||
"https://vault.qa.bitwarden.pw",
|
||||
].includes(this.getWebVaultUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { EncArrayBuffer } from "../../models/domain/enc-array-buffer";
|
||||
|
||||
const MAX_SINGLE_BLOB_UPLOAD_SIZE = 256 * 1024 * 1024; // 256 MiB
|
||||
const MAX_BLOCKS_PER_BLOB = 50000;
|
||||
|
||||
export class AzureFileUploadService {
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
async upload(url: string, data: EncArrayBuffer, renewalCallback: () => Promise<string>) {
|
||||
if (data.buffer.byteLength <= MAX_SINGLE_BLOB_UPLOAD_SIZE) {
|
||||
return await this.azureUploadBlob(url, data);
|
||||
} else {
|
||||
return await this.azureUploadBlocks(url, data, renewalCallback);
|
||||
}
|
||||
}
|
||||
private async azureUploadBlob(url: string, data: EncArrayBuffer) {
|
||||
const urlObject = Utils.getUrl(url);
|
||||
const headers = new Headers({
|
||||
"x-ms-date": new Date().toUTCString(),
|
||||
"x-ms-version": urlObject.searchParams.get("sv"),
|
||||
"Content-Length": data.buffer.byteLength.toString(),
|
||||
"x-ms-blob-type": "BlockBlob",
|
||||
});
|
||||
|
||||
const request = new Request(url, {
|
||||
body: data.buffer,
|
||||
cache: "no-store",
|
||||
method: "PUT",
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
const blobResponse = await fetch(request);
|
||||
|
||||
if (blobResponse.status !== 201) {
|
||||
throw new Error(`Failed to create Azure blob: ${blobResponse.status}`);
|
||||
}
|
||||
}
|
||||
private async azureUploadBlocks(
|
||||
url: string,
|
||||
data: EncArrayBuffer,
|
||||
renewalCallback: () => Promise<string>
|
||||
) {
|
||||
const baseUrl = Utils.getUrl(url);
|
||||
const blockSize = this.getMaxBlockSize(baseUrl.searchParams.get("sv"));
|
||||
let blockIndex = 0;
|
||||
const numBlocks = Math.ceil(data.buffer.byteLength / blockSize);
|
||||
const blocksStaged: string[] = [];
|
||||
|
||||
if (numBlocks > MAX_BLOCKS_PER_BLOB) {
|
||||
throw new Error(
|
||||
`Cannot upload file, exceeds maximum size of ${blockSize * MAX_BLOCKS_PER_BLOB}`
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
try {
|
||||
while (blockIndex < numBlocks) {
|
||||
url = await this.renewUrlIfNecessary(url, renewalCallback);
|
||||
const blockUrl = Utils.getUrl(url);
|
||||
const blockId = this.encodedBlockId(blockIndex);
|
||||
blockUrl.searchParams.append("comp", "block");
|
||||
blockUrl.searchParams.append("blockid", blockId);
|
||||
const start = blockIndex * blockSize;
|
||||
const blockData = data.buffer.slice(start, start + blockSize);
|
||||
const blockHeaders = new Headers({
|
||||
"x-ms-date": new Date().toUTCString(),
|
||||
"x-ms-version": blockUrl.searchParams.get("sv"),
|
||||
"Content-Length": blockData.byteLength.toString(),
|
||||
});
|
||||
|
||||
const blockRequest = new Request(blockUrl.toString(), {
|
||||
body: blockData,
|
||||
cache: "no-store",
|
||||
method: "PUT",
|
||||
headers: blockHeaders,
|
||||
});
|
||||
|
||||
const blockResponse = await fetch(blockRequest);
|
||||
|
||||
if (blockResponse.status !== 201) {
|
||||
const message = `Unsuccessful block PUT. Received status ${blockResponse.status}`;
|
||||
this.logService.error(message + "\n" + (await blockResponse.json()));
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
blocksStaged.push(blockId);
|
||||
blockIndex++;
|
||||
}
|
||||
|
||||
url = await this.renewUrlIfNecessary(url, renewalCallback);
|
||||
const blockListUrl = Utils.getUrl(url);
|
||||
const blockListXml = this.blockListXml(blocksStaged);
|
||||
blockListUrl.searchParams.append("comp", "blocklist");
|
||||
const headers = new Headers({
|
||||
"x-ms-date": new Date().toUTCString(),
|
||||
"x-ms-version": blockListUrl.searchParams.get("sv"),
|
||||
"Content-Length": blockListXml.length.toString(),
|
||||
});
|
||||
|
||||
const request = new Request(blockListUrl.toString(), {
|
||||
body: blockListXml,
|
||||
cache: "no-store",
|
||||
method: "PUT",
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
const response = await fetch(request);
|
||||
|
||||
if (response.status !== 201) {
|
||||
const message = `Unsuccessful block list PUT. Received status ${response.status}`;
|
||||
this.logService.error(message + "\n" + (await response.json()));
|
||||
throw new Error(message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async renewUrlIfNecessary(
|
||||
url: string,
|
||||
renewalCallback: () => Promise<string>
|
||||
): Promise<string> {
|
||||
const urlObject = Utils.getUrl(url);
|
||||
const expiry = new Date(urlObject.searchParams.get("se") ?? "");
|
||||
|
||||
if (isNaN(expiry.getTime())) {
|
||||
expiry.setTime(Date.now() + 3600000);
|
||||
}
|
||||
|
||||
if (expiry.getTime() < Date.now() + 1000) {
|
||||
return await renewalCallback();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private encodedBlockId(blockIndex: number) {
|
||||
// Encoded blockId max size is 64, so pre-encoding max size is 48
|
||||
const utfBlockId = (
|
||||
"000000000000000000000000000000000000000000000000" + blockIndex.toString()
|
||||
).slice(-48);
|
||||
return Utils.fromUtf8ToB64(utfBlockId);
|
||||
}
|
||||
|
||||
private blockListXml(blockIdList: string[]) {
|
||||
let xml = '<?xml version="1.0" encoding="utf-8"?><BlockList>';
|
||||
blockIdList.forEach((blockId) => {
|
||||
xml += `<Latest>${blockId}</Latest>`;
|
||||
});
|
||||
xml += "</BlockList>";
|
||||
return xml;
|
||||
}
|
||||
|
||||
private getMaxBlockSize(version: string) {
|
||||
if (Version.compare(version, "2019-12-12") >= 0) {
|
||||
return 4000 * 1024 * 1024; // 4000 MiB
|
||||
} else if (Version.compare(version, "2016-05-31") >= 0) {
|
||||
return 100 * 1024 * 1024; // 100 MiB
|
||||
} else {
|
||||
return 4 * 1024 * 1024; // 4 MiB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Version {
|
||||
/**
|
||||
* Compares two Azure Versions against each other
|
||||
* @param a Version to compare
|
||||
* @param b Version to compare
|
||||
* @returns a number less than zero if b is newer than a, 0 if equal,
|
||||
* and greater than zero if a is newer than b
|
||||
*/
|
||||
static compare(a: Required<Version> | string, b: Required<Version> | string) {
|
||||
if (typeof a === "string") {
|
||||
a = new Version(a);
|
||||
}
|
||||
|
||||
if (typeof b === "string") {
|
||||
b = new Version(b);
|
||||
}
|
||||
|
||||
return a.year !== b.year
|
||||
? a.year - b.year
|
||||
: a.month !== b.month
|
||||
? a.month - b.month
|
||||
: a.day !== b.day
|
||||
? a.day - b.day
|
||||
: 0;
|
||||
}
|
||||
year = 0;
|
||||
month = 0;
|
||||
day = 0;
|
||||
|
||||
constructor(version: string) {
|
||||
try {
|
||||
const parts = version.split("-").map((v) => Number.parseInt(v, 10));
|
||||
this.year = parts[0];
|
||||
this.month = parts[1];
|
||||
this.day = parts[2];
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Compares two Azure Versions against each other
|
||||
* @param compareTo Version to compare against
|
||||
* @returns a number less than zero if compareTo is newer, 0 if equal,
|
||||
* and greater than zero if this is greater than compareTo
|
||||
*/
|
||||
compare(compareTo: Required<Version> | string) {
|
||||
return Version.compare(this, compareTo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { EncArrayBuffer } from "../../models/domain/enc-array-buffer";
|
||||
|
||||
export class BitwardenFileUploadService {
|
||||
async upload(
|
||||
encryptedFileName: string,
|
||||
encryptedFileData: EncArrayBuffer,
|
||||
apiCall: (fd: FormData) => Promise<any>
|
||||
) {
|
||||
const fd = new FormData();
|
||||
try {
|
||||
const blob = new Blob([encryptedFileData.buffer], { type: "application/octet-stream" });
|
||||
fd.append("data", blob, encryptedFileName);
|
||||
} catch (e) {
|
||||
if (Utils.isNode && !Utils.isBrowser) {
|
||||
fd.append(
|
||||
"data",
|
||||
Buffer.from(encryptedFileData.buffer) as any,
|
||||
{
|
||||
filepath: encryptedFileName,
|
||||
contentType: "application/octet-stream",
|
||||
} as any
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
await apiCall(fd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { FileUploadType } from "../../../enums";
|
||||
import {
|
||||
FileUploadApiMethods,
|
||||
FileUploadService as FileUploadServiceAbstraction,
|
||||
} from "../../abstractions/file-upload/file-upload.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { EncArrayBuffer } from "../../models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../models/domain/enc-string";
|
||||
|
||||
import { AzureFileUploadService } from "./azure-file-upload.service";
|
||||
import { BitwardenFileUploadService } from "./bitwarden-file-upload.service";
|
||||
|
||||
export class FileUploadService implements FileUploadServiceAbstraction {
|
||||
private azureFileUploadService: AzureFileUploadService;
|
||||
private bitwardenFileUploadService: BitwardenFileUploadService;
|
||||
|
||||
constructor(protected logService: LogService) {
|
||||
this.azureFileUploadService = new AzureFileUploadService(logService);
|
||||
this.bitwardenFileUploadService = new BitwardenFileUploadService();
|
||||
}
|
||||
|
||||
async upload(
|
||||
uploadData: { url: string; fileUploadType: FileUploadType },
|
||||
fileName: EncString,
|
||||
encryptedFileData: EncArrayBuffer,
|
||||
fileUploadMethods: FileUploadApiMethods
|
||||
) {
|
||||
try {
|
||||
switch (uploadData.fileUploadType) {
|
||||
case FileUploadType.Direct:
|
||||
await this.bitwardenFileUploadService.upload(
|
||||
fileName.encryptedString,
|
||||
encryptedFileData,
|
||||
(fd) => fileUploadMethods.postDirect(fd)
|
||||
);
|
||||
break;
|
||||
case FileUploadType.Azure: {
|
||||
await this.azureFileUploadService.upload(
|
||||
uploadData.url,
|
||||
encryptedFileData,
|
||||
fileUploadMethods.renewFileUploadUrl
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown file upload type");
|
||||
}
|
||||
} catch (e) {
|
||||
await fileUploadMethods.rollback();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { UntypedFormGroup, ValidationErrors } from "@angular/forms";
|
||||
|
||||
import {
|
||||
FormGroupControls,
|
||||
FormValidationErrorsService as FormValidationErrorsAbstraction,
|
||||
AllValidationErrors,
|
||||
} from "../abstractions/form-validation-errors.service";
|
||||
|
||||
export class FormValidationErrorsService implements FormValidationErrorsAbstraction {
|
||||
getFormValidationErrors(controls: FormGroupControls): AllValidationErrors[] {
|
||||
let errors: AllValidationErrors[] = [];
|
||||
Object.keys(controls).forEach((key) => {
|
||||
const control = controls[key];
|
||||
if (control instanceof UntypedFormGroup) {
|
||||
errors = errors.concat(this.getFormValidationErrors(control.controls));
|
||||
}
|
||||
|
||||
const controlErrors: ValidationErrors = controls[key].errors;
|
||||
if (controlErrors !== null) {
|
||||
Object.keys(controlErrors).forEach((keyError) => {
|
||||
errors.push({
|
||||
controlName: key,
|
||||
errorName: keyError,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
28
libs/common/src/platform/services/i18n.service.ts
Normal file
28
libs/common/src/platform/services/i18n.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Observable, ReplaySubject } from "rxjs";
|
||||
|
||||
import { I18nService as I18nServiceAbstraction } from "../abstractions/i18n.service";
|
||||
|
||||
import { TranslationService } from "./translation.service";
|
||||
|
||||
export class I18nService extends TranslationService implements I18nServiceAbstraction {
|
||||
protected _locale = new ReplaySubject<string>(1);
|
||||
private _translationLocale: string;
|
||||
locale$: Observable<string> = this._locale.asObservable();
|
||||
|
||||
constructor(
|
||||
protected systemLanguage: string,
|
||||
protected localesDirectory: string,
|
||||
protected getLocalesJson: (formattedLocale: string) => Promise<any>
|
||||
) {
|
||||
super(systemLanguage, localesDirectory, getLocalesJson);
|
||||
}
|
||||
|
||||
get translationLocale(): string {
|
||||
return this._translationLocale;
|
||||
}
|
||||
|
||||
set translationLocale(locale: string) {
|
||||
this._translationLocale = locale;
|
||||
this._locale.next(locale);
|
||||
}
|
||||
}
|
||||
34
libs/common/src/platform/services/memory-storage.service.ts
Normal file
34
libs/common/src/platform/services/memory-storage.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AbstractMemoryStorageService } from "../abstractions/storage.service";
|
||||
|
||||
export class MemoryStorageService extends AbstractMemoryStorageService {
|
||||
private store = new Map<string, any>();
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
if (this.store.has(key)) {
|
||||
const obj = this.store.get(key);
|
||||
return Promise.resolve(obj as T);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
save(key: string, obj: any): Promise<any> {
|
||||
if (obj == null) {
|
||||
return this.remove(key);
|
||||
}
|
||||
this.store.set(key, obj);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
remove(key: string): Promise<any> {
|
||||
this.store.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
getBypassCache<T>(key: string): Promise<T> {
|
||||
return this.get<T>(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { MessagingService } from "../abstractions/messaging.service";
|
||||
|
||||
export class NoopMessagingService implements MessagingService {
|
||||
send(subscriber: string, arg: any = {}) {
|
||||
// Do nothing...
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
import { MockProxy, any, mock } from "jest-mock-extended";
|
||||
|
||||
import { StateVersion } from "../../enums";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
import { StateFactory } from "../factories/state-factory";
|
||||
import { Account } from "../models/domain/account";
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
|
||||
import { StateMigrationService } from "./state-migration.service";
|
||||
|
||||
const userId = "USER_ID";
|
||||
|
||||
// Note: each test calls the private migration method for that migration,
|
||||
// so that we don't accidentally run all following migrations as well
|
||||
|
||||
describe("State Migration Service", () => {
|
||||
let storageService: MockProxy<AbstractStorageService>;
|
||||
let secureStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let stateFactory: SubstituteOf<StateFactory>;
|
||||
|
||||
let stateMigrationService: StateMigrationService;
|
||||
|
||||
beforeEach(() => {
|
||||
storageService = mock();
|
||||
secureStorageService = Substitute.for<AbstractStorageService>();
|
||||
stateFactory = Substitute.for<StateFactory>();
|
||||
|
||||
stateMigrationService = new StateMigrationService(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
stateFactory
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("StateVersion 3 to 4 migration", () => {
|
||||
beforeEach(() => {
|
||||
const globalVersion3: Partial<GlobalState> = {
|
||||
stateVersion: StateVersion.Three,
|
||||
};
|
||||
|
||||
storageService.get.calledWith("global", any()).mockResolvedValue(globalVersion3);
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
|
||||
});
|
||||
|
||||
it("clears everBeenUnlocked", async () => {
|
||||
const accountVersion3: Account = {
|
||||
profile: {
|
||||
apiKeyClientId: null,
|
||||
convertAccountToKeyConnector: null,
|
||||
email: "EMAIL",
|
||||
emailVerified: true,
|
||||
everBeenUnlocked: true,
|
||||
hasPremiumPersonally: false,
|
||||
kdfIterations: 100000,
|
||||
kdfType: 0,
|
||||
keyHash: "KEY_HASH",
|
||||
lastSync: "LAST_SYNC",
|
||||
userId: userId,
|
||||
usesKeyConnector: false,
|
||||
forcePasswordResetReason: null,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedAccountVersion4: Account = {
|
||||
profile: {
|
||||
...accountVersion3.profile,
|
||||
},
|
||||
};
|
||||
delete expectedAccountVersion4.profile.everBeenUnlocked;
|
||||
|
||||
storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion3);
|
||||
|
||||
await (stateMigrationService as any).migrateStateFrom3To4();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledTimes(2);
|
||||
expect(storageService.save).toHaveBeenCalledWith(userId, expectedAccountVersion4, any());
|
||||
});
|
||||
|
||||
it("updates StateVersion number", async () => {
|
||||
await (stateMigrationService as any).migrateStateFrom3To4();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
{ stateVersion: StateVersion.Four },
|
||||
any()
|
||||
);
|
||||
expect(storageService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateVersion 4 to 5 migration", () => {
|
||||
it("migrates organization keys to new format", async () => {
|
||||
const accountVersion4 = new Account({
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: "orgOneEncKey",
|
||||
orgTwoId: "orgTwoEncKey",
|
||||
orgThreeId: "orgThreeEncKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const expectedAccount = new Account({
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: {
|
||||
type: "organization",
|
||||
key: "orgOneEncKey",
|
||||
},
|
||||
orgTwoId: {
|
||||
type: "organization",
|
||||
key: "orgTwoEncKey",
|
||||
},
|
||||
orgThreeId: {
|
||||
type: "organization",
|
||||
key: "orgThreeEncKey",
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5(
|
||||
accountVersion4
|
||||
);
|
||||
|
||||
expect(migratedAccount).toEqual(expectedAccount);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateVersion 5 to 6 migration", () => {
|
||||
it("deletes account.keys.legacyEtmKey value", async () => {
|
||||
const accountVersion5 = new Account({
|
||||
keys: {
|
||||
legacyEtmKey: "legacy key",
|
||||
},
|
||||
} as any);
|
||||
|
||||
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom5To6(
|
||||
accountVersion5
|
||||
);
|
||||
|
||||
expect(migratedAccount.keys.legacyEtmKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateVersion 6 to 7 migration", () => {
|
||||
it("should delete global.noAutoPromptBiometrics value", async () => {
|
||||
storageService.get
|
||||
.calledWith("global", any())
|
||||
.mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([]);
|
||||
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
{
|
||||
stateVersion: StateVersion.Seven,
|
||||
},
|
||||
any()
|
||||
);
|
||||
});
|
||||
|
||||
it("should call migrateStateFrom6To7 on each account", async () => {
|
||||
const accountVersion6 = new Account({
|
||||
otherStuff: "other stuff",
|
||||
} as any);
|
||||
|
||||
storageService.get
|
||||
.calledWith("global", any())
|
||||
.mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
|
||||
storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion6);
|
||||
|
||||
const migrateSpy = jest.fn();
|
||||
(stateMigrationService as any).migrateAccountFrom6To7 = migrateSpy;
|
||||
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
expect(migrateSpy).toHaveBeenCalledWith(true, accountVersion6);
|
||||
});
|
||||
|
||||
it("should update account.settings.disableAutoBiometricsPrompt value if global is no prompt", async () => {
|
||||
const result = await (stateMigrationService as any).migrateAccountFrom6To7(true, {
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
otherStuff: "other stuff",
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update account.settings.disableAutoBiometricsPrompt value if global auto prompt is enabled", async () => {
|
||||
const result = await (stateMigrationService as any).migrateAccountFrom6To7(false, {
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
587
libs/common/src/platform/services/state-migration.service.ts
Normal file
587
libs/common/src/platform/services/state-migration.service.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
import { CollectionData } from "../../admin-console/models/data/collection.data";
|
||||
import { OrganizationData } from "../../admin-console/models/data/organization.data";
|
||||
import { PolicyData } from "../../admin-console/models/data/policy.data";
|
||||
import { ProviderData } from "../../admin-console/models/data/provider.data";
|
||||
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
|
||||
import { TokenService } from "../../auth/services/token.service";
|
||||
import { StateVersion, ThemeType, KdfType, HtmlStorageLocation } from "../../enums";
|
||||
import { EventData } from "../../models/data/event.data";
|
||||
import { GeneratedPasswordHistory } from "../../tools/generator/password";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
import { FolderData } from "../../vault/models/data/folder.data";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
import { StateFactory } from "../factories/state-factory";
|
||||
import {
|
||||
Account,
|
||||
AccountSettings,
|
||||
EncryptionPair,
|
||||
AccountSettingsSettings,
|
||||
} from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
import { StorageOptions } from "../models/domain/storage-options";
|
||||
|
||||
// Originally (before January 2022) storage was handled as a flat key/value pair store.
|
||||
// With the move to a typed object for state storage these keys should no longer be in use anywhere outside of this migration.
|
||||
const v1Keys: { [key: string]: string } = {
|
||||
accessToken: "accessToken",
|
||||
alwaysShowDock: "alwaysShowDock",
|
||||
autoConfirmFingerprints: "autoConfirmFingerprints",
|
||||
autoFillOnPageLoadDefault: "autoFillOnPageLoadDefault",
|
||||
biometricAwaitingAcceptance: "biometricAwaitingAcceptance",
|
||||
biometricFingerprintValidated: "biometricFingerprintValidated",
|
||||
biometricText: "biometricText",
|
||||
biometricUnlock: "biometric",
|
||||
clearClipboard: "clearClipboardKey",
|
||||
clientId: "apikey_clientId",
|
||||
clientSecret: "apikey_clientSecret",
|
||||
collapsedGroupings: "collapsedGroupings",
|
||||
convertAccountToKeyConnector: "convertAccountToKeyConnector",
|
||||
defaultUriMatch: "defaultUriMatch",
|
||||
disableAddLoginNotification: "disableAddLoginNotification",
|
||||
disableAutoBiometricsPrompt: "noAutoPromptBiometrics",
|
||||
disableAutoTotpCopy: "disableAutoTotpCopy",
|
||||
disableBadgeCounter: "disableBadgeCounter",
|
||||
disableChangedPasswordNotification: "disableChangedPasswordNotification",
|
||||
disableContextMenuItem: "disableContextMenuItem",
|
||||
disableFavicon: "disableFavicon",
|
||||
disableGa: "disableGa",
|
||||
dontShowCardsCurrentTab: "dontShowCardsCurrentTab",
|
||||
dontShowIdentitiesCurrentTab: "dontShowIdentitiesCurrentTab",
|
||||
emailVerified: "emailVerified",
|
||||
enableAlwaysOnTop: "enableAlwaysOnTopKey",
|
||||
enableAutoFillOnPageLoad: "enableAutoFillOnPageLoad",
|
||||
enableBiometric: "enabledBiometric",
|
||||
enableBrowserIntegration: "enableBrowserIntegration",
|
||||
enableBrowserIntegrationFingerprint: "enableBrowserIntegrationFingerprint",
|
||||
enableCloseToTray: "enableCloseToTray",
|
||||
enableFullWidth: "enableFullWidth",
|
||||
enableMinimizeToTray: "enableMinimizeToTray",
|
||||
enableStartToTray: "enableStartToTrayKey",
|
||||
enableTray: "enableTray",
|
||||
encKey: "encKey", // Generated Symmetric Key
|
||||
encOrgKeys: "encOrgKeys",
|
||||
encPrivate: "encPrivateKey",
|
||||
encProviderKeys: "encProviderKeys",
|
||||
entityId: "entityId",
|
||||
entityType: "entityType",
|
||||
environmentUrls: "environmentUrls",
|
||||
equivalentDomains: "equivalentDomains",
|
||||
eventCollection: "eventCollection",
|
||||
forcePasswordReset: "forcePasswordReset",
|
||||
history: "generatedPasswordHistory",
|
||||
installedVersion: "installedVersion",
|
||||
kdf: "kdf",
|
||||
kdfIterations: "kdfIterations",
|
||||
key: "key", // Master Key
|
||||
keyHash: "keyHash",
|
||||
lastActive: "lastActive",
|
||||
localData: "sitesLocalData",
|
||||
locale: "locale",
|
||||
mainWindowSize: "mainWindowSize",
|
||||
minimizeOnCopyToClipboard: "minimizeOnCopyToClipboardKey",
|
||||
neverDomains: "neverDomains",
|
||||
noAutoPromptBiometricsText: "noAutoPromptBiometricsText",
|
||||
openAtLogin: "openAtLogin",
|
||||
passwordGenerationOptions: "passwordGenerationOptions",
|
||||
pinProtected: "pinProtectedKey",
|
||||
protectedPin: "protectedPin",
|
||||
refreshToken: "refreshToken",
|
||||
ssoCodeVerifier: "ssoCodeVerifier",
|
||||
ssoIdentifier: "ssoOrgIdentifier",
|
||||
ssoState: "ssoState",
|
||||
stamp: "securityStamp",
|
||||
theme: "theme",
|
||||
userEmail: "userEmail",
|
||||
userId: "userId",
|
||||
usesConnector: "usesKeyConnector",
|
||||
vaultTimeoutAction: "vaultTimeoutAction",
|
||||
vaultTimeout: "lockOption",
|
||||
rememberedEmail: "rememberedEmail",
|
||||
};
|
||||
|
||||
const v1KeyPrefixes: { [key: string]: string } = {
|
||||
ciphers: "ciphers_",
|
||||
collections: "collections_",
|
||||
folders: "folders_",
|
||||
lastSync: "lastSync_",
|
||||
policies: "policies_",
|
||||
twoFactorToken: "twoFactorToken_",
|
||||
organizations: "organizations_",
|
||||
providers: "providers_",
|
||||
sends: "sends_",
|
||||
settings: "settings_",
|
||||
};
|
||||
|
||||
const keys = {
|
||||
global: "global",
|
||||
authenticatedAccounts: "authenticatedAccounts",
|
||||
activeUserId: "activeUserId",
|
||||
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
|
||||
accountActivity: "accountActivity",
|
||||
};
|
||||
|
||||
const partialKeys = {
|
||||
autoKey: "_masterkey_auto",
|
||||
biometricKey: "_masterkey_biometric",
|
||||
masterKey: "_masterkey",
|
||||
};
|
||||
|
||||
export class StateMigrationService<
|
||||
TGlobalState extends GlobalState = GlobalState,
|
||||
TAccount extends Account = Account
|
||||
> {
|
||||
constructor(
|
||||
protected storageService: AbstractStorageService,
|
||||
protected secureStorageService: AbstractStorageService,
|
||||
protected stateFactory: StateFactory<TGlobalState, TAccount>
|
||||
) {}
|
||||
|
||||
async needsMigration(): Promise<boolean> {
|
||||
const currentStateVersion = await this.getCurrentStateVersion();
|
||||
return currentStateVersion == null || currentStateVersion < StateVersion.Latest;
|
||||
}
|
||||
|
||||
async migrate(): Promise<void> {
|
||||
let currentStateVersion = await this.getCurrentStateVersion();
|
||||
while (currentStateVersion < StateVersion.Latest) {
|
||||
switch (currentStateVersion) {
|
||||
case StateVersion.One:
|
||||
await this.migrateStateFrom1To2();
|
||||
break;
|
||||
case StateVersion.Two:
|
||||
await this.migrateStateFrom2To3();
|
||||
break;
|
||||
case StateVersion.Three:
|
||||
await this.migrateStateFrom3To4();
|
||||
break;
|
||||
case StateVersion.Four: {
|
||||
const authenticatedAccounts = await this.getAuthenticatedAccounts();
|
||||
for (const account of authenticatedAccounts) {
|
||||
const migratedAccount = await this.migrateAccountFrom4To5(account);
|
||||
await this.set(account.profile.userId, migratedAccount);
|
||||
}
|
||||
await this.setCurrentStateVersion(StateVersion.Five);
|
||||
break;
|
||||
}
|
||||
case StateVersion.Five: {
|
||||
const authenticatedAccounts = await this.getAuthenticatedAccounts();
|
||||
for (const account of authenticatedAccounts) {
|
||||
const migratedAccount = await this.migrateAccountFrom5To6(account);
|
||||
await this.set(account.profile.userId, migratedAccount);
|
||||
}
|
||||
await this.setCurrentStateVersion(StateVersion.Six);
|
||||
break;
|
||||
}
|
||||
case StateVersion.Six: {
|
||||
const authenticatedAccounts = await this.getAuthenticatedAccounts();
|
||||
const globals = (await this.getGlobals()) as any;
|
||||
for (const account of authenticatedAccounts) {
|
||||
const migratedAccount = await this.migrateAccountFrom6To7(
|
||||
globals?.noAutoPromptBiometrics,
|
||||
account
|
||||
);
|
||||
await this.set(account.profile.userId, migratedAccount);
|
||||
}
|
||||
if (globals) {
|
||||
delete globals.noAutoPromptBiometrics;
|
||||
}
|
||||
await this.set(keys.global, globals);
|
||||
await this.setCurrentStateVersion(StateVersion.Seven);
|
||||
}
|
||||
}
|
||||
|
||||
currentStateVersion += 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected async migrateStateFrom1To2(): Promise<void> {
|
||||
const clearV1Keys = async (clearingUserId?: string) => {
|
||||
for (const key in v1Keys) {
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
await this.set(v1Keys[key], null);
|
||||
}
|
||||
if (clearingUserId != null) {
|
||||
for (const keyPrefix in v1KeyPrefixes) {
|
||||
if (keyPrefix == null) {
|
||||
continue;
|
||||
}
|
||||
await this.set(v1KeyPrefixes[keyPrefix] + userId, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Some processes, like biometrics, may have already defined a value before migrations are run.
|
||||
// We don't want to null out those values if they don't exist in the old storage scheme (like for new installs)
|
||||
// So, the OOO for migration is that we:
|
||||
// 1. Check for an existing storage value from the old storage structure OR
|
||||
// 2. Check for a value already set by processes that run before migration OR
|
||||
// 3. Assign the default value
|
||||
const globals: any =
|
||||
(await this.get<GlobalState>(keys.global)) ?? this.stateFactory.createGlobal(null);
|
||||
globals.stateVersion = StateVersion.Two;
|
||||
globals.environmentUrls =
|
||||
(await this.get<EnvironmentUrls>(v1Keys.environmentUrls)) ?? globals.environmentUrls;
|
||||
globals.locale = (await this.get<string>(v1Keys.locale)) ?? globals.locale;
|
||||
globals.noAutoPromptBiometrics =
|
||||
(await this.get<boolean>(v1Keys.disableAutoBiometricsPrompt)) ??
|
||||
globals.noAutoPromptBiometrics;
|
||||
globals.noAutoPromptBiometricsText =
|
||||
(await this.get<string>(v1Keys.noAutoPromptBiometricsText)) ??
|
||||
globals.noAutoPromptBiometricsText;
|
||||
globals.ssoCodeVerifier =
|
||||
(await this.get<string>(v1Keys.ssoCodeVerifier)) ?? globals.ssoCodeVerifier;
|
||||
globals.ssoOrganizationIdentifier =
|
||||
(await this.get<string>(v1Keys.ssoIdentifier)) ?? globals.ssoOrganizationIdentifier;
|
||||
globals.ssoState = (await this.get<any>(v1Keys.ssoState)) ?? globals.ssoState;
|
||||
globals.rememberedEmail =
|
||||
(await this.get<string>(v1Keys.rememberedEmail)) ?? globals.rememberedEmail;
|
||||
globals.theme = (await this.get<ThemeType>(v1Keys.theme)) ?? globals.theme;
|
||||
globals.vaultTimeout = (await this.get<number>(v1Keys.vaultTimeout)) ?? globals.vaultTimeout;
|
||||
globals.vaultTimeoutAction =
|
||||
(await this.get<string>(v1Keys.vaultTimeoutAction)) ?? globals.vaultTimeoutAction;
|
||||
globals.window = (await this.get<any>(v1Keys.mainWindowSize)) ?? globals.window;
|
||||
globals.enableTray = (await this.get<boolean>(v1Keys.enableTray)) ?? globals.enableTray;
|
||||
globals.enableMinimizeToTray =
|
||||
(await this.get<boolean>(v1Keys.enableMinimizeToTray)) ?? globals.enableMinimizeToTray;
|
||||
globals.enableCloseToTray =
|
||||
(await this.get<boolean>(v1Keys.enableCloseToTray)) ?? globals.enableCloseToTray;
|
||||
globals.enableStartToTray =
|
||||
(await this.get<boolean>(v1Keys.enableStartToTray)) ?? globals.enableStartToTray;
|
||||
globals.openAtLogin = (await this.get<boolean>(v1Keys.openAtLogin)) ?? globals.openAtLogin;
|
||||
globals.alwaysShowDock =
|
||||
(await this.get<boolean>(v1Keys.alwaysShowDock)) ?? globals.alwaysShowDock;
|
||||
globals.enableBrowserIntegration =
|
||||
(await this.get<boolean>(v1Keys.enableBrowserIntegration)) ??
|
||||
globals.enableBrowserIntegration;
|
||||
globals.enableBrowserIntegrationFingerprint =
|
||||
(await this.get<boolean>(v1Keys.enableBrowserIntegrationFingerprint)) ??
|
||||
globals.enableBrowserIntegrationFingerprint;
|
||||
|
||||
const userId =
|
||||
(await this.get<string>(v1Keys.userId)) ?? (await this.get<string>(v1Keys.entityId));
|
||||
|
||||
const defaultAccount = this.stateFactory.createAccount(null);
|
||||
const accountSettings: AccountSettings = {
|
||||
autoConfirmFingerPrints:
|
||||
(await this.get<boolean>(v1Keys.autoConfirmFingerprints)) ??
|
||||
defaultAccount.settings.autoConfirmFingerPrints,
|
||||
autoFillOnPageLoadDefault:
|
||||
(await this.get<boolean>(v1Keys.autoFillOnPageLoadDefault)) ??
|
||||
defaultAccount.settings.autoFillOnPageLoadDefault,
|
||||
biometricUnlock:
|
||||
(await this.get<boolean>(v1Keys.biometricUnlock)) ??
|
||||
defaultAccount.settings.biometricUnlock,
|
||||
clearClipboard:
|
||||
(await this.get<number>(v1Keys.clearClipboard)) ?? defaultAccount.settings.clearClipboard,
|
||||
defaultUriMatch:
|
||||
(await this.get<any>(v1Keys.defaultUriMatch)) ?? defaultAccount.settings.defaultUriMatch,
|
||||
disableAddLoginNotification:
|
||||
(await this.get<boolean>(v1Keys.disableAddLoginNotification)) ??
|
||||
defaultAccount.settings.disableAddLoginNotification,
|
||||
disableAutoBiometricsPrompt:
|
||||
(await this.get<boolean>(v1Keys.disableAutoBiometricsPrompt)) ??
|
||||
defaultAccount.settings.disableAutoBiometricsPrompt,
|
||||
disableAutoTotpCopy:
|
||||
(await this.get<boolean>(v1Keys.disableAutoTotpCopy)) ??
|
||||
defaultAccount.settings.disableAutoTotpCopy,
|
||||
disableBadgeCounter:
|
||||
(await this.get<boolean>(v1Keys.disableBadgeCounter)) ??
|
||||
defaultAccount.settings.disableBadgeCounter,
|
||||
disableChangedPasswordNotification:
|
||||
(await this.get<boolean>(v1Keys.disableChangedPasswordNotification)) ??
|
||||
defaultAccount.settings.disableChangedPasswordNotification,
|
||||
disableContextMenuItem:
|
||||
(await this.get<boolean>(v1Keys.disableContextMenuItem)) ??
|
||||
defaultAccount.settings.disableContextMenuItem,
|
||||
disableGa: (await this.get<boolean>(v1Keys.disableGa)) ?? defaultAccount.settings.disableGa,
|
||||
dontShowCardsCurrentTab:
|
||||
(await this.get<boolean>(v1Keys.dontShowCardsCurrentTab)) ??
|
||||
defaultAccount.settings.dontShowCardsCurrentTab,
|
||||
dontShowIdentitiesCurrentTab:
|
||||
(await this.get<boolean>(v1Keys.dontShowIdentitiesCurrentTab)) ??
|
||||
defaultAccount.settings.dontShowIdentitiesCurrentTab,
|
||||
enableAlwaysOnTop:
|
||||
(await this.get<boolean>(v1Keys.enableAlwaysOnTop)) ??
|
||||
defaultAccount.settings.enableAlwaysOnTop,
|
||||
enableAutoFillOnPageLoad:
|
||||
(await this.get<boolean>(v1Keys.enableAutoFillOnPageLoad)) ??
|
||||
defaultAccount.settings.enableAutoFillOnPageLoad,
|
||||
enableBiometric:
|
||||
(await this.get<boolean>(v1Keys.enableBiometric)) ??
|
||||
defaultAccount.settings.enableBiometric,
|
||||
enableFullWidth:
|
||||
(await this.get<boolean>(v1Keys.enableFullWidth)) ??
|
||||
defaultAccount.settings.enableFullWidth,
|
||||
environmentUrls: globals.environmentUrls ?? defaultAccount.settings.environmentUrls,
|
||||
equivalentDomains:
|
||||
(await this.get<any>(v1Keys.equivalentDomains)) ??
|
||||
defaultAccount.settings.equivalentDomains,
|
||||
minimizeOnCopyToClipboard:
|
||||
(await this.get<boolean>(v1Keys.minimizeOnCopyToClipboard)) ??
|
||||
defaultAccount.settings.minimizeOnCopyToClipboard,
|
||||
neverDomains:
|
||||
(await this.get<any>(v1Keys.neverDomains)) ?? defaultAccount.settings.neverDomains,
|
||||
passwordGenerationOptions:
|
||||
(await this.get<any>(v1Keys.passwordGenerationOptions)) ??
|
||||
defaultAccount.settings.passwordGenerationOptions,
|
||||
pinProtected: Object.assign(new EncryptionPair<string, EncString>(), {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<string>(v1Keys.pinProtected),
|
||||
}),
|
||||
protectedPin: await this.get<string>(v1Keys.protectedPin),
|
||||
settings:
|
||||
userId == null
|
||||
? null
|
||||
: await this.get<AccountSettingsSettings>(v1KeyPrefixes.settings + userId),
|
||||
vaultTimeout:
|
||||
(await this.get<number>(v1Keys.vaultTimeout)) ?? defaultAccount.settings.vaultTimeout,
|
||||
vaultTimeoutAction:
|
||||
(await this.get<string>(v1Keys.vaultTimeoutAction)) ??
|
||||
defaultAccount.settings.vaultTimeoutAction,
|
||||
};
|
||||
|
||||
// (userId == null) = no logged in user (so no known userId) and we need to temporarily store account specific settings in state to migrate on first auth
|
||||
// (userId != null) = we have a currently authed user (so known userId) with encrypted data and other key settings we can move, no need to temporarily store account settings
|
||||
if (userId == null) {
|
||||
await this.set(keys.tempAccountSettings, accountSettings);
|
||||
await this.set(keys.global, globals);
|
||||
await this.set(keys.authenticatedAccounts, []);
|
||||
await this.set(keys.activeUserId, null);
|
||||
await clearV1Keys();
|
||||
return;
|
||||
}
|
||||
|
||||
globals.twoFactorToken = await this.get<string>(v1KeyPrefixes.twoFactorToken + userId);
|
||||
await this.set(keys.global, globals);
|
||||
await this.set(userId, {
|
||||
data: {
|
||||
addEditCipherInfo: null,
|
||||
ciphers: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: CipherData }>(v1KeyPrefixes.ciphers + userId),
|
||||
},
|
||||
collapsedGroupings: null,
|
||||
collections: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: CollectionData }>(
|
||||
v1KeyPrefixes.collections + userId
|
||||
),
|
||||
},
|
||||
eventCollection: await this.get<EventData[]>(v1Keys.eventCollection),
|
||||
folders: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: FolderData }>(v1KeyPrefixes.folders + userId),
|
||||
},
|
||||
localData: null,
|
||||
organizations: await this.get<{ [id: string]: OrganizationData }>(
|
||||
v1KeyPrefixes.organizations + userId
|
||||
),
|
||||
passwordGenerationHistory: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<GeneratedPasswordHistory[]>(v1Keys.history),
|
||||
},
|
||||
policies: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: PolicyData }>(v1KeyPrefixes.policies + userId),
|
||||
},
|
||||
providers: await this.get<{ [id: string]: ProviderData }>(v1KeyPrefixes.providers + userId),
|
||||
sends: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: SendData }>(v1KeyPrefixes.sends + userId),
|
||||
},
|
||||
},
|
||||
keys: {
|
||||
apiKeyClientSecret: await this.get<string>(v1Keys.clientSecret),
|
||||
cryptoMasterKey: null,
|
||||
cryptoMasterKeyAuto: null,
|
||||
cryptoMasterKeyB64: null,
|
||||
cryptoMasterKeyBiometric: null,
|
||||
cryptoSymmetricKey: {
|
||||
encrypted: await this.get<string>(v1Keys.encKey),
|
||||
decrypted: null,
|
||||
},
|
||||
legacyEtmKey: null,
|
||||
organizationKeys: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<any>(v1Keys.encOrgKeys),
|
||||
},
|
||||
privateKey: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<string>(v1Keys.encPrivate),
|
||||
},
|
||||
providerKeys: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<any>(v1Keys.encProviderKeys),
|
||||
},
|
||||
publicKey: null,
|
||||
},
|
||||
profile: {
|
||||
apiKeyClientId: await this.get<string>(v1Keys.clientId),
|
||||
authenticationStatus: null,
|
||||
convertAccountToKeyConnector: await this.get<boolean>(v1Keys.convertAccountToKeyConnector),
|
||||
email: await this.get<string>(v1Keys.userEmail),
|
||||
emailVerified: await this.get<boolean>(v1Keys.emailVerified),
|
||||
entityId: null,
|
||||
entityType: null,
|
||||
everBeenUnlocked: null,
|
||||
forcePasswordReset: null,
|
||||
hasPremiumPersonally: null,
|
||||
kdfIterations: await this.get<number>(v1Keys.kdfIterations),
|
||||
kdfType: await this.get<KdfType>(v1Keys.kdf),
|
||||
keyHash: await this.get<string>(v1Keys.keyHash),
|
||||
lastSync: null,
|
||||
userId: userId,
|
||||
usesKeyConnector: null,
|
||||
},
|
||||
settings: accountSettings,
|
||||
tokens: {
|
||||
accessToken: await this.get<string>(v1Keys.accessToken),
|
||||
decodedToken: null,
|
||||
refreshToken: await this.get<string>(v1Keys.refreshToken),
|
||||
securityStamp: null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.set(keys.authenticatedAccounts, [userId]);
|
||||
await this.set(keys.activeUserId, userId);
|
||||
|
||||
const accountActivity: { [userId: string]: number } = {
|
||||
[userId]: await this.get<number>(v1Keys.lastActive),
|
||||
};
|
||||
accountActivity[userId] = await this.get<number>(v1Keys.lastActive);
|
||||
await this.set(keys.accountActivity, accountActivity);
|
||||
|
||||
await clearV1Keys(userId);
|
||||
|
||||
if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "biometric" })) {
|
||||
await this.secureStorageService.save(
|
||||
`${userId}${partialKeys.biometricKey}`,
|
||||
await this.secureStorageService.get(v1Keys.key, { keySuffix: "biometric" }),
|
||||
{ keySuffix: "biometric" }
|
||||
);
|
||||
await this.secureStorageService.remove(v1Keys.key, { keySuffix: "biometric" });
|
||||
}
|
||||
|
||||
if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "auto" })) {
|
||||
await this.secureStorageService.save(
|
||||
`${userId}${partialKeys.autoKey}`,
|
||||
await this.secureStorageService.get(v1Keys.key, { keySuffix: "auto" }),
|
||||
{ keySuffix: "auto" }
|
||||
);
|
||||
await this.secureStorageService.remove(v1Keys.key, { keySuffix: "auto" });
|
||||
}
|
||||
|
||||
if (await this.secureStorageService.has(v1Keys.key)) {
|
||||
await this.secureStorageService.save(
|
||||
`${userId}${partialKeys.masterKey}`,
|
||||
await this.secureStorageService.get(v1Keys.key)
|
||||
);
|
||||
await this.secureStorageService.remove(v1Keys.key);
|
||||
}
|
||||
}
|
||||
|
||||
protected async migrateStateFrom2To3(): Promise<void> {
|
||||
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
|
||||
await Promise.all(
|
||||
authenticatedUserIds.map(async (userId) => {
|
||||
const account = await this.get<TAccount>(userId);
|
||||
if (
|
||||
account?.profile?.hasPremiumPersonally === null &&
|
||||
account.tokens?.accessToken != null
|
||||
) {
|
||||
const decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
|
||||
account.profile.hasPremiumPersonally = decodedToken.premium;
|
||||
await this.set(userId, account);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Three;
|
||||
await this.set(keys.global, globals);
|
||||
}
|
||||
|
||||
protected async migrateStateFrom3To4(): Promise<void> {
|
||||
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
|
||||
await Promise.all(
|
||||
authenticatedUserIds.map(async (userId) => {
|
||||
const account = await this.get<TAccount>(userId);
|
||||
if (account?.profile?.everBeenUnlocked != null) {
|
||||
delete account.profile.everBeenUnlocked;
|
||||
return this.set(userId, account);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Four;
|
||||
await this.set(keys.global, globals);
|
||||
}
|
||||
|
||||
protected async migrateAccountFrom4To5(account: TAccount): Promise<TAccount> {
|
||||
const encryptedOrgKeys = account.keys?.organizationKeys?.encrypted;
|
||||
if (encryptedOrgKeys != null) {
|
||||
for (const [orgId, encKey] of Object.entries(encryptedOrgKeys)) {
|
||||
encryptedOrgKeys[orgId] = {
|
||||
type: "organization",
|
||||
key: encKey as unknown as string, // Account v4 does not reflect the current account model so we have to cast
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
protected async migrateAccountFrom5To6(account: TAccount): Promise<TAccount> {
|
||||
delete (account as any).keys?.legacyEtmKey;
|
||||
return account;
|
||||
}
|
||||
|
||||
protected async migrateAccountFrom6To7(
|
||||
globalSetting: boolean,
|
||||
account: TAccount
|
||||
): Promise<TAccount> {
|
||||
if (globalSetting) {
|
||||
account.settings = Object.assign({}, account.settings, { disableAutoBiometricsPrompt: true });
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
protected get options(): StorageOptions {
|
||||
return { htmlStorageLocation: HtmlStorageLocation.Local };
|
||||
}
|
||||
|
||||
protected get<T>(key: string): Promise<T> {
|
||||
return this.storageService.get<T>(key, this.options);
|
||||
}
|
||||
|
||||
protected set(key: string, value: any): Promise<any> {
|
||||
if (value == null) {
|
||||
return this.storageService.remove(key, this.options);
|
||||
}
|
||||
return this.storageService.save(key, value, this.options);
|
||||
}
|
||||
|
||||
protected async getGlobals(): Promise<TGlobalState> {
|
||||
return await this.get<TGlobalState>(keys.global);
|
||||
}
|
||||
|
||||
protected async getCurrentStateVersion(): Promise<StateVersion> {
|
||||
return (await this.getGlobals())?.stateVersion ?? StateVersion.One;
|
||||
}
|
||||
|
||||
protected async setCurrentStateVersion(newVersion: StateVersion): Promise<void> {
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = newVersion;
|
||||
await this.set(keys.global, globals);
|
||||
}
|
||||
|
||||
protected async getAuthenticatedAccounts(): Promise<TAccount[]> {
|
||||
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
|
||||
return Promise.all(authenticatedUserIds.map((id) => this.get<TAccount>(id)));
|
||||
}
|
||||
}
|
||||
3010
libs/common/src/platform/services/state.service.ts
Normal file
3010
libs/common/src/platform/services/state.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
108
libs/common/src/platform/services/system.service.ts
Normal file
108
libs/common/src/platform/services/system.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { MessagingService } from "../abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
export class SystemService implements SystemServiceAbstraction {
|
||||
private reloadInterval: any = null;
|
||||
private clearClipboardTimeout: any = null;
|
||||
private clearClipboardTimeoutFunction: () => Promise<any> = null;
|
||||
|
||||
constructor(
|
||||
private messagingService: MessagingService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private reloadCallback: () => Promise<void> = null,
|
||||
private stateService: StateService
|
||||
) {}
|
||||
|
||||
async startProcessReload(authService: AuthService): Promise<void> {
|
||||
const accounts = await firstValueFrom(this.stateService.accounts$);
|
||||
if (accounts != null) {
|
||||
const keys = Object.keys(accounts);
|
||||
if (keys.length > 0) {
|
||||
for (const userId of keys) {
|
||||
if ((await authService.getAuthStatus(userId)) === AuthenticationStatus.Unlocked) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reloadInterval has already been set and is executing
|
||||
if (this.reloadInterval != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// User has set a PIN, with ask for master password on restart, to protect their vault
|
||||
const decryptedPinProtected = await this.stateService.getDecryptedPinProtected();
|
||||
if (decryptedPinProtected != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelProcessReload();
|
||||
await this.executeProcessReload();
|
||||
}
|
||||
|
||||
private async executeProcessReload() {
|
||||
const biometricLockedFingerprintValidated =
|
||||
await this.stateService.getBiometricFingerprintValidated();
|
||||
if (!biometricLockedFingerprintValidated) {
|
||||
clearInterval(this.reloadInterval);
|
||||
this.reloadInterval = null;
|
||||
this.messagingService.send("reloadProcess");
|
||||
if (this.reloadCallback != null) {
|
||||
await this.reloadCallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.reloadInterval == null) {
|
||||
this.reloadInterval = setInterval(async () => await this.executeProcessReload(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
cancelProcessReload(): void {
|
||||
if (this.reloadInterval != null) {
|
||||
clearInterval(this.reloadInterval);
|
||||
this.reloadInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async clearClipboard(clipboardValue: string, timeoutMs: number = null): Promise<void> {
|
||||
if (this.clearClipboardTimeout != null) {
|
||||
clearTimeout(this.clearClipboardTimeout);
|
||||
this.clearClipboardTimeout = null;
|
||||
}
|
||||
if (Utils.isNullOrWhitespace(clipboardValue)) {
|
||||
return;
|
||||
}
|
||||
await this.stateService.getClearClipboard().then((clearSeconds) => {
|
||||
if (clearSeconds == null) {
|
||||
return;
|
||||
}
|
||||
if (timeoutMs == null) {
|
||||
timeoutMs = clearSeconds * 1000;
|
||||
}
|
||||
this.clearClipboardTimeoutFunction = async () => {
|
||||
const clipboardValueNow = await this.platformUtilsService.readFromClipboard();
|
||||
if (clipboardValue === clipboardValueNow) {
|
||||
this.platformUtilsService.copyToClipboard("", { clearing: true });
|
||||
}
|
||||
};
|
||||
this.clearClipboardTimeout = setTimeout(async () => {
|
||||
await this.clearPendingClipboard();
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async clearPendingClipboard() {
|
||||
if (this.clearClipboardTimeoutFunction != null) {
|
||||
await this.clearClipboardTimeoutFunction();
|
||||
this.clearClipboardTimeoutFunction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
180
libs/common/src/platform/services/translation.service.ts
Normal file
180
libs/common/src/platform/services/translation.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { TranslationService as TranslationServiceAbstraction } from "../abstractions/translation.service";
|
||||
|
||||
export abstract class TranslationService implements TranslationServiceAbstraction {
|
||||
// First locale is the default (English)
|
||||
supportedTranslationLocales: string[] = ["en"];
|
||||
defaultLocale = "en";
|
||||
abstract translationLocale: string;
|
||||
collator: Intl.Collator;
|
||||
localeNames = new Map<string, string>([
|
||||
["af", "Afrikaans"],
|
||||
["ar", "العربية الفصحى"],
|
||||
["az", "Azərbaycanca"],
|
||||
["be", "Беларуская"],
|
||||
["bg", "български"],
|
||||
["bn", "বাংলা"],
|
||||
["bs", "bosanski jezik"],
|
||||
["ca", "català"],
|
||||
["cs", "čeština"],
|
||||
["da", "dansk"],
|
||||
["de", "Deutsch"],
|
||||
["el", "Ελληνικά"],
|
||||
["en", "English"],
|
||||
["en-GB", "English (British)"],
|
||||
["en-IN", "English (India)"],
|
||||
["eo", "Esperanto"],
|
||||
["es", "español"],
|
||||
["et", "eesti"],
|
||||
["eu", "euskara"],
|
||||
["fa", "فارسی"],
|
||||
["fi", "suomi"],
|
||||
["fil", "Wikang Filipino"],
|
||||
["fr", "français"],
|
||||
["he", "עברית"],
|
||||
["hi", "हिन्दी"],
|
||||
["hr", "hrvatski"],
|
||||
["hu", "magyar"],
|
||||
["id", "Bahasa Indonesia"],
|
||||
["it", "italiano"],
|
||||
["ja", "日本語"],
|
||||
["ka", "ქართული"],
|
||||
["km", "ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ"],
|
||||
["kn", "ಕನ್ನಡ"],
|
||||
["ko", "한국어"],
|
||||
["lt", "lietuvių kalba"],
|
||||
["lv", "Latvietis"],
|
||||
["me", "црногорски"],
|
||||
["ml", "മലയാളം"],
|
||||
["nb", "norsk (bokmål)"],
|
||||
["nl", "Nederlands"],
|
||||
["nn", "Norsk Nynorsk"],
|
||||
["pl", "polski"],
|
||||
["pt-BR", "português do Brasil"],
|
||||
["pt-PT", "português"],
|
||||
["ro", "română"],
|
||||
["ru", "русский"],
|
||||
["si", "සිංහල"],
|
||||
["sk", "slovenčina"],
|
||||
["sl", "Slovenski jezik, Slovenščina"],
|
||||
["sr", "Српски"],
|
||||
["sv", "svenska"],
|
||||
["th", "ไทย"],
|
||||
["tr", "Türkçe"],
|
||||
["uk", "українська"],
|
||||
["vi", "Tiếng Việt"],
|
||||
["zh-CN", "中文(中国大陆)"],
|
||||
["zh-TW", "中文(台灣)"],
|
||||
]);
|
||||
|
||||
protected inited: boolean;
|
||||
protected defaultMessages: any = {};
|
||||
protected localeMessages: any = {};
|
||||
|
||||
constructor(
|
||||
protected systemLanguage: string,
|
||||
protected localesDirectory: string,
|
||||
protected getLocalesJson: (formattedLocale: string) => Promise<any>
|
||||
) {
|
||||
this.systemLanguage = systemLanguage.replace("_", "-");
|
||||
}
|
||||
|
||||
async init(locale?: string) {
|
||||
if (this.inited) {
|
||||
throw new Error("i18n already initialized.");
|
||||
}
|
||||
if (this.supportedTranslationLocales == null || this.supportedTranslationLocales.length === 0) {
|
||||
throw new Error("supportedTranslationLocales not set.");
|
||||
}
|
||||
|
||||
this.inited = true;
|
||||
this.translationLocale = locale != null ? locale : this.systemLanguage;
|
||||
|
||||
try {
|
||||
this.collator = new Intl.Collator(this.translationLocale, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
} catch {
|
||||
this.collator = null;
|
||||
}
|
||||
|
||||
if (this.supportedTranslationLocales.indexOf(this.translationLocale) === -1) {
|
||||
this.translationLocale = this.translationLocale.slice(0, 2);
|
||||
|
||||
if (this.supportedTranslationLocales.indexOf(this.translationLocale) === -1) {
|
||||
this.translationLocale = this.defaultLocale;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.localesDirectory != null) {
|
||||
await this.loadMessages(this.translationLocale, this.localeMessages);
|
||||
if (this.translationLocale !== this.defaultLocale) {
|
||||
await this.loadMessages(this.defaultLocale, this.defaultMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t(id: string, p1?: string, p2?: string, p3?: string): string {
|
||||
return this.translate(id, p1, p2, p3);
|
||||
}
|
||||
|
||||
translate(id: string, p1?: string | number, p2?: string | number, p3?: string | number): string {
|
||||
let result: string;
|
||||
// eslint-disable-next-line
|
||||
if (this.localeMessages.hasOwnProperty(id) && this.localeMessages[id]) {
|
||||
result = this.localeMessages[id];
|
||||
// eslint-disable-next-line
|
||||
} else if (this.defaultMessages.hasOwnProperty(id) && this.defaultMessages[id]) {
|
||||
result = this.defaultMessages[id];
|
||||
} else {
|
||||
result = "";
|
||||
}
|
||||
|
||||
if (result !== "") {
|
||||
if (p1 != null) {
|
||||
result = result.split("__$1__").join(p1.toString());
|
||||
}
|
||||
if (p2 != null) {
|
||||
result = result.split("__$2__").join(p2.toString());
|
||||
}
|
||||
if (p3 != null) {
|
||||
result = result.split("__$3__").join(p3.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected async loadMessages(locale: string, messagesObj: any): Promise<any> {
|
||||
const formattedLocale = locale.replace("-", "_");
|
||||
const locales = await this.getLocalesJson(formattedLocale);
|
||||
for (const prop in locales) {
|
||||
// eslint-disable-next-line
|
||||
if (!locales.hasOwnProperty(prop)) {
|
||||
continue;
|
||||
}
|
||||
messagesObj[prop] = locales[prop].message;
|
||||
|
||||
if (locales[prop].placeholders) {
|
||||
for (const placeProp in locales[prop].placeholders) {
|
||||
if (
|
||||
!locales[prop].placeholders.hasOwnProperty(placeProp) || // eslint-disable-line
|
||||
!locales[prop].placeholders[placeProp].content
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceToken = "\\$" + placeProp.toUpperCase() + "\\$";
|
||||
let replaceContent = locales[prop].placeholders[placeProp].content;
|
||||
if (replaceContent === "$1" || replaceContent === "$2" || replaceContent === "$3") {
|
||||
replaceContent = "__$" + replaceContent + "__";
|
||||
}
|
||||
messagesObj[prop] = messagesObj[prop].replace(
|
||||
new RegExp(replaceToken, "g"),
|
||||
replaceContent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
libs/common/src/platform/services/validation.service.ts
Normal file
36
libs/common/src/platform/services/validation.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ErrorResponse } from "../../models/response/error.response";
|
||||
import { I18nService } from "../abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { ValidationService as ValidationServiceAbstraction } from "../abstractions/validation.service";
|
||||
|
||||
export class ValidationService implements ValidationServiceAbstraction {
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService
|
||||
) {}
|
||||
|
||||
showError(data: any): string[] {
|
||||
const defaultErrorMessage = this.i18nService.t("unexpectedError");
|
||||
let errors: string[] = [];
|
||||
|
||||
if (data != null && typeof data === "string") {
|
||||
errors.push(data);
|
||||
} else if (data == null || typeof data !== "object") {
|
||||
errors.push(defaultErrorMessage);
|
||||
} else if (data.validationErrors != null) {
|
||||
errors = errors.concat((data as ErrorResponse).getAllMessages());
|
||||
} else {
|
||||
errors.push(data.message ? data.message : defaultErrorMessage);
|
||||
}
|
||||
|
||||
if (errors.length === 1) {
|
||||
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), errors[0]);
|
||||
} else if (errors.length > 1) {
|
||||
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), errors, {
|
||||
timeout: 5000 * errors.length,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Substitute } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
import { WebCryptoFunctionService } from "./web-crypto-function.service";
|
||||
|
||||
const RsaPublicKey =
|
||||
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl0Vawl/toXzkEvB82FEtqHP" +
|
||||
"4xlU2ab/v0crqIfXfIoWF/XXdHGIdrZeilnRXPPJT1B9dTsasttEZNnua/0Rek/cjNDHtzT52irfoZYS7X6HNIfOi54Q+egP" +
|
||||
"RQ1H7iNHVZz3K8Db9GCSKPeC8MbW6gVCzb15esCe1gGzg6wkMuWYDFYPoh/oBqcIqrGah7firqB1nDedzEjw32heP2DAffVN" +
|
||||
"084iTDjiWrJNUxBJ2pDD5Z9dT3MzQ2s09ew1yMWK2z37rT3YerC7OgEDmo3WYo3xL3qYJznu3EO2nmrYjiRa40wKSjxsTlUc" +
|
||||
"xDF+F0uMW8oR9EMUHgepdepfAtLsSAQIDAQAB";
|
||||
const RsaPrivateKey =
|
||||
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXRVrCX+2hfOQS8Hz" +
|
||||
"YUS2oc/jGVTZpv+/Ryuoh9d8ihYX9dd0cYh2tl6KWdFc88lPUH11Oxqy20Rk2e5r/RF6T9yM0Me3NPnaKt+hlhLtfoc0h86L" +
|
||||
"nhD56A9FDUfuI0dVnPcrwNv0YJIo94LwxtbqBULNvXl6wJ7WAbODrCQy5ZgMVg+iH+gGpwiqsZqHt+KuoHWcN53MSPDfaF4/" +
|
||||
"YMB99U3TziJMOOJask1TEEnakMPln11PczNDazT17DXIxYrbPfutPdh6sLs6AQOajdZijfEvepgnOe7cQ7aeatiOJFrjTApK" +
|
||||
"PGxOVRzEMX4XS4xbyhH0QxQeB6l16l8C0uxIBAgMBAAECggEASaWfeVDA3cVzOPFSpvJm20OTE+R6uGOU+7vh36TX/POq92q" +
|
||||
"Buwbd0h0oMD32FxsXywd2IxtBDUSiFM9699qufTVuM0Q3tZw6lHDTOVG08+tPdr8qSbMtw7PGFxN79fHLBxejjO4IrM9lapj" +
|
||||
"WpxEF+11x7r+wM+0xRZQ8sNFYG46aPfIaty4BGbL0I2DQ2y8I57iBCAy69eht59NLMm27fRWGJIWCuBIjlpfzET1j2HLXUIh" +
|
||||
"5bTBNzqaN039WH49HczGE3mQKVEJZc/efk3HaVd0a1Sjzyn0QY+N1jtZN3jTRbuDWA1AknkX1LX/0tUhuS3/7C3ejHxjw4Dk" +
|
||||
"1ZLo5/QKBgQDIWvqFn0+IKRSu6Ua2hDsufIHHUNLelbfLUMmFthxabcUn4zlvIscJO00Tq/ezopSRRvbGiqnxjv/mYxucvOU" +
|
||||
"BeZtlus0Q9RTACBtw9TGoNTmQbEunJ2FOSlqbQxkBBAjgGEppRPt30iGj/VjAhCATq2MYOa/X4dVR51BqQAFIEwKBgQDBSIf" +
|
||||
"TFKC/hDk6FKZlgwvupWYJyU9RkyfstPErZFmzoKhPkQ3YORo2oeAYmVUbS9I2iIYpYpYQJHX8jMuCbCz4ONxTCuSIXYQYUcU" +
|
||||
"q4PglCKp31xBAE6TN8SvhfME9/MvuDssnQinAHuF0GDAhF646T3LLS1not6Vszv7brwSoGwKBgQC88v/8cGfi80ssQZeMnVv" +
|
||||
"q1UTXIeQcQnoY5lGHJl3K8mbS3TnXE6c9j417Fdz+rj8KWzBzwWXQB5pSPflWcdZO886Xu/mVGmy9RWgLuVFhXwCwsVEPjNX" +
|
||||
"5ramRb0/vY0yzenUCninBsIxFSbIfrPtLUYCc4hpxr+sr2Mg/y6jpvQKBgBezMRRs3xkcuXepuI2R+BCXL1/b02IJTUf1F+1" +
|
||||
"eLLGd7YV0H+J3fgNc7gGWK51hOrF9JBZHBGeOUPlaukmPwiPdtQZpu4QNE3l37VlIpKTF30E6mb+BqR+nht3rUjarnMXgAoE" +
|
||||
"Z18y6/KIjpSMpqC92Nnk/EBM9EYe6Cf4eA9ApAoGAeqEUg46UTlJySkBKURGpIs3v1kkf5I0X8DnOhwb+HPxNaiEdmO7ckm8" +
|
||||
"+tPVgppLcG0+tMdLjigFQiDUQk2y3WjyxP5ZvXu7U96jaJRI8PFMoE06WeVYcdIzrID2HvqH+w0UQJFrLJ/0Mn4stFAEzXKZ" +
|
||||
"BokBGnjFnTnKcs7nv/O8=";
|
||||
|
||||
const Sha1Mac = "4d4c223f95dc577b665ec4ccbcb680b80a397038";
|
||||
const Sha256Mac = "6be3caa84922e12aaaaa2f16c40d44433bb081ef323db584eb616333ab4e874f";
|
||||
const Sha512Mac =
|
||||
"21910e341fa12106ca35758a2285374509326c9fbe0bd64e7b99c898f841dc948c58ce66d3504d8883c" +
|
||||
"5ea7817a0b7c5d4d9b00364ccd214669131fc17fe4aca";
|
||||
|
||||
describe("WebCrypto Function Service", () => {
|
||||
describe("pbkdf2", () => {
|
||||
const regular256Key = "pj9prw/OHPleXI6bRdmlaD+saJS4awrMiQsQiDjeu2I=";
|
||||
const utf8256Key = "yqvoFXgMRmHR3QPYr5pyR4uVuoHkltv9aHUP63p8n7I=";
|
||||
const unicode256Key = "ZdeOata6xoRpB4DLp8zHhXz5kLmkWtX5pd+TdRH8w8w=";
|
||||
|
||||
const regular512Key =
|
||||
"liTi/Ke8LPU1Qv+Vl7NGEVt/XMbsBVJ2kQxtVG/Z1/JFHFKQW3ZkI81qVlwTiCpb+cFXzs+57" +
|
||||
"eyhhx5wfKo5Cg==";
|
||||
const utf8512Key =
|
||||
"df0KdvIBeCzD/kyXptwQohaqUa4e7IyFUyhFQjXCANu5T+scq55hCcE4dG4T/MhAk2exw8j7ixRN" +
|
||||
"zXANiVZpnw==";
|
||||
const unicode512Key =
|
||||
"FE+AnUJaxv8jh+zUDtZz4mjjcYk0/PZDZm+SLJe3XtxtnpdqqpblX6JjuMZt/dYYNMOrb2+mD" +
|
||||
"L3FiQDTROh1lg==";
|
||||
|
||||
testPbkdf2("sha256", regular256Key, utf8256Key, unicode256Key);
|
||||
testPbkdf2("sha512", regular512Key, utf8512Key, unicode512Key);
|
||||
});
|
||||
|
||||
describe("hkdf", () => {
|
||||
const regular256Key = "qBUmEYtwTwwGPuw/z6bs/qYXXYNUlocFlyAuuANI8Pw=";
|
||||
const utf8256Key = "6DfJwW1R3txgiZKkIFTvVAb7qVlG7lKcmJGJoxR2GBU=";
|
||||
const unicode256Key = "gejGI82xthA+nKtKmIh82kjw+ttHr+ODsUoGdu5sf0A=";
|
||||
|
||||
const regular512Key = "xe5cIG6ZfwGmb1FvsOedM0XKOm21myZkjL/eDeKIqqM=";
|
||||
const utf8512Key = "XQMVBnxVEhlvjSFDQc77j5GDE9aorvbS0vKnjhRg0LY=";
|
||||
const unicode512Key = "148GImrTbrjaGAe/iWEpclINM8Ehhko+9lB14+52lqc=";
|
||||
|
||||
testHkdf("sha256", regular256Key, utf8256Key, unicode256Key);
|
||||
testHkdf("sha512", regular512Key, utf8512Key, unicode512Key);
|
||||
});
|
||||
|
||||
describe("hkdfExpand", () => {
|
||||
const prk16Byte = "criAmKtfzxanbgea5/kelQ==";
|
||||
const prk32Byte = "F5h4KdYQnIVH4rKH0P9CZb1GrR4n16/sJrS0PsQEn0Y=";
|
||||
const prk64Byte =
|
||||
"ssBK0mRG17VHdtsgt8yo4v25CRNpauH+0r2fwY/E9rLyaFBAOMbIeTry+" +
|
||||
"gUJ28p8y+hFh3EI9pcrEWaNvFYonQ==";
|
||||
|
||||
testHkdfExpand("sha256", prk32Byte, 32, "BnIqJlfnHm0e/2iB/15cbHyR19ARPIcWRp4oNS22CD8=");
|
||||
testHkdfExpand(
|
||||
"sha256",
|
||||
prk32Byte,
|
||||
64,
|
||||
"BnIqJlfnHm0e/2iB/15cbHyR19ARPIcWRp4oNS22CD9BV+" +
|
||||
"/queOZenPNkDhmlVyL2WZ3OSU5+7ISNF5NhNfvZA=="
|
||||
);
|
||||
testHkdfExpand("sha512", prk64Byte, 32, "uLWbMWodSBms5uGJ5WTRTesyW+MD7nlpCZvagvIRXlk=");
|
||||
testHkdfExpand(
|
||||
"sha512",
|
||||
prk64Byte,
|
||||
64,
|
||||
"uLWbMWodSBms5uGJ5WTRTesyW+MD7nlpCZvagvIRXlkY5Pv0sB+" +
|
||||
"MqvaopmkC6sD/j89zDwTV9Ib2fpucUydO8w=="
|
||||
);
|
||||
|
||||
it("should fail with prk too small", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const f = cryptoFunctionService.hkdfExpand(
|
||||
Utils.fromB64ToArray(prk16Byte),
|
||||
"info",
|
||||
32,
|
||||
"sha256"
|
||||
);
|
||||
await expect(f).rejects.toEqual(new Error("prk is too small."));
|
||||
});
|
||||
|
||||
it("should fail with outputByteSize is too large", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const f = cryptoFunctionService.hkdfExpand(
|
||||
Utils.fromB64ToArray(prk32Byte),
|
||||
"info",
|
||||
8161,
|
||||
"sha256"
|
||||
);
|
||||
await expect(f).rejects.toEqual(new Error("outputByteSize is too large."));
|
||||
});
|
||||
});
|
||||
|
||||
describe("hash", () => {
|
||||
const regular1Hash = "2a241604fb921fad12bf877282457268e1dccb70";
|
||||
const utf81Hash = "85672798dc5831e96d6c48655d3d39365a9c88b6";
|
||||
const unicode1Hash = "39c975935054a3efc805a9709b60763a823a6ad4";
|
||||
|
||||
const regular256Hash = "2b8e96031d352a8655d733d7a930b5ffbea69dc25cf65c7bca7dd946278908b2";
|
||||
const utf8256Hash = "25fe8440f5b01ed113b0a0e38e721b126d2f3f77a67518c4a04fcde4e33eeb9d";
|
||||
const unicode256Hash = "adc1c0c2afd6e92cefdf703f9b6eb2c38e0d6d1a040c83f8505c561fea58852e";
|
||||
|
||||
const regular512Hash =
|
||||
"c15cf11d43bde333647e3f559ec4193bb2edeaa0e8b902772f514cdf3f785a3f49a6e02a4b87b3" +
|
||||
"b47523271ad45b7e0aebb5cdcc1bc54815d256eb5dcb80da9d";
|
||||
const utf8512Hash =
|
||||
"035c31a877a291af09ed2d3a1a293e69c3e079ea2cecc00211f35e6bce10474ca3ad6e30b59e26118" +
|
||||
"37463f20969c5bc95282965a051a88f8cdf2e166549fcdd";
|
||||
const unicode512Hash =
|
||||
"2b16a5561af8ad6fe414cc103fc8036492e1fc6d9aabe1b655497054f760fe0e34c5d100ac773d" +
|
||||
"9f3030438284f22dbfa20cb2e9b019f2c98dfe38ce1ef41bae";
|
||||
|
||||
const regularMd5 = "5eceffa53a5fd58c44134211e2c5f522";
|
||||
const utf8Md5 = "3abc9433c09551b939c80aa0aa3174e1";
|
||||
const unicodeMd5 = "85ae134072c8d81257933f7045ba17ca";
|
||||
|
||||
testHash("sha1", regular1Hash, utf81Hash, unicode1Hash);
|
||||
testHash("sha256", regular256Hash, utf8256Hash, unicode256Hash);
|
||||
testHash("sha512", regular512Hash, utf8512Hash, unicode512Hash);
|
||||
testHash("md5", regularMd5, utf8Md5, unicodeMd5);
|
||||
});
|
||||
|
||||
describe("hmac", () => {
|
||||
testHmac("sha1", Sha1Mac);
|
||||
testHmac("sha256", Sha256Mac);
|
||||
testHmac("sha512", Sha512Mac);
|
||||
});
|
||||
|
||||
describe("compare", () => {
|
||||
it("should successfully compare two of the same values", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const a = new Uint8Array(2);
|
||||
a[0] = 1;
|
||||
a[1] = 2;
|
||||
const equal = await cryptoFunctionService.compare(a.buffer, a.buffer);
|
||||
expect(equal).toBe(true);
|
||||
});
|
||||
|
||||
it("should successfully compare two different values of the same length", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const a = new Uint8Array(2);
|
||||
a[0] = 1;
|
||||
a[1] = 2;
|
||||
const b = new Uint8Array(2);
|
||||
b[0] = 3;
|
||||
b[1] = 4;
|
||||
const equal = await cryptoFunctionService.compare(a.buffer, b.buffer);
|
||||
expect(equal).toBe(false);
|
||||
});
|
||||
|
||||
it("should successfully compare two different values of different lengths", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const a = new Uint8Array(2);
|
||||
a[0] = 1;
|
||||
a[1] = 2;
|
||||
const b = new Uint8Array(2);
|
||||
b[0] = 3;
|
||||
const equal = await cryptoFunctionService.compare(a.buffer, b.buffer);
|
||||
expect(equal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hmacFast", () => {
|
||||
testHmacFast("sha1", Sha1Mac);
|
||||
testHmacFast("sha256", Sha256Mac);
|
||||
testHmacFast("sha512", Sha512Mac);
|
||||
});
|
||||
|
||||
describe("compareFast", () => {
|
||||
it("should successfully compare two of the same values", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const a = new Uint8Array(2);
|
||||
a[0] = 1;
|
||||
a[1] = 2;
|
||||
const aByteString = Utils.fromBufferToByteString(a.buffer);
|
||||
const equal = await cryptoFunctionService.compareFast(aByteString, aByteString);
|
||||
expect(equal).toBe(true);
|
||||
});
|
||||
|
||||
it("should successfully compare two different values of the same length", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const a = new Uint8Array(2);
|
||||
a[0] = 1;
|
||||
a[1] = 2;
|
||||
const aByteString = Utils.fromBufferToByteString(a.buffer);
|
||||
const b = new Uint8Array(2);
|
||||
b[0] = 3;
|
||||
b[1] = 4;
|
||||
const bByteString = Utils.fromBufferToByteString(b.buffer);
|
||||
const equal = await cryptoFunctionService.compareFast(aByteString, bByteString);
|
||||
expect(equal).toBe(false);
|
||||
});
|
||||
|
||||
it("should successfully compare two different values of different lengths", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const a = new Uint8Array(2);
|
||||
a[0] = 1;
|
||||
a[1] = 2;
|
||||
const aByteString = Utils.fromBufferToByteString(a.buffer);
|
||||
const b = new Uint8Array(2);
|
||||
b[0] = 3;
|
||||
const bByteString = Utils.fromBufferToByteString(b.buffer);
|
||||
const equal = await cryptoFunctionService.compareFast(aByteString, bByteString);
|
||||
expect(equal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aesEncrypt", () => {
|
||||
it("should successfully encrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const iv = makeStaticByteArray(16);
|
||||
const key = makeStaticByteArray(32);
|
||||
const data = Utils.fromUtf8ToArray("EncryptMe!");
|
||||
const encValue = await cryptoFunctionService.aesEncrypt(data.buffer, iv.buffer, key.buffer);
|
||||
expect(Utils.fromBufferToB64(encValue)).toBe("ByUF8vhyX4ddU9gcooznwA==");
|
||||
});
|
||||
|
||||
it("should successfully encrypt and then decrypt data fast", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const iv = makeStaticByteArray(16);
|
||||
const key = makeStaticByteArray(32);
|
||||
const value = "EncryptMe!";
|
||||
const data = Utils.fromUtf8ToArray(value);
|
||||
const encValue = await cryptoFunctionService.aesEncrypt(data.buffer, iv.buffer, key.buffer);
|
||||
const encData = Utils.fromBufferToB64(encValue);
|
||||
const b64Iv = Utils.fromBufferToB64(iv.buffer);
|
||||
const symKey = new SymmetricCryptoKey(key.buffer);
|
||||
const params = cryptoFunctionService.aesDecryptFastParameters(encData, b64Iv, null, symKey);
|
||||
const decValue = await cryptoFunctionService.aesDecryptFast(params);
|
||||
expect(decValue).toBe(value);
|
||||
});
|
||||
|
||||
it("should successfully encrypt and then decrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const iv = makeStaticByteArray(16);
|
||||
const key = makeStaticByteArray(32);
|
||||
const value = "EncryptMe!";
|
||||
const data = Utils.fromUtf8ToArray(value);
|
||||
const encValue = await cryptoFunctionService.aesEncrypt(data.buffer, iv.buffer, key.buffer);
|
||||
const decValue = await cryptoFunctionService.aesDecrypt(encValue, iv.buffer, key.buffer);
|
||||
expect(Utils.fromBufferToUtf8(decValue)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aesDecryptFast", () => {
|
||||
it("should successfully decrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const iv = Utils.fromBufferToB64(makeStaticByteArray(16).buffer);
|
||||
const symKey = new SymmetricCryptoKey(makeStaticByteArray(32).buffer);
|
||||
const data = "ByUF8vhyX4ddU9gcooznwA==";
|
||||
const params = cryptoFunctionService.aesDecryptFastParameters(data, iv, null, symKey);
|
||||
const decValue = await cryptoFunctionService.aesDecryptFast(params);
|
||||
expect(decValue).toBe("EncryptMe!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aesDecrypt", () => {
|
||||
it("should successfully decrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const iv = makeStaticByteArray(16);
|
||||
const key = makeStaticByteArray(32);
|
||||
const data = Utils.fromB64ToArray("ByUF8vhyX4ddU9gcooznwA==");
|
||||
const decValue = await cryptoFunctionService.aesDecrypt(data.buffer, iv.buffer, key.buffer);
|
||||
expect(Utils.fromBufferToUtf8(decValue)).toBe("EncryptMe!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsaEncrypt", () => {
|
||||
it("should successfully encrypt and then decrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const pubKey = Utils.fromB64ToArray(RsaPublicKey);
|
||||
const privKey = Utils.fromB64ToArray(RsaPrivateKey);
|
||||
const value = "EncryptMe!";
|
||||
const data = Utils.fromUtf8ToArray(value);
|
||||
const encValue = await cryptoFunctionService.rsaEncrypt(data.buffer, pubKey.buffer, "sha1");
|
||||
const decValue = await cryptoFunctionService.rsaDecrypt(encValue, privKey.buffer, "sha1");
|
||||
expect(Utils.fromBufferToUtf8(decValue)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsaDecrypt", () => {
|
||||
it("should successfully decrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const privKey = Utils.fromB64ToArray(RsaPrivateKey);
|
||||
const data = Utils.fromB64ToArray(
|
||||
"A1/p8BQzN9UrbdYxUY2Va5+kPLyfZXF9JsZrjeEXcaclsnHurdxVAJcnbEqYMP3UXV" +
|
||||
"4YAS/mpf+Rxe6/X0WS1boQdA0MAHSgx95hIlAraZYpiMLLiJRKeo2u8YivCdTM9V5vuAEJwf9Tof/qFsFci3sApdbATkorCT" +
|
||||
"zFOIEPF2S1zgperEP23M01mr4dWVdYN18B32YF67xdJHMbFhp5dkQwv9CmscoWq7OE5HIfOb+JAh7BEZb+CmKhM3yWJvoR/D" +
|
||||
"/5jcercUtK2o+XrzNrL4UQ7yLZcFz6Bfwb/j6ICYvqd/YJwXNE6dwlL57OfwJyCdw2rRYf0/qI00t9u8Iitw=="
|
||||
);
|
||||
const decValue = await cryptoFunctionService.rsaDecrypt(data.buffer, privKey.buffer, "sha1");
|
||||
expect(Utils.fromBufferToUtf8(decValue)).toBe("EncryptMe!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsaExtractPublicKey", () => {
|
||||
it("should successfully extract key", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const privKey = Utils.fromB64ToArray(RsaPrivateKey);
|
||||
const publicKey = await cryptoFunctionService.rsaExtractPublicKey(privKey.buffer);
|
||||
expect(Utils.fromBufferToB64(publicKey)).toBe(RsaPublicKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsaGenerateKeyPair", () => {
|
||||
testRsaGenerateKeyPair(1024);
|
||||
testRsaGenerateKeyPair(2048);
|
||||
|
||||
// Generating 4096 bit keys can be slow. Commenting it out to save CI.
|
||||
// testRsaGenerateKeyPair(4096);
|
||||
});
|
||||
|
||||
describe("randomBytes", () => {
|
||||
it("should make a value of the correct length", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const randomData = await cryptoFunctionService.randomBytes(16);
|
||||
expect(randomData.byteLength).toBe(16);
|
||||
});
|
||||
|
||||
it("should not make the same value twice", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const randomData = await cryptoFunctionService.randomBytes(16);
|
||||
const randomData2 = await cryptoFunctionService.randomBytes(16);
|
||||
expect(
|
||||
randomData.byteLength === randomData2.byteLength && randomData !== randomData2
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function testPbkdf2(
|
||||
algorithm: "sha256" | "sha512",
|
||||
regularKey: string,
|
||||
utf8Key: string,
|
||||
unicodeKey: string
|
||||
) {
|
||||
const regularEmail = "user@example.com";
|
||||
const utf8Email = "üser@example.com";
|
||||
|
||||
const regularPassword = "password";
|
||||
const utf8Password = "pǻssword";
|
||||
const unicodePassword = "😀password🙏";
|
||||
|
||||
it("should create valid " + algorithm + " key from regular input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const key = await cryptoFunctionService.pbkdf2(regularPassword, regularEmail, algorithm, 5000);
|
||||
expect(Utils.fromBufferToB64(key)).toBe(regularKey);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " key from utf8 input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const key = await cryptoFunctionService.pbkdf2(utf8Password, utf8Email, algorithm, 5000);
|
||||
expect(Utils.fromBufferToB64(key)).toBe(utf8Key);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " key from unicode input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const key = await cryptoFunctionService.pbkdf2(unicodePassword, regularEmail, algorithm, 5000);
|
||||
expect(Utils.fromBufferToB64(key)).toBe(unicodeKey);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " key from array buffer input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const key = await cryptoFunctionService.pbkdf2(
|
||||
Utils.fromUtf8ToArray(regularPassword).buffer,
|
||||
Utils.fromUtf8ToArray(regularEmail).buffer,
|
||||
algorithm,
|
||||
5000
|
||||
);
|
||||
expect(Utils.fromBufferToB64(key)).toBe(regularKey);
|
||||
});
|
||||
}
|
||||
|
||||
function testHkdf(
|
||||
algorithm: "sha256" | "sha512",
|
||||
regularKey: string,
|
||||
utf8Key: string,
|
||||
unicodeKey: string
|
||||
) {
|
||||
const ikm = Utils.fromB64ToArray("criAmKtfzxanbgea5/kelQ==");
|
||||
|
||||
const regularSalt = "salt";
|
||||
const utf8Salt = "üser_salt";
|
||||
const unicodeSalt = "😀salt🙏";
|
||||
|
||||
const regularInfo = "info";
|
||||
const utf8Info = "üser_info";
|
||||
const unicodeInfo = "😀info🙏";
|
||||
|
||||
it("should create valid " + algorithm + " key from regular input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const key = await cryptoFunctionService.hkdf(ikm, regularSalt, regularInfo, 32, algorithm);
|
||||
expect(Utils.fromBufferToB64(key)).toBe(regularKey);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " key from utf8 input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const key = await cryptoFunctionService.hkdf(ikm, utf8Salt, utf8Info, 32, algorithm);
|
||||
expect(Utils.fromBufferToB64(key)).toBe(utf8Key);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " key from unicode input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const key = await cryptoFunctionService.hkdf(ikm, unicodeSalt, unicodeInfo, 32, algorithm);
|
||||
expect(Utils.fromBufferToB64(key)).toBe(unicodeKey);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " key from array buffer input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const key = await cryptoFunctionService.hkdf(
|
||||
ikm,
|
||||
Utils.fromUtf8ToArray(regularSalt).buffer,
|
||||
Utils.fromUtf8ToArray(regularInfo).buffer,
|
||||
32,
|
||||
algorithm
|
||||
);
|
||||
expect(Utils.fromBufferToB64(key)).toBe(regularKey);
|
||||
});
|
||||
}
|
||||
|
||||
function testHkdfExpand(
|
||||
algorithm: "sha256" | "sha512",
|
||||
b64prk: string,
|
||||
outputByteSize: number,
|
||||
b64ExpectedOkm: string
|
||||
) {
|
||||
const info = "info";
|
||||
|
||||
it("should create valid " + algorithm + " " + outputByteSize + " byte okm", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const okm = await cryptoFunctionService.hkdfExpand(
|
||||
Utils.fromB64ToArray(b64prk),
|
||||
info,
|
||||
outputByteSize,
|
||||
algorithm
|
||||
);
|
||||
expect(Utils.fromBufferToB64(okm)).toBe(b64ExpectedOkm);
|
||||
});
|
||||
}
|
||||
|
||||
function testHash(
|
||||
algorithm: "sha1" | "sha256" | "sha512" | "md5",
|
||||
regularHash: string,
|
||||
utf8Hash: string,
|
||||
unicodeHash: string
|
||||
) {
|
||||
const regularValue = "HashMe!!";
|
||||
const utf8Value = "HǻshMe!!";
|
||||
const unicodeValue = "😀HashMe!!!🙏";
|
||||
|
||||
it("should create valid " + algorithm + " hash from regular input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const hash = await cryptoFunctionService.hash(regularValue, algorithm);
|
||||
expect(Utils.fromBufferToHex(hash)).toBe(regularHash);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " hash from utf8 input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const hash = await cryptoFunctionService.hash(utf8Value, algorithm);
|
||||
expect(Utils.fromBufferToHex(hash)).toBe(utf8Hash);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " hash from unicode input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const hash = await cryptoFunctionService.hash(unicodeValue, algorithm);
|
||||
expect(Utils.fromBufferToHex(hash)).toBe(unicodeHash);
|
||||
});
|
||||
|
||||
it("should create valid " + algorithm + " hash from array buffer input", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const hash = await cryptoFunctionService.hash(
|
||||
Utils.fromUtf8ToArray(regularValue).buffer,
|
||||
algorithm
|
||||
);
|
||||
expect(Utils.fromBufferToHex(hash)).toBe(regularHash);
|
||||
});
|
||||
}
|
||||
|
||||
function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) {
|
||||
it("should create valid " + algorithm + " hmac", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const computedMac = await cryptoFunctionService.hmac(
|
||||
Utils.fromUtf8ToArray("SignMe!!").buffer,
|
||||
Utils.fromUtf8ToArray("secretkey").buffer,
|
||||
algorithm
|
||||
);
|
||||
expect(Utils.fromBufferToHex(computedMac)).toBe(mac);
|
||||
});
|
||||
}
|
||||
|
||||
function testHmacFast(algorithm: "sha1" | "sha256" | "sha512", mac: string) {
|
||||
it("should create valid " + algorithm + " hmac", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const keyByteString = Utils.fromBufferToByteString(Utils.fromUtf8ToArray("secretkey").buffer);
|
||||
const dataByteString = Utils.fromBufferToByteString(Utils.fromUtf8ToArray("SignMe!!").buffer);
|
||||
const computedMac = await cryptoFunctionService.hmacFast(
|
||||
dataByteString,
|
||||
keyByteString,
|
||||
algorithm
|
||||
);
|
||||
expect(Utils.fromBufferToHex(Utils.fromByteStringToArray(computedMac).buffer)).toBe(mac);
|
||||
});
|
||||
}
|
||||
|
||||
function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) {
|
||||
it(
|
||||
"should successfully generate a " + length + " bit key pair",
|
||||
async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const keyPair = await cryptoFunctionService.rsaGenerateKeyPair(length);
|
||||
expect(keyPair[0] == null || keyPair[1] == null).toBe(false);
|
||||
const publicKey = await cryptoFunctionService.rsaExtractPublicKey(keyPair[1]);
|
||||
expect(Utils.fromBufferToB64(keyPair[0])).toBe(Utils.fromBufferToB64(publicKey));
|
||||
},
|
||||
30000
|
||||
);
|
||||
}
|
||||
|
||||
function getWebCryptoFunctionService() {
|
||||
const platformUtilsMock = Substitute.for<PlatformUtilsService>();
|
||||
platformUtilsMock.isEdge().mimicks(() => navigator.userAgent.indexOf(" Edg/") !== -1);
|
||||
|
||||
return new WebCryptoFunctionService(window);
|
||||
}
|
||||
|
||||
function makeStaticByteArray(length: number) {
|
||||
const arr = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
arr[i] = i;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
403
libs/common/src/platform/services/web-crypto-function.service.ts
Normal file
403
libs/common/src/platform/services/web-crypto-function.service.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import * as argon2 from "argon2-browser";
|
||||
import * as forge from "node-forge";
|
||||
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { DecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
private crypto: Crypto;
|
||||
private subtle: SubtleCrypto;
|
||||
private wasmSupported: boolean;
|
||||
|
||||
constructor(win: Window | typeof global) {
|
||||
this.crypto = typeof win.crypto !== "undefined" ? win.crypto : null;
|
||||
this.subtle =
|
||||
!!this.crypto && typeof win.crypto.subtle !== "undefined" ? win.crypto.subtle : null;
|
||||
this.wasmSupported = this.checkIfWasmSupported();
|
||||
}
|
||||
|
||||
async pbkdf2(
|
||||
password: string | ArrayBuffer,
|
||||
salt: string | ArrayBuffer,
|
||||
algorithm: "sha256" | "sha512",
|
||||
iterations: number
|
||||
): Promise<ArrayBuffer> {
|
||||
const wcLen = algorithm === "sha256" ? 256 : 512;
|
||||
const passwordBuf = this.toBuf(password);
|
||||
const saltBuf = this.toBuf(salt);
|
||||
|
||||
const pbkdf2Params: Pbkdf2Params = {
|
||||
name: "PBKDF2",
|
||||
salt: saltBuf,
|
||||
iterations: iterations,
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
|
||||
const impKey = await this.subtle.importKey(
|
||||
"raw",
|
||||
passwordBuf,
|
||||
{ name: "PBKDF2" } as any,
|
||||
false,
|
||||
["deriveBits"]
|
||||
);
|
||||
return await this.subtle.deriveBits(pbkdf2Params, impKey, wcLen);
|
||||
}
|
||||
|
||||
async argon2(
|
||||
password: string | ArrayBuffer,
|
||||
salt: string | ArrayBuffer,
|
||||
iterations: number,
|
||||
memory: number,
|
||||
parallelism: number
|
||||
): Promise<ArrayBuffer> {
|
||||
if (!this.wasmSupported) {
|
||||
throw "Webassembly support is required for the Argon2 KDF feature.";
|
||||
}
|
||||
|
||||
const passwordArr = new Uint8Array(this.toBuf(password));
|
||||
const saltArr = new Uint8Array(this.toBuf(salt));
|
||||
|
||||
const result = await argon2.hash({
|
||||
pass: passwordArr,
|
||||
salt: saltArr,
|
||||
time: iterations,
|
||||
mem: memory,
|
||||
parallelism: parallelism,
|
||||
hashLen: 32,
|
||||
type: argon2.ArgonType.Argon2id,
|
||||
});
|
||||
return result.hash;
|
||||
}
|
||||
|
||||
async hkdf(
|
||||
ikm: ArrayBuffer,
|
||||
salt: string | ArrayBuffer,
|
||||
info: string | ArrayBuffer,
|
||||
outputByteSize: number,
|
||||
algorithm: "sha256" | "sha512"
|
||||
): Promise<ArrayBuffer> {
|
||||
const saltBuf = this.toBuf(salt);
|
||||
const infoBuf = this.toBuf(info);
|
||||
|
||||
const hkdfParams: HkdfParams = {
|
||||
name: "HKDF",
|
||||
salt: saltBuf,
|
||||
info: infoBuf,
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
|
||||
const impKey = await this.subtle.importKey("raw", ikm, { name: "HKDF" } as any, false, [
|
||||
"deriveBits",
|
||||
]);
|
||||
return await this.subtle.deriveBits(hkdfParams as any, impKey, outputByteSize * 8);
|
||||
}
|
||||
|
||||
// ref: https://tools.ietf.org/html/rfc5869
|
||||
async hkdfExpand(
|
||||
prk: ArrayBuffer,
|
||||
info: string | ArrayBuffer,
|
||||
outputByteSize: number,
|
||||
algorithm: "sha256" | "sha512"
|
||||
): Promise<ArrayBuffer> {
|
||||
const hashLen = algorithm === "sha256" ? 32 : 64;
|
||||
if (outputByteSize > 255 * hashLen) {
|
||||
throw new Error("outputByteSize is too large.");
|
||||
}
|
||||
const prkArr = new Uint8Array(prk);
|
||||
if (prkArr.length < hashLen) {
|
||||
throw new Error("prk is too small.");
|
||||
}
|
||||
const infoBuf = this.toBuf(info);
|
||||
const infoArr = new Uint8Array(infoBuf);
|
||||
let runningOkmLength = 0;
|
||||
let previousT = new Uint8Array(0);
|
||||
const n = Math.ceil(outputByteSize / hashLen);
|
||||
const okm = new Uint8Array(n * hashLen);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = new Uint8Array(previousT.length + infoArr.length + 1);
|
||||
t.set(previousT);
|
||||
t.set(infoArr, previousT.length);
|
||||
t.set([i + 1], t.length - 1);
|
||||
previousT = new Uint8Array(await this.hmac(t.buffer, prk, algorithm));
|
||||
okm.set(previousT, runningOkmLength);
|
||||
runningOkmLength += previousT.length;
|
||||
if (runningOkmLength >= outputByteSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return okm.slice(0, outputByteSize).buffer;
|
||||
}
|
||||
|
||||
async hash(
|
||||
value: string | ArrayBuffer,
|
||||
algorithm: "sha1" | "sha256" | "sha512" | "md5"
|
||||
): Promise<ArrayBuffer> {
|
||||
if (algorithm === "md5") {
|
||||
const md = algorithm === "md5" ? forge.md.md5.create() : forge.md.sha1.create();
|
||||
const valueBytes = this.toByteString(value);
|
||||
md.update(valueBytes, "raw");
|
||||
return Utils.fromByteStringToArray(md.digest().data).buffer;
|
||||
}
|
||||
|
||||
const valueBuf = this.toBuf(value);
|
||||
return await this.subtle.digest({ name: this.toWebCryptoAlgorithm(algorithm) }, valueBuf);
|
||||
}
|
||||
|
||||
async hmac(
|
||||
value: ArrayBuffer,
|
||||
key: ArrayBuffer,
|
||||
algorithm: "sha1" | "sha256" | "sha512"
|
||||
): Promise<ArrayBuffer> {
|
||||
const signingAlgorithm = {
|
||||
name: "HMAC",
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
|
||||
const impKey = await this.subtle.importKey("raw", key, signingAlgorithm, false, ["sign"]);
|
||||
return await this.subtle.sign(signingAlgorithm, impKey, value);
|
||||
}
|
||||
|
||||
// Safely compare two values in a way that protects against timing attacks (Double HMAC Verification).
|
||||
// ref: https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/
|
||||
// ref: https://paragonie.com/blog/2015/11/preventing-timing-attacks-on-string-comparison-with-double-hmac-strategy
|
||||
async compare(a: ArrayBuffer, b: ArrayBuffer): Promise<boolean> {
|
||||
const macKey = await this.randomBytes(32);
|
||||
const signingAlgorithm = {
|
||||
name: "HMAC",
|
||||
hash: { name: "SHA-256" },
|
||||
};
|
||||
const impKey = await this.subtle.importKey("raw", macKey, signingAlgorithm, false, ["sign"]);
|
||||
const mac1 = await this.subtle.sign(signingAlgorithm, impKey, a);
|
||||
const mac2 = await this.subtle.sign(signingAlgorithm, impKey, b);
|
||||
|
||||
if (mac1.byteLength !== mac2.byteLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const arr1 = new Uint8Array(mac1);
|
||||
const arr2 = new Uint8Array(mac2);
|
||||
for (let i = 0; i < arr2.length; i++) {
|
||||
if (arr1[i] !== arr2[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
hmacFast(value: string, key: string, algorithm: "sha1" | "sha256" | "sha512"): Promise<string> {
|
||||
const hmac = forge.hmac.create();
|
||||
hmac.start(algorithm, key);
|
||||
hmac.update(value);
|
||||
const bytes = hmac.digest().getBytes();
|
||||
return Promise.resolve(bytes);
|
||||
}
|
||||
|
||||
async compareFast(a: string, b: string): Promise<boolean> {
|
||||
const rand = await this.randomBytes(32);
|
||||
const bytes = new Uint32Array(rand);
|
||||
const buffer = forge.util.createBuffer();
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
buffer.putInt32(bytes[i]);
|
||||
}
|
||||
const macKey = buffer.getBytes();
|
||||
|
||||
const hmac = forge.hmac.create();
|
||||
hmac.start("sha256", macKey);
|
||||
hmac.update(a);
|
||||
const mac1 = hmac.digest().getBytes();
|
||||
|
||||
hmac.start(null, null);
|
||||
hmac.update(b);
|
||||
const mac2 = hmac.digest().getBytes();
|
||||
|
||||
const equals = mac1 === mac2;
|
||||
return equals;
|
||||
}
|
||||
|
||||
async aesEncrypt(data: ArrayBuffer, iv: ArrayBuffer, key: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
const impKey = await this.subtle.importKey("raw", key, { name: "AES-CBC" } as any, false, [
|
||||
"encrypt",
|
||||
]);
|
||||
return await this.subtle.encrypt({ name: "AES-CBC", iv: iv }, impKey, data);
|
||||
}
|
||||
|
||||
aesDecryptFastParameters(
|
||||
data: string,
|
||||
iv: string,
|
||||
mac: string,
|
||||
key: SymmetricCryptoKey
|
||||
): DecryptParameters<string> {
|
||||
const p = new DecryptParameters<string>();
|
||||
if (key.meta != null) {
|
||||
p.encKey = key.meta.encKeyByteString;
|
||||
p.macKey = key.meta.macKeyByteString;
|
||||
}
|
||||
|
||||
if (p.encKey == null) {
|
||||
p.encKey = forge.util.decode64(key.encKeyB64);
|
||||
}
|
||||
p.data = forge.util.decode64(data);
|
||||
p.iv = forge.util.decode64(iv);
|
||||
p.macData = p.iv + p.data;
|
||||
if (p.macKey == null && key.macKeyB64 != null) {
|
||||
p.macKey = forge.util.decode64(key.macKeyB64);
|
||||
}
|
||||
if (mac != null) {
|
||||
p.mac = forge.util.decode64(mac);
|
||||
}
|
||||
|
||||
// cache byte string keys for later
|
||||
if (key.meta == null) {
|
||||
key.meta = {};
|
||||
}
|
||||
if (key.meta.encKeyByteString == null) {
|
||||
key.meta.encKeyByteString = p.encKey;
|
||||
}
|
||||
if (p.macKey != null && key.meta.macKeyByteString == null) {
|
||||
key.meta.macKeyByteString = p.macKey;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
aesDecryptFast(parameters: DecryptParameters<string>): Promise<string> {
|
||||
const dataBuffer = forge.util.createBuffer(parameters.data);
|
||||
const decipher = forge.cipher.createDecipher("AES-CBC", parameters.encKey);
|
||||
decipher.start({ iv: parameters.iv });
|
||||
decipher.update(dataBuffer);
|
||||
decipher.finish();
|
||||
const val = decipher.output.toString();
|
||||
return Promise.resolve(val);
|
||||
}
|
||||
|
||||
async aesDecrypt(data: ArrayBuffer, iv: ArrayBuffer, key: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
const impKey = await this.subtle.importKey("raw", key, { name: "AES-CBC" } as any, false, [
|
||||
"decrypt",
|
||||
]);
|
||||
return await this.subtle.decrypt({ name: "AES-CBC", iv: iv }, impKey, data);
|
||||
}
|
||||
|
||||
async rsaEncrypt(
|
||||
data: ArrayBuffer,
|
||||
publicKey: ArrayBuffer,
|
||||
algorithm: "sha1" | "sha256"
|
||||
): Promise<ArrayBuffer> {
|
||||
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
|
||||
// We cannot use the proper types here.
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
const impKey = await this.subtle.importKey("spki", publicKey, rsaParams, false, ["encrypt"]);
|
||||
return await this.subtle.encrypt(rsaParams, impKey, data);
|
||||
}
|
||||
|
||||
async rsaDecrypt(
|
||||
data: ArrayBuffer,
|
||||
privateKey: ArrayBuffer,
|
||||
algorithm: "sha1" | "sha256"
|
||||
): Promise<ArrayBuffer> {
|
||||
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
|
||||
// We cannot use the proper types here.
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
const impKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, false, ["decrypt"]);
|
||||
return await this.subtle.decrypt(rsaParams, impKey, data);
|
||||
}
|
||||
|
||||
async rsaExtractPublicKey(privateKey: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
// Have to specify some algorithm
|
||||
hash: { name: this.toWebCryptoAlgorithm("sha1") },
|
||||
};
|
||||
const impPrivateKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, true, [
|
||||
"decrypt",
|
||||
]);
|
||||
const jwkPrivateKey = await this.subtle.exportKey("jwk", impPrivateKey);
|
||||
const jwkPublicKeyParams = {
|
||||
kty: "RSA",
|
||||
e: jwkPrivateKey.e,
|
||||
n: jwkPrivateKey.n,
|
||||
alg: "RSA-OAEP",
|
||||
ext: true,
|
||||
};
|
||||
const impPublicKey = await this.subtle.importKey("jwk", jwkPublicKeyParams, rsaParams, true, [
|
||||
"encrypt",
|
||||
]);
|
||||
return await this.subtle.exportKey("spki", impPublicKey);
|
||||
}
|
||||
|
||||
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[ArrayBuffer, ArrayBuffer]> {
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: length,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
|
||||
// Have to specify some algorithm
|
||||
hash: { name: this.toWebCryptoAlgorithm("sha1") },
|
||||
};
|
||||
const keyPair = (await this.subtle.generateKey(rsaParams, true, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
])) as CryptoKeyPair;
|
||||
const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey);
|
||||
const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey);
|
||||
return [publicKey, privateKey];
|
||||
}
|
||||
|
||||
randomBytes(length: number): Promise<CsprngArray> {
|
||||
const arr = new Uint8Array(length);
|
||||
this.crypto.getRandomValues(arr);
|
||||
return Promise.resolve(arr.buffer as CsprngArray);
|
||||
}
|
||||
|
||||
private toBuf(value: string | ArrayBuffer): ArrayBuffer {
|
||||
let buf: ArrayBuffer;
|
||||
if (typeof value === "string") {
|
||||
buf = Utils.fromUtf8ToArray(value).buffer;
|
||||
} else {
|
||||
buf = value;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
private toByteString(value: string | ArrayBuffer): string {
|
||||
let bytes: string;
|
||||
if (typeof value === "string") {
|
||||
bytes = forge.util.encodeUtf8(value);
|
||||
} else {
|
||||
bytes = Utils.fromBufferToByteString(value);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private toWebCryptoAlgorithm(algorithm: "sha1" | "sha256" | "sha512" | "md5"): string {
|
||||
if (algorithm === "md5") {
|
||||
throw new Error("MD5 is not supported in WebCrypto.");
|
||||
}
|
||||
return algorithm === "sha1" ? "SHA-1" : algorithm === "sha256" ? "SHA-256" : "SHA-512";
|
||||
}
|
||||
|
||||
// ref: https://stackoverflow.com/a/47880734/1090359
|
||||
private checkIfWasmSupported(): boolean {
|
||||
try {
|
||||
if (typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function") {
|
||||
const module = new WebAssembly.Module(
|
||||
Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)
|
||||
);
|
||||
if (module instanceof WebAssembly.Module) {
|
||||
return new WebAssembly.Instance(module) instanceof WebAssembly.Instance;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user