Cache RSA verifier/signer bridge objects and mtime-key PublicKey.from_file - #69942
Cache RSA verifier/signer bridge objects and mtime-key PublicKey.from_file#69942dwoz wants to merge 2 commits into
Conversation
…_file Under sustained load a busy MWorker rebuilds cryptography + libcrypto RSA state on every public-key operation. memray on a stressed 3008.x master showed ~5,000 RSAX931Verifier.__init__ calls per 60 seconds against a matching PublicKey.decrypt call count. Same pattern on the sign side. Three layers of caching: 1. Lazy per-instance _verifier / _signer on PublicKey / PrivateKey. self.key is immutable after __init__, so the derived libcrypto bridge object can be reused for the lifetime of the instance. 2. Path-level cache on PublicKey.from_file keyed on (path, mtime). A key rotation on disk bumps mtime and invalidates the cache automatically. 3. Retry-on-verify-fail in PublicKey.verify / .decrypt. Preserves the pre-cache "always fresh" behavior for edge cases where a rotation preserves mtime (cp -p, NFS mtime cache, atomic rename with preserved timestamps). On the first failure the cache entry is evicted and one reload-and-retry is attempted. Genuine bad signatures still return False / raise ValueError; the retry costs one extra file read + PEM parse per forged attempt. Fixes saltstack#69940
The mtime-keyed cache added in b6b10f8 calls os.path.getmtime() before opening the key file. That changed the error surface for callers (and tests) that expected FileNotFoundError to come from the salt.utils.files.fopen call path -- most notably tests/pytests/unit/crypt/test_crypt_cryptography.py::test_verify_signature, which mocks fopen to return public-key bytes without actually creating the file on disk. Wrap the getmtime call in try/except OSError and fall through to the uncached BaseKey.from_file path when the mtime probe fails. This preserves the original fopen-first error propagation while keeping the cache behavior intact for the normal on-disk case. Add two regression tests: - from_file on a truly missing path still raises FileNotFoundError. - from_file with fopen mocked but no real file returns a PublicKey via the uncached path and does not populate _pub_key_cache.
|
CI fix pushed: Failed test: Root cause: the new mtime-keyed Fix: wrap the Regression tests added:
Local verification: |
| if cached is instance: | ||
| _pub_key_cache.pop(cache_key, None) | ||
| _pub_key_cache_path_index.pop(path, None) | ||
| try: |
There was a problem hiding this comment.
If _pub_key_cache grows over time due to mtime changes replacing key instances without explicit clearing, _pub_key_cache_path_index keeps growing unless an eviction/retry event is triggered. However, given that public keys on a Salt Master are typically limited to minion key directories, memory impact should remain minimal.
What does this PR do?
Fixes #69940.
Eliminates RSA public-key + libcrypto bridge-object churn in
salt/crypt.pyvia three layers of caching:
_verifieronPublicKeyand
_signeronPrivateKey.self.keyis immutable after__init__,so the derived
RSAX931Verifier/RSAX931Signer(libcryptoBIO+RSAallocations, PEM parse) can be reused for the lifetime of theinstance.
PublicKey.from_file. Keyed on(path, mtime)mirroring the 3006.x_get_key_with_evictidiom. A keyrotation on disk bumps mtime and invalidates the cache automatically. The
private-key side (
get_rsa_key) is separately being restored on 3008.xby a sibling PR and is intentionally untouched here.
PublicKey.verify/.decryptfallback to a bounded reload-and-retry when a cached key fails to validate.
Preserves the pre-cache "always fresh" semantics for the corner cases
where a rotation preserves mtime (
cp -p, NFS mtime cache, atomicrename with preserved timestamps). Genuine bad signatures still return
False/ re-raiseValueErrorand cost only one extra file read + PEMparse per forged attempt.
Rotation safety
The pre-cache contract was: every
PublicKey.from_filereturns a freshlyparsed key. Any rotation on disk between calls is visible immediately. The
three layers keep the same guarantee via two independent mechanisms:
stale cache entry and reloading once from disk. Bounded to one retry so
forged signatures cannot induce a loop.
Note:
AsyncAuth._auth_singleton_keyis a separate cache keyed onopts["pki_dir"] + io_loopfor authenticated session state; it does notoverlap with the new path-level public-key cache, which is keyed on the
absolute filesystem path + mtime of the PEM file.
Empirical validation
Standalone repro under
agents/scratch/repro_pubkey_verifier_churn.pyinstantiates one
PublicKeyand calls.decrypt(signed)1000 times withRSAX931Verifier.__init__instrumented:Test plan
tests/pytests/unit/test_crypt.pyfull run: 23 passed, 6 skippedtests/pytests/unit/test_auth.py+tests/pytests/unit/channel/:66 passed
test_publickey_verifier_cached_across_decrypts- 50 decrypts trigger 1verifier construction (was 50 pre-fix)
test_privatekey_signer_cached_across_encrypts- same for signertest_pubkey_from_file_returns_cached_instance- identity checktest_pubkey_from_file_mtime_evicts-os.utimebump forces freshinstance with matching public numbers
test_verify_retries_after_rotation_without_mtime_bump- stale cacheentry + mtime-preserving rotation + valid signature = retry succeeds
test_decrypt_retries_after_rotation_without_mtime_bump- mirror forthe X9.31 decrypt path used by AsyncAuth
test_verify_genuine_bad_sig_returns_false_after_retry- retry neverpapers over real failures
test_decrypt_genuine_bad_payload_raises_after_retry- preservesValueErrorcontractFixes
Fixes #69940