Skip to content

Never ask the terminal for a PEM passphrase - #111

Merged
quinnj-bot[bot] merged 1 commit into
mainfrom
claude/pem-no-prompt
Oct 29, 2026
Merged

quinnj-bot[bot] merged 1 commit into
mainfrom
claude/pem-no-prompt

Conversation

@quinnj-bot

@quinnj-bot quinnj-bot Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Any PEM block can carry the legacy encryption headers (Proc-Type: 4,ENCRYPTED plus DEK-Info), not just private keys. OpenSSL then asks the passphrase callback for a passphrase. This package passed no callback, so OpenSSL used its default one. That callback prints Enter PEM pass phrase: on the terminal and reads a line from it, or uses stderr and stdin when there is no terminal.

This affects X509Certificate(pem), OpenSSL.read_pubkey(pem) and EvpPKey(pem). In a process with a controlling terminal (started from a shell, tmux or ssh), parsing such text waits inside the ccall until someone types a line. That thread never reaches a GC safe point, so every other thread stops at the next collection too.

Fix

  • The three reads pass a callback that returns -1, so OpenSSL fails the read ("bad password read") instead of prompting.
  • EvpPKey(pem; passphrase="...") reads an encrypted private key. OpenSSL's default callback hands over the given passphrase; it only prompts when given neither a callback nor a passphrase.
  • write(io, key, cipher; passphrase="...") writes an encrypted key. Until now the terminal prompt was the only way to supply a passphrase. A cipher without a passphrase, or a passphrase without a cipher (which would write the key unencrypted), throws ArgumentError.
  • The write's callback argument was declared Cint; it is now a pointer.

Behavior change

  • An encrypted key read without passphrase used to prompt in an interactive session; it now throws OpenSSLError.
  • write(io, key, cipher) without a passphrase used to prompt; it now throws ArgumentError.
  • Unencrypted PEM is read and written exactly as before.

Verification

  • New testset PEMNeverAsksForPassphrase: a subprocess (stdin devnull) parses a certificate, a public key and a private key that carry encryption headers.
    • On main the stderr check fails: OpenSSL printed its prompt.
    • With the fix, each parse throws OpenSSLError and nothing is printed.
  • New testset EncryptedPrivateKey:
    • a round trip through write(...; passphrase) and EvpPKey(...; passphrase);
    • a wrong or missing passphrase throws;
    • the two ArgumentError cases.
  • Under a pseudo-terminal (script) whose input stays silent:
    • main: all three parses were still blocked after 20 s. A task on another thread that allocates froze within a second of the prompt (heartbeat stuck at 10 for the next 9 s).
    • This branch: each parse returns in 0.01 s.
  • Full suite on Julia 1.12 -t 4: passes.

Self-review

  • OpenSSL's own PEM loading (SSL_CTX_load_verify_locations, CA directories) already passes "" as the callback argument, so it never prompts. These four calls were the only ones in the package without a callback.
  • OpenSSL 3 decoders wrap the callback through UI_UTIL_wrap_read_pem_callback, so a -1 return fails the UI step without opening the console. The 1.1 PEM_do_header path treats < 0 as "bad password read". Both paths were exercised on 3.5.
  • A passphrase is passed as Cstring, so an embedded NUL throws instead of silently shortening it. OpenSSL's default callback copies at most PEM_BUFSIZE (1024) bytes for reads; that is OpenSSL's limit.
  • The callback is stateless and cannot throw. @cfunction of a top-level function with a fixed signature is computed when called, so precompilation is unaffected.

🤖 Generated with Claude Code

Any PEM block may carry encryption headers (Proc-Type: 4,ENCRYPTED), and
OpenSSL then asks for a passphrase. The package passed no callback, so
OpenSSL's default one prompted on the terminal and read the answer there:
X509Certificate(pem), read_pubkey(pem) and EvpPKey(pem) on such text
blocked the calling thread inside the ccall until someone typed a line,
and with it every other thread at the next collection. Without a terminal
it prompted on stderr and read stdin.

The reads now pass a callback that fails, so the parse throws at once.
EvpPKey takes a `passphrase` keyword for an encrypted key, and writing a
key with a cipher takes one too (both or neither), since the prompt was
the only way to give one. The write's callback argument was declared Cint;
it is a pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.38%. Comparing base (14a9842) to head (00d7c94).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #111      +/-   ##
==========================================
+ Coverage   83.07%   83.38%   +0.31%     
==========================================
  Files           2        2              
  Lines        1341     1348       +7     
==========================================
+ Hits         1114     1124      +10     
+ Misses        227      224       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@quinnj-bot
quinnj-bot Bot merged commit c7f1272 into main Oct 29, 2026
23 of 24 checks passed
@quinnj-bot
quinnj-bot Bot deleted the claude/pem-no-prompt branch October 29, 2026 23:57
@quinnj-bot quinnj-bot Bot mentioned this pull request Oct 30, 2026
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