Repository navigation
Import Maven signing keys into an isolated temporary GPG home #1211
Description
Description:
When gpg-private-key is provided, setup-java imports the key into the runner's default GPG keyring and later tries to remove one parsed fingerprint in the post action.
The current implementation:
- writes the armored private key to a fixed
$RUNNER_TEMP/private-key.ascpath without an explicit restrictive file mode; - removes that file only after a successful
gpg --import, so an import failure can leave the plaintext private key behind; - imports into the default GPG home rather than an action-owned keyring;
- extracts only the first value matching a fixed 40-character fingerprint regex;
- stores that one fingerprint for post-action deletion.
Lines 9 to 67 in ab597f9
| export const PRIVATE_KEY_FILE = path.join(util.getTempDir(), 'private-key.asc'); | |
| const PRIVATE_KEY_FINGERPRINT_REGEX = /\w{40}/; | |
| // Convert a Windows path (D:\a\_temp\...) to a POSIX path (/d/a/_temp/...). | |
| // The Git-bundled GPG on Windows (MSYS2-based) uses POSIX path conventions | |
| // internally. Passing Windows paths with backslashes can cause fatal GPG errors | |
| // (exit code 2), so all paths passed to GPG must be in POSIX format on Windows. | |
| export function toGpgPath(p: string): string { | |
| if (process.platform !== 'win32') return p; | |
| return p | |
| .replace(/\\/g, '/') | |
| .replace(/^([A-Za-z]):\//, (_, drive) => `/${drive.toLowerCase()}/`); | |
| } | |
| export async function importKey(privateKey: string) { | |
| fs.writeFileSync(PRIVATE_KEY_FILE, privateKey, { | |
| encoding: 'utf-8', | |
| flag: 'w' | |
| }); | |
| let output = ''; | |
| const options: ExecOptions = { | |
| silent: true, | |
| listeners: { | |
| stdout: (data: Buffer) => { | |
| output += data.toString(); | |
| } | |
| } | |
| }; | |
| await exec.exec( | |
| 'gpg', | |
| [ | |
| '--batch', | |
| '--import-options', | |
| 'import-show', | |
| '--import', | |
| PRIVATE_KEY_FILE | |
| ], | |
| options | |
| ); | |
| await io.rmRF(PRIVATE_KEY_FILE); | |
| const match = output.match(PRIVATE_KEY_FINGERPRINT_REGEX); | |
| return match && match[0]; | |
| } | |
| export async function deleteKey(keyFingerprint: string) { | |
| await exec.exec( | |
| 'gpg', | |
| ['--batch', '--yes', '--delete-secret-and-public-key', keyFingerprint], | |
| { | |
| silent: true | |
| } | |
| ); | |
| } |
The fingerprint is saved here:
Lines 53 to 57 in ab597f9
| if (gpgPrivateKey) { | |
| core.info('Importing private gpg key'); | |
| const keyFingerprint = (await gpg.importKey(gpgPrivateKey)) || ''; | |
| core.saveState(constants.STATE_GPG_PRIVATE_KEY_FINGERPRINT, keyFingerprint); | |
| } |
The post action deletes that fingerprint from the default keyring here:
setup-java/src/cleanup-java.ts
Lines 11 to 24 in ab597f9
| async function removePrivateKeyFromKeychain() { | |
| if (core.getInput(constants.INPUT_GPG_PRIVATE_KEY, {required: false})) { | |
| core.info('Removing private key from keychain'); | |
| try { | |
| const keyFingerprint = core.getState( | |
| constants.STATE_GPG_PRIVATE_KEY_FINGERPRINT | |
| ); | |
| await gpg.deleteKey(keyFingerprint); | |
| } catch (error) { | |
| core.setFailed( | |
| `Failed to remove private key due to: ${(error as Error).message}` | |
| ); | |
| } | |
| } |
This creates several lifecycle and ownership problems, especially on persistent self-hosted runners or jobs that invoke setup-java more than once:
- If the same key already existed, setup-java's post action can delete user-managed key material that it did not create.
- If the input contains multiple primary keys, only one fingerprint is retained and the remaining imported keys can be left in the default keyring.
- If import fails, the fixed private-key file is not removed because cleanup is not in a
finallyblock. - A missing or unrecognized fingerprint is saved as an empty string, after which the post action still attempts key deletion.
- Multiple action/composite-action invocations can interfere with one another by importing and deleting keys from the same keyring.
The action already uses an isolated temporary GPG home with finally cleanup for package-signature verification, providing a useful implementation pattern:
Lines 69 to 123 in ab597f9
| export async function verifyPackageSignature( | |
| archivePath: string, | |
| signatureUrl: string, | |
| publicKeyContent: string | |
| ) { | |
| const signaturePath = await tc.downloadTool(signatureUrl); | |
| let gpgHome: string; | |
| try { | |
| gpgHome = fs.mkdtempSync( | |
| path.join(util.getTempDir(), 'verify-signature-gpg-home-') | |
| ); | |
| } catch (error) { | |
| try { | |
| await io.rmRF(signaturePath); | |
| } catch { | |
| // ignore cleanup failures | |
| } | |
| throw new Error( | |
| `Failed to create temporary GPG home directory for signature verification: ${ | |
| (error as Error).message | |
| }`, | |
| {cause: error} | |
| ); | |
| } | |
| try { | |
| const publicKeyFile = path.join(gpgHome, 'public-key.asc'); | |
| fs.writeFileSync(publicKeyFile, publicKeyContent, {encoding: 'utf-8'}); | |
| const options: ExecOptions = {silent: true}; | |
| await exec.exec( | |
| 'gpg', | |
| [ | |
| '--homedir', | |
| toGpgPath(gpgHome), | |
| '--batch', | |
| '--import', | |
| toGpgPath(publicKeyFile) | |
| ], | |
| options | |
| ); | |
| await exec.exec( | |
| 'gpg', | |
| [ | |
| '--homedir', | |
| toGpgPath(gpgHome), | |
| '--batch', | |
| '--verify', | |
| toGpgPath(signaturePath), | |
| toGpgPath(archivePath) | |
| ], | |
| options | |
| ); | |
| } finally { | |
| await io.rmRF(signaturePath); | |
| await io.rmRF(gpgHome); | |
| } |
Task version:
actions/setup-java@ab597f914a6894678251803485c09f413263b889 (main, unreleased v6 development)
Platform:
- Ubuntu
- macOS
- Windows
Runner type:
- Hosted
- Self-hosted
The impact is highest on persistent self-hosted runners, but repeated action invocations can expose the cleanup problem on hosted runners too.
Repro steps:
Pre-existing key deletion:
- On a self-hosted runner, import a private key into the runner user's default GPG keyring.
- Run setup-java with the same key supplied through
gpg-private-key. - After the setup-java post action, run
gpg --list-secret-keys <fingerprint>. - The key can be absent even though it existed before setup-java ran.
Incomplete multi-key cleanup:
- Export two primary private keys into one armored input, for example with
gpg --armor --export-secret-keys <key1> <key2>. - Supply that value through
gpg-private-key. - After the post action, inspect the default secret keyring.
- Only the first regex-matched fingerprint was recorded for deletion, so other imported key material can remain.
Import-failure cleanup:
- Supply malformed or otherwise non-importable private-key data.
- Let
gpg --importfail. - Inspect
$RUNNER_TEMP/private-key.ascon the persistent runner. - The file can remain because deletion occurs only after a successful import.
Expected behavior:
setup-java should not modify or delete pre-existing keys in the runner's default keyring, and private-key material should be removed reliably on both success and failure.
Actual behavior:
setup-java imports into the shared default keyring, tracks only one parsed fingerprint, and relies on fingerprint deletion to undo changes. This cannot distinguish action-owned keys from pre-existing keys and does not cover every failure or multi-key case.
Suggested fix:
- Create an action-owned temporary
GNUPGHOMEwith restrictive permissions. - Import
gpg-private-keyonly into that isolated keyring. - Export
GNUPGHOMEso Maven/GPG commands in subsequent steps use the isolated keyring. - Store the owned GPG-home path as post-action state and remove the complete directory during cleanup instead of deleting fingerprints from the default keyring.
- Use a unique private-key file with mode
0600, deleted infinally, or import without persisting the key to disk. - Make cleanup idempotent and safe across repeated setup-java invocations.
- Add tests covering pre-existing matching keys, multiple imported keys, import failure, repeated invocations, and missing cleanup state.

Activity