From a8f7b5960a32bac74f98af18504b8274722f3e1f Mon Sep 17 00:00:00 2001 From: CrazyMax Date: Mon, 4 May 2020 16:17:14 +0200 Subject: [PATCH] Allow to seed the internal cache of gpg-agent with provided passphrase (#5) Better handling of commands output streams --- .github/workflows/ci.yml | 1 + README.md | 8 + __tests__/gpg.test.ts | 61 +- __tests__/openpgp.test.ts | 6 +- dist/index.js | 1392 ++++++++++++++++++++++++++++++++++++- src/exec.ts | 34 + src/gpg.ts | 149 +++- src/main.ts | 37 +- 8 files changed, 1612 insertions(+), 76 deletions(-) create mode 100644 src/exec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 998f315..ca5026f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,4 @@ jobs: uses: ./ env: SIGNING_KEY: ${{ secrets.SIGNING_KEY_TEST }} + PASSPHRASE: ${{ secrets.PASSPHRASE_TEST }} diff --git a/README.md b/README.md index 76c3f30..2cebd9b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ If you are interested, [check out](https://git.io/Je09Y) my other :octocat: GitH ![Import GPG key](.res/ghaction-import-gpg.png) +## Features + +* Works on Linux, MacOS and Windows [virtual environments](https://help.github.com/en/articles/virtual-environments-for-github-actions#supported-virtual-environments-and-hardware-resources) +* Allow to seed the internal cache of `gpg-agent` with provided passphrase +* Purge imported GPG key and cache information from runner (security) + ## Usage ```yaml @@ -33,6 +39,7 @@ jobs: uses: crazy-max/ghaction-import-gpg@master env: SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + PASSPHRASE: ${{ secrets.PASSPHRASE }} ``` ## Customizing @@ -44,6 +51,7 @@ Following environment variables can be used as `step.env` keys | Name | Description | |----------------|---------------------------------------| | `SIGNING_KEY` | GPG private key exported as an ASCII armored version | +| `PASSPHRASE` | Passphrase of your GPG key if setted for your `SIGNING_KEY` | ## How can I help? diff --git a/__tests__/gpg.test.ts b/__tests__/gpg.test.ts index b955b5e..34c34f0 100644 --- a/__tests__/gpg.test.ts +++ b/__tests__/gpg.test.ts @@ -1,5 +1,4 @@ -import {deleteKey, getVersion, importKey} from '../src/gpg'; -import * as child_process from 'child_process'; +import * as gpg from '../src/gpg'; const userInfo = { name: 'Joe Tester', @@ -8,6 +7,7 @@ const userInfo = { keyID: 'D523BD50DD70B0BA', userID: 'Joe Tester ', fingerprint: '27571A53B86AF0C799B38BA77D851EB72D73BDA0', + keygrip: 'BA83FC8947213477F28ADC019F6564A956456163', pgp: `-----BEGIN PGP PRIVATE KEY BLOCK----- lQdGBF6tzaABEACjFbX7PFEG6vDPN2MPyxYW7/3o/sonORj4HXUFjFxxJxktJ3x3 @@ -119,7 +119,7 @@ PejgXO0uIRolYQ3sz2tMGhx1MfBqH64= describe('gpg', () => { describe('getVersion', () => { it('returns GnuPG and libgcrypt version', async () => { - await getVersion().then(version => { + await gpg.getVersion().then(version => { console.log(version); expect(version.gnupg).not.toEqual(''); expect(version.libgcrypt).not.toEqual(''); @@ -127,25 +127,58 @@ describe('gpg', () => { }); }); + describe('getDirs', () => { + it('returns GnuPG dirs', async () => { + await gpg.getDirs().then(dirs => { + console.log(dirs); + expect(dirs.libdir).not.toEqual(''); + expect(dirs.datadir).not.toEqual(''); + expect(dirs.homedir).not.toEqual(''); + }); + }); + }); + describe('importKey', () => { it('imports key to GnuPG', async () => { - await importKey(userInfo.pgp).then(() => { - console.log( - child_process.execSync(`gpg --batch --list-keys --keyid-format LONG ${userInfo.email}`, {encoding: 'utf8'}) - ); - console.log( - child_process.execSync(`gpg --batch --list-secret-keys --keyid-format LONG ${userInfo.email}`, { - encoding: 'utf8' - }) - ); + await gpg.importKey(userInfo.pgp).then(output => { + console.log(output); + expect(output).not.toEqual(''); + }); + }); + }); + + describe('getKeygrip', () => { + it('returns the keygrip', async () => { + await gpg.importKey(userInfo.pgp); + await gpg.getKeygrip(userInfo.fingerprint).then(keygrip => { + console.log(keygrip); + expect(keygrip).toEqual(userInfo.keygrip); + }); + }); + }); + + describe('configureAgent', () => { + it('configures GnuPG agent', async () => { + await gpg.configureAgent(gpg.agentConfig); + }); + }); + + describe('presetPassphrase', () => { + it('presets passphrase', async () => { + await gpg.importKey(userInfo.pgp); + const keygrip = await gpg.getKeygrip(userInfo.fingerprint); + await gpg.configureAgent(gpg.agentConfig); + await gpg.presetPassphrase(keygrip, userInfo.passphrase).then(output => { + console.log(output); + expect(output).not.toEqual(''); }); }); }); describe('deleteKey', () => { it('removes key from GnuPG', async () => { - await importKey(userInfo.pgp); - await deleteKey(userInfo.fingerprint); + await gpg.importKey(userInfo.pgp); + await gpg.deleteKey(userInfo.fingerprint); }); }); }); diff --git a/__tests__/openpgp.test.ts b/__tests__/openpgp.test.ts index 443fbab..578d9f9 100644 --- a/__tests__/openpgp.test.ts +++ b/__tests__/openpgp.test.ts @@ -1,4 +1,4 @@ -import {readPrivateKey, generateKeyPair} from '../src/openpgp'; +import * as openpgp from '../src/openpgp'; const userInfo = { name: 'Joe Tester', @@ -118,7 +118,7 @@ PejgXO0uIRolYQ3sz2tMGhx1MfBqH64= describe('openpgp', () => { describe('readPrivateKey', () => { it('returns a PGP private key', async () => { - await readPrivateKey(userInfo.pgp).then(privateKey => { + await openpgp.readPrivateKey(userInfo.pgp).then(privateKey => { expect(privateKey.keyID).toEqual(userInfo.keyID); expect(privateKey.userID).toEqual(userInfo.userID); expect(privateKey.fingerprint).toEqual(userInfo.fingerprint); @@ -128,7 +128,7 @@ describe('openpgp', () => { describe('generateKeyPair', () => { it('generates a PGP key pair', async () => { - await generateKeyPair(userInfo.name, userInfo.email, userInfo.passphrase).then(keyPair => { + await openpgp.generateKeyPair(userInfo.name, userInfo.email, userInfo.passphrase).then(keyPair => { expect(keyPair).not.toBeUndefined(); expect(keyPair.publicKey).not.toBeUndefined(); expect(keyPair.privateKey).not.toBeUndefined(); diff --git a/dist/index.js b/dist/index.js index 05d5345..2daa75c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -43,6 +43,910 @@ module.exports = /************************************************************************/ /******/ ({ +/***/ 1: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const childProcess = __webpack_require__(129); +const path = __webpack_require__(622); +const util_1 = __webpack_require__(669); +const ioUtil = __webpack_require__(672); +const exec = util_1.promisify(childProcess.exec); +/** + * Copies a file or folder. + * Based off of shelljs - https://github.com/shelljs/shelljs/blob/9237f66c52e5daa40458f94f9565e18e8132f5a6/src/cp.js + * + * @param source source path + * @param dest destination path + * @param options optional. See CopyOptions. + */ +function cp(source, dest, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const { force, recursive } = readCopyOptions(options); + const destStat = (yield ioUtil.exists(dest)) ? yield ioUtil.stat(dest) : null; + // Dest is an existing file, but not forcing + if (destStat && destStat.isFile() && !force) { + return; + } + // If dest is an existing directory, should copy inside. + const newDest = destStat && destStat.isDirectory() + ? path.join(dest, path.basename(source)) + : dest; + if (!(yield ioUtil.exists(source))) { + throw new Error(`no such file or directory: ${source}`); + } + const sourceStat = yield ioUtil.stat(source); + if (sourceStat.isDirectory()) { + if (!recursive) { + throw new Error(`Failed to copy. ${source} is a directory, but tried to copy without recursive flag.`); + } + else { + yield cpDirRecursive(source, newDest, 0, force); + } + } + else { + if (path.relative(source, newDest) === '') { + // a file cannot be copied to itself + throw new Error(`'${newDest}' and '${source}' are the same file`); + } + yield copyFile(source, newDest, force); + } + }); +} +exports.cp = cp; +/** + * Moves a path. + * + * @param source source path + * @param dest destination path + * @param options optional. See MoveOptions. + */ +function mv(source, dest, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + if (yield ioUtil.exists(dest)) { + let destExists = true; + if (yield ioUtil.isDirectory(dest)) { + // If dest is directory copy src into dest + dest = path.join(dest, path.basename(source)); + destExists = yield ioUtil.exists(dest); + } + if (destExists) { + if (options.force == null || options.force) { + yield rmRF(dest); + } + else { + throw new Error('Destination already exists'); + } + } + } + yield mkdirP(path.dirname(dest)); + yield ioUtil.rename(source, dest); + }); +} +exports.mv = mv; +/** + * Remove a path recursively with force + * + * @param inputPath path to remove + */ +function rmRF(inputPath) { + return __awaiter(this, void 0, void 0, function* () { + if (ioUtil.IS_WINDOWS) { + // Node doesn't provide a delete operation, only an unlink function. This means that if the file is being used by another + // program (e.g. antivirus), it won't be deleted. To address this, we shell out the work to rd/del. + try { + if (yield ioUtil.isDirectory(inputPath, true)) { + yield exec(`rd /s /q "${inputPath}"`); + } + else { + yield exec(`del /f /a "${inputPath}"`); + } + } + catch (err) { + // if you try to delete a file that doesn't exist, desired result is achieved + // other errors are valid + if (err.code !== 'ENOENT') + throw err; + } + // Shelling out fails to remove a symlink folder with missing source, this unlink catches that + try { + yield ioUtil.unlink(inputPath); + } + catch (err) { + // if you try to delete a file that doesn't exist, desired result is achieved + // other errors are valid + if (err.code !== 'ENOENT') + throw err; + } + } + else { + let isDir = false; + try { + isDir = yield ioUtil.isDirectory(inputPath); + } + catch (err) { + // if you try to delete a file that doesn't exist, desired result is achieved + // other errors are valid + if (err.code !== 'ENOENT') + throw err; + return; + } + if (isDir) { + yield exec(`rm -rf "${inputPath}"`); + } + else { + yield ioUtil.unlink(inputPath); + } + } + }); +} +exports.rmRF = rmRF; +/** + * Make a directory. Creates the full path with folders in between + * Will throw if it fails + * + * @param fsPath path to create + * @returns Promise + */ +function mkdirP(fsPath) { + return __awaiter(this, void 0, void 0, function* () { + yield ioUtil.mkdirP(fsPath); + }); +} +exports.mkdirP = mkdirP; +/** + * Returns path of a tool had the tool actually been invoked. Resolves via paths. + * If you check and the tool does not exist, it will throw. + * + * @param tool name of the tool + * @param check whether to check if tool exists + * @returns Promise path to tool + */ +function which(tool, check) { + return __awaiter(this, void 0, void 0, function* () { + if (!tool) { + throw new Error("parameter 'tool' is required"); + } + // recursive when check=true + if (check) { + const result = yield which(tool, false); + if (!result) { + if (ioUtil.IS_WINDOWS) { + throw new Error(`Unable to locate executable file: ${tool}. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also verify the file has a valid extension for an executable file.`); + } + else { + throw new Error(`Unable to locate executable file: ${tool}. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also check the file mode to verify the file is executable.`); + } + } + } + try { + // build the list of extensions to try + const extensions = []; + if (ioUtil.IS_WINDOWS && process.env.PATHEXT) { + for (const extension of process.env.PATHEXT.split(path.delimiter)) { + if (extension) { + extensions.push(extension); + } + } + } + // if it's rooted, return it if exists. otherwise return empty. + if (ioUtil.isRooted(tool)) { + const filePath = yield ioUtil.tryGetExecutablePath(tool, extensions); + if (filePath) { + return filePath; + } + return ''; + } + // if any path separators, return empty + if (tool.includes('/') || (ioUtil.IS_WINDOWS && tool.includes('\\'))) { + return ''; + } + // build the list of directories + // + // Note, technically "where" checks the current directory on Windows. From a toolkit perspective, + // it feels like we should not do this. Checking the current directory seems like more of a use + // case of a shell, and the which() function exposed by the toolkit should strive for consistency + // across platforms. + const directories = []; + if (process.env.PATH) { + for (const p of process.env.PATH.split(path.delimiter)) { + if (p) { + directories.push(p); + } + } + } + // return the first match + for (const directory of directories) { + const filePath = yield ioUtil.tryGetExecutablePath(directory + path.sep + tool, extensions); + if (filePath) { + return filePath; + } + } + return ''; + } + catch (err) { + throw new Error(`which failed with message ${err.message}`); + } + }); +} +exports.which = which; +function readCopyOptions(options) { + const force = options.force == null ? true : options.force; + const recursive = Boolean(options.recursive); + return { force, recursive }; +} +function cpDirRecursive(sourceDir, destDir, currentDepth, force) { + return __awaiter(this, void 0, void 0, function* () { + // Ensure there is not a run away recursive copy + if (currentDepth >= 255) + return; + currentDepth++; + yield mkdirP(destDir); + const files = yield ioUtil.readdir(sourceDir); + for (const fileName of files) { + const srcFile = `${sourceDir}/${fileName}`; + const destFile = `${destDir}/${fileName}`; + const srcFileStat = yield ioUtil.lstat(srcFile); + if (srcFileStat.isDirectory()) { + // Recurse + yield cpDirRecursive(srcFile, destFile, currentDepth, force); + } + else { + yield copyFile(srcFile, destFile, force); + } + } + // Change the mode for the newly created directory + yield ioUtil.chmod(destDir, (yield ioUtil.stat(sourceDir)).mode); + }); +} +// Buffered file copy +function copyFile(srcFile, destFile, force) { + return __awaiter(this, void 0, void 0, function* () { + if ((yield ioUtil.lstat(srcFile)).isSymbolicLink()) { + // unlink/re-link it + try { + yield ioUtil.lstat(destFile); + yield ioUtil.unlink(destFile); + } + catch (e) { + // Try to override file permission + if (e.code === 'EPERM') { + yield ioUtil.chmod(destFile, '0666'); + yield ioUtil.unlink(destFile); + } + // other errors = it doesn't exist, no work to do + } + // Copy over symlink + const symlinkFull = yield ioUtil.readlink(srcFile); + yield ioUtil.symlink(symlinkFull, destFile, ioUtil.IS_WINDOWS ? 'junction' : null); + } + else if (!(yield ioUtil.exists(destFile)) || force) { + yield ioUtil.copyFile(srcFile, destFile); + } + }); +} +//# sourceMappingURL=io.js.map + +/***/ }), + +/***/ 9: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const os = __importStar(__webpack_require__(87)); +const events = __importStar(__webpack_require__(614)); +const child = __importStar(__webpack_require__(129)); +const path = __importStar(__webpack_require__(622)); +const io = __importStar(__webpack_require__(1)); +const ioUtil = __importStar(__webpack_require__(672)); +/* eslint-disable @typescript-eslint/unbound-method */ +const IS_WINDOWS = process.platform === 'win32'; +/* + * Class for running command line tools. Handles quoting and arg parsing in a platform agnostic way. + */ +class ToolRunner extends events.EventEmitter { + constructor(toolPath, args, options) { + super(); + if (!toolPath) { + throw new Error("Parameter 'toolPath' cannot be null or empty."); + } + this.toolPath = toolPath; + this.args = args || []; + this.options = options || {}; + } + _debug(message) { + if (this.options.listeners && this.options.listeners.debug) { + this.options.listeners.debug(message); + } + } + _getCommandString(options, noPrefix) { + const toolPath = this._getSpawnFileName(); + const args = this._getSpawnArgs(options); + let cmd = noPrefix ? '' : '[command]'; // omit prefix when piped to a second tool + if (IS_WINDOWS) { + // Windows + cmd file + if (this._isCmdFile()) { + cmd += toolPath; + for (const a of args) { + cmd += ` ${a}`; + } + } + // Windows + verbatim + else if (options.windowsVerbatimArguments) { + cmd += `"${toolPath}"`; + for (const a of args) { + cmd += ` ${a}`; + } + } + // Windows (regular) + else { + cmd += this._windowsQuoteCmdArg(toolPath); + for (const a of args) { + cmd += ` ${this._windowsQuoteCmdArg(a)}`; + } + } + } + else { + // OSX/Linux - this can likely be improved with some form of quoting. + // creating processes on Unix is fundamentally different than Windows. + // on Unix, execvp() takes an arg array. + cmd += toolPath; + for (const a of args) { + cmd += ` ${a}`; + } + } + return cmd; + } + _processLineBuffer(data, strBuffer, onLine) { + try { + let s = strBuffer + data.toString(); + let n = s.indexOf(os.EOL); + while (n > -1) { + const line = s.substring(0, n); + onLine(line); + // the rest of the string ... + s = s.substring(n + os.EOL.length); + n = s.indexOf(os.EOL); + } + strBuffer = s; + } + catch (err) { + // streaming lines to console is best effort. Don't fail a build. + this._debug(`error processing line. Failed with error ${err}`); + } + } + _getSpawnFileName() { + if (IS_WINDOWS) { + if (this._isCmdFile()) { + return process.env['COMSPEC'] || 'cmd.exe'; + } + } + return this.toolPath; + } + _getSpawnArgs(options) { + if (IS_WINDOWS) { + if (this._isCmdFile()) { + let argline = `/D /S /C "${this._windowsQuoteCmdArg(this.toolPath)}`; + for (const a of this.args) { + argline += ' '; + argline += options.windowsVerbatimArguments + ? a + : this._windowsQuoteCmdArg(a); + } + argline += '"'; + return [argline]; + } + } + return this.args; + } + _endsWith(str, end) { + return str.endsWith(end); + } + _isCmdFile() { + const upperToolPath = this.toolPath.toUpperCase(); + return (this._endsWith(upperToolPath, '.CMD') || + this._endsWith(upperToolPath, '.BAT')); + } + _windowsQuoteCmdArg(arg) { + // for .exe, apply the normal quoting rules that libuv applies + if (!this._isCmdFile()) { + return this._uvQuoteCmdArg(arg); + } + // otherwise apply quoting rules specific to the cmd.exe command line parser. + // the libuv rules are generic and are not designed specifically for cmd.exe + // command line parser. + // + // for a detailed description of the cmd.exe command line parser, refer to + // http://stackoverflow.com/questions/4094699/how-does-the-windows-command-interpreter-cmd-exe-parse-scripts/7970912#7970912 + // need quotes for empty arg + if (!arg) { + return '""'; + } + // determine whether the arg needs to be quoted + const cmdSpecialChars = [ + ' ', + '\t', + '&', + '(', + ')', + '[', + ']', + '{', + '}', + '^', + '=', + ';', + '!', + "'", + '+', + ',', + '`', + '~', + '|', + '<', + '>', + '"' + ]; + let needsQuotes = false; + for (const char of arg) { + if (cmdSpecialChars.some(x => x === char)) { + needsQuotes = true; + break; + } + } + // short-circuit if quotes not needed + if (!needsQuotes) { + return arg; + } + // the following quoting rules are very similar to the rules that by libuv applies. + // + // 1) wrap the string in quotes + // + // 2) double-up quotes - i.e. " => "" + // + // this is different from the libuv quoting rules. libuv replaces " with \", which unfortunately + // doesn't work well with a cmd.exe command line. + // + // note, replacing " with "" also works well if the arg is passed to a downstream .NET console app. + // for example, the command line: + // foo.exe "myarg:""my val""" + // is parsed by a .NET console app into an arg array: + // [ "myarg:\"my val\"" ] + // which is the same end result when applying libuv quoting rules. although the actual + // command line from libuv quoting rules would look like: + // foo.exe "myarg:\"my val\"" + // + // 3) double-up slashes that precede a quote, + // e.g. hello \world => "hello \world" + // hello\"world => "hello\\""world" + // hello\\"world => "hello\\\\""world" + // hello world\ => "hello world\\" + // + // technically this is not required for a cmd.exe command line, or the batch argument parser. + // the reasons for including this as a .cmd quoting rule are: + // + // a) this is optimized for the scenario where the argument is passed from the .cmd file to an + // external program. many programs (e.g. .NET console apps) rely on the slash-doubling rule. + // + // b) it's what we've been doing previously (by deferring to node default behavior) and we + // haven't heard any complaints about that aspect. + // + // note, a weakness of the quoting rules chosen here, is that % is not escaped. in fact, % cannot be + // escaped when used on the command line directly - even though within a .cmd file % can be escaped + // by using %%. + // + // the saving grace is, on the command line, %var% is left as-is if var is not defined. this contrasts + // the line parsing rules within a .cmd file, where if var is not defined it is replaced with nothing. + // + // one option that was explored was replacing % with ^% - i.e. %var% => ^%var^%. this hack would + // often work, since it is unlikely that var^ would exist, and the ^ character is removed when the + // variable is used. the problem, however, is that ^ is not removed when %* is used to pass the args + // to an external program. + // + // an unexplored potential solution for the % escaping problem, is to create a wrapper .cmd file. + // % can be escaped within a .cmd file. + let reverse = '"'; + let quoteHit = true; + for (let i = arg.length; i > 0; i--) { + // walk the string in reverse + reverse += arg[i - 1]; + if (quoteHit && arg[i - 1] === '\\') { + reverse += '\\'; // double the slash + } + else if (arg[i - 1] === '"') { + quoteHit = true; + reverse += '"'; // double the quote + } + else { + quoteHit = false; + } + } + reverse += '"'; + return reverse + .split('') + .reverse() + .join(''); + } + _uvQuoteCmdArg(arg) { + // Tool runner wraps child_process.spawn() and needs to apply the same quoting as + // Node in certain cases where the undocumented spawn option windowsVerbatimArguments + // is used. + // + // Since this function is a port of quote_cmd_arg from Node 4.x (technically, lib UV, + // see https://github.com/nodejs/node/blob/v4.x/deps/uv/src/win/process.c for details), + // pasting copyright notice from Node within this function: + // + // Copyright Joyent, Inc. and other Node contributors. All rights reserved. + // + // Permission is hereby granted, free of charge, to any person obtaining a copy + // of this software and associated documentation files (the "Software"), to + // deal in the Software without restriction, including without limitation the + // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + // sell copies of the Software, and to permit persons to whom the Software is + // furnished to do so, subject to the following conditions: + // + // The above copyright notice and this permission notice shall be included in + // all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + // IN THE SOFTWARE. + if (!arg) { + // Need double quotation for empty argument + return '""'; + } + if (!arg.includes(' ') && !arg.includes('\t') && !arg.includes('"')) { + // No quotation needed + return arg; + } + if (!arg.includes('"') && !arg.includes('\\')) { + // No embedded double quotes or backslashes, so I can just wrap + // quote marks around the whole thing. + return `"${arg}"`; + } + // Expected input/output: + // input : hello"world + // output: "hello\"world" + // input : hello""world + // output: "hello\"\"world" + // input : hello\world + // output: hello\world + // input : hello\\world + // output: hello\\world + // input : hello\"world + // output: "hello\\\"world" + // input : hello\\"world + // output: "hello\\\\\"world" + // input : hello world\ + // output: "hello world\\" - note the comment in libuv actually reads "hello world\" + // but it appears the comment is wrong, it should be "hello world\\" + let reverse = '"'; + let quoteHit = true; + for (let i = arg.length; i > 0; i--) { + // walk the string in reverse + reverse += arg[i - 1]; + if (quoteHit && arg[i - 1] === '\\') { + reverse += '\\'; + } + else if (arg[i - 1] === '"') { + quoteHit = true; + reverse += '\\'; + } + else { + quoteHit = false; + } + } + reverse += '"'; + return reverse + .split('') + .reverse() + .join(''); + } + _cloneExecOptions(options) { + options = options || {}; + const result = { + cwd: options.cwd || process.cwd(), + env: options.env || process.env, + silent: options.silent || false, + windowsVerbatimArguments: options.windowsVerbatimArguments || false, + failOnStdErr: options.failOnStdErr || false, + ignoreReturnCode: options.ignoreReturnCode || false, + delay: options.delay || 10000 + }; + result.outStream = options.outStream || process.stdout; + result.errStream = options.errStream || process.stderr; + return result; + } + _getSpawnOptions(options, toolPath) { + options = options || {}; + const result = {}; + result.cwd = options.cwd; + result.env = options.env; + result['windowsVerbatimArguments'] = + options.windowsVerbatimArguments || this._isCmdFile(); + if (options.windowsVerbatimArguments) { + result.argv0 = `"${toolPath}"`; + } + return result; + } + /** + * Exec a tool. + * Output will be streamed to the live console. + * Returns promise with return code + * + * @param tool path to tool to exec + * @param options optional exec options. See ExecOptions + * @returns number + */ + exec() { + return __awaiter(this, void 0, void 0, function* () { + // root the tool path if it is unrooted and contains relative pathing + if (!ioUtil.isRooted(this.toolPath) && + (this.toolPath.includes('/') || + (IS_WINDOWS && this.toolPath.includes('\\')))) { + // prefer options.cwd if it is specified, however options.cwd may also need to be rooted + this.toolPath = path.resolve(process.cwd(), this.options.cwd || process.cwd(), this.toolPath); + } + // if the tool is only a file name, then resolve it from the PATH + // otherwise verify it exists (add extension on Windows if necessary) + this.toolPath = yield io.which(this.toolPath, true); + return new Promise((resolve, reject) => { + this._debug(`exec tool: ${this.toolPath}`); + this._debug('arguments:'); + for (const arg of this.args) { + this._debug(` ${arg}`); + } + const optionsNonNull = this._cloneExecOptions(this.options); + if (!optionsNonNull.silent && optionsNonNull.outStream) { + optionsNonNull.outStream.write(this._getCommandString(optionsNonNull) + os.EOL); + } + const state = new ExecState(optionsNonNull, this.toolPath); + state.on('debug', (message) => { + this._debug(message); + }); + const fileName = this._getSpawnFileName(); + const cp = child.spawn(fileName, this._getSpawnArgs(optionsNonNull), this._getSpawnOptions(this.options, fileName)); + const stdbuffer = ''; + if (cp.stdout) { + cp.stdout.on('data', (data) => { + if (this.options.listeners && this.options.listeners.stdout) { + this.options.listeners.stdout(data); + } + if (!optionsNonNull.silent && optionsNonNull.outStream) { + optionsNonNull.outStream.write(data); + } + this._processLineBuffer(data, stdbuffer, (line) => { + if (this.options.listeners && this.options.listeners.stdline) { + this.options.listeners.stdline(line); + } + }); + }); + } + const errbuffer = ''; + if (cp.stderr) { + cp.stderr.on('data', (data) => { + state.processStderr = true; + if (this.options.listeners && this.options.listeners.stderr) { + this.options.listeners.stderr(data); + } + if (!optionsNonNull.silent && + optionsNonNull.errStream && + optionsNonNull.outStream) { + const s = optionsNonNull.failOnStdErr + ? optionsNonNull.errStream + : optionsNonNull.outStream; + s.write(data); + } + this._processLineBuffer(data, errbuffer, (line) => { + if (this.options.listeners && this.options.listeners.errline) { + this.options.listeners.errline(line); + } + }); + }); + } + cp.on('error', (err) => { + state.processError = err.message; + state.processExited = true; + state.processClosed = true; + state.CheckComplete(); + }); + cp.on('exit', (code) => { + state.processExitCode = code; + state.processExited = true; + this._debug(`Exit code ${code} received from tool '${this.toolPath}'`); + state.CheckComplete(); + }); + cp.on('close', (code) => { + state.processExitCode = code; + state.processExited = true; + state.processClosed = true; + this._debug(`STDIO streams have closed for tool '${this.toolPath}'`); + state.CheckComplete(); + }); + state.on('done', (error, exitCode) => { + if (stdbuffer.length > 0) { + this.emit('stdline', stdbuffer); + } + if (errbuffer.length > 0) { + this.emit('errline', errbuffer); + } + cp.removeAllListeners(); + if (error) { + reject(error); + } + else { + resolve(exitCode); + } + }); + if (this.options.input) { + if (!cp.stdin) { + throw new Error('child process missing stdin'); + } + cp.stdin.end(this.options.input); + } + }); + }); + } +} +exports.ToolRunner = ToolRunner; +/** + * Convert an arg string to an array of args. Handles escaping + * + * @param argString string of arguments + * @returns string[] array of arguments + */ +function argStringToArray(argString) { + const args = []; + let inQuotes = false; + let escaped = false; + let arg = ''; + function append(c) { + // we only escape double quotes. + if (escaped && c !== '"') { + arg += '\\'; + } + arg += c; + escaped = false; + } + for (let i = 0; i < argString.length; i++) { + const c = argString.charAt(i); + if (c === '"') { + if (!escaped) { + inQuotes = !inQuotes; + } + else { + append(c); + } + continue; + } + if (c === '\\' && escaped) { + append(c); + continue; + } + if (c === '\\' && inQuotes) { + escaped = true; + continue; + } + if (c === ' ' && !inQuotes) { + if (arg.length > 0) { + args.push(arg); + arg = ''; + } + continue; + } + append(c); + } + if (arg.length > 0) { + args.push(arg.trim()); + } + return args; +} +exports.argStringToArray = argStringToArray; +class ExecState extends events.EventEmitter { + constructor(options, toolPath) { + super(); + this.processClosed = false; // tracks whether the process has exited and stdio is closed + this.processError = ''; + this.processExitCode = 0; + this.processExited = false; // tracks whether the process has exited + this.processStderr = false; // tracks whether stderr was written to + this.delay = 10000; // 10 seconds + this.done = false; + this.timeout = null; + if (!toolPath) { + throw new Error('toolPath must not be empty'); + } + this.options = options; + this.toolPath = toolPath; + if (options.delay) { + this.delay = options.delay; + } + } + CheckComplete() { + if (this.done) { + return; + } + if (this.processClosed) { + this._setResult(); + } + else if (this.processExited) { + this.timeout = setTimeout(ExecState.HandleTimeout, this.delay, this); + } + } + _debug(message) { + this.emit('debug', message); + } + _setResult() { + // determine whether there is an error + let error; + if (this.processExited) { + if (this.processError) { + error = new Error(`There was an error when attempting to execute the process '${this.toolPath}'. This may indicate the process failed to start. Error: ${this.processError}`); + } + else if (this.processExitCode !== 0 && !this.options.ignoreReturnCode) { + error = new Error(`The process '${this.toolPath}' failed with exit code ${this.processExitCode}`); + } + else if (this.processStderr && this.options.failOnStdErr) { + error = new Error(`The process '${this.toolPath}' failed because one or more lines were written to the STDERR stream`); + } + } + // clear the timeout + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.done = true; + this.emit('done', error, this.processExitCode); + } + static HandleTimeout(state) { + if (state.done) { + return; + } + if (!state.processClosed && state.processExited) { + const message = `The STDIO streams did not close within ${state.delay / + 1000} seconds of the exit event from process '${state.toolPath}'. This may indicate a child process inherited the STDIO streams and has not yet exited.`; + state._debug(message); + } + state._setResult(); + } +} +//# sourceMappingURL=toolrunner.js.map + +/***/ }), + /***/ 87: /***/ (function(module) { @@ -120,16 +1024,32 @@ function run() { } core.info('📣 GnuPG info'); const version = yield gpg.getVersion(); - core.info(`GnuPG version: ${version.gnupg}`); - core.info(`libgcrypt version: ${version.libgcrypt}`); + const dirs = yield gpg.getDirs(); + core.info(`Version : ${version.gnupg} (libgcrypt ${version.libgcrypt})`); + core.info(`Homedir : ${dirs.homedir}`); + core.info(`Datadir : ${dirs.datadir}`); + core.info(`Libdir : ${dirs.libdir}`); core.info('🔮 Checking signing key...'); const privateKey = yield openpgp.readPrivateKey(process.env.SIGNING_KEY); - core.debug(`key.fingerprint=${privateKey.fingerprint}`); - core.debug(`key.keyID=${privateKey.keyID}`); - core.debug(`key.userID=${privateKey.userID}`); - core.debug(`key.creationTime=${privateKey.creationTime}`); + core.debug(`Fingerprint : ${privateKey.fingerprint}`); + core.debug(`KeyID : ${privateKey.keyID}`); + core.debug(`UserID : ${privateKey.userID}`); + core.debug(`CreationTime : ${privateKey.creationTime}`); core.info('🔑 Importing secret key...'); - yield gpg.importKey(process.env.SIGNING_KEY); + yield gpg.importKey(process.env.SIGNING_KEY).then(stdout => { + core.debug(stdout); + }); + if (process.env.PASSPHRASE) { + core.info('⚙️ Configuring GnuPG agent...'); + yield gpg.configureAgent(gpg.agentConfig); + core.info('📌 Getting keygrip...'); + const keygrip = yield gpg.getKeygrip(privateKey.fingerprint); + core.debug(`${keygrip}`); + core.info('🔓 Preset passphrase...'); + yield gpg.presetPassphrase(keygrip, process.env.PASSPHRASE).then(stdout => { + core.debug(stdout); + }); + } } catch (error) { core.setFailed(error.message); @@ -139,11 +1059,11 @@ function run() { function cleanup() { return __awaiter(this, void 0, void 0, function* () { if (!process.env.SIGNING_KEY) { - core.debug('Private key is not defined. Skipping cleanup.'); + core.debug('Signing key is not defined. Skipping cleanup.'); return; } try { - core.info('🚿 Removing keys from GnuPG...'); + core.info('🚿 Removing keys...'); const privateKey = yield openpgp.readPrivateKey(process.env.SIGNING_KEY); yield gpg.deleteKey(privateKey.fingerprint); } @@ -186,22 +1106,21 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", { value: true }); -const child_process = __importStar(__webpack_require__(129)); const fs = __importStar(__webpack_require__(747)); const path = __importStar(__webpack_require__(622)); const os = __importStar(__webpack_require__(87)); -const gpg = (args = []) => __awaiter(void 0, void 0, void 0, function* () { - return child_process - .execSync(`gpg ${args.join(' ')}`, { - encoding: 'utf8' - }) - .trim(); -}); +const exec = __importStar(__webpack_require__(807)); +exports.agentConfig = `default-cache-ttl 1 +max-cache-ttl 31536000 +allow-preset-passphrase`; exports.getVersion = () => __awaiter(void 0, void 0, void 0, function* () { - let gnupgVersion = ''; - let libgcryptVersion = ''; - yield gpg(['--version']).then(stdout => { - for (let line of stdout.replace(/\r/g, '').trim().split(/\n/g)) { + return yield exec.exec('gpg', ['--version'], true).then(res => { + if (res.stderr != '') { + throw new Error(res.stderr); + } + let gnupgVersion = ''; + let libgcryptVersion = ''; + for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) { if (line.startsWith('gpg (GnuPG) ')) { gnupgVersion = line.substr('gpg (GnuPG) '.length).trim(); } @@ -212,26 +1131,127 @@ exports.getVersion = () => __awaiter(void 0, void 0, void 0, function* () { libgcryptVersion = line.substr('libgcrypt '.length).trim(); } } + return { + gnupg: gnupgVersion, + libgcrypt: libgcryptVersion + }; + }); +}); +exports.getDirs = () => __awaiter(void 0, void 0, void 0, function* () { + return yield exec.exec('gpgconf', ['--list-dirs'], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + let libdir = ''; + let datadir = ''; + let homedir = ''; + for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) { + if (line.startsWith('libdir:')) { + libdir = line.substr('libdir:'.length).replace('%3a', ':').trim(); + } + else if (line.startsWith('datadir:')) { + datadir = line.substr('datadir:'.length).replace('%3a', ':').trim(); + } + else if (line.startsWith('homedir:')) { + homedir = line.substr('homedir:'.length).replace('%3a', ':').trim(); + } + } + return { + libdir: path.normalize(libdir), + datadir: path.normalize(datadir), + homedir: path.normalize(homedir) + }; }); - return { - gnupg: gnupgVersion, - libgcrypt: libgcryptVersion - }; }); exports.importKey = (armoredText) => __awaiter(void 0, void 0, void 0, function* () { const keyFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'ghaction-import-gpg-')); const keyPath = `${keyFolder}/key.pgp`; fs.writeFileSync(keyPath, armoredText, { mode: 0o600 }); - yield gpg(['--import', '--batch', '--yes', keyPath]).finally(() => { + return yield exec + .exec('gpg', ['--import', '--batch', '--yes', keyPath], true) + .then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + if (res.stderr != '') { + return res.stderr.trim(); + } + return res.stdout.trim(); + }) + .finally(() => { fs.unlinkSync(keyPath); }); }); +exports.getKeygrip = (fingerprint) => __awaiter(void 0, void 0, void 0, function* () { + return yield exec + .exec('gpg', ['--batch', '--with-colons', '--with-keygrip', '--list-secret-keys', fingerprint], true) + .then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + let keygrip = ''; + for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) { + if (line.startsWith('grp')) { + keygrip = line.replace(/(grp|:)/g, '').trim(); + } + } + return keygrip; + }); +}); +exports.configureAgent = (config) => __awaiter(void 0, void 0, void 0, function* () { + const { homedir: homedir } = yield exports.getDirs(); + const gpgAgentConf = path.join(homedir, 'gpg-agent.conf'); + yield fs.writeFile(gpgAgentConf, config, function (err) { + if (err) + throw err; + }); + yield exec.exec(`gpg-connect-agent "RELOADAGENT" /bye`, [], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + }); +}); +exports.presetPassphrase = (keygrip, passphrase) => __awaiter(void 0, void 0, void 0, function* () { + yield exec + .exec('gpg-preset-passphrase', ['--verbose', '--preset', '--passphrase', `"${passphrase}"`, keygrip], true) + .then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + }); + return yield exec.exec(`gpg-connect-agent "KEYINFO ${keygrip}" /bye`, [], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) { + if (line.startsWith('ERR')) { + throw new Error(line); + } + } + return res.stdout.trim(); + }); +}); exports.deleteKey = (fingerprint) => __awaiter(void 0, void 0, void 0, function* () { - yield gpg(['--batch', '--yes', ' --delete-secret-keys', fingerprint]); - yield gpg(['--batch', '--yes', ' --delete-keys', fingerprint]); + yield exec.exec('gpg', ['--batch', '--yes', '--delete-secret-keys', fingerprint], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + }); + yield exec.exec('gpg', ['--batch', '--yes', '--delete-keys', fingerprint], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + }); }); +/***/ }), + +/***/ 357: +/***/ (function(module) { + +module.exports = require("assert"); + /***/ }), /***/ 431: @@ -562,6 +1582,13 @@ exports.getState = getState; /***/ }), +/***/ 614: +/***/ (function(module) { + +module.exports = require("events"); + +/***/ }), + /***/ 622: /***/ (function(module) { @@ -569,6 +1596,215 @@ module.exports = require("path"); /***/ }), +/***/ 669: +/***/ (function(module) { + +module.exports = require("util"); + +/***/ }), + +/***/ 672: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var _a; +Object.defineProperty(exports, "__esModule", { value: true }); +const assert_1 = __webpack_require__(357); +const fs = __webpack_require__(747); +const path = __webpack_require__(622); +_a = fs.promises, exports.chmod = _a.chmod, exports.copyFile = _a.copyFile, exports.lstat = _a.lstat, exports.mkdir = _a.mkdir, exports.readdir = _a.readdir, exports.readlink = _a.readlink, exports.rename = _a.rename, exports.rmdir = _a.rmdir, exports.stat = _a.stat, exports.symlink = _a.symlink, exports.unlink = _a.unlink; +exports.IS_WINDOWS = process.platform === 'win32'; +function exists(fsPath) { + return __awaiter(this, void 0, void 0, function* () { + try { + yield exports.stat(fsPath); + } + catch (err) { + if (err.code === 'ENOENT') { + return false; + } + throw err; + } + return true; + }); +} +exports.exists = exists; +function isDirectory(fsPath, useStat = false) { + return __awaiter(this, void 0, void 0, function* () { + const stats = useStat ? yield exports.stat(fsPath) : yield exports.lstat(fsPath); + return stats.isDirectory(); + }); +} +exports.isDirectory = isDirectory; +/** + * On OSX/Linux, true if path starts with '/'. On Windows, true for paths like: + * \, \hello, \\hello\share, C:, and C:\hello (and corresponding alternate separator cases). + */ +function isRooted(p) { + p = normalizeSeparators(p); + if (!p) { + throw new Error('isRooted() parameter "p" cannot be empty'); + } + if (exports.IS_WINDOWS) { + return (p.startsWith('\\') || /^[A-Z]:/i.test(p) // e.g. \ or \hello or \\hello + ); // e.g. C: or C:\hello + } + return p.startsWith('/'); +} +exports.isRooted = isRooted; +/** + * Recursively create a directory at `fsPath`. + * + * This implementation is optimistic, meaning it attempts to create the full + * path first, and backs up the path stack from there. + * + * @param fsPath The path to create + * @param maxDepth The maximum recursion depth + * @param depth The current recursion depth + */ +function mkdirP(fsPath, maxDepth = 1000, depth = 1) { + return __awaiter(this, void 0, void 0, function* () { + assert_1.ok(fsPath, 'a path argument must be provided'); + fsPath = path.resolve(fsPath); + if (depth >= maxDepth) + return exports.mkdir(fsPath); + try { + yield exports.mkdir(fsPath); + return; + } + catch (err) { + switch (err.code) { + case 'ENOENT': { + yield mkdirP(path.dirname(fsPath), maxDepth, depth + 1); + yield exports.mkdir(fsPath); + return; + } + default: { + let stats; + try { + stats = yield exports.stat(fsPath); + } + catch (err2) { + throw err; + } + if (!stats.isDirectory()) + throw err; + } + } + } + }); +} +exports.mkdirP = mkdirP; +/** + * Best effort attempt to determine whether a file exists and is executable. + * @param filePath file path to check + * @param extensions additional file extensions to try + * @return if file exists and is executable, returns the file path. otherwise empty string. + */ +function tryGetExecutablePath(filePath, extensions) { + return __awaiter(this, void 0, void 0, function* () { + let stats = undefined; + try { + // test file exists + stats = yield exports.stat(filePath); + } + catch (err) { + if (err.code !== 'ENOENT') { + // eslint-disable-next-line no-console + console.log(`Unexpected error attempting to determine if executable file exists '${filePath}': ${err}`); + } + } + if (stats && stats.isFile()) { + if (exports.IS_WINDOWS) { + // on Windows, test for valid extension + const upperExt = path.extname(filePath).toUpperCase(); + if (extensions.some(validExt => validExt.toUpperCase() === upperExt)) { + return filePath; + } + } + else { + if (isUnixExecutable(stats)) { + return filePath; + } + } + } + // try each extension + const originalFilePath = filePath; + for (const extension of extensions) { + filePath = originalFilePath + extension; + stats = undefined; + try { + stats = yield exports.stat(filePath); + } + catch (err) { + if (err.code !== 'ENOENT') { + // eslint-disable-next-line no-console + console.log(`Unexpected error attempting to determine if executable file exists '${filePath}': ${err}`); + } + } + if (stats && stats.isFile()) { + if (exports.IS_WINDOWS) { + // preserve the case of the actual file (since an extension was appended) + try { + const directory = path.dirname(filePath); + const upperName = path.basename(filePath).toUpperCase(); + for (const actualName of yield exports.readdir(directory)) { + if (upperName === actualName.toUpperCase()) { + filePath = path.join(directory, actualName); + break; + } + } + } + catch (err) { + // eslint-disable-next-line no-console + console.log(`Unexpected error attempting to determine the actual case of the file '${filePath}': ${err}`); + } + return filePath; + } + else { + if (isUnixExecutable(stats)) { + return filePath; + } + } + } + } + return ''; + }); +} +exports.tryGetExecutablePath = tryGetExecutablePath; +function normalizeSeparators(p) { + p = p || ''; + if (exports.IS_WINDOWS) { + // convert slashes on Windows + p = p.replace(/\//g, '\\'); + // remove redundant slashes + return p.replace(/\\\\+/g, '\\'); + } + // remove redundant slashes + return p.replace(/\/\/+/g, '/'); +} +// on Mac/Linux, test the execute bit +// R W X R W X R W X +// 256 128 64 32 16 8 4 2 1 +function isUnixExecutable(stats) { + return ((stats.mode & 1) > 0 || + ((stats.mode & 8) > 0 && stats.gid === process.getgid()) || + ((stats.mode & 64) > 0 && stats.uid === process.getuid())); +} +//# sourceMappingURL=io-util.js.map + +/***/ }), + /***/ 724: /***/ (function(module) { @@ -44358,6 +45594,106 @@ exports.generateKeyPair = (name, email, passphrase, numBits = 4096) => __awaiter }); +/***/ }), + +/***/ 807: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const actionsExec = __importStar(__webpack_require__(986)); +exports.exec = (command, args = [], silent) => __awaiter(void 0, void 0, void 0, function* () { + let stdout = ''; + let stderr = ''; + const options = { + silent: silent, + ignoreReturnCode: true + }; + options.listeners = { + stdout: (data) => { + stdout += data.toString(); + }, + stderr: (data) => { + stderr += data.toString(); + } + }; + const returnCode = yield actionsExec.exec(command, args, options); + return { + success: returnCode === 0, + stdout: stdout.trim(), + stderr: stderr.trim() + }; +}); + + +/***/ }), + +/***/ 986: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const tr = __importStar(__webpack_require__(9)); +/** + * Exec a command. + * Output will be streamed to the live console. + * Returns promise with return code + * + * @param commandLine command to execute (can include additional args). Must be correctly escaped. + * @param args optional arguments for tool. Escaping is handled by the lib. + * @param options optional exec options. See ExecOptions + * @returns Promise exit code + */ +function exec(commandLine, args, options) { + return __awaiter(this, void 0, void 0, function* () { + const commandArgs = tr.argStringToArray(commandLine); + if (commandArgs.length === 0) { + throw new Error(`Parameter 'commandLine' cannot be null or empty.`); + } + // Path to tool to execute should be first arg + const toolPath = commandArgs[0]; + args = commandArgs.slice(1).concat(args || []); + const runner = new tr.ToolRunner(toolPath, args, options); + return runner.exec(); + }); +} +exports.exec = exec; +//# sourceMappingURL=exec.js.map + /***/ }) /******/ }); \ No newline at end of file diff --git a/src/exec.ts b/src/exec.ts new file mode 100644 index 0000000..9ae09ca --- /dev/null +++ b/src/exec.ts @@ -0,0 +1,34 @@ +import * as actionsExec from '@actions/exec'; +import {ExecOptions} from '@actions/exec'; + +export interface ExecResult { + success: boolean; + stdout: string; + stderr: string; +} + +export const exec = async (command: string, args: string[] = [], silent: boolean): Promise => { + let stdout: string = ''; + let stderr: string = ''; + + const options: ExecOptions = { + silent: silent, + ignoreReturnCode: true + }; + options.listeners = { + stdout: (data: Buffer) => { + stdout += data.toString(); + }, + stderr: (data: Buffer) => { + stderr += data.toString(); + } + }; + + const returnCode: number = await actionsExec.exec(command, args, options); + + return { + success: returnCode === 0, + stdout: stdout.trim(), + stderr: stderr.trim() + }; +}; diff --git a/src/gpg.ts b/src/gpg.ts index 80732f4..c81065e 100644 --- a/src/gpg.ts +++ b/src/gpg.ts @@ -1,27 +1,33 @@ -import * as child_process from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as exec from './exec'; + +export const agentConfig = `default-cache-ttl 1 +max-cache-ttl 31536000 +allow-preset-passphrase`; export interface Version { gnupg: string; libgcrypt: string; } -const gpg = async (args: string[] = []): Promise => { - return child_process - .execSync(`gpg ${args.join(' ')}`, { - encoding: 'utf8' - }) - .trim(); -}; +export interface Dirs { + libdir: string; + datadir: string; + homedir: string; +} export const getVersion = async (): Promise => { - let gnupgVersion: string = ''; - let libgcryptVersion: string = ''; + return await exec.exec('gpg', ['--version'], true).then(res => { + if (res.stderr != '') { + throw new Error(res.stderr); + } - await gpg(['--version']).then(stdout => { - for (let line of stdout.replace(/\r/g, '').trim().split(/\n/g)) { + let gnupgVersion: string = ''; + let libgcryptVersion: string = ''; + + for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) { if (line.startsWith('gpg (GnuPG) ')) { gnupgVersion = line.substr('gpg (GnuPG) '.length).trim(); } else if (line.startsWith('gpg (GnuPG/MacGPG2) ')) { @@ -30,25 +36,124 @@ export const getVersion = async (): Promise => { libgcryptVersion = line.substr('libgcrypt '.length).trim(); } } - }); - return { - gnupg: gnupgVersion, - libgcrypt: libgcryptVersion - }; + return { + gnupg: gnupgVersion, + libgcrypt: libgcryptVersion + }; + }); }; -export const importKey = async (armoredText: string): Promise => { +export const getDirs = async (): Promise => { + return await exec.exec('gpgconf', ['--list-dirs'], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + + let libdir: string = ''; + let datadir: string = ''; + let homedir: string = ''; + + for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) { + if (line.startsWith('libdir:')) { + libdir = line.substr('libdir:'.length).replace('%3a', ':').trim(); + } else if (line.startsWith('datadir:')) { + datadir = line.substr('datadir:'.length).replace('%3a', ':').trim(); + } else if (line.startsWith('homedir:')) { + homedir = line.substr('homedir:'.length).replace('%3a', ':').trim(); + } + } + + return { + libdir: path.normalize(libdir), + datadir: path.normalize(datadir), + homedir: path.normalize(homedir) + }; + }); +}; + +export const importKey = async (armoredText: string): Promise => { const keyFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'ghaction-import-gpg-')); const keyPath: string = `${keyFolder}/key.pgp`; fs.writeFileSync(keyPath, armoredText, {mode: 0o600}); - await gpg(['--import', '--batch', '--yes', keyPath]).finally(() => { - fs.unlinkSync(keyPath); + return await exec + .exec('gpg', ['--import', '--batch', '--yes', keyPath], true) + .then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + if (res.stderr != '') { + return res.stderr.trim(); + } + return res.stdout.trim(); + }) + .finally(() => { + fs.unlinkSync(keyPath); + }); +}; + +export const getKeygrip = async (fingerprint: string): Promise => { + return await exec + .exec('gpg', ['--batch', '--with-colons', '--with-keygrip', '--list-secret-keys', fingerprint], true) + .then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + let keygrip: string = ''; + for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) { + if (line.startsWith('grp')) { + keygrip = line.replace(/(grp|:)/g, '').trim(); + } + } + return keygrip; + }); +}; + +export const configureAgent = async (config: string): Promise => { + const {homedir: homedir} = await getDirs(); + const gpgAgentConf = path.join(homedir, 'gpg-agent.conf'); + await fs.writeFile(gpgAgentConf, config, function (err) { + if (err) throw err; + }); + await exec.exec(`gpg-connect-agent "RELOADAGENT" /bye`, [], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + }); +}; + +export const presetPassphrase = async (keygrip: string, passphrase: string): Promise => { + await exec + .exec('gpg-preset-passphrase', ['--verbose', '--preset', '--passphrase', `"${passphrase}"`, keygrip], true) + .then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + }); + + return await exec.exec(`gpg-connect-agent "KEYINFO ${keygrip}" /bye`, [], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) { + if (line.startsWith('ERR')) { + throw new Error(line); + } + } + return res.stdout.trim(); }); }; export const deleteKey = async (fingerprint: string): Promise => { - await gpg(['--batch', '--yes', ' --delete-secret-keys', fingerprint]); - await gpg(['--batch', '--yes', ' --delete-keys', fingerprint]); + await exec.exec('gpg', ['--batch', '--yes', '--delete-secret-keys', fingerprint], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + }); + await exec.exec('gpg', ['--batch', '--yes', '--delete-keys', fingerprint], true).then(res => { + if (res.stderr != '' && !res.success) { + throw new Error(res.stderr); + } + }); }; diff --git a/src/main.ts b/src/main.ts index 239cba8..b634783 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,18 +12,37 @@ async function run(): Promise { core.info('📣 GnuPG info'); const version = await gpg.getVersion(); - core.info(`GnuPG version: ${version.gnupg}`); - core.info(`libgcrypt version: ${version.libgcrypt}`); + const dirs = await gpg.getDirs(); + core.info(`Version : ${version.gnupg} (libgcrypt ${version.libgcrypt})`); + core.info(`Homedir : ${dirs.homedir}`); + core.info(`Datadir : ${dirs.datadir}`); + core.info(`Libdir : ${dirs.libdir}`); core.info('🔮 Checking signing key...'); const privateKey = await openpgp.readPrivateKey(process.env.SIGNING_KEY); - core.debug(`key.fingerprint=${privateKey.fingerprint}`); - core.debug(`key.keyID=${privateKey.keyID}`); - core.debug(`key.userID=${privateKey.userID}`); - core.debug(`key.creationTime=${privateKey.creationTime}`); + core.debug(`Fingerprint : ${privateKey.fingerprint}`); + core.debug(`KeyID : ${privateKey.keyID}`); + core.debug(`UserID : ${privateKey.userID}`); + core.debug(`CreationTime : ${privateKey.creationTime}`); core.info('🔑 Importing secret key...'); - await gpg.importKey(process.env.SIGNING_KEY); + await gpg.importKey(process.env.SIGNING_KEY).then(stdout => { + core.debug(stdout); + }); + + if (process.env.PASSPHRASE) { + core.info('⚙️ Configuring GnuPG agent...'); + await gpg.configureAgent(gpg.agentConfig); + + core.info('📌 Getting keygrip...'); + const keygrip = await gpg.getKeygrip(privateKey.fingerprint); + core.debug(`${keygrip}`); + + core.info('🔓 Preset passphrase...'); + await gpg.presetPassphrase(keygrip, process.env.PASSPHRASE).then(stdout => { + core.debug(stdout); + }); + } } catch (error) { core.setFailed(error.message); } @@ -31,11 +50,11 @@ async function run(): Promise { async function cleanup(): Promise { if (!process.env.SIGNING_KEY) { - core.debug('Private key is not defined. Skipping cleanup.'); + core.debug('Signing key is not defined. Skipping cleanup.'); return; } try { - core.info('🚿 Removing keys from GnuPG...'); + core.info('🚿 Removing keys...'); const privateKey = await openpgp.readPrivateKey(process.env.SIGNING_KEY); await gpg.deleteKey(privateKey.fingerprint); } catch (error) {