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, furszy

If your review is incorrectly listed, please copy-paste <!--meta-tag:bot-skip--> into the comment that the bot should ignore.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #34004 (Implementation of SwiftSync by rustaceanrob)
  • #28690 (build: Introduce internal kernel library by sedited)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

@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 591764f6170d6f74d7eebb5fec1cbf5b912098a4, 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
@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.

@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.

@DrahtBot DrahtBot removed the CI failed label May 6, 2025
@achow101 achow101 self-requested a review October 22, 2025 14:45
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 8bdcd12d3bcbeaf922fa10dc2a261848e3900cfd

@DrahtBot DrahtBot requested a review from theStack November 3, 2025 18:22
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 8bdcd12d3bcbeaf922fa10dc2a261848e3900cfd

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.

ACK 8bdcd12d3bcbeaf922fa10dc2a261848e3900cfd

No need to tackle the nano nits I left.

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));
// Get the weighted average of iterations we can do in 100ms over 2 runs.
Copy link
Member

Choose a reason for hiding this comment

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

In 7176b26:
Pedantic ultra-nano nit:
We finish comments with a dot only if they span more than one line.

Copy link
Contributor Author

@davidgumberg davidgumberg Nov 10, 2025

Choose a reason for hiding this comment

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

Really? I couldn't find this in doc/ and I find a lot of counter-examples when doing:

git grep -n -C 2 "// .*\." '*.cpp' '*.h'

master_key.nDeriveIterations = (master_key.nDeriveIterations + static_cast<unsigned int>(master_key.nDeriveIterations * target / (SteadyClock::now() - start))) / 2;
// target_iterations : elapsed_iterations :: target_time : elapsed_time
unsigned int target_iterations = master_key.nDeriveIterations * target_time / elapsed_time;
// Get the weighted average with previous runs.
Copy link
Member

Choose a reason for hiding this comment

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

In 7176b26:
nano nit: you are already mentioning this above the for-loop line.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fair enough, I think this is adding a little more information than the comment above the for loop, but I'll remove when/if retouching.

start = SteadyClock::now();
crypter.SetKeyFromPassphrase(wallet_passphrase, master_key.vchSalt, master_key.nDeriveIterations, master_key.nDerivationMethod);
master_key.nDeriveIterations = (master_key.nDeriveIterations + static_cast<unsigned int>(master_key.nDeriveIterations * target / (SteadyClock::now() - start))) / 2;
// target_iterations : elapsed_iterations :: target_time : elapsed_time
Copy link
Member

Choose a reason for hiding this comment

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

In 7176b26cde7cbaffdd92af9c25f85f8e5233e78a "refactor: Generalize derivation target calculation"

This comment does not seem helpful, I'm not sure what information it is trying to convey.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

https://en.wikipedia.org/wiki/Ratio#Notation_and_terminology (archive link)

This comment explains why the assignment below is correct. This can be read as "target_iterations is to elapsed_iterations as target_time is to elapsed_time", this is mostly equivalent to: $\frac{\text{targetiterations}}{\text{elapsediterations}} = \frac{\text{targettime}}{\text{elapsedtime}}$

// Skip actually encrypting wallet on the overhead measuring run, so we
// can subtract the overhead from the full benchmark results to get
// encryption performance.
if (!measure_overhead) {
Copy link
Member

Choose a reason for hiding this comment

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

In 26442fec572da263765c576c080186c84cee1615 "bench: Add wallet encryption benchmark"

We've generally not had "measure overhead" in the benchmarks. If this is because encryption is actually a very small amount of time, I would suggest generating significantly more keys that need to actually be encrypted.

Copy link
Contributor Author

@davidgumberg davidgumberg Dec 10, 2025

Choose a reason for hiding this comment

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

Unfortunately, even after generating more keys, there is still significant overhead, and this overhead is not fixed, it scales with the number of keys...

Even though total - overhead isn't something we've measured in any existing benchmarks, it's probably a good idea for some benchmarks. It won't make a difference for catching extreme regressions, but for smaller performance regressions / improvements, the setup overhead in each run adds noise that makes changes harder to measure. Ideally there would be some way to achieve this with nanobench, but for now this hasn't been implemented and the maintainer of nanobench has suggested this approach: martinus/nanobench#86 (comment)

@glozow
Copy link
Member

glozow commented Dec 9, 2025

IIRC we're waiting on @davidgumberg to address the comments from @achow101 's reviews #31774 (comment) here?

@davidgumberg davidgumberg force-pushed the 1-31-25-aes-secure-alloc branch 2 times, most recently from 07497f0 to b8048b1 Compare December 9, 2025 22:35
@DrahtBot
Copy link
Contributor

DrahtBot commented Dec 9, 2025

🚧 At least one of the CI tasks failed.
Task Windows-cross to x86_64, msvcrt: https://github.com/bitcoin/bitcoin/actions/runs/20080403030/job/57605821707
LLM reason (✨ experimental): Linker failure from multiple definitions in lockedpool.cpp (duplicate symbols across modules).

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 b8048b1 to e54690a Compare December 9, 2025 23:49
@davidgumberg
Copy link
Contributor Author

git range-diff 68ac9f1..8bdcd12 9890058..e54690a

Rebased against master to fix a silent merge conflict with lockedpool.cpp being moved from util -> kernel in aed38ea, and addressed @achow101's comment about the previous benchmark only measuring encryption of a single key, I've updated the benchmarks to test encryption of 2000 keys, the performance impact is still negligible:

branch EncryptWallet (total - overhead) total ns/key overhead ns/key
master + benchmarks (8abee2b) 192,652.62 ns 293,553.20 ns 104,682.68 ns
branch (e54690a) 192,772.32 ns 294,794.46 ns 104,329.09 ns

Benchmark details

To set up, I cleaned my build dir and used pyperf. ```bash rm -rf ./build pip install pyperf && sudo pyperf system tune ```

Master + benchmarks (8abee2b)

git checkout 8abee2b
cmake -B build -DBUILD_BENCH=ON && cmake --build build -j $(nproc)
./build/bin/bench_bitcoin --filter="WalletEncrypt.*" -min-time=12000
ns/key key/s err% ins/key cyc/key IPC bra/key miss% total benchmark
293,553.20 3,406.54 0.1% 6,848,134.51 1,258,326.47 5.442 151,429.70 0.9% 12.91 WalletEncryptDescriptors
104,682.68 9,552.68 0.1% 2,329,994.28 448,813.08 5.191 74,084.65 0.9% 13.02 WalletEncryptDescriptorsBenchOverhead

Branch (e54690a)

git checkout e54690a
cmake -B build -DBUILD_BENCH=ON && cmake --build build -j $(nproc)
./build/bin/bench_bitcoin --filter="WalletEncrypt.*" -min-time=12000
ns/key key/s err% ins/key cyc/key IPC bra/key miss% total benchmark
294,794.46 3,392.19 0.3% 6,855,246.26 1,264,321.32 5.422 153,125.52 0.9% 12.97 WalletEncryptDescriptors
104,329.09 9,585.05 0.1% 2,330,778.18 447,414.99 5.209 74,203.06 0.8% 12.94 WalletEncryptDescriptorsBenchOverhead

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

8 participants