@salesforce/core
Version:
Core libraries to interact with SFDX projects, orgs, and APIs.
480 lines • 17.7 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
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.keyChainImpl = exports.GenericWindowsKeychainAccess = exports.GenericUnixKeychainAccess = exports.GenericKeychainAccess = exports.KeychainAccess = void 0;
const childProcess = require("child_process");
const nodeFs = require("fs");
const fs = require("fs");
const os = require("os");
const os_1 = require("os");
const path = require("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");
messages_1.Messages.importMessagesDirectory(__dirname);
const messages = messages_1.Messages.load('@salesforce/core', 'encryption', [
'missingCredentialProgramError',
'credentialProgramAccessError',
'keyChainServiceRequiredError',
'keyChainAccountRequiredError',
'passwordRetryError',
'passwordRequiredError',
'passwordNotFoundError',
'setCredentialError',
'keyChainUserCanceledError',
'genericKeychainServiceError',
'genericKeychainInvalidPermsError',
]);
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.
*/
function _optionsToString(optionsArray) {
return optionsArray.reduce((accum, element) => `${accum} ${element}`);
}
/**
* 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) ||
(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.
*/
const _validateProgram = async (programPath, fsIfc, isExeIfc) => {
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 {
/**
* 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) {
// @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) => await 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));
},
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 != null && stderr.includes('invalid or unencryptable secret')) {
// @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;
},
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));
},
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 || !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));
},
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);
}
});
}
async isValidFileAccess(cb) {
try {
const root = (0, 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 {
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
;