Skip to content

Conversation

@jatalahd
Copy link

@jatalahd jatalahd commented Jul 16, 2025

What kind of change does this PR introduce?

  • 🐞 bug fix
  • 🐣 feature
  • 📋 docs update
  • 📋 tests/coverage improvement
  • 📋 refactoring
  • 💥 other

📋 What is the related issue number (starting with #)

cherrypy/cherrypy#1583

What is the current behavior? (You can also link to an open issue here)
With the current functionality it is only possible to use ssl adapters with private keys without password protection

What is the new behavior (if this is a feature change)?
With this change, there is a new option to give the ssl adapter a "private_key_password" argument, which can be in either string or bytestring format.

📋 Other information:
Added also unit tests to test the new functionality

📋 Contribution checklist:

  • I wrote descriptive pull request text above
  • I think the code is well written
  • I wrote good commit messages
  • I have squashed related commits together after
    the changes have been approved
  • Unit tests for the changes exist
  • Integration tests for the changes exist (if applicable)
  • I used the same coding conventions as the rest of the project
  • The new code doesn't generate linter offenses
  • Documentation reflects the changes
  • The PR relates to only one subject with a clear title
    and description in grammatically correct, complete sentences

This change is Reviewable

@codecov
Copy link

codecov bot commented Jul 16, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.54%. Comparing base (4a8dc43) to head (88e187a).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #752      +/-   ##
==========================================
+ Coverage   79.25%   79.54%   +0.29%     
==========================================
  Files          29       29              
  Lines        4203     4263      +60     
  Branches      539      543       +4     
==========================================
+ Hits         3331     3391      +60     
  Misses        728      728              
  Partials      144      144              

@jatalahd
Copy link
Author

Fixed linter issues with second commit, but now it seems that I need to do the git squash... Anyway, it would be important for us to get this improvement into some upcoming cheroot release, which we can then reference in our pipfile so that we can take these changes into use.

@jatalahd
Copy link
Author

@webknjaz ; I understand that you are busy, but I am pinging you because you seem to be the only administrator actively working on this repository. I am hoping that we could proceed with this pull request until I forget what have I done. I assume at least the review is required, but maybe some other tasks need to be still done until this can be merged?

@webknjaz
Copy link
Member

@jatalahd thanks for the contrib! You don't have to squash commits unconditionally. Only if the commits are non-atomic. If they aren't, it's usually a good idea to combine them to keep Git history clean.

One thing that's definitely missing is a change note. Read https://cheroot.cherrypy.dev/en/latest/contributing/guidelines/#adding-change-notes-with-your-prs and follow the guidelines. Do you best and if it needs editing, I'll tell you.

I can't give you a timeline right now. I'm a bit unhappy about the state of the TLS adapters in general and wanted to redesign them eventually. And so I'm a bit hesitant on what changes would be acceptable in the public API. I'll need to think about it first.

I'll leave a few notes in the diff but that'll be an incomplete review.

@jatalahd
Copy link
Author

@webknjaz ; Thanks for reviewing the code. I fixed all problems you mentioned (as well as I could). Added also change note and had to do the git squash to clean up the git history. Due to that, I had to force push and therefore your review comments disappeared from the "files changed" -tab. However I answered all those comments in this conversation-tab. Please let me know if there is something that still needs fixing.

@jatalahd jatalahd force-pushed the add_private_key_password branch 3 times, most recently from 95bc21c to 6d4f3d2 Compare July 31, 2025 07:36
@jatalahd
Copy link
Author

With the latest two pushes, I fixed one linter issue and one test fail that was occuring on Ubuntu arm platform. To me the CI pipeline results look fine, the remaining test failures are not due to my commit. I consider this now ready for hopefully final review.

@jatalahd
Copy link
Author

jatalahd commented Oct 8, 2025

@webknjaz ; I am still hoping that this improvement will be included in some upcoming cheroot release. We are currently using a workaround of creating a local wheel of cheroot with this change included and that cannot be a sustainable solution in long term.

@webknjaz
Copy link
Member

@jatalahd I've added a few comments + you need to rebase to pick up the recent main. But overall seems good.

@jatalahd jatalahd force-pushed the add_private_key_password branch from 6d4f3d2 to ae8e1bc Compare October 16, 2025 10:13
@jatalahd
Copy link
Author

@webknjaz ; Thank you for review and feedback. I have made fixes as you suggested with my best effort. Made a few additions to unit tests as well. The code is rebased on top of main with the fixes included and force pushed for (hopefully) final review.

f'User-provided password is {len(b_password)} bytes long and will '
f'be truncated since it exceeds the maximum of {password_max_length}.',
UserWarning,
stacklevel=2,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have an example of where this is pointing to on the stack? Any output? Trace?

Copy link
Author

@jatalahd jatalahd Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the trace when the password is too long. The warning is given first and then the exception, because the password did not match in this case:

/Users/xxx/lib/python3.11/site-packages/OpenSSL/SSL.py:805: UserWarning: User-provided password is 2048 bytes long and will be truncated since it exceeds the maximum of 1024.
  return callback(size, verify, self._passphrase_userdata)
Exception in thread Thread-1 (run):
Traceback (most recent call last):
  File "/Users/xxx/temp/xxx/xxx.py", line 53, in run
    self.server.start()
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/cheroot/server.py", line 1966, in start
    self.prepare()
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/cheroot/server.py", line 1880, in prepare
    self.bind(af, socktype, proto)
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/cheroot/server.py", line 2016, in bind
    sock = self.prepare_socket(
           ^^^^^^^^^^^^^^^^^^^^
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/cheroot/server.py", line 2176, in prepare_socket
    sock = ssl_adapter.bind(sock)
           ^^^^^^^^^^^^^^^^^^^^
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/cheroot/ssl/pyopenssl.py", line 326, in bind
    self.context = self.get_context()
                   ^^^^^^^^^^^^^^^^^^
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/cheroot/ssl/pyopenssl.py", line 371, in get_context
    c.use_privatekey_file(self.private_key)
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/OpenSSL/SSL.py", line 1018, in use_privatekey_file
    self._raise_passphrase_exception()
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/OpenSSL/SSL.py", line 994, in _raise_passphrase_exception
    _raise_current_error()
  File "/Users/xxx/.local/share/virtualenvs/xxx/lib/python3.11/site-packages/OpenSSL/_util.py", line 57, in exception_from_error_queue
    raise exception_type(errors)
OpenSSL.SSL.Error: [('Provider routines', '', 'bad decrypt'), ('PKCS12 routines', '', 'pkcs12 cipherfinal error'), ('Provider routines', '', 'bad decrypt'), ('PKCS12 routines', '', 'pkcs12 cipherfinal error'), ('SSL routines', '', 'PEM lib')]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would stacklevel=1 read better?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Differences between 1 and 2:

stacklevel=1:
/pyopenssl.py:354: UserWarning: User-provided password is 2048 bytes long and will be truncated since it exceeds the maximum of 1024.
_warn(

stacklevel=2:
/SSL.py:805: UserWarning: User-provided password is 2048 bytes long and will be truncated since it exceeds the maximum of 1024.
return callback(size, verify, self._passphrase_userdata)

Changed to use 1 for now.

Comment on lines 221 to 223
keyfile_temp_path.write_bytes(encrypted_key_as_bytes)

yield keyfile_temp_path
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be under with so that the .tempile() CM would delete it by itlsef. OTOH, do we actually need to call .tempfile()? what are you after? I'd rather make a key next to the original one, in the same dir.

Copy link
Author

@jatalahd jatalahd Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved under with. You commented on this already 4 months ago, and I managed to get this much cleaner since then. Now it uses the base certificate provided by trustme and only encrypts the output. After this change the yield part is similar to other certificate fixtures. I cannot do any better than this. All the certs are stored in the same temp directory and cleaned after test.

encrypted_key_as_bytes = private_key_object.private_bytes(
encoding=Encoding.PEM,
format=PrivateFormat.PKCS8,
encryption_algorithm=BestAvailableEncryption(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of best encryption, it's better to use the worst. Less compute would contributed to tests running faster.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no other viable option for encryption_algorithm in this part. Only other option is NoEncryption(), which does not serve the purpose.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? Is this some sort of a documented limitation?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#key-serialization

I tried mocking the BestAvailableEncryption and creating custom encryption methods, but no luck. I took timings on my machine, it takes max 3 milliseconds to execute this part of code, typically 1 millisecond. These tests on average take total of 10 to 20 milliseconds to execute, they are far faster than other tests in this test suite.

httpserver.ssl_adapter = tls_adapter

with pytest.raises(OpenSSL.SSL.Error, match=r'.+bad decrypt.+'):
httpserver.prepare()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does pyOpenSSL fail in a different place?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The builtin adapter prepares the server env with a call to already in __init__ while pyopenssl uses the cert only when doing bind. Also it seems that the builtin adapter makes two calls to load the certs, so when starting the server manually, user needs to enter the password twice. So there is a clear difference how builtin and openssl adapters behave in this sense. I am not proficient enough to start making changes to unify the behaviour.

@jatalahd jatalahd force-pushed the add_private_key_password branch from ae8e1bc to b13063a Compare October 17, 2025 11:14
@jatalahd
Copy link
Author

@webknjaz Latest push available with fixes to most of the provided comments. For the comments which I left unresolved I provided an explanation to the answers, which I hope make the issues resolved.

),
)

with tls_certificate.private_key_pem.tempfile() as tf:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems pointless to make the library write something into file only to write on top of it. Let's just use the built-in tmp_path fixture which would also save us some indentation.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to use tmp_path, but I am not happy with it, it leaves the files on my machine. The earlier versions cleaned the files nicely.

- It is now possible to use password protected private keys
  in both builtin and openssl ssl-adapters

- Added also positive and negative unit test cases

- With reference to #1583
@jatalahd jatalahd force-pushed the add_private_key_password branch from b13063a to 88e187a Compare October 22, 2025 05:30
@jatalahd
Copy link
Author

@webknjaz New push provided. It resolves some of the new comments and probably triggers new problems. I have spent a lot of time trying to get an alternative for the BestAvailableEncryption, but I am not able to do that. For what it is worth for code maintenance point of view, keeping the code as is will be stable, since it always uses some supported encryption.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants