From f584cc5d113b3a2d488a9b97b26a6b77a0caba41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:28:41 +0000 Subject: [PATCH 1/5] Initial plan From 94833ac0e698ddf85fa5a24ba4c4e3cff9bfef56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:42:01 +0000 Subject: [PATCH 2/5] Encrypt ledger private domains into final entry spans Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- include/ccf/crypto/symmetric_key.h | 19 ++++ src/crypto/openssl/symmetric_key.cpp | 58 +++++++++++-- src/crypto/openssl/symmetric_key.h | 7 ++ src/crypto/symmetric_key.cpp | 20 +++-- src/crypto/test/bench.cpp | 41 +++++++++ src/crypto/test/crypto.cpp | 51 +++++++++++ src/kv/encryptor.h | 36 +++++++- src/kv/generic_serialise_wrapper.h | 36 +++----- src/kv/kv_types.h | 9 ++ src/kv/null_encryptor.h | 19 ++++ src/kv/test/kv_bench.cpp | 41 +++++++++ src/kv/test/kv_serialisation.cpp | 125 +++++++++++++++++++++++++++ src/kv/test/kv_snapshot.cpp | 36 ++++++++ src/node/ledger_secret.h | 11 +++ 14 files changed, 474 insertions(+), 35 deletions(-) diff --git a/include/ccf/crypto/symmetric_key.h b/include/ccf/crypto/symmetric_key.h index 186979489c11..ff3f600db925 100644 --- a/include/ccf/crypto/symmetric_key.h +++ b/include/ccf/crypto/symmetric_key.h @@ -29,6 +29,7 @@ namespace ccf::crypto [[nodiscard]] size_t serialised_size() const; std::vector serialise(); + void serialise(std::span output) const; void deserialise(const std::vector& ser); void deserialise(const uint8_t*& data, size_t& size); @@ -92,6 +93,15 @@ namespace ccf::crypto std::vector& cipher, uint8_t tag[GCM_SIZE_TAG]) = 0; + // cipher must be exactly plain.size() bytes. Input and output must not + // partially overlap; identical plaintext and ciphertext spans are valid. + virtual void encrypt( + std::span iv, + std::span plain, + std::span aad, + std::span cipher, + uint8_t tag[GCM_SIZE_TAG]) = 0; + // Replaces plain on success and clears it if authentication fails. virtual bool decrypt( std::span iv, @@ -118,6 +128,15 @@ namespace ccf::crypto std::vector& cipher, uint8_t tag[GCM_SIZE_TAG]) const = 0; + // cipher must be exactly plain.size() bytes. Input and output must not + // partially overlap; identical plaintext and ciphertext spans are valid. + virtual void encrypt( + std::span iv, + std::span plain, + std::span aad, + std::span cipher, + uint8_t tag[GCM_SIZE_TAG]) const = 0; + // AES-GCM decryption // Replaces plain on success and clears it if authentication fails. virtual bool decrypt( diff --git a/src/crypto/openssl/symmetric_key.cpp b/src/crypto/openssl/symmetric_key.cpp index 4acf36080ec5..91c69cb78a8f 100644 --- a/src/crypto/openssl/symmetric_key.cpp +++ b/src/crypto/openssl/symmetric_key.cpp @@ -59,13 +59,19 @@ namespace ccf::crypto std::span iv, std::span plain, std::span aad, - std::vector& cipher, + std::span cipher, uint8_t tag[GCM_SIZE_TAG]) { if (aad.empty() && plain.empty()) { throw std::logic_error("aad and plain cannot both be empty"); } + if ( + cipher.size() != plain.size() || plain.size() > INT_MAX || + aad.size() > INT_MAX) + { + throw std::logic_error("Invalid AES-GCM input or output size"); + } CHECK1(EVP_CIPHER_CTX_ctrl( context, EVP_CTRL_GCM_SET_IVLEN, iv.size(), nullptr)); @@ -79,16 +85,15 @@ namespace ccf::crypto context, nullptr, &aad_outl, aad.data(), aad.size())); } - std::vector ciphertext(plain.size()); if (!plain.empty()) { int cipher_outl{0}; CHECK1(EVP_EncryptUpdate( context, - ciphertext.data(), + cipher.data(), &cipher_outl, plain.data(), - plain.size())); + static_cast(plain.size()))); // As we use no padding, we expect the input and output lengths to // match. @@ -106,8 +111,28 @@ namespace ccf::crypto CHECK1(EVP_CIPHER_CTX_ctrl( context, EVP_CTRL_GCM_GET_TAG, GCM_SIZE_TAG, &tag[0])); + } - cipher = std::move(ciphertext); + void encrypt_with_context( + EVP_CIPHER_CTX* context, + std::span iv, + std::span plain, + std::span aad, + std::vector& cipher, + uint8_t tag[GCM_SIZE_TAG]) + { + if (cipher.size() == plain.size()) + { + encrypt_with_context( + context, iv, plain, aad, std::span(cipher), tag); + } + else + { + std::vector output(plain.size()); + encrypt_with_context( + context, iv, plain, aad, std::span(output), tag); + cipher = std::move(output); + } } bool decrypt_with_context( @@ -194,6 +219,16 @@ namespace ccf::crypto encrypt_with_context(encrypt_context, iv, plain, aad, cipher, tag); } + void encrypt( + std::span iv, + std::span plain, + std::span aad, + std::span cipher, + uint8_t tag[GCM_SIZE_TAG]) override + { + encrypt_with_context(encrypt_context, iv, plain, aad, cipher, tag); + } + bool decrypt( std::span iv, const uint8_t tag[GCM_SIZE_TAG], @@ -241,6 +276,19 @@ namespace ccf::crypto encrypt_with_context(context, iv, plain, aad, cipher, tag); } + void KeyAesGcm_OpenSSL::encrypt( + std::span iv, + std::span plain, + std::span aad, + std::span cipher, + uint8_t tag[GCM_SIZE_TAG]) const + { + Unique_EVP_CIPHER_CTX context; + CHECK1( + EVP_EncryptInit_ex2(context, evp_cipher, key.data(), nullptr, nullptr)); + encrypt_with_context(context, iv, plain, aad, cipher, tag); + } + bool KeyAesGcm_OpenSSL::decrypt( std::span iv, const uint8_t tag[GCM_SIZE_TAG], diff --git a/src/crypto/openssl/symmetric_key.h b/src/crypto/openssl/symmetric_key.h index cf789c98c73d..551bd2f9f5be 100644 --- a/src/crypto/openssl/symmetric_key.h +++ b/src/crypto/openssl/symmetric_key.h @@ -32,6 +32,13 @@ namespace ccf::crypto std::vector& cipher, uint8_t tag[GCM_SIZE_TAG]) const override; + void encrypt( + std::span iv, + std::span plain, + std::span aad, + std::span cipher, + uint8_t tag[GCM_SIZE_TAG]) const override; + bool decrypt( std::span iv, const uint8_t tag[GCM_SIZE_TAG], diff --git a/src/crypto/symmetric_key.cpp b/src/crypto/symmetric_key.cpp index c420f49bc762..ca75acdb9ef8 100644 --- a/src/crypto/symmetric_key.cpp +++ b/src/crypto/symmetric_key.cpp @@ -41,14 +41,22 @@ namespace ccf::crypto std::vector GcmHeader::serialise() { - auto space = serialised_size(); - std::vector serial_hdr(space); + std::vector serial_hdr(serialised_size()); + serialise(serial_hdr); + return serial_hdr; + } - auto* data_ = serial_hdr.data(); - serialized::write(data_, space, static_cast(tag), sizeof(tag)); - serialized::write(data_, space, iv.data(), iv.size()); + void GcmHeader::serialise(std::span output) const + { + if (output.size() != serialised_size()) + { + throw std::logic_error("Incorrect GCM header output size"); + } - return serial_hdr; + auto* data_ = output.data(); + auto space = output.size(); + serialized::write(data_, space, tag, sizeof(tag)); + serialized::write(data_, space, iv.data(), iv.size()); } void GcmHeader::deserialise(const std::vector& ser) diff --git a/src/crypto/test/bench.cpp b/src/crypto/test/bench.cpp index 7d634daec06c..0250b4738fd6 100644 --- a/src/crypto/test/bench.cpp +++ b/src/crypto/test/bench.cpp @@ -143,6 +143,39 @@ static void benchmark_aes_gcm_encrypt(picobench::state& s) s.stop_timer(); } +template +static void benchmark_aes_gcm_encrypt_span(picobench::state& s) +{ + const std::vector key(GCM_DEFAULT_KEY_SIZE, 0x42); + const auto contents = make_contents(); + auto aes_gcm_key = make_key_aes_gcm(key); + auto context = aes_gcm_key->make_context(); + StandardGcmHeader header; + std::vector cipher(contents.size()); + uint64_t iv = 0; + + s.start_timer(); + for (auto _ : s) + { + (void)_; + memcpy(header.iv.data(), &iv, sizeof(iv)); + ++iv; + if constexpr (ReuseContext) + { + context->encrypt( + header.get_iv(), contents, {}, std::span(cipher), header.tag); + } + else + { + aes_gcm_key->encrypt( + header.get_iv(), contents, {}, std::span(cipher), header.tag); + } + do_not_optimize(cipher); + clobber_memory(); + } + s.stop_timer(); +} + template static void benchmark_aes_gcm_decrypt(picobench::state& s) { @@ -537,6 +570,14 @@ namespace AES_GCM_ENCRYPT_64 auto aes_gcm_encrypt_reused_context = benchmark_aes_gcm_encrypt<64, true>; PICOBENCH(aes_gcm_encrypt_reused_context).iterations({100000}); + + auto aes_gcm_encrypt_new_context_span = + benchmark_aes_gcm_encrypt_span<64, false>; + PICOBENCH(aes_gcm_encrypt_new_context_span).iterations({100000}); + + auto aes_gcm_encrypt_reused_context_span = + benchmark_aes_gcm_encrypt_span<64, true>; + PICOBENCH(aes_gcm_encrypt_reused_context_span).iterations({100000}); } PICOBENCH_SUITE("aes gcm encrypt 1024 bytes"); diff --git a/src/crypto/test/crypto.cpp b/src/crypto/test/crypto.cpp index c8cb8a7d9027..278b90bba5c2 100644 --- a/src/crypto/test/crypto.cpp +++ b/src/crypto/test/crypto.cpp @@ -1260,6 +1260,57 @@ TEST_CASE("AES-GCM empty inputs") REQUIRE(decrypted.empty()); } +TEST_CASE("AES-GCM caller-owned ciphertext") +{ + auto key = make_key_aes_gcm(get_raw_key()); + auto context = key->make_context(); + const std::vector iv(12, 0); + const std::vector plain(31, 0x24); + const std::vector aad(7, 0x42); + std::vector expected; + uint8_t expected_tag[GCM_SIZE_TAG] = {}; + key->encrypt(iv, plain, aad, expected, expected_tag); + + std::array storage = {}; + storage.front() = 0xAA; + storage.back() = 0xBB; + auto output = std::span(storage).subspan(1, plain.size()); + uint8_t tag[GCM_SIZE_TAG] = {}; + key->encrypt(iv, plain, aad, output, tag); + REQUIRE(std::equal(output.begin(), output.end(), expected.begin())); + REQUIRE(std::equal(std::begin(tag), std::end(tag), std::begin(expected_tag))); + REQUIRE(storage.front() == 0xAA); + REQUIRE(storage.back() == 0xBB); + + context->encrypt(iv, plain, aad, output, tag); + REQUIRE(std::equal(output.begin(), output.end(), expected.begin())); + REQUIRE(std::equal(std::begin(tag), std::end(tag), std::begin(expected_tag))); + + std::vector preallocated(plain.size(), 0); + auto* original_data = preallocated.data(); + key->encrypt(iv, plain, aad, preallocated, tag); + REQUIRE(preallocated.data() == original_data); + REQUIRE(preallocated == expected); + + REQUIRE_THROWS_AS( + key->encrypt(iv, plain, aad, output.first(output.size() - 1), tag), + std::logic_error); + REQUIRE(storage.front() == 0xAA); + REQUIRE(storage.back() == 0xBB); + + std::vector decrypted; + REQUIRE(key->decrypt(iv, tag, output, aad, decrypted)); + REQUIRE(decrypted == plain); + auto bad_aad = aad; + bad_aad.front() ^= 1; + REQUIRE_FALSE(key->decrypt(iv, tag, output, bad_aad, decrypted)); + + std::span empty_output; + key->encrypt(iv, {}, aad, empty_output, tag); + REQUIRE(key->decrypt(iv, tag, empty_output, aad, decrypted)); + REQUIRE(decrypted.empty()); +} + TEST_CASE("Concurrent AES-GCM convenience calls") { constexpr size_t thread_count = 24; diff --git a/src/kv/encryptor.h b/src/kv/encryptor.h index a34dce836d77..773ca9f61a5c 100644 --- a/src/kv/encryptor.h +++ b/src/kv/encryptor.h @@ -78,6 +78,39 @@ namespace ccf::kv EntryType entry_type = EntryType::WriteSet, bool historical_hint = false) override { + std::vector header(get_header_length()); + std::vector output(plain.size()); + if (!encrypt( + std::span(plain), + std::span(additional_data), + std::span(header), + std::span(output), + tx_id, + entry_type, + historical_hint)) + { + return false; + } + + serialised_header = std::move(header); + cipher = std::move(output); + return true; + } + + bool encrypt( + std::span plain, + std::span additional_data, + std::span serialised_header, + std::span cipher, + const TxID& tx_id, + EntryType entry_type = EntryType::WriteSet, + bool historical_hint = false) override + { + if (serialised_header.size() != get_header_length()) + { + throw std::logic_error("Incorrect serialised GCM header size"); + } + S hdr; set_iv(hdr, tx_id, entry_type); @@ -101,8 +134,7 @@ namespace ccf::kv secret->encrypt(hdr.get_iv(), plain, additional_data, cipher, hdr.tag); } - serialised_header = hdr.serialise(); - + hdr.serialise(serialised_header); return true; } diff --git a/src/kv/generic_serialise_wrapper.h b/src/kv/generic_serialise_wrapper.h index 0f7d111b6202..a3865f851f13 100644 --- a/src/kv/generic_serialise_wrapper.h +++ b/src/kv/generic_serialise_wrapper.h @@ -227,15 +227,24 @@ namespace ccf::kv return entry; } - std::vector serialised_hdr; - std::vector encrypted_private_domain( - serialised_private_domain.size()); + const auto header_size = crypto_util->get_header_length(); + std::span serialised_hdr(data_, header_size); + data_ += header_size; + size_ -= header_size; + serialized::write(data_, size_, serialised_public_domain.size()); + auto* public_data = data_; + serialized::write( + data_, + size_, + serialised_public_domain.data(), + serialised_public_domain.size()); if (!crypto_util->encrypt( serialised_private_domain, - serialised_public_domain, + std::span( + public_data, serialised_public_domain.size()), serialised_hdr, - encrypted_private_domain, + std::span(data_, serialised_private_domain.size()), tx_id, entry_type, historical_hint)) @@ -244,23 +253,6 @@ namespace ccf::kv "Could not serialise transaction at seqno {}", tx_id.seqno)); } - serialized::write( - data_, size_, serialised_hdr.data(), serialised_hdr.size()); - serialized::write(data_, size_, serialised_public_domain.size()); - serialized::write( - data_, - size_, - serialised_public_domain.data(), - serialised_public_domain.size()); - if (!encrypted_private_domain.empty()) - { - serialized::write( - data_, - size_, - encrypted_private_domain.data(), - encrypted_private_domain.size()); - } - return entry; } }; diff --git a/src/kv/kv_types.h b/src/kv/kv_types.h index 33238c5a50aa..5ad6a4b9f57c 100644 --- a/src/kv/kv_types.h +++ b/src/kv/kv_types.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -557,6 +558,14 @@ namespace ccf::kv const ccf::TxID& tx_id, EntryType entry_type = EntryType::WriteSet, bool historical_hint = false) = 0; + virtual bool encrypt( + std::span plain, + std::span additional_data, + std::span serialised_header, + std::span cipher, + const ccf::TxID& tx_id, + EntryType entry_type = EntryType::WriteSet, + bool historical_hint = false) = 0; virtual bool decrypt( const std::vector& cipher, const std::vector& additional_data, diff --git a/src/kv/null_encryptor.h b/src/kv/null_encryptor.h index c845fa66a3d2..609ff2420866 100644 --- a/src/kv/null_encryptor.h +++ b/src/kv/null_encryptor.h @@ -4,6 +4,8 @@ #include "kv/kv_types.h" +#include + namespace ccf::kv { // NullTxEncryptor does not decrypt or verify integrity @@ -23,6 +25,23 @@ namespace ccf::kv return true; } + bool encrypt( + std::span plain, + std::span additional_data, + std::span serialised_header, + std::span cipher, + const TxID& tx_id, + EntryType entry_type = EntryType::WriteSet, + bool historical_hint = false) override + { + if (!serialised_header.empty() || cipher.size() != plain.size()) + { + throw std::logic_error("Incorrect null-encryptor output size"); + } + std::copy(plain.begin(), plain.end(), cipher.begin()); + return true; + } + bool decrypt( const std::vector& cipher, const std::vector& additional_data, diff --git a/src/kv/test/kv_bench.cpp b/src/kv/test/kv_bench.cpp index 90eb829bb9dd..e5b0af6b3637 100644 --- a/src/kv/test/kv_bench.cpp +++ b/src/kv/test/kv_bench.cpp @@ -83,6 +83,38 @@ static void serialise(picobench::state& s) s.stop_timer(); } +template +static void serialise_small(picobench::state& s) +{ + ccf::logger::config::level() = ccf::LoggerLevel::INFO; + ccf::kv::Store kv_store; + auto encryptor = + std::make_shared(create_ledger_secrets()); + kv_store.set_encryptor(encryptor); + const auto map_name = build_map_name("small", SD); + + std::vector keys; + std::vector values; + for (int i = 0; i < s.iterations(); ++i) + { + keys.push_back(gen_key(i)); + values.push_back(gen_value(i)); + } + + s.start_timer(); + for (auto _ : s) + { + auto tx = kv_store.create_tx(); + tx.rw(map_name)->put(keys[_], values[_]); + if (tx.commit() != ccf::kv::CommitResult::SUCCESS) + { + throw std::logic_error("Transaction commit failed"); + } + clobber_memory(); + } + s.stop_timer(); +} + template static void deserialise(picobench::state& s) { @@ -306,6 +338,15 @@ PICOBENCH(serialise) .baseline(); PICOBENCH(serialise).iterations(tx_count).samples(sample_size); +PICOBENCH_SUITE("serialise small transactions"); +PICOBENCH(serialise_small) + .iterations({100, 1000}) + .samples(sample_size) + .baseline(); +PICOBENCH(serialise_small) + .iterations({100, 1000}) + .samples(sample_size); + PICOBENCH_SUITE("deserialise"); PICOBENCH(deserialise) .iterations(tx_count) diff --git a/src/kv/test/kv_serialisation.cpp b/src/kv/test/kv_serialisation.cpp index c51663731a17..bbc33c35d66d 100644 --- a/src/kv/test/kv_serialisation.cpp +++ b/src/kv/test/kv_serialisation.cpp @@ -605,6 +605,131 @@ TEST_CASE( } } +TEST_CASE( + "Encrypted entries retain the GCM layout and authenticate public bytes" * + doctest::test_suite("serialisation")) +{ + auto secrets = std::make_shared(); + secrets->init(); + secrets->set_secret(4, ccf::make_ledger_secret()); + auto encryptor = std::make_shared(secrets); + const ccf::TxID tx_id{2, 2}; + + const auto check = [&]( + const std::vector& public_domain, + const std::vector& private_domain, + ccf::kv::EntryType type, + bool historical_hint) { + ccf::kv::RawKvStoreSerialiser serialiser( + encryptor, tx_id, type, 0, {}, ccf::no_claims(), historical_hint); + const auto entry = + serialiser.serialise_domains(public_domain, private_domain); + + std::vector header; + std::vector cipher; + REQUIRE(encryptor->encrypt( + private_domain, + public_domain, + header, + cipher, + tx_id, + type, + historical_hint)); + + ccf::kv::SerialisedEntryHeader entry_header; + entry_header.set_size( + header.size() + sizeof(size_t) + public_domain.size() + cipher.size()); + std::vector expected(entry.size()); + auto* data = expected.data(); + auto remaining = expected.size(); + serialized::write(data, remaining, entry_header); + serialized::write(data, remaining, header.data(), header.size()); + serialized::write(data, remaining, public_domain.size()); + serialized::write( + data, remaining, public_domain.data(), public_domain.size()); + serialized::write(data, remaining, cipher.data(), cipher.size()); + REQUIRE(remaining == 0); + REQUIRE(entry == expected); + + std::vector decrypted; + ccf::kv::Term term = 0; + REQUIRE(encryptor->decrypt( + cipher, public_domain, header, decrypted, tx_id.seqno, term, true)); + REQUIRE(decrypted == private_domain); + if (type != ccf::kv::EntryType::Snapshot) + { + REQUIRE(term == tx_id.view); + } + + auto changed_public_domain = public_domain; + changed_public_domain.front() ^= 1; + REQUIRE_FALSE(encryptor->decrypt( + cipher, + changed_public_domain, + header, + decrypted, + tx_id.seqno, + term, + true)); + }; + + check({1, 2, 3}, {}, ccf::kv::EntryType::WriteSet, false); + check({1}, {4, 5, 6}, ccf::kv::EntryType::WriteSet, false); + check({1, 2, 3}, {4, 5, 6}, ccf::kv::EntryType::WriteSet, false); + check({1, 2, 3}, {4, 5, 6}, ccf::kv::EntryType::Snapshot, true); + + auto missing_secrets = std::make_shared(); + ccf::kv::RawKvStoreSerialiser failing( + std::make_shared(missing_secrets), + tx_id, + ccf::kv::EntryType::WriteSet, + 0); + REQUIRE_THROWS_AS( + failing.serialise_domains({1, 2, 3}, {4, 5, 6}), + ccf::kv::KvSerialiserException); +} + +TEST_CASE( + "Encrypted public and private transactions replay" * + doctest::test_suite("serialisation")) +{ + auto secrets = std::make_shared(); + secrets->init(); + auto encryptor = std::make_shared(secrets); + auto consensus = std::make_shared(); + ccf::kv::Store source; + source.set_encryptor(encryptor); + source.set_consensus(consensus); + ccf::kv::Store recovered; + recovered.set_encryptor(encryptor); + + MapTypes::StringString public_map("public:pub_map"); + MapTypes::StringString private_map("priv_map"); + for (size_t i = 1; i <= 3; ++i) + { + auto tx = source.create_tx(); + if (i != 2) + { + tx.rw(public_map)->put(std::to_string(i), "public"); + } + if (i != 1) + { + tx.rw(private_map)->put(std::to_string(i), "private"); + } + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + const auto entry = consensus->get_latest_data(); + REQUIRE(entry.has_value()); + REQUIRE( + recovered.deserialize(*entry)->apply() == ccf::kv::ApplyResult::PASS); + } + + auto tx = recovered.create_tx(); + REQUIRE(tx.ro(public_map)->get("1") == "public"); + REQUIRE(tx.ro(private_map)->get("2") == "private"); + REQUIRE(tx.ro(public_map)->get("3") == "public"); + REQUIRE(tx.ro(private_map)->get("3") == "private"); +} + TEST_CASE( "Serialise/deserialise removed keys" * doctest::test_suite("serialisation")) { diff --git a/src/kv/test/kv_snapshot.cpp b/src/kv/test/kv_snapshot.cpp index 698972a3337b..9060d55a1d44 100644 --- a/src/kv/test/kv_snapshot.cpp +++ b/src/kv/test/kv_snapshot.cpp @@ -4,6 +4,7 @@ #include "kv/kv_serialiser.h" #include "kv/null_encryptor.h" #include "kv/store.h" +#include "node/encryptor.h" #include #include @@ -74,6 +75,41 @@ TEST_CASE( REQUIRE_EQ(new_store.current_version(), snapshot_version); } +TEST_CASE( + "Encrypted snapshot replays both domains" * doctest::test_suite("snapshot")) +{ + auto secrets = std::make_shared(); + secrets->init(); + auto encryptor = std::make_shared(secrets); + ccf::kv::Store source; + source.set_encryptor(encryptor); + + MapTypes::StringString private_map("private_map"); + auto tx = source.create_tx(); + tx.rw(string_map)->put("public", "value"); + tx.rw(private_map)->put("private", "secret"); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + const auto snapshot_version = tx.commit_version(); + secrets->set_secret(snapshot_version + 1, ccf::make_ledger_secret()); + + std::unique_ptr snapshot; + { + ccf::kv::ScopedStoreMapsLock maps_lock(&source); + snapshot = source.snapshot_unsafe_maps(snapshot_version); + } + auto entry = source.serialise_snapshot(std::move(snapshot)); + + ccf::kv::Store recovered; + recovered.set_encryptor(encryptor); + ccf::kv::ConsensusHookPtrs hooks; + REQUIRE( + recovered.deserialise_snapshot(entry.data(), entry.size(), hooks) == + ccf::kv::ApplyResult::PASS); + auto recovered_tx = recovered.create_tx(); + REQUIRE(recovered_tx.ro(string_map)->get("public") == "value"); + REQUIRE(recovered_tx.ro(private_map)->get("private") == "secret"); +} + TEST_CASE("Simple snapshot" * doctest::test_suite("snapshot")) { ccf::kv::Store store; diff --git a/src/node/ledger_secret.h b/src/node/ledger_secret.h index 34aec7c26b44..1ba532aae762 100644 --- a/src/node/ledger_secret.h +++ b/src/node/ledger_secret.h @@ -56,6 +56,17 @@ namespace ccf context->encrypt(iv, plain, aad, cipher, tag); } + void encrypt( + std::span iv, + std::span plain, + std::span aad, + std::span cipher, + uint8_t tag[ccf::crypto::GCM_SIZE_TAG]) + { + std::lock_guard guard(context_lock); + context->encrypt(iv, plain, aad, cipher, tag); + } + bool decrypt( std::span iv, const uint8_t tag[ccf::crypto::GCM_SIZE_TAG], From aef5de969cbed2f87ee55a6597ad86730561f794 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:45:19 +0000 Subject: [PATCH 3/5] Test partial ledger encryption failure cannot replicate Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- src/kv/test/kv_serialisation.cpp | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/kv/test/kv_serialisation.cpp b/src/kv/test/kv_serialisation.cpp index bbc33c35d66d..2b931ad6dfd7 100644 --- a/src/kv/test/kv_serialisation.cpp +++ b/src/kv/test/kv_serialisation.cpp @@ -10,6 +10,7 @@ #include #undef FAIL +#include #include #include #include @@ -730,6 +731,43 @@ TEST_CASE( REQUIRE(tx.ro(private_map)->get("3") == "private"); } +TEST_CASE( + "Partial encryption failure cannot replicate an entry" * + doctest::test_suite("serialisation")) +{ + class FailingEncryptor : public ccf::NodeEncryptor + { + public: + using ccf::NodeEncryptor::NodeEncryptor; + + bool encrypt( + std::span, + std::span, + std::span header, + std::span cipher, + const ccf::TxID&, + ccf::kv::EntryType, + bool) override + { + std::fill(header.begin(), header.end(), 0xAA); + std::fill(cipher.begin(), cipher.end(), 0xBB); + return false; + } + }; + + auto secrets = std::make_shared(); + secrets->init(); + auto consensus = std::make_shared(); + ccf::kv::Store store; + store.set_encryptor(std::make_shared(secrets)); + store.set_consensus(consensus); + + auto tx = store.create_tx(); + tx.rw("private_map")->put("key", "secret"); + REQUIRE_THROWS_AS(tx.commit(), ccf::kv::KvSerialiserException); + REQUIRE_FALSE(consensus->get_latest_data().has_value()); +} + TEST_CASE( "Serialise/deserialise removed keys" * doctest::test_suite("serialisation")) { From bd8006ee6768fbdcc226d34ebdf789445ec3af44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:53:32 +0000 Subject: [PATCH 4/5] Preserve vector-only encryptor implementations Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- include/ccf/crypto/symmetric_key.h | 4 +-- src/crypto/symmetric_key.cpp | 48 ++++++++++++++++++++++++++++++ src/crypto/test/crypto.cpp | 14 +++++++++ src/kv/kv_types.h | 43 +++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/include/ccf/crypto/symmetric_key.h b/include/ccf/crypto/symmetric_key.h index ff3f600db925..fd5655d7f0bb 100644 --- a/include/ccf/crypto/symmetric_key.h +++ b/include/ccf/crypto/symmetric_key.h @@ -100,7 +100,7 @@ namespace ccf::crypto std::span plain, std::span aad, std::span cipher, - uint8_t tag[GCM_SIZE_TAG]) = 0; + uint8_t tag[GCM_SIZE_TAG]); // Replaces plain on success and clears it if authentication fails. virtual bool decrypt( @@ -135,7 +135,7 @@ namespace ccf::crypto std::span plain, std::span aad, std::span cipher, - uint8_t tag[GCM_SIZE_TAG]) const = 0; + uint8_t tag[GCM_SIZE_TAG]) const; // AES-GCM decryption // Replaces plain on success and clears it if authentication fails. diff --git a/src/crypto/symmetric_key.cpp b/src/crypto/symmetric_key.cpp index ca75acdb9ef8..08076bfe0aa0 100644 --- a/src/crypto/symmetric_key.cpp +++ b/src/crypto/symmetric_key.cpp @@ -7,6 +7,8 @@ #include "ccf/crypto/symmetric_key.h" #include "ds/serialized.h" +#include + #define FMT_HEADER_ONLY #include @@ -76,6 +78,52 @@ namespace ccf::crypto iv = serialized::read(data, size, iv.size()); } + void KeyAesGcm::Context::encrypt( + std::span iv, + std::span plain, + std::span aad, + std::span cipher, + uint8_t tag[GCM_SIZE_TAG]) + { + if (cipher.size() != plain.size()) + { + throw std::logic_error("Incorrect AES-GCM output size"); + } + std::vector output; + encrypt(iv, plain, aad, output, tag); + if (output.size() != cipher.size()) + { + throw std::logic_error("Unexpected AES-GCM ciphertext size"); + } + if (!output.empty()) + { + std::copy(output.begin(), output.end(), cipher.begin()); + } + } + + void KeyAesGcm::encrypt( + std::span iv, + std::span plain, + std::span aad, + std::span cipher, + uint8_t tag[GCM_SIZE_TAG]) const + { + if (cipher.size() != plain.size()) + { + throw std::logic_error("Incorrect AES-GCM output size"); + } + std::vector output; + encrypt(iv, plain, aad, output, tag); + if (output.size() != cipher.size()) + { + throw std::logic_error("Unexpected AES-GCM ciphertext size"); + } + if (!output.empty()) + { + std::copy(output.begin(), output.end(), cipher.begin()); + } + } + /// GcmCipher implementation GcmCipher::GcmCipher() = default; diff --git a/src/crypto/test/crypto.cpp b/src/crypto/test/crypto.cpp index 278b90bba5c2..23069cb2e27f 100644 --- a/src/crypto/test/crypto.cpp +++ b/src/crypto/test/crypto.cpp @@ -1292,6 +1292,20 @@ TEST_CASE("AES-GCM caller-owned ciphertext") REQUIRE(preallocated.data() == original_data); REQUIRE(preallocated == expected); + auto in_place_plain = plain; + key->encrypt( + iv, + std::span(in_place_plain), + aad, + std::span(in_place_plain), + tag); + REQUIRE(in_place_plain == expected); + + auto vector_in_place = plain; + key->encrypt( + iv, std::span(vector_in_place), aad, vector_in_place, tag); + REQUIRE(vector_in_place == expected); + REQUIRE_THROWS_AS( key->encrypt(iv, plain, aad, output.first(output.size() - 1), tag), std::logic_error); diff --git a/src/kv/kv_types.h b/src/kv/kv_types.h index 5ad6a4b9f57c..00a9cc6e81f8 100644 --- a/src/kv/kv_types.h +++ b/src/kv/kv_types.h @@ -22,6 +22,7 @@ #include "kv/ledger_chunker_interface.h" #include "serialised_entry_format.h" +#include #include #include #include @@ -565,7 +566,47 @@ namespace ccf::kv std::span cipher, const ccf::TxID& tx_id, EntryType entry_type = EntryType::WriteSet, - bool historical_hint = false) = 0; + bool historical_hint = false) + { + std::vector plain_bytes; + std::vector aad_bytes; + if (!plain.empty()) + { + plain_bytes.assign(plain.begin(), plain.end()); + } + if (!additional_data.empty()) + { + aad_bytes.assign(additional_data.begin(), additional_data.end()); + } + std::vector header; + std::vector output; + if (!encrypt( + plain_bytes, + aad_bytes, + header, + output, + tx_id, + entry_type, + historical_hint)) + { + return false; + } + if ( + header.size() != serialised_header.size() || + output.size() != cipher.size()) + { + throw std::logic_error("Incorrect transaction encryption output size"); + } + if (!header.empty()) + { + std::copy(header.begin(), header.end(), serialised_header.begin()); + } + if (!output.empty()) + { + std::copy(output.begin(), output.end(), cipher.begin()); + } + return true; + } virtual bool decrypt( const std::vector& cipher, const std::vector& additional_data, From 6af78b6e45be43e747c33fb95c01e2af9fa9d89c Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Thu, 8 Oct 2026 18:42:46 +0100 Subject: [PATCH 5/5] Mark const GCM header serialization as nodiscard Const qualification enables clang-tidy's modernize-use-nodiscard check. Mark the vector serialization result nodiscard, matching the existing read-only header accessors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- include/ccf/crypto/symmetric_key.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/ccf/crypto/symmetric_key.h b/include/ccf/crypto/symmetric_key.h index c7db82c68566..3c6054c227ee 100644 --- a/include/ccf/crypto/symmetric_key.h +++ b/include/ccf/crypto/symmetric_key.h @@ -28,7 +28,7 @@ namespace ccf::crypto [[nodiscard]] std::span get_iv() const; [[nodiscard]] size_t serialised_size() const; - std::vector serialise() const; + [[nodiscard]] std::vector serialise() const; void serialise(std::span output) const; void deserialise(const std::vector& ser);