Skip to content

security(gpg): pipe private key via stdin instead of writing to /tmp#798

Merged
BYK merged 1 commit into
masterfrom
security/gpg-key-via-stdin
Apr 21, 2026
Merged

security(gpg): pipe private key via stdin instead of writing to /tmp#798
BYK merged 1 commit into
masterfrom
security/gpg-key-via-stdin

Conversation

@BYK

@BYK BYK commented Apr 21, 2026

Copy link
Copy Markdown
Member

Summary

Eliminates the TOCTOU / information-disclosure hazard in importGPGKey(): the private key is now piped to gpg --batch --import via stdin instead of being written to a fixed, predictable path in os.tmpdir().

Motivation

The previous implementation:

const PRIVATE_KEY_FILE = path.join(tmpdir(), 'private-key.asc');
await fsPromises.writeFile(PRIVATE_KEY_FILE, privateKey);
await spawnProcess('gpg', ['--batch', '--import', PRIVATE_KEY_FILE]);
await fsPromises.unlink(PRIVATE_KEY_FILE);

On Linux, /tmp is mode 1777 (world-writable, with the sticky bit). The path is deterministic, so:

  1. Read race: any co-resident process on the runner can read the key between writeFile and unlink.
  2. Write redirect via symlink: an attacker who wins a race to ln -s /some/target /tmp/private-key.asc before the writeFile call causes Craft to overwrite /some/target with the private key.
  3. Crash persistence: an unexpected exit between writeFile and unlink leaves the key on disk indefinitely.

Fix

Pass the key via stdin. gpg --batch --import reads a key from stdin when no file argument is given. spawnProcess already supports a stdin option — no new infrastructure needed.

await spawnProcess('gpg', ['--batch', '--import'], {}, { stdin: privateKey });

Benefits vs. mkdtemp(0o700) alternative:

  • Zero filesystem contact — no dir creation, no file write, no cleanup path to get wrong.
  • Key no longer appears in argv either, so it doesn't show up in ps / /proc/<pid>/cmdline.

Tests

src/utils/__tests__/gpg.test.ts is rewritten to assert the new invariants:

  • spawnProcess is called with ['--batch', '--import'] and { stdin: KEY }.
  • No fs.writeFile / fs.unlink happens.
  • The key is not embedded in any argv entry (regression guard against future reintroduction).

pnpm test src/utils/__tests__/gpg.test.ts → 3 tests pass. Full lint / build clean.

Callers

Only src/targets/maven.ts:271 calls importGPGKey. The signature is unchanged (importGPGKey(privateKey: string): Promise<void>) — no caller updates needed.

importGPGKey() used to write the GPG private key to a fixed,
predictable path in os.tmpdir() (typically /tmp/private-key.asc on
Linux, a world-readable mode 1777 directory), call gpg --batch
--import <path>, and then unlink. This had several hazards:

- Co-resident processes on shared runners could read the key between
  writeFile and unlink.
- A symlink planted at /tmp/private-key.asc before writeFile would
  redirect the write to an attacker-chosen destination.
- An unexpected crash between writeFile and unlink would leave the
  key on disk indefinitely.

Pipe the key to gpg via stdin instead. The spawnProcess helper
already supports stdin; gpg with --batch --import reads a key from
stdin when no file argument is given. Nothing ever touches disk.

Key also no longer appears in argv, which improves the process-list
visibility profile.
@BYK BYK merged commit 2930998 into master Apr 21, 2026
16 checks passed
@BYK BYK deleted the security/gpg-key-via-stdin branch April 21, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant