@salesforce/core
Version:
Core libraries to interact with SFDX projects, orgs, and APIs.
500 lines • 21.6 kB
JavaScript
/*
* Copyright (c) 2020, 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
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.keyChainImpl = exports.GenericWindowsKeychainAccess = exports.GenericUnixKeychainAccess = exports.GenericKeychainAccess = exports.KeychainAccess = void 0;
const childProcess = __importStar(require("node:child_process"));
const nodeFs = __importStar(require("node:fs"));
const fs = __importStar(require("node:fs"));
const os = __importStar(require("node:os"));
const node_os_1 = require("node:os");
const path = __importStar(require("node:path"));
const ts_types_1 = require("@salesforce/ts-types");
const kit_1 = require("@salesforce/kit");
const global_1 = require("../global");
const messages_1 = require("../messages");
;
const messages = new messages_1.Messages('@salesforce/core', 'encryption', new Map([["invalidEncryptedFormatError", "The encrypted data is not properly formatted."], ["invalidEncryptedFormatError.actions", ["If attempting to create a scratch org then re-authorize. Otherwise create a new scratch org."]], ["authDecryptError", "Failed to decipher auth data. reason: %s."], ["unsupportedOperatingSystemError", "Unsupported Operating System: %s"], ["missingCredentialProgramError", "Unable to find required security software: %s"], ["credentialProgramAccessError", "Unable to execute security software: %s"], ["passwordRetryError", "Failed to get the password after %i retries."], ["passwordRequiredError", "A password is required."], ["keyChainServiceRequiredError", "Unable to get or set a keychain value without a service name."], ["keyChainAccountRequiredError", "Unable to get or set a keychain value without an account name."], ["keyChainUserCanceledError", "User canceled authentication."], ["keychainPasswordCreationError", "Failed to create a password in the keychain."], ["genericKeychainServiceError", "The service and account specified in %s do not match the version of the toolbelt."], ["genericKeychainServiceError.actions", ["Check your toolbelt version and re-auth."]], ["genericKeychainInvalidPermsError", "Invalid file permissions for secret file: %s"], ["genericKeychainInvalidPermsError.actions", ["Ensure the file %s has the file permission octal value of %s."]], ["passwordNotFoundError", "Could not find password.\n%s"], ["passwordNotFoundError.actions", ["Ensure a valid password is returned with the following command: [%s]"]], ["setCredentialError", "Command failed with response:\n%s"], ["setCredentialError.actions", ["Determine why this command failed to set an encryption key for user %s: [%s]."]], ["macKeychainOutOfSync", "We\u2019ve encountered an error with the Mac keychain being out of sync with your `sfdx` credentials. To fix the problem, sync your credentials by authenticating into your org again using the auth commands."], ["v1CryptoWithV2KeyWarning", "The SF_CRYPTO_V2 environment variable was set to \"false\" but a v2 crypto key was detected. v1 crypto can only be used with a v1 key. Unset the SF_CRYPTO_V2 environment variable."], ["v2CryptoWithV1KeyWarning", "SF_CRYPTO_V2 was set to \"true\" but a v1 crypto key was detected. v2 crypto can only be used with a v2 key. To generate a v2 key:\n\n1. Logout of all orgs: `sf org logout --all`\n2. Delete the sfdx keychain entry (account: local, service: sfdx). If `SF_USE_GENERIC_UNIX_KEYCHAIN=true` env var is set, you can delete the `key.json` file.\n3. Set `SF_CRYPTO_V2=true` env var.\n4. Re-Authenticate with your orgs using the CLI org login commands."]]));
const GET_PASSWORD_RETRY_COUNT = 3;
/**
* Helper to reduce an array of cli args down to a presentable string for logging.
*
* @param optionsArray CLI command args.
*/
const optionsToString = (optionsArray) => optionsArray.join(' ');
/**
* Helper to determine if a program is executable. Returns `true` if the program is executable for the user. For
* Windows true is always returned.
*
* @param mode Stats mode.
* @param gid Unix group id.
* @param uid Unix user id.
*/
const isExe = (mode, gid, uid) => {
if (process.platform === 'win32') {
return true;
}
return Boolean(mode & parseInt('0001', 8) ||
Boolean(mode & parseInt('0010', 8) && process.getgid && gid === process.getgid()) ||
(mode & parseInt('0100', 8) && process.getuid && uid === process.getuid()));
};
/**
* Private helper to validate that a program exists on the file system and is executable.
*
* **Throws** *{@link SfError}{ name: 'MissingCredentialProgramError' }* When the OS credential program isn't found.
*
* **Throws** *{@link SfError}{ name: 'CredentialProgramAccessError' }* When the OS credential program isn't accessible.
*
* @param programPath The absolute path of the program.
* @param fsIfc The file system interface.
* @param isExeIfc Executable validation function.
*/
// eslint-disable-next-line no-underscore-dangle
const _validateProgram = async (programPath, fsIfc, isExeIfc
// eslint-disable-next-line @typescript-eslint/require-await
) => {
let noPermission;
try {
const stats = fsIfc.statSync(programPath);
noPermission = !isExeIfc(stats.mode, stats.gid, stats.uid);
}
catch (e) {
throw messages.createError('missingCredentialProgramError', [programPath]);
}
if (noPermission) {
throw messages.createError('credentialProgramAccessError', [programPath]);
}
};
/**
* @private
*/
class KeychainAccess {
osImpl;
fsIfc;
/**
* Abstract prototype for general cross platform keychain interaction.
*
* @param osImpl The platform impl for (linux, darwin, windows).
* @param fsIfc The file system interface.
*/
constructor(osImpl, fsIfc) {
this.osImpl = osImpl;
this.fsIfc = fsIfc;
}
/**
* Validates the os level program is executable.
*/
async validateProgram() {
await _validateProgram(this.osImpl.getProgram(), this.fsIfc, isExe);
}
/**
* Returns a password using the native program for credential management.
*
* @param opts Options for the credential lookup.
* @param fn Callback function (err, password).
* @param retryCount Used internally to track the number of retries for getting a password out of the keychain.
*/
async getPassword(opts, fn, retryCount = 0) {
if (opts.service == null) {
fn(messages.createError('keyChainServiceRequiredError'));
return;
}
if (opts.account == null) {
fn(messages.createError('keyChainAccountRequiredError'));
return;
}
await this.validateProgram();
const credManager = this.osImpl.getCommandFunc(opts, childProcess.spawn);
let stdout = '';
let stderr = '';
if (credManager.stdout) {
credManager.stdout.on('data', (data) => {
stdout += data;
});
}
if (credManager.stderr) {
credManager.stderr.on('data', (data) => {
stderr += data;
});
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
credManager.on('close', async (code) => {
try {
return await this.osImpl.onGetCommandClose(code, stdout, stderr, opts, fn);
}
catch (e) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (e.retry) {
if (retryCount >= GET_PASSWORD_RETRY_COUNT) {
throw messages.createError('passwordRetryError', [GET_PASSWORD_RETRY_COUNT]);
}
return this.getPassword(opts, fn, retryCount + 1);
}
else {
// if retry
throw e;
}
}
});
if (credManager.stdin) {
credManager.stdin.end();
}
}
/**
* Sets a password using the native program for credential management.
*
* @param opts Options for the credential lookup.
* @param fn Callback function (err, ConfigContents).
*/
async setPassword(opts, fn) {
if (opts.service == null) {
fn(messages.createError('keyChainServiceRequiredError'));
return;
}
if (opts.account == null) {
fn(messages.createError('keyChainAccountRequiredError'));
return;
}
if (opts.password == null) {
fn(messages.createError('passwordRequiredError'));
return;
}
await _validateProgram(this.osImpl.getProgram(), this.fsIfc, isExe);
const credManager = this.osImpl.setCommandFunc(opts, childProcess.spawn);
let stdout = '';
let stderr = '';
if (credManager.stdout) {
credManager.stdout.on('data', (data) => {
stdout += data;
});
}
if (credManager.stderr) {
credManager.stderr.on('data', (data) => {
stderr += data;
});
}
credManager.on('close',
// eslint-disable-next-line @typescript-eslint/no-misused-promises
async (code) => this.osImpl.onSetCommandClose(code, stdout, stderr, opts, fn));
if (credManager.stdin) {
credManager.stdin.end();
}
}
}
exports.KeychainAccess = KeychainAccess;
/**
* Linux implementation.
*
* Uses libsecret.
*/
const linuxImpl = {
getProgram() {
return process.env.SFDX_SECRET_TOOL_PATH ?? path.join(path.sep, 'usr', 'bin', 'secret-tool');
},
getProgramOptions(opts) {
return ['lookup', 'user', opts.account, 'domain', opts.service];
},
getCommandFunc(opts, fn) {
return fn(linuxImpl.getProgram(), linuxImpl.getProgramOptions(opts));
},
// eslint-disable-next-line @typescript-eslint/require-await
async onGetCommandClose(code, stdout, stderr, opts, fn) {
if (code === 1) {
const command = `${linuxImpl.getProgram()} ${optionsToString(linuxImpl.getProgramOptions(opts))}`;
const error = messages.createError('passwordNotFoundError', [], [command]);
// This is a workaround for linux.
// Calling secret-tool too fast can cause it to return an unexpected error. (below)
if (stderr?.includes('invalid or unencryptable secret')) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: make an error subclass with this field
error.retry = true;
// Throwing here allows us to perform a retry in KeychainAccess
throw error;
}
// All other issues we will report back to the handler.
fn(error);
}
else {
fn(null, stdout.trim());
}
},
setProgramOptions(opts) {
return ['store', "--label='salesforce.com'", 'user', opts.account, 'domain', opts.service];
},
setCommandFunc(opts, fn) {
const secretTool = fn(linuxImpl.getProgram(), linuxImpl.setProgramOptions(opts));
if (secretTool.stdin) {
secretTool.stdin.write(`${opts.password}\n`);
}
return secretTool;
},
// eslint-disable-next-line @typescript-eslint/require-await
async onSetCommandClose(code, stdout, stderr, opts, fn) {
if (code !== 0) {
const command = `${linuxImpl.getProgram()} ${optionsToString(linuxImpl.setProgramOptions(opts))}`;
fn(messages.createError('setCredentialError', [`${stdout} - ${stderr}`], [os.userInfo().username, command]));
}
else {
fn(null);
}
},
};
/**
* OSX implementation.
*
* /usr/bin/security is a cli front end for OSX keychain.
*/
const darwinImpl = {
getProgram() {
return path.join(path.sep, 'usr', 'bin', 'security');
},
getProgramOptions(opts) {
return ['find-generic-password', '-a', opts.account, '-s', opts.service, '-g'];
},
getCommandFunc(opts, fn) {
return fn(darwinImpl.getProgram(), darwinImpl.getProgramOptions(opts));
},
// eslint-disable-next-line @typescript-eslint/require-await
async onGetCommandClose(code, stdout, stderr, opts, fn) {
let err;
if (code !== 0) {
switch (code) {
case 128: {
err = messages.createError('keyChainUserCanceledError');
break;
}
default: {
const command = `${darwinImpl.getProgram()} ${optionsToString(darwinImpl.getProgramOptions(opts))}`;
err = messages.createError('passwordNotFoundError', [`${stdout} - ${stderr}`], [command]);
}
}
fn(err);
return;
}
// For better or worse, the last line (containing the actual password) is actually written to stderr instead of
// stdout. Reference: http://blog.macromates.com/2006/keychain-access-from-shell/
if (stderr.includes('password')) {
const match = RegExp(/"(.*)"/).exec(stderr);
if (!match?.[1]) {
fn(messages.createError('passwordNotFoundError', [`${stdout} - ${stderr}`]));
}
else {
fn(null, match[1]);
}
}
else {
const command = `${darwinImpl.getProgram()} ${optionsToString(darwinImpl.getProgramOptions(opts))}`;
fn(messages.createError('passwordNotFoundError', [`${stdout} - ${stderr}`], [command]));
}
},
setProgramOptions(opts) {
const result = ['add-generic-password', '-a', opts.account, '-s', opts.service];
if (opts.password) {
result.push('-w', opts.password);
}
return result;
},
setCommandFunc(opts, fn) {
return fn(darwinImpl.getProgram(), darwinImpl.setProgramOptions(opts));
},
// eslint-disable-next-line @typescript-eslint/require-await
async onSetCommandClose(code, stdout, stderr, opts, fn) {
if (code !== 0) {
const command = `${darwinImpl.getProgram()} ${optionsToString(darwinImpl.setProgramOptions(opts))}`;
fn(messages.createError('setCredentialError', [`${stdout} - ${stderr}`], [os.userInfo().username, command]));
}
else {
fn(null);
}
},
};
const getSecretFile = () => path.join(global_1.Global.DIR, 'key.json');
var SecretField;
(function (SecretField) {
SecretField["SERVICE"] = "service";
SecretField["ACCOUNT"] = "account";
SecretField["KEY"] = "key";
})(SecretField || (SecretField = {}));
async function writeFile(opts, fn) {
try {
const contents = {
[SecretField.ACCOUNT]: opts.account,
[SecretField.KEY]: opts.password,
[SecretField.SERVICE]: opts.service,
};
const secretFile = getSecretFile();
await fs.promises.mkdir(path.dirname(secretFile), { recursive: true });
await fs.promises.writeFile(secretFile, JSON.stringify(contents, null, 4), { mode: '600' });
fn(null, contents);
}
catch (err) {
fn(err);
}
}
async function readFile() {
// The file and access is validated before this method is called
const fileContents = (0, kit_1.parseJsonMap)(await fs.promises.readFile(getSecretFile(), 'utf8'));
return {
account: (0, ts_types_1.ensureString)(fileContents[SecretField.ACCOUNT]),
password: (0, ts_types_1.asString)(fileContents[SecretField.KEY]),
service: (0, ts_types_1.ensureString)(fileContents[SecretField.SERVICE]),
};
}
// istanbul ignore next - getPassword/setPassword is always mocked out
/**
* @@ignore
*/
class GenericKeychainAccess {
async getPassword(opts, fn) {
// validate the file in .sfdx
await this.isValidFileAccess(async (fileAccessError) => {
// the file checks out.
if (fileAccessError == null) {
// read it's contents
try {
const { service, account, password } = await readFile();
// validate service name and account just because
if (opts.service === service && opts.account === account) {
fn(null, password);
}
else {
// if the service and account names don't match then maybe someone or something is editing
// that file. #donotallow
fn(messages.createError('genericKeychainServiceError', [getSecretFile()]));
}
}
catch (readJsonErr) {
fn(readJsonErr);
}
}
else if (fileAccessError.code === 'ENOENT') {
fn(messages.createError('passwordNotFoundError'));
}
else {
fn(fileAccessError);
}
});
}
async setPassword(opts, fn) {
// validate the file in .sfdx
await this.isValidFileAccess(async (fileAccessError) => {
// if there is a validation error
if (fileAccessError != null) {
// file not found
if (fileAccessError.code === 'ENOENT') {
// create the file
await writeFile.call(this, opts, fn);
}
else {
fn(fileAccessError);
}
}
else {
// the existing file validated. we can write the updated key
await writeFile.call(this, opts, fn);
}
});
}
// eslint-disable-next-line class-methods-use-this
async isValidFileAccess(cb) {
try {
const root = (0, node_os_1.homedir)();
await fs.promises.access(path.join(root, global_1.Global.SFDX_STATE_FOLDER), fs.constants.R_OK | fs.constants.X_OK | fs.constants.W_OK);
await cb(null);
}
catch (err) {
await cb(err);
}
}
}
exports.GenericKeychainAccess = GenericKeychainAccess;
/**
* @ignore
*/
// istanbul ignore next - getPassword/setPassword is always mocked out
class GenericUnixKeychainAccess extends GenericKeychainAccess {
async isValidFileAccess(cb) {
await super.isValidFileAccess(async (err) => {
if (err != null) {
await cb(err);
}
else {
const secretFile = getSecretFile();
const stats = await fs.promises.stat(secretFile);
const octalModeStr = (stats.mode & 0o777).toString(8);
const EXPECTED_OCTAL_PERM_VALUE = '600';
if (octalModeStr === EXPECTED_OCTAL_PERM_VALUE) {
await cb(null);
}
else {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
cb(messages.createError('genericKeychainInvalidPermsError', [secretFile], [secretFile, EXPECTED_OCTAL_PERM_VALUE]));
}
}
});
}
}
exports.GenericUnixKeychainAccess = GenericUnixKeychainAccess;
/**
* @ignore
*/
class GenericWindowsKeychainAccess extends GenericKeychainAccess {
async isValidFileAccess(cb) {
await super.isValidFileAccess(async (err) => {
if (err != null) {
await cb(err);
}
else {
try {
await fs.promises.access(getSecretFile(), fs.constants.R_OK | fs.constants.W_OK);
await cb(null);
}
catch (e) {
await cb(e);
}
}
});
}
}
exports.GenericWindowsKeychainAccess = GenericWindowsKeychainAccess;
/**
* @ignore
*/
exports.keyChainImpl = {
// eslint-disable-next-line camelcase
generic_unix: new GenericUnixKeychainAccess(),
// eslint-disable-next-line camelcase
generic_windows: new GenericWindowsKeychainAccess(),
darwin: new KeychainAccess(darwinImpl, nodeFs),
linux: new KeychainAccess(linuxImpl, nodeFs),
validateProgram: _validateProgram,
};
//# sourceMappingURL=keyChainImpl.js.map
;