Skip to content

Conversation

davidgumberg
Copy link
Contributor

Fixes #31744

Reuse secure_allocator for AES256_ctx in the aes 256 encrypters and decrypters and the iv of AES256CBC encrypters and decrypters. These classes are relevant to CCrypter, used for encrypting wallets, and my understanding is that if an attacker knows some or all of the contents of these data structures (AES256_ctx & iv) they might be able to decrypt a user's wallet.

Presently the secure_allocator tries to protect sensitive data with mlock() on POSIX systems and VirtualLock() on Windows to prevent memory being paged to disk, and by zero'ing out memory contents on deallocation with memory_cleanse() which is similar to OPENSSL_cleanse() by scaring compilers away from optimizing memset calls on non-Windows systems, and using SecureZeroMemory() on Windows.

@DrahtBot
Copy link
Contributor

DrahtBot commented Jan 31, 2025

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/31774.

Reviews

See the guideline for information on the review process.

Type Reviewers
Stale ACK sipa, theStack

If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

Conflicts

No conflicts as of last run.

@DrahtBot
Copy link
Contributor

🚧 At least one of the CI tasks failed.
Debug: https://github.com/bitcoin/bitcoin/runs/36501336640

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

@theStack
Copy link
Contributor

theStack commented Feb 4, 2025

Concept ACK on clearing out the ctx/iv members

I'm wondering if a minimum-diff fix which simply replaces the memset calls in the dtors with memory_cleanse would be largely sufficient here? In #31166 (comment) one argument for not needing secure allocators was the short-lived nature of the secrets. Looking at the only usage in the wallet, this would imho apply here too (en/decrypting might take a while for larger inputs, but I still wouldn't classify that as long-lived):

AES256CBCEncrypt enc(vchKey.data(), vchIV.data(), true);
size_t nLen = enc.Encrypt(vchPlaintext.data(), vchPlaintext.size(), vchCiphertext.data());
if(nLen < vchPlaintext.size())
return false;
vchCiphertext.resize(nLen);
return true;

AES256CBCDecrypt dec(vchKey.data(), vchIV.data(), true);
int len = dec.Decrypt(ciphertext.data(), ciphertext.size(), plaintext.data());
if (len == 0) {
return false;
}
plaintext.resize(len);
return true;

The reasoning might be a bit loose as there might be other uses of these classes in the future where the situation is different. Curious about other opinions.

@sipa
Copy link
Member

sipa commented Feb 4, 2025

I think the approach here might have an unacceptable performance impact, as it is allocating and deallocating a secure area for every individual key being encrypted/decrypted.

It may be better to:

  • Somehow make the AES256CBC classes members of CCrypter, surviving an individual encryption/decryption (e.g. by adding a reset function that can get called for each encryption/decryption, resetting the CBC state, but letting the key material survive).
  • Follow @theStack's suggestion of not locking the memory, but just securely wiping it after each operation.

@davidgumberg davidgumberg force-pushed the 1-31-25-aes-secure-alloc branch 2 times, most recently from 046c5e4 to a3be398 Compare February 8, 2025 00:50
@DrahtBot
Copy link
Contributor

DrahtBot commented Feb 8, 2025

🚧 At least one of the CI tasks failed.
Debug: https://github.com/bitcoin/bitcoin/runs/36882999841

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

@davidgumberg davidgumberg force-pushed the 1-31-25-aes-secure-alloc branch 2 times, most recently from fda2ec1 to 15d8500 Compare February 8, 2025 01:05
@davidgumberg
Copy link
Contributor Author

davidgumberg commented Feb 8, 2025

I'm wondering if a minimum-diff fix which simply replaces the memset calls in the dtors with memory_cleanse would be largely sufficient here? In #31166 (comment) one argument for not needing secure allocators was the short-lived nature of the secrets.

It seems right to me that this structure is always short lived, but I also felt in #31166 secure_allocator should have been used over just memory_cleanse(). I feel that the alloc/dealloc strategy for secrets in memory should be reused and applied universally unless there is a good reason not to.


I think the approach here might have an unacceptable performance impact, as it is allocating and deallocating a secure area for every individual key being encrypted/decrypted.

I added a benchmark for WalletEncrypt() which I ran a few times and it appears that the performance impact is neglible on my machine (Ryzen 7900x, Fedora 40), if the benchmark I wrote is actually representative:

wallet type branch EncryptWallet() (ns/key) benchmark overhead (ns/key) normalized value (total - overhead) flamegraph
Descriptor 15d8500 42,106.55 36,235.03 5,871.53 link
Descriptor master 42,050.82 36,362.40 5,688.42 link
Legacy 15d8500 17,067.82 2,779.16 14,288.6 link
Legacy master 16,812.63 2,757.26 14,055.57 link

But if the short-lived nature of the key material makes just using memory_cleanse() a good-enough solution over having to reason about whether the benchmark is sufficiently correct, and whether the larger / more complicated diff required to reuse secure_allocator is worth the review effort / risk, I'm happy to change it.

@laanwj
Copy link
Member

laanwj commented Mar 27, 2025

I think the approach here might have an unacceptable performance impact, as it is allocating and deallocating a secure area for every individual key being encrypted/decrypted.

This may still be true, though FWIW the idea behind the LockedPool is that it reduces the amount of locking/unlocking operations by mapping and locking memory in blocks, not every time it's requested.

