mirror of
https://github.com/crazy-max/ghaction-import-gpg.git
synced 2024-11-05 21:25:50 -05:00
Allow to seed the internal cache of gpg-agent with provided passphrase (#5)
Better handling of commands output streams
This commit is contained in:
parent
9a41db05e6
commit
a8f7b5960a
8 changed files with 1612 additions and 76 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
@ -32,3 +32,4 @@ jobs:
|
|||
uses: ./
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.SIGNING_KEY_TEST }}
|
||||
PASSPHRASE: ${{ secrets.PASSPHRASE_TEST }}
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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 <joe@foo.bar>',
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
1392
dist/index.js
generated
vendored
1392
dist/index.js
generated
vendored
File diff suppressed because it is too large
Load diff
34
src/exec.ts
Normal file
34
src/exec.ts
Normal 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()
|
||||
};
|
||||
};
|
149
src/gpg.ts
149
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<string> => {
|
||||
return child_process
|
||||
.execSync(`gpg ${args.join(' ')}`, {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim();
|
||||
};
|
||||
export interface Dirs {
|
||||
libdir: string;
|
||||
datadir: string;
|
||||
homedir: string;
|
||||
}
|
||||
|
||||
export const getVersion = async (): Promise<Version> => {
|
||||
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<Version> => {
|
|||
libgcryptVersion = line.substr('libgcrypt '.length).trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
gnupg: gnupgVersion,
|
||||
libgcrypt: libgcryptVersion
|
||||
};
|
||||
return {
|
||||
gnupg: gnupgVersion,
|
||||
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 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<string> => {
|
||||
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<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 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
37
src/main.ts
37
src/main.ts
|
@ -12,18 +12,37 @@ async function run(): Promise<void> {
|
|||
|
||||
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<void> {
|
|||
|
||||
async function cleanup(): Promise<void> {
|
||||
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) {
|
||||
|
|
Loading…
Reference in a new issue