Allow to seed the internal cache of gpg-agent with provided passphrase (#5)

Better handling of commands output streams
This commit is contained in:
CrazyMax 2020-05-04 16:17:14 +02:00
parent 9a41db05e6
commit a8f7b5960a
No known key found for this signature in database
GPG key ID: 3248E46B6BB8C7F7
8 changed files with 1612 additions and 76 deletions

View file

@ -32,3 +32,4 @@ jobs:
uses: ./ uses: ./
env: env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY_TEST }} SIGNING_KEY: ${{ secrets.SIGNING_KEY_TEST }}
PASSPHRASE: ${{ secrets.PASSPHRASE_TEST }}

View file

@ -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) ![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 ## Usage
```yaml ```yaml
@ -33,6 +39,7 @@ jobs:
uses: crazy-max/ghaction-import-gpg@master uses: crazy-max/ghaction-import-gpg@master
env: env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
PASSPHRASE: ${{ secrets.PASSPHRASE }}
``` ```
## Customizing ## Customizing
@ -44,6 +51,7 @@ Following environment variables can be used as `step.env` keys
| Name | Description | | Name | Description |
|----------------|---------------------------------------| |----------------|---------------------------------------|
| `SIGNING_KEY` | GPG private key exported as an ASCII armored version | | `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? ## How can I help?

View file

@ -1,5 +1,4 @@
import {deleteKey, getVersion, importKey} from '../src/gpg'; import * as gpg from '../src/gpg';
import * as child_process from 'child_process';
const userInfo = { const userInfo = {
name: 'Joe Tester', name: 'Joe Tester',
@ -8,6 +7,7 @@ const userInfo = {
keyID: 'D523BD50DD70B0BA', keyID: 'D523BD50DD70B0BA',
userID: 'Joe Tester <joe@foo.bar>', userID: 'Joe Tester <joe@foo.bar>',
fingerprint: '27571A53B86AF0C799B38BA77D851EB72D73BDA0', fingerprint: '27571A53B86AF0C799B38BA77D851EB72D73BDA0',
keygrip: 'BA83FC8947213477F28ADC019F6564A956456163',
pgp: `-----BEGIN PGP PRIVATE KEY BLOCK----- pgp: `-----BEGIN PGP PRIVATE KEY BLOCK-----
lQdGBF6tzaABEACjFbX7PFEG6vDPN2MPyxYW7/3o/sonORj4HXUFjFxxJxktJ3x3 lQdGBF6tzaABEACjFbX7PFEG6vDPN2MPyxYW7/3o/sonORj4HXUFjFxxJxktJ3x3
@ -119,7 +119,7 @@ PejgXO0uIRolYQ3sz2tMGhx1MfBqH64=
describe('gpg', () => { describe('gpg', () => {
describe('getVersion', () => { describe('getVersion', () => {
it('returns GnuPG and libgcrypt version', async () => { it('returns GnuPG and libgcrypt version', async () => {
await getVersion().then(version => { await gpg.getVersion().then(version => {
console.log(version); console.log(version);
expect(version.gnupg).not.toEqual(''); expect(version.gnupg).not.toEqual('');
expect(version.libgcrypt).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', () => { describe('importKey', () => {
it('imports key to GnuPG', async () => { it('imports key to GnuPG', async () => {
await importKey(userInfo.pgp).then(() => { await gpg.importKey(userInfo.pgp).then(output => {
console.log( console.log(output);
child_process.execSync(`gpg --batch --list-keys --keyid-format LONG ${userInfo.email}`, {encoding: 'utf8'}) expect(output).not.toEqual('');
); });
console.log( });
child_process.execSync(`gpg --batch --list-secret-keys --keyid-format LONG ${userInfo.email}`, { });
encoding: 'utf8'
}) 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', () => { describe('deleteKey', () => {
it('removes key from GnuPG', async () => { it('removes key from GnuPG', async () => {
await importKey(userInfo.pgp); await gpg.importKey(userInfo.pgp);
await deleteKey(userInfo.fingerprint); await gpg.deleteKey(userInfo.fingerprint);
}); });
}); });
}); });

View file

@ -1,4 +1,4 @@
import {readPrivateKey, generateKeyPair} from '../src/openpgp'; import * as openpgp from '../src/openpgp';
const userInfo = { const userInfo = {
name: 'Joe Tester', name: 'Joe Tester',
@ -118,7 +118,7 @@ PejgXO0uIRolYQ3sz2tMGhx1MfBqH64=
describe('openpgp', () => { describe('openpgp', () => {
describe('readPrivateKey', () => { describe('readPrivateKey', () => {
it('returns a PGP private key', async () => { 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.keyID).toEqual(userInfo.keyID);
expect(privateKey.userID).toEqual(userInfo.userID); expect(privateKey.userID).toEqual(userInfo.userID);
expect(privateKey.fingerprint).toEqual(userInfo.fingerprint); expect(privateKey.fingerprint).toEqual(userInfo.fingerprint);
@ -128,7 +128,7 @@ describe('openpgp', () => {
describe('generateKeyPair', () => { describe('generateKeyPair', () => {
it('generates a PGP key pair', async () => { 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).not.toBeUndefined();
expect(keyPair.publicKey).not.toBeUndefined(); expect(keyPair.publicKey).not.toBeUndefined();
expect(keyPair.privateKey).not.toBeUndefined(); expect(keyPair.privateKey).not.toBeUndefined();

1382
dist/index.js generated vendored

File diff suppressed because it is too large Load diff

34
src/exec.ts Normal file
View file

@ -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<ExecResult> => {
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()
};
};

View file

@ -1,27 +1,33 @@
import * as child_process from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; 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 { export interface Version {
gnupg: string; gnupg: string;
libgcrypt: string; libgcrypt: string;
} }
const gpg = async (args: string[] = []): Promise<string> => { export interface Dirs {
return child_process libdir: string;
.execSync(`gpg ${args.join(' ')}`, { datadir: string;
encoding: 'utf8' homedir: string;
}) }
.trim();
};
export const getVersion = async (): Promise<Version> => { export const getVersion = async (): Promise<Version> => {
return await exec.exec('gpg', ['--version'], true).then(res => {
if (res.stderr != '') {
throw new Error(res.stderr);
}
let gnupgVersion: string = ''; let gnupgVersion: string = '';
let libgcryptVersion: string = ''; let libgcryptVersion: string = '';
await gpg(['--version']).then(stdout => { for (let line of res.stdout.replace(/\r/g, '').trim().split(/\n/g)) {
for (let line of stdout.replace(/\r/g, '').trim().split(/\n/g)) {
if (line.startsWith('gpg (GnuPG) ')) { if (line.startsWith('gpg (GnuPG) ')) {
gnupgVersion = line.substr('gpg (GnuPG) '.length).trim(); gnupgVersion = line.substr('gpg (GnuPG) '.length).trim();
} else if (line.startsWith('gpg (GnuPG/MacGPG2) ')) { } else if (line.startsWith('gpg (GnuPG/MacGPG2) ')) {
@ -30,25 +36,124 @@ export const getVersion = async (): Promise<Version> => {
libgcryptVersion = line.substr('libgcrypt '.length).trim(); libgcryptVersion = line.substr('libgcrypt '.length).trim();
} }
} }
});
return { return {
gnupg: gnupgVersion, gnupg: gnupgVersion,
libgcrypt: libgcryptVersion libgcrypt: libgcryptVersion
}; };
});
}; };
export const importKey = async (armoredText: string): Promise<void> => { export const getDirs = async (): Promise<Dirs> => {
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<string> => {
const keyFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'ghaction-import-gpg-')); const keyFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'ghaction-import-gpg-'));
const keyPath: string = `${keyFolder}/key.pgp`; const keyPath: string = `${keyFolder}/key.pgp`;
fs.writeFileSync(keyPath, armoredText, {mode: 0o600}); fs.writeFileSync(keyPath, armoredText, {mode: 0o600});
await gpg(['--import', '--batch', '--yes', keyPath]).finally(() => { 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); fs.unlinkSync(keyPath);
}); });
}; };
export const deleteKey = async (fingerprint: string): Promise<void> => { export const getKeygrip = async (fingerprint: string): Promise<string> => {
await gpg(['--batch', '--yes', ' --delete-secret-keys', fingerprint]); return await exec
await gpg(['--batch', '--yes', ' --delete-keys', fingerprint]); .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<void> => {
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<string> => {
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<void> => {
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);
}
});
}; };

View file

@ -12,18 +12,37 @@ async function run(): Promise<void> {
core.info('📣 GnuPG info'); core.info('📣 GnuPG info');
const version = await gpg.getVersion(); const version = await gpg.getVersion();
core.info(`GnuPG version: ${version.gnupg}`); const dirs = await gpg.getDirs();
core.info(`libgcrypt version: ${version.libgcrypt}`); 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...'); core.info('🔮 Checking signing key...');
const privateKey = await openpgp.readPrivateKey(process.env.SIGNING_KEY); const privateKey = await openpgp.readPrivateKey(process.env.SIGNING_KEY);
core.debug(`key.fingerprint=${privateKey.fingerprint}`); core.debug(`Fingerprint : ${privateKey.fingerprint}`);
core.debug(`key.keyID=${privateKey.keyID}`); core.debug(`KeyID : ${privateKey.keyID}`);
core.debug(`key.userID=${privateKey.userID}`); core.debug(`UserID : ${privateKey.userID}`);
core.debug(`key.creationTime=${privateKey.creationTime}`); core.debug(`CreationTime : ${privateKey.creationTime}`);
core.info('🔑 Importing secret key...'); 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) { } catch (error) {
core.setFailed(error.message); core.setFailed(error.message);
} }
@ -31,11 +50,11 @@ async function run(): Promise<void> {
async function cleanup(): Promise<void> { async function cleanup(): Promise<void> {
if (!process.env.SIGNING_KEY) { if (!process.env.SIGNING_KEY) {
core.debug('Private key is not defined. Skipping cleanup.'); core.debug('Signing key is not defined. Skipping cleanup.');
return; return;
} }
try { try {
core.info('🚿 Removing keys from GnuPG...'); core.info('🚿 Removing keys...');
const privateKey = await openpgp.readPrivateKey(process.env.SIGNING_KEY); const privateKey = await openpgp.readPrivateKey(process.env.SIGNING_KEY);
await gpg.deleteKey(privateKey.fingerprint); await gpg.deleteKey(privateKey.fingerprint);
} catch (error) { } catch (error) {