From 2724049ae2973a54f5de9b4cd897b1c78bf2113d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 28 Feb 2022 15:36:54 +0000 Subject: [PATCH] Set passphrase only for the fingerprint being used (#123) * If fingerprint input is provided it sets only the passphrase for that key * Update README with how to use subkeys example --- README.md | 45 +++++++++++++++++++ __tests__/fixtures/test-key-gpg-output.txt | 8 ++++ __tests__/fixtures/test-subkey-gpg-output.txt | 8 ++++ __tests__/gpg.test.ts | 42 +++++++++++++++++ dist/index.js | 45 ++++++++++++++++++- src/gpg.ts | 33 ++++++++++++++ src/main.ts | 19 +++++++- 7 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 __tests__/fixtures/test-key-gpg-output.txt create mode 100644 __tests__/fixtures/test-subkey-gpg-output.txt diff --git a/README.md b/README.md index 3edc8be..e52bd1d 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,51 @@ jobs: git push ``` +### Use a subkey + +With the input `fingerprint`, you can specify which one of the subkeys in a GPG key you want to use for signing. + +```yaml +name: import-gpg + +on: + push: + branches: master + +jobs: + import-gpg: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v2 + - + name: Import GPG key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v4 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.PASSPHRASE }} + fingerprint: "C17D11ADF199F12A30A0910F1F80449BE0B08CB8" + - + name: List keys + run: gpg -K +``` + +For example, given this GPG key with a signing subkey: + +```s +pub ed25519 2021-09-24 [C] + 87F257B89CE462100BEC0FFE6071D218380FDCC8 + Keygrip = F5C3ABFAAB36B427FD98C4EDD0387E08EA1E8092 +uid [ unknown] Joe Bar +sub ed25519 2021-09-24 [S] + C17D11ADF199F12A30A0910F1F80449BE0B08CB8 + Keygrip = DEE0FC98F441519CA5DE5D79773CB29009695FEB +``` + +You can use the subkey with signing capability whose fingerprint is `C17D11ADF199F12A30A0910F1F80449BE0B08CB8`. + ## Customizing ### inputs diff --git a/__tests__/fixtures/test-key-gpg-output.txt b/__tests__/fixtures/test-key-gpg-output.txt new file mode 100644 index 0000000..ab9cc37 --- /dev/null +++ b/__tests__/fixtures/test-key-gpg-output.txt @@ -0,0 +1,8 @@ +tru::1:1645715610:1661267528:3:1:5 +pub:-:4096:1:7D851EB72D73BDA0:1588448672:::-:::scESC::::::23::0: +fpr:::::::::27571A53B86AF0C799B38BA77D851EB72D73BDA0: +grp:::::::::3E2D1142AA59E08E16B7E2C64BA6DDC773B1A627: +uid:-::::1588448672::C1B25336F8F0F0F22BAF57137BE493ADEDA8CCAA::Joe Tester ::::::::::0: +sub:-:4096:1:D523BD50DD70B0BA:1588448672::::::e::::::23: +fpr:::::::::5A282E1460C0BC419615D34DD523BD50DD70B0BA: +grp:::::::::BA83FC8947213477F28ADC019F6564A956456163: diff --git a/__tests__/fixtures/test-subkey-gpg-output.txt b/__tests__/fixtures/test-subkey-gpg-output.txt new file mode 100644 index 0000000..b4a37c2 --- /dev/null +++ b/__tests__/fixtures/test-subkey-gpg-output.txt @@ -0,0 +1,8 @@ +tru::1:1645715610:1661267528:3:1:5 +pub:-:256:22:6071D218380FDCC8:1632521434:::-:::cSC:::::ed25519:::0: +fpr:::::::::87F257B89CE462100BEC0FFE6071D218380FDCC8: +grp:::::::::F5C3ABFAAB36B427FD98C4EDD0387E08EA1E8092: +uid:-::::1632521434::019F22ECD701BC0F6AFE686ABD2B010B812B828E::Joe Bar ::::::::::0: +sub:-:256:22:1F80449BE0B08CB8:1632521539::::::s:::::ed25519:: +fpr:::::::::C17D11ADF199F12A30A0910F1F80449BE0B08CB8: +grp:::::::::DEE0FC98F441519CA5DE5D79773CB29009695FEB: diff --git a/__tests__/gpg.test.ts b/__tests__/gpg.test.ts index 4c810cc..dc449fd 100644 --- a/__tests__/gpg.test.ts +++ b/__tests__/gpg.test.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as gpg from '../src/gpg'; +import {parseKeygripFromGpgColonsOutput} from '../src/gpg'; const userInfos = [ { @@ -20,6 +21,7 @@ const userInfos = [ email: 'joe@foo.bar', keyID: '7D851EB72D73BDA0', fingerprint: '27571A53B86AF0C799B38BA77D851EB72D73BDA0', + fingerprints: ['27571A53B86AF0C799B38BA77D851EB72D73BDA0', '5A282E1460C0BC419615D34DD523BD50DD70B0BA'], keygrips: ['3E2D1142AA59E08E16B7E2C64BA6DDC773B1A627', 'BA83FC8947213477F28ADC019F6564A956456163'] }, { @@ -40,6 +42,7 @@ const userInfos = [ email: 'joe@bar.foo', keyID: '6071D218380FDCC8', fingerprint: 'C17D11ADF199F12A30A0910F1F80449BE0B08CB8', + fingerprints: ['87F257B89CE462100BEC0FFE6071D218380FDCC8', 'C17D11ADF199F12A30A0910F1F80449BE0B08CB8'], keygrips: ['F5C3ABFAAB36B427FD98C4EDD0387E08EA1E8092', 'DEE0FC98F441519CA5DE5D79773CB29009695FEB'] } ]; @@ -101,6 +104,19 @@ for (let userInfo of userInfos) { }); }); + describe('getKeygrip', () => { + it('returns the keygrip for a given fingerprint', async () => { + await gpg.importKey(userInfo.pgp); + for (let [i, fingerprint] of userInfo.fingerprints.entries()) { + await gpg.getKeygrip(fingerprint).then(keygrip => { + console.log(`Fingerprint: ${fingerprint}; Index: ${i}; Keygrip: ${keygrip}`); + expect(keygrip.length).toEqual(userInfo.keygrips[i].length); + expect(keygrip).toEqual(userInfo.keygrips[i]); + }); + } + }); + }); + describe('presetPassphrase', () => { it('presets passphrase', async () => { await gpg.importKey(userInfo.pgp); @@ -128,3 +144,29 @@ describe('killAgent', () => { await gpg.killAgent(); }); }); + +describe('parseKeygripFromGpgColonsOutput', () => { + it('returns the keygrip of a given fingerprint from a GPG command output using the option: --with-colons', async () => { + const outputUsingTestKey = fs.readFileSync('__tests__/fixtures/test-key-gpg-output.txt', { + encoding: 'utf8', + flag: 'r' + }); + + const keygripPrimaryTestKey = parseKeygripFromGpgColonsOutput(outputUsingTestKey, '27571A53B86AF0C799B38BA77D851EB72D73BDA0'); + expect(keygripPrimaryTestKey).toBe('3E2D1142AA59E08E16B7E2C64BA6DDC773B1A627'); + + const keygripSubkeyTestKey = parseKeygripFromGpgColonsOutput(outputUsingTestKey, '5A282E1460C0BC419615D34DD523BD50DD70B0BA'); + expect(keygripSubkeyTestKey).toBe('BA83FC8947213477F28ADC019F6564A956456163'); + + const outputUsingTestSubkey = fs.readFileSync('__tests__/fixtures/test-subkey-gpg-output.txt', { + encoding: 'utf8', + flag: 'r' + }); + + const keygripPrimaryTestSubkey = parseKeygripFromGpgColonsOutput(outputUsingTestSubkey, '87F257B89CE462100BEC0FFE6071D218380FDCC8'); + expect(keygripPrimaryTestSubkey).toBe('F5C3ABFAAB36B427FD98C4EDD0387E08EA1E8092'); + + const keygripSubkeyTestSubkey = parseKeygripFromGpgColonsOutput(outputUsingTestSubkey, 'C17D11ADF199F12A30A0910F1F80449BE0B08CB8'); + expect(keygripSubkeyTestSubkey).toBe('DEE0FC98F441519CA5DE5D79773CB29009695FEB'); + }); +}); diff --git a/dist/index.js b/dist/index.js index 97e5e7e..72668cc 100644 --- a/dist/index.js +++ b/dist/index.js @@ -164,7 +164,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.killAgent = exports.deleteKey = exports.presetPassphrase = exports.configureAgent = exports.getKeygrips = exports.importKey = exports.getDirs = exports.getVersion = exports.agentConfig = void 0; +exports.killAgent = exports.deleteKey = exports.presetPassphrase = exports.configureAgent = exports.getKeygrip = exports.parseKeygripFromGpgColonsOutput = exports.getKeygrips = exports.importKey = exports.getDirs = exports.getVersion = exports.agentConfig = void 0; const exec = __importStar(__webpack_require__(1514)); const fs = __importStar(__webpack_require__(5747)); const path = __importStar(__webpack_require__(5622)); @@ -304,6 +304,34 @@ exports.getKeygrips = (fingerprint) => __awaiter(void 0, void 0, void 0, functio return keygrips; }); }); +exports.parseKeygripFromGpgColonsOutput = (output, fingerprint) => { + let keygrip = ''; + let fingerPrintFound = false; + const lines = output.replace(/\r/g, '').trim().split(/\n/g); + for (let line of lines) { + if (line.startsWith(`fpr:`) && line.includes(`:${fingerprint}:`)) { + // We reach the record with the matching fingerprint. + // The next keygrip record is the keygrip for this fingerprint. + fingerPrintFound = true; + continue; + } + if (line.startsWith('grp:') && fingerPrintFound) { + keygrip = line.replace(/(grp|:)/g, '').trim(); + break; + } + } + return keygrip; +}; +exports.getKeygrip = (fingerprint) => __awaiter(void 0, void 0, void 0, function* () { + return yield exec + .getExecOutput('gpg', ['--batch', '--with-colons', '--with-keygrip', '--list-secret-keys', fingerprint], { + ignoreReturnCode: true, + silent: true + }) + .then(res => { + return exports.parseKeygripFromGpgColonsOutput(res.stdout, fingerprint); + }); +}); exports.configureAgent = (config) => __awaiter(void 0, void 0, void 0, function* () { const gpgAgentConf = path.join(yield getGnupgHome(), 'gpg-agent.conf'); yield fs.writeFile(gpgAgentConf, config, function (err) { @@ -424,7 +452,8 @@ function run() { core.info(stdout); }); })); - if (inputs.passphrase) { + if (inputs.passphrase && !inputs.fingerprint) { + // Set the passphrase for all subkeys core.info('Configuring GnuPG agent'); yield gpg.configureAgent(gpg.agentConfig); yield core.group(`Getting keygrips`, () => __awaiter(this, void 0, void 0, function* () { @@ -436,6 +465,18 @@ function run() { } })); } + if (inputs.passphrase && inputs.fingerprint) { + // Set the passphrase only for the subkey specified in the input `fingerprint` + core.info('Configuring GnuPG agent'); + yield gpg.configureAgent(gpg.agentConfig); + yield core.group(`Getting keygrip for fingerprint`, () => __awaiter(this, void 0, void 0, function* () { + const keygrip = yield gpg.getKeygrip(fingerprint); + core.info(`Presetting passphrase for key ${fingerprint} with keygrip ${keygrip}`); + yield gpg.presetPassphrase(keygrip, inputs.passphrase).then(stdout => { + core.debug(stdout); + }); + })); + } yield core.group(`Setting outputs`, () => __awaiter(this, void 0, void 0, function* () { core.info(`fingerprint=${fingerprint}`); context.setOutput('fingerprint', fingerprint); diff --git a/src/gpg.ts b/src/gpg.ts index cdf98f9..82920a6 100644 --- a/src/gpg.ts +++ b/src/gpg.ts @@ -159,6 +159,39 @@ export const getKeygrips = async (fingerprint: string): Promise> = }); }; +export const parseKeygripFromGpgColonsOutput = (output: string, fingerprint: string): string => { + let keygrip = ''; + let fingerPrintFound = false; + const lines = output.replace(/\r/g, '').trim().split(/\n/g); + + for (let line of lines) { + if (line.startsWith(`fpr:`) && line.includes(`:${fingerprint}:`)) { + // We reach the record with the matching fingerprint. + // The next keygrip record is the keygrip for this fingerprint. + fingerPrintFound = true; + continue; + } + + if (line.startsWith('grp:') && fingerPrintFound) { + keygrip = line.replace(/(grp|:)/g, '').trim(); + break; + } + } + + return keygrip; +}; + +export const getKeygrip = async (fingerprint: string): Promise => { + return await exec + .getExecOutput('gpg', ['--batch', '--with-colons', '--with-keygrip', '--list-secret-keys', fingerprint], { + ignoreReturnCode: true, + silent: true + }) + .then(res => { + return parseKeygripFromGpgColonsOutput(res.stdout, fingerprint); + }); +}; + export const configureAgent = async (config: string): Promise => { const gpgAgentConf = path.join(await getGnupgHome(), 'gpg-agent.conf'); await fs.writeFile(gpgAgentConf, config, function (err) { diff --git a/src/main.ts b/src/main.ts index 283186d..840c436 100644 --- a/src/main.ts +++ b/src/main.ts @@ -48,7 +48,9 @@ async function run(): Promise { }); }); - if (inputs.passphrase) { + if (inputs.passphrase && !inputs.fingerprint) { + // Set the passphrase for all subkeys + core.info('Configuring GnuPG agent'); await gpg.configureAgent(gpg.agentConfig); @@ -62,6 +64,21 @@ async function run(): Promise { }); } + if (inputs.passphrase && inputs.fingerprint) { + // Set the passphrase only for the subkey specified in the input `fingerprint` + + core.info('Configuring GnuPG agent'); + await gpg.configureAgent(gpg.agentConfig); + + await core.group(`Getting keygrip for fingerprint`, async () => { + const keygrip = await gpg.getKeygrip(fingerprint); + core.info(`Presetting passphrase for key ${fingerprint} with keygrip ${keygrip}`); + await gpg.presetPassphrase(keygrip, inputs.passphrase).then(stdout => { + core.debug(stdout); + }); + }); + } + await core.group(`Setting outputs`, async () => { core.info(`fingerprint=${fingerprint}`); context.setOutput('fingerprint', fingerprint);