diff --git a/extensions/src/platform-scripture/src/checks/extension-host-check-runner.service.ts b/extensions/src/platform-scripture/src/checks/extension-host-check-runner.service.ts index fdc5b1e702..8446d7858a 100644 --- a/extensions/src/platform-scripture/src/checks/extension-host-check-runner.service.ts +++ b/extensions/src/platform-scripture/src/checks/extension-host-check-runner.service.ts @@ -308,6 +308,7 @@ const registerCheck = async ( // #region Initialize the check runner let initializationPromise: Promise | undefined; +const unsubscribers = new UnsubscriberAsyncList(); async function initialize(): Promise { if (!initializationPromise) { initializationPromise = new Promise((resolve, reject) => { @@ -318,7 +319,10 @@ async function initialize(): Promise { checkRunnerEngine, CHECK_RUNNER_NETWORK_OBJECT_TYPE, ); - await papi.commands.registerCommand('platformScripture.registerCheck', registerCheck); + unsubscribers.add(dataProvider.dispose); + unsubscribers.add( + await papi.commands.registerCommand('platformScripture.registerCheck', registerCheck), + ); resolve(); } catch (error) { reject(error); @@ -334,7 +338,7 @@ async function initialize(): Promise { const checkHostingService: ICheckHostingService = { initialize, - dispose: async () => dataProvider.dispose(), + dispose: async () => unsubscribers.runAllUnsubscribers(), getCheckRunner: async () => { await initialize(); return dataProvider; diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 4d05e0b98e..cf80aaa09e 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -4333,6 +4333,13 @@ declare module 'node/services/node-file-system.service' { destinationUri: Uri, mode?: Parameters[2], ): Promise; + /** + * Moves a file from one location to another + * + * @param sourceUri The location of the file to move + * @param destinationUri The uri where the file should be moved + */ + export function moveFile(sourceUri: Uri, destinationUri: Uri): Promise; /** * Delete a file if it exists * @@ -4405,6 +4412,19 @@ declare module 'node/utils/crypto-util' { * @returns Cryptographically secure, pseudo-randomly generated value encoded as a string */ export function createNonce(encoding: 'base64url' | 'hex', numberOfBytes?: number): string; + /** + * Calculates the hash of a given data buffer + * + * @param hashAlgorithm Name of the hash algorithm to use, such as "sha512" + * @param encodingType String encoding to use for returning the binary hash value that is calculated + * @param buffer Raw data to be fed into the hash algorithm + * @returns String encoded value of the digest (https://csrc.nist.gov/glossary/term/hash_digest) + */ + export function generateHashFromBuffer( + hashAlgorithm: string, + encodingType: 'base64' | 'base64url' | 'hex' | 'binary', + buffer: Buffer, + ): string; } declare module 'node/models/execution-token.model' { /** For now this is just for extensions, but maybe we will want to expand this in the future */ @@ -4762,15 +4782,120 @@ declare module 'shared/services/dialog.service' { const dialogService: DialogService; export default dialogService; } +declare module 'shared/models/manage-extensions-privilege.model' { + /** Base64 encoded hash values */ + export type HashValues = Partial<{ + sha256: string; + sha512: string; + }>; + /** Represents an extension that can be enabled or disabled */ + export type ExtensionIdentifier = { + extensionName: string; + extensionVersion: string; + }; + /** + * Represents all extensions that are installed. Note that packaged extensions cannot be disabled, + * so they are implied to always be enabled. + */ + export type InstalledExtensions = { + /** + * Extensions that are explicitly bundled to be part of the application. They cannot be disabled. + * At runtime no extensions can be added or removed from the set of packaged extensions. + */ + packaged: ExtensionIdentifier[]; + /** + * Extensions that are running but can be dynamically disabled. At runtime extensions can be added + * or removed from the set of enabled extensions. + */ + enabled: ExtensionIdentifier[]; + /** + * Extensions that are not running but can be dynamically enabled. At runtime extensions can be + * added or removed from the set of disabled extensions. + * + * The only difference between a disabled extension and an extension that isn't installed is that + * disabled extensions do not need to be downloaded again to run them. + */ + disabled: ExtensionIdentifier[]; + }; + /** + * Download an extension from a given URL and enable it + * + * @param extensionUrlToDownload URL to the extension ZIP file to download + * @param fileSize Expected size of the file + * @param fileHashes Hash value(s) of the file to download. Note that only one hash value may be + * validated, but multiple hash values may be provided so the installer can choose any of them for + * validation. For example, if you provide a sha256 hash value and a sha512 hash value, the + * installer may only use the sha512 hash value for validation. + * @returns Promise that resolves when the extension has been installed + */ + export type InstallExtensionFunction = ( + extensionUrlToDownload: string, + fileSize: number, + fileHashes: HashValues, + ) => Promise; + /** + * Start running an extension that had been previously downloaded and disabled + * + * @param extensionIdentifier Details of the extension to enable + * @returns Promise that resolves when the extension has been enabled, throws if enabling fails + */ + export type EnableExtensionFunction = (extensionIdentifier: ExtensionIdentifier) => Promise; + /** + * Stop running an extension that had been previously downloaded and enabled + * + * @param extensionIdentifier Details of the extension to disable + * @returns Promise that resolves when the extension has been enabled, throws if enabling fails + */ + export type DisableExtensionFunction = ( + extensionIdentifier: ExtensionIdentifier, + ) => Promise; + /** Get extension identifiers of all extensions on the system */ + export type GetInstalledExtensionsFunction = () => Promise; + /** Functions needed to manage extensions */ + export type ManageExtensions = { + /** Function to download an extension and enable it */ + installExtension: InstallExtensionFunction; + /** Function to start running an extension that had been previously downloaded and disabled */ + enableExtension: EnableExtensionFunction; + /** Function to stop running an extension that had been previously downloaded and enabled */ + disableExtension: DisableExtensionFunction; + /** Function to retrieve details about all installed extensions */ + getInstalledExtensions: GetInstalledExtensionsFunction; + }; +} +declare module 'shared/models/elevated-privileges.model' { + import { ManageExtensions } from 'shared/models/manage-extensions-privilege.model'; + /** String constants that are listed in an extension's manifest.json to state needed privileges */ + export enum ElevatedPrivilegeNames { + manageExtensions = 'manageExtensions', + } + /** Object that contains properties with special capabilities for extensions that required them */ + export type ElevatedPrivileges = { + /** Functions that can be run to manage what extensions are running */ + manageExtensions: ManageExtensions | undefined; + }; +} declare module 'extension-host/extension-types/extension-activation-context.model' { import { ExecutionToken } from 'node/models/execution-token.model'; import { UnsubscriberAsyncList } from 'platform-bible-utils'; + import { ElevatedPrivileges } from 'shared/models/elevated-privileges.model'; /** An object of this type is passed into `activate()` for each extension during initialization */ export type ExecutionActivationContext = { /** Canonical name of the extension */ name: string; - /** Used to save and load data from the storage service. */ + /** Used to save and load data by the storage service. */ executionToken: ExecutionToken; + /** + * Objects that provide special capabilities required by an extension based on the + * `elevatedPrivileges` values listed in its manifest. For example, if an extension needs to be + * able to manage other extensions, then it should include `manageExtensions` in the + * `elevatedPrivileges` array in `manifest.json`. Then when the extension is activated this + * {@link ElevatedPrivileges} object will have the `manageExtensions` property set to an object + * with functions used to manage extensions. + * + * See {@link ElevatedPrivilegeNames} for the full list of elevated privileges available. + */ + elevatedPrivileges: ElevatedPrivileges; /** Tracks all registrations made by an extension so they can be cleaned up when it is unloaded */ registrations: UnsubscriberAsyncList; }; @@ -5867,6 +5992,7 @@ declare module 'extension-host/extension-types/extension.interface' { } } declare module 'extension-host/extension-types/extension-manifest.model' { + import { ElevatedPrivilegeNames } from 'shared/models/elevated-privileges.model'; /** Information about an extension provided by the extension developer. */ export type ExtensionManifest = { /** Name of the extension */ @@ -5884,6 +6010,8 @@ declare module 'extension-host/extension-types/extension-manifest.model' { * Must be specified. Can be an empty string if the extension does not have any JavaScript to run. */ main: string; + /** List of special permissions required by the extension to work as intended */ + elevatedPrivileges: `${ElevatedPrivilegeNames}`[]; /** * Path to the TypeScript type declaration file that describes this extension and its interactions * on the PAPI. Relative to the extension's root folder. diff --git a/src/extension-host/extension-types/extension-activation-context.model.ts b/src/extension-host/extension-types/extension-activation-context.model.ts index a84a301155..433549cc5d 100644 --- a/src/extension-host/extension-types/extension-activation-context.model.ts +++ b/src/extension-host/extension-types/extension-activation-context.model.ts @@ -1,12 +1,29 @@ import { ExecutionToken } from '@node/models/execution-token.model'; import { UnsubscriberAsyncList } from 'platform-bible-utils'; +import { + ElevatedPrivileges, + // Needed for documentation links to work + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ElevatedPrivilegeNames, +} from '@shared/models/elevated-privileges.model'; /** An object of this type is passed into `activate()` for each extension during initialization */ export type ExecutionActivationContext = { /** Canonical name of the extension */ name: string; - /** Used to save and load data from the storage service. */ + /** Used to save and load data by the storage service. */ executionToken: ExecutionToken; + /** + * Objects that provide special capabilities required by an extension based on the + * `elevatedPrivileges` values listed in its manifest. For example, if an extension needs to be + * able to manage other extensions, then it should include `manageExtensions` in the + * `elevatedPrivileges` array in `manifest.json`. Then when the extension is activated this + * {@link ElevatedPrivileges} object will have the `manageExtensions` property set to an object + * with functions used to manage extensions. + * + * See {@link ElevatedPrivilegeNames} for the full list of elevated privileges available. + */ + elevatedPrivileges: ElevatedPrivileges; /** Tracks all registrations made by an extension so they can be cleaned up when it is unloaded */ registrations: UnsubscriberAsyncList; }; diff --git a/src/extension-host/extension-types/extension-manifest.model.ts b/src/extension-host/extension-types/extension-manifest.model.ts index c09eaf8444..a5acab1085 100644 --- a/src/extension-host/extension-types/extension-manifest.model.ts +++ b/src/extension-host/extension-types/extension-manifest.model.ts @@ -1,3 +1,5 @@ +import { ElevatedPrivilegeNames } from '@shared/models/elevated-privileges.model'; + /** Information about an extension provided by the extension developer. */ export type ExtensionManifest = { /** Name of the extension */ @@ -15,6 +17,8 @@ export type ExtensionManifest = { * Must be specified. Can be an empty string if the extension does not have any JavaScript to run. */ main: string; + /** List of special permissions required by the extension to work as intended */ + elevatedPrivileges: `${ElevatedPrivilegeNames}`[]; /** * Path to the TypeScript type declaration file that describes this extension and its interactions * on the PAPI. Relative to the extension's root folder. diff --git a/src/extension-host/services/extension.service.ts b/src/extension-host/services/extension.service.ts index 6f566742c9..3578e90c3b 100644 --- a/src/extension-host/services/extension.service.ts +++ b/src/extension-host/services/extension.service.ts @@ -36,6 +36,17 @@ import { localizedStringsDocumentCombiner } from '@extension-host/services/local import { settingsDocumentCombiner } from '@extension-host/services/settings.service-host'; import { PLATFORM_NAMESPACE } from '@shared/data/platform.data'; import { projectSettingsDocumentCombiner } from '@extension-host/services/project-settings.service-host'; +import { + ElevatedPrivilegeNames, + ElevatedPrivileges, +} from '@shared/models/elevated-privileges.model'; +import { generateHashFromBuffer } from '@node/utils/crypto-util'; +import { + ExtensionIdentifier, + HashValues, + InstalledExtensions, + ManageExtensions, +} from '@shared/models/manage-extensions-privilege.model'; /** * The way to use `require` directly - provided by webpack because they overwrite normal `require`. @@ -110,6 +121,13 @@ const systemRequire = globalThis.isPackaged ? __non_webpack_require__ : require; const installedExtensionsUri: Uri = `app://installed-extensions`; nodeFS.createDir(installedExtensionsUri); +/** + * The location where installed extensions may be moved if they are disabled. Created if it does not + * exist for ease of use + */ +const disabledExtensionsUri: Uri = `app://disabled-extensions`; +nodeFS.createDir(disabledExtensionsUri); + /** The location where we will store decompressed extension ZIP files */ const userUnzippedExtensionsCacheUri: Uri = 'cache://extensions'; @@ -143,7 +161,7 @@ function parseManifest(extensionManifestJson: string): ExtensionManifest { if (FORBIDDEN_EXTENSION_NAMES.some((forbiddenName) => forbiddenName === extensionManifest.name)) throw new Error(`Extension name '${extensionManifest.name}' forbidden!`); - if (extensionManifest.main && extensionManifest.main.toLowerCase().endsWith('.ts')) + if (extensionManifest.main?.toLowerCase().endsWith('.ts')) // Replace ts with js so people can list their source code ts name but run the transpiled js extensionManifest.main = `${extensionManifest.main.slice(0, -3)}.js`; @@ -468,7 +486,7 @@ async function cacheExtensionTypeDeclarations(extensionInfos: ExtensionInfo[]) { if (extensionInfo.types) { const providedDtsUri = joinUriPaths(extensionInfo.dirUri, extensionInfo.types); const providedDtsStats = await nodeFS.getStats(providedDtsUri); - if (providedDtsStats && providedDtsStats.isFile()) { + if (providedDtsStats?.isFile()) { // The extension's specified dts exists, so use it extensionDtsInfo = createDtsInfoFromUri(providedDtsUri); } else @@ -578,6 +596,203 @@ function watchForExtensionChanges(): UnsubscriberAsync { }; } +/** + * Returns a URI we can use with `nodeFS` calls that is an expected filename given a base URI, + * extension name, and extension version. This is not that useful for packaged extensions but is + * important for all other extensions, both enabled and disabled. + */ +function getExtensionUri(baseUri: string, extensionName: string, extensionVersion: string): string { + return `${baseUri}/${extensionName}_${extensionVersion}.zip`; +} + +/** + * Provides extension identifier information based on extension ZIP files names given the naming + * convention used in {@link getExtensionUri} + */ +function extractExtensionDetailsFromFileNames(fileUris: string[]): ExtensionIdentifier[] { + return fileUris.map((fileUri: string) => { + const fileName = fileUri.split('/').pop(); + if (!fileName?.endsWith('.zip')) throw new Error(`Not a ZIP file: ${fileName}`); + const lastDashIndex = fileName.lastIndexOf('_'); + const extensionName = fileName.substring(0, lastDashIndex); + const extensionVersion = fileName.substring(lastDashIndex + 1, fileName.length - 4); + return { extensionName, extensionVersion }; + }); +} + +/** Extracts extension identifier information from a buffer containing an extension ZIP file */ +async function extractExtensionDetailsFromZip(zipData: Buffer): Promise { + const zip: JSZip = await JSZip.loadAsync(zipData); + const zippedManifest = zip.file(MANIFEST_FILE_NAME); + if (!zippedManifest) throw new Error('no manifest file found in ZIP data'); + // Assert the extracted manifest.json data as the associated type + // eslint-disable-next-line no-type-assertion/no-type-assertion + const manifest = JSON.parse(await zippedManifest.async('string')) as ExtensionManifest; + return { extensionName: manifest.name, extensionVersion: manifest.version }; +} + +/** + * IMPORTANT: ONLY RUN THIS BEFORE EXTENSIONS ARE ACTIVATED + * + * Ensures extension file names match the extension names and versions in their manifest files. This + * only looks at locations where downloaded extensions reside. Packaged extensions and those pointed + * to directly from the command line are unaffected. + */ +async function normalizeExtensionFileNames(): Promise { + const enabledExtensionZipUris = ( + await nodeFS.readDir(installedExtensionsUri, (uri) => uri?.toLowerCase().endsWith('zip')) + ).file; + const enabledExtensionPromises = enabledExtensionZipUris.map(async (enabledZipUri) => { + await normalizeExtensionFileName(installedExtensionsUri, enabledZipUri); + }); + + const disabledExtensionZipUris = ( + await nodeFS.readDir(disabledExtensionsUri, (uri) => uri?.toLowerCase().endsWith('zip')) + ).file; + const disabledExtensionPromises = disabledExtensionZipUris.map(async (disabledZipUri) => { + await normalizeExtensionFileName(disabledExtensionsUri, disabledZipUri); + }); + + await Promise.all(enabledExtensionPromises.concat(disabledExtensionPromises)); +} + +async function normalizeExtensionFileName(baseUri: string, zipUri: string) { + try { + const zipBuffer = await nodeFS.readFileBinary(zipUri); + const { extensionName, extensionVersion } = await extractExtensionDetailsFromZip(zipBuffer); + const expectedUri = getExtensionUri(baseUri, extensionName, extensionVersion); + if (zipUri !== expectedUri) { + await nodeFS.moveFile(zipUri, expectedUri); + logger.info(`Renamed '${extensionName}' ZIP file from ${zipUri} to ${expectedUri}`); + } + } catch (error) { + logger.warn(`Failed to normalize extension file for ${zipUri}: ${error}`); + } +} + +// #region Extension management privileges + +async function installExtension( + extensionUrlToDownload: string, + fileSize: number, + fileHashes: HashValues, +): Promise { + // Make sure a supported hash value was provided + let hashAlgo: string; + let expectedHashValue: string; + if (fileHashes.sha512) { + hashAlgo = 'sha512'; + expectedHashValue = fileHashes.sha512; + } else if (fileHashes.sha256) { + hashAlgo = 'sha256'; + expectedHashValue = fileHashes.sha256; + } else throw new Error(`Missing known hash algorithms from ${JSON.stringify(fileHashes)}`); + + // Download the file and make sure the size and hash values match + const response = await fetch(extensionUrlToDownload); + const extensionBuffer = Buffer.from(await response.arrayBuffer()); + if (extensionBuffer.byteLength !== fileSize) + throw new Error( + `file size mismatch, expected ${JSON.stringify(fileSize)}, actual ${JSON.stringify(extensionBuffer.byteLength)}`, + ); + const hashValue = generateHashFromBuffer(hashAlgo, 'base64', extensionBuffer); + if (expectedHashValue !== hashValue) + throw new Error(`file hash mismatch, expected ${expectedHashValue}, actual ${hashValue}`); + + // Extract information needed from the extension + const { extensionName, extensionVersion } = await extractExtensionDetailsFromZip(extensionBuffer); + if (FORBIDDEN_EXTENSION_NAMES.find((forbiddenName) => extensionName === forbiddenName)) + throw new Error(`Forbidden extension name: ${extensionName}`); + + // Save the extension file in a location where it will be automatically installed + const extensionUri = getExtensionUri(installedExtensionsUri, extensionName, extensionVersion); + if (await nodeFS.getStats(extensionUri)) + logger.warn(`Attempting to overwrite extension ZIP file: ${extensionUri}`); + await nodeFS.writeFile(extensionUri, extensionBuffer); + logger.info(`Installed ${extensionName} ${extensionVersion} from ${extensionUrlToDownload}`); +} + +async function enableExtension(extensionId: ExtensionIdentifier) { + const { extensionName, extensionVersion } = extensionId; + const sourceUri = getExtensionUri(disabledExtensionsUri, extensionName, extensionVersion); + if (!(await nodeFS.getStats(sourceUri))) + throw new Error(`'${extensionName} ${extensionVersion}' is not disabled`); + const destinationUri = getExtensionUri(installedExtensionsUri, extensionName, extensionVersion); + if (await nodeFS.getStats(destinationUri)) + throw new Error(`'${extensionName} ${extensionVersion}' is already enabled`); + await nodeFS.moveFile(sourceUri, destinationUri); + logger.info(`Enabled ${extensionName} ${extensionVersion}`); +} + +async function disableExtension(extensionId: ExtensionIdentifier) { + const { extensionName, extensionVersion } = extensionId; + const sourceUri = getExtensionUri(installedExtensionsUri, extensionName, extensionVersion); + if (!(await nodeFS.getStats(sourceUri))) + throw new Error(`'${extensionName} ${extensionVersion}' does not exist or is not enabled`); + const destinationUri = getExtensionUri(disabledExtensionsUri, extensionName, extensionVersion); + if (await nodeFS.getStats(destinationUri)) + logger.warn(`Attempting to overwrite extension ZIP file: ${destinationUri}`); + await nodeFS.moveFile(sourceUri, destinationUri); + logger.info(`Disabled ${extensionName} ${extensionVersion}`); +} + +async function getInstalledExtensions(): Promise { + // "Enabled" extensions are all the ones in the "installed" directory + const installedExtensionZips = ( + await nodeFS.readDir(installedExtensionsUri, (uri) => uri?.toLowerCase().endsWith('zip')) + ).file; + const enabled = extractExtensionDetailsFromFileNames(installedExtensionZips); + + // "Disabled" extensions are all the ones in the "disabled" directory that aren't also "enabled" + const disabledExtensionZips = ( + await nodeFS.readDir(disabledExtensionsUri, (uri) => uri?.toLowerCase().endsWith('zip')) + ).file; + const disabled = extractExtensionDetailsFromFileNames(disabledExtensionZips).filter( + (disabledId) => + !enabled.find((enabledId) => enabledId.extensionName === disabledId.extensionName), + ); + + // "Packaged" extensions are all the running extensions that aren't "enabled" + const packaged = [...activeExtensions.values()] + .map((active) => { + const packagedId: ExtensionIdentifier = { + extensionName: active.info.name, + extensionVersion: active.info.version, + }; + + return enabled.find((enabledId) => enabledId.extensionName === packagedId.extensionName) + ? undefined + : packagedId; + }) + .filter((identifier) => !!identifier); + + return { + enabled, + disabled, + packaged, + }; +} + +// #endregion + +function prepareElevatedPrivileges(manifest: ExtensionManifest): Readonly { + const retVal: ElevatedPrivileges = { + manageExtensions: undefined, + }; + if (manifest.elevatedPrivileges?.find((p) => p === ElevatedPrivilegeNames.manageExtensions)) { + const manageExtensions: ManageExtensions = { + installExtension, + enableExtension, + disableExtension, + getInstalledExtensions, + }; + Object.freeze(manageExtensions); + retVal.manageExtensions = manageExtensions; + } + Object.freeze(retVal); + return retVal; +} + /** * Loads an extension and runs its activate function. * @@ -613,6 +828,7 @@ async function activateExtension(extension: ExtensionInfo): Promise { initializePromise = (async (): Promise => { if (isInitialized) return; + await normalizeExtensionFileNames(); + await reloadExtensions(false); watchForExtensionChanges(); diff --git a/src/node/services/node-file-system.service.ts b/src/node/services/node-file-system.service.ts index 5aeca8b4c7..98f277919e 100644 --- a/src/node/services/node-file-system.service.ts +++ b/src/node/services/node-file-system.service.ts @@ -64,6 +64,18 @@ export async function copyFile( return fs.promises.copyFile(filePathSource, filePathDest, mode); } +/** + * Moves a file from one location to another + * + * @param sourceUri The location of the file to move + * @param destinationUri The uri where the file should be moved + */ +export async function moveFile(sourceUri: Uri, destinationUri: Uri) { + const filePathSource: string = getPathFromUri(sourceUri); + const filePathDest: string = getPathFromUri(destinationUri); + await fs.promises.rename(filePathSource, filePathDest); +} + /** * Delete a file if it exists * @@ -72,7 +84,7 @@ export async function copyFile( */ export async function deleteFile(uri: Uri): Promise { const stats = await getStats(uri); - if (stats && stats.isFile()) await fs.promises.rm(getPathFromUri(uri)); + if (stats?.isFile()) await fs.promises.rm(getPathFromUri(uri)); } /** @@ -140,7 +152,7 @@ export async function readDir( entryFilter?: (entryName: string) => boolean, ): Promise { const stats = await getStats(uri); - if (!stats || !stats.isDirectory()) + if (!stats?.isDirectory()) // Assert return type. // eslint-disable-next-line no-type-assertion/no-type-assertion return Object.freeze(Object.fromEntries(fillMissingEntryTypeProperties())) as DirectoryEntries; @@ -189,6 +201,6 @@ export async function createDir(uri: Uri): Promise { */ export async function deleteDir(uri: Uri): Promise { const stats = await getStats(uri); - if (!stats || !stats.isDirectory()) return; + if (!stats?.isDirectory()) return; await fs.promises.rm(getPathFromUri(uri), { recursive: true, maxRetries: 1 }); } diff --git a/src/node/utils/crypto-util.test.ts b/src/node/utils/crypto-util.test.ts index 44062138fa..dcd70bc28c 100644 --- a/src/node/utils/crypto-util.test.ts +++ b/src/node/utils/crypto-util.test.ts @@ -1,4 +1,4 @@ -import { createUuid, createNonce } from './crypto-util'; +import { createUuid, createNonce, generateHashFromBuffer } from './crypto-util'; test('createUuid returns a property formatted UUID', () => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[8-9a-b][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -64,3 +64,13 @@ function calculateEntropy(data: Uint8Array): number { return entropy; } + +test('hash algorithm works as expected', () => { + const buffer = Buffer.from('Hello, World!', 'utf-8'); + const hashValue256 = generateHashFromBuffer('sha256', 'base64', buffer); + expect(hashValue256).toBe('3/1gIbsr1bCvZ2KQgJ7DpTGR3YHH9wpLKGiKNiGCmG8='); + const hashValue512 = generateHashFromBuffer('SHA-512', 'base64', buffer); + expect(hashValue512).toBe( + 'N015SpXNz9izWZMYX++bo2jxYNja9DLQi6nx7R5avmzGkpHg+i/gAGpSVw7xjBne9OYXwzzlLvCm5fvjGMsDhw==', + ); +}); diff --git a/src/node/utils/crypto-util.ts b/src/node/utils/crypto-util.ts index 182db01a73..5f2e6976b9 100644 --- a/src/node/utils/crypto-util.ts +++ b/src/node/utils/crypto-util.ts @@ -19,3 +19,26 @@ export function createNonce(encoding: 'base64url' | 'hex', numberOfBytes: number const randomBytes = crypto.randomBytes(numberOfBytes); return randomBytes.toString(encoding); } + +/** + * Calculates the hash of a given data buffer + * + * @param hashAlgorithm Name of the hash algorithm to use, such as "sha512" + * @param encodingType String encoding to use for returning the binary hash value that is calculated + * @param buffer Raw data to be fed into the hash algorithm + * @returns String encoded value of the digest (https://csrc.nist.gov/glossary/term/hash_digest) + */ +export function generateHashFromBuffer( + hashAlgorithm: string, + encodingType: 'base64' | 'base64url' | 'hex' | 'binary', + buffer: Buffer, +): string { + // Names of hash algorithms can vary based on the library used + // The 'crypto' module wants lowercase with no dashes according to the docs + const algorithm = crypto.getHashes().find((algo) => algo === hashAlgorithm) + ? hashAlgorithm + : hashAlgorithm.toLowerCase().replaceAll('-', ''); + const hashAlgo = crypto.createHash(algorithm); + hashAlgo.update(buffer); + return hashAlgo.digest(encodingType); +} diff --git a/src/shared/models/elevated-privileges.model.ts b/src/shared/models/elevated-privileges.model.ts new file mode 100644 index 0000000000..7101cc7df2 --- /dev/null +++ b/src/shared/models/elevated-privileges.model.ts @@ -0,0 +1,12 @@ +import { ManageExtensions } from '@shared/models/manage-extensions-privilege.model'; + +/** String constants that are listed in an extension's manifest.json to state needed privileges */ +export enum ElevatedPrivilegeNames { + manageExtensions = 'manageExtensions', +} + +/** Object that contains properties with special capabilities for extensions that required them */ +export type ElevatedPrivileges = { + /** Functions that can be run to manage what extensions are running */ + manageExtensions: ManageExtensions | undefined; +}; diff --git a/src/shared/models/manage-extensions-privilege.model.ts b/src/shared/models/manage-extensions-privilege.model.ts new file mode 100644 index 0000000000..8388043c13 --- /dev/null +++ b/src/shared/models/manage-extensions-privilege.model.ts @@ -0,0 +1,84 @@ +/** Base64 encoded hash values */ +export type HashValues = Partial<{ + sha256: string; + sha512: string; +}>; + +/** Represents an extension that can be enabled or disabled */ +export type ExtensionIdentifier = { + extensionName: string; + extensionVersion: string; +}; + +/** + * Represents all extensions that are installed. Note that packaged extensions cannot be disabled, + * so they are implied to always be enabled. + */ +export type InstalledExtensions = { + /** + * Extensions that are explicitly bundled to be part of the application. They cannot be disabled. + * At runtime no extensions can be added or removed from the set of packaged extensions. + */ + packaged: ExtensionIdentifier[]; + /** + * Extensions that are running but can be dynamically disabled. At runtime extensions can be added + * or removed from the set of enabled extensions. + */ + enabled: ExtensionIdentifier[]; + /** + * Extensions that are not running but can be dynamically enabled. At runtime extensions can be + * added or removed from the set of disabled extensions. + * + * The only difference between a disabled extension and an extension that isn't installed is that + * disabled extensions do not need to be downloaded again to run them. + */ + disabled: ExtensionIdentifier[]; +}; + +/** + * Download an extension from a given URL and enable it + * + * @param extensionUrlToDownload URL to the extension ZIP file to download + * @param fileSize Expected size of the file + * @param fileHashes Hash value(s) of the file to download. Note that only one hash value may be + * validated, but multiple hash values may be provided so the installer can choose any of them for + * validation. For example, if you provide a sha256 hash value and a sha512 hash value, the + * installer may only use the sha512 hash value for validation. + * @returns Promise that resolves when the extension has been installed + */ +export type InstallExtensionFunction = ( + extensionUrlToDownload: string, + fileSize: number, + fileHashes: HashValues, +) => Promise; + +/** + * Start running an extension that had been previously downloaded and disabled + * + * @param extensionIdentifier Details of the extension to enable + * @returns Promise that resolves when the extension has been enabled, throws if enabling fails + */ +export type EnableExtensionFunction = (extensionIdentifier: ExtensionIdentifier) => Promise; + +/** + * Stop running an extension that had been previously downloaded and enabled + * + * @param extensionIdentifier Details of the extension to disable + * @returns Promise that resolves when the extension has been enabled, throws if enabling fails + */ +export type DisableExtensionFunction = (extensionIdentifier: ExtensionIdentifier) => Promise; + +/** Get extension identifiers of all extensions on the system */ +export type GetInstalledExtensionsFunction = () => Promise; + +/** Functions needed to manage extensions */ +export type ManageExtensions = { + /** Function to download an extension and enable it */ + installExtension: InstallExtensionFunction; + /** Function to start running an extension that had been previously downloaded and disabled */ + enableExtension: EnableExtensionFunction; + /** Function to stop running an extension that had been previously downloaded and enabled */ + disableExtension: DisableExtensionFunction; + /** Function to retrieve details about all installed extensions */ + getInstalledExtensions: GetInstalledExtensionsFunction; +};