1
0
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:
Matt Gibson
2023-06-06 15:34:53 -05:00
committed by GitHub
parent ce4fc31efd
commit 78248db590
869 changed files with 2840 additions and 2746 deletions

View File

@@ -0,0 +1,4 @@
export abstract class AppIdService {
getAppId: () => Promise<string>;
getAnonymousAppId: () => Promise<string>;
}

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

View File

@@ -0,0 +1,5 @@
import { ServerConfigResponse } from "../../models/response/server-config.response";
export abstract class ConfigApiServiceAbstraction {
get: () => Promise<ServerConfigResponse>;
}

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

@@ -0,0 +1,6 @@
export type FileDownloadRequest = {
fileName: string;
blobData: BlobPart;
blobOptions?: BlobPropertyBag;
downloadMethod?: "save" | "open";
};

View File

@@ -0,0 +1,5 @@
import { FileDownloadRequest } from "./file-download.request";
export abstract class FileDownloadService {
download: (request: FileDownloadRequest) => void;
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { Observable } from "rxjs";
import { TranslationService } from "./translation.service";
export abstract class I18nService extends TranslationService {
locale$: Observable<string>;
}

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

View File

@@ -0,0 +1,3 @@
export abstract class MessagingService {
send: (subscriber: string, arg?: any) => void;
}

View File

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

View File

@@ -0,0 +1,4 @@
export abstract class StateMigrationService {
needsMigration: () => Promise<boolean>;
migrate: () => Promise<void>;
}

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

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

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

View File

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

View File

@@ -0,0 +1,3 @@
export abstract class ValidationService {
showError: (data: any) => string[];
}

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

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

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

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

View File

@@ -0,0 +1,8 @@
import { EncryptionType } from "../../enums";
export interface Encrypted {
encryptionType?: EncryptionType;
dataBytes: ArrayBuffer;
macBytes: ArrayBuffer;
ivBytes: ArrayBuffer;
}

View File

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,8 @@
export class DecryptParameters<T> {
encKey: T;
data: T;
iv: T;
macKey: T;
mac: T;
macData: T;
}

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

View File

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

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

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

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

View File

@@ -0,0 +1,8 @@
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
export class EncryptedObject {
iv: ArrayBuffer;
data: ArrayBuffer;
mac: ArrayBuffer;
key: SymmetricCryptoKey;
}

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export enum InitializerKey {
Cipher = 0,
CipherView = 1,
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,7 @@
import { MessagingService } from "../abstractions/messaging.service";
export class NoopMessagingService implements MessagingService {
send(subscriber: string, arg: any = {}) {
// Do nothing...
}
}

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

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

View File

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

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