From 43eab5f8cd0216bc4bc59967108a8743234a4a7c Mon Sep 17 00:00:00 2001 From: Krystie Date: Sat, 4 Jul 2026 19:35:00 -0700 Subject: [PATCH] test: add wallet-encryption (CCrypter) coverage crypter.cpp had zero tests despite guarding every encrypted wallet. Add 8 cases: passphrase round-trip for both KDFs (sha512 method 0 and scrypt method 1), wrong-passphrase rejection, salt-affects-key, KDF determinism, bad-parameter rejection (zero rounds / short salt / encrypt-before-key), the EncryptSecret/DecryptSecret private-key path with a uint256 IV, and ciphertext-tamper rejection. Round-trip/negative style, no brittle hard-coded ciphertext. No implementation change (crypter.cpp is correct). Note captured in the test: the wallet uses a uint256 as the AES IV but AES-256-CBC consumes only the first 16 (little-endian) memory bytes -- a subtlety worth remembering for anyone touching the key-encryption path. --- src/test/crypter_tests.cpp | 160 +++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/test/crypter_tests.cpp diff --git a/src/test/crypter_tests.cpp b/src/test/crypter_tests.cpp new file mode 100644 index 0000000..a13fea6 --- /dev/null +++ b/src/test/crypter_tests.cpp @@ -0,0 +1,160 @@ +// Wallet-encryption (CCrypter) tests. Added 2026-07-04 during the test audit. +// crypter.cpp had ZERO coverage despite guarding every encrypted wallet: a +// bug here corrupts keys or weakens protection. These are round-trip, +// negative, and determinism checks (no brittle hard-coded ciphertext). +#include + +#include "../crypter.h" +#include "../key.h" + +#include +#include + +BOOST_AUTO_TEST_SUITE(crypter_tests) + +static std::vector Salt8(unsigned char seed) +{ + return std::vector(WALLET_CRYPTO_SALT_SIZE, seed); +} + +static CKeyingMaterial MakePlain(const std::string& s) +{ + return CKeyingMaterial(s.begin(), s.end()); +} + +// sha512 KDF (method 0): passphrase -> encrypt -> decrypt round-trips. +BOOST_AUTO_TEST_CASE(passphrase_roundtrip_sha512) +{ + CCrypter c; + BOOST_REQUIRE(c.SetKeyFromPassphrase(SecureString("correct horse"), Salt8(0x11), 1000, 0)); + + CKeyingMaterial plain = MakePlain("a 32-byte secret payload here!!"); + std::vector cipher; + BOOST_REQUIRE(c.Encrypt(plain, cipher)); + BOOST_CHECK(cipher.size() >= plain.size()); + BOOST_CHECK(cipher != std::vector(plain.begin(), plain.end())); + + CKeyingMaterial out; + BOOST_REQUIRE(c.Decrypt(cipher, out)); + BOOST_CHECK(out == plain); +} + +// scrypt KDF (method 1) round-trips too. +BOOST_AUTO_TEST_CASE(passphrase_roundtrip_scrypt) +{ + CCrypter c; + BOOST_REQUIRE(c.SetKeyFromPassphrase(SecureString("correct horse"), Salt8(0x22), 100, 1)); + + CKeyingMaterial plain = MakePlain("scrypt-derived key path payload"); + std::vector cipher; + BOOST_REQUIRE(c.Encrypt(plain, cipher)); + CKeyingMaterial out; + BOOST_REQUIRE(c.Decrypt(cipher, out)); + BOOST_CHECK(out == plain); +} + +// A different passphrase derives a different key: decryption must NOT recover +// the plaintext (AES-CBC padding check rejects the wrong key). +BOOST_AUTO_TEST_CASE(wrong_passphrase_fails) +{ + std::vector salt = Salt8(0x33); + CCrypter good; + BOOST_REQUIRE(good.SetKeyFromPassphrase(SecureString("right pass"), salt, 1000, 0)); + CKeyingMaterial plain = MakePlain("top secret wallet material x"); + std::vector cipher; + BOOST_REQUIRE(good.Encrypt(plain, cipher)); + + CCrypter bad; + BOOST_REQUIRE(bad.SetKeyFromPassphrase(SecureString("wrong pass"), salt, 1000, 0)); + CKeyingMaterial out; + bool ok = bad.Decrypt(cipher, out); + // Either the padding check fails outright, or (rarely) it "succeeds" with + // garbage — in no case may it recover the real plaintext. + BOOST_CHECK(!ok || out != plain); +} + +// Different salt => different derived key => different ciphertext. +BOOST_AUTO_TEST_CASE(salt_affects_key) +{ + CKeyingMaterial plain = MakePlain("same plaintext, two salts here"); + CCrypter a, b; + BOOST_REQUIRE(a.SetKeyFromPassphrase(SecureString("pw"), Salt8(0x01), 1000, 0)); + BOOST_REQUIRE(b.SetKeyFromPassphrase(SecureString("pw"), Salt8(0x02), 1000, 0)); + std::vector ca, cb; + BOOST_REQUIRE(a.Encrypt(plain, ca)); + BOOST_REQUIRE(b.Encrypt(plain, cb)); + BOOST_CHECK(ca != cb); +} + +// Same passphrase+salt+rounds is deterministic (fixed key+IV, AES-CBC). +BOOST_AUTO_TEST_CASE(derivation_is_deterministic) +{ + CKeyingMaterial plain = MakePlain("deterministic check payload!!"); + CCrypter a, b; + BOOST_REQUIRE(a.SetKeyFromPassphrase(SecureString("pw"), Salt8(0x44), 2000, 0)); + BOOST_REQUIRE(b.SetKeyFromPassphrase(SecureString("pw"), Salt8(0x44), 2000, 0)); + std::vector ca, cb; + BOOST_REQUIRE(a.Encrypt(plain, ca)); + BOOST_REQUIRE(b.Encrypt(plain, cb)); + BOOST_CHECK(ca == cb); +} + +// Bad parameters are rejected: zero rounds and wrong salt length. +BOOST_AUTO_TEST_CASE(bad_params_rejected) +{ + CCrypter c; + BOOST_CHECK(!c.SetKeyFromPassphrase(SecureString("pw"), Salt8(0x55), 0, 0)); + std::vector shortSalt(WALLET_CRYPTO_SALT_SIZE - 1, 0x00); + BOOST_CHECK(!c.SetKeyFromPassphrase(SecureString("pw"), shortSalt, 1000, 0)); + // Encrypt before any key is set must fail. + CCrypter unset; + std::vector cipher; + BOOST_CHECK(!unset.Encrypt(MakePlain("x"), cipher)); +} + +// The actual wallet key-encryption path: EncryptSecret/DecryptSecret with a +// 32-byte master key and a uint256 IV round-trips a private-key-sized secret. +BOOST_AUTO_TEST_CASE(encrypt_secret_roundtrip) +{ + CKeyingMaterial master(WALLET_CRYPTO_KEY_SIZE, 0xAB); + uint256 iv("0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"); + + CSecret secret; + for (int i = 0; i < 32; i++) secret.push_back((unsigned char)(i * 7 + 1)); + + std::vector cipher; + BOOST_REQUIRE(EncryptSecret(master, secret, iv, cipher)); + BOOST_CHECK(cipher.size() >= secret.size()); + + CSecret recovered; + BOOST_REQUIRE(DecryptSecret(master, cipher, iv, recovered)); + BOOST_CHECK(recovered == secret); + + // Wrong IV must not recover the secret. NOTE: uint256 hex is big-endian + // for display but little-endian in memory, and AES-256-CBC uses only the + // FIRST 16 memory bytes as the IV. So we must perturb a low-order byte + // (the trailing hex pair), which maps to memory byte 0 -- inside the AES + // IV window. A wrong IV corrupts the first plaintext block, so the full + // 32-byte secret cannot be recovered intact. + uint256 iv2("0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f21"); + CSecret wrong; + bool ok = DecryptSecret(master, cipher, iv2, wrong); + BOOST_CHECK(!ok || wrong != secret); +} + +// Flipping a ciphertext byte must break decryption (padding/integrity). +BOOST_AUTO_TEST_CASE(tampered_ciphertext_fails) +{ + CCrypter c; + BOOST_REQUIRE(c.SetKeyFromPassphrase(SecureString("pw"), Salt8(0x66), 1000, 0)); + CKeyingMaterial plain = MakePlain("integrity of this block matters"); + std::vector cipher; + BOOST_REQUIRE(c.Encrypt(plain, cipher)); + + cipher[cipher.size() - 1] ^= 0x01; // corrupt last block + CKeyingMaterial out; + bool ok = c.Decrypt(cipher, out); + BOOST_CHECK(!ok || out != plain); +} + +BOOST_AUTO_TEST_SUITE_END()