But agree that for short-lived key material, it's less important, there is little to no chance of it ending up in swap anyway.

@DrahtBot
Copy link
Contributor

DrahtBot commented May 1, 2025

🚧 At least one of the CI tasks failed.
Task ARM, unit tests, no functional tests: https://github.com/bitcoin/bitcoin/runs/41468173464
LLM reason (✨ experimental): The CI failure is caused by an assertion failure in wallet creation during the bench_sanity_check test.

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

@davidgumberg davidgumberg force-pushed the 1-31-25-aes-secure-alloc branch from 95d266f to 591764f Compare May 1, 2025 05:43
@davidgumberg
Copy link
Contributor Author

Rebased to address merge conflict, dropped legacy wallet encryption benchmark, and reduced number of keys since the benchmark was taking an unreasonably long time.

@DrahtBot DrahtBot removed the CI failed label May 1, 2025
Copy link
Member

@sipa sipa left a comment

Choose a reason for hiding this comment

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

utACK 591764f, just coding style nits

@DrahtBot DrahtBot requested a review from theStack May 1, 2025 12:56
@davidgumberg davidgumberg force-pushed the 1-31-25-aes-secure-alloc branch from 591764f to 3d7d2c3 Compare May 2, 2025 01:15
@davidgumberg
Copy link
Contributor Author

Rebase to address style feedback.

Copy link
Contributor

@theStack theStack left a comment

Choose a reason for hiding this comment

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

Code-review ACK 3d7d2c3

@DrahtBot DrahtBot requested a review from sipa May 4, 2025 11:30
Copy link
Member

@furszy furszy left a comment

Choose a reason for hiding this comment

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

I don't see why the first commit is necessary. Couldn't you just mock the time so that it's fixed and the number of derivation rounds always stays at the default value?

@davidgumberg
Copy link
Contributor Author

I don't see why the first commit is necessary. Couldn't you just mock the time so that it's fixed and the number of derivation rounds always stays at the default value?

Maybe I'm not thinking creatively enough, but I think because the timing takes place entirely inside of EncryptWallet()->EncryptMasterKey, from the wallet_encrypt.cpp benchmark, I could only mock the clock so that it's static during the derivation "benchmark"1 runs, so the difference between the end time and the start time of a run will be 0 which will result in dividing by 0:

static bool EncryptMasterKey(const SecureString& wallet_passphrase, const CKeyingMaterial& plain_master_key, CMasterKey& master_key)
{
constexpr MillisecondsDouble target{100};
auto start{SteadyClock::now()};
CCrypter crypter;
crypter.SetKeyFromPassphrase(wallet_passphrase, master_key.vchSalt, master_key.nDeriveIterations, master_key.nDerivationMethod);
master_key.nDeriveIterations = static_cast<unsigned int>(master_key.nDeriveIterations * target / (SteadyClock::now() - start));

Footnotes

  1. The ones that decide how many derivations to perform based on hardware speed.

@furszy
Copy link
Member

furszy commented May 5, 2025

so the difference between the end time and the start time of a run will be 0 which will result in dividing by 0:

Yes, it seems simpler and more scoped to address that inside the encryption function (which could arguably be considered a 'fix', since we shouldn’t be dividing by zero regardless of whether the clock time is messed up) than to add a test-only field to the wallet and another function arg just just for this.

@davidgumberg
Copy link
Contributor Author

since we shouldn’t be dividing by zero regardless of whether the clock time is messed up

AFAIK, std::chrono::steady_clock, unlike the system clock, cannot fail to move forward, and this will never happen.

than to add a test-only field to the wallet and another function arg just just for this.

I agree that adding a test-only field and arg is very bad, and would like to avoid it, but it seems to me worse to imbue secret meaning into the benchmark by handling a time difference of 0 in this special way which appears on the surface to be error-handling, but is really a "side-channel" for test code to sneak an extra parameter into EncryptWallet, but I don't feel strongly enough about which of those is worse, so I'll implement your approach.

@davidgumberg davidgumberg force-pushed the 1-31-25-aes-secure-alloc branch 2 times, most recently from 7237df9 to 90528e7 Compare May 6, 2025 04:02
@DrahtBot
Copy link
Contributor

DrahtBot commented May 6, 2025

🚧 At least one of the CI tasks failed.
Task lint: https://github.com/bitcoin/bitcoin/runs/41695411207
LLM reason (✨ experimental): The CI failure is due to issues identified by lint-includes.py and the all_python_linters check.

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

Adds a special case where if the elapsed time during measurement of DKF
performance is 0, the default derive iterations are used so that
behavior is stable for testing and benchmarks.
Allows `crypto` functions and classes to use `secure_allocator`.
@davidgumberg davidgumberg force-pushed the 1-31-25-aes-secure-alloc branch from 90528e7 to 8bdcd12 Compare May 6, 2025 04:13
@davidgumberg
Copy link
Contributor Author

davidgumberg commented May 6, 2025

Refactored to address @furszy feedback to avoid adding a test-only parameter to CWallet, and added a somewhat opinionated refactor (7176b26) of CWallet::EncryptMasterKey() since I was touching the iteration measuring stuff anyways.

I don't like the idea of using a for loop that iterates once with 0 and once with 1, but it's shorter, I believe it is easier to understand how the weighted average calculation is working, and this version generalizes to any number of measurement runs.

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.

crypto: secure erase memory
6 participants