From b143e967ea3d1bed5c22c9a105e6579f4a19fc07 Mon Sep 17 00:00:00 2001
From: Sjors Provoost <sjors@sprovoost.nl>
Date: Thu, 19 Sep 2024 16:18:21 +0200
Subject: [PATCH] Add sv2 noise protocol

Co-Authored-By: Christopher Coverdale <chris.coverdale24@gmail.com>
---
 src/pubkey.h                 |   2 +-
 src/sv2/CMakeLists.txt       |   1 +
 src/sv2/noise.cpp            | 508 +++++++++++++++++++++++++++++++++++
 src/sv2/noise.h              | 299 +++++++++++++++++++++
 src/test/CMakeLists.txt      |   1 +
 src/test/fuzz/CMakeLists.txt |   1 +
 src/test/fuzz/sv2_noise.cpp  | 168 ++++++++++++
 src/test/sv2_noise_tests.cpp | 159 +++++++++++
 8 files changed, 1138 insertions(+), 1 deletion(-)
 create mode 100644 src/sv2/noise.cpp
 create mode 100644 src/sv2/noise.h
 create mode 100644 src/test/fuzz/sv2_noise.cpp
 create mode 100644 src/test/sv2_noise_tests.cpp

diff --git a/src/pubkey.h b/src/pubkey.h
index b4666aad228e1..798687de1fe5a 100644
--- a/src/pubkey.h
+++ b/src/pubkey.h
@@ -319,7 +319,7 @@ struct EllSwiftPubKey
     /** Construct a new ellswift public key from a given serialization. */
     EllSwiftPubKey(Span<const std::byte> ellswift) noexcept;
 
-    /** Decode to normal compressed CPubKey (for debugging purposes). */
+    /** Decode to normal compressed CPubKey. */
     CPubKey Decode() const;
 
     // Read-only access for serialization.
