Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,11 @@ target/

/guix-build-*

# libFuzzer artifacts, written to the working directory
/crash-*
/leak-*
/oom-*
/slow-unit-*
/timeout-*

/ci/scratch/
3 changes: 2 additions & 1 deletion ci/test/03_test_script.sh
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ if [ "$RUN_FUZZ_TESTS" = "true" ]; then
--require_qbit_corpus \
--mutate_min_time="${QBIT_FUZZ_MUTATE_MIN_TIME}" \
"${DIR_FUZZ_IN}" \
asert_chain_transition asert_edge_cases asert_math auxpow mlkem mlkem_backend_diff p2mr_script pqc
asert_chain_transition asert_edge_cases asert_math auxpow mlkem mlkem_backend_diff p2mr_script \
p2p_transport_bidirectional_v1v2 p2p_transport_bidirectional_v2 p2p_v2_pq_malicious_peer pq_records pqc
fi
fi
381 changes: 381 additions & 0 deletions contrib/devtools/generate-pq-transport-vectors.py

Large diffs are not rendered by default.

507 changes: 507 additions & 0 deletions doc/design/pq-transport.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions doc/development/fuzzing.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ $ ctest --test-dir build_fuzz -R '^required_fuzz_targets$' --output-on-failure
## qbit seed corpora

