UNPKG

@salesforce/plugin-trust

Version:

validate a digital signature for a npm package

426 lines 18.5 kB
/* * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import path from 'node:path'; import { Readable } from 'node:stream'; import { URL } from 'node:url'; import crypto from 'node:crypto'; import fs from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { Ux } from '@salesforce/sf-plugins-core/Ux'; import { Logger, SfError, Messages } from '@salesforce/core'; import got from 'got'; import { ProxyAgent } from 'proxy-agent'; import { prompts } from '@salesforce/sf-plugins-core'; import { maxSatisfying } from 'semver'; import { NpmModule } from './npmCommand.js'; import { npmNameToString } from './npmName.js'; import { setErrorName } from './errors.js'; const CRYPTO_LEVEL = 'RSA-SHA256'; const ALLOW_LIST_FILENAME = 'unsignedPluginAllowList.json'; export const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); class CodeVerifierInfo { signature; publicKey; data; get dataToVerify() { if (!this.data) { throw new Error('CodeVerifierInfo: Verifier has no data because it has not be set'); } return this.data; } set dataToVerify(value) { this.data = value; } // eslint-disable-next-line @typescript-eslint/member-ordering get signatureStream() { if (!this.signature) { throw new Error('CodeVerifierInfo: signatureStream has no value because it has not be set'); } return this.signature; } set signatureStream(value) { this.signature = value; } // eslint-disable-next-line @typescript-eslint/member-ordering get publicKeyStream() { if (!this.publicKey) { throw new Error('CodeVerifierInfo: publicKey has no value because it has not be set'); } return this.publicKey; } set publicKeyStream(value) { this.publicKey = value; } } function validSalesforceHostname(url) { if (!url) { return false; } const parsedUrl = new URL(url); if (process.env.SFDX_ALLOW_ALL_SALESFORCE_CERTSIG_HOSTING === 'true') { return Boolean(parsedUrl.hostname) && /(\.salesforce\.com)$/.test(parsedUrl.hostname); } else { return (parsedUrl.protocol === 'https:' && Boolean(parsedUrl.hostname) && parsedUrl.hostname === 'developer.salesforce.com'); } } function retrieveKey(stream) { return new Promise((resolve, reject) => { let key = ''; if (stream) { stream.on('data', (chunk) => { key += chunk; }); stream.on('end', () => { if (!key.includes('-----BEGIN')) { return reject(new SfError('The specified key format is invalid.', 'InvalidKeyFormat')); } return resolve(key); }); stream.on('error', (err) => reject(err)); } }); } export async function verify(codeVerifierInfo) { const publicKey = await retrieveKey(codeVerifierInfo.publicKeyStream); const signApi = crypto.createVerify(CRYPTO_LEVEL); return new Promise((resolve, reject) => { codeVerifierInfo.dataToVerify.on('error', (err) => reject(errorHandlerForVerify(err))); codeVerifierInfo.dataToVerify.pipe(signApi); codeVerifierInfo.dataToVerify.on('end', () => { // The sign signature returns a base64 encode string. let signature = Buffer.alloc(0); codeVerifierInfo.signatureStream.on('data', (chunk) => { signature = Buffer.concat([signature, chunk]); }); codeVerifierInfo.signatureStream.on('end', () => { if (signature.byteLength === 0) { return reject(new SfError('The provided signature is invalid or missing.', 'InvalidSignature')); } else { const verification = signApi.verify(publicKey, signature.toString('utf8'), 'base64'); return resolve(verification); } }); codeVerifierInfo.signatureStream.on('error', (err) => reject(errorHandlerForVerify(err))); }); }); } const errorHandlerForVerify = (err) => { if ('code' in err && err.code === 'DEPTH_ZERO_SELF_SIGNED_CERT') { return setErrorName(new SfError('Encountered a self signed certificated. To enable "export NODE_TLS_REJECT_UNAUTHORIZED=0"'), 'SelfSignedCert'); } return err; }; export const getNpmRegistry = () => new URL(process.env.SF_NPM_REGISTRY ?? process.env.SFDX_NPM_REGISTRY ?? DEFAULT_REGISTRY); export async function isAllowListed({ logger, configPath, name, }) { const allowListedFilePath = path.join(configPath, ALLOW_LIST_FILENAME); logger.debug(`isAllowListed | allowlistFilePath: ${allowListedFilePath}`); let fileContent; try { fileContent = await fs.promises.readFile(allowListedFilePath, 'utf8'); const allowlistArray = JSON.parse(fileContent); logger.debug('isAllowListed | Successfully parsed allowlist.'); return name ? allowlistArray.includes(name) : false; } catch (err) { if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { return false; } else { throw err; } } } /** * class for verifying a digital signature pack of an npm */ export class InstallationVerification { // The name of the published plugin pluginNpmName; // config derived from the cli environment config; logger; /** * setter for the cli engine config * * @param _config cli engine config */ setConfig(_config) { if (_config) { this.config = _config; return this; } throw setErrorName(new SfError('the cli engine config cannot be null', 'InvalidParam'), 'InvalidParam'); } /** * setter for the plugin name * * @param _pluginName the published plugin name */ setPluginNpmName(_pluginName) { if (_pluginName) { this.pluginNpmName = _pluginName; return this; } throw setErrorName(new SfError('the plugin name cannot be null', 'InvalidParam'), 'InvalidParam'); } /** * validates the digital signature. */ async verify() { const logger = await this.getLogger(); const npmMeta = await this.streamTagGz(); if (!npmMeta.tarballLocalPath) { throw new SfError('The npmMeta does not contain a tarball path'); } if (!npmMeta.signatureUrl) { throw new SfError('The npmMeta does not contain a signatureUrl'); } if (!npmMeta.publicKeyUrl) { throw new SfError('The npmMeta does not contain a publicKeyUrl'); } logger.debug(`verify | Found npmMeta? ${!!npmMeta}`); logger.debug(`verify | creating a read stream for path - npmMeta.tarballLocalPath: ${npmMeta.tarballLocalPath}`); logger.debug(`verify | npmMeta.signatureUrl: ${npmMeta.signatureUrl}`); logger.debug(`verify | npmMeta.publicKeyUrl: ${npmMeta.publicKeyUrl}`); const [signatureStream, publicKeyStream] = await Promise.all([ getSigningContent(npmMeta.signatureUrl), getSigningContent(npmMeta.publicKeyUrl), ]); const info = new CodeVerifierInfo(); info.dataToVerify = fs.createReadStream(npmMeta.tarballLocalPath, { encoding: 'binary' }); info.publicKeyStream = publicKeyStream; info.signatureStream = signatureStream; npmMeta.verified = await verify(info); try { await fs.promises.rm(npmMeta.tarballLocalPath); } catch (err) { logger.debug(`error occurred deleting cache tgz at path: ${npmMeta.tarballLocalPath}`); logger.debug(err); } return npmMeta; } async isAllowListed() { return isAllowListed({ logger: await this.getLogger(), configPath: this.getConfigPath() ?? '', name: this.pluginNpmName ? npmNameToString(this.pluginNpmName) : undefined, }); } /** * Downloads the tgz file content and stores it in a cache folder */ async streamTagGz() { const logger = await this.getLogger(); const npmMeta = await this.retrieveNpmMeta(); if (!npmMeta.tarballUrl) { throw new Error('tarballUrl is not defined in the npmMeta object'); } const urlObject = new URL(npmMeta.tarballUrl); const urlPathsAsArray = urlObject.pathname.split('/'); npmMeta.tarballFilename = npmMeta.moduleName?.replace(/@/g, ''); logger.debug(`streamTagGz | urlPathsAsArray: ${urlPathsAsArray.join(',')}`); const fileNameStr = urlPathsAsArray[urlPathsAsArray.length - 1]; logger.debug(`streamTagGz | fileNameStr: ${fileNameStr}`); // Make sure the cache path exists. try { if (!npmMeta.moduleName) { throw new Error('moduleName is not defined in the npmMeta object'); } if (!npmMeta.version) { throw new Error('version is not defined in the npmMeta object'); } await mkdir(this.getCachePath(), { recursive: true }); const npmModule = new NpmModule(npmMeta.moduleName, npmMeta.version, this.config?.cliRoot); await npmModule.fetchTarball(getNpmRegistry().href, { cwd: this.getCachePath(), }); const tarBallFile = fs .readdirSync(this.getCachePath(), { withFileTypes: true }) .find((entry) => entry.isFile() && npmMeta.version && entry.name.includes(npmMeta.version)); if (!tarBallFile) { throw new Error(`Unable to find retrieved tarball file for ${npmMeta.moduleName} version ${npmMeta.version}`); } npmMeta.tarballLocalPath = path.join(this.getCachePath(), tarBallFile.name); } catch (err) { logger.debug(err); throw err; } return npmMeta; } // this is generally $HOME/.config/sfdx getConfigPath() { if (!this.config?.configDir) { throw new Error('configDir is not defined in the config object'); } return this.config.configDir; } // this is generally $HOME/Library/Caches/sfdx on mac getCachePath() { if (!this.config?.cacheDir) { throw new Error('cacheDir is not defined in the config object'); } return this.config.cacheDir; } /** * Invoke npm to discover a urls for the certificate and digital signature. */ async retrieveNpmMeta() { const logger = await this.getLogger(); const npmRegistry = getNpmRegistry(); if (!this.pluginNpmName) { throw new Error('pluginNpmName is not defined on the InstallationVerification class. setPluginNpmName should have been called before this method.'); } logger.debug(`retrieveNpmMeta | npmRegistry: ${npmRegistry.href}`); logger.debug(`retrieveNpmMeta | this.pluginNpmName.name: ${this.pluginNpmName.name}`); logger.debug(`retrieveNpmMeta | this.pluginNpmName.scope: ${this.pluginNpmName.scope ?? '<not defined>'}`); logger.debug(`retrieveNpmMeta | this.pluginNpmName.tag: ${this.pluginNpmName.tag}`); const npmShowModule = this.pluginNpmName.scope ? `@${this.pluginNpmName.scope}/${this.pluginNpmName.name}` : this.pluginNpmName.name; const npmModule = new NpmModule(npmShowModule, this.pluginNpmName.tag, this.config?.cliRoot); const npmMetadata = npmModule.show(npmRegistry.href); logger.debug('retrieveNpmMeta | Found npm meta information.'); if (!npmMetadata.versions) { const err = new SfError(`The npm metadata for plugin ${this.pluginNpmName.name} is missing the versions attribute.`, 'InvalidNpmMetadata'); throw setErrorName(err, 'InvalidNpmMetadata'); } // Assume the tag is version tag. let versionNumber = maxSatisfying(npmMetadata.versions, this.pluginNpmName.tag) ?? npmMetadata.versions.find((version) => version === this.pluginNpmName?.tag); logger.debug(`retrieveNpmMeta | versionObject: ${JSON.stringify(versionNumber)}`); // If the assumption was not correct the tag must be a non-versioned dist-tag or not specified. if (!versionNumber) { // Assume dist-tag; const distTags = npmMetadata['dist-tags']; logger.debug(`retrieveNpmMeta | distTags: ${JSON.stringify(distTags)}`); if (distTags) { const tagVersionStr = distTags[this.pluginNpmName.tag]; logger.debug(`retrieveNpmMeta | tagVersionStr: ${tagVersionStr}`); // if we got a dist tag hit look up the version object if (tagVersionStr && tagVersionStr.length > 0 && tagVersionStr.includes('.')) { versionNumber = maxSatisfying(npmMetadata.versions, tagVersionStr) ?? npmMetadata.versions.find((version) => version === tagVersionStr); logger.debug(`retrieveNpmMeta | versionObject: ${versionNumber ?? '<not defined>'}`); } else { const err = new SfError(`The dist tag ${this.pluginNpmName.tag} was not found for plugin: ${this.pluginNpmName.name}`, 'NpmTagNotFound'); throw setErrorName(err, 'NpmTagNotFound'); } } else { throw setErrorName(new SfError('The deployed NPM is missing dist-tags.', 'UnexpectedNpmFormat'), 'UnexpectedNpmFormat'); } } npmModule.npmMeta.version = versionNumber; if (!npmMetadata.sfdx) { throw setErrorName(new SfError('This plugin is not signed by Salesforce.com, Inc.', 'NotSigned'), 'NotSigned'); } else { if (!validSalesforceHostname(npmMetadata.sfdx.publicKeyUrl)) { const err = new SfError(`The host is not allowed to provide signing information. [${npmMetadata.sfdx.publicKeyUrl}]`, 'UnexpectedHost'); throw setErrorName(err, 'UnexpectedHost'); } else { logger.debug(`retrieveNpmMeta | versionObject.sfdx.publicKeyUrl: ${npmMetadata.sfdx.publicKeyUrl}`); npmModule.npmMeta.publicKeyUrl = npmMetadata.sfdx.publicKeyUrl; } if (!validSalesforceHostname(npmMetadata.sfdx.signatureUrl)) { const err = new SfError(`The host is not allowed to provide signing information. [${npmMetadata.sfdx.signatureUrl}]`, 'UnexpectedHost'); throw setErrorName(err, 'UnexpectedHost'); } else { logger.debug(`retrieveNpmMeta | versionObject.sfdx.signatureUrl: ${npmMetadata.sfdx.signatureUrl}`); npmModule.npmMeta.signatureUrl = npmMetadata.sfdx.signatureUrl; } npmModule.npmMeta.tarballUrl = npmMetadata.dist?.tarball; logger.debug(`retrieveNpmMeta | meta.tarballUrl: ${npmModule.npmMeta.tarballUrl ?? '<not defined>'}`); return npmModule.npmMeta; } } async getLogger() { if (!this.logger) { this.logger = await Logger.child('InstallationVerification'); } return this.logger; } } export class VerificationConfig { verifier; ux = new Ux(); // eslint-disable-next-line class-methods-use-this log(message) { this.ux.log(message); } } export const doPrompt = (ux) => async (plugin) => { const messages = Messages.loadMessages('@salesforce/plugin-trust', 'verify'); if (!(await prompts.confirm({ message: messages.getMessage('InstallConfirmation', [plugin ?? 'This plugin']), ms: 30_000, }))) { throw new SfError('The user canceled the plugin installation.', 'InstallationCanceledError'); } // they approved the plugin. Let them know how to automate this. ux.log(messages.getMessage('SuggestAllowList')); }; export const doInstallationCodeSigningVerification = (ux) => async (config, plugin, verificationConfig) => { const messages = Messages.loadMessages('@salesforce/plugin-trust', 'verify'); if (await verificationConfig.verifier?.isAllowListed()) { verificationConfig.log(messages.getMessage('SkipSignatureCheck', [plugin.plugin])); return; } try { if (!verificationConfig.verifier) { throw new Error('VerificationConfig.verifier is not set.'); } const meta = await verificationConfig.verifier.verify(); if (!meta.verified) { const err = messages.createError('FailedDigitalSignatureVerification'); throw setErrorName(err, 'FailedDigitalSignatureVerification'); } verificationConfig.log(messages.getMessage('SignatureCheckSuccess', [plugin.plugin])); } catch (err) { if (err instanceof Error) { if (err.name === 'NotSigned' || err.message?.includes('Response code 403')) { if (!verificationConfig.verifier) { throw new Error('VerificationConfig.verifier is not set.'); } return doPrompt(ux)(plugin.plugin); } else if (err.name === 'PluginNotFound' || err.name === 'PluginAccessDenied') { throw setErrorName(new SfError(err.message ?? 'The user canceled the plugin installation.'), ''); } throw setErrorName(SfError.wrap(err), err.name); } } }; /** * Retrieve url content for a host * * @param url host url. */ const getSigningContent = async (url) => { const res = await got.get({ url, timeout: { request: 10_000 }, agent: { https: new ProxyAgent() }, }); if (res.statusCode !== 200) { throw new SfError(`A request to url ${url} failed with error code: [${res.statusCode}]`, 'ErrorGettingContent'); } return Readable.from(Buffer.from(res.body)); }; //# sourceMappingURL=installationVerification.js.map