mirror of
https://github.com/bitwarden/browser
synced 2026-02-21 20:04:02 +00:00
.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
export type EncryptedOrganizationKeyData =
|
||||
| OrganizationEncryptedOrganizationKeyData
|
||||
| ProviderEncryptedOrganizationKeyData;
|
||||
|
||||
type OrganizationEncryptedOrganizationKeyData = {
|
||||
type: "organization";
|
||||
key: string;
|
||||
};
|
||||
|
||||
type ProviderEncryptedOrganizationKeyData = {
|
||||
type: "provider";
|
||||
key: string;
|
||||
providerId: string;
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ProfileOrganizationResponse } from "./profile-organization.response";
|
||||
|
||||
export class ProfileProviderOrganizationResponse extends ProfileOrganizationResponse {
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.keyConnectorEnabled = false;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
20
libs/auth-functions/jest.config.js
Normal file
20
libs/auth-functions/jest.config.js
Normal file
@@ -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: ["<rootDir>/test.setup.ts"],
|
||||
moduleNameMapper: pathsToModuleNameMapper(
|
||||
// lets us use @bitwarden/common/spec in tests
|
||||
{ "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) },
|
||||
{
|
||||
prefix: "<rootDir>/",
|
||||
},
|
||||
),
|
||||
};
|
||||
21
libs/auth-functions/package.json
Normal file
21
libs/auth-functions/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
36
libs/auth-functions/src/decode-jwt-token-to-json.utility.ts
Normal file
36
libs/auth-functions/src/decode-jwt-token-to-json.utility.ts
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
1
libs/auth-functions/src/index.ts
Normal file
1
libs/auth-functions/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./logout-reason.type";
|
||||
28
libs/auth-functions/test.setup.ts
Normal file
28
libs/auth-functions/test.setup.ts
Normal file
@@ -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: "<!DOCTYPE html>",
|
||||
});
|
||||
Object.defineProperty(document.body.style, "transform", {
|
||||
value: () => {
|
||||
return {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "crypto", {
|
||||
value: webcrypto,
|
||||
});
|
||||
11
libs/auth-functions/tsconfig.json
Normal file
11
libs/auth-functions/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../shared/tsconfig",
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@bitwarden/ui-common": ["../ui/common/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "spec"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
libs/auth-functions/tsconfig.spec.json
Normal file
4
libs/auth-functions/tsconfig.spec.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"files": ["./test.setup.ts"]
|
||||
}
|
||||
20
libs/auth-types/package.json
Normal file
20
libs/auth-types/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1
libs/auth-types/src/index.ts
Normal file
1
libs/auth-types/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./logout-reason.type";
|
||||
10
libs/auth-types/src/logout-reason.type.ts
Normal file
10
libs/auth-types/src/logout-reason.type.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type LogoutReason =
|
||||
| "invalidGrantError"
|
||||
| "vaultTimeout"
|
||||
| "invalidSecurityStamp"
|
||||
| "logoutNotification"
|
||||
| "keyConnectorError"
|
||||
| "sessionExpired"
|
||||
| "accessTokenUnableToBeDecrypted"
|
||||
| "refreshTokenSecureStorageRetrievalFailure"
|
||||
| "accountDeleted";
|
||||
10
libs/auth-types/tsconfig.json
Normal file
10
libs/auth-types/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../shared/tsconfig",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from "./logout-reason.type";
|
||||
export { LogoutReason } from "../../../../auth-types/src/logout-reason.type";
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export type LogoutReason =
|
||||
| "invalidGrantError"
|
||||
| "vaultTimeout"
|
||||
| "invalidSecurityStamp"
|
||||
| "logoutNotification"
|
||||
| "keyConnectorError"
|
||||
| "sessionExpired"
|
||||
| "accessTokenUnableToBeDecrypted"
|
||||
| "refreshTokenSecureStorageRetrievalFailure"
|
||||
| "accountDeleted";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { TranslationService } from "./translation.service";
|
||||
|
||||
export abstract class I18nService extends TranslationService {
|
||||
abstract userSetLocale$: Observable<string | undefined>;
|
||||
abstract locale$: Observable<string>;
|
||||
abstract setLocale(locale: string): Promise<void>;
|
||||
abstract init(): Promise<void>;
|
||||
}
|
||||
export { I18nService } from "@bitwarden/i18n-abstractions";
|
||||
|
||||
@@ -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<string, Set<string>>([
|
||||
["google.com", new Set(["script.google.com"])],
|
||||
]);
|
||||
|
||||
static init() {
|
||||
if (Utils.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
Utils.inited = true;
|
||||
Utils.isNode =
|
||||
typeof process !== "undefined" &&
|
||||
(process as any).release != null &&
|
||||
(process as any).release.name === "node";
|
||||
Utils.isBrowser = typeof window !== "undefined";
|
||||
|
||||
Utils.isMobileBrowser = Utils.isBrowser && this.isMobile(window);
|
||||
Utils.isAppleMobileBrowser = Utils.isBrowser && this.isAppleMobile(window);
|
||||
|
||||
if (Utils.isNode) {
|
||||
Utils.global = global;
|
||||
} else if (Utils.isBrowser) {
|
||||
Utils.global = window;
|
||||
} else {
|
||||
// If it's not browser or node then it must be a service worker
|
||||
Utils.global = self;
|
||||
}
|
||||
}
|
||||
|
||||
static fromB64ToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "base64"));
|
||||
} else {
|
||||
const binaryString = Utils.global.atob(str);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
static fromUrlB64ToArray(str: string): Uint8Array {
|
||||
return Utils.fromB64ToArray(Utils.fromUrlB64ToB64(str));
|
||||
}
|
||||
|
||||
static fromHexToArray(str: string): Uint8Array {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "hex"));
|
||||
} else {
|
||||
const bytes = new Uint8Array(str.length / 2);
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(str.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
static fromUtf8ToArray(str: string): Uint8Array {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "utf8"));
|
||||
} else {
|
||||
const strUtf8 = unescape(encodeURIComponent(str));
|
||||
const arr = new Uint8Array(strUtf8.length);
|
||||
for (let i = 0; i < strUtf8.length; i++) {
|
||||
arr[i] = strUtf8.charCodeAt(i);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
static fromByteStringToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
const arr = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
arr[i] = str.charCodeAt(i);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
static fromBufferToB64(buffer: ArrayBuffer): string {
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(buffer).toString("base64");
|
||||
} else {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return Utils.global.btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
|
||||
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
|
||||
}
|
||||
|
||||
static fromB64toUrlB64(b64Str: string) {
|
||||
return b64Str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
static fromBufferToUtf8(buffer: ArrayBuffer): string {
|
||||
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<string, string> {
|
||||
const url = Utils.getUrl(uriString);
|
||||
if (url == null || url.search == null || url.search === "") {
|
||||
return null;
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
const pairs = (url.search[0] === "?" ? url.search.substr(1) : url.search).split("&");
|
||||
pairs.forEach((pair) => {
|
||||
const parts = pair.split("=");
|
||||
if (parts.length < 1) {
|
||||
return;
|
||||
}
|
||||
map.set(
|
||||
decodeURIComponent(parts[0]).toLowerCase(),
|
||||
parts[1] == null ? "" : decodeURIComponent(parts[1]),
|
||||
);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
static getSortFunction<T>(
|
||||
i18nService: I18nService,
|
||||
prop: { [K in keyof T]: T[K] extends string ? K : never }[keyof T],
|
||||
): (a: T, b: T) => number {
|
||||
return (a, b) => {
|
||||
if (a[prop] == null && b[prop] != null) {
|
||||
return -1;
|
||||
}
|
||||
if (a[prop] != null && b[prop] == null) {
|
||||
return 1;
|
||||
}
|
||||
if (a[prop] == null && b[prop] == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The `as unknown as string` here is unfortunate because typescript doesn't property understand that the return of T[prop] will be a string
|
||||
return i18nService.collator
|
||||
? i18nService.collator.compare(a[prop] as unknown as string, b[prop] as unknown as string)
|
||||
: (a[prop] as unknown as string).localeCompare(b[prop] as unknown as string);
|
||||
};
|
||||
}
|
||||
|
||||
static isNullOrWhitespace(str: string): boolean {
|
||||
return str == null || typeof str !== "string" || str.trim() === "";
|
||||
}
|
||||
|
||||
static isNullOrEmpty(str: string): boolean {
|
||||
return str == null || typeof str !== "string" || str == "";
|
||||
}
|
||||
|
||||
static isPromise(obj: any): obj is Promise<unknown> {
|
||||
return (
|
||||
obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function"
|
||||
);
|
||||
}
|
||||
|
||||
static nameOf<T>(name: string & keyof T) {
|
||||
return name;
|
||||
}
|
||||
|
||||
static assign<T>(target: T, source: Partial<T>): T {
|
||||
return Object.assign(target, source);
|
||||
}
|
||||
|
||||
static iterateEnum<O extends object, K extends keyof O = keyof O>(obj: O) {
|
||||
return (Object.keys(obj).filter((k) => Number.isNaN(+k)) as K[]).map((k) => obj[k]);
|
||||
}
|
||||
|
||||
static getUrl(uriString: string): URL {
|
||||
if (this.isNullOrWhitespace(uriString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
uriString = uriString.trim();
|
||||
|
||||
return Utils.getUrlObject(uriString);
|
||||
}
|
||||
|
||||
static camelToPascalCase(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* There are a few ways to calculate text color for contrast, this one seems to fit accessibility guidelines best.
|
||||
* https://stackoverflow.com/a/3943023/6869691
|
||||
*
|
||||
* @param {string} bgColor
|
||||
* @param {number} [threshold] see stackoverflow link above
|
||||
* @param {boolean} [svgTextFill]
|
||||
* Indicates if this method is performed on an SVG <text> 'fill' attribute (e.g. <text fill="black"></text>).
|
||||
* This check is necessary because the '!important' tag cannot be used in a 'fill' attribute.
|
||||
*/
|
||||
static pickTextColorBasedOnBgColor(bgColor: string, threshold = 186, svgTextFill = false) {
|
||||
const bgColorHexNums = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor;
|
||||
const r = parseInt(bgColorHexNums.substring(0, 2), 16); // hexToR
|
||||
const g = parseInt(bgColorHexNums.substring(2, 4), 16); // hexToG
|
||||
const b = parseInt(bgColorHexNums.substring(4, 6), 16); // hexToB
|
||||
const blackColor = svgTextFill ? "black" : "black !important";
|
||||
const whiteColor = svgTextFill ? "white" : "white !important";
|
||||
return r * 0.299 + g * 0.587 + b * 0.114 > threshold ? blackColor : whiteColor;
|
||||
}
|
||||
|
||||
static stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += ("00" + value.toString(16)).substr(-2);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Will throw an error if the ContainerService has not been attached to the window object
|
||||
*/
|
||||
static getContainerService(): BitwardenContainerService {
|
||||
if (this.global.bitwardenContainerService == null) {
|
||||
throw new Error("global bitwardenContainerService not initialized.");
|
||||
}
|
||||
return this.global.bitwardenContainerService;
|
||||
}
|
||||
|
||||
static validateHexColor(color: string) {
|
||||
return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts map to a Record<string, V> with the same data. Inverse of recordToMap
|
||||
* Useful in toJSON methods, since Maps are not serializable
|
||||
* @param map
|
||||
* @returns
|
||||
*/
|
||||
static mapToRecord<K extends string | number, V>(map: Map<K, V>): Record<string, V> {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(map instanceof Map)) {
|
||||
return map;
|
||||
}
|
||||
return Object.fromEntries(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts record to a Map<string, V> with the same data. Inverse of mapToRecord
|
||||
* Useful in fromJSON methods, since Maps are not serializable
|
||||
*
|
||||
* Warning: If the record has string keys that are numbers, they will be converted to numbers in the map
|
||||
* @param record
|
||||
* @returns
|
||||
*/
|
||||
static recordToMap<K extends string | number, V>(record: Record<K, V>): Map<K, V> {
|
||||
if (record == null) {
|
||||
return null;
|
||||
} else if (record instanceof Map) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const entries = Object.entries(record);
|
||||
if (entries.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
if (isNaN(Number(entries[0][0]))) {
|
||||
return new Map(entries) as Map<K, V>;
|
||||
} else {
|
||||
return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map<K, V>;
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies Object.assign, but converts the type nicely using Type-Fest Merge<Destination, Source> */
|
||||
static merge<Destination, Source>(
|
||||
destination: Destination,
|
||||
source: Source,
|
||||
): Merge<Destination, Source> {
|
||||
return Object.assign(destination, source) as unknown as Merge<Destination, Source>;
|
||||
}
|
||||
|
||||
/**
|
||||
* encodeURIComponent escapes all characters except the following:
|
||||
* alphabetic, decimal digits, - _ . ! ~ * ' ( )
|
||||
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
*/
|
||||
static encodeRFC3986URIComponent(str: string): string {
|
||||
return encodeURIComponent(str).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a path for defense against attacks like traversals
|
||||
* @param denormalizedPath
|
||||
* @returns
|
||||
*/
|
||||
static normalizePath(denormalizedPath: string): string {
|
||||
return path.normalize(decodeURIComponent(denormalizedPath)).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
}
|
||||
|
||||
private static isMobile(win: Window) {
|
||||
let mobile = false;
|
||||
((a) => {
|
||||
if (
|
||||
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
|
||||
a,
|
||||
) ||
|
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
|
||||
a.substr(0, 4),
|
||||
)
|
||||
) {
|
||||
mobile = true;
|
||||
}
|
||||
})(win.navigator.userAgent || win.navigator.vendor || (win as any).opera);
|
||||
return mobile || win.navigator.userAgent.match(/iPad/i) != null;
|
||||
}
|
||||
|
||||
static delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an observable from a function that returns a promise.
|
||||
* Similar to the rxjs function {@link from} with one big exception:
|
||||
* {@link from} will not re-execute the function when observers resubscribe.
|
||||
* {@link Util.asyncToObservable} will execute `generator` for every
|
||||
* subscribe, making it ideal if the value ever needs to be refreshed.
|
||||
* */
|
||||
static asyncToObservable<T>(generator: () => Promise<T>): Observable<T> {
|
||||
return of(undefined).pipe(switchMap(() => generator()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
|
||||
@@ -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"],
|
||||
|
||||
10
libs/i18n-abstractions/src/i18n.service.ts
Normal file
10
libs/i18n-abstractions/src/i18n.service.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { TranslationService } from "./translation.service";
|
||||
|
||||
export abstract class I18nService extends TranslationService {
|
||||
abstract userSetLocale$: Observable<string | undefined>;
|
||||
abstract locale$: Observable<string>;
|
||||
abstract setLocale(locale: string): Promise<void>;
|
||||
abstract init(): Promise<void>;
|
||||
}
|
||||
2
libs/i18n-abstractions/src/index.ts
Normal file
2
libs/i18n-abstractions/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { i18nService } from "i18nService";
|
||||
export { TranslationService } from "TranslationService";
|
||||
8
libs/i18n-abstractions/src/translation.service.ts
Normal file
8
libs/i18n-abstractions/src/translation.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export abstract class TranslationService {
|
||||
abstract supportedTranslationLocales: string[];
|
||||
abstract translationLocale: string;
|
||||
abstract collator: Intl.Collator;
|
||||
abstract localeNames: Map<string, string>;
|
||||
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;
|
||||
}
|
||||
122
libs/platform-models/symmetric-crypto-key.spec.ts
Normal file
122
libs/platform-models/symmetric-crypto-key.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
114
libs/platform-models/symmetric-crypto-key.ts
Normal file
114
libs/platform-models/symmetric-crypto-key.ts
Normal file
@@ -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>): SymmetricCryptoKey {
|
||||
return SymmetricCryptoKey.fromString(obj?.keyB64);
|
||||
}
|
||||
}
|
||||
6
libs/platform-types/src/hash-purpose.enum.ts
Normal file
6
libs/platform-types/src/hash-purpose.enum.ts
Normal file
@@ -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,
|
||||
}
|
||||
18
libs/string-utils/jest.config.js
Normal file
18
libs/string-utils/jest.config.js
Normal file
@@ -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: "<rootDir>/",
|
||||
},
|
||||
),
|
||||
};
|
||||
21
libs/string-utils/package.json
Normal file
21
libs/string-utils/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1
libs/string-utils/src/index.ts
Normal file
1
libs/string-utils/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Utils } from "./utils"
|
||||
778
libs/string-utils/src/utils.spec.ts
Normal file
778
libs/string-utils/src/utils.spec.ts
Normal file
@@ -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: "<22>O'<27><><EFBFBD>1<EFBFBD>qv<71>/c9Ѝ<39>6%<25><><EFBFBD>}<7D><>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<4B><45><EFBFBD>ےH<DB92><0E>x<1E>i<EFBFBD><0E><>w=Xl<58><Xx<58>",
|
||||
},
|
||||
{
|
||||
input: [
|
||||
121, 110, 81, 148, 48, 67, 209, 43, 3, 39, 143, 184, 237, 184, 213, 183, 84, 157, 47, 6,
|
||||
31, 183, 99, 142, 155, 156, 192, 107, 118, 64, 176, 36,
|
||||
],
|
||||
output: "ynQ<6E>0C<30>+'<27><><EFBFBD><EFBFBD>շT<D5B7>/<1F>c<EFBFBD><63><EFBFBD><EFBFBD>kv@<40>$",
|
||||
},
|
||||
];
|
||||
|
||||
cases.forEach((c) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
631
libs/string-utils/src/utils.ts
Normal file
631
libs/string-utils/src/utils.ts
Normal file
@@ -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<string, Set<string>>([
|
||||
["google.com", new Set(["script.google.com"])],
|
||||
]);
|
||||
|
||||
static init() {
|
||||
if (Utils.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
Utils.inited = true;
|
||||
Utils.isNode =
|
||||
typeof process !== "undefined" &&
|
||||
(process as any).release != null &&
|
||||
(process as any).release.name === "node";
|
||||
Utils.isBrowser = typeof window !== "undefined";
|
||||
|
||||
Utils.isMobileBrowser = Utils.isBrowser && this.isMobile(window);
|
||||
Utils.isAppleMobileBrowser = Utils.isBrowser && this.isAppleMobile(window);
|
||||
|
||||
if (Utils.isNode) {
|
||||
Utils.global = global;
|
||||
} else if (Utils.isBrowser) {
|
||||
Utils.global = window;
|
||||
} else {
|
||||
// If it's not browser or node then it must be a service worker
|
||||
Utils.global = self;
|
||||
}
|
||||
}
|
||||
|
||||
static fromB64ToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "base64"));
|
||||
} else {
|
||||
const binaryString = Utils.global.atob(str);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
static fromUrlB64ToArray(str: string): Uint8Array {
|
||||
return Utils.fromB64ToArray(Utils.fromUrlB64ToB64(str));
|
||||
}
|
||||
|
||||
static fromHexToArray(str: string): Uint8Array {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "hex"));
|
||||
} else {
|
||||
const bytes = new Uint8Array(str.length / 2);
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(str.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
static fromUtf8ToArray(str: string): Uint8Array {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "utf8"));
|
||||
} else {
|
||||
const strUtf8 = unescape(encodeURIComponent(str));
|
||||
const arr = new Uint8Array(strUtf8.length);
|
||||
for (let i = 0; i < strUtf8.length; i++) {
|
||||
arr[i] = strUtf8.charCodeAt(i);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
static fromByteStringToArray(str: string): Uint8Array {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
const arr = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
arr[i] = str.charCodeAt(i);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
static fromBufferToB64(buffer: ArrayBuffer): string {
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(buffer).toString("base64");
|
||||
} else {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return Utils.global.btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
|
||||
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
|
||||
}
|
||||
|
||||
static fromB64toUrlB64(b64Str: string) {
|
||||
return b64Str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
static fromBufferToUtf8(buffer: ArrayBuffer): string {
|
||||
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<string, string> {
|
||||
const url = Utils.getUrl(uriString);
|
||||
if (url == null || url.search == null || url.search === "") {
|
||||
return null;
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
const pairs = (url.search[0] === "?" ? url.search.substr(1) : url.search).split("&");
|
||||
pairs.forEach((pair) => {
|
||||
const parts = pair.split("=");
|
||||
if (parts.length < 1) {
|
||||
return;
|
||||
}
|
||||
map.set(
|
||||
decodeURIComponent(parts[0]).toLowerCase(),
|
||||
parts[1] == null ? "" : decodeURIComponent(parts[1]),
|
||||
);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
static getSortFunction<T>(
|
||||
i18nService: I18nService,
|
||||
prop: { [K in keyof T]: T[K] extends string ? K : never }[keyof T],
|
||||
): (a: T, b: T) => number {
|
||||
return (a, b) => {
|
||||
if (a[prop] == null && b[prop] != null) {
|
||||
return -1;
|
||||
}
|
||||
if (a[prop] != null && b[prop] == null) {
|
||||
return 1;
|
||||
}
|
||||
if (a[prop] == null && b[prop] == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The `as unknown as string` here is unfortunate because typescript doesn't property understand that the return of T[prop] will be a string
|
||||
return i18nService.collator
|
||||
? i18nService.collator.compare(a[prop] as unknown as string, b[prop] as unknown as string)
|
||||
: (a[prop] as unknown as string).localeCompare(b[prop] as unknown as string);
|
||||
};
|
||||
}
|
||||
|
||||
static isNullOrWhitespace(str: string): boolean {
|
||||
return str == null || typeof str !== "string" || str.trim() === "";
|
||||
}
|
||||
|
||||
static isNullOrEmpty(str: string): boolean {
|
||||
return str == null || typeof str !== "string" || str == "";
|
||||
}
|
||||
|
||||
static isPromise(obj: any): obj is Promise<unknown> {
|
||||
return (
|
||||
obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function"
|
||||
);
|
||||
}
|
||||
|
||||
static nameOf<T>(name: string & keyof T) {
|
||||
return name;
|
||||
}
|
||||
|
||||
static assign<T>(target: T, source: Partial<T>): T {
|
||||
return Object.assign(target, source);
|
||||
}
|
||||
|
||||
static iterateEnum<O extends object, K extends keyof O = keyof O>(obj: O) {
|
||||
return (Object.keys(obj).filter((k) => Number.isNaN(+k)) as K[]).map((k) => obj[k]);
|
||||
}
|
||||
|
||||
static getUrl(uriString: string): URL {
|
||||
if (this.isNullOrWhitespace(uriString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
uriString = uriString.trim();
|
||||
|
||||
return Utils.getUrlObject(uriString);
|
||||
}
|
||||
|
||||
static camelToPascalCase(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* There are a few ways to calculate text color for contrast, this one seems to fit accessibility guidelines best.
|
||||
* https://stackoverflow.com/a/3943023/6869691
|
||||
*
|
||||
* @param {string} bgColor
|
||||
* @param {number} [threshold] see stackoverflow link above
|
||||
* @param {boolean} [svgTextFill]
|
||||
* Indicates if this method is performed on an SVG <text> 'fill' attribute (e.g. <text fill="black"></text>).
|
||||
* This check is necessary because the '!important' tag cannot be used in a 'fill' attribute.
|
||||
*/
|
||||
static pickTextColorBasedOnBgColor(bgColor: string, threshold = 186, svgTextFill = false) {
|
||||
const bgColorHexNums = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor;
|
||||
const r = parseInt(bgColorHexNums.substring(0, 2), 16); // hexToR
|
||||
const g = parseInt(bgColorHexNums.substring(2, 4), 16); // hexToG
|
||||
const b = parseInt(bgColorHexNums.substring(4, 6), 16); // hexToB
|
||||
const blackColor = svgTextFill ? "black" : "black !important";
|
||||
const whiteColor = svgTextFill ? "white" : "white !important";
|
||||
return r * 0.299 + g * 0.587 + b * 0.114 > threshold ? blackColor : whiteColor;
|
||||
}
|
||||
|
||||
static stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += ("00" + value.toString(16)).substr(-2);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Will throw an error if the ContainerService has not been attached to the window object
|
||||
*/
|
||||
static getContainerService(): BitwardenContainerService {
|
||||
if (this.global.bitwardenContainerService == null) {
|
||||
throw new Error("global bitwardenContainerService not initialized.");
|
||||
}
|
||||
return this.global.bitwardenContainerService;
|
||||
}
|
||||
|
||||
static validateHexColor(color: string) {
|
||||
return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts map to a Record<string, V> with the same data. Inverse of recordToMap
|
||||
* Useful in toJSON methods, since Maps are not serializable
|
||||
* @param map
|
||||
* @returns
|
||||
*/
|
||||
static mapToRecord<K extends string | number, V>(map: Map<K, V>): Record<string, V> {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(map instanceof Map)) {
|
||||
return map;
|
||||
}
|
||||
return Object.fromEntries(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts record to a Map<string, V> with the same data. Inverse of mapToRecord
|
||||
* Useful in fromJSON methods, since Maps are not serializable
|
||||
*
|
||||
* Warning: If the record has string keys that are numbers, they will be converted to numbers in the map
|
||||
* @param record
|
||||
* @returns
|
||||
*/
|
||||
static recordToMap<K extends string | number, V>(record: Record<K, V>): Map<K, V> {
|
||||
if (record == null) {
|
||||
return null;
|
||||
} else if (record instanceof Map) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const entries = Object.entries(record);
|
||||
if (entries.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
if (isNaN(Number(entries[0][0]))) {
|
||||
return new Map(entries) as Map<K, V>;
|
||||
} else {
|
||||
return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map<K, V>;
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies Object.assign, but converts the type nicely using Type-Fest Merge<Destination, Source> */
|
||||
static merge<Destination, Source>(
|
||||
destination: Destination,
|
||||
source: Source,
|
||||
): Merge<Destination, Source> {
|
||||
return Object.assign(destination, source) as unknown as Merge<Destination, Source>;
|
||||
}
|
||||
|
||||
/**
|
||||
* encodeURIComponent escapes all characters except the following:
|
||||
* alphabetic, decimal digits, - _ . ! ~ * ' ( )
|
||||
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
*/
|
||||
static encodeRFC3986URIComponent(str: string): string {
|
||||
return encodeURIComponent(str).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a path for defense against attacks like traversals
|
||||
* @param denormalizedPath
|
||||
* @returns
|
||||
*/
|
||||
static normalizePath(denormalizedPath: string): string {
|
||||
return path.normalize(decodeURIComponent(denormalizedPath)).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
}
|
||||
|
||||
private static isMobile(win: Window) {
|
||||
let mobile = false;
|
||||
((a) => {
|
||||
if (
|
||||
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
|
||||
a,
|
||||
) ||
|
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
|
||||
a.substr(0, 4),
|
||||
)
|
||||
) {
|
||||
mobile = true;
|
||||
}
|
||||
})(win.navigator.userAgent || win.navigator.vendor || (win as any).opera);
|
||||
return mobile || win.navigator.userAgent.match(/iPad/i) != null;
|
||||
}
|
||||
|
||||
static delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an observable from a function that returns a promise.
|
||||
* Similar to the rxjs function {@link from} with one big exception:
|
||||
* {@link from} will not re-execute the function when observers resubscribe.
|
||||
* {@link Util.asyncToObservable} will execute `generator` for every
|
||||
* subscribe, making it ideal if the value ever needs to be refreshed.
|
||||
* */
|
||||
static asyncToObservable<T>(generator: () => Promise<T>): Observable<T> {
|
||||
return of(undefined).pipe(switchMap(() => generator()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
10
libs/string-utils/tsconfig.json
Normal file
10
libs/string-utils/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../shared/tsconfig",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@bitwarden/i18n-abstractions": ["../i18n-abstractions/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "spec"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
libs/string-utils/tsconfig.spec.json
Normal file
4
libs/string-utils/tsconfig.spec.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"files": ["./test.setup.ts"]
|
||||
}
|
||||
17
libs/types/src/key.ts
Normal file
17
libs/types/src/key.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { SymmetricCryptoKey } from "../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
// symmetric keys
|
||||
export type DeviceKey = Opaque<SymmetricCryptoKey, "DeviceKey">;
|
||||
export type PrfKey = Opaque<SymmetricCryptoKey, "PrfKey">;
|
||||
export type UserKey = Opaque<SymmetricCryptoKey, "UserKey">;
|
||||
export type MasterKey = Opaque<SymmetricCryptoKey, "MasterKey">;
|
||||
export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">;
|
||||
export type OrgKey = Opaque<SymmetricCryptoKey, "OrgKey">;
|
||||
export type ProviderKey = Opaque<SymmetricCryptoKey, "ProviderKey">;
|
||||
export type CipherKey = Opaque<SymmetricCryptoKey, "CipherKey">;
|
||||
|
||||
// asymmetric keys
|
||||
export type UserPrivateKey = Opaque<Uint8Array, "UserPrivateKey">;
|
||||
export type UserPublicKey = Opaque<Uint8Array, "UserPublicKey">;
|
||||
Reference in New Issue
Block a user