diff --git a/src/sv2/CMakeLists.txt b/src/sv2/CMakeLists.txt
index e02c4c01fa877..d6e44842e8c87 100644
--- a/src/sv2/CMakeLists.txt
+++ b/src/sv2/CMakeLists.txt
@@ -3,6 +3,7 @@
 # file COPYING or https://opensource.org/license/mit/.
 
 add_library(bitcoin_sv2 STATIC EXCLUDE_FROM_ALL
+  noise.cpp
 )
 
 target_link_libraries(bitcoin_sv2
diff --git a/src/sv2/noise.cpp b/src/sv2/noise.cpp
new file mode 100644
index 0000000000000..798450a67246a
--- /dev/null
+++ b/src/sv2/noise.cpp
@@ -0,0 +1,508 @@
+// Copyright (c) 2023-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include <sv2/noise.h>
+
+#include <crypto/chacha20poly1305.h>
+#include <crypto/hmac_sha256.h>
+#include <logging.h>
+#include <util/check.h>
+#include <util/strencodings.h>
+#include <util/time.h>
+
+Sv2SignatureNoiseMessage::Sv2SignatureNoiseMessage(uint16_t version, uint32_t valid_from, uint32_t valid_to, const XOnlyPubKey& static_key, const CKey& authority_key) : m_version{version}, m_valid_from{valid_from}, m_valid_to{valid_to}, m_static_key{static_key}
+{
+    SignSchnorr(authority_key, m_sig);
+}
+
+uint256 Sv2SignatureNoiseMessage::GetHash()
+{
+    DataStream ss{};
+    ss << m_version
+       << m_valid_from
+       << m_valid_to
+       << m_static_key;
+
+    LogTrace(BCLog::SV2, "Certificate hashed data: %s\n", HexStr(ss));
+
+    CSHA256 hasher;
+    hasher.Write(reinterpret_cast<unsigned char*>(&(*ss.begin())), ss.end() - ss.begin());
+
+    uint256 hash_output;
+    hasher.Finalize(hash_output.begin());
+    return hash_output;
+}
+
+bool Sv2SignatureNoiseMessage::Validate(XOnlyPubKey authority_key)
+{
+    if (m_version > 0) {
+        LogTrace(BCLog::SV2, "Invalid certificate version: %d\n", m_version);
+        return false;
+    }
+    auto now{GetTime<std::chrono::seconds>()};
+    if (std::chrono::seconds{m_valid_from} > now) {
+        LogTrace(BCLog::SV2, "Certificate valid from is in the future: %d\n", m_valid_from);
+        return false;
+    }
+    if (std::chrono::seconds{m_valid_to} < now) {
+        LogTrace(BCLog::SV2, "Certificate expired: %d\n", m_valid_to);
+        return false;
+    }
+
+    if (!authority_key.VerifySchnorr(this->GetHash(), m_sig)) {
+        LogTrace(BCLog::SV2, "Certificate signature is invalid\n");
+        return false;
+    }
+    return true;
+}
+
+void Sv2SignatureNoiseMessage::SignSchnorr(const CKey& authority_key, Span<unsigned char> sig)
+{
+    authority_key.SignSchnorr(this->GetHash(), sig, nullptr, {});
+}
+
+Sv2CipherState::Sv2CipherState(NoiseHash&& key) : m_key(std::move(key)) {};
+
+bool Sv2CipherState::DecryptWithAd(Span<const std::byte> associated_data, Span<std::byte> ciphertext, Span<std::byte> plain)
+{
+    Assume(Sv2Cipher::EncryptedMessageSize(plain.size()) == ciphertext.size());
+
+    if (m_nonce == UINT64_MAX) {
+        // This nonce value is reserved, see chapter 5.1 of the Noise paper.
+        LogTrace(BCLog::SV2, "Nonce exceeds maximum value\n");
+        return false;
+    }
+    AEADChaCha20Poly1305::Nonce96 nonce = {0, m_nonce};
+    auto key = MakeByteSpan(m_key);
+    AEADChaCha20Poly1305 aead{key};
+    if (!aead.Decrypt(ciphertext, associated_data, nonce, plain)) {
+        LogTrace(BCLog::SV2, "Message decryption failed\n");
+        return false;
+    }
+    // Only increase nonce if decryption succeeded
+    m_nonce++;
+    return true;
+}
+
+bool Sv2CipherState::EncryptWithAd(Span<const std::byte> associated_data, Span<const std::byte> plain, Span<std::byte> ciphertext)
+{
+    Assume(Sv2Cipher::EncryptedMessageSize(plain.size()) == ciphertext.size());
+
+    if (m_nonce == UINT64_MAX) {
+        // This nonce value is reserved, see chapter 5.1 of the Noise paper.
+        LogTrace(BCLog::SV2, "Nonce exceeds maximum value\n");
+        return false;
+    }
+    AEADChaCha20Poly1305::Nonce96 nonce = {0, m_nonce++};
+    auto key = MakeByteSpan(m_key);
+    AEADChaCha20Poly1305 aead{key};
+    aead.Encrypt(plain, associated_data, nonce, ciphertext);
+    return true;
+}
+
+bool Sv2CipherState::EncryptMessage(Span<const std::byte> plain, Span<std::byte> ciphertext)
+{
+    Assume(ciphertext.size() == Sv2Cipher::EncryptedMessageSize(plain.size()));
+
+    std::vector<std::byte> ad; // No associated data
+
+    constexpr size_t max_chunk_size = NOISE_MAX_CHUNK_SIZE - Poly1305::TAGLEN;
+    size_t num_chunks = (plain.size() + max_chunk_size - 1) / max_chunk_size;
+    if (num_chunks > 1) {
+        LogTrace(BCLog::SV2,
+                 "Split into %d chunks (max %d bytes)\n",
+                 num_chunks, max_chunk_size);
+    }
+
+    // Copy input bytes into output buffer
+    const std::vector<std::byte> padding(Poly1305::TAGLEN, std::byte(0));
+    for (size_t i = 0; i < num_chunks; ++i) {
+        size_t chunk_start = i * max_chunk_size;
+        size_t chunk_end = std::min(chunk_start + max_chunk_size, plain.size());
+        size_t chunk_size = chunk_end - chunk_start;
+        const auto encrypted_chunk_start = ciphertext.begin() + i * NOISE_MAX_CHUNK_SIZE;
+        std::copy(plain.begin() + chunk_start, plain.begin() + chunk_start + chunk_size, encrypted_chunk_start);
+        std::copy(padding.begin(), padding.end(), encrypted_chunk_start + chunk_size);
+    }
+
+    // Encrypt each chunk
+    size_t bytes_written = 0;
+    for (size_t i = 0; i < num_chunks; ++i) {
+        size_t chunk_size = std::min(ciphertext.size() - bytes_written, NOISE_MAX_CHUNK_SIZE);
+        Span<std::byte> chunk = ciphertext.subspan(bytes_written, chunk_size);
+        Span<std::byte> chunk_plain = ciphertext.subspan(bytes_written, chunk_size - Poly1305::TAGLEN);
+        if (!EncryptWithAd(ad, chunk_plain, chunk)) {
+            return false;
+        }
+        bytes_written += chunk.size();
+    }
+
+    Assume(bytes_written == ciphertext.size());
+    return true;
+}
+
+bool Sv2CipherState::DecryptMessage(Span<std::byte> ciphertext, Span<std::byte> plain)
+{
+    Assume(Sv2Cipher::EncryptedMessageSize(plain.size()) == ciphertext.size());
+
+    size_t processed = 0;
+    size_t plain_position = 0;
+    std::vector<std::byte> ad; // No associated data
+
+    while (processed < ciphertext.size()) {
+        size_t chunk_size = std::min(ciphertext.size() - processed, NOISE_MAX_CHUNK_SIZE);
+        Span<std::byte> chunk_cipher = ciphertext.subspan(processed, chunk_size);
+        Span<std::byte> chunk_plain = plain.subspan(plain_position, chunk_size - Poly1305::TAGLEN);
+        if (!DecryptWithAd(ad, chunk_cipher, chunk_plain)) return false;
+        processed += chunk_size;
+        plain_position += chunk_size - Poly1305::TAGLEN;
+    }
+
+    return true;
+}
+
+void Sv2SymmetricState::MixHash(const Span<const std::byte> input)
+{
+    m_hash_output = (HashWriter{} << m_hash_output << input).GetSHA256();
+}
+
+void Sv2SymmetricState::MixKey(const Span<const std::byte> input_key_material)
+{
+    NoiseHash out0;
+    NoiseHash out1;
+    HKDF2(input_key_material, out0, out1);
+    m_chaining_key = std::move(out0);
+    m_cipher_state = Sv2CipherState{std::move(out1)};
+}
+
+std::string Sv2SymmetricState::GetChainingKey()
+{
+    return HexStr(m_chaining_key);
+}
+
+void Sv2SymmetricState::LogChainingKey()
+{
+    LogTrace(BCLog::SV2, "Chaining key: %s\n", GetChainingKey());
+}
+
+void Sv2SymmetricState::HKDF2(const Span<const std::byte> input_key_material, NoiseHash& out0, NoiseHash& out1)
+{
+    NoiseHash tmp_key;
+    CHMAC_SHA256 tmp_mac(m_chaining_key.data(), m_chaining_key.size());
+    tmp_mac.Write(UCharCast(input_key_material.data()), input_key_material.size());
+    tmp_mac.Finalize(tmp_key.data());
+
+    CHMAC_SHA256 out0_mac(tmp_key.data(), tmp_key.size());
+    uint8_t one[1]{0x1};
+    out0_mac.Write(one, 1);
+    out0_mac.Finalize(out0.data());
+
+    std::vector<uint8_t> in1;
+    in1.reserve(HASHLEN + 1);
+    std::copy(out0.begin(), out0.end(), std::back_inserter(in1));
+    in1.push_back(0x02);
+
+    CHMAC_SHA256 out1_mac(tmp_key.data(), tmp_key.size());
+    out1_mac.Write(&in1[0], in1.size());
+    out1_mac.Finalize(out1.data());
+}
+
+bool Sv2SymmetricState::EncryptAndHash(Span<const std::byte> plain, Span<std::byte> ciphertext)
+{
+    Assume(Sv2Cipher::EncryptedMessageSize(plain.size()) == ciphertext.size());
+
+    if (!m_cipher_state.EncryptWithAd(MakeByteSpan(m_hash_output), plain, ciphertext)) {
+        return false;
+    }
+    MixHash(ciphertext);
+    return true;
+}
+
+bool Sv2SymmetricState::DecryptAndHash(Span<std::byte> ciphertext, Span<std::byte> plain)
+{
+    Assume(Sv2Cipher::EncryptedMessageSize(plain.size()) == ciphertext.size());
+
+    // The handshake requires mix hashing the cipher text NOT the decrypted
+    // plaintext.
+    std::vector<std::byte> ciphertext_copy;
+    ciphertext_copy.assign(ciphertext.begin(), ciphertext.end());
+
+    bool res = m_cipher_state.DecryptWithAd(MakeByteSpan(m_hash_output), ciphertext, plain);
+    if (!res) return false;
+    MixHash(ciphertext_copy);
+    return true;
+}
+
+std::array<Sv2CipherState, 2> Sv2SymmetricState::Split()
+{
+    NoiseHash send_key;
+    NoiseHash recv_key;
+    HKDF2({}, send_key, recv_key);
+    return {Sv2CipherState{std::move(send_key)}, Sv2CipherState{std::move(recv_key)}};
+}
+
+uint256 Sv2SymmetricState::GetHashOutput()
+{
+    return m_hash_output;
+}
+
+void Sv2HandshakeState::SetEphemeralKey(CKey&& key)
+{
+    m_ephemeral_key = key;
+    m_ephemeral_ellswift_pk = m_ephemeral_key.EllSwiftCreate(MakeByteSpan(GetRandHash()));
+};
+
+void Sv2HandshakeState::GenerateEphemeralKey() noexcept
+{
+    Assume(!m_ephemeral_key.size());
+    LogTrace(BCLog::SV2, "Generate ephemeral key\n");
+    SetEphemeralKey(GenerateRandomKey());
+};
+
+void Sv2HandshakeState::WriteMsgEphemeralPK(Span<std::byte> msg)
+{
+    if (msg.size() < ELLSWIFT_PUB_KEY_SIZE) {
+        throw std::runtime_error(strprintf("Invalid message size: %d bytes < %d", msg.size(), ELLSWIFT_PUB_KEY_SIZE));
+    }
+
+    if (!m_ephemeral_key.IsValid()) {
+        GenerateEphemeralKey();
+    }
+
+    LogTrace(BCLog::SV2, "Write our ephemeral key\n");
+    std::copy(m_ephemeral_ellswift_pk.begin(), m_ephemeral_ellswift_pk.end(), msg.begin());
+
+    m_symmetric_state.MixHash(msg.subspan(0, ELLSWIFT_PUB_KEY_SIZE));
+    LogTrace(BCLog::SV2, "Mix hash: %s\n", HexStr(m_symmetric_state.GetHashOutput()));
+
+    std::vector<std::byte> empty;
+    m_symmetric_state.MixHash(empty);
+}
+
+void Sv2HandshakeState::ReadMsgEphemeralPK(Span<std::byte> msg)
+{
+    LogTrace(BCLog::SV2, "Read their ephemeral key\n");
+    Assume(msg.size() == ELLSWIFT_PUB_KEY_SIZE);
+    m_remote_ephemeral_ellswift_pk = EllSwiftPubKey(msg);
+
+    m_symmetric_state.MixHash(msg.subspan(0, ELLSWIFT_PUB_KEY_SIZE));
+    LogTrace(BCLog::SV2, "Mix hash: %s\n", HexStr(m_symmetric_state.GetHashOutput()));
+
+    std::vector<std::byte> empty;
+    m_symmetric_state.MixHash(empty);
+}
+
+void Sv2HandshakeState::WriteMsgES(Span<std::byte> msg)
+{
+    if (msg.size() < HANDSHAKE_STEP2_SIZE) {
+        throw std::runtime_error(strprintf("Invalid message size: %d bytes < %d", msg.size(), HANDSHAKE_STEP2_SIZE));
+    }
+
+    ssize_t bytes_written = 0;
+
+    if (!m_ephemeral_key.IsValid()) {
+        GenerateEphemeralKey();
+    }
+
+    // Send our ephemeral pk.
+    LogTrace(BCLog::SV2, "Write our ephemeral key\n");
+    std::copy(m_ephemeral_ellswift_pk.begin(), m_ephemeral_ellswift_pk.end(), msg.begin());
+
+    m_symmetric_state.MixHash(msg.subspan(0, ELLSWIFT_PUB_KEY_SIZE));
+    bytes_written += ELLSWIFT_PUB_KEY_SIZE;
+
+    LogTrace(BCLog::SV2, "Mix hash: %s\n", HexStr(m_symmetric_state.GetHashOutput()));
+
+    LogTrace(BCLog::SV2, "Perform ECDH with the remote ephemeral key\n");
+    ECDHSecret ecdh_secret{m_ephemeral_key.ComputeBIP324ECDHSecret(m_remote_ephemeral_ellswift_pk,
+                                                                   m_ephemeral_ellswift_pk,
+                                                                   /*initiating=*/false)};
+
+    LogTrace(BCLog::SV2, "Mix key with ECDH result: ephemeral ours -- remote ephemeral\n");
+    m_symmetric_state.MixKey(ecdh_secret);
+    m_symmetric_state.LogChainingKey();
+
+    // Send our static pk.
+    LogTrace(BCLog::SV2, "Encrypt and write our static key\n");
+
+    if (!m_symmetric_state.EncryptAndHash(m_static_ellswift_pk, msg.subspan(ELLSWIFT_PUB_KEY_SIZE, ELLSWIFT_PUB_KEY_SIZE + Poly1305::TAGLEN))) {
+        // This should never happen
+        Assume(false);
+        throw std::runtime_error("Failed to encrypt our ephemeral key\n");
+    }
+
+    bytes_written += ELLSWIFT_PUB_KEY_SIZE + Poly1305::TAGLEN;
+
+    LogTrace(BCLog::SV2, "Mix hash: %s\n", HexStr(m_symmetric_state.GetHashOutput()));
+
+    LogTrace(BCLog::SV2, "Perform ECDH between our static and remote ephemeral key\n");
+    ECDHSecret ecdh_static_secret{m_static_key.ComputeBIP324ECDHSecret(m_remote_ephemeral_ellswift_pk,
+                                                                       m_static_ellswift_pk,
+                                                                       /*initiating=*/false)};
+    LogTrace(BCLog::SV2, "ECDH result: %s\n", HexStr(ecdh_static_secret));
+
+    LogTrace(BCLog::SV2, "Mix key with ECDH result: static ours -- remote ephemeral\n");
+    m_symmetric_state.MixKey(ecdh_static_secret);
+    m_symmetric_state.LogChainingKey();
+
+    // Serialize our digital signature noise message and encrypt.
+    DataStream ss{};
+    Assume(m_certificate);
+    ss << m_certificate.value();
+    Assume(ss.size() == Sv2SignatureNoiseMessage::SIZE);
+
+    LogTrace(BCLog::SV2, "Encrypt certificate: %s\n", HexStr(ss));
+    if (!m_symmetric_state.EncryptAndHash(ss, msg.subspan(bytes_written, Sv2SignatureNoiseMessage::SIZE + Poly1305::TAGLEN))) {
+        // This should never happen
+        Assume(false);
+        throw std::runtime_error("Failed to encrypt our certificate\n");
+    }
+
+    LogTrace(BCLog::SV2, "Mix hash: %s\n", HexStr(m_symmetric_state.GetHashOutput()));
+
+    bytes_written += Sv2SignatureNoiseMessage::SIZE + Poly1305::TAGLEN;
+    Assume(bytes_written == HANDSHAKE_STEP2_SIZE);
+}
+
+bool Sv2HandshakeState::ReadMsgES(Span<std::byte> msg)
+{
+    Assume(msg.size() == HANDSHAKE_STEP2_SIZE);
+    ssize_t bytes_read = 0;
+
+    // Read the remote ephemeral key from the msg and decrypt.
+    LogTrace(BCLog::SV2, "Read remote ephemeral key\n");
+    m_remote_ephemeral_ellswift_pk = EllSwiftPubKey(msg.subspan(0, ELLSWIFT_PUB_KEY_SIZE));
+    bytes_read += ELLSWIFT_PUB_KEY_SIZE;
+
+    m_symmetric_state.MixHash(m_remote_ephemeral_ellswift_pk);
+    LogTrace(BCLog::SV2, "Mix hash: %s\n", HexStr(m_symmetric_state.GetHashOutput()));
+
+    LogTrace(BCLog::SV2, "Perform ECDH with the remote ephemeral key\n");
+    ECDHSecret ecdh_secret{m_ephemeral_key.ComputeBIP324ECDHSecret(m_remote_ephemeral_ellswift_pk,
+                                                                   m_ephemeral_ellswift_pk,
+                                                                   /*initiating=*/true)};
+
+    LogTrace(BCLog::SV2, "Mix key with ECDH result: ephemeral ours -- remote ephemeral\n");
+    m_symmetric_state.MixKey(ecdh_secret);
+    m_symmetric_state.LogChainingKey();
+
+    LogTrace(BCLog::SV2, "Decrypt remote static key\n");
+    std::array<std::byte, ELLSWIFT_PUB_KEY_SIZE> remote_static_key_bytes;
+    bool res = m_symmetric_state.DecryptAndHash(msg.subspan(ELLSWIFT_PUB_KEY_SIZE, ELLSWIFT_PUB_KEY_SIZE + Poly1305::TAGLEN), remote_static_key_bytes);
+    if (!res) return false;
+    bytes_read += ELLSWIFT_PUB_KEY_SIZE + Poly1305::TAGLEN;
+
+    LogTrace(BCLog::SV2, "Mix hash: %s\n", HexStr(m_symmetric_state.GetHashOutput()));
+
+    // Load remote static key from the decryted msg
+    m_remote_static_ellswift_pk = EllSwiftPubKey(remote_static_key_bytes);
+
+    LogTrace(BCLog::SV2, "Perform ECDH on the remote static key\n");
+    ECDHSecret ecdh_static_secret{m_ephemeral_key.ComputeBIP324ECDHSecret(m_remote_static_ellswift_pk,
+                                                                          m_ephemeral_ellswift_pk,
+                                                                          /*initiating=*/true)};
+    LogTrace(BCLog::SV2, "ECDH result: %s\n", HexStr(ecdh_static_secret));
+
+    LogTrace(BCLog::SV2, "Mix key with ECDH result: ephemeral ours -- remote static\n");
+    m_symmetric_state.MixKey(ecdh_static_secret);
+    m_symmetric_state.LogChainingKey();
+
+    LogTrace(BCLog::SV2, "Decrypt remote certificate\n");
+    std::array<std::byte, Sv2SignatureNoiseMessage::SIZE> remote_cert_bytes;
+    res = m_symmetric_state.DecryptAndHash(msg.subspan(bytes_read, Sv2SignatureNoiseMessage::SIZE + Poly1305::TAGLEN), remote_cert_bytes);
+    if (!res) return false;
+    bytes_read += (Sv2SignatureNoiseMessage::SIZE + Poly1305::TAGLEN);
+    LogTrace(BCLog::SV2, "Mix hash: %s\n", HexStr(m_symmetric_state.GetHashOutput()));
+
+    LogTrace(BCLog::SV2, "Validate remote certificate\n");
+    DataStream ss_cert(remote_cert_bytes);
+    Sv2SignatureNoiseMessage cert;
+    ss_cert >> cert;
+    cert.m_static_key = XOnlyPubKey(m_remote_static_ellswift_pk.Decode());
+    Assume(m_authority_pubkey);
+    if (!cert.Validate(m_authority_pubkey.value())) {
+        // We initiated the connection, so it's safe to unconditionally log this:
+        LogWarning("Invalid certificate: %s\n", HexStr(remote_cert_bytes));
+        return false;
+    }
+
+    Assume(bytes_read == HANDSHAKE_STEP2_SIZE);
+    return true;
+}
+
+std::array<Sv2CipherState, 2> Sv2HandshakeState::SplitSymmetricState()
+{
+    return m_symmetric_state.Split();
+}
+
+uint256 Sv2HandshakeState::GetHashOutput()
+{
+    return m_symmetric_state.GetHashOutput();
+}
+
+Sv2Cipher::Sv2Cipher(CKey&& static_key, XOnlyPubKey authority_pubkey)
+{
+    m_handshake_state = std::make_unique<Sv2HandshakeState>(std::move(static_key), authority_pubkey);
+    m_initiator = true;
+}
+
+Sv2Cipher::Sv2Cipher(CKey&& static_key, Sv2SignatureNoiseMessage&& certificate)
+{
+    m_handshake_state = std::make_unique<Sv2HandshakeState>(std::move(static_key), std::move(certificate));
+    m_initiator = false;
+}
+
+Sv2HandshakeState& Sv2Cipher::GetHandshakeState()
+{
+    Assume(m_handshake_state);
+    return *m_handshake_state;
+}
+
+void Sv2Cipher::FinishHandshake()
+{
+    Assume(m_handshake_state);
+
+    auto cipher_state{m_handshake_state->SplitSymmetricState()};
+
+    m_hash = m_handshake_state->GetHashOutput();
+
+    m_cs1 = std::move(cipher_state[0]);
+    m_cs2 = std::move(cipher_state[1]);
+
+    m_handshake_state.reset();
+}
+
+size_t Sv2Cipher::EncryptedMessageSize(const size_t msg_len)
+{
+    constexpr size_t chunk_size = NOISE_MAX_CHUNK_SIZE - Poly1305::TAGLEN;
+    const size_t num_chunks = (msg_len + chunk_size - 1) / chunk_size;
+    return msg_len + (num_chunks * Poly1305::TAGLEN);
+}
+
+bool Sv2Cipher::DecryptMessage(Span<std::byte> ciphertext, Span<std::byte> plain)
+{
+    Assume(EncryptedMessageSize(plain.size()) == ciphertext.size());
+
+    if (m_initiator) {
+        return m_cs2.DecryptMessage(ciphertext, plain);
+    } else {
+        return m_cs1.DecryptMessage(ciphertext, plain);
+    }
+}
+
+bool Sv2Cipher::EncryptMessage(Span<const std::byte> input, Span<std::byte> output)
+{
+    Assume(output.size() == Sv2Cipher::EncryptedMessageSize(input.size()));
+
+    if (m_initiator) {
+        return m_cs1.EncryptMessage(input, output);
+    } else {
+        return m_cs2.EncryptMessage(input, output);
+    }
+}
+
+uint256 Sv2Cipher::GetHash() const
+{
+    return m_hash;
+}
diff --git a/src/sv2/noise.h b/src/sv2/noise.h
new file mode 100644
index 0000000000000..13036424438f5
--- /dev/null
+++ b/src/sv2/noise.h
@@ -0,0 +1,299 @@
+// Copyright (c) 2023-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#ifndef BITCOIN_SV2_NOISE_H
+#define BITCOIN_SV2_NOISE_H
+
+#include <compat/compat.h>
+#include <crypto/poly1305.h>
+#include <key.h>
+#include <pubkey.h>
+#include <random.h>
+#include <streams.h>
+#include <uint256.h>
+
+/** The Noise Protocol Framework
+ *  https://noiseprotocol.org/noise.html
+ *  Revision 38, 2018-07-11
+ *
+ *  Stratum v2 handshake and cipher specification:
+ *  https://github.com/stratum-mining/sv2-spec/blob/main/04-Protocol-Security.md
+ */
+
+/** Section 3: All Noise messages are less than or equal to 65535 bytes in length. */
+static constexpr size_t NOISE_MAX_CHUNK_SIZE = 65535;
+
+static constexpr size_t HASHLEN{32};
+using NoiseHash = std::array<uint8_t, HASHLEN>;
+
+/** Simple certificate for the static key signed by the authority key.
+ * See 4.5.2 and 4.5.3 of the Stratum v2 spec.
+ */
+class Sv2SignatureNoiseMessage
+{
+public:
+    /** Size of a Schnorr signature. */
+    static constexpr size_t SCHNORR_SIGNATURE_SIZE = 64;
+    /** Size of serialized message, which does not include the static key.  */
+    static constexpr size_t SIZE = 2 + 4 + 4 + SCHNORR_SIGNATURE_SIZE;
+
+private:
+    uint16_t m_version = 0;
+    uint32_t m_valid_from = 0;
+    uint32_t m_valid_to = 0;
+    std::array<unsigned char, SCHNORR_SIGNATURE_SIZE> m_sig;
+
+    /** Hash of version, valid from/to and the static key. */
+    uint256 GetHash();
+    void SignSchnorr(const CKey& authority_key, Span<unsigned char> sig);
+
+public:
+    Sv2SignatureNoiseMessage() = default;
+    Sv2SignatureNoiseMessage(uint16_t version, uint32_t valid_from, uint32_t valid_to, const XOnlyPubKey& static_key, const CKey& authority_key);
+
+    /* The certificate serializes pubkeys in x-only format, not EllSwift. */
+    XOnlyPubKey m_static_key = {};
+
+    [[nodiscard]] bool Validate(XOnlyPubKey authority_key);
+
+    template <typename Stream>
+    // The static_key is signed for, but not serialized.
+    void Serialize(Stream& s) const
+    {
+        s << m_version
+          << m_valid_from
+          << m_valid_to
+          << m_sig;
+    }
+    template <typename Stream>
+    void Unserialize(Stream& s)
+    {
+        s >> m_version
+          >> m_valid_from
+          >> m_valid_to
+          >> m_sig;
+    }
+};
+
+/*
+ * The CipherState uses m_key (k) and m_nonce (n) to encrypt and decrypt ciphertexts.
+ * During the handshake phase each party has a single CipherState, but during
+ * the transport phase each party has two CipherState objects: one for sending,
+ * and one for receiving.
+ *
+ * See chapter "5. Processing rules" of the Noise paper.
+ */
+class Sv2CipherState
+{
+public:
+    Sv2CipherState() = default;
+    explicit Sv2CipherState(NoiseHash&& key);
+
+    /** Decrypt message
+     * @param[in] associated_data associated data
+     * @param[in] ciphertext message with encrypted and authenticated chunks.
+     * @param[out] plain message (defragmented)
+     * @returns whether decryption succeeded
+     */
+    [[nodiscard]] bool DecryptWithAd(Span<const std::byte> associated_data, Span<std::byte> ciphertext, Span<std::byte> plain);
+
+    /** Encrypt message
+     * @param[in] associated_data associated data
+     * @param[in] plain message
+     * @param[out] ciphertext message with encrypted and authenticated chunks.
+     * @returns whether encryption succeeded
+     */
+    [[nodiscard]] bool EncryptWithAd(Span<const std::byte> associated_data, Span<const std::byte> plain, Span<std::byte> ciphertext);
+
+    /** The message will be chunked in NOISE_MAX_CHUNK_SIZE parts and expanded
+     *  by 16 bytes per chunk for its MAC.
+     *
+     * @param[in] plain message. Can't point to the same memory location as ciphertext,
+     *                  because each encrypted message chunk would override the
+     *                  start of the next plain text chunk.
+     * @param[out] ciphertext   message with encrypted and authenticated chunks
+     * @return whether encryption succeeded. Only fails if nonce is uint64_max.
+     */
+    [[nodiscard]] bool EncryptMessage(Span<const std::byte> plain, Span<std::byte> ciphertext);
+
+    /** Decrypt message.
+     *
+     * @param[in] ciphertext encrypted message
+     * @param[out] plain decrypted message. May point to the same memory location
+     *                  as ciphertext. The result is defragmented.
+     */
+    [[nodiscard]] bool DecryptMessage(Span<std::byte> ciphertext, Span<std::byte> plain);
+
+private:
+    NoiseHash m_key{0};
+    uint64_t m_nonce = 0;
+};
+
+/*
+ * A SymmetricState object contains a CipherState plus m_chaining_key (ck) and
+ * m_hash_output (h) variables. It is so-named because it encapsulates all the
+ * "symmetric crypto" used by Noise. During the handshake phase each party has
+ * a single SymmetricState, which can be deleted once the handshake is finished.
+ *
+ * See chapter "5. Processing rules" of the Noise paper.
+ */
+class Sv2SymmetricState
+{
+public:
+    // Sha256 hash of the ascii encoding - "Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256".
+    // This is the first step required when setting up the chaining key.
+    static constexpr NoiseHash PROTOCOL_NAME_HASH = {
+        46, 180, 120, 129, 32, 142, 158, 238, 31, 102, 159, 103, 198, 110, 231, 14,
+        169, 234, 136, 9, 13, 80, 63, 232, 48, 220, 75, 200, 62, 41, 191, 16};
+
+    // The double hash of protocol name "Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256".
+    static constexpr NoiseHash PROTOCOL_NAME_DOUBLE_HASH = {
+        146, 47, 163, 46, 79, 72, 124, 13, 89, 202, 163, 190, 215, 137, 156, 227,
+        217, 141, 183, 225, 61, 189, 59, 124, 242, 210, 61, 212, 51, 220, 97, 4};
+
+    Sv2SymmetricState() : m_chaining_key{PROTOCOL_NAME_HASH} {}
+
+    void MixHash(const Span<const std::byte> input);
+    void MixKey(const Span<const std::byte> input_key_material);
+    [[nodiscard]] bool EncryptAndHash(Span<const std::byte> plain, Span<std::byte> ciphertext);
+    [[nodiscard]] bool DecryptAndHash(Span<std::byte> ciphertext, Span<std::byte> plain);
+    std::array<Sv2CipherState, 2> Split();
+
+    uint256 GetHashOutput();
+
+    /* For testing */
+    void LogChainingKey();
+    std::string GetChainingKey();
+
+private:
+    NoiseHash m_chaining_key;
+    uint256 m_hash_output{uint256(PROTOCOL_NAME_DOUBLE_HASH)};
+    Sv2CipherState m_cipher_state;
+
+    void HKDF2(const Span<const std::byte> input_key_material,
+               NoiseHash& out0,
+               NoiseHash& out1);
+};
+
+/*
+ * A HandshakeState object contains a SymmetricState plus DH variables (s, e, rs, re)
+ * and a variable representing the handshake pattern. During the handshake phase
+ * each party has a single HandshakeState, which can be deleted once the handshake
+ * is finished.
+ *
+ * See chapter "5. Processing rules" of the Noise paper.
+ */
+
+class Sv2HandshakeState
+{
+public:
+    static constexpr size_t ELLSWIFT_PUB_KEY_SIZE{64};
+    static constexpr size_t ECDH_OUTPUT_SIZE{32};
+
+    static constexpr size_t HANDSHAKE_STEP2_SIZE = ELLSWIFT_PUB_KEY_SIZE + ELLSWIFT_PUB_KEY_SIZE +
+                                                   Poly1305::TAGLEN + Sv2SignatureNoiseMessage::SIZE + Poly1305::TAGLEN;
+
+    /*
+     * If we are the initiator m_authority_pubkey must be set in order to verify
+     * the received certificate.
+     */
+    Sv2HandshakeState(CKey&& static_key,
+                      XOnlyPubKey authority_pubkey) : m_static_key{static_key},
+                                                        m_authority_pubkey{authority_pubkey}
+    {
+        m_static_ellswift_pk = static_key.EllSwiftCreate(MakeByteSpan(GetRandHash()));
+    };
+
+    /*
+     * If we are the responder, the certificate must be set
+     */
+    Sv2HandshakeState(CKey&& static_key,
+                      Sv2SignatureNoiseMessage&& certificate) : m_static_key{static_key},
+                                                                m_certificate{certificate}
+    {
+        m_static_ellswift_pk = static_key.EllSwiftCreate(MakeByteSpan(GetRandHash()));
+    };
+
+    /** Handshake step 1 for initiator: -> e */
+    void WriteMsgEphemeralPK(Span<std::byte> msg);
+    /** Handshake step 1 for responder: -> e */
+    void ReadMsgEphemeralPK(Span<std::byte> msg);
+    /** During handshake step 2, put our ephmeral key, static key
+     * and certificate in the buffer: <- e, ee, s, es, SIGNATURE_NOISE_MESSAGE
+     */
+    void WriteMsgES(Span<std::byte> msg);
+    /** During handshake step 2, read the remote ephmeral key, static key
+     * and certificate. Verify their certificate.
+     * <- e, ee, s, es, SIGNATURE_NOISE_MESSAGE
+     */
+    [[nodiscard]] bool ReadMsgES(Span<std::byte> msg);
+
+    std::array<Sv2CipherState, 2> SplitSymmetricState();
+    uint256 GetHashOutput();
+
+    void SetEphemeralKey(CKey&& key);
+
+private:
+    /** Our static key (s) */
+    CKey m_static_key;
+    /** EllSwift encoded static key, for optimized ECDH */
+    EllSwiftPubKey m_static_ellswift_pk;
+    /** Our ephemeral key (e) */
+    CKey m_ephemeral_key;
+    /** EllSwift encoded ephemeral key, for optimized ECDH */
+    EllSwiftPubKey m_ephemeral_ellswift_pk;
+    /** Remote static key (rs) */
+    EllSwiftPubKey m_remote_static_ellswift_pk;
+    /** Remote ephemeral key (re) */
+    EllSwiftPubKey m_remote_ephemeral_ellswift_pk;
+    Sv2SymmetricState m_symmetric_state;
+    /** Certificate signed by m_authority_pubkey. */
+    std::optional<Sv2SignatureNoiseMessage> m_certificate;
+    /** Authority public key. */
+    std::optional<XOnlyPubKey> m_authority_pubkey;
+
+    /** Generate ephemeral key, sets set m_ephemeral_key and m_ephemeral_ellswift_pk */
+    void GenerateEphemeralKey() noexcept;
+};
+
+/**
+ * Interface somewhat similar to BIP324Cipher for use by a Transport class.
+ * The initiator and responder roles have their own constructor.
+ * FinishHandshake() must be called after all handshake bytes have been processed.
+ */
+class Sv2Cipher
+{
+public:
+    Sv2Cipher(CKey&& static_key, XOnlyPubKey authority_pubkey);
+    Sv2Cipher(CKey&& static_key, Sv2SignatureNoiseMessage&& certificate);
+
+    Sv2Cipher(bool initiator, std::unique_ptr<Sv2HandshakeState> handshake_state) : m_initiator{initiator}, m_handshake_state{std::move(handshake_state)} {};
+
+    Sv2HandshakeState& GetHandshakeState();
+    /**
+     * Populates m_hash, m_cs1 and m_cs2 from m_handshake_state and deletes the latter.
+     */
+    void FinishHandshake();
+
+    /** Decrypts a message. May only be called after FinishHandshake() */
+    bool DecryptMessage(Span<std::byte> ciphertext, Span<std::byte> plain);
+    /** Encrypts a message. May only be called after FinishHandshake() */
+    [[nodiscard]] bool EncryptMessage(Span<const std::byte> input, Span<std::byte> output);
+
+    /* Expected size after chunking and with MAC */
+    static size_t EncryptedMessageSize(const size_t msg_len);
+
+    /* Test only */
+    uint256 GetHash() const;
+
+private:
+    bool m_initiator;
+    std::unique_ptr<Sv2HandshakeState> m_handshake_state;
+
+    uint256 m_hash;
+    Sv2CipherState m_cs1;
+    Sv2CipherState m_cs2;
+};
+
+#endif // BITCOIN_SV2_NOISE_H
diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt
index 4d13043c95505..dbbe5228267ae 100644
--- a/src/test/CMakeLists.txt
+++ b/src/test/CMakeLists.txt
@@ -178,6 +178,7 @@ endif()
 if(WITH_SV2)
   target_sources(test_bitcoin
   PRIVATE
+    sv2_noise_tests.cpp
   )
   target_link_libraries(test_bitcoin bitcoin_sv2)
 endif()
diff --git a/src/test/fuzz/CMakeLists.txt b/src/test/fuzz/CMakeLists.txt
index 5f959596d9d48..b7c91b19bd142 100644
--- a/src/test/fuzz/CMakeLists.txt
+++ b/src/test/fuzz/CMakeLists.txt
@@ -153,6 +153,7 @@ endif()
 if(WITH_SV2)
   target_sources(fuzz
   PRIVATE
+    sv2_noise.cpp
   )
   target_link_libraries(fuzz
     bitcoin_sv2
diff --git a/src/test/fuzz/sv2_noise.cpp b/src/test/fuzz/sv2_noise.cpp
new file mode 100644
index 0000000000000..465e4171d99a9
--- /dev/null
+++ b/src/test/fuzz/sv2_noise.cpp
@@ -0,0 +1,168 @@
+// Copyright (c) 2024 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include <sv2/noise.h>
+#include <logging.h>
+#include <random.h>
+#include <span.h>
+#include <test/fuzz/FuzzedDataProvider.h>
+#include <test/fuzz/fuzz.h>
+#include <test/fuzz/util.h>
+#include <test/util/setup_common.h>
+
+#include <cstdint>
+#include <util/vector.h>
+
+namespace {
+
+
+void Initialize()
+{
+    // Add test context for debugging. Usage:
+    // --debug=sv2 --loglevel=sv2:trace --printtoconsole=1
+    static const auto testing_setup = std::make_unique<const BasicTestingSetup>();
+}
+} // namespace
+
+bool MaybeDamage(FuzzedDataProvider& provider, std::vector<std::byte>& transport)
+{
+    if (transport.size() == 0) return false;
+
+    // Optionally damage 1 bit in the ciphertext.
+    const bool damage = provider.ConsumeBool();
+    if (damage) {
+        unsigned damage_bit = provider.ConsumeIntegralInRange<unsigned>(0,
+                                                                        transport.size() * 8U - 1U);
+        unsigned damage_pos = damage_bit >> 3;
+        LogTrace(BCLog::SV2, "Damage byte %d of %d\n", damage_pos, transport.size());
+        std::byte damage_val{(uint8_t)(1U << (damage_bit & 7))};
+        transport.at(damage_pos) ^= damage_val;
+    }
+    return damage;
+}
+
+FUZZ_TARGET(sv2_noise_cipher_roundtrip, .init = Initialize)
+{
+    // Test that Sv2Noise's encryption and decryption agree.
+
+    // To conserve fuzzer entropy, deterministically generate Alice and Bob keys.
+    FuzzedDataProvider provider(buffer.data(), buffer.size());
+    auto seed_ent = provider.ConsumeBytes<std::byte>(32);
+    seed_ent.resize(32);
+    CExtKey seed;
+    seed.SetSeed(seed_ent);
+
+    CExtKey tmp;
+    if (!seed.Derive(tmp, 0)) return;
+    CKey alice_authority_key{tmp.key};
+
+    if (!seed.Derive(tmp, 1)) return;
+    CKey alice_static_key{tmp.key};
+
+    if (!seed.Derive(tmp, 2)) return;
+    CKey alice_ephemeral_key{tmp.key};
+
+    if (!seed.Derive(tmp, 10)) return;
+    CKey bob_authority_key{tmp.key};
+
+    if (!seed.Derive(tmp, 11)) return;
+    CKey bob_static_key{tmp.key};
+
+    if (!seed.Derive(tmp, 12)) return;
+    CKey bob_ephemeral_key{tmp.key};
+
+    // Create certificate
+    // Pick random times in the past or future
+    uint32_t now = provider.ConsumeIntegralInRange<uint32_t>(10000U, UINT32_MAX);
+    SetMockTime(now);
+    uint16_t version = provider.ConsumeBool() ? 0 : provider.ConsumeIntegral<uint16_t>();
+    uint32_t past = provider.ConsumeIntegralInRange<uint32_t>(0, now);
+    uint32_t future = provider.ConsumeIntegralInRange<uint32_t>(now, UINT32_MAX);
+    uint32_t valid_from = int32_t(provider.ConsumeBool() ? past : future);
+    uint32_t valid_to = int32_t(provider.ConsumeBool() ? future : past);
+
+    auto bob_certificate = Sv2SignatureNoiseMessage(version, valid_from, valid_to,
+                                                    XOnlyPubKey(bob_static_key.GetPubKey()), bob_authority_key);
+
+    bool valid_certificate = version == 0 &&
+                             (valid_from <= now) &&
+                             (valid_to >= now);
+
+    LogTrace(BCLog::SV2, "valid_certificate: %d - version %u, past: %u, now %u, future: %u\n", valid_certificate, version, past, now, future);
+
+    // Alice's static is not used in the test
+    // Alice needs to verify Bob's certificate, so we pass his authority key
+    auto alice_handshake = std::make_unique<Sv2HandshakeState>(std::move(alice_static_key), XOnlyPubKey(bob_authority_key.GetPubKey()));
+    alice_handshake->SetEphemeralKey(std::move(alice_ephemeral_key));
+    // Bob is the responder and does not receive (or verify) Alice's certificate,
+    // so we don't pass her authority key.
+    auto bob_handshake = std::make_unique<Sv2HandshakeState>(std::move(bob_static_key), std::move(bob_certificate));
+    bob_handshake->SetEphemeralKey(std::move(bob_ephemeral_key));
+
+    // Handshake Act 1: e ->
+
+    std::vector<std::byte> transport;
+    transport.resize(Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE);
+    // Alice generates her ephemeral public key and write it into the buffer:
+    alice_handshake->WriteMsgEphemeralPK(transport);
+
+    bool damage_e = MaybeDamage(provider, transport);
+
+    // Bob reads the ephemeral key ()
+    // With EllSwift encoding this step can't fail
+    bob_handshake->ReadMsgEphemeralPK(transport);
+    ClearShrink(transport);
+
+    // Handshake Act 2: <- e, ee, s, es, SIGNATURE_NOISE_MESSAGE
+    transport.resize(Sv2HandshakeState::HANDSHAKE_STEP2_SIZE);
+    bob_handshake->WriteMsgES(transport);
+
+    bool damage_es = MaybeDamage(provider, transport);
+
+    // This ignores the remote possibility that the fuzzer finds two equivalent
+    // EllSwift encodings by flipping a single ephemeral key bit.
+    assert(alice_handshake->ReadMsgES(transport) == (valid_certificate && !damage_e && !damage_es));
+
+    if (!valid_certificate || damage_e || damage_es) return;
+
+    // Construct Sv2Cipher from the Sv2HandshakeState and test transport
+    auto alice{Sv2Cipher(/*initiator=*/true, std::move(alice_handshake))};
+    auto bob{Sv2Cipher(/*initiator=*/false, std::move(bob_handshake))};
+    alice.FinishHandshake();
+    bob.FinishHandshake();
+
+    // Use deterministic RNG to generate content rather than creating it from
+    // the fuzzer input.
+    InsecureRandomContext rng(provider.ConsumeIntegral<uint64_t>());
+
+    LIMITED_WHILE(provider.remaining_bytes(), 1000)
+    {
+        ClearShrink(transport);
+
+        // Alice or Bob sends a message
+        bool from_alice = provider.ConsumeBool();
+
+        // Set content length (slightly above NOISE_MAX_CHUNK_SIZE)
+        unsigned length = provider.ConsumeIntegralInRange<unsigned>(0, NOISE_MAX_CHUNK_SIZE + 100);
+        std::vector<std::byte> plain(length);
+        for (auto& val : plain)
+            val = std::byte{(uint8_t)rng()};
+
+        const size_t encrypted_size = Sv2Cipher::EncryptedMessageSize(plain.size());
+        transport.resize(encrypted_size);
+
+        assert((from_alice ? alice : bob).EncryptMessage(plain, transport));
+
+        const bool damage = MaybeDamage(provider, transport);
+
+        std::vector<std::byte> plain_read;
+        plain_read.resize(plain.size());
+
+        bool ok = (from_alice ? bob : alice).DecryptMessage(transport, plain_read);
+        assert(!ok == damage);
+        if (!ok) break;
+
+        assert(plain == plain_read);
+    }
+}
diff --git a/src/test/sv2_noise_tests.cpp b/src/test/sv2_noise_tests.cpp
new file mode 100644
index 0000000000000..07da06e4e361d
--- /dev/null
+++ b/src/test/sv2_noise_tests.cpp
@@ -0,0 +1,159 @@
+#include <sv2/noise.h>
+#include <key.h>
+#include <random.h>
+#include <test/util/setup_common.h>
+#include <util/strencodings.h>
+
+#include <boost/test/unit_test.hpp>
+
+BOOST_FIXTURE_TEST_SUITE(sv2_noise_tests, BasicTestingSetup)
+
+BOOST_AUTO_TEST_CASE(MixKey_test)
+{
+    Sv2SymmetricState i_ss;
+    Sv2SymmetricState r_ss;
+    BOOST_CHECK_EQUAL(r_ss.GetChainingKey(), i_ss.GetChainingKey());
+
+    CKey initiator_key{GenerateRandomKey()};
+    auto initiator_pk = initiator_key.EllSwiftCreate(MakeByteSpan(GetRandHash()));
+
+    CKey responder_key{GenerateRandomKey()};
+    auto responder_pk = responder_key.EllSwiftCreate(MakeByteSpan(GetRandHash()));
+
+    auto ecdh_output_1 = initiator_key.ComputeBIP324ECDHSecret(responder_pk, initiator_pk, true);
+    auto ecdh_output_2 = responder_key.ComputeBIP324ECDHSecret(initiator_pk, responder_pk, false);
+
+    BOOST_CHECK(std::memcmp(&ecdh_output_1[0], &ecdh_output_2[0], 32) == 0);
+
+    i_ss.MixKey(ecdh_output_1);
+    r_ss.MixKey(ecdh_output_2);
+
+    BOOST_CHECK_EQUAL(r_ss.GetChainingKey(), i_ss.GetChainingKey());
+}
+
+BOOST_AUTO_TEST_CASE(certificate_test)
+{
+    auto alice_static_key{GenerateRandomKey()};
+    auto alice_authority_key{GenerateRandomKey()};
+
+    // Create certificate
+    auto epoch_now = std::chrono::system_clock::now().time_since_epoch();
+    uint32_t now = static_cast<uint32_t>(std::chrono::duration_cast<std::chrono::seconds>(epoch_now).count());
+    uint16_t version = 0;
+    uint32_t valid_from = now;
+    uint32_t valid_to = std::numeric_limits<unsigned int>::max();
+
+    auto alice_certificate = Sv2SignatureNoiseMessage(version, valid_from, valid_to,
+                                                      XOnlyPubKey(alice_static_key.GetPubKey()), alice_authority_key);
+
+    BOOST_REQUIRE(alice_certificate.Validate(XOnlyPubKey(alice_authority_key.GetPubKey())));
+
+    auto malory_authority_key{GenerateRandomKey()};
+    BOOST_REQUIRE(!alice_certificate.Validate(XOnlyPubKey(malory_authority_key.GetPubKey())));
+
+    // Check that certificate is not from the future
+    valid_from = now + 10000;
+    alice_certificate = Sv2SignatureNoiseMessage(version, valid_from, valid_to,
+                                                 XOnlyPubKey(alice_static_key.GetPubKey()), alice_authority_key);
+    BOOST_REQUIRE(!alice_certificate.Validate(XOnlyPubKey(alice_authority_key.GetPubKey())));
+
+    valid_from = now;
+
+    // Check certificate expiration
+    valid_to = now - 10000;
+    alice_certificate = Sv2SignatureNoiseMessage(version, valid_from, valid_to,
+                                                 XOnlyPubKey(alice_static_key.GetPubKey()), alice_authority_key);
+    BOOST_REQUIRE(!alice_certificate.Validate(XOnlyPubKey(alice_authority_key.GetPubKey())));
+
+    valid_to = now;
+
+    // Only version 0 is supported
+    version = 1;
+    alice_certificate = Sv2SignatureNoiseMessage(version, valid_from, valid_to,
+                                                 XOnlyPubKey(alice_static_key.GetPubKey()), alice_authority_key);
+    BOOST_REQUIRE(!alice_certificate.Validate(XOnlyPubKey(alice_authority_key.GetPubKey())));
+}
+
+BOOST_AUTO_TEST_CASE(handshake_and_transport_test)
+{
+    // Alice initiates a handshake with Bob
+
+    auto alice_static_key{GenerateRandomKey()};
+    auto bob_static_key{GenerateRandomKey()};
+    auto bob_authority_key{GenerateRandomKey()};
+
+    // Create certificates
+    auto epoch_now = std::chrono::system_clock::now().time_since_epoch();
+    uint16_t version = 0;
+    uint32_t valid_from = static_cast<uint32_t>(std::chrono::duration_cast<std::chrono::seconds>(epoch_now).count());
+    uint32_t valid_to = std::numeric_limits<unsigned int>::max();
+
+    auto bob_certificate = Sv2SignatureNoiseMessage(version, valid_from, valid_to,
+                                                    XOnlyPubKey(bob_static_key.GetPubKey()),
+                                                    bob_authority_key);
+
+    // Alice's static is not used in the test
+    // Alice needs to verify Bob's certificate, so we pass his authority key
+    auto alice_handshake = std::make_unique<Sv2HandshakeState>(std::move(alice_static_key),
+                                                               XOnlyPubKey(bob_authority_key.GetPubKey()));
+    // Bob is the responder and does not receive (or verify) Alice's certificate,
+    // so we don't pass her authority key.
+    auto bob_handshake = std::make_unique<Sv2HandshakeState>(std::move(bob_static_key),
+                                                             std::move(bob_certificate));
+
+    // Handshake Act 1: e ->
+
+    std::vector<std::byte> transport;
+    transport.resize(Sv2HandshakeState::ELLSWIFT_PUB_KEY_SIZE);
+    // Alice generates her ephemeral public key and write it into the buffer:
+    alice_handshake->WriteMsgEphemeralPK(transport);
+    EllSwiftPubKey alice_pubkey(transport);
+
+    // Bob reads the ephemeral key
+    bob_handshake->ReadMsgEphemeralPK(transport);
+
+    ClearShrink(transport);
+
+    // Handshake Act 2: <- e, ee, s, es, SIGNATURE_NOISE_MESSAGE
+    transport.resize(Sv2HandshakeState::HANDSHAKE_STEP2_SIZE);
+    bob_handshake->WriteMsgES(transport);
+    BOOST_REQUIRE(alice_handshake->ReadMsgES(transport));
+
+    // Construct Sv2Cipher from the Sv2HandshakeState and test transport
+    auto alice{Sv2Cipher(/*initiator=*/true, std::move(alice_handshake))};
+    auto bob{Sv2Cipher(/*initiator=*/false, std::move(bob_handshake))};
+    alice.FinishHandshake();
+    bob.FinishHandshake();
+
+    ClearShrink(transport);
+
+    constexpr std::array<uint8_t, 11> TEST{
+        // hello world
+        0x68,
+        0x65,
+        0x6C,
+        0x6C,
+        0x6F,
+        0x20,
+        0x77,
+        0x6F,
+        0x72,
+        0x6C,
+        0x64,
+    };
+
+    const size_t encrypted_size = Sv2Cipher::EncryptedMessageSize(TEST.size());
+    BOOST_CHECK_EQUAL(encrypted_size, TEST.size() + Poly1305::TAGLEN);
+
+    transport.resize(encrypted_size);
+
+    auto plain_send{MakeByteSpan(TEST)};
+    BOOST_TEST_CHECKPOINT("Alice encrypts message");
+    BOOST_REQUIRE(alice.EncryptMessage(plain_send, transport));
+
+    std::vector<std::byte> plain_receive(TEST.size(), std::byte(0));
+    BOOST_TEST_CHECKPOINT("Bob decrypts message");
+    BOOST_REQUIRE(bob.DecryptMessage(transport, plain_receive));
+    BOOST_CHECK_EQUAL(HexStr(plain_receive), HexStr(TEST));
+}
+BOOST_AUTO_TEST_SUITE_END()