`qa-assets` has no inputs for `asert_chain_transition`, `asert_edge_cases`,
`asert_math`, `auxpow`, `mlkem`, `mlkem_backend_diff`, `p2mr_script` or `pqc`.
`asert_math`, `auxpow`, `mlkem`, `mlkem_backend_diff`, `p2mr_script`,
`p2p_v2_pq_malicious_peer`, `pq_records` or `pqc`, and its inputs for
`p2p_transport_bidirectional_v2` and `p2p_transport_bidirectional_v1v2` predate
the hybrid v2 negotiation.
Generated seeds for these
targets, their manifest and the input format are described in
[`test/fuzz/qbit_corpora/README.md`](../../test/fuzz/qbit_corpora/README.md).
Expand All @@ -52,7 +55,7 @@ $ test/fuzz/qbit_corpora/overlay.py qa-assets/fuzz_corpora
$ build_fuzz/test/fuzz/test_runner.py --require_qbit_corpus qa-assets/fuzz_corpora
```

`--require_qbit_corpus` fails if any of the eight targets is not compiled or not
`--require_qbit_corpus` fails if any of the twelve targets is not compiled or not
selected (including through `--exclude`), has no regular input files, or is not
reported as replayed by the fuzz executable. The runner prints the number of
input files present and replayed for each target.
Expand All @@ -68,7 +71,7 @@ replay with a mutation phase of that many seconds per target. The phase starts
from the corpus, writes new inputs to a temporary directory and leaves the
corpus unchanged. It fails on non-libFuzzer builds. The nightly native fuzz job
sets `QBIT_FUZZ_MUTATE_MIN_TIME`, which makes `ci/test/03_test_script.sh` run
this phase for the eight targets after the normal replay.
this phase for the twelve targets after the normal replay.

The budget is libFuzzer's `-max_total_time`, which counts time spent loading
the seed corpus (libFuzzer's `INITED` line) as well as mutation. A run can end
Expand Down
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ add_library(bitcoin_node STATIC EXCLUDE_FROM_ALL
auxpow_validation.cpp
banman.cpp
bip324.cpp
bip324_pq.cpp
blockencodings.cpp
blockfilter.cpp
consensus/tx_verify.cpp
Expand Down
93 changes: 92 additions & 1 deletion src/bip324.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
#include <crypto/chacha20.h>
#include <crypto/chacha20poly1305.h>
#include <crypto/hkdf_sha256_32.h>
#include <crypto/sha256.h>
#include <key.h>
#include <pubkey.h>
#include <random.h>
#include <serialize.h>
#include <span.h>
#include <support/cleanse.h>
#include <uint256.h>
Expand All @@ -22,6 +24,20 @@
#include <iterator>
#include <string>

namespace {

/** Serialization stream that feeds every written byte into a running SHA256, without copying. */
class TranscriptWriter
{
CSHA256& m_hasher;

public:
explicit TranscriptWriter(CSHA256& hasher) noexcept : m_hasher{hasher} {}
void write(std::span<const std::byte> src) noexcept { m_hasher.Write(UCharCast(src.data()), src.size()); }
};

} // namespace

BIP324Cipher::BIP324Cipher(const CKey& key, std::span<const std::byte> ent32) noexcept
: m_key(key)
{
Expand All @@ -31,7 +47,12 @@ BIP324Cipher::BIP324Cipher(const CKey& key, std::span<const std::byte> ent32) no
BIP324Cipher::BIP324Cipher(const CKey& key, const EllSwiftPubKey& pubkey) noexcept :
m_key(key), m_our_pubkey(pubkey) {}

void BIP324Cipher::Initialize(const EllSwiftPubKey& their_pubkey, bool initiator, bool self_decrypt) noexcept
BIP324Cipher::~BIP324Cipher()
{
DiscardHybridSecret();
}

void BIP324Cipher::Initialize(const EllSwiftPubKey& their_pubkey, bool initiator, bool self_decrypt, bool retain_for_hybrid) noexcept
{
// Determine salt (fixed string + network magic bytes)
const auto& message_header = Params().MessageStart();
Expand Down Expand Up @@ -63,13 +84,83 @@ void BIP324Cipher::Initialize(const EllSwiftPubKey& their_pubkey, bool initiator
// Derive session id from shared secret.
hkdf.Expand32("session_id", UCharCast(m_session_id.data()));

// Keep what SwitchToHybrid() needs: the ECDH secret itself, the transcript started with the
// actual-role public keys (initiator first), the network magic and the cipher direction.
if (retain_for_hybrid) {
m_retained_ecdh = ecdh_secret;
m_hybrid_transcript.emplace();
const EllSwiftPubKey& ellswift_initiator{initiator ? m_our_pubkey : their_pubkey};
const EllSwiftPubKey& ellswift_responder{initiator ? their_pubkey : m_our_pubkey};
m_hybrid_transcript->Write(UCharCast(ellswift_initiator.data()), ellswift_initiator.size());
m_hybrid_transcript->Write(UCharCast(ellswift_responder.data()), ellswift_responder.size());
std::copy(std::begin(message_header), std::end(message_header), m_hybrid_magic.begin());
m_hybrid_send_is_initiator = side;
}

// Wipe all variables that contain information which could be used to re-derive encryption keys.
memory_cleanse(ecdh_secret.data(), ecdh_secret.size());
memory_cleanse(hkdf_32_okm.data(), sizeof(hkdf_32_okm));
memory_cleanse(&hkdf, sizeof(hkdf));
m_key = CKey();
}

bool BIP324Cipher::AddVersionContents(std::span<const std::byte> contents) noexcept
{
if (!m_hybrid_transcript || m_version_contents_count >= 2) return false;
TranscriptWriter writer{*m_hybrid_transcript};
WriteCompactSize(writer, contents.size());
writer.write(contents);
++m_version_contents_count;
return true;
}

bool BIP324Cipher::SwitchToHybrid(std::span<const std::byte, 32> ss_mlkem) noexcept
{
if (!*this || !m_retained_ecdh || !m_hybrid_transcript || m_version_contents_count != 2 || m_hybrid_switched) return false;

// Determine salt (fixed string + network magic bytes, as snapshotted by Initialize).
std::string salt = std::string{"qbit_v2_hybrid_mlkem1024"} + std::string(std::begin(m_hybrid_magic), std::end(m_hybrid_magic));

// IKM = ss_ECDH (32) || ss_MLKEM (32) || TH (32), where finishing the transcript gives TH.
std::array<std::byte, ECDH_SECRET_SIZE + 32 + CSHA256::OUTPUT_SIZE> ikm;
std::copy(m_retained_ecdh->begin(), m_retained_ecdh->end(), ikm.begin());
std::copy(ss_mlkem.begin(), ss_mlkem.end(), ikm.begin() + ECDH_SECRET_SIZE);
m_hybrid_transcript->Finalize(UCharCast(ikm.data() + ECDH_SECRET_SIZE + 32));

// Derive the hybrid keys, and re-emplace all four ciphers, so their counters start at 0.
bool side = m_hybrid_send_is_initiator;
CHKDF_HMAC_SHA256_L32 hkdf(UCharCast(ikm.data()), ikm.size(), salt);
std::array<std::byte, 32> hkdf_32_okm;
hkdf.Expand32("initiator_L", UCharCast(hkdf_32_okm.data()));
(side ? m_send_l_cipher : m_recv_l_cipher).emplace(hkdf_32_okm, REKEY_INTERVAL);
hkdf.Expand32("initiator_P", UCharCast(hkdf_32_okm.data()));
(side ? m_send_p_cipher : m_recv_p_cipher).emplace(hkdf_32_okm, REKEY_INTERVAL);
hkdf.Expand32("responder_L", UCharCast(hkdf_32_okm.data()));
(side ? m_recv_l_cipher : m_send_l_cipher).emplace(hkdf_32_okm, REKEY_INTERVAL);
hkdf.Expand32("responder_P", UCharCast(hkdf_32_okm.data()));
(side ? m_recv_p_cipher : m_send_p_cipher).emplace(hkdf_32_okm, REKEY_INTERVAL);

// Replace the session id. The garbage terminators are not re-derived.
hkdf.Expand32("session_id", UCharCast(m_session_id.data()));

// Wipe everything that could re-derive the keys, including the retained secret.
memory_cleanse(ikm.data(), ikm.size());
memory_cleanse(hkdf_32_okm.data(), hkdf_32_okm.size());
memory_cleanse(&hkdf, sizeof(hkdf));
DiscardHybridSecret();
m_hybrid_switched = true;
return true;
}

void BIP324Cipher::DiscardHybridSecret() noexcept
{
if (m_retained_ecdh) {
memory_cleanse(m_retained_ecdh->data(), m_retained_ecdh->size());
m_retained_ecdh.reset();
}
m_hybrid_transcript.reset();
}

void BIP324Cipher::Encrypt(std::span<const std::byte> contents, std::span<const std::byte> aad, bool ignore, std::span<std::byte> output) noexcept
{
assert(output.size() == contents.size() + EXPANSION);
Expand Down
44 changes: 43 additions & 1 deletion src/bip324.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>

#include <crypto/chacha20.h>
#include <crypto/chacha20poly1305.h>
#include <crypto/sha256.h>
#include <key.h>
#include <pubkey.h>
#include <span.h>
Expand Down Expand Up @@ -40,10 +42,23 @@ class BIP324Cipher
std::array<std::byte, GARBAGE_TERMINATOR_LEN> m_send_garbage_terminator;
std::array<std::byte, GARBAGE_TERMINATOR_LEN> m_recv_garbage_terminator;

/** Hybrid key schedule state, only kept when Initialize() was called with retain_for_hybrid. */
std::optional<ECDHSecret> m_retained_ecdh;
std::optional<CSHA256> m_hybrid_transcript;
std::array<uint8_t, 4> m_hybrid_magic{};
uint8_t m_version_contents_count{0};
bool m_hybrid_send_is_initiator{false};
bool m_hybrid_switched{false};

public:
/** No default constructor; keys must be provided to create a BIP324Cipher. */
BIP324Cipher() = delete;

/** Wipes any retained hybrid secret. */
~BIP324Cipher();
BIP324Cipher(const BIP324Cipher&) = delete;
BIP324Cipher& operator=(const BIP324Cipher&) = delete;

/** Initialize a BIP324 cipher with specified key and encoding entropy (testing only). */
BIP324Cipher(const CKey& key, std::span<const std::byte> ent32) noexcept;

Expand All @@ -58,8 +73,35 @@ class BIP324Cipher
* initiator is set to true if we are the initiator establishing the v2 P2P connection.
* self_decrypt is only for testing, and swaps encryption/decryption keys, so that encryption
* and decryption can be tested without knowing the other side's private key.
* retain_for_hybrid keeps the ECDH secret and starts the hybrid transcript, so that
* SwitchToHybrid() can later replace the keys. Without it, the ECDH secret is wiped here.
*/
void Initialize(const EllSwiftPubKey& their_pubkey, bool initiator, bool self_decrypt = false,
bool retain_for_hybrid = false) noexcept;

/** Add the full version packet contents of one side to the hybrid transcript, the
* responder's first and the initiator's second.
*
* Returns false, changing nothing, unless Initialize() retained the hybrid state, no
* DiscardHybridSecret() or SwitchToHybrid() happened since, and fewer than two contents
* were added.
*/
void Initialize(const EllSwiftPubKey& their_pubkey, bool initiator, bool self_decrypt = false) noexcept;
[[nodiscard]] bool AddVersionContents(std::span<const std::byte> contents) noexcept;

/** Replace all four packet ciphers (counters at 0) and the session id with keys derived from
* the retained ECDH secret, the ML-KEM shared secret and the transcript. The garbage
* terminators are unchanged, and the retained secret is wiped.
*
* Returns false, changing nothing, unless the cipher is initialized, the hybrid state is
* retained, both version contents were added and no earlier switch happened.
*/
[[nodiscard]] bool SwitchToHybrid(std::span<const std::byte, 32> ss_mlkem) noexcept;

/** Wipe the retained ECDH secret and reset the transcript; the current ciphers stay in use. */
void DiscardHybridSecret() noexcept;

/** Whether the retained ECDH secret or the transcript is still held. */
bool HoldsHybridSecret() const noexcept { return m_retained_ecdh.has_value() || m_hybrid_transcript.has_value(); }

/** Determine whether this cipher is fully initialized. */
explicit operator bool() const noexcept { return m_send_l_cipher.has_value(); }
Expand Down
Loading
Loading