diff --git a/libs/admin-console-models/src/data/encrypted-organization-key.data.ts b/libs/admin-console-models/src/data/encrypted-organization-key.data.ts new file mode 100644 index 00000000000..8ecbeefb814 --- /dev/null +++ b/libs/admin-console-models/src/data/encrypted-organization-key.data.ts @@ -0,0 +1,14 @@ +export type EncryptedOrganizationKeyData = + | OrganizationEncryptedOrganizationKeyData + | ProviderEncryptedOrganizationKeyData; + +type OrganizationEncryptedOrganizationKeyData = { + type: "organization"; + key: string; +}; + +type ProviderEncryptedOrganizationKeyData = { + type: "provider"; + key: string; + providerId: string; +}; diff --git a/libs/admin-console-models/src/response/profile-organization.response.ts b/libs/admin-console-models/src/response/profile-organization.response.ts new file mode 100644 index 00000000000..6e451ce9808 --- /dev/null +++ b/libs/admin-console-models/src/response/profile-organization.response.ts @@ -0,0 +1,131 @@ +import { ProductTierType } from "../../../billing/enums"; +import { BaseResponse } from "../../../models/response/base.response"; +import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums"; +import { PermissionsApi } from "../api/permissions.api"; + +export class ProfileOrganizationResponse extends BaseResponse { + id: string; + name: string; + usePolicies: boolean; + useGroups: boolean; + useDirectory: boolean; + useEvents: boolean; + useTotp: boolean; + use2fa: boolean; + useApi: boolean; + useSso: boolean; + useOrganizationDomains: boolean; + useKeyConnector: boolean; + useScim: boolean; + useCustomPermissions: boolean; + useResetPassword: boolean; + useSecretsManager: boolean; + usePasswordManager: boolean; + useActivateAutofillPolicy: boolean; + selfHost: boolean; + usersGetPremium: boolean; + seats: number; + maxCollections: number; + maxStorageGb?: number; + key: string; + hasPublicAndPrivateKeys: boolean; + status: OrganizationUserStatusType; + type: OrganizationUserType; + enabled: boolean; + ssoBound: boolean; + identifier: string; + permissions: PermissionsApi; + resetPasswordEnrolled: boolean; + userId: string; + organizationUserId: string; + providerId: string; + providerName: string; + providerType?: ProviderType; + familySponsorshipFriendlyName: string; + familySponsorshipAvailable: boolean; + productTierType: ProductTierType; + keyConnectorEnabled: boolean; + keyConnectorUrl: string; + familySponsorshipLastSyncDate?: Date; + familySponsorshipValidUntil?: Date; + familySponsorshipToDelete?: boolean; + accessSecretsManager: boolean; + limitCollectionCreation: boolean; + limitCollectionDeletion: boolean; + limitItemDeletion: boolean; + allowAdminAccessToAllCollectionItems: boolean; + userIsManagedByOrganization: boolean; + useRiskInsights: boolean; + useAdminSponsoredFamilies: boolean; + isAdminInitiated: boolean; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.name = this.getResponseProperty("Name"); + this.usePolicies = this.getResponseProperty("UsePolicies"); + this.useGroups = this.getResponseProperty("UseGroups"); + this.useDirectory = this.getResponseProperty("UseDirectory"); + this.useEvents = this.getResponseProperty("UseEvents"); + this.useTotp = this.getResponseProperty("UseTotp"); + this.use2fa = this.getResponseProperty("Use2fa"); + this.useApi = this.getResponseProperty("UseApi"); + this.useSso = this.getResponseProperty("UseSso"); + this.useOrganizationDomains = this.getResponseProperty("UseOrganizationDomains"); + this.useKeyConnector = this.getResponseProperty("UseKeyConnector") ?? false; + this.useScim = this.getResponseProperty("UseScim") ?? false; + this.useCustomPermissions = this.getResponseProperty("UseCustomPermissions") ?? false; + this.useResetPassword = this.getResponseProperty("UseResetPassword"); + this.useSecretsManager = this.getResponseProperty("UseSecretsManager"); + this.usePasswordManager = this.getResponseProperty("UsePasswordManager"); + this.useActivateAutofillPolicy = this.getResponseProperty("UseActivateAutofillPolicy"); + this.selfHost = this.getResponseProperty("SelfHost"); + this.usersGetPremium = this.getResponseProperty("UsersGetPremium"); + this.seats = this.getResponseProperty("Seats"); + this.maxCollections = this.getResponseProperty("MaxCollections"); + this.maxStorageGb = this.getResponseProperty("MaxStorageGb"); + this.key = this.getResponseProperty("Key"); + this.hasPublicAndPrivateKeys = this.getResponseProperty("HasPublicAndPrivateKeys"); + this.status = this.getResponseProperty("Status"); + this.type = this.getResponseProperty("Type"); + this.enabled = this.getResponseProperty("Enabled"); + this.ssoBound = this.getResponseProperty("SsoBound"); + this.identifier = this.getResponseProperty("Identifier"); + this.permissions = new PermissionsApi(this.getResponseProperty("permissions")); + this.resetPasswordEnrolled = this.getResponseProperty("ResetPasswordEnrolled"); + this.userId = this.getResponseProperty("UserId"); + this.organizationUserId = this.getResponseProperty("OrganizationUserId"); + this.providerId = this.getResponseProperty("ProviderId"); + this.providerName = this.getResponseProperty("ProviderName"); + this.providerType = this.getResponseProperty("ProviderType"); + this.familySponsorshipFriendlyName = this.getResponseProperty("FamilySponsorshipFriendlyName"); + this.familySponsorshipAvailable = this.getResponseProperty("FamilySponsorshipAvailable"); + this.productTierType = this.getResponseProperty("ProductTierType"); + this.keyConnectorEnabled = this.getResponseProperty("KeyConnectorEnabled") ?? false; + this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl"); + const familySponsorshipLastSyncDateString = this.getResponseProperty( + "FamilySponsorshipLastSyncDate", + ); + if (familySponsorshipLastSyncDateString) { + this.familySponsorshipLastSyncDate = new Date(familySponsorshipLastSyncDateString); + } + const familySponsorshipValidUntilString = this.getResponseProperty( + "FamilySponsorshipValidUntil", + ); + if (familySponsorshipValidUntilString) { + this.familySponsorshipValidUntil = new Date(familySponsorshipValidUntilString); + } + this.familySponsorshipToDelete = this.getResponseProperty("FamilySponsorshipToDelete"); + this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager"); + this.limitCollectionCreation = this.getResponseProperty("LimitCollectionCreation"); + this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion"); + this.limitItemDeletion = this.getResponseProperty("LimitItemDeletion"); + this.allowAdminAccessToAllCollectionItems = this.getResponseProperty( + "AllowAdminAccessToAllCollectionItems", + ); + this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization"); + this.useRiskInsights = this.getResponseProperty("UseRiskInsights"); + this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies"); + this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated"); + } +} diff --git a/libs/admin-console-models/src/response/profile-provider-organization.response.ts b/libs/admin-console-models/src/response/profile-provider-organization.response.ts new file mode 100644 index 00000000000..89c14e9a4d2 --- /dev/null +++ b/libs/admin-console-models/src/response/profile-provider-organization.response.ts @@ -0,0 +1,8 @@ +import { ProfileOrganizationResponse } from "./profile-organization.response"; + +export class ProfileProviderOrganizationResponse extends ProfileOrganizationResponse { + constructor(response: any) { + super(response); + this.keyConnectorEnabled = false; + } +} diff --git a/libs/admin-console-models/src/response/profile-provider.response.ts b/libs/admin-console-models/src/response/profile-provider.response.ts new file mode 100644 index 00000000000..ce35b064d52 --- /dev/null +++ b/libs/admin-console-models/src/response/profile-provider.response.ts @@ -0,0 +1,37 @@ +import { BaseResponse } from "../../../models/response/base.response"; +import { + ProviderStatusType, + ProviderType, + ProviderUserStatusType, + ProviderUserType, +} from "../../enums"; +import { PermissionsApi } from "../api/permissions.api"; + +export class ProfileProviderResponse extends BaseResponse { + id: string; + name: string; + key: string; + status: ProviderUserStatusType; + type: ProviderUserType; + enabled: boolean; + permissions: PermissionsApi; + userId: string; + useEvents: boolean; + providerStatus: ProviderStatusType; + providerType: ProviderType; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.name = this.getResponseProperty("Name"); + this.key = this.getResponseProperty("Key"); + this.status = this.getResponseProperty("Status"); + this.type = this.getResponseProperty("Type"); + this.enabled = this.getResponseProperty("Enabled"); + this.permissions = new PermissionsApi(this.getResponseProperty("permissions")); + this.userId = this.getResponseProperty("UserId"); + this.useEvents = this.getResponseProperty("UseEvents"); + this.providerStatus = this.getResponseProperty("ProviderStatus"); + this.providerType = this.getResponseProperty("ProviderType"); + } +} diff --git a/libs/auth-functions/jest.config.js b/libs/auth-functions/jest.config.js new file mode 100644 index 00000000000..eda7b746c0f --- /dev/null +++ b/libs/auth-functions/jest.config.js @@ -0,0 +1,20 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../shared/tsconfig.spec"); + +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +/** @type {import('jest').Config} */ +module.exports = { + ...sharedConfig, + displayName: "libs/auth-functions tests", + preset: "jest-preset-angular", + setupFilesAfterEnv: ["/test.setup.ts"], + moduleNameMapper: pathsToModuleNameMapper( + // lets us use @bitwarden/common/spec in tests + { "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) }, + { + prefix: "/", + }, + ), +}; diff --git a/libs/auth-functions/package.json b/libs/auth-functions/package.json new file mode 100644 index 00000000000..c61cd74fe3c --- /dev/null +++ b/libs/auth-functions/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bitwarden/auth-functions", + "version": "0.0.0", + "description": "Function library for Auth's domain", + "keywords": [ + "bitwarden" + ], + "author": "Bitwarden Inc.", + "homepage": "https://bitwarden.com", + "repository": { + "type": "git", + "url": "https://github.com/bitwarden/clients" + }, + "license": "GPL-3.0", + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && tsc", + "build:watch": "npm run clean && tsc -watch", + "test": "jest" + } +} diff --git a/libs/auth-functions/src/decode-jwt-token-to-json.utility.spec.ts b/libs/auth-functions/src/decode-jwt-token-to-json.utility.spec.ts new file mode 100644 index 00000000000..84778b82f88 --- /dev/null +++ b/libs/auth-functions/src/decode-jwt-token-to-json.utility.spec.ts @@ -0,0 +1,90 @@ +import { DecodedAccessToken } from "@bitwarden/common/auth/services/token.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { decodeJwtTokenToJson } from "./decode-jwt-token-to-json.utility"; + +describe("decodeJwtTokenToJson", () => { + const accessTokenJwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzA5MzI0MTExLCJpYXQiOjE3MDkzMjQxMTEsImV4cCI6MTcwOTMyNzcxMSwic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6IndlYiIsInN1YiI6ImVjZTcwYTEzLTcyMTYtNDNjNC05OTc3LWIxMDMwMTQ2ZTFlNyIsImF1dGhfdGltZSI6MTcwOTMyNDEwNCwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoiZXhhbXBsZUBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJUZXN0IFVzZXIiLCJvcmdvd25lciI6WyI5MmI0OTkwOC1iNTE0LTQ1YTgtYmFkYi1iMTAzMDE0OGZlNTMiLCIzOGVkZTMyMi1iNGI0LTRiZDgtOWUwOS1iMTA3MDExMmRjMTEiLCJiMmQwNzAyOC1hNTgzLTRjM2UtOGQ2MC1iMTA3MDExOThjMjkiLCJiZjkzNGJhMi0wZmQ0LTQ5ZjItYTk1ZS1iMTA3MDExZmM5ZTYiLCJjMGI3Zjc1ZC0wMTVmLTQyYzktYjNhNi1iMTA4MDE3NjA3Y2EiXSwiZGV2aWNlIjoiNGI4NzIzNjctMGRhNi00MWEwLWFkY2ItNzdmMmZlZWZjNGY0IiwianRpIjoiNzUxNjFCRTQxMzFGRjVBMkRFNTExQjhDNEUyRkY4OUEifQ.n7roP8sSbfwcYdvRxZNZds27IK32TW6anorE6BORx_Q"; + + const accessTokenDecoded: DecodedAccessToken = { + iss: "http://localhost", + nbf: 1709324111, + iat: 1709324111, + exp: 1709327711, + scope: ["api", "offline_access"], + amr: ["Application"], + client_id: "web", + sub: "ece70a13-7216-43c4-9977-b1030146e1e7", // user id + auth_time: 1709324104, + idp: "bitwarden", + premium: false, + email: "example@bitwarden.com", + email_verified: false, + sstamp: "GY7JAO64CKKTKBB6ZEAUYL2WOQU7AST2", + name: "Test User", + orgowner: [ + "92b49908-b514-45a8-badb-b1030148fe53", + "38ede322-b4b4-4bd8-9e09-b1070112dc11", + "b2d07028-a583-4c3e-8d60-b10701198c29", + "bf934ba2-0fd4-49f2-a95e-b107011fc9e6", + "c0b7f75d-015f-42c9-b3a6-b108017607ca", + ], + device: "4b872367-0da6-41a0-adcb-77f2feefc4f4", + jti: "75161BE4131FF5A2DE511B8C4E2FF89A", + }; + + it("should decode the JWT token", () => { + // Act + const result = decodeJwtTokenToJson(accessTokenJwt); + + // Assert + expect(result).toEqual(accessTokenDecoded); + }); + + it("should throw an error if the JWT token is null", () => { + // Act && Assert + expect(() => decodeJwtTokenToJson(null)).toThrow("JWT token not found"); + }); + + it("should throw an error if the JWT token is missing 3 parts", () => { + // Act && Assert + expect(() => decodeJwtTokenToJson("invalidToken")).toThrow("JWT must have 3 parts"); + }); + + it("should throw an error if the JWT token payload contains invalid JSON", () => { + // Arrange: Create a token with a valid format but with a payload that's valid Base64 but not valid JSON + const header = btoa(JSON.stringify({ alg: "none" })); + // Create a Base64-encoded string which fails to parse as JSON + const payload = btoa("invalid JSON"); + const signature = "signature"; + const malformedToken = `${header}.${payload}.${signature}`; + + // Act & Assert + expect(() => decodeJwtTokenToJson(malformedToken)).toThrow( + "Cannot parse the token's payload into JSON", + ); + }); + + it("should throw an error if the JWT token cannot be decoded", () => { + // Arrange: Create a token with a valid format + const header = btoa(JSON.stringify({ alg: "none" })); + const payload = "invalidPayloadBecauseWeWillMockTheFailure"; + const signature = "signature"; + const malformedToken = `${header}.${payload}.${signature}`; + + // Mock Utils.fromUrlB64ToUtf8 to throw an error for this specific payload + jest.spyOn(Utils, "fromUrlB64ToUtf8").mockImplementation((input) => { + if (input === payload) { + throw new Error("Mock error"); + } + return input; // Default behavior for other inputs + }); + + // Act & Assert + expect(() => decodeJwtTokenToJson(malformedToken)).toThrow("Cannot decode the token"); + + // Restore original function so other tests are not affected + jest.restoreAllMocks(); + }); +}); diff --git a/libs/auth-functions/src/decode-jwt-token-to-json.utility.ts b/libs/auth-functions/src/decode-jwt-token-to-json.utility.ts new file mode 100644 index 00000000000..24b3adacc21 --- /dev/null +++ b/libs/auth-functions/src/decode-jwt-token-to-json.utility.ts @@ -0,0 +1,36 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +export function decodeJwtTokenToJson(jwtToken: string): any { + if (jwtToken == null) { + throw new Error("JWT token not found"); + } + + const parts = jwtToken.split("."); + if (parts.length !== 3) { + throw new Error("JWT must have 3 parts"); + } + + // JWT has 3 parts: header, payload, signature separated by '.' + // So, grab the payload to decode + const encodedPayload = parts[1]; + + let decodedPayloadJSON: string; + try { + // Attempt to decode from URL-safe Base64 to UTF-8 + decodedPayloadJSON = Utils.fromUrlB64ToUtf8(encodedPayload); + // FIXME: Remove when updating file. Eslint update + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (decodingError) { + throw new Error("Cannot decode the token"); + } + + try { + // Attempt to parse the JSON payload + const decodedToken = JSON.parse(decodedPayloadJSON); + return decodedToken; + // FIXME: Remove when updating file. Eslint update + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (jsonError) { + throw new Error("Cannot parse the token's payload into JSON"); + } +} diff --git a/libs/auth-functions/src/index.ts b/libs/auth-functions/src/index.ts new file mode 100644 index 00000000000..37ec426fb68 --- /dev/null +++ b/libs/auth-functions/src/index.ts @@ -0,0 +1 @@ +export * from "./logout-reason.type"; diff --git a/libs/auth-functions/test.setup.ts b/libs/auth-functions/test.setup.ts new file mode 100644 index 00000000000..159c28d2be5 --- /dev/null +++ b/libs/auth-functions/test.setup.ts @@ -0,0 +1,28 @@ +import { webcrypto } from "crypto"; +import "@bitwarden/ui-common/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); + +Object.defineProperty(window, "crypto", { + value: webcrypto, +}); diff --git a/libs/auth-functions/tsconfig.json b/libs/auth-functions/tsconfig.json new file mode 100644 index 00000000000..41e89bb3f55 --- /dev/null +++ b/libs/auth-functions/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../shared/tsconfig", + "compilerOptions": { + "resolveJsonModule": true, + "paths": { + "@bitwarden/ui-common": ["../ui/common/src"] + } + }, + "include": ["src", "spec"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/auth-functions/tsconfig.spec.json b/libs/auth-functions/tsconfig.spec.json new file mode 100644 index 00000000000..de184bd7608 --- /dev/null +++ b/libs/auth-functions/tsconfig.spec.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "files": ["./test.setup.ts"] +} diff --git a/libs/auth-types/package.json b/libs/auth-types/package.json new file mode 100644 index 00000000000..51da30cc04b --- /dev/null +++ b/libs/auth-types/package.json @@ -0,0 +1,20 @@ +{ + "name": "@bitwarden/auth-types", + "version": "0.0.0", + "description": "Type library for Auth's domain", + "keywords": [ + "bitwarden" + ], + "author": "Bitwarden Inc.", + "homepage": "https://bitwarden.com", + "repository": { + "type": "git", + "url": "https://github.com/bitwarden/clients" + }, + "license": "GPL-3.0", + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && tsc", + "build:watch": "npm run clean && tsc -watch" + } +} diff --git a/libs/auth-types/src/index.ts b/libs/auth-types/src/index.ts new file mode 100644 index 00000000000..37ec426fb68 --- /dev/null +++ b/libs/auth-types/src/index.ts @@ -0,0 +1 @@ +export * from "./logout-reason.type"; diff --git a/libs/auth-types/src/logout-reason.type.ts b/libs/auth-types/src/logout-reason.type.ts new file mode 100644 index 00000000000..71fff51064a --- /dev/null +++ b/libs/auth-types/src/logout-reason.type.ts @@ -0,0 +1,10 @@ +export type LogoutReason = + | "invalidGrantError" + | "vaultTimeout" + | "invalidSecurityStamp" + | "logoutNotification" + | "keyConnectorError" + | "sessionExpired" + | "accessTokenUnableToBeDecrypted" + | "refreshTokenSecureStorageRetrievalFailure" + | "accountDeleted"; diff --git a/libs/auth-types/tsconfig.json b/libs/auth-types/tsconfig.json new file mode 100644 index 00000000000..757c8e54cd0 --- /dev/null +++ b/libs/auth-types/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../shared/tsconfig", + "include": ["src"], + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "types": ["node"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/libs/auth/src/common/types/index.ts b/libs/auth/src/common/types/index.ts index 37ec426fb68..139292f6a45 100644 --- a/libs/auth/src/common/types/index.ts +++ b/libs/auth/src/common/types/index.ts @@ -1 +1 @@ -export * from "./logout-reason.type"; +export { LogoutReason } from "../../../../auth-types/src/logout-reason.type"; diff --git a/libs/auth/src/common/types/logout-reason.type.ts b/libs/auth/src/common/types/logout-reason.type.ts index 71fff51064a..e69de29bb2d 100644 --- a/libs/auth/src/common/types/logout-reason.type.ts +++ b/libs/auth/src/common/types/logout-reason.type.ts @@ -1,10 +0,0 @@ -export type LogoutReason = - | "invalidGrantError" - | "vaultTimeout" - | "invalidSecurityStamp" - | "logoutNotification" - | "keyConnectorError" - | "sessionExpired" - | "accessTokenUnableToBeDecrypted" - | "refreshTokenSecureStorageRetrievalFailure" - | "accountDeleted"; diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index a56853c479c..f6c5e3dee61 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -3,7 +3,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { firstValueFrom } from "rxjs"; -import { LogoutReason } from "@bitwarden/auth/common"; +import { LogoutReason } from "@bitwarden/auth-types"; import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index 61c00f69215..4b6f3de8821 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -3,7 +3,8 @@ import { Observable, combineLatest, firstValueFrom, map } from "rxjs"; import { Opaque } from "type-fest"; -import { LogoutReason, decodeJwtTokenToJson } from "@bitwarden/auth/common"; +import { LogoutReason } from "@bitwarden/auth-types"; +import { decodeJwtTokenToJson } from "@bitwarden/auth/common"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; import { diff --git a/libs/common/src/platform/abstractions/i18n.service.ts b/libs/common/src/platform/abstractions/i18n.service.ts index a1b44d956a9..fd3764221d6 100644 --- a/libs/common/src/platform/abstractions/i18n.service.ts +++ b/libs/common/src/platform/abstractions/i18n.service.ts @@ -1,10 +1 @@ -import { Observable } from "rxjs"; - -import { TranslationService } from "./translation.service"; - -export abstract class I18nService extends TranslationService { - abstract userSetLocale$: Observable; - abstract locale$: Observable; - abstract setLocale(locale: string): Promise; - abstract init(): Promise; -} +export { I18nService } from "@bitwarden/i18n-abstractions"; diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index 203a04851c5..5f3ff402ad5 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -1,624 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -/* eslint-disable no-useless-escape */ -import * as path from "path"; - -import { Buffer as BufferLib } from "buffer/"; -import { Observable, of, switchMap } from "rxjs"; -import { getHostname, parse } from "tldts"; -import { Merge } from "type-fest"; - -import { KeyService } from "@bitwarden/key-management"; - -import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; -import { I18nService } from "../abstractions/i18n.service"; - -// FIXME: Remove when updating file. Eslint update -// eslint-disable-next-line @typescript-eslint/no-require-imports -const nodeURL = typeof self === "undefined" ? require("url") : null; - -declare global { - /* eslint-disable-next-line no-var */ - var bitwardenContainerService: BitwardenContainerService; -} - -interface BitwardenContainerService { - getKeyService: () => KeyService; - 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 originalMinimumPasswordLength = 8; - static readonly minimumPasswordLength = 12; - static readonly DomainMatchBlacklist = new Map>([ - ["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 { - return BufferLib.from(buffer).toString("utf8"); - } - - 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(""); - } - } - - /** - * Converts a hex string to an ArrayBuffer. - * Note: this doesn't need any Node specific code as parseInt() / ArrayBuffer / Uint8Array - * work the same in Node and the browser. - * @param {string} hexString - A string of hexadecimal characters. - * @returns {ArrayBuffer} The ArrayBuffer representation of the hex string. - */ - static hexStringToArrayBuffer(hexString: string): ArrayBuffer { - // Check if the hexString has an even length, as each hex digit represents half a byte (4 bits), - // and it takes two hex digits to represent a full byte (8 bits). - if (hexString.length % 2 !== 0) { - throw "HexString has to be an even length"; - } - - // Create an ArrayBuffer with a length that is half the length of the hex string, - // because each pair of hex digits will become a single byte. - const arrayBuffer = new ArrayBuffer(hexString.length / 2); - - // Create a Uint8Array view on top of the ArrayBuffer (each position represents a byte) - // as ArrayBuffers cannot be edited directly. - const uint8Array = new Uint8Array(arrayBuffer); - - // Loop through the bytes - for (let i = 0; i < uint8Array.length; i++) { - // Extract two hex characters (1 byte) - const hexByte = hexString.substr(i * 2, 2); - - // Convert hexByte into a decimal value from base 16. (ex: ff --> 255) - const byteValue = parseInt(hexByte, 16); - - // Place the byte value into the uint8Array - uint8Array[i] = byteValue; - } - - return arrayBuffer; - } - - 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 BufferLib.from(utfStr, "utf8").toString("base64"); - } - } - - 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 BufferLib.from(b64Str, "base64").toString("utf8"); - } - } - - // 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 guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; - - static isGuid(id: string) { - return RegExp(Utils.guidRegex, "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, - allowPrivateDomains: true, - }); - 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 { - const url = Utils.getUrl(uriString); - if (url == null || url.search == null || url.search === "") { - return null; - } - const map = new Map(); - 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( - 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 { - return ( - obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function" - ); - } - - static nameOf(name: string & keyof T) { - return name; - } - - static assign(target: T, source: Partial): T { - return Object.assign(target, source); - } - - static iterateEnum(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 'fill' attribute (e.g. ). - * 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 with the same data. Inverse of recordToMap - * Useful in toJSON methods, since Maps are not serializable - * @param map - * @returns - */ - static mapToRecord(map: Map): Record { - if (map == null) { - return null; - } - if (!(map instanceof Map)) { - return map; - } - return Object.fromEntries(map); - } - - /** - * Converts record to a Map 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(record: Record): Map { - 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; - } else { - return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map; - } - } - - /** Applies Object.assign, but converts the type nicely using Type-Fest Merge */ - static merge( - destination: Destination, - source: Source, - ): Merge { - return Object.assign(destination, source) as unknown as Merge; - } - - /** - * 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 { - 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(generator: () => Promise): Observable { - return of(undefined).pipe(switchMap(() => generator())); - } - - /** - * Return the number of days remaining before a target date arrives. - * Returns 0 if the day has already passed. - */ - static daysRemaining(targetDate: Date): number { - const diffTime = targetDate.getTime() - Date.now(); - const msPerDay = 86400000; - return Math.max(0, Math.floor(diffTime / msPerDay)); - } - - 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); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - // Ignore error - } - - return null; - } -} - -Utils.init(); +export { Utils } from "@bitwarden/string-utils"; diff --git a/libs/common/tsconfig.json b/libs/common/tsconfig.json index 03f66196a30..548178c908b 100644 --- a/libs/common/tsconfig.json +++ b/libs/common/tsconfig.json @@ -4,10 +4,13 @@ "paths": { "@bitwarden/admin-console/common": ["../admin-console/src/common"], "@bitwarden/auth/common": ["../auth/src/common"], + "@bitwarden/auth-types": ["../auth-types/src"] // TODO: Remove once circular dependencies in admin-console, auth and key-management are resolved "@bitwarden/common/*": ["../common/src/*"], "@bitwarden/key-management": ["../key-management/src"], "@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"] + "@bitwarden/i18n-abstractions": ["../i18n-abstractions/src"] + "@bitwarden/string-utils": ["../string-utils/src"] } }, "include": ["src", "spec", "./custom-matchers.d.ts", "../key-management/src/index.ts"], diff --git a/libs/i18n-abstractions/src/i18n.service.ts b/libs/i18n-abstractions/src/i18n.service.ts new file mode 100644 index 00000000000..a1b44d956a9 --- /dev/null +++ b/libs/i18n-abstractions/src/i18n.service.ts @@ -0,0 +1,10 @@ +import { Observable } from "rxjs"; + +import { TranslationService } from "./translation.service"; + +export abstract class I18nService extends TranslationService { + abstract userSetLocale$: Observable; + abstract locale$: Observable; + abstract setLocale(locale: string): Promise; + abstract init(): Promise; +} diff --git a/libs/i18n-abstractions/src/index.ts b/libs/i18n-abstractions/src/index.ts new file mode 100644 index 00000000000..976d8f97bfa --- /dev/null +++ b/libs/i18n-abstractions/src/index.ts @@ -0,0 +1,2 @@ +export { i18nService } from "i18nService"; +export { TranslationService } from "TranslationService"; diff --git a/libs/i18n-abstractions/src/translation.service.ts b/libs/i18n-abstractions/src/translation.service.ts new file mode 100644 index 00000000000..8a8faff1d8f --- /dev/null +++ b/libs/i18n-abstractions/src/translation.service.ts @@ -0,0 +1,8 @@ +export abstract class TranslationService { + abstract supportedTranslationLocales: string[]; + abstract translationLocale: string; + abstract collator: Intl.Collator; + abstract localeNames: Map; + abstract t(id: string, p1?: string | number, p2?: string | number, p3?: string | number): string; + abstract translate(id: string, p1?: string, p2?: string, p3?: string): string; +} diff --git a/libs/platform-models/symmetric-crypto-key.spec.ts b/libs/platform-models/symmetric-crypto-key.spec.ts new file mode 100644 index 00000000000..9246652b4c8 --- /dev/null +++ b/libs/platform-models/symmetric-crypto-key.spec.ts @@ -0,0 +1,122 @@ +import { makeStaticByteArray } from "../../../../spec"; +import { EncryptionType } from "../../enums"; +import { Utils } from "../../misc/utils"; + +import { Aes256CbcHmacKey, 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({ + keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + innerKey: { + type: EncryptionType.AesCbc256_B64, + encryptionKey: key, + }, + }); + }); + + it("AesCbc256_HmacSha256_B64", () => { + const key = makeStaticByteArray(64); + const cryptoKey = new SymmetricCryptoKey(key); + + expect(cryptoKey).toEqual({ + keyB64: + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==", + innerKey: { + type: EncryptionType.AesCbc256_HmacSha256_B64, + encryptionKey: key.slice(0, 32), + authenticationKey: key.slice(32), + }, + }); + }); + + it("unknown length", () => { + const t = () => { + new SymmetricCryptoKey(makeStaticByteArray(30)); + }; + + expect(t).toThrowError(`Unsupported encType/key length 30`); + }); + }); + + it("toJSON creates object for serialization", () => { + const key = new SymmetricCryptoKey(makeStaticByteArray(64)); + const actual = key.toJSON(); + + const expected = { keyB64: key.keyB64 }; + + expect(actual).toEqual(expected); + }); + + it("fromJSON hydrates new object", () => { + const expected = new SymmetricCryptoKey(makeStaticByteArray(64)); + const actual = SymmetricCryptoKey.fromJSON({ keyB64: expected.keyB64 }); + + expect(actual).toEqual(expected); + expect(actual).toBeInstanceOf(SymmetricCryptoKey); + }); + + it("inner returns inner key", () => { + const key = new SymmetricCryptoKey(makeStaticByteArray(64)); + const actual = key.inner(); + + expect(actual).toEqual({ + type: EncryptionType.AesCbc256_HmacSha256_B64, + encryptionKey: key.inner().encryptionKey, + authenticationKey: (key.inner() as Aes256CbcHmacKey).authenticationKey, + }); + }); + + it("toEncoded returns encoded key for AesCbc256_B64", () => { + const key = new SymmetricCryptoKey(makeStaticByteArray(32)); + const actual = key.toEncoded(); + + expect(actual).toEqual(key.inner().encryptionKey); + }); + + it("toEncoded returns encoded key for AesCbc256_HmacSha256_B64", () => { + const keyBytes = makeStaticByteArray(64); + const key = new SymmetricCryptoKey(keyBytes); + const actual = key.toEncoded(); + + expect(actual).toEqual(keyBytes); + }); + + it("toBase64 returns base64 encoded key", () => { + const keyBytes = makeStaticByteArray(64); + const keyB64 = Utils.fromBufferToB64(keyBytes); + const key = new SymmetricCryptoKey(keyBytes); + const actual = key.toBase64(); + + expect(actual).toEqual(keyB64); + }); + + describe("fromString", () => { + it("null string returns null", () => { + const actual = SymmetricCryptoKey.fromString(null); + + expect(actual).toBeNull(); + }); + + it("base64 string creates object", () => { + const key = makeStaticByteArray(64); + const expected = new SymmetricCryptoKey(key); + const actual = SymmetricCryptoKey.fromString(expected.keyB64); + + expect(actual).toEqual(expected); + expect(actual).toBeInstanceOf(SymmetricCryptoKey); + }); + }); +}); diff --git a/libs/platform-models/symmetric-crypto-key.ts b/libs/platform-models/symmetric-crypto-key.ts new file mode 100644 index 00000000000..ad16ddd06f6 --- /dev/null +++ b/libs/platform-models/symmetric-crypto-key.ts @@ -0,0 +1,114 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Jsonify } from "type-fest"; + +import { Utils } from "../../../platform/misc/utils"; +import { EncryptionType } from "../../enums"; + +export type Aes256CbcHmacKey = { + type: EncryptionType.AesCbc256_HmacSha256_B64; + encryptionKey: Uint8Array; + authenticationKey: Uint8Array; +}; + +export type Aes256CbcKey = { + type: EncryptionType.AesCbc256_B64; + encryptionKey: Uint8Array; +}; + +/** + * A symmetric crypto key represents a symmetric key usable for symmetric encryption and decryption operations. + * The specific algorithm used is private to the key, and should only be exposed to encrypt service implementations. + * This can be done via `inner()`. + */ +export class SymmetricCryptoKey { + private innerKey: Aes256CbcHmacKey | Aes256CbcKey; + + keyB64: string; + + /** + * @param key The key in one of the permitted serialization formats + */ + constructor(key: Uint8Array) { + if (key == null) { + throw new Error("Must provide key"); + } + + if (key.byteLength === 32) { + this.innerKey = { + type: EncryptionType.AesCbc256_B64, + encryptionKey: key, + }; + this.keyB64 = this.toBase64(); + } else if (key.byteLength === 64) { + this.innerKey = { + type: EncryptionType.AesCbc256_HmacSha256_B64, + encryptionKey: key.slice(0, 32), + authenticationKey: key.slice(32), + }; + this.keyB64 = this.toBase64(); + } else { + throw new Error(`Unsupported encType/key length ${key.byteLength}`); + } + } + + toJSON() { + // The whole object is constructed from the initial key, so just store the B64 key + return { keyB64: this.keyB64 }; + } + + /** + * It is preferred not to work with the raw key where possible. + * Only use this method if absolutely necessary. + * + * @returns The inner key instance that can be directly used for encryption primitives + */ + inner(): Aes256CbcHmacKey | Aes256CbcKey { + return this.innerKey; + } + + /** + * @returns The serialized key in base64 format + */ + toBase64(): string { + return Utils.fromBufferToB64(this.toEncoded()); + } + + /** + * Serializes the key to a format that can be written to state or shared + * The currently permitted format is: + * - AesCbc256_B64: 32 bytes (the raw key) + * - AesCbc256_HmacSha256_B64: 64 bytes (32 bytes encryption key, 32 bytes authentication key, concatenated) + * + * @returns The serialized key that can be written to state or encrypted and then written to state / shared + */ + toEncoded(): Uint8Array { + if (this.innerKey.type === EncryptionType.AesCbc256_B64) { + return this.innerKey.encryptionKey; + } else if (this.innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) { + const encodedKey = new Uint8Array(64); + encodedKey.set(this.innerKey.encryptionKey, 0); + encodedKey.set(this.innerKey.authenticationKey, 32); + return encodedKey; + } else { + throw new Error("Unsupported encryption type."); + } + } + + /** + * @param s The serialized key in base64 format + * @returns A SymmetricCryptoKey instance + */ + static fromString(s: string): SymmetricCryptoKey { + if (s == null) { + return null; + } + + const arrayBuffer = Utils.fromB64ToArray(s); + return new SymmetricCryptoKey(arrayBuffer); + } + + static fromJSON(obj: Jsonify): SymmetricCryptoKey { + return SymmetricCryptoKey.fromString(obj?.keyB64); + } +} diff --git a/libs/platform-types/src/hash-purpose.enum.ts b/libs/platform-types/src/hash-purpose.enum.ts new file mode 100644 index 00000000000..4b61db914a1 --- /dev/null +++ b/libs/platform-types/src/hash-purpose.enum.ts @@ -0,0 +1,6 @@ +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum HashPurpose { + ServerAuthorization = 1, + LocalAuthorization = 2, +} diff --git a/libs/common/src/platform/enums/key-suffix-options.enum.ts b/libs/platform-types/src/key-suffix-options.enum.ts similarity index 100% rename from libs/common/src/platform/enums/key-suffix-options.enum.ts rename to libs/platform-types/src/key-suffix-options.enum.ts diff --git a/libs/string-utils/jest.config.js b/libs/string-utils/jest.config.js new file mode 100644 index 00000000000..226086bc9ab --- /dev/null +++ b/libs/string-utils/jest.config.js @@ -0,0 +1,18 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../shared/tsconfig.spec"); + +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +/** @type {import('jest').Config} */ +module.exports = { + ...sharedConfig, + displayName: "libs/string-utils function tests", + preset: "node", + moduleNameMapper: pathsToModuleNameMapper( + ...(compilerOptions?.paths ?? {}), + { + prefix: "/", + }, + ), +}; diff --git a/libs/string-utils/package.json b/libs/string-utils/package.json new file mode 100644 index 00000000000..98cce329db0 --- /dev/null +++ b/libs/string-utils/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bitwarden/string-utils", + "version": "0.0.0", + "description": "Function library for working with different types of strings", + "keywords": [ + "bitwarden" + ], + "author": "Bitwarden Inc.", + "homepage": "https://bitwarden.com", + "repository": { + "type": "git", + "url": "https://github.com/bitwarden/clients" + }, + "license": "GPL-3.0", + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && tsc", + "build:watch": "npm run clean && tsc -watch", + "test": "jest" + } +} diff --git a/libs/string-utils/src/index.ts b/libs/string-utils/src/index.ts new file mode 100644 index 00000000000..a179469d497 --- /dev/null +++ b/libs/string-utils/src/index.ts @@ -0,0 +1 @@ +export { Utils } from "./utils" diff --git a/libs/string-utils/src/utils.spec.ts b/libs/string-utils/src/utils.spec.ts new file mode 100644 index 00000000000..818138863fb --- /dev/null +++ b/libs/string-utils/src/utils.spec.ts @@ -0,0 +1,778 @@ +import * as path from "path"; + +import { Utils } from "./utils"; + +describe("Utils Service", () => { + describe("isGuid", () => { + it("is false when null", () => { + expect(Utils.isGuid(null)).toBe(false); + }); + + it("is false when undefined", () => { + expect(Utils.isGuid(undefined)).toBe(false); + }); + + it("is false when empty", () => { + expect(Utils.isGuid("")).toBe(false); + }); + + it("is false when not a string", () => { + expect(Utils.isGuid(123 as any)).toBe(false); + }); + + it("is false when not a guid", () => { + expect(Utils.isGuid("not a guid")).toBe(false); + }); + + it("is true when a guid", () => { + // we use a limited guid scope in which all zeroes is invalid + expect(Utils.isGuid("00000000-0000-1000-8000-000000000000")).toBe(true); + }); + }); + + 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); + }); + }); + + function runInBothEnvironments(testName: string, testFunc: () => void): void { + const environments = [ + { isNode: true, name: "Node environment" }, + { isNode: false, name: "non-Node environment" }, + ]; + + environments.forEach((env) => { + it(`${testName} in ${env.name}`, () => { + Utils.isNode = env.isNode; + testFunc(); + }); + }); + } + + const asciiHelloWorld = "hello world"; + const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]; + const b64HelloWorldString = "aGVsbG8gd29ybGQ="; + + describe("fromBufferToB64(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should convert an ArrayBuffer to a b64 string", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const b64String = Utils.fromBufferToB64(buffer); + expect(b64String).toBe(b64HelloWorldString); + }); + + runInBothEnvironments("should return an empty string for an empty ArrayBuffer", () => { + const buffer = new Uint8Array([]).buffer; + const b64String = Utils.fromBufferToB64(buffer); + expect(b64String).toBe(""); + }); + + runInBothEnvironments("should return null for null input", () => { + const b64String = Utils.fromBufferToB64(null); + expect(b64String).toBeNull(); + }); + }); + + describe("fromB64ToArray(...)", () => { + runInBothEnvironments("should convert a b64 string to an Uint8Array", () => { + const expectedArray = new Uint8Array(asciiHelloWorldArray); + + const resultArray = Utils.fromB64ToArray(b64HelloWorldString); + + expect(resultArray).toEqual(expectedArray); + }); + + runInBothEnvironments("should return null for null input", () => { + const expectedArray = Utils.fromB64ToArray(null); + expect(expectedArray).toBeNull(); + }); + + // Hmmm... this passes in browser but not in node + // as node doesn't throw an error for invalid base64 strings. + // It instead produces a buffer with the bytes that could be decoded + // and ignores the rest after an invalid character. + // https://github.com/nodejs/node/issues/8569 + // This could be mitigated with a regex check before decoding... + // runInBothEnvironments("should throw an error for invalid base64 string", () => { + // const invalidB64String = "invalid base64"; + // expect(() => { + // Utils.fromB64ToArrayBuffer(invalidB64String); + // }).toThrow(); + // }); + }); + + describe("Base64 and ArrayBuffer round trip conversions", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments( + "should correctly round trip convert from ArrayBuffer to base64 and back", + () => { + // Start with a known ArrayBuffer + const originalArray = new Uint8Array(asciiHelloWorldArray); + const originalBuffer = originalArray.buffer; + + // Convert ArrayBuffer to a base64 string + const b64String = Utils.fromBufferToB64(originalBuffer); + + // Convert that base64 string back to an ArrayBuffer + const roundTrippedBuffer = Utils.fromB64ToArray(b64String).buffer; + const roundTrippedArray = new Uint8Array(roundTrippedBuffer); + + // Compare the original ArrayBuffer with the round-tripped ArrayBuffer + expect(roundTrippedArray).toEqual(originalArray); + }, + ); + + runInBothEnvironments( + "should correctly round trip convert from base64 to ArrayBuffer and back", + () => { + // Convert known base64 string to ArrayBuffer + const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString).buffer; + + // Convert the ArrayBuffer back to a base64 string + const roundTrippedB64String = Utils.fromBufferToB64(bufferFromB64); + + // Compare the original base64 string with the round-tripped base64 string + expect(roundTrippedB64String).toBe(b64HelloWorldString); + }, + ); + }); + + describe("fromBufferToHex(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + /** + * Creates a string that represents a sequence of hexadecimal byte values in ascending order. + * Each byte value corresponds to its position in the sequence. + * + * @param {number} length - The number of bytes to include in the string. + * @return {string} A string of hexadecimal byte values in sequential order. + * + * @example + * // Returns '000102030405060708090a0b0c0d0e0f101112...ff' + * createSequentialHexByteString(256); + */ + function createSequentialHexByteString(length: number) { + let sequentialHexString = ""; + for (let i = 0; i < length; i++) { + // Convert the number to a hex string and pad with leading zeros if necessary + const hexByte = i.toString(16).padStart(2, "0"); + sequentialHexString += hexByte; + } + return sequentialHexString; + } + + runInBothEnvironments("should convert an ArrayBuffer to a hex string", () => { + const buffer = new Uint8Array([0, 1, 10, 16, 255]).buffer; + const hexString = Utils.fromBufferToHex(buffer); + expect(hexString).toBe("00010a10ff"); + }); + + runInBothEnvironments("should handle an empty buffer", () => { + const buffer = new ArrayBuffer(0); + const hexString = Utils.fromBufferToHex(buffer); + expect(hexString).toBe(""); + }); + + runInBothEnvironments( + "should correctly convert a large buffer containing a repeating sequence of all 256 unique byte values to hex", + () => { + const largeBuffer = new Uint8Array(1024).map((_, index) => index % 256).buffer; + const hexString = Utils.fromBufferToHex(largeBuffer); + const expectedHexString = createSequentialHexByteString(256).repeat(4); + expect(hexString).toBe(expectedHexString); + }, + ); + + runInBothEnvironments("should correctly convert a buffer with a single byte to hex", () => { + const singleByteBuffer = new Uint8Array([0xab]).buffer; + const hexString = Utils.fromBufferToHex(singleByteBuffer); + expect(hexString).toBe("ab"); + }); + + runInBothEnvironments( + "should correctly convert a buffer with an odd number of bytes to hex", + () => { + const oddByteBuffer = new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89]).buffer; + const hexString = Utils.fromBufferToHex(oddByteBuffer); + expect(hexString).toBe("0123456789"); + }, + ); + }); + + describe("hexStringToArrayBuffer(...)", () => { + test("should convert a hex string to an ArrayBuffer correctly", () => { + const hexString = "ff0a1b"; // Arbitrary hex string + const expectedResult = new Uint8Array([255, 10, 27]).buffer; + const result = Utils.hexStringToArrayBuffer(hexString); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult)); + }); + + test("should throw an error if the hex string length is not even", () => { + const hexString = "abc"; // Odd number of characters + expect(() => { + Utils.hexStringToArrayBuffer(hexString); + }).toThrow("HexString has to be an even length"); + }); + + test("should convert a hex string representing zero to an ArrayBuffer correctly", () => { + const hexString = "00"; + const expectedResult = new Uint8Array([0]).buffer; + const result = Utils.hexStringToArrayBuffer(hexString); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult)); + }); + + test("should handle an empty hex string", () => { + const hexString = ""; + const expectedResult = new ArrayBuffer(0); + const result = Utils.hexStringToArrayBuffer(hexString); + expect(result).toEqual(expectedResult); + }); + + test("should convert a long hex string to an ArrayBuffer correctly", () => { + const hexString = "0102030405060708090a0b0c0d0e0f"; + const expectedResult = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + .buffer; + const result = Utils.hexStringToArrayBuffer(hexString); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult)); + }); + }); + + describe("ArrayBuffer and Hex string round trip conversions", () => { + runInBothEnvironments( + "should allow round-trip conversion from ArrayBuffer to hex and back", + () => { + const originalBuffer = new Uint8Array([10, 20, 30, 40, 255]).buffer; // arbitrary buffer + const hexString = Utils.fromBufferToHex(originalBuffer); + const roundTripBuffer = Utils.hexStringToArrayBuffer(hexString); + expect(new Uint8Array(roundTripBuffer)).toEqual(new Uint8Array(originalBuffer)); + }, + ); + + runInBothEnvironments( + "should allow round-trip conversion from hex to ArrayBuffer and back", + () => { + const hexString = "0a141e28ff"; // arbitrary hex string + const bufferFromHex = Utils.hexStringToArrayBuffer(hexString); + const roundTripHexString = Utils.fromBufferToHex(bufferFromHex); + expect(roundTripHexString).toBe(hexString); + }, + ); + }); + + 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:"); + }); + }); + + describe("daysRemaining", () => { + beforeAll(() => { + const now = new Date(2023, 9, 2, 10); + jest.spyOn(Date, "now").mockReturnValue(now.getTime()); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("should return 0 for equal dates", () => { + expect(Utils.daysRemaining(new Date(2023, 9, 2))).toBe(0); + expect(Utils.daysRemaining(new Date(2023, 9, 2, 12))).toBe(0); + }); + + it("should return 0 for dates in the past", () => { + expect(Utils.daysRemaining(new Date(2020, 5, 11))).toBe(0); + expect(Utils.daysRemaining(new Date(2023, 9, 1))).toBe(0); + }); + + it("should handle future dates", () => { + expect(Utils.daysRemaining(new Date(2023, 9, 3, 10))).toBe(1); + expect(Utils.daysRemaining(new Date(2023, 10, 12, 10))).toBe(41); + // leap year + expect(Utils.daysRemaining(new Date(2024, 9, 2, 10))).toBe(366); + }); + }); + + describe("fromBufferToUtf8(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should convert an ArrayBuffer to a utf8 string", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const str = Utils.fromBufferToUtf8(buffer); + expect(str).toBe(asciiHelloWorld); + }); + + runInBothEnvironments("should handle an empty buffer", () => { + const buffer = new ArrayBuffer(0); + const str = Utils.fromBufferToUtf8(buffer); + expect(str).toBe(""); + }); + + runInBothEnvironments("should convert a binary ArrayBuffer to a binary string", () => { + const cases = [ + { + input: [ + 174, 21, 17, 79, 39, 130, 132, 173, 49, 180, 113, 118, 160, 15, 47, 99, 57, 208, 141, + 187, 54, 194, 153, 12, 37, 130, 155, 213, 125, 196, 241, 101, + ], + output: "�O'���1�qv�/c9Ѝ�6™ %���}��e", + }, + { + input: [ + 88, 17, 69, 41, 75, 69, 128, 225, 252, 219, 146, 72, 162, 14, 139, 120, 30, 239, 105, + 229, 14, 131, 174, 119, 61, 88, 108, 135, 60, 88, 120, 145, + ], + output: "XE)KE���ےH��x�i���w=Xl� { + const buffer = new Uint8Array(c.input).buffer; + const str = Utils.fromBufferToUtf8(buffer); + // Match the expected output + expect(str).toBe(c.output); + // Make sure it matches with the Node.js Buffer output + expect(str).toBe(Buffer.from(buffer).toString("utf8")); + }); + }); + }); + + describe("fromUtf8ToB64(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should handle empty string", () => { + const str = Utils.fromUtf8ToB64(""); + expect(str).toBe(""); + }); + + runInBothEnvironments("should convert a normal b64 string", () => { + const str = Utils.fromUtf8ToB64(asciiHelloWorld); + expect(str).toBe(b64HelloWorldString); + }); + + runInBothEnvironments("should convert various special characters", () => { + const cases = [ + { input: "»", output: "wrs=" }, + { input: "¦", output: "wqY=" }, + { input: "£", output: "wqM=" }, + { input: "é", output: "w6k=" }, + { input: "ö", output: "w7Y=" }, + { input: "»»", output: "wrvCuw==" }, + ]; + cases.forEach((c) => { + const utfStr = c.input; + const str = Utils.fromUtf8ToB64(utfStr); + expect(str).toBe(c.output); + }); + }); + }); + + describe("fromB64ToUtf8(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should handle empty string", () => { + const str = Utils.fromB64ToUtf8(""); + expect(str).toBe(""); + }); + + runInBothEnvironments("should convert a normal b64 string", () => { + const str = Utils.fromB64ToUtf8(b64HelloWorldString); + expect(str).toBe(asciiHelloWorld); + }); + + runInBothEnvironments("should handle various special characters", () => { + const cases = [ + { input: "wrs=", output: "»" }, + { input: "wqY=", output: "¦" }, + { input: "wqM=", output: "£" }, + { input: "w6k=", output: "é" }, + { input: "w7Y=", output: "ö" }, + { input: "wrvCuw==", output: "»»" }, + ]; + + cases.forEach((c) => { + const b64Str = c.input; + const str = Utils.fromB64ToUtf8(b64Str); + expect(str).toBe(c.output); + }); + }); + }); +}); diff --git a/libs/string-utils/src/utils.ts b/libs/string-utils/src/utils.ts new file mode 100644 index 00000000000..c5c5c833285 --- /dev/null +++ b/libs/string-utils/src/utils.ts @@ -0,0 +1,631 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +/* eslint-disable no-useless-escape */ +import * as path from "path"; + +import { Buffer as BufferLib } from "buffer/"; +import { Observable, of, switchMap } from "rxjs"; +import { getHostname, parse } from "tldts"; +import { Merge } from "type-fest"; + +import { KeyService } from "@bitwarden/key-management"; + +import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/i18n-abstractions/i18n.service"; + +// FIXME: Remove when updating file. Eslint update +// eslint-disable-next-line @typescript-eslint/no-require-imports +const nodeURL = typeof self === "undefined" ? require("url") : null; + +declare global { + /* eslint-disable-next-line no-var */ + var bitwardenContainerService: BitwardenContainerService; +} + +interface BitwardenContainerService { + getKeyService: () => KeyService; + 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 originalMinimumPasswordLength = 8; + static readonly minimumPasswordLength = 12; + static readonly DomainMatchBlacklist = new Map>([ + ["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 { + return BufferLib.from(buffer).toString("utf8"); + } + + 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(""); + } + } + + /** + * Converts a hex string to an ArrayBuffer. + * Note: this doesn't need any Node specific code as parseInt() / ArrayBuffer / Uint8Array + * work the same in Node and the browser. + * @param {string} hexString - A string of hexadecimal characters. + * @returns {ArrayBuffer} The ArrayBuffer representation of the hex string. + */ + static hexStringToArrayBuffer(hexString: string): ArrayBuffer { + // Check if the hexString has an even length, as each hex digit represents half a byte (4 bits), + // and it takes two hex digits to represent a full byte (8 bits). + if (hexString.length % 2 !== 0) { + throw "HexString has to be an even length"; + } + + // Create an ArrayBuffer with a length that is half the length of the hex string, + // because each pair of hex digits will become a single byte. + const arrayBuffer = new ArrayBuffer(hexString.length / 2); + + // Create a Uint8Array view on top of the ArrayBuffer (each position represents a byte) + // as ArrayBuffers cannot be edited directly. + const uint8Array = new Uint8Array(arrayBuffer); + + // Loop through the bytes + for (let i = 0; i < uint8Array.length; i++) { + // Extract two hex characters (1 byte) + const hexByte = hexString.substr(i * 2, 2); + + // Convert hexByte into a decimal value from base 16. (ex: ff --> 255) + const byteValue = parseInt(hexByte, 16); + + // Place the byte value into the uint8Array + uint8Array[i] = byteValue; + } + + return arrayBuffer; + } + + 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; + } + + +/** + * @deprecated Use `@bitwarden/string-utils/fromUrlB64ToUtf8` instead. + */ + static fromUrlB64ToUtf8(urlB64Str: string): string { + return Utils.fromB64ToUtf8(Utils.fromUrlB64ToB64(urlB64Str)); + } + +/** + * @deprecated Use `@bitwarden/string-utils/fromB64ToUtf8` instead. + */ + static fromUtf8ToB64(utfStr: string): string { + if (Utils.isNode) { + return Buffer.from(utfStr, "utf8").toString("base64"); + } else { + return BufferLib.from(utfStr, "utf8").toString("base64"); + } + } + + 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 BufferLib.from(b64Str, "base64").toString("utf8"); + } + } + + // 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 guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + + static isGuid(id: string) { + return RegExp(Utils.guidRegex, "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, + allowPrivateDomains: true, + }); + 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 { + const url = Utils.getUrl(uriString); + if (url == null || url.search == null || url.search === "") { + return null; + } + const map = new Map(); + 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( + 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 { + return ( + obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function" + ); + } + + static nameOf(name: string & keyof T) { + return name; + } + + static assign(target: T, source: Partial): T { + return Object.assign(target, source); + } + + static iterateEnum(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 'fill' attribute (e.g. ). + * 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 with the same data. Inverse of recordToMap + * Useful in toJSON methods, since Maps are not serializable + * @param map + * @returns + */ + static mapToRecord(map: Map): Record { + if (map == null) { + return null; + } + if (!(map instanceof Map)) { + return map; + } + return Object.fromEntries(map); + } + + /** + * Converts record to a Map 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(record: Record): Map { + 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; + } else { + return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map; + } + } + + /** Applies Object.assign, but converts the type nicely using Type-Fest Merge */ + static merge( + destination: Destination, + source: Source, + ): Merge { + return Object.assign(destination, source) as unknown as Merge; + } + + /** + * 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 { + 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(generator: () => Promise): Observable { + return of(undefined).pipe(switchMap(() => generator())); + } + + /** + * Return the number of days remaining before a target date arrives. + * Returns 0 if the day has already passed. + */ + static daysRemaining(targetDate: Date): number { + const diffTime = targetDate.getTime() - Date.now(); + const msPerDay = 86400000; + return Math.max(0, Math.floor(diffTime / msPerDay)); + } + + 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); + // FIXME: Remove when updating file. Eslint update + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + // Ignore error + } + + return null; + } +} + +Utils.init(); diff --git a/libs/string-utils/tsconfig.json b/libs/string-utils/tsconfig.json new file mode 100644 index 00000000000..8a23cb8b24c --- /dev/null +++ b/libs/string-utils/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../shared/tsconfig", + "compilerOptions": { + "paths": { + "@bitwarden/i18n-abstractions": ["../i18n-abstractions/src"] + } + }, + "include": ["src", "spec"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/string-utils/tsconfig.spec.json b/libs/string-utils/tsconfig.spec.json new file mode 100644 index 00000000000..de184bd7608 --- /dev/null +++ b/libs/string-utils/tsconfig.spec.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "files": ["./test.setup.ts"] +} diff --git a/libs/common/src/types/guid.ts b/libs/types/src/guid.ts similarity index 100% rename from libs/common/src/types/guid.ts rename to libs/types/src/guid.ts diff --git a/libs/types/src/key.ts b/libs/types/src/key.ts new file mode 100644 index 00000000000..c9fd6975960 --- /dev/null +++ b/libs/types/src/key.ts @@ -0,0 +1,17 @@ +import { Opaque } from "type-fest"; + +import { SymmetricCryptoKey } from "../platform/models/domain/symmetric-crypto-key"; + +// symmetric keys +export type DeviceKey = Opaque; +export type PrfKey = Opaque; +export type UserKey = Opaque; +export type MasterKey = Opaque; +export type PinKey = Opaque; +export type OrgKey = Opaque; +export type ProviderKey = Opaque; +export type CipherKey = Opaque; + +// asymmetric keys +export type UserPrivateKey = Opaque; +export type UserPublicKey = Opaque;